EENE Dashboard upload to Gitea

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-17 16:59:34 +09:00
parent cf72281c6d
commit b3f2da203b
138 changed files with 13013 additions and 1929 deletions

View File

@@ -34,7 +34,98 @@ function relBox(el: Element, parent: Element): Box {
}
function snap(n: number) {
return Math.round(n * 2) / 2;
return Math.round(n * 100) / 100;
}
/** 참고 레이아웃: 카드 변 부메랑(둔각 다이아) 끝점 */
function appendBoomerangMarker(
group: SVGGElement,
at: Point,
side: 'left' | 'right',
fill: string,
) {
const dir = side === 'right' ? 1 : -1;
const tipLen = 7;
const wing = 4.5;
const pts = [
[at.x + dir * tipLen, at.y],
[at.x + dir * 1.5, at.y - wing],
[at.x - dir * 2.5, at.y],
[at.x + dir * 1.5, at.y + wing],
]
.map(([x, y]) => `${snap(x)},${snap(y)}`)
.join(' ');
const poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', fill);
group.appendChild(poly);
}
/** 참고 레이아웃: 허브 박스 연결부 V형 부메랑 (박스 안쪽 방향) */
function appendHubBoomerangMarker(
group: SVGGElement,
at: Point,
dir: 'up' | 'down',
fill: string,
) {
const sign = dir === 'down' ? 1 : -1;
const tipLen = 5.5;
const wing = 5;
const base = 3;
const pts = [
[at.x, at.y + sign * tipLen],
[at.x - wing, at.y - sign * base],
[at.x + wing, at.y - sign * base],
]
.map(([x, y]) => `${snap(x)},${snap(y)}`)
.join(' ');
const poly = document.createElementNS(SVG_NS, 'polygon');
poly.setAttribute('points', pts);
poly.setAttribute('fill', fill);
group.appendChild(poly);
}
function pathLength(a: Point, b: Point) {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/** snap·배율 100%에서도 0px 세그먼트가 되지 않도록 보정 */
function normalizePathPoints(points: Point[], minSeg = 1.25): Point[] {
if (points.length < 2) return points;
const out: Point[] = [{ ...points[0] }];
for (let i = 1; i < points.length; i++) {
const prev = out[out.length - 1];
const cur = { ...points[i] };
const len = pathLength(prev, cur);
if (len < minSeg) {
const ref =
i + 1 < points.length
? points[i + 1]
: i > 0
? points[i - 1]
: cur;
let dx = cur.x - prev.x;
let dy = cur.y - prev.y;
if (len < 0.01) {
dx = ref.x - prev.x;
dy = ref.y - prev.y;
}
const d = Math.hypot(dx, dy) || 1;
out.push({ x: prev.x + (dx / d) * minSeg, y: prev.y + (dy / d) * minSeg });
} else {
out.push(cur);
}
}
return out;
}
/** reference 상·하: 허브 테두리 ↔ 다이아 꼭짓점 (wrap clamp·stub 보정 없음) */
function referenceVerticalLine(cx: number, yHub: number, yDiamond: number): Point[] {
if (Math.abs(yDiamond - yHub) < 0.5) return [];
return [
{ x: cx, y: yHub },
{ x: cx, y: yDiamond },
];
}
function pointsToPath(points: Point[]) {
@@ -59,12 +150,42 @@ function diamondEdgeMidpoint(cx: number, cy: number, size: number, edge: string)
return { x: cx + d, y: cy + d };
}
/** 다이아몬드 변 중점에서 바깥(테두리)쪽으로 살짝 밀어 연결 */
function diamondBorderEdgeMidpoint(
cx: number,
cy: number,
size: number,
edge: string,
outward = 2,
): Point {
const mid = diamondEdgeMidpoint(cx, cy, size, edge);
const dx = mid.x - cx;
const dy = mid.y - cy;
const len = Math.hypot(dx, dy) || 1;
return { x: mid.x + (dx / len) * outward, y: mid.y + (dy / len) * outward };
}
function cardEdgeAnchor(cardEl: Element, layout: Element, side: 'left' | 'right', y: number): Point {
const cardBox = relBox(cardEl, layout);
const clampedY = Math.max(cardBox.top + 10, Math.min(cardBox.bottom - 10, y));
return { x: side === 'right' ? cardBox.right : cardBox.left, y: clampedY };
}
/** 참고 레이아웃: 카드 안쪽 변 — 위 행은 중앙보다 살짝 아래, 아래 행은 살짝 위 */
function cardReferenceInnerEdgeAnchor(
cardEl: Element,
layout: Element,
side: 'left' | 'right',
vert: 'top' | 'bottom',
): Point {
const cardBox = relBox(cardEl, layout);
const offset = Math.min(36, Math.max(22, cardBox.height * 0.09));
const yShift = vert === 'top' ? offset : -offset;
const pad = 14;
const y = Math.max(cardBox.top + pad, Math.min(cardBox.bottom - pad, cardBox.cy + yShift));
return { x: side === 'right' ? cardBox.right : cardBox.left, y };
}
const FACE_LINKS = [
{ edge: 'top-left', card: '.dept-card--hrm', side: 'right' as const, vert: 'top' as const, knee: 'left' as const },
{ edge: 'top-right', card: '.dept-card--hrd', side: 'left' as const, vert: 'top' as const, knee: 'right' as const },
@@ -89,10 +210,60 @@ function buildBentPath(
return [cardAnchor, { x: x1, y: cardAnchor.y }, { x: approachX, y: edgeMid.y }, edgeMid];
}
/** 참고 레이아웃: 카드 → 짧은 수평 → ~120° 둔각 꺾임 → 대각 → 다이아몬드 */
function buildReferenceElbowPath(
cardAnchor: Point,
edgeMid: Point,
side: 'left' | 'right',
vert: 'top' | 'bottom',
): Point[] {
const towardCenter = side === 'right' ? 1 : -1;
const turnSign = vert === 'top' ? -1 : 1;
const turn = Math.PI / 3; // 60° 꺾임 → 내각 120°
const uOutX = towardCenter * Math.cos(turn);
const uOutY = turnSign * Math.sin(turn);
const minStub = 18;
const maxStub = 64;
let knee: Point;
if (Math.abs(uOutY) > 1e-4) {
const t = (cardAnchor.y - edgeMid.y) / uOutY;
knee = { x: edgeMid.x - t * uOutX, y: cardAnchor.y };
} else {
knee = { x: cardAnchor.x + towardCenter * minStub, y: cardAnchor.y };
}
const stub = (knee.x - cardAnchor.x) * towardCenter;
if (!Number.isFinite(stub) || stub < minStub || stub > maxStub) {
knee = { x: cardAnchor.x + towardCenter * Math.max(minStub, Math.min(maxStub, stub || minStub)), y: cardAnchor.y };
}
return [cardAnchor, knee, edgeMid];
}
function anchorYForSymmetricFace(faceY: number, runX: number, vert: 'top' | 'bottom') {
return vert === 'top' ? faceY + runX : faceY - runX;
}
const REF_HUB_LINE_COLOR = '#c5d2de';
const REF_HUB_DOT_COLOR = '#b8c6d4';
/** 둥근 꼭짓점 — 선 끝은 테두리 밖(다이아 레이어에 가려짐) */
const REF_DIAMOND_LINE_OVERLAP = 0;
/** reference 상·하: 실제 렌더 박스 + border-radius 보정 (기하 topV는 둥근 꼭짓점보다 바깥) */
function referenceDiamondVerticalY(
diamondEl: HTMLElement,
diamondBox: Box,
end: 'top' | 'bottom',
): number {
const r = parseFloat(getComputedStyle(diamondEl).borderRadius) || 16;
const tipOffset = Math.min(r * 0.7, diamondBox.height * 0.07);
return end === 'top'
? diamondBox.top + tipOffset + REF_DIAMOND_LINE_OVERLAP
: diamondBox.bottom - tipOffset - REF_DIAMOND_LINE_OVERLAP;
}
function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null) {
if (!hubColumn || !diamond || window.innerWidth <= 1200) return;
const wrap = diamond.parentElement;
@@ -109,9 +280,13 @@ function fitDiamond(hubColumn: HTMLElement | null, diamond: HTMLElement | null)
diamond.style.height = `${size}px`;
}
export function useBoardConnectors(enabled = true) {
export type ConnectorStyle = 'default' | 'reference';
export function useBoardConnectors(enabled = true, style: ConnectorStyle = 'default') {
const lineGroupRef = useRef<SVGGElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const dotGroupRef = useRef<SVGGElement>(null);
const dotSvgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!enabled) return;
@@ -124,29 +299,73 @@ export function useBoardConnectors(enabled = true) {
const hubColumn = document.getElementById('hub-column');
const lineGroup = lineGroupRef.current;
const svg = svgRef.current;
const dotGroup = dotGroupRef.current;
const dotSvg = dotSvgRef.current;
if (!layout || !diamond || !lineGroup || !svg) return;
fitDiamond(hubColumn, diamond);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!layout.isConnected || !diamond.isConnected) return;
drawConnectorsNow(
layout,
diamond,
hubColumn,
lineGroup,
svg,
dotGroup,
dotSvg,
);
});
});
};
const drawConnectorsNow = (
layout: Element,
diamond: HTMLElement,
hubColumn: HTMLElement | null,
lineGroup: SVGGElement,
svg: SVGSVGElement,
dotGroup: SVGGElement | null,
dotSvg: SVGSVGElement | null,
) => {
if (window.innerWidth <= 1200) {
lineGroup.innerHTML = '';
dotGroup && (dotGroup.innerHTML = '');
svg.removeAttribute('viewBox');
dotSvg?.removeAttribute('viewBox');
return;
}
const layoutBox = layout.getBoundingClientRect();
const diamondBox = relBox(diamond, layout);
const diamondSize = diamond.offsetWidth;
const { cx, cy } = diamondBox;
const diamondCx = diamondBox.cx;
const diamondCy = diamondBox.cy;
svg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
lineGroup.innerHTML = '';
if (dotGroup) dotGroup.innerHTML = '';
if (dotSvg) dotSvg.setAttribute('viewBox', `0 0 ${layoutBox.width} ${layoutBox.height}`);
const topV = diamondVertex(cx, cy, diamondSize, 'top');
const bottomV = diamondVertex(cx, cy, diamondSize, 'bottom');
const topV = diamondVertex(diamondCx, diamondCy, diamondSize, 'top');
const bottomV = diamondVertex(diamondCx, diamondCy, diamondSize, 'bottom');
const msgBox = document.querySelector('.hub-postit-sheet--front');
const focusBox = document.querySelector('.hub-schedule-planner');
const hubColumnEl = layout.querySelector('.hub-column, #hub-column');
const hubBoxEls = hubColumnEl
? Array.from(hubColumnEl.querySelectorAll(':scope > .hub-box'))
: [];
const topHubBox =
hubBoxEls[0] ??
(style === 'reference'
? layout.querySelector('.hub-box--message')
: layout.querySelector('.hub-postit-sheet--front'));
const bottomHubBox =
(hubBoxEls.length > 1 ? hubBoxEls[hubBoxEls.length - 1] : hubBoxEls[0]) ??
(style === 'reference'
? layout.querySelector('.hub-box--focus')
: layout.querySelector('.hub-schedule-planner'));
const hubBox = hubColumn ? relBox(hubColumn, layout) : null;
const hrmBox = document.querySelector('.dept-card--hrm');
@@ -166,39 +385,138 @@ export function useBoardConnectors(enabled = true) {
const d = diamondSize / (2 * Math.SQRT2);
const approachGap = 32;
const leftApproachX = cx - d - approachGap;
const rightApproachX = cx + d + approachGap;
const leftApproachX = diamondCx - d - approachGap;
const rightApproachX = diamondCx + d + approachGap;
const leftRunX = Math.max(12, leftApproachX - kneeXs.left);
const rightRunX = Math.max(12, kneeXs.right - rightApproachX);
const appendPath = (points: Point[]) => {
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', pointsToPath(points));
path.setAttribute('stroke', '#b0bcc8');
path.setAttribute('stroke-width', '2.5');
path.setAttribute('opacity', '0.85');
path.setAttribute('fill', 'none');
lineGroup.appendChild(path);
const REF_CARD_LINE_COLORS: Record<string, string> = {
'.dept-card--hrm': '#4a90d9',
'.dept-card--hrd': '#37a184',
'.dept-card--ex': '#9168b8',
'.dept-card--ga': '#2563ab',
};
const appendPath = (
points: Point[],
opts?: {
cardSelector?: string;
dotAt?: Point;
vertical?: boolean;
markerSide?: 'left' | 'right';
markerDir?: 'up' | 'down';
},
) => {
if (points.length < 2) return;
const cardSelector = opts?.cardSelector;
const stroke =
opts?.vertical
? REF_HUB_LINE_COLOR
: style === 'reference' && cardSelector
? REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_LINE_COLOR
: style === 'reference'
? REF_HUB_LINE_COLOR
: '#b0bcc8';
if (opts?.vertical && points.length === 2) {
const [a, b] = points;
const line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('x1', String(snap(a.x)));
line.setAttribute('y1', String(snap(a.y)));
line.setAttribute('x2', String(snap(b.x)));
line.setAttribute('y2', String(snap(b.y)));
line.setAttribute('stroke', stroke);
line.setAttribute('stroke-width', '2');
line.setAttribute('fill', 'none');
line.setAttribute('stroke-linecap', 'butt');
lineGroup.appendChild(line);
} else {
const path = document.createElementNS(SVG_NS, 'path');
const normalized = normalizePathPoints(points);
if (normalized.length < 2) return;
path.setAttribute('d', pointsToPath(normalized));
path.setAttribute('stroke', stroke);
path.setAttribute('stroke-width', style === 'reference' ? '2' : '2.5');
path.setAttribute('opacity', style === 'reference' ? '1' : '0.85');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', style === 'reference' ? 'butt' : 'round');
path.setAttribute('stroke-linejoin', style === 'reference' ? 'miter' : 'round');
lineGroup.appendChild(path);
}
if (style === 'reference' && opts?.dotAt && dotGroup) {
const fill =
cardSelector ? (REF_CARD_LINE_COLORS[cardSelector] ?? REF_HUB_DOT_COLOR) : REF_HUB_DOT_COLOR;
if (cardSelector && opts.markerSide) {
appendBoomerangMarker(dotGroup, opts.dotAt, opts.markerSide, fill);
} else if (opts.markerDir) {
appendHubBoomerangMarker(dotGroup, opts.dotAt, opts.markerDir, fill);
} else {
const dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('cx', String(snap(opts.dotAt.x)));
dot.setAttribute('cy', String(snap(opts.dotAt.y)));
dot.setAttribute('r', '4');
dot.setAttribute('fill', fill);
dotGroup.appendChild(dot);
}
}
};
FACE_LINKS.forEach((link) => {
const cardEl = document.querySelector(link.card);
if (!cardEl) return;
const edgeMid = diamondEdgeMidpoint(cx, cy, diamondSize, link.edge);
const edgeMid =
style === 'reference'
? diamondBorderEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge)
: diamondEdgeMidpoint(diamondCx, diamondCy, diamondSize, link.edge);
const runX = link.knee === 'left' ? leftRunX : rightRunX;
const approachX = link.knee === 'left' ? leftApproachX : rightApproachX;
const anchorY = anchorYForSymmetricFace(edgeMid.y, runX, link.vert);
const cardAnchor = cardEdgeAnchor(cardEl, layout, link.side, anchorY);
appendPath(buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX));
const cardAnchor =
style === 'reference'
? cardReferenceInnerEdgeAnchor(cardEl, layout, link.side, link.vert)
: cardEdgeAnchor(
cardEl,
layout,
link.side,
anchorYForSymmetricFace(edgeMid.y, runX, link.vert),
);
const pathPoints =
style === 'reference'
? buildReferenceElbowPath(cardAnchor, edgeMid, link.side, link.vert)
: buildBentPath(cardAnchor, edgeMid, link.side, kneeXs[link.knee], approachX);
if (style === 'reference') {
appendPath(pathPoints, {
cardSelector: link.card,
dotAt: cardAnchor,
markerSide: link.side,
});
} else {
appendPath(pathPoints);
}
});
if (msgBox) {
const msg = relBox(msgBox, layout);
appendPath([topV, { x: cx, y: msg.bottom }]);
if (style === 'reference' && topHubBox) {
const topHub = relBox(topHubBox, layout);
const hubAnchor = { x: diamondCx, y: topHub.bottom };
const diamondTopY = referenceDiamondVerticalY(diamond, diamondBox, 'top');
const topPoints = referenceVerticalLine(diamondCx, hubAnchor.y, diamondTopY);
appendPath(topPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'down' });
} else if (topHubBox) {
const topHub = relBox(topHubBox, layout);
appendPath([topV, { x: diamondCx, y: topHub.bottom }]);
}
if (focusBox) {
const focus = relBox(focusBox, layout);
appendPath([bottomV, { x: cx, y: focus.top }]);
if (style === 'reference' && bottomHubBox) {
const bottomHub = relBox(bottomHubBox, layout);
const hubAnchor = { x: diamondCx, y: bottomHub.top };
const diamondBottomY = referenceDiamondVerticalY(diamond, diamondBox, 'bottom');
const bottomPoints = referenceVerticalLine(diamondCx, diamondBottomY, hubAnchor.y);
appendPath(bottomPoints, { vertical: true, dotAt: hubAnchor, markerDir: 'up' });
} else if (bottomHubBox) {
const bottomHub = relBox(bottomHubBox, layout);
appendPath([bottomV, { x: diamondCx, y: bottomHub.top }]);
}
};
@@ -217,10 +535,12 @@ export function useBoardConnectors(enabled = true) {
}
const layoutEl = document.querySelector('.board-layout');
const hubColEl = document.getElementById('hub-column');
let ro: ResizeObserver | undefined;
if (layoutEl && typeof ResizeObserver !== 'undefined') {
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleDraw);
ro.observe(layoutEl);
if (layoutEl) ro.observe(layoutEl);
if (hubColEl) ro.observe(hubColEl);
}
return () => {
@@ -228,7 +548,7 @@ export function useBoardConnectors(enabled = true) {
clearTimeout(resizeTimer);
ro?.disconnect();
};
}, [enabled]);
}, [enabled, style]);
return { svgRef, lineGroupRef };
return { svgRef, lineGroupRef, dotSvgRef, dotGroupRef };
}

View File

@@ -0,0 +1,42 @@
import { useCallback, useMemo, useState } from 'react';
import {
BOARD_REF_DATE_KEY,
dateToQuarter,
parseIsoDate,
startOfDay,
toIsoDate,
} from '../lib/boardCalendar';
export function useBoardReferenceDate() {
const [referenceDate, setReferenceDateState] = useState<Date>(() => {
try {
const stored = localStorage.getItem(BOARD_REF_DATE_KEY);
if (stored) {
const parsed = parseIsoDate(stored);
if (parsed) return startOfDay(parsed);
}
} catch {
/* ignore */
}
return startOfDay(new Date());
});
const setReferenceDate = useCallback((d: Date) => {
const normalized = startOfDay(d);
setReferenceDateState(normalized);
try {
localStorage.setItem(BOARD_REF_DATE_KEY, toIsoDate(normalized));
} catch {
/* ignore */
}
}, []);
const quarter = useMemo(() => dateToQuarter(referenceDate), [referenceDate]);
const resetToToday = useCallback(() => {
setReferenceDate(startOfDay(new Date()));
}, [setReferenceDate]);
return { referenceDate, setReferenceDate, quarter, resetToToday };
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { fileViewUrl } from '../lib/apiClient';
export function useFileArrayBuffer(fileId: string | null) {
const [buffer, setBuffer] = useState<ArrayBuffer | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!fileId) {
setBuffer(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setBuffer(null);
fetch(fileViewUrl(fileId))
.then((res) => {
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
return res.arrayBuffer();
})
.then((data) => {
if (!cancelled) {
setBuffer(data);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [fileId]);
return { buffer, loading, error };
}
export function useFileBlobUrl(fileId: string | null, mime: string) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!fileId) {
setBlobUrl(null);
setError(null);
setLoading(false);
return;
}
let cancelled = false;
let objectUrl: string | null = null;
setLoading(true);
setError(null);
setBlobUrl(null);
fetch(fileViewUrl(fileId))
.then((res) => {
if (!res.ok) throw new Error('파일을 불러올 수 없습니다.');
return res.arrayBuffer();
})
.then((data) => {
if (cancelled) return;
objectUrl = URL.createObjectURL(new Blob([data], { type: mime }));
setBlobUrl(objectUrl);
setLoading(false);
})
.catch((e) => {
if (!cancelled) {
setError(e instanceof Error ? e.message : '미리보기 실패');
setLoading(false);
}
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileId, mime]);
return { blobUrl, loading, error };
}

View File

@@ -0,0 +1,58 @@
import { useQueries } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import {
ROUTINE_CATEGORIES,
pickRoutineCategoryTask,
type RoutineCategory,
} from '../lib/routineCategories';
import type { Milestone, Task } from '../types';
type TaskWithMilestones = Task & { milestones?: Milestone[] };
export interface RoutineFocusMilestone {
id: string;
title: string;
}
export interface RoutineCategoryFocus {
category: RoutineCategory;
task: Task | null;
milestones: RoutineFocusMilestone[];
isLoading: boolean;
}
export function useRoutineCategoryMilestones(routineTasks: Task[]): RoutineCategoryFocus[] {
const shells = ROUTINE_CATEGORIES.map((category) => ({
category,
task: pickRoutineCategoryTask(routineTasks, category),
}));
const queries = useQueries({
queries: shells.map(({ task }) => ({
queryKey: ['task', task?.id, 'hub-routine-focus'],
queryFn: async () => {
const { data } = await apiClient.get<TaskWithMilestones>(`/tasks/${task!.id}`);
return data;
},
enabled: !!task?.id,
staleTime: 30_000,
})),
});
return shells.map(({ category, task }, index) => {
const data = queries[index].data;
const milestones = (data?.milestones ?? [])
.slice()
.sort((a, b) => a.order - b.order)
.map((m) => ({ id: m.id, title: m.title.trim() }))
.filter((m) => m.title);
return {
category,
task,
milestones,
isLoading: queries[index].isLoading && !!task?.id,
};
});
}