/* ============================================================================= * B09_Estimation_UI_Page.ts * 로그인 후 09: 6차 워크플로우 (원가계산) * * 화면 규칙 (PLAN 8-13 · 화면 기획) * - 3단 레이아웃: 상단 타이틀·스텝바 / 좌측 고정폭 입력 / 우측 탭 + 표. * - 원가계산서 줄은 **「비목 · 금액 · 요율 · 산출근거」 네 칸**을 다 보인다. * 결과 숫자만 보이면 설계자가 검산을 못 한다. * - **안전관리비는 A·B 두 줄을 나란히 두고 채택한 쪽을 표시**한다 * (실무 `안전관리비검토` 시트와 같은 서식, PLAN 8-12). * - **어느 판 요율로 계산했는지**를 좌측에 남긴다 — 재현성(PLAN 9-2). * - 이윤 조정액은 **설계자가 직접 넣을 때만** 반영. 목표 도급액을 넣으면 필요액을 * 보여만 준다 (★법대로 PLAN 8-10). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } /* ----------------------------------------------------------------------------- * 타입 — 라우터 응답과 1:1 * -------------------------------------------------------------------------- */ interface CostLineDto { key: string; name: string; base_label: string; base_amount_krw: string; rate_percent: string | null; flat_amount_krw: string; amount_krw: string; formula_text: string; note: string; } interface CostSheetDto { status: string; direct_cost_source: "manual" | "quantities"; missing_unit_prices: string[]; lines: CostLineDto[]; totals: Record; rate_version: { dataset_id: string; effective_date: string; sha256: string }; notes: string[]; suggested_profit_adjustment_krw?: string; } interface UnitPriceRow { code: string; name: string; spec: string; unit: string; material: string; labor: string; expense: string; total: string; } interface UnitPriceListDto { status: string; summary: { titles: number; unit_prices: number; machine_hourly: number; notes: string[]; }; rows: UnitPriceRow[]; } interface UnitPriceDetailRow extends UnitPriceRow { ref_code: string; source_label: string; source_index: number; drillable: boolean; quantity: string; unit_total: string; note: string; } interface UnitPriceDetailDto { status: string; precise_total: string; code: string; name: string; spec: string; unit: string; material: string; labor: string; expense: string; total: string; sum_matches: boolean; rows: UnitPriceDetailRow[]; } /** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ interface CostFormState { direct_material_krw: string; direct_labor_krw: string; direct_expense_krw: string; duration_days: string; owner_supplied_material_krw: string; procurement_fee_krw: string; profit_adjustment_krw: string; target_contract_amount_krw: string; /** 「공종코드=수량」 한 줄씩. 비어 있으면 위 직접비 3칸을 그대로 쓴다. */ quantities_text: string; } const INITIAL_FORM: CostFormState = { direct_material_krw: "0", direct_labor_krw: "0", direct_expense_krw: "0", duration_days: "183", owner_supplied_material_krw: "0", procurement_fee_krw: "0", profit_adjustment_krw: "0", target_contract_amount_krw: "", quantities_text: "", }; /** 총계 성격의 줄 — 표에서 굵게 띄운다. */ const TOTAL_KEYS = new Set([ "material_cost", "labor_cost", "expense", "net_construction_cost", "total_cost", "contract_amount", "grand_total", ]); /* ----------------------------------------------------------------------------- * 스타일 — 공통 토큰만 사용 (frontend.md §1 하드코딩 금지) * -------------------------------------------------------------------------- */ const STYLE_ID = "b09-estimation-styles"; function injectStyles(): void { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .b09-panel { display: flex; flex-direction: column; gap: var(--space-md, 12px); } .b09-panel__group { display: flex; flex-direction: column; gap: var(--space-xs, 4px); } .b09-panel__legend { font-size: var(--font-size-xs, 12px); letter-spacing: .06em; color: var(--color-text-secondary); text-transform: uppercase; } .b09-panel__readonly { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); display: flex; justify-content: space-between; gap: var(--space-sm, 8px); border-bottom: 1px solid var(--color-border); padding: 2px 0; } .b09-panel__actions { display: flex; gap: var(--space-xs, 4px); margin-top: var(--space-sm, 8px); } .b09-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } .b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; } .b09-tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--color-border); padding-bottom: 6px; } .b09-tab { font-size: var(--font-size-xs, 12px); padding: 2px 8px; cursor: pointer; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); } .b09-tab.is-active { border-color: var(--color-primary); color: var(--color-primary); background: var(--color-surface); } .b09-tab:disabled { cursor: not-allowed; opacity: .55; } .b09-sheet { overflow: auto; min-height: 0; flex: 1; } .b09-sheet table { width: 100%; border-collapse: collapse; font-size: var(--font-size-sm, 13px); } .b09-sheet th, .b09-sheet td { border-bottom: 1px solid var(--color-border); padding: 4px 8px; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } .b09-sheet th { text-align: center; color: var(--color-text-secondary); font-weight: 600; } .b09-sheet td.b09-left, .b09-sheet th.b09-left { text-align: left; white-space: normal; } .b09-sheet tr.is-total td { font-weight: 600; background: var(--color-surface); } .b09-sheet tr.is-adopted td { background: var(--color-surface); } .b09-sheet tr.is-dropped td { color: var(--color-text-secondary); text-decoration: line-through; } .b09-qty { min-height: 64px; font-family: monospace; font-size: var(--font-size-xs, 12px); } .b09-clickable { cursor: pointer; } .b09-clickable:hover td { background: var(--color-surface); } .b09-up-list { max-height: 45%; } .b09-up-detail { border-top: 2px solid var(--color-border); padding-top: 6px; } .b09-empty { padding: var(--space-lg, 16px); color: var(--color-text-secondary); font-size: var(--font-size-sm, 13px); } `; document.head.append(style); } /* ----------------------------------------------------------------------------- * 표 그리기 * -------------------------------------------------------------------------- */ function formatWon(value: string): string { const n = Number(value); if (!Number.isFinite(n)) return value; return n.toLocaleString("ko-KR"); } function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b09-sheet"; const table = document.createElement("table"); const thead = document.createElement("thead"); const headRow = document.createElement("tr"); const headers: Array<[string, boolean]> = [ [L("B09_Estimation_Col_Item"), true], [L("B09_Estimation_Col_Amount"), false], [L("B09_Estimation_Col_Rate"), false], [L("B09_Estimation_Col_Basis"), true], [L("B09_Estimation_Col_Note"), true], ]; for (const [text, left] of headers) { const th = document.createElement("th"); th.textContent = text; if (left) th.className = "b09-left"; headRow.append(th); } thead.append(headRow); table.append(thead); const tbody = document.createElement("tbody"); for (const line of sheet.lines) { const tr = document.createElement("tr"); if (TOTAL_KEYS.has(line.key)) tr.classList.add("is-total"); if (line.note === L("B09_Estimation_Adopted")) tr.classList.add("is-adopted"); if (line.note === L("B09_Estimation_NotAdopted")) tr.classList.add("is-dropped"); const name = document.createElement("td"); name.className = "b09-left"; name.textContent = line.name; const amount = document.createElement("td"); amount.textContent = formatWon(line.amount_krw); const rate = document.createElement("td"); rate.textContent = line.rate_percent === null ? "" : `${line.rate_percent}%`; const basis = document.createElement("td"); basis.className = "b09-left"; basis.textContent = line.formula_text; const note = document.createElement("td"); note.className = "b09-left"; note.textContent = line.note; tr.append(name, amount, rate, basis, note); tbody.append(tr); } table.append(tbody); wrap.append(table); return wrap; } /** 일위대가 **목록표** — 「무엇이 있나」. 고르면 아래에 본표가 뜬다(9-3 제목+상세). */ function buildUnitPriceList( list: UnitPriceListDto, selected: string | null, onPick: (code: string) => void, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b09-sheet b09-up-list"; const caption = document.createElement("div"); caption.className = "b09-hint"; caption.textContent = `${L("B09_Estimation_UP_List")} · ${list.summary.unit_prices}`; wrap.append(caption); const table = document.createElement("table"); const head = document.createElement("tr"); for (const [key, left] of [ ["B09_Estimation_Col_Name", true], ["B09_Estimation_Col_Unit", true], ["B09_Estimation_Col_Material", false], ["B09_Estimation_Col_Labor", false], ["B09_Estimation_Col_Expense", false], ["B09_Estimation_Col_Total", false], ] as Array<[keyof typeof ui_locales, boolean]>) { const th = document.createElement("th"); th.textContent = L(key); if (left) th.className = "b09-left"; head.append(th); } const thead = document.createElement("thead"); thead.append(head); table.append(thead); const body = document.createElement("tbody"); for (const row of list.rows) { const tr = document.createElement("tr"); tr.className = "b09-clickable"; if (row.code === selected) tr.classList.add("is-adopted"); tr.addEventListener("click", () => onPick(row.code)); const name = document.createElement("td"); name.className = "b09-left"; name.textContent = row.name; const unit = document.createElement("td"); unit.className = "b09-left"; unit.textContent = row.unit; tr.append(name, unit); for (const value of [row.material, row.labor, row.expense, row.total]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); tr.append(cell); } body.append(tr); } table.append(body); wrap.append(table); return wrap; } /** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */ function buildUnitPriceDetail( detail: UnitPriceDetailDto, onDrill: (code: string) => void, ): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b09-sheet b09-up-detail"; const caption = document.createElement("div"); caption.className = "b09-hint"; caption.textContent = `${L("B09_Estimation_UP_Detail")} · ${detail.name}` + (detail.spec ? ` (${detail.spec})` : "") + ` · ${formatWon(detail.total)}` + ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; wrap.append(caption); // 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.** // 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다. if (detail.precise_total !== detail.total) { const gap = document.createElement("div"); gap.className = "b09-hint"; gap.textContent = `${L("B09_Estimation_UP_RoundGap")} ${formatWon(detail.precise_total)}`; wrap.append(gap); } const table = document.createElement("table"); const head = document.createElement("tr"); for (const [key, left] of [ ["B09_Estimation_Col_Name", true], ["B09_Estimation_Col_Spec", true], ["B09_Estimation_Col_Source", true], ["B09_Estimation_Col_Unit", true], ["B09_Estimation_Col_Qty", false], ["B09_Estimation_Col_Material", false], ["B09_Estimation_Col_Labor", false], ["B09_Estimation_Col_Expense", false], ["B09_Estimation_Col_Total", false], ] as Array<[keyof typeof ui_locales, boolean]>) { const th = document.createElement("th"); th.textContent = L(key); if (left) th.className = "b09-left"; head.append(th); } const thead = document.createElement("thead"); thead.append(head); table.append(thead); const body = document.createElement("tbody"); for (const row of detail.rows) { const tr = document.createElement("tr"); if (row.drillable) { tr.className = "b09-clickable"; tr.title = L("B09_Estimation_UP_Drill"); tr.addEventListener("click", () => onDrill(row.ref_code)); } const name = document.createElement("td"); name.className = "b09-left"; name.textContent = row.drillable ? `▸ ${row.name}` : row.name; const spec = document.createElement("td"); spec.className = "b09-left"; spec.textContent = row.spec; const source = document.createElement("td"); source.className = "b09-left"; source.textContent = `${row.source_label} (${row.source_index})`; const unit = document.createElement("td"); unit.className = "b09-left"; unit.textContent = row.unit; tr.append(name, spec, source, unit); for (const value of [row.quantity, row.material, row.labor, row.expense, row.total]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); tr.append(cell); } body.append(tr); } const sum = document.createElement("tr"); sum.className = "is-total"; const label = document.createElement("td"); label.className = "b09-left"; label.colSpan = 5; label.textContent = L("B09_Estimation_Col_Total"); sum.append(label); for (const value of [detail.material, detail.labor, detail.expense, detail.total]) { const cell = document.createElement("td"); cell.textContent = formatWon(value); sum.append(cell); } body.append(sum); table.append(body); wrap.append(table); return wrap; } /* ----------------------------------------------------------------------------- * 좌측 패널 * -------------------------------------------------------------------------- */ interface PanelHandles { root: HTMLElement; rateVersionBox: HTMLElement; hintBox: HTMLElement; } function buildSidePanel( form: CostFormState, onRecalc: () => void, onConfirm: () => void, ): PanelHandles { const root = document.createElement("div"); root.className = "b09-panel"; const addGroup = ( legendKey: keyof typeof ui_locales, fields: Array<[keyof CostFormState, keyof typeof ui_locales]>, ): void => { const group = document.createElement("div"); group.className = "b09-panel__group"; const legend = document.createElement("span"); legend.className = "b09-panel__legend"; legend.textContent = L(legendKey); group.append(legend); for (const [field, labelKey] of fields) { const handle = createInputField({ label: L(labelKey), type: "number", min: 0, value: form[field], onInput: (value) => { form[field] = value; }, }); group.append(handle.root); } root.append(group); }; addGroup("B09_Estimation_Group_Condition", [ ["direct_material_krw", "B09_Estimation_Field_DirectMaterial"], ["direct_labor_krw", "B09_Estimation_Field_DirectLabor"], ["direct_expense_krw", "B09_Estimation_Field_DirectExpense"], ["duration_days", "B09_Estimation_Field_Duration"], ]); // 요율 판 — 읽기 전용. 「어느 판으로 계산했나」가 화면에 남아야 재현성이 선다. const rateGroup = document.createElement("div"); rateGroup.className = "b09-panel__group"; const rateLegend = document.createElement("span"); rateLegend.className = "b09-panel__legend"; rateLegend.textContent = L("B09_Estimation_Group_RateVersion"); const rateVersionBox = document.createElement("div"); rateGroup.append(rateLegend, rateVersionBox); root.append(rateGroup); addGroup("B09_Estimation_Group_Supplied", [ ["owner_supplied_material_krw", "B09_Estimation_Field_OwnerMaterial"], ["procurement_fee_krw", "B09_Estimation_Field_ProcurementFee"], ]); addGroup("B09_Estimation_Group_Profit", [ ["profit_adjustment_krw", "B09_Estimation_Field_ProfitAdjust"], ["target_contract_amount_krw", "B09_Estimation_Field_TargetContract"], ]); // 수량 — 여러 줄이라 텍스트 영역으로. 비어 있으면 위 직접비 3칸을 그대로 쓴다. const quantityGroup = document.createElement("div"); quantityGroup.className = "b09-panel__group"; const quantityLegend = document.createElement("span"); quantityLegend.className = "b09-panel__legend"; quantityLegend.textContent = L("B09_Estimation_Group_Quantity"); const quantityLabel = document.createElement("label"); quantityLabel.className = "ui-field__label"; quantityLabel.textContent = L("B09_Estimation_Field_Quantities"); const quantityInput = document.createElement("textarea"); quantityInput.className = "ui-input b09-qty"; quantityInput.rows = 4; quantityInput.placeholder = "FP-09-21=500"; quantityInput.addEventListener("input", () => { form.quantities_text = quantityInput.value; }); quantityGroup.append(quantityLegend, quantityLabel, quantityInput); root.append(quantityGroup); const hintBox = document.createElement("div"); hintBox.className = "b09-hint"; root.append(hintBox); const actions = document.createElement("div"); actions.className = "b09-panel__actions"; actions.append( createButton({ label: L("B09_Estimation_Btn_Recalc"), variant: "filled", onClick: onRecalc, }), createButton({ label: L("B09_Estimation_Btn_Confirm"), onClick: onConfirm, }), ); root.append(actions); return { root, rateVersionBox, hintBox }; } function renderRateVersion(box: HTMLElement, sheet: CostSheetDto | null): void { box.replaceChildren(); if (!sheet) return; const rows: Array<[string, string]> = [ ["적용일", sheet.rate_version.effective_date || "—"], ["지문", sheet.rate_version.sha256 ? `${sheet.rate_version.sha256.slice(0, 8)}…` : "—"], ]; for (const [label, value] of rows) { const row = document.createElement("div"); row.className = "b09-panel__readonly"; const left = document.createElement("span"); left.textContent = label; const right = document.createElement("span"); right.textContent = value; row.append(left, right); box.append(row); } } /* ----------------------------------------------------------------------------- * 탭 * -------------------------------------------------------------------------- */ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [ ["cost_sheet", "B09_Estimation_Tab_CostSheet", true], ["boq", "B09_Estimation_Tab_Boq", true], ["unit_price", "B09_Estimation_Tab_UnitPrice", true], ["price_basis", "B09_Estimation_Tab_PriceBasis", true], ["machine", "B09_Estimation_Tab_Machine", false], ["duration", "B09_Estimation_Tab_Duration", false], ["supply", "B09_Estimation_Tab_Supply", true], ["base_data", "B09_Estimation_Tab_BaseData", false], ]; function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement { const bar = document.createElement("div"); bar.className = "b09-tabs"; for (const [key, labelKey, enabled] of TAB_KEYS) { const button = document.createElement("button"); button.type = "button"; button.className = "b09-tab"; button.dataset.tab = key; button.textContent = L(labelKey); button.disabled = !enabled; if (!enabled) button.title = L("B09_Estimation_Tab_Pending"); if (key === active) button.classList.add("is-active"); button.addEventListener("click", () => onSelect(key)); bar.append(button); } return bar; } /* ----------------------------------------------------------------------------- * API * -------------------------------------------------------------------------- */ /** 「공종코드=수량」 여러 줄을 객체로. 형식이 아닌 줄은 조용히 버리지 않고 건너뛴다. */ function parseQuantities(text: string): Record { const out: Record = {}; for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed) continue; const [code, value] = trimmed.split(/[=\t,]/); if (!code || !value) continue; const qty = value.trim(); if (!/^\d+(\.\d+)?$/.test(qty)) continue; out[code.trim()] = qty; } return out; } function toRequestBody(form: CostFormState): Record { const num = (value: string): string => (value.trim() === "" ? "0" : value.trim()); const body: Record = { direct_material_krw: num(form.direct_material_krw), direct_labor_krw: num(form.direct_labor_krw), direct_expense_krw: num(form.direct_expense_krw), duration_days: Number(num(form.duration_days)), owner_supplied_material_krw: num(form.owner_supplied_material_krw), procurement_fee_krw: num(form.procurement_fee_krw), profit_adjustment_krw: num(form.profit_adjustment_krw), }; if (form.target_contract_amount_krw.trim() !== "") { body.target_contract_amount_krw = form.target_contract_amount_krw.trim(); } const quantities = parseQuantities(form.quantities_text); if (Object.keys(quantities).length > 0) body.quantities = quantities; return body; } async function fetchCostSheet(projectId: string, form: CostFormState): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/cost`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(toRequestBody(form)), }, ); if (!response.ok) throw new Error(`estimation cost failed: ${response.status}`); return (await response.json()) as CostSheetDto; } async function fetchUnitPriceList(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices`, { credentials: "include" }, ); if (!response.ok) throw new Error(`unit price list failed: ${response.status}`); return (await response.json()) as UnitPriceListDto; } async function fetchUnitPriceDetail(projectId: string, code: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/unit-prices/${encodeURIComponent(code)}`, { credentials: "include" }, ); if (!response.ok) throw new Error(`unit price detail failed: ${response.status}`); return (await response.json()) as UnitPriceDetailDto; } /** ④ 예산내역서 한 줄. 금액이 `null` 이면 **못 세운 것**이지 0 이 아니다. */ interface BillRowDto { item_no: string; level: number; code: string | null; name: string; spec: string; unit: string; quantity: string | null; unit_price_krw: string | null; amount_krw: string | null; is_group: boolean; in_bill: boolean; note: string; } interface PriceBasisEntryDto { number: number; label: string; code: string; name: string; spec: string; unit: string; unit_price_krw: string; ref_code: string; } interface BillDto { rows: BillRowDto[]; excluded: BillRowDto[]; materials: BillRowDto[]; summary: { rows: number; detail_rows: number; body_total_krw: string; missing: Array<{ name: string; reason: string; unit?: string; quantity?: string; blocked_kind?: string; }>; notes: string[]; material_sheet: MaterialSheetDto | null; }; price_basis: { entries: PriceBasisEntryDto[] }; } interface MaterialSheetRowDto { name: string; spec: string; unit: string; total_amount: string; unit_price_krw: string | null; amount_krw: string | null; note: string; } interface MaterialSheetDto { contractor: MaterialSheetRowDto[]; owner: MaterialSheetRowDto[]; unknown: MaterialSheetRowDto[]; contractor_total_krw: string; owner_total_krw: string; missing: Array<{ name: string; reason: string }>; notes: string[]; } /** * 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다. * * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. * 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다. */ function formatQuantity(value: string | null): string { if (value === null || value === "") return ""; const parsed = Number(value); if (!Number.isFinite(parsed)) return value; return parsed.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } async function fetchBill(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/bill`, { credentials: "include" }, ); if (!response.ok) throw new Error(`estimation bill failed: ${response.status}`); return (await response.json()) as BillDto; } async function confirmEstimationStage(projectId: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/confirm`, { method: "POST", credentials: "include" }, ); if (!response.ok) throw new Error(`estimation confirm failed: ${response.status}`); } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export async function renderB09Estimation(root: HTMLElement): Promise { injectStyles(); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const form: CostFormState = { ...INITIAL_FORM }; let activeTab = "cost_sheet"; let sheet: CostSheetDto | null = null; let unitPriceList: UnitPriceListDto | null = null; let unitPriceDetail: UnitPriceDetailDto | null = null; let selectedUnitPrice: string | null = null; let bill: BillDto | null = null; let priceBasis: string | null = null; const main = document.createElement("div"); main.className = "b09-main"; const body = document.createElement("div"); body.style.flex = "1"; body.style.minHeight = "0"; body.style.display = "flex"; body.style.flexDirection = "column"; /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ const openUnitPrice = async (code: string): Promise => { if (!projectId) return; try { unitPriceDetail = await fetchUnitPriceDetail(projectId, code); selectedUnitPrice = code; drawBody(); } catch { showToast(L("B09_Estimation_UP_Load_Failed"), "error"); } }; const drawUnitPriceTab = (): void => { if (!unitPriceList) { const empty = document.createElement("div"); empty.className = "b09-empty"; empty.textContent = L("B09_Estimation_Tab_Pending"); body.append(empty); return; } // 산출 요약을 **화면에도** 낸다 — 무엇이 안 선 상태인지 사용자가 알아야 한다. for (const note of unitPriceList.summary.notes) { const line = document.createElement("div"); line.className = "b09-hint"; line.textContent = note; body.append(line); } body.append( buildUnitPriceList(unitPriceList, selectedUnitPrice, (code) => { void openUnitPrice(code); }), ); if (unitPriceDetail) { body.append( buildUnitPriceDetail(unitPriceDetail, (code) => { void openUnitPrice(code); }), ); } else { const hint = document.createElement("div"); hint.className = "b09-empty"; hint.textContent = L("B09_Estimation_UP_Pick"); body.append(hint); } }; /** ④ 예산내역서 — B08 수량에 단가를 붙인 표. 못 세운 줄은 **그대로 보인다**. */ const drawBoqTab = (): void => { if (!bill) { if (!projectId) { const empty = document.createElement("div"); empty.className = "b09-empty"; empty.textContent = L("B09_Estimation_Boq_Failed"); body.append(empty); return; } const load = document.createElement("button"); load.type = "button"; load.className = "b09-btn"; load.textContent = L("B09_Estimation_Boq_Load"); load.addEventListener("click", () => { void (async () => { try { bill = await fetchBill(projectId); } catch { bill = null; window.alert(L("B09_Estimation_Boq_Failed")); } drawBody(); })(); }); body.append(load); return; } const table = document.createElement("table"); table.className = "b09-sheet"; const head = document.createElement("thead"); head.innerHTML = "No.공종규격단위" + "수량단가금액비고"; const tbody = document.createElement("tbody"); for (const row of bill.rows) { const tr = document.createElement("tr"); // 계층은 들여쓰기로 보인다 — 번호만으로는 깊이가 안 읽힌다. const indent = " ".repeat(Math.max(0, (row.level - 1) * 2)); const cells = row.is_group ? [row.item_no, indent + row.name, "", "", "", "", "", ""] : [ row.item_no, indent + row.name, row.spec, row.unit, formatQuantity(row.quantity), row.unit_price_krw ?? "", row.amount_krw ?? "", row.note, ]; for (const text of cells) { const td = document.createElement("td"); td.textContent = text; tr.append(td); } if (row.is_group) tr.style.fontWeight = "600"; tbody.append(tr); } table.append(head, tbody); body.append(table); const total = document.createElement("div"); total.className = "b09-hint"; total.textContent = `${L("B09_Estimation_Boq_Total")}: ${bill.summary.body_total_krw}`; body.append(total); // 표시 자릿수와 계산 자릿수가 다르다는 것을 숨기지 않는다. const precision = document.createElement("div"); precision.className = "b09-hint"; precision.textContent = L("B09_Estimation_Boq_Precision"); body.append(precision); // ⚠ 자재비가 빠진 채 선 합계임을 숨기지 않는다. const shortfall = document.createElement("div"); shortfall.className = "b09-hint"; shortfall.textContent = L("B09_Estimation_Boq_NoMaterialPrice"); body.append(shortfall); if (bill.excluded.length > 0) { const note = document.createElement("div"); note.className = "b09-hint"; note.textContent = `${L("B09_Estimation_Boq_Excluded")}: ` + bill.excluded .map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`) .join(", "); body.append(note); } if (bill.summary.missing.length > 0) { // ⚠ **머리글을 한 번만 단다.** 종전엔 「못 세운 줄 (16)」 뒤에 갈래별 머리글이 // 또 붙어 **같은 수가 두 번** 떴다. 안내를 더하는 것이 곧 다른 안내를 묻는 것이라 // (2026-09-08 메인 창 지적), 한 화면에 뜨는 줄 수를 늘리지 않는다. // ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과 // 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다. const needsInput = bill.summary.missing.filter( (item) => item.blocked_kind === "input_missing", ); const rest = bill.summary.missing.filter((item) => item.blocked_kind !== "input_missing"); for (const [labelKey, group] of [ ["B09_Estimation_Boq_NeedsInput", needsInput], ["B09_Estimation_Boq_NeedsWork", rest], ] as Array<[keyof typeof ui_locales, typeof bill.summary.missing]>) { if (group.length === 0) continue; const head = document.createElement("div"); head.className = "b09-hint"; // 갈래가 하나뿐이면 「금액을 못 세운 줄」이라는 말을 앞에 붙여 뜻이 온전하게 한다. const prefix = needsInput.length > 0 && rest.length > 0 ? "" : `${L("B09_Estimation_Boq_Missing")} — `; head.textContent = `${prefix}${L(labelKey)} (${group.length})`; body.append(head); const list = document.createElement("ul"); for (const item of group) { const li = document.createElement("li"); li.textContent = `${item.name} — ${item.reason}`; list.append(li); } body.append(list); } } if (bill.materials.length > 0) { const note = document.createElement("div"); note.className = "b09-hint"; note.textContent = `${L("B09_Estimation_Boq_Materials")}: ` + bill.materials.map((row) => `${row.name} ${row.quantity ?? ""}${row.unit}`).join(", "); body.append(note); } }; /** ③ 단가산출서 — 내역 줄의 단가가 **어떻게 나왔는지** 보이는 표(실무 「단산 46 참조」). */ const drawPriceBasisTab = (): void => { if (!bill) { const empty = document.createElement("div"); empty.className = "b09-empty"; empty.textContent = L("B09_Estimation_PB_Empty"); body.append(empty); return; } const entries = bill.price_basis?.entries ?? []; // 일위대가 탭과 **같은 모양**으로 — 목록 위, 본표 아래 2단(PLAN 9-3 「표를 세 벌 // 만들지 않는다」와 같은 뜻: 화면도 한 벌로 쓴다). const split = document.createElement("div"); const list = document.createElement("table"); list.className = "b09-sheet b09-up-list"; list.innerHTML = "번호공종단위단가"; const tbody = document.createElement("tbody"); for (const entry of entries) { const tr = document.createElement("tr"); for (const text of [ String(entry.number), `${entry.name} ${entry.spec}`.trim(), entry.unit, entry.unit_price_krw, ]) { const td = document.createElement("td"); td.textContent = text; tr.append(td); } tr.style.cursor = "pointer"; if (entry.code === priceBasis) tr.style.fontWeight = "600"; tr.addEventListener("click", () => { priceBasis = entry.code; drawBody(); }); tbody.append(tr); } list.append(tbody); split.append(list); const picked = entries.find((entry) => entry.code === priceBasis) ?? null; const panel = document.createElement("div"); panel.className = "b09-up-detail"; if (picked === null) { panel.textContent = L("B09_Estimation_PB_Pick"); } else { const head = document.createElement("div"); head.className = "b09-hint"; head.textContent = `${picked.label} — ${picked.name} ${picked.spec} (${picked.unit}) ${picked.unit_price_krw}`; const ref = document.createElement("div"); ref.className = "b09-hint"; // 한 층 아래(일위대가)를 가리킨다 — 그 표는 일위대가 탭에서 그대로 본다. ref.textContent = `${L("B09_Estimation_PB_Ref")}: ${picked.ref_code}`; panel.append(head, ref); } split.append(panel); body.append(split); }; /** 자재대 — B08 수량·할증에 단가를 붙인 표. 관급은 **총원가 밖 별도 표기**다. */ const drawMaterialTab = (): void => { const sheet = bill?.summary.material_sheet ?? null; if (!sheet) { const empty = document.createElement("div"); empty.className = "b09-empty"; empty.textContent = L("B09_Estimation_Mat_Empty"); body.append(empty); return; } // 자재가 아예 없으면 **빈 표 셋을 늘어놓지 않는다** — 「없다」 한 줄이면 된다 // (2026-09-08 화면 전수에서 세 무리가 모두 「(0) — 0」 으로 뜨고 있었다). if (sheet.contractor.length === 0 && sheet.owner.length === 0 && sheet.unknown.length === 0) { const none = document.createElement("div"); none.className = "b09-empty"; none.textContent = L("B09_Estimation_Mat_None"); body.append(none); return; } for (const [labelKey, rows, total] of [ ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw], ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw], ["B09_Estimation_Mat_Unknown", sheet.unknown, null], ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null]>) { const head = document.createElement("div"); head.className = "b09-hint"; head.textContent = `${L(labelKey)} (${rows.length})` + (total === null ? "" : ` — ${total}`); body.append(head); if (rows.length === 0) continue; const table = document.createElement("table"); table.className = "b09-sheet"; table.innerHTML = "자재규격단위수량" + "단가금액비고"; const tbody = document.createElement("tbody"); for (const row of rows) { const tr = document.createElement("tr"); for (const text of [ row.name, row.spec, row.unit, formatQuantity(row.total_amount), row.unit_price_krw ?? "", row.amount_krw ?? "", row.note, ]) { const td = document.createElement("td"); td.textContent = text; tr.append(td); } tbody.append(tr); } table.append(tbody); body.append(table); } for (const note of sheet.notes) { const line = document.createElement("div"); line.className = "b09-hint"; line.textContent = note.replace(/\*\*/g, ""); body.append(line); } }; const drawBody = (): void => { body.replaceChildren(); if (activeTab === "unit_price") { drawUnitPriceTab(); return; } if (activeTab === "boq") { drawBoqTab(); return; } if (activeTab === "price_basis") { drawPriceBasisTab(); return; } if (activeTab === "supply") { drawMaterialTab(); return; } if (activeTab !== "cost_sheet") { const empty = document.createElement("div"); empty.className = "b09-empty"; empty.textContent = L("B09_Estimation_Tab_Pending"); body.append(empty); return; } if (!sheet) { const empty = document.createElement("div"); empty.className = "b09-empty"; empty.textContent = L("B09_Estimation_Btn_Recalc"); body.append(empty); return; } // 어느 값으로 계산했는지 화면에 남긴다 — 안 보이면 나중에 못 가른다. const source = document.createElement("div"); source.className = "b09-hint"; source.textContent = sheet.direct_cost_source === "quantities" ? L("B09_Estimation_Src_Quantities") : L("B09_Estimation_Src_Manual"); body.append(source); // 수량은 있는데 단가가 없는 공종 — 총액에서 빠졌으므로 **반드시 보인다**. if (sheet.missing_unit_prices.length > 0) { const missing = document.createElement("div"); missing.className = "b09-hint"; missing.textContent = `${L("B09_Estimation_Missing_UP")} ${sheet.missing_unit_prices.join(", ")}`; body.append(missing); } body.append(buildCostSheetTable(sheet)); for (const note of sheet.notes) { const line = document.createElement("div"); line.className = "b09-hint"; line.textContent = note; body.append(line); } }; const drawTabs = (): void => { const bar = buildTabs(activeTab, (key) => { activeTab = key; drawTabs(); drawBody(); if (key === "unit_price" && !unitPriceList && projectId) { void fetchUnitPriceList(projectId) .then((data) => { unitPriceList = data; drawBody(); }) .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); } }); const old = main.querySelector(".b09-tabs"); if (old) old.replaceWith(bar); else main.prepend(bar); }; const panel = buildSidePanel( form, async () => { if (!projectId) return; try { sheet = await fetchCostSheet(projectId, form); renderRateVersion(panel.rateVersionBox, sheet); panel.hintBox.textContent = sheet.suggested_profit_adjustment_krw && sheet.suggested_profit_adjustment_krw !== "0" ? `${L("B09_Estimation_Suggest_Adjust")} ${formatWon(sheet.suggested_profit_adjustment_krw)}` : ""; drawBody(); } catch { showToast(L("B09_Estimation_Calc_Failed"), "error"); } }, async () => { if (!projectId) return; try { await confirmEstimationStage(projectId); showToast(L("B09_Estimation_Confirm_Success"), "success"); goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[6]); } catch { showToast(L("B09_Estimation_Confirm_Failed"), "error"); } }, ); main.append(body); drawTabs(); drawBody(); const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, leftPanel: panel.root, mainContent: main, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); }