diff --git a/B09_Estimation/B09_Estimation_UI_Factors.ts b/B09_Estimation/B09_Estimation_UI_Factors.ts new file mode 100644 index 00000000..dd64b8e0 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Factors.ts @@ -0,0 +1,295 @@ +/* ============================================================================= + * B09_Estimation_UI_Factors.ts + * 산출 조건 — 품셈이 범위로 준 계수·장비 규격·공구손료·수송·품 할증 (사용자 확정 ① 딸림 지시, 2026-09-09) + * + * - 옛 `B09_Estimation_UI_BaseData.ts` 의 산출 조건 구역을 그대로 옮김(PLAN 12장 옛 탭 옮기기). + * - ⚠ **계산 입력**이다 — 고르면 PUT `/estimation/factors` 로 저장되고 단가가 다시 섬. + * 고를 수 있는 것은 원문에 적힌 값뿐이고, 왜 그 값인지를 칸 밑에 그대로 적음. + * - ⚠ 기본값으로 돌고 있음을 숨기지 않음 — 조용히 기본으로 돌면 잠정인 줄도 모름. + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { el } from "./B09_Estimation_UI_Sheet"; +import { head, note } from "./B09_Estimation_UI_Table"; + +export interface FactorOption { + key: string; + value?: string; + label: string; + note?: string; +} + +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[]; +} + +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). */ +interface MiscMaterialRow { + percent: string; + min: string; + max: string; + basis: string[]; + base_items: number; + base_note: string; +} + +/** 기계 수송비 칸 — 거리·도로 구분이 있어야 줄이 섬(산림품셈 10-4). */ +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). */ +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 { + 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((payload: { message?: string }) => payload.message ?? "") + .catch(() => ""); + throw new Error(message || `factors save ${response.status}`); + } +} + +/** + * 숫자 칸 하나 — **빈 칸이 기본**. [적용]을 눌러야 저장됨. + * ⚠ 고르는 칸과 달리 사용자가 값을 짓는 자리라 누를 때만 보냄(「2」를 치는 도중에 2% 로 저장되지 않게). + */ +function percentBox( + label: string, + value: string, + placeholder: string, + onApply: (text: string) => void, +): HTMLElement { + const wrap = el("div", "b09s-hint b09s-inline"); + const input = el("input"); + input.type = "number"; + input.step = "0.1"; + input.min = "0"; + input.value = value; + input.placeholder = placeholder; + input.style.width = "72px"; + const apply = el("button", "", "적용"); + apply.type = "button"; + apply.addEventListener("click", () => onApply(input.value.trim())); + wrap.append(el("span", "b09s-head", label), input, el("span", "", "%"), apply); + return wrap; +} + +export function picker( + label: string, + options: FactorOption[], + chosen: string, + onPick: (key: string) => void, +): HTMLElement { + const wrap = el("div", "b09s-hint b09s-inline"); + const select = el("select"); + for (const option of options) { + const item = el("option", "", option.label); + item.value = option.key; + item.selected = option.key === chosen; + select.append(item); + } + select.addEventListener("change", () => onPick(select.value)); + wrap.append(el("span", "b09s-head", label), select); + return wrap; +} + +/** 산출 조건 구역 — 기초자료 탭 맨 위. `reload` = 저장 뒤 다시 받아 그리기. */ +export function drawFactorChoices( + body: HTMLElement, + data: FactorChoicesDto, + projectId: string, + reload: () => void, +): void { + const save = (payload: Parameters[1]): void => { + void saveFactorChoices(projectId, payload) + .then(reload) + .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); + }; + body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리")); + + for (const row of data.ranges) { + body.append( + picker(`${row.work_item_name} 작업효율(${row.factor})`, row.options, row.chosen, (key) => + save({ range_factor_choices: { [row.key]: key } }), + ), + ); + 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) => + save({ machine_choices: { [row.work_item_code]: key } }), + ), + ); + 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) => + save({ misc_material_percent: text }), + ), + ); + 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) => save({ transport_distance_km: text }), + ), + ); + const roadOptions: FactorOption[] = [ + { key: "", label: "안 고름" }, + ...transport.roads.map((road) => ({ key: road.key, label: road.label })), + ]; + body.append( + picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })), + ); + 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) => + save({ labor_surcharge: { [item.key]: key } }), + ), + ); + 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_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index ff91db4d..fd16990d 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -13,7 +13,6 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; import { L, el, injectSheetStyles } from "./B09_Estimation_UI_Sheet"; -import { legacyTab } from "./B09_Estimation_UI_Tab_Legacy"; import { costSheetTab } from "./B09_Estimation_UI_Tab_CostSheet"; import { rateTableTab } from "./B09_Estimation_UI_Tab_RateTable"; import { billTab } from "./B09_Estimation_UI_Tab_Bill"; @@ -26,8 +25,9 @@ import { listsTab } from "./B09_Estimation_UI_Tab_Lists"; import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc"; import { basisSheetTab } from "./B09_Estimation_UI_Tab_BasisSheet"; import { supplyTab } from "./B09_Estimation_UI_Tab_Supply"; +import { baseDataTab } from "./B09_Estimation_UI_Tab_BaseData"; -/** 탭 등록 — 한 줄에 탭 하나. 옛 탭(`legacyTab`)은 새 탭 파일이 서면 그 줄만 바꿈. */ +/** 탭 등록 — 한 줄에 탭 하나. */ const TABS: B09Tab[] = [ costSheetTab, // 랩탑_메인 rateTableTab, // 랩탑_메인 @@ -39,7 +39,7 @@ const TABS: B09Tab[] = [ summaryTab, listsTab, supplyTab, - legacyTab("base_data", "B09_Estimation_Tab_BaseData"), + baseDataTab, designDocTab, basisSheetTab, ]; diff --git a/B09_Estimation/B09_Estimation_UI_Store.ts b/B09_Estimation/B09_Estimation_UI_Store.ts index 1db83951..6f161977 100644 --- a/B09_Estimation/B09_Estimation_UI_Store.ts +++ b/B09_Estimation/B09_Estimation_UI_Store.ts @@ -180,6 +180,11 @@ async function getJson(path: string): Promise { return body as T; } +/** 산출 조건을 저장한 뒤 — 단가가 다시 서므로 다음에 고르는 탭이 새로 받게 비움. */ +export function forgetBill(projectId: string): void { + bills.delete(projectId); +} + /** 설계내역서 한 벌 — 세 탭이 같은 응답을 봄. `force` 면 다시 받음. */ export function loadBill(projectId: string, force = false): Promise { if (force || !bills.has(projectId)) { diff --git a/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts b/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts new file mode 100644 index 00000000..546e549d --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tab_BaseData.ts @@ -0,0 +1,376 @@ +/* ============================================================================= + * 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); + }, +};