- 등록부 home 줄 = mountHome · 썸네일 선 · 단계 점 · 「다음: 지금 단계」 · [이어서 하기] = 그 단계 화면 · [자세히] = 상세 - 4~8 = 팀원별 · 단계 분포 · 14 일 넘게 그대로 표시 · 9 넘음 = 요약 숫자 · 회사별 막대 · 종류 원(인라인 SVG) - 오른쪽 = 처리할 일 · 임시 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음 = 세 걸음 · 꼴 고정(자동 · 작게 · 크게) 기억 - 홈 역할 차례 = 셸 roleOf 와 같게(회사 관리자가 산림청 앞) - test_546 등록부 시험 — import 한 mount 이름을 빈 함수로 저절로(페이지 줄 갈아 끼워도 그대로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
/* =============================================================================
|
||
* 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] }),
|
||
],
|
||
});
|
||
}
|