/* ============================================================================= * B09_Estimation_UI_Tab_Execution.ts * 실행예산 탭 — STmate 「시간/단위당 중기실행단가 계산 및 입력」(wM_Boq_ExecX)을 본뜸 (PLAN 12장 · 랩탑 메인). * * - 본문 위 = 중기 실행단가 표 — 최초단가(노·재·경) · 1단위당 중기사용료 · 1단위 = 시간 · * 절사 · 차액 보정 → 실행단가. * - 본문 아래 = 설계 내역 줄 옆에 **실행수량** 칸 · 실행 단가 · 실행 금액. * - ⚠ 값은 서버(`/estimation/execution`)가 설계 단가표를 복사해 셈 — 설계·계약은 안 바뀜. * - ⚠ 실행예산 표본이 없어 「구조가 선다」까지만 확인된 화면 — 머리에 그 한계를 적음. * ========================================================================== */ 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]; } interface Money { material_krw: string; labor_krw: string; expense_krw: string; total_krw: string; } interface MachineEntry { unit_price_krw: string; hours_per_unit: string; cut_unit_krw: string; correction: string; } interface MachineRow { code: string; name: string; spec: string; design?: Money; execution?: Money; note?: string; } interface ExecutionRow { item_no: string; name: string; spec: string; unit: string; quantity: string | null; is_group: boolean; in_bill: boolean; amount_krw: string | null; execution_quantity?: string; execution_unit_price_krw?: string; execution_amount_krw?: string; execution_note?: string; } interface Settings { machines: Record; quantities: Record; } interface ExecutionDto { status: string; message?: string; rows: ExecutionRow[]; machines: MachineRow[]; totals: { design: Money; execution: Money }; settings: Settings; fields: { cut_units: string[]; corrections: { key: string; label: string }[] }; bill_missing_count: number; limit_note: string; } const STYLE_ID = "b09-execution-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09ex { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; } .b09ex__meta { font-size: 12px; color: var(--color-text-secondary); } .b09ex__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); } .b09ex__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; } .b09ex__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; } .b09ex__table th, .b09ex__table td { border: 1px solid var(--color-border); padding: 2px 6px; } .b09ex__table td.num { text-align: right; font-variant-numeric: tabular-nums; } .b09ex__table tr.is-group td { font-weight: 600; } .b09ex__table input { width: 7em; text-align: right; } .b09ex__table input.is-changed { font-weight: 600; } .b09ex__panel { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-variant-numeric: tabular-nums; } `; 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; } /** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). */ const drafts = new Map(); function draftOf(projectId: string, data: ExecutionDto): Settings { let draft = drafts.get(projectId); if (!draft) { draft = { machines: structuredClone(data.settings.machines), quantities: { ...data.settings.quantities }, }; drafts.set(projectId, draft); } return draft; } function endpoint(projectId: string): string { return `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/execution`; } async function fetchExecution(projectId: string): Promise { const response = await fetch(endpoint(projectId), { credentials: "include" }); const body = (await response.json()) as ExecutionDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } async function saveExecution(projectId: string, values: Settings): Promise { const response = await fetch(endpoint(projectId), { method: "PUT", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values), }); const body = (await response.json()) as { message?: string }; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); } function drawPanel(ctx: B09TabContext, data: ExecutionDto, reload: () => void): void { const projectId = ctx.projectId as string; const box = el("div", "b09ex__panel"); box.append( createButton({ label: "저장", onClick: async () => { try { await saveExecution(projectId, draftOf(projectId, data)); drafts.delete(projectId); showToast("실행예산 저장 — 설계·계약 내역은 그대로", "success"); reload(); } catch (error) { showToast(error instanceof Error ? error.message : "저장 못 함", "error"); } }, }), ); const t = data.totals; for (const [label, part] of [ ["재료비", "material"], ["노무비", "labor"], ["경비", "expense"], ["직접공사비", "total"], ] as const) { box.append( el( "div", "", `${label} ${won(t.design[`${part}_krw`])} → ${won(t.execution[`${part}_krw`])}`, ), ); } ctx.panel.append(box); } function input(value: string, onInput: (value: string) => void): HTMLInputElement { const node = el("input"); node.type = "number"; node.min = "0"; node.value = value; node.addEventListener("input", () => onInput(node.value)); return node; } function select( options: { key: string; label: string }[], value: string, onChange: (value: string) => void, ): HTMLSelectElement { const node = el("select"); for (const option of options) { const item = el("option", "", option.label); item.value = option.key; node.append(item); } node.value = value; node.addEventListener("change", () => onChange(node.value)); return node; } /** 중기 실행단가 표 — 사용료가 빈 중기는 설계 사용료 그대로. */ function drawMachines(data: ExecutionDto, draft: Settings): HTMLElement { const box = el("div"); box.append(el("strong", "", `시간/단위당 중기실행단가 — 중기 ${data.machines.length}종`)); const table = el("table", "b09ex__table"); const head = el("tr"); for (const label of [ "코드", "명칭", "규격", "최초 노무비", "최초 재료비", "최초 경비", "최초 계", "1단위당 중기사용료(원)", "1단위 = 시간", "절사", "차액 보정", "실행 노무비", "실행 재료비", "실행 경비", "실행 계", "비고", ]) { head.append(el("th", "", label)); } table.append(head); const cuts = data.fields.cut_units.map((unit) => ({ key: unit, label: `${won(unit)}원 미만절사`, })); for (const machine of data.machines) { const entry = (): MachineEntry => (draft.machines[machine.code] ??= { unit_price_krw: "", hours_per_unit: "", cut_unit_krw: "1", correction: "basic", }); const saved = draft.machines[machine.code]; const tr = el("tr"); const d = machine.design; const x = machine.execution; tr.append( el("td", "", machine.code), el("td", "", machine.name), el("td", "", machine.spec ?? ""), el("td", "num", won(d?.labor_krw)), el("td", "num", won(d?.material_krw)), el("td", "num", won(d?.expense_krw)), el("td", "num", won(d?.total_krw)), ); const cells: HTMLElement[] = [ input(saved?.unit_price_krw ?? "", (v) => (entry().unit_price_krw = v)), input(saved?.hours_per_unit ?? "", (v) => (entry().hours_per_unit = v)), select(cuts, saved?.cut_unit_krw ?? "1", (v) => (entry().cut_unit_krw = v)), select( data.fields.corrections, saved?.correction ?? "basic", (v) => (entry().correction = v), ), ]; for (const cell of cells) { const td = el("td"); td.append(cell); tr.append(td); } tr.append( el("td", "num", won(x?.labor_krw)), el("td", "num", won(x?.material_krw)), el("td", "num", won(x?.expense_krw)), el("td", "num", won(x?.total_krw)), el("td", "", machine.note ?? ""), ); table.append(tr); } box.append(table); return box; } function drawBill(data: ExecutionDto, draft: Settings): HTMLElement { const box = el("div"); box.append(el("strong", "", "실행예산 내역 — 최초수량 → 실행수량")); const table = el("table", "b09ex__table"); const head = el("tr"); for (const label of [ "공종번호", "명칭", "규격", "단위", "최초수량", "금액(설계)", "실행수량", "실행 단가", "실행 금액", "비고", ]) { head.append(el("th", "", label)); } table.append(head); 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", row.quantity ?? ""), el("td", "num", won(row.amount_krw)), ); const cell = el("td"); if (!row.is_group && row.in_bill && row.execution_quantity !== undefined) { const qty = input(draft.quantities[row.item_no] ?? row.execution_quantity, (v) => { if (v === "" || v === row.quantity) delete draft.quantities[row.item_no]; else draft.quantities[row.item_no] = v; }); qty.step = "any"; qty.classList.toggle("is-changed", row.item_no in draft.quantities); cell.append(qty); } tr.append( cell, el("td", "num", won(row.execution_unit_price_krw)), el("td", "num", won(row.execution_amount_krw)), el("td", "", row.execution_note ?? ""), ); table.append(tr); } box.append(table); return box; } function drawBody(ctx: B09TabContext, data: ExecutionDto): void { const draft = draftOf(ctx.projectId as string, data); const wrap = el("div", "b09ex"); wrap.append(el("div", "b09ex__warn", `⚠ ${data.limit_note}`)); if (data.bill_missing_count) { wrap.append( el( "div", "b09ex__warn", `⚠ 설계 내역 ${L("B09_Sheet_Missing")} ${data.bill_missing_count}${L("B09_Sheet_Count")} — 실행단가도 못 섬`, ), ); } wrap.append( el("div", "b09ex__meta", "1단위당 중기사용료를 비우면 설계 사용료 그대로 — [저장]하면 반영"), ); const scroll = el("div", "b09ex__scroll"); scroll.append(drawMachines(data, draft), drawBill(data, draft)); wrap.append(scroll); ctx.body.append(wrap); } function render(ctx: B09TabContext): void { injectStyles(); if (!ctx.projectId) { ctx.body.append(el("div", "b09ex__meta", "프로젝트를 고르세요")); return; } const load = (): void => { ctx.body.replaceChildren(el("div", "b09ex__meta", "실행예산 계산 중…")); ctx.panel.replaceChildren(); fetchExecution(ctx.projectId as string) .then((data) => { ctx.body.replaceChildren(); drawPanel(ctx, data, load); drawBody(ctx, data); }) .catch((error: unknown) => { ctx.body.replaceChildren( el( "div", "b09ex__warn", `실행예산을 세우지 못함 — ${error instanceof Error ? error.message : ""}`, ), ); }); }; load(); } export const executionTab: B09Tab = { key: "execution", label: () => L("B09_Estimation_Tab_Execution"), render, };