Files
Aislo/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts
T
eomsangdonandClaude Opus 5.5 729f4c75b8 feat(B01): 대시보드 홈 — 진행 중 수에 따라 꼴이 저절로(1 큰 카드 · 2~3 중간 · 4~8 한 줄 · 9 넘음 요약) (PLAN 547-2)
- 등록부 home 줄 = mountHome · 썸네일 선 · 단계 점 · 「다음: 지금 단계」 · [이어서 하기] = 그 단계 화면 · [자세히] = 상세
- 4~8 = 팀원별 · 단계 분포 · 14 일 넘게 그대로 표시 · 9 넘음 = 요약 숫자 · 회사별 막대 · 종류 원(인라인 SVG)
- 오른쪽 = 처리할 일 · 임시 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음 = 세 걸음 · 꼴 고정(자동 · 작게 · 크게) 기억
- 홈 역할 차례 = 셸 roleOf 와 같게(회사 관리자가 산림청 앞)
- test_546 등록부 시험 — import 한 mount 이름을 빈 함수로 저절로(페이지 줄 갈아 끼워도 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:20:21 +09:00

208 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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<string, string | number>): 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] }),
],
});
}