/* ============================================================================= * B09_Estimation_UI_Tab_Completion.ts * 준공 탭 — STmate 「준공조서(을/병)」의 「계약금액 | 준공금액」 두 열을 본뜸 (PLAN 12장 · 랩탑 메인). * * - 위 = 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). * - 아래 = 공종별 계약금액 | 준공금액. * - ⚠ 준공 별도 계산 규칙은 미확인 — 서버(`/estimation/completion`)가 기성 마지막 회차 누계를 * 옮기기만 함. 입력 칸·[저장] 없음(기성 탭에서 고침). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { API_BASE_URL } from "@config/config_frontend"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } interface CompletionRow { item_no: string; name: string; spec: string | null; unit: string | null; is_group: boolean; contract_amount_krw: string | null; completion_amount_krw: string | null; } interface CompletionLine { key: string; name: string; total: boolean; contract_krw: string; completion_krw: string | null; completion_pct: string | null; } interface CompletionDto { status: string; message?: string; rows: CompletionRow[]; lines: CompletionLine[]; from_round: number; notes: string[]; limit_note: string; } const STYLE_ID = "b09-completion-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09cp { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09cp__meta { font-size: 12px; color: var(--color-text-secondary); } .b09cp__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09cp__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } .b09cp__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } .b09cp__table th, .b09cp__table td { border: 1px solid var(--color-border); padding: 2px 6px; } .b09cp__table td.num { text-align: right; font-variant-numeric: tabular-nums; } .b09cp__table tr.is-total td, .b09cp__table tr.is-group td { font-weight: 600; } `; document.head.append(style); } function el( tag: K, className = "", text = "", ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; } function won(value: string | null | undefined): string { if (value === null || value === undefined || value === "") return ""; const n = Number(value); return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; } async function fetchCompletion(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/completion`, { credentials: "include" }, ); const body = (await response.json()) as CompletionDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } function table(headers: string[]): HTMLTableElement { const node = el("table", "b09cp__table"); const head = el("tr"); for (const label of headers) head.append(el("th", "", label)); node.append(head); return node; } function draw(ctx: B09TabContext, data: CompletionDto): void { const wrap = el("div", "b09cp"); wrap.append(el("div", "b09cp__warn", `⚠ ${data.limit_note}`)); for (const note of data.notes) wrap.append(el("div", "b09cp__warn", `⚠ ${note}`)); wrap.append( el( "div", "b09cp__meta", data.from_round ? `준공금액 = 기성 ${data.from_round}회(마지막 회차) 누계 — 고칠 곳은 기성 탭` : "기성 탭에서 회차를 넣으면 그 누계가 준공금액으로 옮겨짐", ), ); const scroll = el("div", "b09cp__scroll"); const summary = table(["명칭", "계약금액", "준공금액", "준공(%)"]); for (const line of data.lines) { const tr = el("tr", line.total ? "is-total" : ""); tr.append( el("td", "", line.name), el("td", "num", won(line.contract_krw)), el("td", "num", won(line.completion_krw)), el("td", "num", line.completion_pct ? `${line.completion_pct}%` : ""), ); summary.append(tr); } const bill = table(["공종번호", "명칭", "규격", "단위", "계약금액", "준공금액"]); for (const row of data.rows) { const tr = el("tr", row.is_group ? "is-group" : ""); tr.append( el("td", "", row.item_no), el("td", "", row.name), el("td", "", row.spec ?? ""), el("td", "", row.unit ?? ""), el("td", "num", won(row.contract_amount_krw)), el("td", "num", won(row.completion_amount_krw)), ); bill.append(tr); } const top = el("div"); top.append(el("strong", "", "준공조서 — 계약금액 | 준공금액"), summary); const bottom = el("div"); bottom.append(el("strong", "", "공종별"), bill); scroll.append(top, bottom); wrap.append(scroll); ctx.body.append(wrap); } function render(ctx: B09TabContext): void { injectStyles(); if (!ctx.projectId) { ctx.body.append(el("div", "b09cp__meta", "프로젝트를 고르세요")); return; } ctx.body.replaceChildren( el("div", "b09cp__meta", `${L("B09_Estimation_Tab_Completion")} 계산 중…`), ); fetchCompletion(ctx.projectId) .then((data) => { ctx.body.replaceChildren(); draw(ctx, data); }) .catch((error: unknown) => { ctx.body.replaceChildren( el( "div", "b09cp__warn", `준공을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); }); } export const completionTab: B09Tab = { key: "completion", label: () => L("B09_Estimation_Tab_Completion"), render, };