/* ============================================================================= * 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; lines: CostLineDto[]; totals: Record; rate_version: { dataset_id: string; effective_date: string; sha256: string }; notes: string[]; suggested_profit_adjustment_krw?: string; } /** 좌측 입력 상태 — 화면이 들고 있는 값. 저장은 [확정] 때만. */ 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; } 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: "", }; /** 총계 성격의 줄 — 표에서 굵게 띄운다. */ 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-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; } /* ----------------------------------------------------------------------------- * 좌측 패널 * -------------------------------------------------------------------------- */ 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"], ]); 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", false], ["unit_price", "B09_Estimation_Tab_UnitPrice", false], ["price_basis", "B09_Estimation_Tab_PriceBasis", false], ["machine", "B09_Estimation_Tab_Machine", false], ["duration", "B09_Estimation_Tab_Duration", false], ["supply", "B09_Estimation_Tab_Supply", false], ["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 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(); } 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 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; 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 drawBody = (): void => { body.replaceChildren(); 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; } 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(); }); 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); }