/* ============================================================================= * B01_Dashboard_UI_Home_Many.ts * 홈 4 넘음 부품(PLAN 547-2) — 팀원별 · 단계 분포(4~8) · 요약 숫자 · 막대 · 원 그래프(9 넘음) * 그래프 = 인라인 SVG(새 라이브러리 없음) · 색 = 셸 · 테마 변수(CSS 클래스) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { HomeData, ProjectSummary } from "./B01_Dashboard_Api_Home"; import { kindClass, kindLabel, type Go } from "./B01_Dashboard_UI_Home_Cards"; import { countBy, donutArcs, isStale, stageBuckets, topWithRest, } from "./logic/B01_Dashboard_Home_Layout"; const SVG = "http://www.w3.org/2000/svg"; function svgNode(tag: string, attrs: Record): SVGElement { const node = document.createElementNS(SVG, tag); for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); return node; } export function pane(titleText: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement { const node = el("div", { className: "b01-home__pane", children: [el("h3", { className: "b01-home__pane-title", text: titleText })], }); node.append(...children); return node; } const ownerOf = (card: ProjectSummary): string => card.owner_names[0] ?? "담당 없음"; /** 팀원별 진행 중 개수 */ export function memberPane(cards: ProjectSummary[]): HTMLElement { const rows = countBy(cards, ownerOf).map(({ label, count }) => el("div", { className: "b01-home__member", children: [ el("span", { className: "b01-home__avatar", text: label.slice(0, 1) }), el("span", { text: label }), el("span", { className: "b01-home__muted b01-home__push", text: `${count} 개` }), ], }), ); return pane("팀원별", ...rows); } /** 단계 분포 — 시작 전 · 설계 중 · 확정 대기 · 쌓은 막대 */ export function stagePane(cards: ProjectSummary[]): HTMLElement { const b = stageBuckets(cards); const total = Math.max(1, cards.length); const parts: [number, string, string][] = [ [b.start, "start", "시작 전"], [b.working, "working", "설계 중"], [b.last, "last", "확정 대기"], ]; const bar = el("div", { className: "b01-home__stack" }); for (const [n, cls, label] of parts) { if (!n) continue; const seg = el("i", { className: `b01-home__stack--${cls}`, attrs: { title: `${label} ${n}` }, }); seg.style.width = `${(n / total) * 100}%`; bar.append(seg); } const text = parts.map(([n, , label]) => `${label} ${n}`).join(" · "); const stale = cards.filter(isStale).length; const legend = el("div", { className: "b01-home__muted", text }); const children: HTMLElement[] = [bar, legend]; if (stale) children.push( el("div", { className: "b01-home__stale", text: `14 일 넘게 그대로 ${stale} 개` }), ); return pane("단계 분포", ...children); } function kpi(label: string, value: string, note?: string, warn = false): HTMLElement { return el("div", { className: "b01-home__kpi", children: [ el("div", { className: "b01-home__muted", text: label }), el("b", { className: warn ? "b01-home__alert" : "", text: value }), ...(note ? [el("span", { className: "b01-home__muted", text: note })] : []), ], }); } /** 가로 막대 — 회사별(시스템 관리자) · 담당별(그 밖) · 앞 4 + 그 밖 */ function barChart(rows: { label: string; count: number; rest?: number }[]): SVGElement { const shown = topWithRest(rows, 5); const max = Math.max(1, ...shown.map((r) => r.count)); const rowH = 22; const svg = svgNode("svg", { class: "b01-home__bars", viewBox: `0 0 320 ${shown.length * rowH}`, width: "100%", preserveAspectRatio: "xMinYMin meet", role: "img", }); shown.forEach((r, i) => { const y = i * rowH; const label = r.rest ? `그 밖 ${r.rest} 곳` : r.label; const width = (r.count / max) * 180; const text = svgNode("text", { x: 0, y: y + 14 }); text.textContent = label.length > 9 ? `${label.slice(0, 9)}…` : label; const count = svgNode("text", { x: 96 + width, y: y + 14 }); count.textContent = String(r.count); svg.append( text, svgNode("rect", { x: 90, y: y + 4, width, height: 12, rx: 3, class: r.rest ? "rest" : "" }), count, ); }); return svg; } /** 원 그래프 — 종류별 · 가운데 = 합 */ function kindDonut(cards: ProjectSummary[]): HTMLElement { const kinds = countBy(cards, (c) => c.kind); const arcs = donutArcs(kinds.map((k) => k.count)); const svg = svgNode("svg", { class: "b01-home__donut", viewBox: "0 0 42 42", width: 96, height: 96, }); kinds.forEach((k, i) => { svg.append( svgNode("circle", { cx: 21, cy: 21, r: 15.9155, class: kindClass(k.label), "stroke-dasharray": arcs[i].dash, "stroke-dashoffset": arcs[i].offset, }), ); }); const sum = svgNode("text", { x: 21, y: 24, "text-anchor": "middle" }); sum.textContent = String(cards.length); svg.append(sum); const legend = el("div", { className: "b01-home__legend", children: kinds.map((k) => el("span", { children: [ el("i", { className: `b01-home__swatch ${kindClass(k.label)}` }), `${kindLabel(k.label)} ${k.count}`, ], }), ), }); return el("div", { className: "b01-home__donut-row", children: [svg as unknown as HTMLElement, legend], }); } /** 9 넘음 — 요약 숫자 · 회사별(담당별) 막대 · 종류 원 · 단계 분포 · [모든 프로젝트 ›] */ export function manyView(data: HomeData, go: Go): HTMLElement { const cards = data.projects; const sys = data.system; const pending = data.todos.join_requests ?? 0; const kpis = el("div", { className: "b01-home__kpis" }); if (sys) kpis.append(kpi("회사", String(sys.companies)), kpi("사용자", String(sys.users))); kpis.append(kpi("진행 중 프로젝트", String(cards.length), `전체 ${data.project_total}`)); if (data.todos.join_requests !== undefined) kpis.append( kpi("처리 대기", String(pending), pending ? `가입 ${pending}` : undefined, pending > 0), ); if (sys) { const s = sys.server; const disk = s.disk ?? 0; kpis.append( kpi( "서버", `디스크 ${Math.round(disk)}%`, `CPU ${Math.round(s.cpu ?? 0)} · 메모리 ${Math.round(s.memory ?? 0)}`, disk >= 85, ), ); } const rows = sys ? countBy(cards, (c) => sys.company_names[String(c.company_id)] ?? "회사 없음") : countBy(cards, (c) => c.owner_names[0] ?? "담당 없음"); const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") }); return el("div", { className: "b01-home__many", children: [ kpis, el("div", { className: "b01-home__split", children: [ pane(sys ? "회사별 진행 중" : "담당별 진행 중", barChart(rows)), pane("종류", kindDonut(cards)), ], }), stagePane(cards), el("div", { className: "b01-home__actions", children: [all] }), ], }); }