/* ============================================================================= * B09_Estimation_UI_Tab_BaseData.ts * B09 기초자료 탭 — 산출 조건(계산 입력) · 노무비·재료비·경비 목록표 · 자재단가대비표 · 환율및기초자료 * * - 옛 `B09_Estimation_UI_BaseData.ts`·`_UI_Page.ts` 의 기초자료 탭을 그대로 옮김(PLAN 12장 옛 탭 옮기기). * - ⚠ 산출 조건·유가 지역은 **계산 입력** — 저장하면 단가가 다시 서므로 이 탭 자료와 * 설계내역서 한 벌(`UI_Store`)을 함께 새로 받음. * - 표를 냈는데 화면에 없으면 낸 것이 아님 — 목록표 셋·대비표·기초자료를 한 탭에 모두 보임. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { markProvenanceCell, attachProvenance, type ProvenancePayload, type ProvenanceSheet, } from "@ui/ui_template_provenance"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; import { drawFactorChoices, fetchFactorChoices, picker, saveFactorChoices, } from "./B09_Estimation_UI_Factors"; import type { FactorChoicesDto } from "./B09_Estimation_UI_Factors"; import { L, el, hint } from "./B09_Estimation_UI_Sheet"; import { forgetBill } from "./B09_Estimation_UI_Store"; import { head, infoTable, money, note } from "./B09_Estimation_UI_Table"; interface BaseDataRow { code: string; name: string; spec: string; unit: string; unit_price_krw: string | null; note: string; } interface BaseDataDto { labor: BaseDataRow[]; material: BaseDataRow[]; expense: BaseDataRow[]; provenance?: ProvenancePayload; } interface PriceSlot { name: string; price_krw: string | null; source_note: string; adopted: boolean; } interface MaterialComparisonRow { code: string; name: string; spec: string; unit: string; slots: PriceSlot[]; adopted_slot: number; adopted_price_krw: string | null; note: string; } interface FuelScope { key: string; label: string; available: boolean; why?: string; } interface PriceSourcesDto { provenance?: ProvenancePayload; material_comparison: { slot_names: string[]; rows: MaterialComparisonRow[]; notes: string[] }; base_reference: { exchange: { 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; }; }; } async function getJson(projectId: string, path: string): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/${path}`, { credentials: "include" }, ); if (!response.ok) throw new Error(`${path} ${response.status}`); return (await response.json()) as T; } /** * 목록표 셋 — 코드·명칭·규격·단위·단가·비고. * ⚠ 표가 비거나 한 줄뿐일 때 그냥 두지 않음 — 「다 채운 것」으로 읽힘(재료비목록표가 그 자리). */ function drawCatalogs(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, text] of groups) { body.append(head(`${title} (${rows.length})`)); if (text) body.append(note(text)); if (rows.length === 0) { body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); continue; } body.append( infoTable( ["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"], 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"], data.provenance?.sheets?.catalog, ), ); } } /** 자재단가대비표 — 원천마다 **단가·페이지** 두 칸, 채택한 원천을 굵게. */ function comparisonTable( slotNames: string[], rows: MaterialComparisonRow[], sheet?: ProvenanceSheet, ): HTMLElement { const wrap = el("div", "b09s-wrap"); const table = el("table", "b09s-table b09s-info"); const thead = el("thead"); const top = el("tr"); const bottom = el("tr"); // ⚠ 슬롯 6 이 곧 「적용 단가」(`JUKNM=6`) — 그 자리에 「적 용」 칸을 또 세우면 같은 값이 두 번 섬. const appliedIsLastSlot = rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length); ["코드번호", "명 칭", "규 격", "단위"].forEach((text, index) => { const th = el("th", index <= 2 ? "b09s-left" : "", text); th.rowSpan = 2; top.append(th); }); for (const name of [...slotNames, ...(appliedIsLastSlot ? [] : ["적 용"])]) { const th = el("th", "", name); th.colSpan = 2; top.append(th); bottom.append(el("th", "", "단 가"), el("th", "", "페이지")); } const noteHead = el("th", "b09s-left", "비 고"); noteHead.rowSpan = 2; top.append(noteHead); thead.append(top, bottom); const tbody = el("tbody"); for (const row of rows) { const tr = el("tr"); const mark = (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 = el("td", left ? "b09s-left" : "", text); if (key) mark(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 = el("td", slot.adopted ? "b09s-adopted" : "", money(slot.price_krw)); // ⚠ 빈 칸은 「0원」이 아니라 「그 판에 그 품목이 없다」 — 막힌 자리로 표시. mark(td, "slot_price", slot.price_krw === null ? "blocked" : undefined); const page = el("td", "b09s-left", slot.source_note); mark(page, "slot_page"); tr.append(td, 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); } table.append(thead, tbody); attachProvenance(table, sheet); wrap.append(table); return wrap; } /** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비) + 유가 지역 고르개(계산 입력). */ function drawReference( body: HTMLElement, data: PriceSourcesDto, projectId: string, reload: () => void, ): void { const reference = data.base_reference; const sheets = data.provenance?.sheets; body.append(head("환율및기초자료 — ① 환율")); body.append(note(reference.exchange.note)); body.append(head(`환율및기초자료 — ② 인건비 (${reference.labor.rows.length})`)); if (reference.labor.rows.length === 0) { body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다.")); } else { body.append( infoTable( ["코드번호", "직 종", "일 당", "시간당", "산 식"], reference.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"], sheets?.base_reference_labor, ), ); } body.append( note( "시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " + "조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.", ), ); body.append(note(reference.labor.note)); body.append(head("환율및기초자료 — ③ 단가 및 재료비")); const fuel = reference.fuel; body.append( infoTable( ["항 목", "단 가", "적용 범위", "기준일", "자료"], [ [ "경유", 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"], sheets?.base_reference_fuel, ), ); // ⚠ 확정 ⑮ — 전국/시도를 한 칸에서 고름. 자료가 없으면 고를 수 없게 두고 까닭을 밝힘. const options = [ { key: "", label: "전국 공시가" }, ...fuel.regions.map((region) => ({ key: region.code, label: `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`, })), ]; const fuelPicker = picker("유가 적용 범위", options, fuel.region, (key) => { void saveFactorChoices(projectId, { fuel_region: key }) .then(reload) .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); }); const select = fuelPicker.querySelector("select"); if (select) select.disabled = fuel.regions.length === 0; body.append(fuelPicker); 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)); } function drawSources( 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)); drawReference(body, data, projectId, reload); } function show(ctx: B09TabContext, projectId: string): void { // 저장 뒤 — 이 탭 자료와 내역 한 벌을 함께 새로 받음(값이 다시 섬). const reload = (): void => { forgetBill(projectId); ctx.body.replaceChildren(); show(ctx, projectId); }; ctx.body.append(hint(L("B09_Sheet_Loading"))); void Promise.allSettled([ fetchFactorChoices(projectId), getJson(projectId, "base-data"), getJson(projectId, "price-sources"), ]).then(([factors, lists, sources]) => { ctx.body.replaceChildren(); // 산출 조건이 목록표보다 **먼저** — 값을 낳는 자리가 아래 있으면 「바꿀 수 있는 것」을 못 봄. if (factors.status === "fulfilled") { drawFactorChoices(ctx.body, factors.value as FactorChoicesDto, projectId, reload); } if (lists.status === "fulfilled") drawCatalogs(ctx.body, lists.value); else ctx.body.append(hint(`${L("B09_Sheet_LoadFailed")} base-data`, true)); if (sources.status === "fulfilled") drawSources(ctx.body, sources.value, projectId, reload); else ctx.body.append(hint(`${L("B09_Sheet_LoadFailed")} price-sources`, true)); }); } export const baseDataTab: B09Tab = { key: "base_data", label: () => L("B09_Estimation_Tab_BaseData"), render(ctx) { if (!ctx.projectId) { ctx.body.append(hint(L("B09_Sheet_NoProject"))); return; } show(ctx, ctx.projectId); }, };