diff --git a/B01_Dashboard/B01_Dashboard_Api_Home.ts b/B01_Dashboard/B01_Dashboard_Api_Home.ts new file mode 100644 index 000000000..11663f1ed --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_Api_Home.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * B01_Dashboard_Api_Home.ts + * 대시보드 홈 API(PLAN 547) — GET /dashboard/home · /dashboard/projects/summary + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 프로젝트 카드 요약(547-1 · `B01_Dashboard_Summary.py`) — 값이 없으면 칸이 빠짐 */ +export interface ProjectSummary { + id: string; + name: string; + kind: string; + region?: string | null; + company_id?: number | null; + stage_index: number; + stage_total: number; + stage_name: string; + done: boolean; + updated_at?: string | null; + stale_days?: number | null; + owner_user_id?: number | null; + owner_names: string[]; + /** 노선 중심선 0~1(y 아래로) · 없으면 null */ + thumb: [number, number][] | null; + metrics: { length_m?: number; stations?: number; structures?: number }; +} + +export interface PlanTodo { + project_id: string; + project_name: string; + year: number; + linked_project_id: string | null; +} + +export interface HomeData { + role: "SYSTEM_ADMIN" | "FOREST" | "ADMIN" | "USER"; + /** 진행 중(완료 아님) · 최근 수정 순 · 썸네일은 앞 8 개만 */ + projects: ProjectSummary[]; + /** 홈 범위 프로젝트 전체(완료 포함) — 0 = 처음 */ + project_total: number; + todos: { + new_plans: PlanTodo[]; + updated_plans: PlanTodo[]; + join_requests?: number; + expiring_temp: { batch_id: string; name: string; expires_at: string; days_left: number }[]; + temp_count: number; + }; + company?: { id: number; name: string | null; member_count: number | null }; + forest?: { + plan_status: { draft: number; analyzed: number; confirmed: number }; + assign_waiting: number; + }; + system?: { + companies: number; + users: number; + company_names: Record; + server: { cpu: number | null; memory: number | null; disk: number | null }; + }; +} + +async function getJson(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!response.ok) throw new Error(`홈 자료를 불러오지 못했습니다 (${response.status})`); + return (await response.json()) as T; +} + +export const fetchHome = (): Promise => getJson("/dashboard/home"); + +/** 카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) */ +export async function fetchProjectSummaries(ids?: string[]): Promise { + const query = ids?.length ? `?ids=${encodeURIComponent(ids.join(","))}` : ""; + return (await getJson<{ projects: ProjectSummary[] }>(`/dashboard/projects/summary${query}`)) + .projects; +} diff --git a/B01_Dashboard/B01_Dashboard_Router_Home.py b/B01_Dashboard/B01_Dashboard_Router_Home.py index 8984ba898..de6e859b9 100644 --- a/B01_Dashboard/B01_Dashboard_Router_Home.py +++ b/B01_Dashboard/B01_Dashboard_Router_Home.py @@ -57,14 +57,10 @@ async def projects_summary( def home_role(session: dict[str, Any]) -> str: - """홈 꼴 역할 — SYSTEM_ADMIN · FOREST(*.go.kr) · ADMIN(회사 관리자 · 마스터) · USER.""" - if session["role"] == "SYSTEM_ADMIN": - return "SYSTEM_ADMIN" - if is_forest_email(session.get("email")): - return "FOREST" - if session["role"] == "ADMIN" or session.get("is_master"): - return "ADMIN" - return "USER" + """홈 역할 — 셸 등록부 `roleOf` 와 같은 차례(시스템 · 회사 관리자 · 산림청 *.go.kr · 그 밖).""" + if session["role"] in ("SYSTEM_ADMIN", "ADMIN"): + return session["role"] + return "FOREST" if is_forest_email(session.get("email")) else "USER" async def home_project_ids(session: dict[str, Any], role: str) -> list[str]: diff --git a/B01_Dashboard/B01_Dashboard_UI_Home.ts b/B01_Dashboard/B01_Dashboard_UI_Home.ts new file mode 100644 index 000000000..5f96b5045 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Home.ts @@ -0,0 +1,252 @@ +/* ============================================================================= + * B01_Dashboard_UI_Home.ts + * 대시보드 홈(PLAN 547-2 · 요구 369~374) — 진행 중 프로젝트 수에 따라 꼴이 저절로 바뀜 + * 1 = 큰 카드 · 2~3 = 중간 카드 · 4~8 = 한 줄 목록 + 팀원별 · 단계 분포 · 9 넘음 = 요약 · 그래프 + * 오른쪽 = 처리할 일 · 보관함 · 회사(회사 관리자) · 예정노선(산림청) · 처음이면 세 걸음 + * 꼴 고정(자동 · 작게 · 크게) = localStorage 기억 + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { fetchHome, type HomeData, type ProjectSummary } from "./B01_Dashboard_Api_Home"; +import { openNewProjectPicker } from "./B01_Dashboard_UI_NewProject"; +import { bigCard, midCard, rowCard, type Go } from "./B01_Dashboard_UI_Home_Cards"; +import { manyView, memberPane, pane, stagePane } from "./B01_Dashboard_UI_Home_Many"; +import type { ViewCtx } from "./B01_Dashboard_UI_Views"; +import { homeLayout, type HomeFixed, type HomeLayout } from "./logic/B01_Dashboard_Home_Layout"; +import "./B01_Dashboard_UI_Style_Home.css"; + +const FIXED_KEY = "aislo.b01.homeLayout"; +const LAYOUT_TEXT: Record = { + empty: "처음 시작", + one: "진행 중 1 개 → 큰 카드", + few: "진행 중 2~3 개 → 중간 카드", + list: "진행 중 4~8 개 → 한 줄 목록", + many: "진행 중 9 개 넘음 → 요약 · 그래프", +}; + +function readFixed(): HomeFixed { + const saved = localStorage.getItem(FIXED_KEY); + return saved === "small" || saved === "large" ? saved : "auto"; +} + +function fixedSwitch(current: HomeFixed, onPick: (next: HomeFixed) => void): HTMLElement { + const seg = el("div", { + className: "b01-home__seg", + attrs: { role: "group", "aria-label": "꼴" }, + }); + for (const [value, label] of [ + ["auto", "자동"], + ["small", "작게"], + ["large", "크게"], + ] as const) { + const b = el("button", { + className: value === current ? "on" : "", + text: label, + attrs: { type: "button", "aria-pressed": String(value === current) }, + }); + b.addEventListener("click", () => onPick(value)); + seg.append(b); + } + return seg; +} + +function todoRow(dot: string, text: string, action: string, onClick: () => void): HTMLElement { + return el("div", { + className: "b01-home__todo", + children: [ + el("span", { className: `b01-home__dot b01-home__dot--${dot}` }), + el("span", { text }), + createButton({ label: action, variant: "ghost", size: "sm", onClick }), + ], + }); +} + +/** 처리할 일 — 없으면 「처리할 일 없음」 */ +function todoPane(data: HomeData, go: Go): HTMLElement { + const t = data.todos; + const rows: HTMLElement[] = []; + for (const p of t.new_plans) + rows.push( + todoRow("plan", `새 예정노선 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")), + ); + for (const p of t.updated_plans) + rows.push( + todoRow("warn", `업데이트 — ${p.project_name} ${p.year}`, "보기", () => go("assigned")), + ); + if (t.join_requests) + rows.push(todoRow("alert", `가입 요청 ${t.join_requests} 건`, "처리", () => go("people"))); + for (const b of t.expiring_temp) + rows.push( + todoRow("warn", `보관 파일 ${b.name} · ${b.days_left} 일 남음`, "열기", () => go("temp")), + ); + if (!rows.length) rows.push(el("div", { className: "b01-home__muted", text: "처리할 일 없음" })); + return pane("처리할 일", ...rows); +} + +function sidePanes(data: HomeData, go: Go): HTMLElement[] { + const panes = [todoPane(data, go)]; + panes.push( + pane( + "임시 보관함", + el("div", { className: "b01-home__muted", text: `보관 묶음 ${data.todos.temp_count} 개` }), + createButton({ label: "보관함 ›", variant: "ghost", size: "sm", onClick: () => go("temp") }), + ), + ); + if (data.company) { + const c = data.company; + panes.push( + pane( + c.name ?? "회사", + el("div", { + className: "b01-home__muted", + text: `팀원 ${c.member_count ?? 0} 명 · 진행 중 ${data.projects.length} 개`, + }), + createButton({ + label: "사람 ›", + variant: "ghost", + size: "sm", + onClick: () => go("people"), + }), + ), + ); + } + if (data.forest) { + const f = data.forest; + panes.push( + pane( + "예정노선", + el("div", { + className: "b01-home__muted", + text: `분석 전 ${f.plan_status.draft} · 분석됨 ${f.plan_status.analyzed} · 확정 ${f.plan_status.confirmed}`, + }), + el("div", { + className: f.assign_waiting ? "b01-home__stale" : "b01-home__muted", + text: `업체 지정 대기 ${f.assign_waiting}`, + }), + ), + ); + } + return panes; +} + +/** 처음 — 세 걸음(내 정보 · 파일 · 첫 프로젝트) */ +function firstSteps(ctx: ViewCtx, data: HomeData): HTMLElement { + const me = ctx.me; + const steps: [boolean, string, string, () => void][] = [ + [ + Boolean(me.position || me.department || me.phone), + "내 정보 채우기", + "내 정보 ›", + () => ctx.go("me"), + ], + [data.todos.temp_count > 0, "측량 파일 올려 두기", "보관함 ›", () => ctx.go("temp")], + [false, "첫 프로젝트 만들기", "+ 새 프로젝트", () => void openNewProjectPicker()], + ]; + return pane( + "처음 오셨네요 — 세 걸음", + ...steps.map(([done, text, action, onClick], i) => + el("div", { + className: `b01-home__step${done ? " b01-home__step--done" : ""}`, + children: [ + el("span", { className: "b01-home__step-no", text: done ? "✓" : String(i + 1) }), + el("span", { text }), + createButton({ label: action, variant: done ? "ghost" : "filled", size: "sm", onClick }), + ], + }), + ), + ); +} + +function mainArea(layout: HomeLayout, ctx: ViewCtx, data: HomeData): HTMLElement { + const cards: ProjectSummary[] = data.projects; + const go = ctx.go; + if (layout === "empty") + return data.project_total + ? pane( + "진행 중인 프로젝트 없음", + el("div", { className: "b01-home__muted", text: "모두 끝났습니다." }), + ) + : firstSteps(ctx, data); + if (layout === "one") return bigCard(cards[0], go); + if (layout === "few") { + const mids = el("div", { + className: "b01-home__mids", + children: cards.slice(0, 8).map((c, i) => midCard(c, go, i === 0)), + }); + if (cards.length <= 8) return mids; + const all = createButton({ label: "모든 프로젝트 ›", onClick: () => go("projects") }); + return el("div", { className: "b01-home__many", children: [mids, all] }); + } + if (layout === "list") + return el("div", { + className: "b01-home__split b01-home__split--list", + children: [ + el("div", { + children: [ + el("div", { + className: "b01-home__muted", + text: `진행 중 ${cards.length} · 최근 작업 순`, + }), + el("div", { className: "b01-home__rows", children: cards.map((c) => rowCard(c, go)) }), + ], + }), + el("div", { children: [memberPane(cards), stagePane(cards)] }), + ], + }); + return manyView(data, go); +} + +export function mountHome(host: HTMLElement, ctx: ViewCtx): () => void { + let alive = true; + let data: HomeData | null = null; + const root = el("div", { className: "b01-home" }); + host.append(root); + + const draw = (): void => { + if (!data) return; + const fixed = readFixed(); + const layout = homeLayout(data.projects.length, fixed); + root.dataset.layout = layout; + const head = el("div", { + className: "b01-home__head", + children: [ + el("h2", { text: "홈" }), + el("span", { className: "b01-home__muted", text: LAYOUT_TEXT[layout] }), + el("span", { className: "b01-home__push" }), + fixedSwitch(fixed, (next) => { + if (next === "auto") localStorage.removeItem(FIXED_KEY); + else localStorage.setItem(FIXED_KEY, next); + draw(); + }), + createButton({ label: "+ 새 프로젝트", onClick: () => void openNewProjectPicker() }), + ], + }); + const body = el("div", { + className: "b01-home__body", + children: [ + el("div", { className: "b01-home__main", children: [mainArea(layout, ctx, data)] }), + el("aside", { className: "b01-home__side", children: sidePanes(data, ctx.go) }), + ], + }); + root.replaceChildren(head, body); + }; + + root.append(el("div", { className: "b01-home__muted", text: "불러오는 중…" })); + fetchHome() + .then((loaded) => { + if (!alive) return; + data = loaded; + draw(); + }) + .catch((error: unknown) => { + if (!alive) return; + root.replaceChildren( + el("div", { className: "b01-home__muted", text: "홈을 불러오지 못했습니다." }), + ); + showToast(error instanceof Error ? error.message : String(error), "error"); + }); + + return () => { + alive = false; + }; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts b/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts new file mode 100644 index 000000000..ca7502b4a --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Home_Cards.ts @@ -0,0 +1,219 @@ +/* ============================================================================= + * B01_Dashboard_UI_Home_Cards.ts + * 홈 카드 부품(PLAN 547-2) — 썸네일 선 · 단계 점 · 큰 카드(1) · 중간 카드(2~3) · 한 줄(4~8) + * [이어서 하기] = 그 단계 화면 · [자세히] · 줄 누름 = go("project", { id }) + * ========================================================================== */ + +import type { RoutePath } from "@config/config_frontend"; +import { createButton, el } from "@ui/ui_template_elements"; +import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; +import { groupFlowOf } from "../A00_Common/group_workflow"; +import type { ProjectSummary } from "./B01_Dashboard_Api_Home"; +import { L } from "./B01_Dashboard_UI_Common"; +import { isStale } from "./logic/B01_Dashboard_Home_Layout"; + +export type Go = (view: string, params?: Record) => void; + +const SVG = "http://www.w3.org/2000/svg"; +const KIND_LABEL = { + ROAD: "DE_New_Road", + PLAN: "DE_New_Plan", + STABILITY: "DE_New_Stability", + EROSION: "DE_New_Erosion", +} as const; + +export const kindLabel = (kind: string): string => + L(KIND_LABEL[kind as keyof typeof KIND_LABEL] ?? "DE_New_Road"); + +export const kindClass = (kind: string): string => + `b01-home__kind--${(kind in KIND_LABEL ? kind : "ROAD").toLowerCase()}`; + +/** 노선 중심선 썸네일 — 선이 없으면 빈 바탕 */ +export function thumbSvg(card: ProjectSummary, className: string): SVGSVGElement { + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", `b01-home__thumb ${kindClass(card.kind)} ${className}`); + svg.setAttribute("viewBox", "-0.08 -0.08 1.16 1.16"); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.setAttribute("aria-hidden", "true"); + if (card.thumb) { + const line = document.createElementNS(SVG, "polyline"); + line.setAttribute("points", card.thumb.map(([x, y]) => `${x},${y}`).join(" ")); + line.setAttribute("vector-effect", "non-scaling-stroke"); + svg.append(line); + } + return svg; +} + +/** 단계 점 — 지난 단계 · 지금 단계 · 남은 단계 */ +export function stagePips(card: ProjectSummary): HTMLElement { + const pips = el("div", { className: `b01-home__pips ${kindClass(card.kind)}` }); + for (let i = 0; i < card.stage_total; i += 1) { + const state = i < card.stage_index ? "ok" : i === card.stage_index ? "cur" : ""; + pips.append(el("i", { className: state })); + } + return pips; +} + +export const stageText = (card: ProjectSummary): string => + `${card.stage_index + 1} / ${card.stage_total} · ${card.stage_name}`; + +/** 「n 일째 그대로」 · 오늘 · n 일 전 */ +export function whenText(card: ProjectSummary): string { + const days = card.stale_days ?? null; + if (days === null) return ""; + if (isStale(card)) return `${days} 일째 그대로`; + return days === 0 ? "오늘" : `${days} 일 전`; +} + +export function continueStage(card: ProjectSummary): void { + const routes: readonly RoutePath[] = groupFlowOf(card.kind)?.routes ?? WORKFLOW_STEP_ROUTES; + void goToWorkflowStage(card.id, routes[Math.min(card.stage_index, routes.length - 1)]); +} + +function kindBadge(card: ProjectSummary): HTMLElement { + return el("span", { + className: `b01-home__badge ${kindClass(card.kind)}`, + text: kindLabel(card.kind), + }); +} + +function title(card: ProjectSummary): HTMLElement { + return el("div", { + className: "b01-home__title", + children: [el("b", { text: card.name }), kindBadge(card)], + }); +} + +function nextBox(card: ProjectSummary): HTMLElement { + return el("div", { className: "b01-home__next", text: `다음: ${card.stage_name}` }); +} + +function whenLine(card: ProjectSummary): HTMLElement { + const line = el("div", { className: "b01-home__muted" }); + const owner = card.owner_names[0]; + if (owner) line.append(`${owner} · `); + line.append( + el("span", { className: isStale(card) ? "b01-home__stale" : "", text: whenText(card) }), + ); + return line; +} + +const METRIC_ROWS: [keyof ProjectSummary["metrics"], string, (v: number) => string][] = [ + ["length_m", "연장", (v) => `${v.toLocaleString()} m`], + ["stations", "측점", (v) => `${v}`], + ["structures", "구조물", (v) => `${v}`], +]; + +/** 핵심 숫자 — 있는 값만 */ +function metricStrip(card: ProjectSummary): HTMLElement | null { + const cells = METRIC_ROWS.filter(([key]) => card.metrics[key] !== undefined).map( + ([key, label, fmt]) => + el("div", { + children: [ + el("b", { text: fmt(card.metrics[key] as number) }), + el("span", { text: label }), + ], + }), + ); + return cells.length ? el("div", { className: "b01-home__metrics", children: cells }) : null; +} + +const continueButton = (card: ProjectSummary, label = "이어서 하기 ›"): HTMLButtonElement => + createButton({ label, size: "sm", onClick: () => continueStage(card) }); + +const detailButton = (card: ProjectSummary, go: Go): HTMLButtonElement => + createButton({ + label: "자세히", + variant: "ghost", + size: "sm", + onClick: () => go("project", { id: card.id }), + }); + +/** 진행 중 1 — 큰 카드(썸네일 · 단계 점 · 다음 할 일 · 핵심 숫자) */ +export function bigCard(card: ProjectSummary, go: Go): HTMLElement { + const info = el("div", { + className: "b01-home__big-info", + children: [ + el("div", { className: "b01-home__muted", text: "지금 하는 프로젝트" }), + title(card), + el("b", { className: "b01-home__stage", text: stageText(card) }), + stagePips(card), + nextBox(card), + whenLine(card), + el("div", { + className: "b01-home__actions", + children: [continueButton(card), detailButton(card, go)], + }), + ], + }); + const top = el("div", { + className: "b01-home__big-top", + children: [thumbSvg(card, "b01-home__thumb--big") as unknown as HTMLElement, info], + }); + const metrics = metricStrip(card); + return el("div", { + className: "b01-home__pane b01-home__big", + children: metrics ? [top, metrics] : [top], + }); +} + +/** 진행 중 2~3 — 중간 카드(맨 앞 = 최근 작업) */ +export function midCard(card: ProjectSummary, go: Go, first: boolean): HTMLElement { + const node = el("div", { + className: `b01-home__mid${first ? " b01-home__mid--first" : ""}`, + children: [ + thumbSvg(card, "b01-home__thumb--mid") as unknown as HTMLElement, + el("div", { + className: "b01-home__mid-body", + children: [ + whenLine(card), + title(card), + el("div", { className: "b01-home__muted", text: stageText(card) }), + stagePips(card), + nextBox(card), + el("div", { + className: "b01-home__actions", + children: [ + continueButton(card, first ? "이어서 하기 ›" : "이어서 ›"), + detailButton(card, go), + ], + }), + ], + }), + ], + }); + return node; +} + +/** 진행 중 4~8 — 한 줄(누름 = 상세 · 14 일 넘게 그대로면 노랑) */ +export function rowCard(card: ProjectSummary, go: Go): HTMLElement { + const open = createButton({ label: "열기 ›", variant: "ghost", size: "sm" }); + open.addEventListener("click", (event) => { + event.stopPropagation(); + continueStage(card); + }); + const row = el("div", { + className: `b01-home__row${isStale(card) ? " b01-home__row--stale" : ""}`, + attrs: { role: "button", tabindex: "0" }, + children: [ + thumbSvg(card, "b01-home__thumb--row") as unknown as HTMLElement, + el("div", { className: "b01-home__row-name", children: [title(card), whenLine(card)] }), + el("div", { + className: "b01-home__row-stage", + children: [ + stagePips(card), + el("div", { + className: "b01-home__muted", + text: `${card.stage_name} ${card.stage_index + 1}/${card.stage_total}`, + }), + ], + }), + open, + ], + }); + row.addEventListener("click", () => go("project", { id: card.id })); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter") go("project", { id: card.id }); + }); + return row; +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts b/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts new file mode 100644 index 000000000..d5e7b43db --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Home_Many.ts @@ -0,0 +1,207 @@ +/* ============================================================================= + * 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] }), + ], + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Home.css b/B01_Dashboard/B01_Dashboard_UI_Style_Home.css new file mode 100644 index 000000000..9a9a8592d --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Home.css @@ -0,0 +1,500 @@ +/* 대시보드 홈(PLAN 547-2) — 색 · 간격 = 셸 변수(--b01-*) · 종류 색 = 테마 차트 변수 */ +.b01-home { + --b01-home-road: var(--b01-accent); + --b01-home-plan: var(--color-chart-0); + --b01-home-stab: var(--color-chart-2); + --b01-home-warn: var(--color-warning); + display: flex; + flex-direction: column; + gap: var(--spacing-16); + color: var(--b01-text); +} + +.b01-home__head { + display: flex; + align-items: center; + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.b01-home__head h2 { + margin: 0; + font-size: 19px; +} + +.b01-home__push { + margin-left: auto; +} + +.b01-home__muted { + color: var(--b01-muted); + font-size: 12px; +} + +.b01-home__stale { + color: var(--b01-home-warn); + font-size: 12px; + font-weight: 600; +} + +.b01-home__alert { + color: var(--b01-alert); +} + +.b01-home__seg { + display: inline-flex; + border: 1px solid var(--b01-line); + border-radius: 8px; + overflow: hidden; +} + +.b01-home__seg button { + border: 0; + background: var(--b01-card); + color: var(--b01-text); + padding: var(--spacing-4) var(--spacing-12); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.b01-home__seg button.on { + background: var(--b01-accent); + color: var(--b01-accent-text); +} + +.b01-home__body { + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: var(--spacing-16); + align-items: start; +} + +.b01-home__main, +.b01-home__side { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; +} + +.b01-home__pane { + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); + padding: var(--spacing-12) var(--spacing-16); + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b01-home__pane > .ui-btn { + align-self: flex-start; +} + +.b01-home__pane-title { + margin: 0; + font-size: 13px; +} + +.b01-home__actions { + display: flex; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +/* 종류 배지 · 썸네일 · 단계 점 */ +.b01-home__kind--road { + --kind: var(--b01-home-road); +} +.b01-home__kind--plan { + --kind: var(--b01-home-plan); +} +.b01-home__kind--stability { + --kind: var(--b01-home-stab); +} +.b01-home__kind--erosion { + --kind: var(--b01-muted); +} + +.b01-home__badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + border-radius: 5px; + padding: 0 6px; + white-space: nowrap; + color: var(--kind); + border: 1px solid var(--kind); +} + +.b01-home__title { + display: flex; + align-items: center; + gap: var(--spacing-8); + min-width: 0; +} + +.b01-home__title b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.b01-home__thumb { + display: block; + width: 100%; + background: var(--b01-accent-soft); +} + +.b01-home__thumb polyline { + fill: none; + stroke: var(--kind); + stroke-width: 3; + stroke-linejoin: round; + stroke-linecap: round; +} + +.b01-home__thumb--big { + height: 210px; +} +.b01-home__thumb--mid { + height: 100px; +} +.b01-home__thumb--row { + width: 80px; + height: 44px; + border-radius: 6px; +} +.b01-home__thumb--row polyline { + stroke-width: 2; +} + +.b01-home__pips { + display: flex; + gap: 4px; +} + +.b01-home__pips i { + flex: 1; + height: 7px; + border-radius: 4px; + background: var(--b01-line); + box-sizing: border-box; +} + +.b01-home__pips i.ok { + background: var(--kind); +} + +.b01-home__pips i.cur { + background: var(--b01-card); + border: 2px solid var(--kind); +} + +.b01-home__next { + border: 1px solid var(--b01-home-warn); + border-radius: 8px; + padding: var(--spacing-4) var(--spacing-8); + font-size: 12.5px; +} + +/* 1 — 큰 카드 */ +.b01-home__big { + padding: 0; + overflow: hidden; + gap: 0; +} + +.b01-home__big-top { + display: grid; + grid-template-columns: 1.1fr 1fr; +} + +.b01-home__big-info { + padding: var(--spacing-12) var(--spacing-16); + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b01-home__big-info .b01-home__title b { + font-size: 17px; +} + +.b01-home__metrics { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + border-top: 1px solid var(--b01-line); +} + +.b01-home__metrics div { + padding: var(--spacing-8) var(--spacing-12); + border-left: 1px solid var(--b01-line); +} + +.b01-home__metrics div:first-child { + border-left: 0; +} + +.b01-home__metrics b { + display: block; + font-size: 15px; + font-variant-numeric: tabular-nums; +} + +.b01-home__metrics span { + color: var(--b01-muted); + font-size: 11.5px; +} + +/* 2~3 — 중간 카드 */ +.b01-home__mids { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: var(--spacing-12); +} + +.b01-home__mid { + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); + overflow: hidden; +} + +.b01-home__mid--first { + border-color: var(--b01-accent); +} + +.b01-home__mid-body { + padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* 4~8 — 한 줄 목록 */ +.b01-home__split { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: var(--spacing-12); + align-items: start; +} + +.b01-home__split--list { + grid-template-columns: minmax(0, 1fr) 220px; +} + +.b01-home__split--list > div { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.b01-home__rows { + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); + overflow: hidden; +} + +.b01-home__row { + display: grid; + grid-template-columns: 80px minmax(0, 1.2fr) minmax(0, 1fr) auto; + gap: var(--spacing-12); + align-items: center; + padding: var(--spacing-8) var(--spacing-12); + border-top: 1px solid var(--b01-line); + cursor: pointer; +} + +.b01-home__row:first-child { + border-top: 0; +} + +.b01-home__row:hover, +.b01-home__row:focus-visible { + background: var(--b01-hover); + outline: none; +} + +.b01-home__row--stale { + box-shadow: inset 3px 0 0 var(--b01-home-warn); +} + +.b01-home__member { + display: flex; + align-items: center; + gap: var(--spacing-8); + font-size: 12.5px; +} + +.b01-home__avatar { + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 10px; + font-weight: 700; + background: var(--b01-accent-soft); + color: var(--b01-accent); +} + +.b01-home__stack { + display: flex; + height: 10px; + border-radius: 6px; + overflow: hidden; + background: var(--b01-line); +} + +.b01-home__stack i { + display: block; + height: 100%; +} + +.b01-home__stack--start { + background: var(--b01-accent-soft); +} +.b01-home__stack--working { + background: var(--b01-accent); +} +.b01-home__stack--last { + background: var(--b01-home-warn); +} + +/* 9 넘음 — 요약 숫자 · 그래프 */ +.b01-home__many { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.b01-home__kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: var(--spacing-12); +} + +.b01-home__kpi { + background: var(--b01-card); + border: 1px solid var(--b01-line); + border-radius: var(--b01-radius); + padding: var(--spacing-8) var(--spacing-12); +} + +.b01-home__kpi b { + display: block; + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.b01-home__bars text { + font-size: 10px; + fill: var(--b01-text); +} + +.b01-home__bars rect { + fill: var(--b01-accent); +} + +.b01-home__bars rect.rest { + fill: var(--b01-accent-soft); +} + +.b01-home__donut-row { + display: flex; + align-items: center; + gap: var(--spacing-16); +} + +.b01-home__donut circle { + fill: none; + stroke: var(--kind); + stroke-width: 6; +} + +.b01-home__donut text { + font-size: 8px; + font-weight: 700; + fill: var(--b01-text); +} + +.b01-home__legend { + display: grid; + gap: var(--spacing-4); + font-size: 12px; +} + +.b01-home__legend span { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +.b01-home__swatch { + width: 9px; + height: 9px; + border-radius: 3px; + background: var(--kind); +} + +/* 오른쪽 — 처리할 일 · 처음 세 걸음 */ +.b01-home__todo, +.b01-home__step { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: var(--spacing-8); + align-items: center; + font-size: 12.5px; +} + +.b01-home__dot { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.b01-home__dot--plan { + background: var(--b01-home-plan); +} +.b01-home__dot--warn { + background: var(--b01-home-warn); +} +.b01-home__dot--alert { + background: var(--b01-alert); +} + +.b01-home__step-no { + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + font-weight: 700; + border: 2px solid var(--b01-accent); + color: var(--b01-accent); +} + +.b01-home__step--done .b01-home__step-no { + background: var(--b01-accent); + color: var(--b01-accent-text); +} + +@media (max-width: 900px) { + .b01-home__body, + .b01-home__split, + .b01-home__split--list, + .b01-home__big-top { + grid-template-columns: 1fr; + } + .b01-home__row { + grid-template-columns: 60px minmax(0, 1fr) auto; + } + .b01-home__row-stage { + display: none; + } +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Views.ts b/B01_Dashboard/B01_Dashboard_UI_Views.ts index 3297a73a9..92df347f0 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Views.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Views.ts @@ -10,6 +10,7 @@ import type { IconName } from "@ui/ui_template_icons"; import type { DashboardUser } from "./B01_Dashboard_Api_Fetch"; +import { mountHome } from "./B01_Dashboard_UI_Home"; import { mountLegacy, type LegacyKey } from "./B01_Dashboard_UI_Legacy"; import { mountChat, mountGuide } from "./B01_Dashboard_UI_Nav"; @@ -46,7 +47,7 @@ const legacy = // prettier-ignore export const VIEWS: View[] = [ // 작업 - { key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: legacy("projects") }, + { key: "home", title: "홈", group: "work", roles: ALL, icon: "home", mount: mountHome }, { key: "projects", title: "프로젝트", group: "work", roles: ALL, icon: "folder", mount: legacy("projects") }, { key: "project", title: "프로젝트 상세", group: "hidden", roles: ALL, mount: legacy("projects") }, { key: "assigned", title: "받은 예정노선", group: "work", roles: ["USER", "ADMIN", "SYSTEM_ADMIN"], icon: "route", mount: legacy("assigned") }, diff --git a/B01_Dashboard/logic/B01_Dashboard_Home_Layout.ts b/B01_Dashboard/logic/B01_Dashboard_Home_Layout.ts new file mode 100644 index 000000000..0e920eebb --- /dev/null +++ b/B01_Dashboard/logic/B01_Dashboard_Home_Layout.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * logic/B01_Dashboard_Home_Layout.ts + * 홈 꼴 고르기 · 묶어 세기(PLAN 547-2) — 순수 함수 · 시험 `test_547_2_home_layout.py` + * 진행 중 1 = one · 2~3 = few · 4~8 = list · 9 넘음 = many · 0 = empty + * 고정 small = list(9 넘음 = many) · large = one(1) / few(2 넘음) + * ========================================================================== */ + +export type HomeLayout = "empty" | "one" | "few" | "list" | "many"; +export type HomeFixed = "auto" | "small" | "large"; + +export const STALE_DAYS = 14; +export const MANY_FROM = 9; + +export function homeLayout(count: number, fixed: HomeFixed = "auto"): HomeLayout { + if (count <= 0) return "empty"; + if (fixed === "large") return count === 1 ? "one" : "few"; + if (fixed === "small") return count >= MANY_FROM ? "many" : "list"; + if (count === 1) return "one"; + if (count <= 3) return "few"; + return count < MANY_FROM ? "list" : "many"; +} + +interface StageLike { + stage_index: number; + stage_total: number; + stale_days?: number | null; +} + +export const isStale = (card: StageLike): boolean => (card.stale_days ?? 0) >= STALE_DAYS; + +/** 단계 분포 — 시작 전(첫 단계) · 설계 중 · 확정 대기(마지막 단계) */ +export function stageBuckets(cards: StageLike[]): { start: number; working: number; last: number } { + const out = { start: 0, working: 0, last: 0 }; + for (const c of cards) { + if (c.stage_index <= 0) out.start += 1; + else if (c.stage_index >= c.stage_total - 1) out.last += 1; + else out.working += 1; + } + return out; +} + +/** 묶어 세기 — 많은 차례 · 같으면 이름 차례 */ +export function countBy( + items: T[], + key: (item: T) => string, +): { label: string; count: number }[] { + const counts = new Map(); + for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1); + return [...counts] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); +} + +/** 앞 limit 개 + 나머지 하나로 묶음(「그 밖 N 곳」) */ +export function topWithRest( + rows: { label: string; count: number }[], + limit: number, +): { label: string; count: number; rest?: number }[] { + if (rows.length <= limit) return rows; + const rest = rows.slice(limit - 1); + return [ + ...rows.slice(0, limit - 1), + { label: "", count: rest.reduce((sum, r) => sum + r.count, 0), rest: rest.length }, + ]; +} + +/** 원 그래프 조각 — 둘레 100 기준 stroke-dasharray · stroke-dashoffset(12 시 시작 · 시계 방향) */ +export function donutArcs(values: number[]): { dash: string; offset: number }[] { + const total = values.reduce((sum, v) => sum + v, 0); + if (total <= 0) return []; + let start = 0; + return values.map((v) => { + const len = (v / total) * 100; + const arc = { dash: `${round2(len)} ${round2(100 - len)}`, offset: round2(25 - start) }; + start += len; + return arc; + }); +} + +const round2 = (v: number): number => Math.round(v * 100) / 100; diff --git a/resources/tester/test_546_b01_shell.py b/resources/tester/test_546_b01_shell.py index baf8cfe74..2ff74ecfc 100644 --- a/resources/tester/test_546_b01_shell.py +++ b/resources/tester/test_546_b01_shell.py @@ -24,13 +24,16 @@ def _text(path: Path) -> str: def _run_views(body: str) -> object: - src = re.sub(r"^import[\s\S]*?;\n", "", _text(B01 / "B01_Dashboard_UI_Views.ts"), flags=re.M) - script = ( - "const mountLegacy = () => {}; const mountGuide = () => {}; const mountChat = () => {};\n" - + src - + "\n" - + body - ) + text = _text(B01 / "B01_Dashboard_UI_Views.ts") + # import 한 값(type 아닌 이름) = 빈 함수 — 페이지 줄이 새 mount 를 끌어와도 그대로 돎 + stubs = [ + name.split(" as ")[-1].strip() + for group in re.findall(r"^import \{([^}]*)\}", text, flags=re.M) + for name in group.split(",") + if name.strip() and not name.strip().startswith("type ") + ] + src = re.sub(r"^import[\s\S]*?;\n", "", text, flags=re.M) + script = "".join(f"const {n} = () => {{}};\n" for n in stubs) + src + "\n" + body result = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", script], diff --git a/resources/tester/test_547_2_home.py b/resources/tester/test_547_2_home.py new file mode 100644 index 000000000..816a16085 --- /dev/null +++ b/resources/tester/test_547_2_home.py @@ -0,0 +1,152 @@ +"""547-2 대시보드 홈 — 개수별 꼴 고르기(TS logic) · 역할 가르기 · 역할별 홈 API 칸.""" + +import asyncio +import json +import re +import subprocess +from pathlib import Path + +import pytest + +import B01_Dashboard.B01_Dashboard_Router_Home as home + +ROOT = Path(__file__).resolve().parents[2] +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +LOGIC = ROOT / "B01_Dashboard" / "logic" / "B01_Dashboard_Home_Layout.ts" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as m from "./B01_Dashboard_Home_Layout.js"; +const counts = [0, 1, 2, 3, 4, 8, 9, 40]; +const out = { + auto: counts.map((n) => m.homeLayout(n)), + small: counts.map((n) => m.homeLayout(n, "small")), + large: counts.map((n) => m.homeLayout(n, "large")), + buckets: m.stageBuckets([ + { stage_index: 0, stage_total: 7 }, { stage_index: 3, stage_total: 7 }, + { stage_index: 6, stage_total: 7 }, { stage_index: 2, stage_total: 3 }, + ]), + stale: [m.isStale({ stage_index: 1, stage_total: 7, stale_days: 13 }), + m.isStale({ stage_index: 1, stage_total: 7, stale_days: 14 })], + count: m.countBy(["나", "가", "나"], (x) => x), + rest: m.topWithRest([5, 4, 3, 2, 1, 1].map((c, i) => ({ label: "c" + i, count: c })), 5), + donut: m.donutArcs([1, 1, 2]), +}; +writeFileSync(process.argv[2], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory): + if not TSC.is_file(): + pytest.skip("프론트엔드 의존성 미설치") + tmp = tmp_path_factory.mktemp("home") + out = tmp / "js" + args = ["--outDir", str(out), "--module", "esnext", "--target", "es2022", "--ignoreConfig"] + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(LOGIC), *args], cwd=str(ROOT), check=True, capture_output=True + ) + for emitted in out.glob("*.js"): + text = emitted.read_text(encoding="utf-8") + emitted.write_text(re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', text), encoding="utf-8") + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + dst = tmp / "out.json" + subprocess.run(["node", str(out / "runner.mjs"), str(dst)], check=True, capture_output=True) # noqa: S603, S607 + return json.loads(dst.read_text(encoding="utf-8")) + + +def test_layout_by_count(ts): + # 개수 0 1 2 3 4 8 9 40 + assert ts["auto"] == ["empty", "one", "few", "few", "list", "list", "many", "many"] + assert ts["small"] == ["empty", "list", "list", "list", "list", "list", "many", "many"] + assert ts["large"] == ["empty", "one", "few", "few", "few", "few", "few", "few"] + + +def test_counts_and_charts(ts): + assert ts["buckets"] == {"start": 1, "working": 1, "last": 2} + assert ts["stale"] == [False, True] + assert ts["count"] == [{"label": "나", "count": 2}, {"label": "가", "count": 1}] + assert ts["rest"][-1] == {"label": "", "count": 2, "rest": 2} and len(ts["rest"]) == 5 + assert [a["dash"] for a in ts["donut"]] == ["25 75", "25 75", "50 50"] + assert [a["offset"] for a in ts["donut"]] == [25, 0, -25] + + +def test_home_role_order_matches_shell(): + s = {"email": "a@forest.go.kr"} + assert home.home_role({**s, "role": "SYSTEM_ADMIN"}) == "SYSTEM_ADMIN" + assert home.home_role({**s, "role": "ADMIN"}) == "ADMIN" + assert home.home_role({**s, "role": "USER"}) == "FOREST" + assert home.home_role({"email": "a@b.com", "role": "USER"}) == "USER" + + +class _Cursor: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def execute(self, sql, args=()): + self.sql = sql + + async def fetchone(self): + return {"n": 2, "name": "회사", "member_count": 3, "companies": 4, "users": 9} + + async def fetchall(self): + if "temp_upload_batches" in self.sql or "plan_route_years" in self.sql: + return [] + return [{"id": 1, "name": "회사"}] + + +class _Pool: + def acquire(self): + class _Conn: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + def cursor(self, *_): + return _Cursor() + + return _Conn() + + +@pytest.mark.parametrize( + ("session", "keys", "join"), + [ + ({"role": "SYSTEM_ADMIN", "user_id": 1, "company_id": 1}, {"system"}, True), + ({"role": "ADMIN", "user_id": 1, "company_id": 1}, {"company"}, True), + ({"role": "USER", "user_id": 1, "company_id": 4, "email": "x@a.go.kr"}, {"forest"}, False), + ({"role": "USER", "user_id": 1, "company_id": 1, "email": "x@a.com"}, set(), False), + ], +) +def test_home_blocks_by_role(monkeypatch, session, keys, join): + async def fake_ids(_session, _role): + return ["a", "b"] + + async def fake_summaries(ids): + return [ + {"id": i, "done": i == "b", "thumb": [[0, 0], [1, 1]]} for i in ids + ] # b = 완료 → 빠짐 + + async def fake_plans(_session): + return {"routes": []} + + async def fake_resources(days): + return {"current": {"cpu_usage_percent": 1, "memory_usage_percent": 2}} + + import D01_PlanRegister.D01_PlanRegister_Router as d01 + + monkeypatch.setattr(home, "home_project_ids", fake_ids) + monkeypatch.setattr(home, "project_summaries", fake_summaries) + monkeypatch.setattr(home, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(home, "get_system_resources", fake_resources) + monkeypatch.setattr(d01, "assigned_plan_routes", fake_plans) + body = asyncio.run(home.dashboard_home(session=session)) + assert {"system", "company", "forest"} & body.keys() == keys + assert [p["id"] for p in body["projects"]] == ["a"] and body["project_total"] == 2 + assert ("join_requests" in body["todos"]) is join + if "system" in body: + assert body["system"]["server"] == {"cpu": 1, "memory": 2, "disk": None}