/* ============================================================================= * B09_Estimation_UI_Shell.ts * 로그인 후 09: 6차 워크플로우 (원가계산) — 탭 화면 (PLAN 144-1) * * 원본 = 기번3 공통 원가산출 엑셀 · 탭 하나 = 화면 표 하나(`…_UI_Tabs` 차례) · 옛 원가 코드 안 봄 * 자료 = GET …/estimation(입력 · 사용자 값 · 서버가 셈한 표) · 조작 = 캐시(`b09-estimation`) + * 짝 계산(`…_Calc_*.ts`)으로 바로 다시 셈 · [저장] = PUT → 서버 정본 · [단가 갱신] = POST …/prices * 호표 칸 누름 = 그 탭 그 줄로 이동 · 자동 저장 없음 * ========================================================================== */ import "@ui/ui_template_general_layout.css"; import "./B09_Estimation_UI_Page.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createButton, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createDataTable } from "@ui/ui_template_data_table"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { computeBase, type Row } from "./B09_Estimation_Calc_Base"; import { computeEstimation } from "./B09_Estimation_Calc_Bill"; import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor"; import { computeTotal } from "./B09_Estimation_Calc_Total"; import { buildDaegaBlocks, buildDaegaList } from "./B09_Estimation_UI_Tabs_Labor"; import { buildCheck, rowIndex, TABS, type Link, type TabBuild, type TabContext, type TabId, } from "./B09_Estimation_UI_Tabs"; import { buildBasis, buildCompare, buildExpense, buildLabor, buildMachine, buildMachineBlocks, buildMaterial, } from "./B09_Estimation_UI_Tabs_Base"; import { buildBill } from "./B09_Estimation_UI_Tabs_Bill"; import { buildSumExpense, buildSumLabor, buildSumMachine, buildSumMaterial, } from "./B09_Estimation_UI_Tabs_Summary"; import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total"; import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } const LATER: Record = {}; const BUILDERS: Partial> = { check: buildCheck, dg_list: buildDaegaList, dg: buildDaegaBlocks, bill: buildBill, basis: buildBasis, machine: buildMachine, machine_blocks: buildMachineBlocks, material: buildMaterial, labor: buildLabor, expense: buildExpense, compare: buildCompare, cost: buildCost, total: buildTotal, sum_material: buildSumMaterial, sum_labor: buildSumLabor, sum_expense: buildSumExpense, sum_machine: buildSumMachine, sg_list: buildSgList, sg: buildSg, }; async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; if (!response.ok) { const detail = body?.detail; throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); } return body as T; } function note(textValue: string): HTMLElement { const p = document.createElement("p"); p.className = "ui-general-block__empty"; p.textContent = textValue; return p; } /** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸 */ const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const; /** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */ export function computeSheets(payload: Row, user: Row): Row { const server: Row = payload.sheets || {}; const kept = Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])); // 대가(145-2) — 산근 단가는 서버 답 목록 그대로 · 내역(145-3)은 대가 단가로 블록마다 const sg = Object.fromEntries((kept.sg_list as Row[]).map((r) => [r.key, r])); const labor = computeLabor( payload.inputs.daega, { ...computeBase(payload.inputs, user), gaps: [] }, sg, ); const base = computeEstimation(payload.inputs, user, (owner: string) => daegaPrice(labor, owner)); const { gaps: daegaGaps, ...daega } = labor; return { ...base, ...kept, ...daega, gaps: [...base.gaps, ...(server.sg_gaps || []), ...daegaGaps], // 145-4 총괄 · 원가계산서 = 설계내역서 비목 합(145-3 `bill.sums`) + 사용자 요율 · 손입력 total: computeTotal(base.bill?.sums, user.estimate), }; } export function renderB09Estimation(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); let payload: Row | null = null; let user: Row = {}; let sheets: Row = {}; let current: TabId = "check"; const tabBar = document.createElement("div"); tabBar.className = "b09-estimation__tabs"; tabBar.setAttribute("role", "tablist"); const panel = document.createElement("div"); panel.className = "b09-estimation__panel"; panel.setAttribute("role", "tabpanel"); const body = document.createElement("div"); body.className = "b09-estimation"; body.append(tabBar, panel); const made = document.createElement("p"); made.className = "b09-estimation__hint"; const recompute = (): void => { if (payload) sheets = computeSheets(payload, user); }; const setUser = (next: Row): void => { user = next; if (projectId) writeState("b09-estimation", user, projectId); recompute(); show(current); }; /** 탭 하나 그림 · focus = 이동해 온 줄 키 */ const show = (tab: TabId, focus?: string): void => { current = tab; tabBar.replaceChildren( ...TABS.map(([id, label]) => { const button = createButton({ label, variant: id === tab ? "filled" : "ghost", size: "sm", onClick: () => id !== current && show(id), }); button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(id === tab)); button.dataset.b09Tab = id; return button; }), ); if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요.")); if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…")); const build = BUILDERS[tab]; if (!build) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움")); const ctx: TabContext = { sheets, payload, user, setUser }; const spec = build(ctx); if (typeof spec === "string") return void panel.replaceChildren(note(spec)); const goto = (link: Link | null): void => { if (link) show(link.tab, link.key); }; const table = createDataTable({ columns: spec.columns, rows: spec.rows, total: spec.total ?? null, frozen: spec.frozen ?? 0, empty: "", caption: TABS.find(([id]) => id === tab)?.[1], editable: spec.editable, onEdit: spec.onEdit, rowClass: spec.rowClass, onCellClick: (i, key) => { if (spec.onCell) spec.onCell(i, key); if (spec.link && !spec.editable?.(i, key)) goto(spec.link(i, key)); }, }); table.root.dataset.b09Table = tab; panel.replaceChildren(...(spec.hint ? [note(spec.hint)] : []), table.root); if (focus !== undefined) { const index = rowIndex(spec.rows, focus); if (index >= 0) requestAnimationFrame(() => table.focusRow(index)); } }; const apply = (next: Row, fromServer: boolean): void => { payload = next; const draft = projectId && !fromServer ? readState("b09-estimation", projectId) : null; user = draft ?? next.user ?? {}; recompute(); made.textContent = next.book?.made ? `단가 굳힘 ${String(next.book.made).replace("T", " ")} — [단가 갱신] 때만 새로` : "단가 아직 안 굳힘 — [저장] 이나 [단가 갱신] 때 M01 값을 굳힘"; show(current); }; const run = async (path: string, method: string, done: string, init?: Row): Promise => { if (!projectId) return; showLoadingOverlay(); try { const next = await request(`/projects/${projectId}${path}`, { method, ...(init ? { body: JSON.stringify(init) } : {}), }); clearState("b09-estimation", projectId); apply(next, true); showToast(done, "success"); } catch (error) { showToast(`하지 못했습니다 — ${String(error)}`, "error"); } finally { hideLoadingOverlay(); } }; const save = createButton({ label: "저장", variant: "filled", onClick: () => void run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }), }); save.dataset.b09Save = ""; const refresh = createButton({ label: "단가 갱신", variant: "ghost", onClick: () => void run("/estimation/prices", "POST", "단가를 지금 M01 값으로 새로 굳혔습니다."), }); refresh.dataset.b09Refresh = ""; const section = document.createElement("section"); section.className = "ui-collapsible ui-sidebar-section ui-sidebar-card"; const heading = document.createElement("h3"); heading.className = "ui-collapsible__title"; heading.textContent = "원가 단가"; section.append(heading, made, refresh); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; actions.append(save); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-options"; left.append(section, actions); attachCollapsible(left); const layout = createWorkflowLayout({ title: L("B09_Estimation_Title"), steps: workflowSteps(), activeStep: 6, mainContent: body, leftPanel: left, routes: WORKFLOW_STEP_ROUTES, onStepClick: (stepIndex: number) => { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); root.append(layout.root); show(current); if (projectId) request(`/projects/${projectId}/estimation`).then( (next) => apply(next, false), (error: unknown) => panel.replaceChildren(note(`원가 자료를 불러오지 못했습니다 — ${String(error)}`)), ); }