/* ============================================================================= * B09_Estimation_UI_Tab_PriceCompare.ts * B09 자재단가대비표 탭 — 원천 슬롯 여섯 · 채택 · 최소단가 (실무 `자재단가대비표` · STmate `wM_Boxa`) * * - 칸: 호표 · 명칭 · 규격 · 단위 · 슬롯 1~6(단가 · 페이지) · 채택 · 비고. * - 채택 슬롯 = 굵게·색 · 최소단가 = 「최소」 표시(서버 `min_slot`). 값 없는 슬롯은 빈칸(0 아님). * - 2차(PLAN 12장 · 프로젝트 단위) — 줄마다 채택 슬롯을 고르거나 「변동없음 / 1~5 단가 / 최소단가」로 일괄. * 고친 줄 = 「사용자」 표시 + ↺(지우면 계산값으로). 값 없는 슬롯은 서버가 받지 않음. * - ⚠ 저장은 고른 슬롯 번호만 — 금액은 서버가 다시 셈(다른 탭은 내역 한 벌을 새로 받음). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types"; import { L, el, hint, numberCell, sheetTable, userMark, won } from "./B09_Estimation_UI_Sheet"; import { loadEdits, loadPriceCompare, saveEdits, type EditChange, type EditsDto, type PriceCompareDto, } from "./B09_Estimation_UI_Store"; const SECTION = "adopted_slots"; function head(slotNames: string[]): HTMLElement { const thead = el("thead"); const top = el("tr"); const bottom = el("tr"); const fixed = [ L("B09_Sheet_Col_Sheet"), L("B09_Sheet_Col_Name"), L("B09_Sheet_Col_Spec"), L("B09_Sheet_Col_Unit"), ]; for (const label of fixed) { const th = el("th", "", label); th.rowSpan = 2; top.append(th); } slotNames.forEach((name, index) => { const th = el("th", "", `${index + 1} ${name}`); th.colSpan = 2; top.append(th); bottom.append( el("th", "", L("B09_Sheet_Col_UnitPrice")), el("th", "", L("B09_Sheet_Col_Page")), ); }); for (const label of [L("B09_Sheet_Adopt"), L("B09_Sheet_Col_Note")]) { const th = el("th", "", label); th.rowSpan = 2; top.append(th); } thead.append(top, bottom); return thead; } type Row = PriceCompareDto["material_comparison"]["rows"][number]; function adoptPicker(row: Row, onPick: (slot: number) => void): HTMLSelectElement { const select = el("select"); row.slots.forEach((slot, index) => { if (slot.price_krw === null) return; // 값 없는 원천은 고를 수 없음 const option = el("option", "", `${index + 1} ${won(slot.price_krw)}`); option.value = String(index + 1); option.selected = index + 1 === row.adopted_slot; select.append(option); }); select.addEventListener("change", () => onPick(Number(select.value))); return select; } /** 일괄 — 「변동없음 / 1~5 단가 / 최소단가」. 그 슬롯에 값이 있는 줄만 고침(STmate `wM_Boxa`). */ function bulkChanges(rows: Row[], choice: string): EditChange[] { if (choice === "none") return []; return rows.flatMap((row) => { const slot = choice === "min" ? row.min_slot : Number(choice); if (!slot || row.slots[slot - 1]?.price_krw === null) return []; return [{ section: SECTION, key: row.code, value: slot }]; }); } function draw(ctx: B09TabContext, projectId: string, data: PriceCompareDto, edits: EditsDto): void { const table = data.material_comparison; const userSlots = edits[SECTION] ?? {}; const save = (changes: EditChange[]): void => { if (changes.length === 0) return; void saveEdits(projectId, changes) .then(() => { showToast(L("B09_Sheet_Saved"), "success"); ctx.open("price_compare"); }) .catch((error: Error) => showToast(`${L("B09_Sheet_SaveFailed")} ${error.message}`, "error")); }; const bar = el("div", "b09s-bar"); const bulk = el("select"); const choices: Array<[string, string]> = [ ["none", L("B09_Sheet_Bulk_None")], ...[1, 2, 3, 4, 5].map((n): [string, string] => [ String(n), `${n} ${table.slot_names[n - 1] ?? ""}`, ]), ["min", L("B09_Sheet_Bulk_Min")], ]; for (const [value, label] of choices) { const option = el("option", "", label); option.value = value; bulk.append(option); } const apply = el("button", "b09s-undo", L("B09_Sheet_Apply")); apply.type = "button"; apply.addEventListener("click", () => save(bulkChanges(table.rows, bulk.value))); bar.append(el("span", "b09s-head", L("B09_Sheet_Bulk")), bulk, apply); const { wrap, tbody } = sheetTable(head(table.slot_names)); table.rows.forEach((row, index) => { const tr = el("tr", row.code in userSlots ? "is-user" : ""); tr.append( el("td", "", String(index + 1)), el("td", "", row.name), el("td", "", row.spec), el("td", "", row.unit), ); row.slots.forEach((slot, slotIndex) => { const price = numberCell(won(slot.price_krw)); if (slot.adopted) price.classList.add("b09s-adopted"); if (row.min_slot === slotIndex + 1) price.append(el("span", "b09s-min", L("B09_Sheet_Min"))); tr.append(price, el("td", "", slot.source_note)); }); const adopt = el("td"); adopt.append( adoptPicker(row, (slot) => save([{ section: SECTION, key: row.code, value: slot }])), ); if (row.code in userSlots) { adopt.append(userMark(() => save([{ section: SECTION, key: row.code, value: null }]))); } tr.append(adopt, el("td", "b09s-note", row.note)); tbody.append(tr); }); ctx.body.append(el("div", "b09s-title", L("B09_Sheet_Tab_PriceCompare")), bar, wrap); if (table.rows.length === 0) ctx.body.append(hint(L("B09_Sheet_EmptyGroup"))); for (const note of table.notes) ctx.body.append(hint(note)); } export const priceCompareTab: B09Tab = { key: "price_compare", label: () => L("B09_Sheet_Tab_PriceCompare"), render(ctx) { if (!ctx.projectId) { ctx.body.append(hint(L("B09_Sheet_NoProject"))); return; } const projectId = ctx.projectId; ctx.body.append(hint(L("B09_Sheet_Loading"))); Promise.all([loadPriceCompare(projectId), loadEdits(projectId)]) .then(([data, stored]) => { ctx.body.replaceChildren(); draw(ctx, projectId, data, stored.edits); for (const line of stored.skipped) { ctx.body.append(hint(`${L("B09_Sheet_EditSkipped")} ${line}`, true)); } }) .catch((error: Error) => { ctx.body.replaceChildren(hint(`${L("B09_Sheet_LoadFailed")} ${error.message}`, true)); }); }, };