/* ============================================================================= * B01_Dashboard_UI_ProjectDetail.ts * 프로젝트 상세(요구 369~374 · PLAN 548-2) — 주소 `view=project&id=`. * 머리(이름 · 종류 · 지역 · 설계속도 · [⋯] · [작업 열기 — 지금 단계 ›]) · 단계 진행(누름 = 그 단계 화면) · * 지도(노선 · 시점 · 종점) + 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경 — 값이 있는 칸만. * ========================================================================== */ import "./B01_Dashboard_UI_Style_Projects.css"; import type { RoutePath } from "@config/config_frontend"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { createButton } from "@ui/ui_template_elements"; import { el } from "@ui/ui_template_elements_base"; import { goToWorkflowStage } from "../A00_Common/b_workflow_nav"; import { currentQuery } from "../A00_Common/router"; import { fetchProjectDetail, type ProjectChange, type ProjectDetail, type ProjectLink, } from "./B01_Dashboard_Api_Projects"; import { L } from "./B01_Dashboard_UI_Common"; import { routeMap } from "./B01_Dashboard_UI_ProjectDetail_Map"; import type { ProjectViewCtx } from "./B01_Dashboard_UI_ProjectList"; import { kindBadge, openStage, stageFlow, stageProgress, } from "./B01_Dashboard_UI_ProjectList_Parts"; import { rowMenuItems } from "./B01_Dashboard_UI_ProjectList_Views"; /** 인쇄 · 내려받기 화면(B09 초안 길) — 도면 PDF · 수량 · 내역 xlsx 를 받는 곳 */ const PRINT_ROUTE = "b09-print" as RoutePath; const ROAD_TYPE: Record[0]> = { main: "B02_Proj_RoadType_Main", fire: "B02_Proj_RoadType_Fire", work: "B02_Proj_RoadType_Work", }; const roadType = (value?: string | null): string | null => value ? (ROAD_TYPE[value] ? L(ROAD_TYPE[value]) : value) : null; const NUMBER_LABEL: Record = { length: "연장", stations: "측점", structures: "구조물", }; const LINK_LABEL: Record = { source: "원본", derived: "이어 만든 것", plan: "받은 예정노선", }; function pane(title: string, ...children: (HTMLElement | SVGElement)[]): HTMLElement { const box = el("section", { className: "b01-pd-pane", children: [el("h3", { text: title })] }); box.append(...children); return box; } function steps(detail: ProjectDetail): HTMLElement { const project = detail.project; const { steps: names } = stageFlow(project.project_kind); const { current } = stageProgress(project); const states = new Map((project.workflow_state?.stages ?? []).map((s) => [s.stage_no, s.state])); const row = el("div", { className: "b01-pd-steps" }); row.style.setProperty("--n", String(names.length)); names.forEach((name, index) => { const state = states.get(index); const done = state === "COMPLETE"; const cls = done ? "is-done" : state === "STALE" || state === "FAILED" ? "is-stale" : index === current && project.board !== "start" ? "is-cur" : ""; const step = el("button", { className: `b01-pd-step ${cls}`, attrs: { type: "button", title: `${name} 화면 열기` }, children: [el("span", { text: done ? "✓" : String(index + 1) }), el("b", { text: name })], }); step.addEventListener("click", () => openStage(project, index)); row.append(step); }); return row; } function numbers(detail: ProjectDetail): HTMLElement | null { if (!detail.numbers.length) return null; return el("div", { className: "b01-pd-kpis", children: detail.numbers.map((n) => el("div", { className: "b01-pd-kpi", children: [ el("b", { text: `${n.value.toLocaleString("ko-KR")}${n.unit ? ` ${n.unit}` : ""}` }), el("span", { text: NUMBER_LABEL[n.key] ?? n.key }), ], }), ), }); } function infoPane(detail: ProjectDetail): HTMLElement { const p = detail.project; const year = p.project_year ? `${p.project_year}${p.project_year_end && p.project_year_end !== p.project_year ? ` ~ ${p.project_year_end}` : ""}` : null; const people = [ ...new Set([p.manager_name, detail.people.field_lead, detail.people.designer].filter(Boolean)), ].join(" · "); const rows: [string, string | null | undefined][] = [ ["사업 연도", year], ["지역", p.region], ["임도 종류", roadType(p.road_type)], ["설계속도", p.design_speed_kph ? `${p.design_speed_kph} km/h` : null], ["시행청", p.client_org], ["사업 번호", p.project_number], ["담당", people], ["만든 날", (p.created_at ?? "").slice(0, 10)], ]; const kv = el("div", { className: "b01-pd-kv" }); for (const [label, value] of rows) { if (value) kv.append(el("span", { text: label }), el("b", { text: value })); } return pane("기본 정보", kv); } function linkPane(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement | null { if (!detail.links.length) return null; return pane( "연결된 것", ...detail.links.map((link) => el("div", { className: "b01-pd-link", children: [ kindBadge(link.project_kind), el("span", { text: `${LINK_LABEL[link.kind]} · ${link.name}` }), ...(link.note ? [el("span", { className: "b01-proj-mini", text: link.note })] : []), el("span", { className: "b01-proj-sp" }), createButton({ label: "보기", variant: "ghost", onClick: () => ctx.go("project", { id: link.project_id }), }), ], }), ), ); } function outputPane(detail: ProjectDetail): HTMLElement | null { if (!detail.outputs.length) return null; return pane( "산출물 내려받기", ...detail.outputs.map((out) => el("div", { className: "b01-pd-link", attrs: { "data-output": out.key }, children: [ el("span", { text: out.label }), el("span", { className: "b01-proj-sp" }), createButton({ label: "받기 화면", variant: "ghost", onClick: () => void goToWorkflowStage(detail.project.id, PRINT_ROUTE), }), ], }), ), ); } function changeText(change: ProjectChange, detail: ProjectDetail): string { const what = change.action === "STAGE_COMPLETE" ? `${stageFlow(detail.project.project_kind).steps[change.stage_no ?? 0] ?? ""} 마침` : change.action === "PROJECT_CREATE" ? "만듦" : "정보 고침"; return [change.at.slice(0, 16), change.who, what].filter(Boolean).join(" · "); } function changePane(detail: ProjectDetail): HTMLElement | null { if (!detail.changes.length) return null; return pane( "최근 변경", el("div", { className: "b01-pd-timeline", children: detail.changes.map((c) => el("div", { text: changeText(c, detail) })), }), ); } function head(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement { const p = detail.project; const { steps: names } = stageFlow(p.project_kind); const { current } = stageProgress(p); const sub = [ p.region, roadType(p.road_type), p.design_speed_kph && `설계속도 ${p.design_speed_kph} km/h`, ] .filter(Boolean) .join(" · "); const bar = el("div", { className: "b01-pd-head" }); const menu = createMapContextMenu("b01-proj-menu"); const items = rowMenuItems(p, ctx.me); const more = el("button", { className: "b01-proj-more", text: "⋯", attrs: { type: "button", "aria-label": "더 보기" }, }); more.hidden = !items.length; more.addEventListener("click", (event) => { event.stopPropagation(); const box = bar.getBoundingClientRect(); const at = more.getBoundingClientRect(); menu.open(at.right - box.left - 120, at.bottom - box.top, items); }); const work = createButton({ label: names.length ? `작업 열기 — ${names[current] ?? names[0]} ›` : "작업 열기 ›", onClick: () => openStage(p, current), }); work.dataset.openWork = ""; bar.append( el("h2", { text: p.name }), kindBadge(p.project_kind), el("span", { className: "b01-proj-mini", text: sub }), el("span", { className: "b01-proj-sp" }), more, work, menu.element, ); return bar; } function render(detail: ProjectDetail, ctx: ProjectViewCtx): HTMLElement { const left: HTMLElement[] = []; const kpis = numbers(detail); if (detail.route) { left.push(pane("노선 미리보기", routeMap(detail.route.points), ...(kpis ? [kpis] : []))); } else if (kpis) { left.push(pane("핵심 숫자", kpis)); } const right = [ infoPane(detail), linkPane(detail, ctx), outputPane(detail), changePane(detail), ].filter((x): x is HTMLElement => x !== null); const crumb = el("button", { className: "b01-pd-crumb", text: "프로젝트 ›", attrs: { type: "button" }, }); crumb.addEventListener("click", () => ctx.go("projects")); return el("div", { className: "b01-pd", children: [ crumb, head(detail, ctx), pane("단계 진행 · 누르면 그 단계로", steps(detail)), el("div", { className: `b01-pd-grid${left.length ? "" : " is-single"}`, children: [ el("div", { className: "b01-pd-col", children: left }), el("div", { className: "b01-pd-col", children: right }), ], }), ], }); } /** 「프로젝트 상세」 — 등록부 mount · id = 주소 `id` */ export function mountProjectDetail(host: HTMLElement, ctx: ProjectViewCtx): () => void { let alive = true; const id = currentQuery().get("id") ?? ""; host.replaceChildren(el("p", { className: "b01-proj-empty", text: "불러오는 중…" })); void fetchProjectDetail(id).then( (detail) => alive && host.replaceChildren(render(detail, ctx)), (error) => { if (!alive) return; const back = createButton({ label: "프로젝트 목록", onClick: () => ctx.go("projects") }); host.replaceChildren( el("div", { className: "b01-proj-empty", children: [ el("p", { text: error instanceof Error ? error.message : String(error) }), back, ], }), ); }, ); return () => { alive = false; }; }