Files
Aislo/B01_Dashboard/B01_Dashboard_UI_ProjectDetail.ts
T
eomsangdonandClaude Opus 5.5 070791854c feat(B01): 프로젝트 상세 화면 · 카드 요약 연결 · 지킴 시험 (PLAN 548-2)
- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) ·
  노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만)
- 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌
- 색 = 셸 변수(--b01-*) 이름에 맞춤
- 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝

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

300 lines
10 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_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<string, Parameters<typeof L>[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<string, string> = {
length: "연장",
stations: "측점",
structures: "구조물",
};
const LINK_LABEL: Record<ProjectLink["kind"], string> = {
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;
};
}