/* ============================================================================= * 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 }>; trips: string; trips_note: string; variants: Array<{ key: string; label: string; unit_price_krw: string; amount_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; transport_trips?: 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 })), ); body.append( percentBox("기계 수송 회수 (대수 × 왕복)", transport.trips, "비움", (text) => save({ transport_trips: text }), ), ); for (const variant of transport.variants) { body.append( note( !variant.unit_price_krw ? `${variant.label} — 아직 안 섬` : variant.amount_krw ? `${variant.label} — 회당 ${variant.unit_price_krw}원 × ${transport.trips}회 = ${variant.amount_krw}원` : `${variant.label} — 회당 ${variant.unit_price_krw}원 (회수를 넣으면 금액이 섭니다)`, ), ); } for (const line of transport.notes) body.append(note(`⚠ ${line}`)); for (const line of transport.basis) body.append(note(line)); body.append(note(transport.trips_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)); }