/* ============================================================================= * 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 { 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[]; } 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; } function table(headers: string[], rows: string[][], leftCols: number[]): 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"; tr.append(td); }); tbody.append(tr); } el.append(thead, tbody); return el; } /** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */ function catalogTable(rows: BaseDataRow[]): 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], ); } /** * 기초자료 탭 — 목록표 셋. * * ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다. * 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다). */ 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)); } } /** 중기 탭 — 중기목록표. 합계와 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], ), ); // ⚠ 계산 과정을 감추지 않는다(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; 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], ), ); 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], ), ); } 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], ), ); 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], ), ); 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; 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[]): 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"); const put = (text: string, left = false): void => { const td = document.createElement("td"); td.textContent = text; if (left) td.className = "b09-left"; tr.append(td); }; put(row.code, true); put(row.name, true); put(row.spec, true); put(row.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"; tr.append(td); const page = document.createElement("td"); page.textContent = slot.source_note; page.className = "b09-left"; tr.append(page); } if (!appliedIsLastSlot) { put(money(row.adopted_price_krw)); put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true); } put(row.note, true); tbody.append(tr); } el.append(thead, tbody); return el; } /** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */ function baseReferenceSections( body: HTMLElement, data: PriceSourcesDto["base_reference"], projectId: string, reload: () => void, ): 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], ), ); } // 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다. 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], ), ); // ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고 // 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다. // ⚠ 시도가 들어온 뒤로는 **한 칸에서 전국과 시도를 함께** 고른다 — 범위 칸과 시도 칸을 // 따로 두면 「지역인데 시도를 안 고른 상태」가 생겨 무슨 값으로 섰는지 흐려진다. 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)); } for (const text of comparison.notes) body.append(note(text)); baseReferenceSections(body, data.base_reference, projectId, reload); } /** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */ 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)); }