/* ============================================================================= * 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 { drawBaseDataTab, drawFactorChoices, drawMachineTab, drawPriceSourcesPending, drawPriceSourcesSections, fetchBaseData, fetchFactorChoices, fetchPriceSources, type BaseDataDto, type FactorChoicesDto, type PriceSourcesDto, } from "./B09_Estimation_UI_BaseData"; 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; /** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */ unattached: string[]; unattached_note: string; 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); } /* ⚠ min-width: 0 이 빠지면 **표가 넓은 만큼 이 칸이 통째로 밀려 나간다**(2026-09-08 실측: 창 620 에서 1,190px 넘침). flex 자식의 기본 최소폭이 auto 라 안쪽 표의 최소폭 (칸이 nowrap)을 그대로 물기 때문이다. 0 으로 끊어야 아래 .b09-sheet 의 overflow: auto 가 제 몫을 해서 **표만 제 안에서 가로로 넘어간다.** ⚠ 이 주석 안에 백틱을 쓰지 말 것 — 이 블록은 템플릿 문자열 안이라 거기서 끊긴다. */ .b09-main { display: flex; flex-direction: column; gap: var(--space-sm, 8px); height: 100%; min-height: 0; min-width: 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; min-width: 0; flex: 1; } /* ⚠ 이 클래스가 **감싸는 칸(div)에 붙는 자리와 표(table)에 바로 붙는 자리**가 둘 다 있다 (내역서·자재대는 표에 직접 붙인다). 표는 그대로 두면 overflow 가 안 먹어 **표 폭만큼 바깥 칸을 밀어낸다**(2026-09-08 실측: 창 620 에서 1,190px). 블록으로 바꾸면 제 안에서 가로로 넘어가고 바깥은 안 밀린다. 표 안쪽(thead·tbody)의 칸 배치는 그대로다. */ table.b09-sheet { display: block; overflow-x: auto; max-width: 100%; } .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); // ⚠ **일부만 선 단가는 반드시 말한다.** 안 말하면 조용히 싼 값이 내역서에 그대로 든다 // (2026-09-09 실측: 일위대가가 선 141 공종 중 70 공종이 이 자리 — 초류종자살포는 // 자재 다섯·장비 셋이 빠진 채 인력 둘만으로 서 있었다). if (detail.unattached_note) { const gap = document.createElement("div"); gap.className = "b09-hint"; gap.style.fontWeight = "600"; gap.textContent = detail.unattached_note; wrap.append(gap); } // 행별로 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", true], ["duration", "B09_Estimation_Tab_Duration", false], ["supply", "B09_Estimation_Tab_Supply", true], ["base_data", "B09_Estimation_Tab_BaseData", true], ]; 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; /** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */ quantity_shown: string | null; quantity_digits: number | 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[]; } /** * 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1). * * 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다 * `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다. * ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을 * 아는 척 자르지 않는다. * * ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이 * 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다. */ function formatQuantity(value: string | null, digits: number | null = null): string { if (value === null || value === "") return ""; const parsed = Number(value); if (!Number.isFinite(parsed)) return value; const places = digits ?? 2; return parsed.toLocaleString("ko-KR", { minimumFractionDigits: places, maximumFractionDigits: places, }); } 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 baseData: BaseDataDto | null = null; let priceSources: PriceSourcesDto | null = null; let factorChoices: FactorChoicesDto | null = null; 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"; // ⚠ 세로와 마찬가지로 **가로도 0 으로 끊어야** 한다(2026-09-08 실측). 안 끊으면 이 칸이 // 안쪽 표의 최소폭(칸이 nowrap)을 그대로 물어 창 620 에서 1,190px 밀려 나갔다. // 0 이면 아래 .b09-sheet 의 overflow:auto 가 살아나 **표만 제 안에서 가로로 넘어간다.** body.style.minWidth = "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_shown ?? row.quantity, row.quantity_digits), 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_shown ?? row.quantity, row.quantity_digits)}${row.unit}`, ) .join(", "); body.append(note); } if (bill.summary.missing.length > 0) { // ⚠ **머리글을 한 번만 단다.** 종전엔 「못 세운 줄 (16)」 뒤에 갈래별 머리글이 // 또 붙어 **같은 수가 두 번** 떴다. 안내를 더하는 것이 곧 다른 안내를 묻는 것이라 // (2026-09-08 메인 창 지적), 한 화면에 뜨는 줄 수를 늘리지 않는다. // ⚠ **할 일이 다르므로 갈라 보인다** — 「사용자가 입력하면 풀리는 것」과 // 「우리가 만들어야 하는 것」. 한 목록에 섞으면 사용자가 무엇을 해야 할지 못 읽는다. // ⚠ **세 갈래로 가른다.** 「여기서 세지 않는 줄」을 할 일 목록에 얹으면 // 사용자가 세우려 들고, 그것이 곧 이중계상이다(㉠~㉦ 규칙). const notOurs = bill.summary.missing.filter((item) => item.blocked_kind === "not_our_row"); const needsInput = bill.summary.missing.filter( (item) => item.blocked_kind === "input_missing", ); const rest = bill.summary.missing.filter( (item) => item.blocked_kind !== "input_missing" && item.blocked_kind !== "not_our_row", ); for (const [labelKey, group] of [ ["B09_Estimation_Boq_NeedsInput", needsInput], ["B09_Estimation_Boq_NeedsWork", rest], ["B09_Estimation_Boq_NotOurs", notOurs], ] 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 === "base_data" || activeTab === "machine") { // 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다. if (!baseData) { const loading = document.createElement("div"); loading.className = "b09-empty"; loading.textContent = L("B09_Estimation_Tab_Pending"); body.append(loading); if (projectId) { void fetchBaseData(projectId) .then((data) => { baseData = data; drawBody(); }) .catch(() => { /* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */ }); } return; } if (activeTab === "machine") { drawMachineTab(body, baseData); return; } // 산출 조건이 목록표보다 **먼저** 선다 — 값을 낳는 자리가 값보다 아래 있으면 // 사용자가 「바꿀 수 있는 것」을 못 본다. if (factorChoices && projectId) { drawFactorChoices(body, factorChoices, projectId, () => { factorChoices = null; baseData = null; priceSources = null; drawBody(); }); } else if (projectId) { void fetchFactorChoices(projectId) .then((data) => { factorChoices = data; drawBody(); }) .catch(() => { /* 못 받아도 아래 표는 그대로 선다. */ }); } drawBaseDataTab(body, baseData); // 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고 // 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다. if (priceSources) { drawPriceSourcesSections(body, priceSources); return; } drawPriceSourcesPending(body); if (projectId) { void fetchPriceSources(projectId) .then((data) => { priceSources = data; drawBody(); }) .catch(() => { /* 못 받아도 목록표 넷은 그대로 남는다. */ }); } 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); }