/* ============================================================================= * B09_Estimation_UI_Tab_MaterialPrices.ts * 자재 단가 탭 — 자재 수동 단가 칸 (PLAN 1장 Ⓐ · 랩탑 메인 · 2026-09-14 브레인 판정) * * - 줄 = 일위대가 자원 축이 쓰는 자재(코드 `AR-M-…`) · 코드 · 명칭 · 규격 · 단위 · 쓰인 공종 · * 단가 · 출처 · 넣은 날. 넣은 줄 = 빨간 테두리(수동 단가 = 미확정 · 구조물도·폐기물과 같은 꼴). * - [저장] = 바뀐 줄만 서버로. 단가 칸을 비우면 그 줄을 지움(단가 없음으로 돌아감) · 0 이하는 서버가 거절. * - ⚠ 금액은 서버가 다시 조립 — 여기서 곱하지 않음. 저장 뒤 내역서·일위대가 탭은 새로 받음. * - 자원 축에서 사라진 코드의 저장 줄도 조용히 안 버림 — 경고와 함께 보이고 지울 수 있음. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { showToast } from "@ui/ui_template_elements"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; import { L, el, hint, injectSheetStyles, plainTable, won } from "./B09_Estimation_UI_Sheet"; interface MaterialPriceRow { key: string; name: string; spec: string; unit: string; work_items: string[]; price_krw: string | null; source: string; entered_at: string; missing: boolean; /** `unit_price` = 일위대가 자원(코드 키) · `material_sheet` = 자재총괄(이름 규격 키). */ origin?: string; supply_type?: string; quantity?: string; } interface MaterialPricesDto { status: string; message?: string; rows: MaterialPriceRow[]; unconfirmed_count: number; handoff_note?: string; } const SUPPLY_LABELS: Record = { contractor_supplied: "사급", owner_supplied: "관급 — 도급 금액 밖(수량만)", unknown: "미정 — 관급구분을 먼저 고름(B08 자재총괄)", }; function whereText(row: MaterialPriceRow): string { if (row.missing) return "⚠ 목록에서 사라진 자재 — 단가를 비우고 저장해 지움"; if (row.origin === "material_sheet") { return `자재총괄 ${row.quantity ?? ""}${row.unit} · ${row.work_items.join(", ")} · ${ SUPPLY_LABELS[row.supply_type ?? ""] ?? row.supply_type ?? "" }`; } return `일위대가 · ${row.work_items.join(", ")}`; } interface Change { key: string; price_krw: string; source: string; name: string; spec: string; unit: string; } async function request(projectId: string, init: RequestInit = {}): Promise { const response = await fetch( `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/material-prices`, { credentials: "include", ...init }, ); const body = (await response.json()) as MaterialPricesDto; if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`); return body; } function input(value: string, width: string, placeholder = ""): HTMLInputElement { const node = el("input", "b09s-edit-input"); node.value = value; node.placeholder = placeholder; node.style.width = width; return node; } function draw(ctx: B09TabContext, projectId: string, data: MaterialPricesDto): void { const drafts: Array<{ row: MaterialPriceRow; price: HTMLInputElement; source: HTMLInputElement; }> = []; const save = el("button", "b09s-undo", "저장"); save.type = "button"; const bar = el("div", "b09s-bar"); bar.append(el("span", "b09s-title", L("B09_Estimation_Tab_MaterialPrices")), save); if (data.unconfirmed_count > 0) { bar.append(el("span", "b09s-badge", `수동 단가 ${data.unconfirmed_count}건 — 미확정`)); } const { wrap, tbody } = plainTable([ "코드·키", "명칭", "규격", "단위", "쓰이는 곳", "단가(원)", "출처", "넣은 날", ]); for (const row of data.rows) { const tr = el("tr", row.price_krw ? "is-manual" : ""); const price = input(row.price_krw ?? "", "96px", "비움 = 단가 없음"); price.inputMode = "decimal"; price.title = row.price_krw ? won(row.price_krw) : ""; const source = input(row.source, "160px", "견적 업체·물가지 쪽"); // 자재총괄 줄은 사급만 금액이 섬 — 관급·미정 줄은 칸을 잠금(값이 남아 있으면 지울 수는 있게). const locked = row.origin === "material_sheet" && row.supply_type !== "contractor_supplied" && !row.price_krw; price.disabled = locked; source.disabled = locked; const priceCell = el("td", "b09s-num"); priceCell.append(price); const sourceCell = el("td"); sourceCell.append(source); tr.append( el("td", "", row.key), el("td", "", row.name), el("td", "", row.spec), el("td", "", row.unit), el("td", "b09s-note", whereText(row)), priceCell, sourceCell, el("td", "", row.entered_at), ); tbody.append(tr); drafts.push({ row, price, source }); } save.addEventListener("click", () => { const changes: Change[] = drafts .filter( ({ row, price, source }) => price.value.trim() !== (row.price_krw ?? "") || source.value.trim() !== row.source, ) .map(({ row, price, source }) => ({ key: row.key, price_krw: price.value.trim(), source: source.value.trim(), name: row.name, spec: row.spec, unit: row.unit, })); if (changes.length === 0) return; save.disabled = true; request(projectId, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ changes }), }) .then((next) => { showToast(L("B09_Sheet_Saved"), "success"); ctx.body.replaceChildren(); draw(ctx, projectId, next); }) .catch((error: Error) => { save.disabled = false; showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error"); }); }); ctx.body.append(bar, wrap); if (data.handoff_note) ctx.body.append(hint(data.handoff_note, true)); if (data.rows.length === 0) ctx.body.append(hint("단가 칸을 낼 자재가 없음")); ctx.body.append( hint( "일위대가 자재 — 6번 슬롯(적용 단가)으로 서고 닿은 내역 줄마다 「미확정」으로 셈 · 자재단가대비표에도 보임", ), hint( "자재총괄 사급 자재 — 내역서 끝 「자재(사급)」 줄로 섬(할증 뒤 수량 × 단가) · 관급은 도급 금액 밖", ), hint("값·출처가 같으면 다시 저장해도 넣은 날은 그대로"), ); } export const materialPricesTab: B09Tab = { key: "material_prices", label: () => L("B09_Estimation_Tab_MaterialPrices"), render(ctx) { injectSheetStyles(); if (!ctx.projectId) { ctx.body.append(hint(L("B09_Sheet_NoProject"))); return; } const projectId = ctx.projectId; ctx.body.append(hint(L("B09_Sheet_Loading"))); request(projectId) .then((data) => { ctx.body.replaceChildren(); draw(ctx, projectId, data); }) .catch((error: Error) => { ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); }); }, };