From 964375d2a7addfdf31231d5696765895ab8be0be Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 02:56:27 +0900 Subject: [PATCH] =?UTF-8?q?refactor(b09):=20=EC=98=9B=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=85=8B=20=EC=A7=80=EC=9B=80=20?= =?UTF-8?q?=E2=80=94=20UI=5FPage(1,561=EC=A4=84)=20=C2=B7=20UI=5FBaseData(?= =?UTF-8?q?1,161=EC=A4=84)=20=C2=B7=20UI=5FTab=5FLegacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 탭 여섯은 모두 새 틀 탭 파일로 옮겨짐(설계서 구성 · 산출기초 · 관급·사급 · 중기경비계산서 · 기초자료, 원가계산서는 랩탑_메인) - 화면 확인(지운 뒤 13 탭 전부): 설계내역서 53줄 · 본체 122,848,989 · 일위대가 18 · 산근 10 · 중기 표 19·줄 171 · 관급·사급 13 · 기초자료 표 6·줄 737 · 설계서 구성 13 · 산출기초 표 3·줄 192 — 옮기기 전과 같음 · tsc 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq --- B09_Estimation/B09_Estimation_UI_BaseData.ts | 1161 ------------ B09_Estimation/B09_Estimation_UI_Page.ts | 1573 ----------------- B09_Estimation/B09_Estimation_UI_Sheet.ts | 1 - .../B09_Estimation_UI_Tab_Legacy.ts | 30 - 4 files changed, 2765 deletions(-) delete mode 100644 B09_Estimation/B09_Estimation_UI_BaseData.ts delete mode 100644 B09_Estimation/B09_Estimation_UI_Page.ts delete mode 100644 B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts deleted file mode 100644 index c23cac17..00000000 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ /dev/null @@ -1,1161 +0,0 @@ -/* ============================================================================= - * B09_Estimation_UI_BaseData.ts - * 기초자료 탭 · 중기 탭 — 목록표 넷을 그린다 (사용자 확정 12번 「내야 할 표 16개 전체」). - * - * 기초자료 탭 : 노무비목록표 · 재료비목록표 · 경비목록표 - * 중기 탭 : 중기목록표 (합계 + 노무·재료·경비 3분할) - * - * 서식은 지어내지 않았다 — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를 - * 그대로 옮겼다. 칸 이름·차례가 그 시트와 같다. - * - * 화면 조립부(`B09_Estimation_UI_Page.ts`)가 이미 700줄을 크게 넘어 여기로 뺐다. - * 표를 그리는 일만 하고 **상태를 들지 않는다** — 부르는 쪽이 자료를 넘긴다. - * ========================================================================== */ - -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { - attachProvenance, - markProvenanceCell, - type ProvenancePayload, - type ProvenanceSheet, -} from "@ui/ui_template_provenance"; -import { API_BASE_URL } from "@config/config_frontend"; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 목록표 한 줄 — 실무 시트 칸 그대로. */ -export interface BaseDataRow { - code: string; - name: string; - spec: string; - unit: string; - unit_price_krw: string | null; - note: string; -} - -/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */ -export interface MachineRow { - code: string; - name: string; - spec: string; - unit: string; - total_krw: string | null; - labor_krw: string | null; - material_krw: string | null; - expense_krw: string | null; - note: string; -} - -export interface BaseDataDto { - status: string; - labor: BaseDataRow[]; - material: BaseDataRow[]; - expense: BaseDataRow[]; - machine: MachineRow[]; - /** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */ - provenance?: ProvenancePayload; -} - -export async function fetchBaseData(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`base-data ${response.status}`); - return (await response.json()) as BaseDataDto; -} - -function money(value: string | null): string { - if (value === null || value === "") return ""; - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value; -} - -function head(text: string): HTMLElement { - const el = document.createElement("div"); - el.className = "b09-hint"; - el.style.fontWeight = "600"; - el.textContent = text; - return el; -} - -function note(text: string): HTMLElement { - const el = document.createElement("div"); - el.className = "b09-hint"; - el.textContent = text; - return el; -} - -/** - * 표 한 장. - * - * `keys`·`sheet` 를 함께 주면 칸마다 근거 호버가 붙는다 — **사전이 없으면 아무 일도 - * 안 한다**(빈 카드를 띄우면 「설명이 있다」는 거짓만 남는다). 안 주는 표는 종전 그대로다. - */ -function table( - headers: string[], - rows: string[][], - leftCols: number[], - keys?: string[], - sheet?: ProvenanceSheet, -): HTMLElement { - const el = document.createElement("table"); - el.className = "b09-sheet"; - const thead = document.createElement("thead"); - const headRow = document.createElement("tr"); - headers.forEach((text, index) => { - const th = document.createElement("th"); - th.textContent = text; - if (leftCols.includes(index)) th.className = "b09-left"; - headRow.append(th); - }); - thead.append(headRow); - const tbody = document.createElement("tbody"); - for (const cells of rows) { - const tr = document.createElement("tr"); - cells.forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - if (leftCols.includes(index)) td.className = "b09-left"; - const key = keys?.[index]; - const column = key ? sheet?.columns[key] : undefined; - if (key && column) markProvenanceCell(td, key, column.tier); - tr.append(td); - }); - tbody.append(tr); - } - el.append(thead, tbody); - attachProvenance(el, sheet); - return el; -} - -/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */ -function catalogTable(rows: BaseDataRow[], sheet?: ProvenanceSheet): HTMLElement { - return table( - ["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"], - rows.map((row) => [ - row.code, - row.name, - row.spec, - row.unit, - money(row.unit_price_krw), - row.note, - ]), - [0, 1, 2, 5], - ["code", "name", "spec", "unit", "unit_price_krw", "note"], - sheet, - ); -} - -/** - * 기초자료 탭 — 목록표 셋. - * - * ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다. - * 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다). - */ -export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void { - const groups: Array<[string, BaseDataRow[], string]> = [ - ["노무비목록표", data.labor, ""], - [ - "재료비목록표", - data.material, - data.material.length <= 1 - ? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다." - : "", - ], - ["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."], - ]; - for (const [title, rows, hint] of groups) { - body.append(head(`${title} (${rows.length})`)); - if (hint) body.append(note(hint)); - if (rows.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - continue; - } - body.append(catalogTable(rows, data.provenance?.sheets?.catalog)); - } -} - -/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */ -export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void { - body.append(head(`중기목록표 (${data.machine.length})`)); - if (data.machine.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - return; - } - body.append( - table( - ["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"], - data.machine.map((row) => [ - row.code, - row.name, - row.spec, - row.unit, - money(row.total_krw), - money(row.labor_krw), - money(row.material_krw), - money(row.expense_krw), - row.note, - ]), - [0, 1, 2, 8], - [ - "code", - "name", - "spec", - "unit", - "total_krw", - "labor_krw", - "material_krw", - "expense_krw", - "note", - ], - data.provenance?.sheets?.machine, - ), - ); - // ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다. - body.append( - note( - "조종원 노임은 「노임 ÷ 8시간 × 16/12 × 25/20」(약 1.667배)으로 셉니다. " + - "공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 계상해야 " + - "합니다(건협 임금적용요령 4-나 · 기재부 정부 입찰·계약 집행기준 제76조의3). " + - "⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따랐습니다 — 실무 두 공사지· " + - "임도교본 예제·상용 적산 프로그램이 모두 같은 계수를 씁니다.", - ), - ); - body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다.")); -} - -/* ============================================================================= - * 설계서 구성표 — 법이 정한 목차와 우리가 내는 것을 맞대 본다(별표2 (5)(가)). - * ⚠ 「없음」과 「우리 몫 아님」을 갈라 보인다 — 갈라야 다음에 할 일이 달라진다. - * ========================================================================== */ - -export interface DesignDocDto { - status: string; - law: string; - summary: string; - counts: Record; - items: Array<{ - order: number; - name: string; - status: string; - owner: string; - where: string; - note: string; - }>; - notes: string[]; -} - -export async function fetchDesignDocIndex(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/design-doc-index`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`design-doc-index ${response.status}`); - return (await response.json()) as DesignDocDto; -} - -export function drawDesignDocTab(body: HTMLElement, data: DesignDocDto): void { - body.append(head(`설계서 구성 (법이 정한 ${data.items.length})`)); - body.append(note(data.summary)); - body.append(note(data.law)); - body.append( - table( - ["차례", "이 름", "상 태", "누가 만드나", "어디서 나오나", "비 고"], - data.items.map((row) => [ - String(row.order), - row.name, - row.status, - row.owner, - row.where || "—", - row.note, - ]), - [0, 1, 2, 3, 4, 5], - ), - ); - for (const line of data.notes) body.append(note(line)); -} - -/* ============================================================================= - * 각종 중기경비계산서 — 기종마다 한 장(별표2 (5)(가) 아홉째). - * ⚠ 목록표가 「얼마」라면 이 장은 **왜 그 값인가**다. 계산 과정을 감추지 않는다. - * ========================================================================== */ - -export interface MachineExpenseDto { - status: string; - summary: string; - notes: string[]; - sheets: Array<{ - machine_code: string; - name: string; - spec: string; - price_thousand_krw: string | null; - economic_life_hours: number | null; - annual_standard_hours: number | null; - depreciation_coefficient: number | null; - maintenance_coefficient: number | null; - management_coefficient: number | null; - loss_coefficient: number | null; - loss_krw_per_hour: string | null; - fuel_liters_per_hour: string | null; - fuel_price_per_liter: string | null; - fuel_scope: string; - misc_material_percent: string | null; - operator_code: string; - operator_daily_wage: string | null; - operator_krw_per_hour: string | null; - material_krw: string | null; - labor_krw: string | null; - expense_krw: string | null; - total_krw: string | null; - variant: string; - attachment: boolean; - attachment_note: string; - gaps: string[]; - }>; -} - -export async function fetchMachineExpense(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/machine-expense`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`machine-expense ${response.status}`); - return (await response.json()) as MachineExpenseDto; -} - -/** 기종 한 장 — 손료·운전경비·시간당 사용료를 차례로. */ -function machineExpenseSheet(sheet: MachineExpenseDto["sheets"][number]): HTMLElement { - const box = document.createElement("div"); - box.className = "b09-panel__group"; - const title = document.createElement("p"); - title.className = "b09-panel__legend"; - title.textContent = - `${sheet.machine_code} ${sheet.name} ${sheet.spec}`.trim() + - (sheet.variant ? ` — ${sheet.variant}` : ""); - box.append(title); - - const coefficient = (value: number | null) => (value === null ? "—" : String(value)); - box.append( - table( - ["구 분", "내 용", "값"], - [ - ["① 손료", "취득가격(천원)", money(sheet.price_thousand_krw)], - [ - "", - "내용시간 / 연간표준가동시간", - `${coefficient(sheet.economic_life_hours)} / ${coefficient(sheet.annual_standard_hours)}`, - ], - [ - "", - "상각비·정비비·관리비 계수 (10⁻⁷)", - `${coefficient(sheet.depreciation_coefficient)} + ${coefficient(sheet.maintenance_coefficient)} + ${coefficient(sheet.management_coefficient)} = ${coefficient(sheet.loss_coefficient)}`, - ], - ["", "시간당 손료(원)", money(sheet.loss_krw_per_hour)], - [ - "② 운전경비", - `주연료(L/hr) × 유가(${sheet.fuel_scope})`, - `${sheet.fuel_liters_per_hour ?? "—"} × ${money(sheet.fuel_price_per_liter)}`, - ], - ["", "잡재료(주연료의 %)", sheet.misc_material_percent ?? "—"], - [ - "", - `조종원(${sheet.operator_code || "—"}) 일당 → 시간당`, - `${money(sheet.operator_daily_wage)} → ${money(sheet.operator_krw_per_hour)}`, - ], - [ - "③ 시간당 사용료", - "재료비 / 노무비 / 경비", - `${money(sheet.material_krw)} / ${money(sheet.labor_krw)} / ${money(sheet.expense_krw)}`, - ], - ["", "합 계", money(sheet.total_krw)], - ], - [0, 1], - ), - ); - if (sheet.variant) { - box.append( - note( - "같은 기종이라도 조합 사용이면 잡재료가 16% 로 줄어 재료비가 달라집니다 —" + - " 그래서 층이 따로 섭니다(건설품셈 제8장 [주]⑤).", - ), - ); - } - if (sheet.attachment_note) box.append(note(sheet.attachment_note)); - for (const gap of sheet.gaps) box.append(note(`⚠ ${gap}`)); - return box; -} - -export function drawMachineExpense(body: HTMLElement, data: MachineExpenseDto): void { - body.append(head(`각종 중기경비계산서 (${data.sheets.length})`)); - body.append(note(data.summary)); - for (const line of data.notes) body.append(note(line)); - for (const sheet of data.sheets) body.append(machineExpenseSheet(sheet)); -} - -/* ============================================================================= - * 산출기초 — 줄에 달린 근거를 한 장으로 모은 장(별표2 (5)(가) 열셋째). - * ⚠ 여기서 값을 다시 계산하지 않는다 — 모으기만 한다. - * ========================================================================== */ - -export interface BasisSheetDto { - status: string; - provenance?: ProvenancePayload; - note: string; - summary: string; - dataset_versions: Array<{ - dataset_id: string; - file: string; - effective_date: string; - sha256: string; - }>; - chosen_conditions: Array<{ item: string; value: string }>; - work_items: Array<{ code: string; name: string; unit: string; notes: string[] }>; - gaps: Array<{ kind: string; code: string; reason: string }>; -} - -export async function fetchBasisSheet(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/basis-sheet`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`basis-sheet ${response.status}`); - return (await response.json()) as BasisSheetDto; -} - -export function drawBasisSheet(body: HTMLElement, data: BasisSheetDto): void { - body.append(head("산출기초")); - body.append(note(data.summary)); - body.append(note(data.note)); - - body.append(head(`① 어느 판으로 계산했나 (${data.dataset_versions.length})`)); - body.append( - table( - ["자료", "파일", "기준일", "지문(앞 12)"], - data.dataset_versions.map((row) => [ - row.dataset_id, - row.file, - row.effective_date, - row.sha256 || "—", - ]), - [0, 1, 2, 3], - ["dataset_id", "file", "effective_date", "sha256"], - data.provenance?.sheets?.basis_versions, - ), - ); - - body.append(head(`② 무엇을 골랐나 (${data.chosen_conditions.length})`)); - if (data.chosen_conditions.length === 0) { - body.append(note("고른 값이 없습니다 — 전부 확정 기본값으로 돌고 있습니다.")); - } else { - body.append( - table( - ["항 목", "고른 값"], - data.chosen_conditions.map((row) => [row.item, row.value]), - [0, 1], - ["item", "value"], - data.provenance?.sheets?.basis_chosen, - ), - ); - } - - body.append(head(`③ 공종마다 무엇을 근거로 했나 (${data.work_items.length})`)); - body.append( - table( - ["코드", "공 종", "단위", "근 거"], - data.work_items.map((row) => [row.code, row.name, row.unit, row.notes.join(" · ")]), - [0, 1, 2, 3], - ["code", "name", "unit", "notes"], - data.provenance?.sheets?.basis_items, - ), - ); - - body.append(head(`④ 못 채운 자리 (${data.gaps.length})`)); - if (data.gaps.length === 0) { - body.append(note("못 채운 자리가 없습니다.")); - } else { - body.append( - table( - ["갈 래", "코드", "사 유"], - data.gaps.map((row) => [row.kind, row.code, row.reason]), - [0, 1, 2], - ["kind", "code", "reason"], - data.provenance?.sheets?.basis_gaps, - ), - ); - body.append(note("⚠ 여기 있는 것은 0 으로 때우지 않고 남겨 둔 자리입니다.")); - } -} - -/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */ -export function drawBaseDataError(body: HTMLElement): void { - body.append(note(L("B09_Estimation_Tab_Pending"))); -} - -/* ============================================================================= - * 자재단가대비표(A9) · 환율및기초자료(A10) — 사용자 확정 ③·⑮ 와 한 벌. - * - * 표를 냈는데 화면에 없으면 **낸 것이 아니다.** 그래서 기초자료 탭 아래에 붙인다. - * 서식은 실무 「자재단가대비표」·「환율및기초자료」 시트를 그대로 옮겼다 — - * 원천마다 **단가·페이지** 두 칸이 서고, 채택한 원천에 표시가 붙는다. - * ========================================================================== */ - -/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */ -export interface PriceSlot { - name: string; - price_krw: string | null; - /** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */ - source_note: string; - adopted: boolean; -} - -export interface MaterialComparisonRow { - code: string; - name: string; - spec: string; - unit: string; - slots: PriceSlot[]; - adopted_slot: number; - adopted_price_krw: string | null; - note: string; -} - -export interface FuelScope { - key: string; - label: string; - available: boolean; - why?: string; -} - -export interface PriceSourcesDto { - status: string; - provenance?: ProvenancePayload; - material_comparison: { - slot_names: string[]; - rows: MaterialComparisonRow[]; - notes: string[]; - }; - base_reference: { - exchange: { rows: unknown[]; note: string }; - labor: { - rows: Array<{ - code: string; - name: string; - day_wage_krw: string | null; - hourly_krw: string | null; - formula: string; - }>; - note: string; - }; - fuel: { - diesel_krw_per_l: string | null; - scope: string; - effective_date: string; - dataset_id: string; - scopes: FuelScope[]; - /** 고를 수 있는 시도 — 판에 있는 것만. 값을 함께 실어 고르기 전에 견줄 수 있다. */ - regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>; - region: string; - region_name: string; - region_missing?: string; - note: string; - }; - }; -} - -export async function fetchPriceSources(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`price-sources ${response.status}`); - return (await response.json()) as PriceSourcesDto; -} - -/** - * 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다. - * 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다. - */ -function comparisonTable( - slotNames: string[], - rows: MaterialComparisonRow[], - sheet?: ProvenanceSheet, -): HTMLElement { - const el = document.createElement("table"); - el.className = "b09-sheet"; - - const thead = document.createElement("thead"); - const top = document.createElement("tr"); - const bottom = document.createElement("tr"); - // ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면 - // 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다. - const appliedIsLastSlot = - rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length); - const trailing = appliedIsLastSlot ? [] : ["적 용"]; - const fixed = ["코드번호", "명 칭", "규 격", "단위"]; - fixed.forEach((text, index) => { - const th = document.createElement("th"); - th.textContent = text; - th.rowSpan = 2; - if (index <= 2) th.className = "b09-left"; - top.append(th); - }); - for (const name of [...slotNames, ...trailing]) { - const th = document.createElement("th"); - th.textContent = name; - th.colSpan = 2; - top.append(th); - for (const sub of ["단 가", "페이지"]) { - const cell = document.createElement("th"); - cell.textContent = sub; - bottom.append(cell); - } - } - const noteHead = document.createElement("th"); - noteHead.textContent = "비 고"; - noteHead.rowSpan = 2; - noteHead.className = "b09-left"; - top.append(noteHead); - thead.append(top, bottom); - - const tbody = document.createElement("tbody"); - for (const row of rows) { - const tr = document.createElement("tr"); - /** 칸 하나 — `key` 를 주면 근거 호버가 붙는다(사전에 없는 열은 아무 일도 안 한다). */ - const put2 = (td: HTMLElement, key: string, tier?: string): void => { - const column = sheet?.columns[key]; - if (column) markProvenanceCell(td, key, tier ?? column.tier); - }; - const put = (text: string, left = false, key?: string): void => { - const td = document.createElement("td"); - td.textContent = text; - if (left) td.className = "b09-left"; - if (key) put2(td, key); - tr.append(td); - }; - put(row.code, true, "code"); - put(row.name, true, "name"); - put(row.spec, true, "spec"); - put(row.unit, false, "unit"); - for (const slot of row.slots) { - const td = document.createElement("td"); - td.textContent = money(slot.price_krw); - // 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다. - if (slot.adopted) td.style.fontWeight = "700"; - // ⚠ **빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」** — 막힌 자리로 표시한다. - put2(td, "slot_price", slot.price_krw === null ? "blocked" : undefined); - tr.append(td); - const page = document.createElement("td"); - page.textContent = slot.source_note; - page.className = "b09-left"; - put2(page, "slot_page"); - tr.append(page); - } - if (!appliedIsLastSlot) { - put(money(row.adopted_price_krw), false, "adopted_price_krw"); - put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true); - } - put(row.note, true, "note"); - tbody.append(tr); - } - el.append(thead, tbody); - attachProvenance(el, sheet); - return el; -} - -/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */ -function baseReferenceSections( - body: HTMLElement, - data: PriceSourcesDto["base_reference"], - projectId: string, - reload: () => void, - laborSheet?: ProvenanceSheet, - fuelSheet?: ProvenanceSheet, -): void { - body.append(head("환율및기초자료 — ① 환율")); - body.append(note(data.exchange.note)); - - body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`)); - if (data.labor.rows.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - } else { - body.append( - table( - ["코드번호", "직 종", "일 당", "시간당", "산 식"], - data.labor.rows.map((row) => [ - row.code, - row.name, - money(row.day_wage_krw), - money(row.hourly_krw), - row.formula, - ]), - [0, 1, 4], - ["code", "name", "day_wage_krw", "hourly_krw", "formula"], - laborSheet, - ), - ); - } - // 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다. - body.append( - note( - "시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " + - "조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.", - ), - ); - body.append(note(data.labor.note)); - - body.append(head("환율및기초자료 — ③ 단가 및 재료비")); - const fuel = data.fuel; - body.append( - table( - ["항 목", "단 가", "적용 범위", "기준일", "자료"], - [ - [ - "경유", - money(fuel.diesel_krw_per_l), - fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope, - fuel.effective_date, - fuel.dataset_id, - ], - ], - [0, 2, 3, 4], - ["item", "price_krw", "scope", "effective_date", "dataset_id"], - fuelSheet, - ), - ); - - // ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고 - // 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다. - // ⚠ 시도가 들어온 뒤로는 **한 칸에서 전국과 시도를 함께** 고른다 — 범위 칸과 시도 칸을 - // 따로 두면 「지역인데 시도를 안 고른 상태」가 생겨 무슨 값으로 섰는지 흐려진다. - const picker = document.createElement("div"); - picker.className = "b09-hint"; - picker.style.display = "flex"; - picker.style.alignItems = "center"; - picker.style.gap = "8px"; - picker.style.flexWrap = "wrap"; - const label = document.createElement("span"); - label.textContent = "유가 적용 범위"; - const select = document.createElement("select"); - const national = document.createElement("option"); - national.value = ""; - national.textContent = "전국 공시가"; - national.selected = !fuel.region; - select.append(national); - for (const region of fuel.regions) { - const option = document.createElement("option"); - option.value = region.code; - option.textContent = `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`; - option.selected = region.code === fuel.region; - select.append(option); - } - select.disabled = fuel.regions.length === 0; - select.addEventListener("change", () => { - void saveFactorChoices(projectId, { fuel_region: select.value }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }); - picker.append(label, select); - body.append(picker); - if (fuel.regions.length === 0) { - for (const scope of fuel.scopes) { - if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`)); - } - } else { - body.append( - note( - fuel.region - ? `${fuel.region_name} 공시가로 서 있습니다 — 기계 연료비가 그 값으로 다시 섭니다.` - : "전국 공시가로 서 있습니다 — 현장 시도를 고르면 그 지역 값으로 바뀝니다.", - ), - ); - } - if (fuel.region_missing) body.append(note(`⚠ ${fuel.region_missing}`)); - body.append(note(fuel.note)); -} - -/** 기초자료 탭 아래쪽 — A9·A10 두 장. */ -export function drawPriceSourcesSections( - body: HTMLElement, - data: PriceSourcesDto, - projectId: string, - reload: () => void, -): void { - const comparison = data.material_comparison; - body.append(head(`자재단가대비표 (${comparison.rows.length})`)); - if (comparison.rows.length <= 1) { - body.append( - note( - "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.", - ), - ); - } - if (comparison.rows.length === 0) { - body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); - } else { - body.append( - comparisonTable( - comparison.slot_names, - comparison.rows, - data.provenance?.sheets?.material_comparison, - ), - ); - } - for (const text of comparison.notes) body.append(note(text)); - - baseReferenceSections( - body, - data.base_reference, - projectId, - reload, - data.provenance?.sheets?.base_reference_labor, - data.provenance?.sheets?.base_reference_fuel, - ); -} - -/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */ -export function drawPriceSourcesPending(body: HTMLElement): void { - body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…")); -} - -/* ============================================================================= - * 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09) - * - * 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」 - * 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를 - * 칸 밑에 그대로 적는다. - * ========================================================================== */ - -export interface FactorOption { - key: string; - value?: string; - label: string; - note?: string; -} - -export interface RangeFactorRow { - key: string; - work_item_code: string; - work_item_name: string; - factor: string; - raw_cell: string; - chosen: string; - value: string; - is_default: boolean; - options: FactorOption[]; - basis: string[]; -} - -export interface MachineChoiceRow { - work_item_code: string; - work_item_name: string; - chosen: string; - default: string; - is_default: boolean; - source: string; - options: FactorOption[]; - basis: string[]; -} - -/** 공구손료·잡재료 칸 — **비어 있는 것이 기본**이고, 비면 안 붙는다(산림품셈 1-2-6). */ -export interface MiscMaterialRow { - percent: string; - min: string; - max: string; - basis: string[]; - /** 주재료비가 선 일위대가 수 — 0 이면 넣어도 붙을 밑수가 없다. */ - base_items: number; - base_note: string; -} - -/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 선다(산림품셈 10-4). */ -export interface TransportRow { - distance_km: string; - road: string; - roads: Array<{ key: string; label: string }>; - variants: Array<{ key: string; label: string; unit_price_krw: string }>; - basis: string[]; - notes: string[]; -} - -/** 품의 할인·할증 26계열 — 안 고르면 안 붙는다(산림품셈 1-4). */ -export interface LaborSurchargeRow { - chosen: Record; - total_percent: string; - reasons: string[]; - series: Array<{ - key: string; - title: string; - section: string; - source_note: string; - options: FactorOption[]; - }>; - basis: string[]; -} - -export interface FactorChoicesDto { - status: string; - ranges: RangeFactorRow[]; - machines: MachineChoiceRow[]; - misc_material?: MiscMaterialRow; - transport?: TransportRow; - labor_surcharge?: LaborSurchargeRow; - notes: string[]; -} - -export async function fetchFactorChoices(projectId: string): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, - { credentials: "include" }, - ); - if (!response.ok) throw new Error(`factors ${response.status}`); - return (await response.json()) as FactorChoicesDto; -} - -export async function saveFactorChoices( - projectId: string, - body: { - range_factor_choices?: Record; - machine_choices?: Record; - misc_material_percent?: string; - fuel_region?: string; - transport_distance_km?: string; - transport_road?: string; - labor_surcharge?: Record; - }, -): Promise { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`, - { - method: "PUT", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }, - ); - if (!response.ok) { - const message = await response - .json() - .then((body: { message?: string }) => body.message ?? "") - .catch(() => ""); - throw new Error(message || `factors save ${response.status}`); - } -} - -/** - * 숫자 칸 하나 — **빈 칸이 기본**이다. [적용]을 눌러야 저장된다. - * - * ⚠ 고르는 칸(`picker`)과 달리 여기는 **사용자가 값을 짓는 자리**라 누를 때만 보낸다 — - * 타자 한 자마다 보내면 「2」를 치는 도중에 2% 로 저장돼 버린다. - */ -function percentBox( - label: string, - value: string, - placeholder: string, - onApply: (text: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-hint"; - wrap.style.display = "flex"; - wrap.style.alignItems = "center"; - wrap.style.gap = "8px"; - wrap.style.flexWrap = "wrap"; - - const name = document.createElement("span"); - name.style.fontWeight = "600"; - name.textContent = label; - - const input = document.createElement("input"); - input.type = "number"; - input.step = "0.1"; - input.min = "0"; - input.value = value; - input.placeholder = placeholder; - input.style.width = "72px"; - - const unit = document.createElement("span"); - unit.textContent = "%"; - - const apply = document.createElement("button"); - apply.type = "button"; - apply.textContent = "적용"; - apply.addEventListener("click", () => onApply(input.value.trim())); - - wrap.append(name, input, unit, apply); - return wrap; -} - -function picker( - label: string, - options: FactorOption[], - chosen: string, - onPick: (key: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-hint"; - wrap.style.display = "flex"; - wrap.style.alignItems = "center"; - wrap.style.gap = "8px"; - wrap.style.flexWrap = "wrap"; - - const name = document.createElement("span"); - name.style.fontWeight = "600"; - name.textContent = label; - - const select = document.createElement("select"); - for (const option of options) { - const item = document.createElement("option"); - item.value = option.key; - item.textContent = option.label; - item.selected = option.key === chosen; - select.append(item); - } - select.addEventListener("change", () => onPick(select.value)); - wrap.append(name, select); - return wrap; -} - -/** - * 산출 조건 구역 — 기초자료 탭 맨 위에 선다. - * - * ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이 - * 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리). - */ -export function drawFactorChoices( - body: HTMLElement, - data: FactorChoicesDto, - projectId: string, - reload: () => void, -): void { - body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리")); - - for (const row of data.ranges) { - const title = `${row.work_item_name} 작업효율(${row.factor})`; - body.append( - picker(title, row.options, row.chosen, (key) => { - void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then( - reload, - ); - }), - ); - body.append( - note( - `품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` + - (row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"), - ), - ); - for (const line of row.basis) body.append(note(line)); - } - - for (const row of data.machines) { - body.append( - picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => { - void saveFactorChoices(projectId, { - machine_choices: { [row.work_item_code]: key }, - }).then(reload); - }), - ); - body.append( - note( - row.source === "note" - ? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다." - : "품셈 표가 정한 장비입니다." + - (row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."), - ), - ); - for (const line of row.basis) body.append(note(line)); - } - - const misc = data.misc_material; - if (misc) { - body.append( - percentBox("공구손료·잡재료 (주재료비의)", misc.percent, "비움", (text) => { - void saveFactorChoices(projectId, { misc_material_percent: text }) - .then(reload) - .catch((error: Error) => { - body.append(note(`⚠ ${error.message}`)); - }); - }), - ); - body.append( - note( - misc.percent - ? `지금 ${misc.percent}% 로 붙고 있습니다 — 칸을 비우고 [적용]하면 도로 안 붙습니다.` - : `비어 있어 안 붙고 있습니다 — 넣을 수 있는 값은 ${misc.min}~${misc.max}% 입니다.`, - ), - ); - if (misc.base_note) body.append(note(misc.base_note)); - for (const line of misc.basis) body.append(note(line)); - } - - const transport = data.transport; - if (transport) { - body.append( - percentBox( - "기계 수송 거리 (인근 시·군·구청 → 현장, 편도 ㎞)", - transport.distance_km, - "비움", - (text) => { - void saveFactorChoices(projectId, { transport_distance_km: text }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }, - ), - ); - const roadOptions: FactorOption[] = [ - { key: "", label: "안 고름" }, - ...transport.roads.map((road) => ({ key: road.key, label: road.label })), - ]; - body.append( - picker("수송 도로 구분", roadOptions, transport.road, (key) => { - void saveFactorChoices(projectId, { transport_road: key }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }), - ); - for (const variant of transport.variants) { - body.append( - note( - variant.unit_price_krw - ? `${variant.label} — 회당 ${variant.unit_price_krw}원` - : `${variant.label} — 아직 안 섬`, - ), - ); - } - for (const line of transport.notes) body.append(note(`⚠ ${line}`)); - for (const line of transport.basis) body.append(note(line)); - body.append( - note( - "⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.", - ), - ); - } - - const surcharge = data.labor_surcharge; - if (surcharge) { - body.append(head("품의 할인·할증 (산림품셈 1-4) — 고른 것만 붙습니다")); - body.append( - note( - Number(surcharge.total_percent) === 0 - ? "지금 한 계열도 안 골라 한 원도 안 움직이고 있습니다." - : `지금 ${surcharge.total_percent}% 가 품에 붙고 있습니다 — ${surcharge.reasons.join(" · ")}`, - ), - ); - for (const line of surcharge.basis) body.append(note(line)); - for (const item of surcharge.series) { - const options: FactorOption[] = [{ key: "", label: "안 고름" }, ...item.options]; - body.append( - picker(`${item.section}`, options, surcharge.chosen[item.key] ?? "", (key) => { - void saveFactorChoices(projectId, { labor_surcharge: { [item.key]: key } }) - .then(reload) - .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); - }), - ); - if (item.source_note) body.append(note(item.source_note)); - } - } - - for (const line of data.notes) body.append(note(line)); -} diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts deleted file mode 100644 index 40a7f58a..00000000 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ /dev/null @@ -1,1573 +0,0 @@ -/* ============================================================================= - * 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 { attachCollapsible } from "@ui/ui_template_collapsible"; -import { - attachProvenance, - createProvenanceToggle, - markProvenanceCell, - type ProvenancePayload, - type ProvenanceSheet, -} from "@ui/ui_template_provenance"; -import { - drawBaseDataTab, - drawFactorChoices, - drawBasisSheet, - drawDesignDocTab, - drawMachineExpense, - drawMachineTab, - drawPriceSourcesPending, - drawPriceSourcesSections, - fetchBaseData, - fetchBasisSheet, - fetchDesignDocIndex, - fetchMachineExpense, - fetchFactorChoices, - fetchPriceSources, - type BaseDataDto, - type BasisSheetDto, - type DesignDocDto, - type MachineExpenseDto, - type FactorChoicesDto, - type PriceSourcesDto, -} from "./B09_Estimation_UI_BaseData"; -import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -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; - /** 근거 사전 — **개발환경에서만** 온다. 없으면 호버·등급색이 통째로 안 붙는다. */ - provenance?: ProvenancePayload; -} - -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[]; - // 표본이 얇은 노임 — 금액은 서 있고 「조사현장이 적다」는 사실만 알린다. - labor_reliability: Array<{ code: string; name: string; flag: string; why: string }>; - }; - rows: UnitPriceRow[]; - provenance?: ProvenancePayload; -} - -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; - provenance?: ProvenancePayload; - /** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */ - 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); } -/* 좌측 패널 상자 — B04~B07 과 같은 꼴(테두리는 공용 .ui-sidebar-section 이 전담). - .ui-sidebar-section 이 붙은 것만 집어 본문 기초자료 표의 같은 클래스는 안 건드린다. */ -.b09-panel__group.ui-sidebar-section { - margin: 0; - padding: calc(var(--spacing-8) + var(--spacing-4)); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); -} -.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-hint { font-size: var(--font-size-xs, 12px); color: var(--color-text-secondary); } -/* 표본이 얇은 노임 — 막는 것이 아니라 눈에 띄기만 하면 된다. */ -.b09-hint--warn { color: var(--color-warning-text, #8a5a00); } - -/* ⚠ 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"); -} - -/** - * 줄 사유 조각을 줄에 실어 둔다 — 카드가 꺼내 쓴다. - * - * ⚠ 조각마다 **닿는 열**이 함께 온다. 줄에 달렸다고 모든 칸에 띄우면 - * 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 속인다(2026-09-12 B08 실측). - */ -function stashRowNotes(tr: HTMLElement, notes?: Array<{ column: string; text: string }>): void { - if (notes?.length) tr.dataset.provNotes = JSON.stringify(notes); -} - -/** 그 칸에 **닿는** 줄 사유만 돌려준다. 열 키가 빈 조각은 줄 전체에 걸리는 사유다. */ -function rowNotesFor(cell: HTMLElement, columnKey: string): string[] { - const raw = cell.closest("tr")?.dataset.provNotes; - if (!raw) return []; - try { - return (JSON.parse(raw) as Array<{ column: string; text: string }>) - .filter((note) => note.column === "" || note.column === columnKey) - .map((note) => note.text); - } catch { - return []; - } -} - -/** 칸에 열 키·등급을 심는다 — **사전에 없는 열은 아무 일도 안 한다**(빈 카드 방지). */ -function mark(cell: HTMLElement, sheet: ProvenanceSheet | undefined, columnKey: string): void { - const column = sheet?.columns[columnKey]; - if (column) markProvenanceCell(cell, columnKey, column.tier); -} - -function buildCostSheetTable(sheet: CostSheetDto): HTMLElement { - const prov = sheet.provenance?.sheets?.cost_sheet; - 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; - - mark(name, prov, "name"); - // ⚠ **같은 열 안에서 줄마다 등급이 갈리는 첫 자리.** 중간줄(간접노무비 따위)은 - // `calc` 인데 마지막줄 셋은 계약으로 나가는 `final` 이다. 열 사전은 등급이 하나뿐이라 - // 여기서 칸에 덮어 심는다 — 나머지 칸은 생략해 열 등급을 그대로 물려받는다. - const isFinalLine = - line.key === "total_cost" || line.key === "contract_amount" || line.key === "grand_total"; - const amountColumn = prov?.columns.amount_krw; - // 등급을 빼면 공용 쪽이 열 등급으로 채워 주지만, **여기서 명시**해 두면 그 채움이 - // 없는 판에서도 띠 색이 제대로 붙는다. - if (amountColumn) - markProvenanceCell(amount, "amount_krw", isFinalLine ? "final" : amountColumn.tier); - mark(rate, prov, "rate_percent"); - mark(basis, prov, "formula_text"); - mark(note, prov, "note"); - tr.append(name, amount, rate, basis, note); - tbody.append(tr); - } - table.append(tbody); - wrap.append(table); - attachProvenance(wrap, prov); - 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 prov = list.provenance?.sheets?.unit_price_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; - mark(name, prov, "name"); - mark(unit, prov, "unit"); - tr.append(name, unit); - const moneyKeys = ["material", "labor", "expense", "total"]; - [row.material, row.labor, row.expense, row.total].forEach((value, index) => { - const cell = document.createElement("td"); - cell.textContent = formatWon(value); - mark(cell, prov, moneyKeys[index]); - tr.append(cell); - }); - body.append(tr); - } - table.append(body); - wrap.append(table); - attachProvenance(wrap, prov); - return wrap; -} - -/** 일위대가 **본표** — 「무엇으로 이루어졌나」. 줄마다 원천과 파고들기가 붙는다. */ -function buildUnitPriceDetail( - detail: UnitPriceDetailDto, - onDrill: (code: string) => void, -): HTMLElement { - const wrap = document.createElement("div"); - wrap.className = "b09-sheet b09-up-detail"; - const prov = detail.provenance?.sheets?.unit_price_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; - mark(name, prov, "name"); - mark(spec, prov, "spec"); - mark(source, prov, "source"); - mark(unit, prov, "unit"); - tr.append(name, spec, source, unit); - const detailKeys = ["quantity", "material", "labor", "expense", "total"]; - [row.quantity, row.material, row.labor, row.expense, row.total].forEach((value, index) => { - const cell = document.createElement("td"); - cell.textContent = formatWon(value); - mark(cell, prov, detailKeys[index]); - 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); - attachProvenance(wrap, prov); - 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("section"); - group.className = "b09-panel__group ui-collapsible ui-sidebar-section"; - const legend = document.createElement("span"); - legend.className = "b09-panel__legend ui-collapsible__title"; - 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("section"); - rateGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; - const rateLegend = document.createElement("span"); - rateLegend.className = "b09-panel__legend ui-collapsible__title"; - 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("section"); - quantityGroup.className = "b09-panel__group ui-collapsible ui-sidebar-section"; - const quantityLegend = document.createElement("span"); - quantityLegend.className = "b09-panel__legend ui-collapsible__title"; - 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"); - // 바닥 고정 액션 줄(공용) — ui_template_overlay 가 이 줄을 스크롤 밖으로 빼낸다. - actions.className = "ui-sidebar-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); - - // 그룹 제목 행 클릭 시 접기/펼치기(B04~B07 공통). 액션 줄은 collapsible 이 아니다. - attachCollapsible(root); - - 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], - ["design_doc", "B09_Estimation_Tab_DesignDoc", true], - ["basis_sheet", "B09_Estimation_Tab_BasisSheet", 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; - /** - * 줄 사유 **조각** — 어느 사유가 어느 열에 닿는지까지 서버가 갈라 보낸다. - * ⚠ 줄에 달렸다고 모든 칸에 띄우면 「금액」 카드에 「갈래 근거…」 가 떠서 읽는 사람을 - * 속인다(2026-09-12 B08 실측). `column` 이 빈 글인 것만 줄 전체에 붙는다. - */ - notes?: Array<{ column: string; text: 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[] }; - provenance?: ProvenancePayload; -} - -interface MaterialSheetRowDto { - name: string; - spec: string; - unit: string; - total_amount: string; - unit_price_krw: string | null; - amount_krw: string | null; - note: string; - notes?: Array<{ column: string; text: 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}`); -} - -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ - -/** 옛 화면을 새 틀(`B09_Estimation_UI_Shell`) 안에 이어 붙이는 자리 — 옛 탭 줄은 숨고 틀이 `select` 로 고름. - * ⚠ 옛 탭을 새 탭 파일로 다 바꾸면 이 파일째 지움(PLAN 12장 · 브레인 판정). */ -export interface LegacyEstimation { - main: HTMLElement; - panel: HTMLElement; - select: (key: string) => void; -} - -export function createLegacyEstimation(root: HTMLElement): LegacyEstimation { - 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 designDoc: DesignDocDto | null = null; - let basisSheet: BasisSheetDto | null = null; - let machineExpense: MachineExpenseDto | 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; - // 근거 사전이 **한 번이라도** 왔는지 — 개발환경에서만 온다. 안 오면 토글도 안 세운다 - // (없는 기능의 단추가 떠 있으면 눌러 보고 「고장났다」고 읽는다). - let hasProvenance = false; - - 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 noteProvenance = (payload?: ProvenancePayload): void => { - if (!payload || hasProvenance) return; - hasProvenance = true; - drawTabs(); - }; - - /** 일위대가 본표를 불러 다시 그린다 — 기계 줄을 누르면 그 층으로 파고든다. */ - const openUnitPrice = async (code: string): Promise => { - if (!projectId) return; - try { - unitPriceDetail = await fetchUnitPriceDetail(projectId, code); - noteProvenance(unitPriceDetail.provenance); - 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); - } - // ⚠ 표본이 얇은 노임(`*` 조사현장 5개 미만 · `**` 미조사)이 내역에 실렸으면 알린다. - // 금액을 막지 않는다 — 값은 그대로 서고 **무엇이 얇은지**만 말한다 - // (지식DB `노임단가_적용 §2-3` 「단가 채택 시 플래그 유지 필요」). - for (const item of unitPriceList.summary.labor_reliability ?? []) { - const line = document.createElement("div"); - line.className = "b09-hint b09-hint--warn"; - line.textContent = `⚠ ${item.name}(${item.code}) ${item.flag} — ${item.why}`; - 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); - noteProvenance(bill.provenance); - } 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.공종규격단위" + - "수량단가금액비고"; - // 열 키는 서버 `BillRow.as_dict()` 낱말과 같아야 사전이 붙는다. - const boqKeys = [ - "item_no", - "name", - "spec", - "unit", - "quantity", - "unit_price_krw", - "amount_krw", - "note", - ]; - const prov = bill.provenance?.sheets?.boq; - 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, - ]; - cells.forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - // 머리(그룹)줄은 값이 없다 — 빈 칸에 카드를 띄우면 「설명이 있다」는 거짓이 남는다. - if (!row.is_group) mark(td, prov, boqKeys[index]); - tr.append(td); - }); - if (row.is_group) tr.style.fontWeight = "600"; - else stashRowNotes(tr, row.notes); - tbody.append(tr); - } - table.append(head, tbody); - body.append(table); - attachProvenance(table, prov, rowNotesFor); - - 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 prov = bill.provenance?.sheets?.price_basis; - const pbKeys = ["number", "name", "unit", "unit_price_krw"]; - const tbody = document.createElement("tbody"); - for (const entry of entries) { - const tr = document.createElement("tr"); - [ - String(entry.number), - `${entry.name} ${entry.spec}`.trim(), - entry.unit, - entry.unit_price_krw, - ].forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - mark(td, prov, pbKeys[index]); - 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); - attachProvenance(list, prov); - 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; - } - - // ⚠ 「안 갈린 것」은 **못 세운 것이 아니라 세면 안 되는 것**이라 사전을 따로 쓴다 - // (`excluded` — 채우면 이중계상, PLAN 8-36 ㉱). - for (const [labelKey, rows, total, sheetName] of [ - ["B09_Estimation_Mat_Contractor", sheet.contractor, sheet.contractor_total_krw, "material"], - ["B09_Estimation_Mat_Owner", sheet.owner, sheet.owner_total_krw, "material"], - ["B09_Estimation_Mat_Unknown", sheet.unknown, null, "material_unknown"], - ] as Array<[keyof typeof ui_locales, MaterialSheetRowDto[], string | null, string]>) { - 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 matKeys = [ - "name", - "spec", - "unit", - "total_amount", - "unit_price_krw", - "amount_krw", - "note", - ]; - const prov = bill?.provenance?.sheets?.[sheetName]; - const tbody = document.createElement("tbody"); - for (const row of rows) { - const tr = document.createElement("tr"); - [ - row.name, - row.spec, - row.unit, - formatQuantity(row.total_amount), - row.unit_price_krw ?? "", - row.amount_krw ?? "", - row.note, - ].forEach((text, index) => { - const td = document.createElement("td"); - td.textContent = text; - mark(td, prov, matKeys[index]); - tr.append(td); - }); - stashRowNotes(tr, row.notes); - tbody.append(tr); - } - table.append(tbody); - body.append(table); - attachProvenance(table, prov, rowNotesFor); - } - - 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 === "basis_sheet") { - // 산출기초 — 줄에 달린 근거를 모아 오는 장이라 조립이 끝나야 뜬다. - if (basisSheet) { - drawBasisSheet(body, basisSheet); - return; - } - const waiting = document.createElement("div"); - waiting.className = "b09-empty"; - waiting.textContent = L("B09_Estimation_Tab_Pending"); - body.append(waiting); - if (projectId) { - void fetchBasisSheet(projectId) - .then((data) => { - basisSheet = data; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => { - /* 못 받아도 화면을 비우지 않는다. */ - }); - } - return; - } - if (activeTab === "design_doc") { - // 설계서 구성표 — 프로젝트 값이 아니라 **우리가 무엇을 내는가**의 표다. - if (designDoc) { - drawDesignDocTab(body, designDoc); - return; - } - const loading = document.createElement("div"); - loading.className = "b09-empty"; - loading.textContent = L("B09_Estimation_Tab_Pending"); - body.append(loading); - if (projectId) { - void fetchDesignDocIndex(projectId) - .then((data) => { - designDoc = data; - drawBody(); - }) - .catch(() => { - /* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */ - }); - } - 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; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => { - /* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */ - }); - } - return; - } - if (activeTab === "machine") { - drawMachineTab(body, baseData); - // 계산서는 목록표 **아래**에 붙는다 — 「얼마」를 보고 「왜」로 내려간다. - if (machineExpense) { - drawMachineExpense(body, machineExpense); - return; - } - if (projectId) { - void fetchMachineExpense(projectId) - .then((data) => { - machineExpense = data; - drawBody(); - }) - .catch(() => { - /* 못 받아도 목록표는 그대로 선다. */ - }); - } - 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 && projectId) { - drawPriceSourcesSections(body, priceSources, projectId, () => { - // 유가 지역을 바꾸면 **기계 연료비가 다시 서므로** 목록표까지 함께 새로 받는다. - factorChoices = null; - baseData = null; - priceSources = null; - drawBody(); - }); - return; - } - drawPriceSourcesPending(body); - if (projectId) { - void fetchPriceSources(projectId) - .then((data) => { - priceSources = data; - noteProvenance(data.provenance); - 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; - noteProvenance(data.provenance); - drawBody(); - }) - .catch(() => showToast(L("B09_Estimation_UP_Load_Failed"), "error")); - } - }); - // ⚠ 등급색은 **평소엔 꺼 둔다** — 여덟 색이 늘 켜져 있으면 표가 알록달록해 - // 실무 시트와 눈으로 대조를 못 한다(PLAN 8-36 ②). 단추는 탭 줄 끝에 둔다. - if (hasProvenance) bar.append(createProvenanceToggle(root)); - 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); - noteProvenance(sheet.provenance); - 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(); - - return { - main, - panel: panel.root, - select: (key: string) => { - activeTab = key; - drawTabs(); - drawBody(); - // 관급·사급 표는 내역 응답에 실려 옴 — 옛 내역 탭을 안 거치므로 여기서 받음. - if (key === "supply" && !bill && projectId) { - void fetchBill(projectId) - .then((data) => { - bill = data; - drawBody(); - }) - .catch(() => showToast(L("B09_Estimation_Boq_Failed"), "error")); - } - }, - }; -} diff --git a/B09_Estimation/B09_Estimation_UI_Sheet.ts b/B09_Estimation/B09_Estimation_UI_Sheet.ts index 89231aa9..1aa18eee 100644 --- a/B09_Estimation/B09_Estimation_UI_Sheet.ts +++ b/B09_Estimation/B09_Estimation_UI_Sheet.ts @@ -194,7 +194,6 @@ export function injectSheetStyles(): void { .b09s-split { display:flex; flex-direction:column; gap:12px; min-width:0; } .b09s-title { font-weight:700; font-size:14px; } .b09s-formula { white-space:pre-wrap; font-size:12px; color:var(--ui-text, #1f2430); } - .b09s-legacy .b09-tabs { display:none; } .b09s-head { font-weight:600; margin-top:6px; } .b09s-info td { text-align:right; font-variant-numeric:tabular-nums; } .b09s-info td.b09s-left, .b09s-info th.b09s-left { text-align:left; white-space:normal; } diff --git a/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts b/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts deleted file mode 100644 index 855d5554..00000000 --- a/B09_Estimation/B09_Estimation_UI_Tab_Legacy.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* ============================================================================= - * B09_Estimation_UI_Tab_Legacy.ts - * 옛 B09 화면의 탭을 새 틀에 **그대로 이어 붙이는** 자리 (PLAN 12장 · 2026-09-14 브레인 판정) - * - * - 원가계산서(→ 랩탑_메인 새 탭 파일) · 중기 · 관급·사급 · 기초자료(계수 고르개 = 계산 입력) · - * 설계서 구성 · 산출기초 — 새 탭 파일이 서면 틀 등록 한 줄을 바꾸고, 다 바뀌면 이 파일과 옛 파일을 지움. - * - 옛 화면 한 벌을 처음 고를 때 한 번 세우고 탭끼리 나눠 씀(옛 상태·캐시 그대로). - * ========================================================================== */ - -import type { ui_locales } from "@ui/ui_template_locale"; -import type { B09Tab } from "./B09_Estimation_UI_Shell_Types"; -import { L } from "./B09_Estimation_UI_Sheet"; -import { createLegacyEstimation, type LegacyEstimation } from "./B09_Estimation_UI_Page"; - -let legacy: LegacyEstimation | null = null; - -export function legacyTab(key: string, labelKey: keyof typeof ui_locales): B09Tab { - return { - key, - label: () => L(labelKey), - render(ctx) { - legacy ??= createLegacyEstimation(ctx.root); - legacy.main.classList.add("b09s-legacy"); - ctx.body.append(legacy.main); - // 옛 좌측 칸은 원가계산서 입력 — 그 탭에서만 보임. - if (key === "cost_sheet") ctx.panel.append(legacy.panel); - legacy.select(key); - }, - }; -}