/* ============================================================================= * B09_Estimation_UI_Tab_Progress.ts * 기성 탭 — STmate 「기성 제잡비 계산서」(wM_KanJub_K)와 기성내역서(계약|전회|금회|누계)를 본뜸 (PLAN 12장 · 랩탑 메인). * * - 좌측 = 회차 고르기 · [회차 추가] · 부가세 방식 넷(직접입력이면 금액) · [저장]. * - 본문 위 = 기성 제잡비 계산서 — 도급액 · 계약잡비율 · 전회/금회/누계 금액과 율. * - 본문 아래 = 기성내역 — 계약 · 전회 · **금회 기성수량 칸** · 누계 · 기성(%) · 잔량. * - ⚠ 값은 서버(`/estimation/progress`)가 계약내역·계약 원가계산서에서 파생 — 계약은 안 바뀜. * - ⚠ 기성 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showToast } from "@ui/ui_template_elements"; 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]; } type Column = "previous" | "current" | "cumulative"; const COLUMNS: [Column, string, string][] = [ ["previous", "전회금액", "전율"], ["current", "금회금액", "금율"], ["cumulative", "누계금액", "누율"], ]; interface Round { quantities: Record; vat_mode: string; vat_manual_krw: string; supply_cut_krw: string; total_cut_krw: string; profit_manual_krw: string; /** 사정 — 뜻 미확인이라 칸만(계산에 안 씀). */ assessed: Record; } type Override = { rate_pct: string; reason: string }; type Overrides = Record; type Choice = { key: string; label: string }; interface AmountRow { key: string; name: string; contract_krw: string; contract_ratio_pct?: string | null; default_ratio_pct?: string; override?: Override | null; [column: string]: string | null | undefined | Override; } interface ProgressRow { item_no: string; name: string; spec: string; unit: string; quantity: string | null; is_group: boolean; in_bill: boolean; contract_amount_krw?: string; progress_pct?: string | null; progress_remaining_quantity?: string; progress_note?: string; [column: string]: string | boolean | null | undefined; } interface ProgressDto { status: string; message?: string; rows: ProgressRow[]; items: AmountRow[]; summary: AmountRow[]; contract_overhead_ratio_pct: string | null; round: number; round_count: number; notes: string[]; settings: { rounds: Round[]; rate_overrides: Overrides }; fields: { vat: Choice[]; supply_cut: Choice[]; total_cut: Choice[] }; limit_note: string; } const STYLE_ID = "b09-progress-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09pg { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09pg__meta { font-size: 12px; color: var(--color-text-secondary); } .b09pg__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09pg__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } .b09pg__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } .b09pg__table th, .b09pg__table td { border: 1px solid var(--color-border); padding: 2px 6px; } .b09pg__table td.num { text-align: right; font-variant-numeric: tabular-nums; } .b09pg__table tr.is-group td, .b09pg__table tr.is-total td { font-weight: 600; } .b09pg__table input { width: 7em; text-align: right; } .b09pg__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } .b09pg__panel label { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } `; 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 | boolean | null | undefined): string { if (value === null || value === undefined || value === "" || typeof value === "boolean") return ""; const n = Number(value); return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value; } function pct(value: string | boolean | null | undefined): string { return typeof value === "string" ? `${value}%` : ""; } /** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */ const drafts = new Map(); function draftOf(projectId: string, data: ProgressDto) { let draft = drafts.get(projectId); if (!draft) { draft = { rounds: structuredClone(data.settings.rounds), overrides: structuredClone(data.settings.rate_overrides), round: data.round, }; drafts.set(projectId, draft); } return draft; } function endpoint(projectId: string): string { return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/progress`; } async function fetchProgress(projectId: string, round: number | null): Promise { const query = round ? `?round=${round}` : ""; const response = await fetch(`${endpoint(projectId)}${query}`, { credentials: "include" }); const body = (await response.json()) as ProgressDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } async function saveProgress( projectId: string, rounds: Round[], overrides: Overrides, ): Promise { const response = await fetch(endpoint(projectId), { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rounds, rate_overrides: overrides }), }); const body = (await response.json()) as { message?: string }; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); } function choiceSelect(choices: Choice[], value: string, onChange: (v: string) => void) { const node = el("select"); for (const choice of choices) { const option = el("option", "", choice.label); option.value = choice.key; node.append(option); } node.value = value; node.addEventListener("change", () => onChange(node.value)); return node; } /** 공급가액 절사 · 총공사비 절사(9택) · 이윤금액 직접입력 — 떨어지는 몫은 이윤에서. */ function drawCuts(data: ProgressDto, current: Round): HTMLElement[] { const supply = el("label"); supply.append( el("span", "", "공급가액 절사"), choiceSelect( data.fields.supply_cut, current.supply_cut_krw, (v) => (current.supply_cut_krw = v), ), ); const total = el("label"); const profit = el("input"); profit.type = "number"; profit.min = "0"; profit.placeholder = "이윤금액(비우면 계약잡비율 값)"; profit.value = current.profit_manual_krw; profit.hidden = current.total_cut_krw !== ""; profit.addEventListener("input", () => (current.profit_manual_krw = profit.value)); total.append( el("span", "", "총공사비 절사"), choiceSelect(data.fields.total_cut, current.total_cut_krw, (v) => { current.total_cut_krw = v; profit.hidden = v !== ""; }), profit, ); return [supply, total]; } function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number) => void): void { const projectId = ctx.projectId as string; const draft = draftOf(projectId, data); const box = el("div", "b09pg__panel"); const save = async (round: number): Promise => { try { await saveProgress(projectId, draft.rounds, draft.overrides); drafts.delete(projectId); showToast("기성 회차 저장 — 계약 내역은 그대로", "success"); reload(round); } catch (error) { showToast(error instanceof Error ? error.message : "저장 못 함", "error"); } }; const pick = el("label"); pick.append(el("span", "", "회차")); const select = el("select"); for (let n = 1; n <= draft.rounds.length; n += 1) { const option = el("option", "", `${n}회`); option.value = String(n); select.append(option); } select.value = String(data.round); select.addEventListener("change", () => reload(Number(select.value))); pick.append(select); box.append(pick); box.append( createButton({ label: "회차 추가(저장)", variant: "ghost", onClick: () => { const last = draft.rounds[draft.rounds.length - 1]; draft.rounds.push({ quantities: {}, vat_mode: last?.vat_mode ?? "supply", vat_manual_krw: "", supply_cut_krw: last?.supply_cut_krw ?? "", total_cut_krw: last?.total_cut_krw ?? "", profit_manual_krw: "", assessed: {}, }); void save(draft.rounds.length); }, }), ); if (draft.rounds.length) { box.append( createButton({ label: `${draft.rounds.length}회 지우기(저장)`, variant: "ghost", onClick: () => { // 앞 회차를 지우면 뒤 회차의 전회가 통째로 바뀜 — 마지막 회차만 지움. draft.rounds.pop(); void save(draft.rounds.length); }, }), ); } const current = draft.rounds[data.round - 1]; if (current) { const vat = el("label"); vat.append(el("span", "", "부가세")); const mode = el("select"); for (const choice of data.fields.vat) { const option = el("option", "", choice.label); option.value = choice.key; mode.append(option); } mode.value = current.vat_mode; const manual = el("input"); manual.type = "number"; manual.min = "0"; manual.placeholder = "직접입력 금액"; manual.value = current.vat_manual_krw; manual.hidden = current.vat_mode !== "manual"; mode.addEventListener("change", () => { current.vat_mode = mode.value; manual.hidden = mode.value !== "manual"; }); manual.addEventListener("input", () => (current.vat_manual_krw = manual.value)); vat.append(mode, manual); box.append(vat, ...drawCuts(data, current)); box.append(createButton({ label: "저장", onClick: () => save(data.round) })); } else { box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터")); } ctx.panel.append(box); } /** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */ function drawJab(data: ProgressDto, overrides: Overrides): HTMLElement { const box = el("div"); box.append( el( "strong", "", `기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`, ), el( "div", "b09pg__meta", "계약잡비율 기본 = 역산(도급액 ÷ 계약 직접공사비) · 계약서 값이 다르면 직접입력 + 사유 → [저장] · ↺ 로 역산값", ), ); const table = el("table", "b09pg__table"); const head = el("tr"); for (const label of [ "명칭", "도급액", "계약잡비율", "직접입력(%)", "사유", "", ...COLUMNS.flatMap(([, a, r]) => [a, r]), ]) { head.append(el("th", "", label)); } table.append(head); const line = (row: AmountRow, total: boolean): void => { const tr = el("tr", total ? "is-total" : ""); const ratio = el("td", "num", pct(row.contract_ratio_pct)); if (row.override) ratio.title = `역산값 ${row.default_ratio_pct}% — 직접입력이 이김`; tr.append(el("td", "", row.name), el("td", "num", won(row.contract_krw)), ratio); const cells = [el("td"), el("td"), el("td")]; if (!total) { const entry = (): Override => (overrides[row.key] ??= { rate_pct: "", reason: "" }); const rate = el("input"); rate.type = "number"; rate.min = "0"; rate.step = "any"; rate.placeholder = row.default_ratio_pct ?? ""; rate.value = overrides[row.key]?.rate_pct ?? ""; rate.addEventListener("input", () => (entry().rate_pct = rate.value)); const reason = el("input"); reason.placeholder = "사유(계약서 등)"; reason.style.width = "12em"; reason.style.textAlign = "left"; reason.value = overrides[row.key]?.reason ?? ""; reason.addEventListener("input", () => (entry().reason = reason.value)); const revert = el("button", "", "↺"); revert.type = "button"; revert.title = "역산값으로 되돌림 — [저장]하면 반영"; revert.disabled = !overrides[row.key]; revert.addEventListener("click", () => { delete overrides[row.key]; rate.value = ""; reason.value = ""; }); cells[0].append(rate); cells[1].append(reason); cells[2].append(revert); } tr.append(...cells); for (const [col] of COLUMNS) { const text = (key: string): string | null => { const value = row[key]; return typeof value === "string" ? value : null; }; tr.append(el("td", "num", won(text(`${col}_krw`))), el("td", "num", pct(text(`${col}_pct`)))); } table.append(tr); }; for (const row of data.items) line(row, false); for (const row of data.summary) line(row, true); box.append(table); return box; } function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement { const box = el("div"); box.append(el("strong", "", `기성내역 — ${data.round ? `${data.round}회` : "회차 없음"}`)); const table = el("table", "b09pg__table"); const head = el("tr"); for (const label of [ "공종번호", "명칭", "규격", "단위", "계약수량", "계약금액", "전회수량", "전회금액", "금회수량", "금회금액", "사정(칸만)", "누계수량", "누계금액", "기성(%)", "잔량", "비고", ]) { head.append(el("th", "", label)); } table.append(head); const current = rounds[data.round - 1]; for (const row of data.rows) { const tr = el("tr", row.is_group ? "is-group" : ""); const leaf = !row.is_group && row.in_bill && row.progress_current_quantity !== undefined; tr.append( el("td", "", row.item_no), el("td", "", row.name), el("td", "", row.spec ?? ""), el("td", "", row.unit ?? ""), el("td", "num", leaf ? (row.quantity ?? "") : ""), el("td", "num", won(row.contract_amount_krw)), el("td", "num", leaf ? String(row.progress_previous_quantity) : ""), el("td", "num", won(row.progress_previous_amount_krw)), ); const cell = el("td"); if (leaf && current) { const qty = el("input"); qty.type = "number"; qty.min = "0"; qty.step = "any"; qty.value = current.quantities[row.item_no] ?? ""; qty.addEventListener("input", () => { if (qty.value === "") delete current.quantities[row.item_no]; else current.quantities[row.item_no] = qty.value; }); cell.append(qty); } // 사정 — 뜻 미확인(「기성내역서(사정)」 열 이름뿐) · 계산에 안 씀. const assessed = el("td"); if (leaf && current) { const amount = el("input"); amount.type = "number"; amount.min = "0"; amount.title = "사정 — 뜻 확인 대기 · 금액에 안 섞임"; amount.value = current.assessed[row.item_no] ?? ""; amount.addEventListener("input", () => { if (amount.value === "") delete current.assessed[row.item_no]; else current.assessed[row.item_no] = amount.value; }); assessed.append(amount); } tr.append( cell, el("td", "num", won(row.progress_current_amount_krw)), assessed, el("td", "num", leaf ? String(row.progress_cumulative_quantity) : ""), el("td", "num", won(row.progress_cumulative_amount_krw)), el("td", "num", pct(row.progress_pct)), el("td", "num", row.progress_remaining_quantity ?? ""), el("td", "", row.progress_note ?? ""), ); table.append(tr); } box.append(table); return box; } function drawBody(ctx: B09TabContext, data: ProgressDto): void { const draft = draftOf(ctx.projectId as string, data); const wrap = el("div", "b09pg"); wrap.append(el("div", "b09pg__warn", `⚠ ${data.limit_note}`)); for (const note of data.notes) wrap.append(el("div", "b09pg__warn", `⚠ ${note}`)); wrap.append(el("div", "b09pg__meta", "금회 기성수량을 넣고 [저장]하면 반영")); const scroll = el("div", "b09pg__scroll"); scroll.append(drawJab(data, draft.overrides), drawBill(data, draft.rounds)); wrap.append(scroll); ctx.body.append(wrap); } function render(ctx: B09TabContext): void { injectStyles(); if (!ctx.projectId) { ctx.body.append(el("div", "b09pg__meta", "프로젝트를 고르세요")); return; } const load = (round: number | null): void => { ctx.body.replaceChildren( el("div", "b09pg__meta", `${L("B09_Estimation_Tab_Progress")} 계산 중…`), ); ctx.panel.replaceChildren(); fetchProgress(ctx.projectId as string, round) .then((data) => { const draft = drafts.get(ctx.projectId as string); if (draft) draft.round = data.round; ctx.body.replaceChildren(); drawPanel(ctx, data, load); drawBody(ctx, data); }) .catch((error: unknown) => { ctx.body.replaceChildren( el( "div", "b09pg__warn", `기성을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); }); }; load(drafts.get(ctx.projectId)?.round ?? null); } export const progressTab: B09Tab = { key: "progress", label: () => L("B09_Estimation_Tab_Progress"), render, };