- 상세 화면(view=project) — 머리 · [⋯] · [작업 열기 — 지금 단계 ›] · 단계 진행(누름 = 그 단계) · 노선 지도(배경 타일 + 선 · 시점 · 종점) · 핵심 숫자 · 기본 정보 · 연결된 것 · 산출물 · 최근 변경(있는 칸만) - 목록 · 상세 숫자 · 썸네일 = 547-1 카드 요약(project_summaries) 한 벌 - 색 = 셸 변수(--b01-*) 이름에 맞춤 - 시험 test_548_project_overview — 칸반 나눔 · 산출물 · 내 것 · 단계 수 짝 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
};
|
||
}
|