/* ============================================================================= * B08_Quantity_UI_StructureSheet_Formula.ts * 구조물도 양식 장의 **식·반올림 칸 표** (PLAN 3장 ⑤ · 반올림 칸) — 700줄 제한으로 본 파일에서 뗌. * * ⚠ 칸을 고치면 **이 화면이 같은 풀이기(TS)로 즉시** 다시 풂(왕복 없음) · 값은 [식 저장] 때 서버가 다시 냄. * ========================================================================== */ import { evaluateSheet, type FormulaRounding, type FormulaSheet, type RoundingMode, } from "./B08_Quantity_Formula"; import type { StructureSheet, StructureSheetRow } from "./B08_Quantity_UI_StructureSheet"; export const DESTINATION_LABELS: Record = { earthwork: "토공집계", material: "자재총괄", unit_price: "일위대가", reference: "보여주기", haul_deduction: "운반 공제", }; /** * 반올림 갈래 — **엑셀 이름을 함께 보임**(명세 13장 대응표). ⚠ INT↔ROUNDDOWN · 위로↔ROUNDUP 은 * 양수에서 같고 **음수에서만 갈려** 시험을 빠져나감 — 이름만 보고 고르면 틀림. */ export const ROUNDING_LABELS: Record = { none: "안 함", floor: "내림 — 엑셀 INT", trunc: "버림 — 엑셀 ROUNDDOWN", round: "사사오입 — 엑셀 ROUND", ceil_away: "올림 — 엑셀 ROUNDUP", ceil: "위로(+쪽) — 엑셀 없음", round_half_even: "짝수 반올림 — 엑셀 없음", }; export const ROUNDING_HINT = "음수에서 갈림: -2.5 → INT -3 · ROUNDDOWN -2 · ROUNDUP -3 · 위로 -2 (양수는 INT=ROUNDDOWN)"; export type Rounding = { mode: string; digits: number }; /** 같은 반올림인지 — 「안 함」은 자리수와 무관하게 하나(서버 `rounding_key` 와 같은 뜻). */ export function roundingKey(rounding: Rounding | null | undefined): string { const mode = rounding?.mode ?? "none"; return mode === "none" ? "none" : `${mode}:${Math.trunc(rounding?.digits ?? 0)}`; } /** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */ export function amountText(row: StructureSheetRow): string { if (row.skipped) return "안 섬"; return num(row.unit_amount, 3); } /** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */ export function noteText(row: StructureSheetRow): string { if (row.skipped) return row.reason ?? ""; if (row.error) return `⚠ ${row.error}`; // 고친 줄은 「사용자 식」 — 양식·전개와 한 단 더 갈림(브레인 챙길 것 ③). const origin = row.source === "user" ? "사용자 식" : row.source === "library" ? "양식" : row.basis_kind === "observed" ? "실무 관측" : "치수 전개"; const mode = row.rounding?.mode; return mode && mode !== "none" ? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리` : origin; } export type FormulaEditRow = { seq: number; formula: string | null; rounding: Rounding | null }; /** * 양식 장의 원단위 수량표 — 식·반올림 칸을 고치면 **이 화면이 같은 풀이기로 즉시** 다시 풂. * `onSave` 는 바뀐 줄의 **뜻한 상태 전부**(식·반올림)를 받음 — 양식과 같은 칸은 null. 값은 서버가 다시 냄. */ export function formulaTable( sheet: StructureSheet, onSave: (rows: FormulaEditRow[]) => Promise, onDirty: (dirty: boolean) => void, ): HTMLElement { const body = sheet.formula_sheet as FormulaSheet; const saved = new Map( sheet.rows.map((row) => [row.no, `${row.formula ?? ""}|${roundingKey(row.rounding)}`]), ); const defaults = new Map( sheet.rows.map((row) => [ row.no, { formula: row.default_formula ?? "", rounding: row.default_rounding ?? null }, ]), ); const cells = new Map< number, { amount: HTMLElement; note: HTMLElement; input: HTMLInputElement; mode: HTMLSelectElement; digits: HTMLInputElement; } >(); // 칸에서 읽은 한 줄 — 빈 식은 양식 식(되돌림). const current = (seq: number): { formula: string; rounding: FormulaRounding } => { const cell = cells.get(seq)!; return { formula: cell.input.value.trim() || defaults.get(seq)?.formula || "", rounding: { mode: cell.mode.value as RoundingMode, digits: Math.trunc(Number(cell.digits.value) || 0), }, }; }; const stateOf = (seq: number): string => { const now = current(seq); return `${now.formula}|${roundingKey(now.rounding)}`; }; const isUser = (seq: number): boolean => { const now = current(seq); const base = defaults.get(seq); return ( now.formula !== base?.formula || roundingKey(now.rounding) !== roundingKey(base?.rounding) ); }; const wrap = el("div", "b08-grid"); const scroller = el("div", "b08-grid__scroll"); const grid = el("table", "b08-grid__table b08-grid__table--summary b08-sheet__rows"); const headRow = document.createElement("tr"); for (const label of ["공종", "규격", "산출 근거 · 식", "수량", "단위", "갈 곳", "비고"]) { headRow.append(el("th", "", label)); } const thead = document.createElement("thead"); thead.append(headRow); const tbody = document.createElement("tbody"); const status = el("span", "b08-grid__caption"); const saveButton = el("button", "b08-spec__save", "식 저장"); saveButton.type = "button"; const discardButton = el("button", "b08-quantity__tab", "고친 것 버리기"); discardButton.type = "button"; // 칸 값으로 장을 다시 풀어 **모든 줄**을 고침 — 앞 줄을 고치면 뒷줄도 따라 바뀜. const recompute = (): void => { const results = evaluateSheet({ ...body, rows: body.rows.map((row) => (cells.has(row.seq) ? { ...row, ...current(row.seq) } : row)), }); for (const result of results) { const cell = cells.get(result.seq); const row = sheet.rows.find((item) => item.no === result.seq); if (!cell || !row) continue; const local: StructureSheetRow = { ...row, unit_amount: result.amount === null ? null : Number(result.amount), skipped: result.skipped, reason: result.reason ?? "", error: result.error ?? "", rounding: current(result.seq).rounding, source: isUser(result.seq) ? "user" : "library", }; cell.amount.textContent = amountText(local); cell.note.textContent = noteText(local); const changed = stateOf(result.seq) !== saved.get(result.seq); cell.input.classList.toggle("is-changed", changed); cell.mode.classList.toggle("is-changed", changed); cell.digits.disabled = cell.mode.value === "none"; } const dirty = [...cells.keys()].some((seq) => stateOf(seq) !== saved.get(seq)); saveButton.disabled = !dirty; discardButton.disabled = !dirty; status.textContent = dirty ? "저장 안 한 식 있음 — 값은 이 화면 계산(저장하면 서버가 다시 셈 · 같은 양식의 장 모두에 걸림 · 고친 식은 이 양식·프로젝트에 묶여 제원(높이 등)을 바꿔도 따라감)" : ""; onDirty(dirty); }; for (const row of sheet.rows) { const tr = document.createElement("tr"); const basis = el("td", "", row.basis.replace(/\*\*/g, "")); const line = el("div", "b08-sheet__formula"); const input = document.createElement("input"); input.type = "text"; input.value = row.formula ?? ""; input.title = `양식 식: ${row.default_formula ?? ""}`; input.addEventListener("input", recompute); // 반올림 — 고르개에 엑셀 이름을 함께 적음(명세 13장 대응표, 음수에서만 갈리는 함정). const base = row.default_rounding ?? null; const baseText = `${ROUNDING_LABELS[base?.mode ?? "none"]}${base && base.mode !== "none" ? ` ${base.digits}자리` : ""}`; const mode = document.createElement("select"); mode.title = `${ROUNDING_HINT} · 양식: ${baseText}`; for (const [value, label] of Object.entries(ROUNDING_LABELS)) { const option = document.createElement("option"); option.value = value; option.textContent = label; mode.append(option); } mode.value = row.rounding?.mode ?? "none"; mode.addEventListener("change", recompute); const digits = document.createElement("input"); digits.type = "number"; digits.min = "-6"; digits.max = "10"; digits.step = "1"; digits.value = String(row.rounding?.digits ?? 0); digits.title = "자리수 — 2 는 소수 둘째 자리, -1 은 10의 자리"; digits.className = "b08-sheet__digits"; digits.addEventListener("input", recompute); const revert = el("button", "", "양식대로"); revert.type = "button"; revert.title = "이 줄의 식·반올림을 양식 원래 값으로 되돌림"; revert.addEventListener("click", () => { input.value = defaults.get(row.no)?.formula ?? ""; mode.value = base?.mode ?? "none"; digits.value = String(base?.digits ?? 0); recompute(); }); line.append(input, mode, digits, revert); basis.append(line); const amount = el("td", "", amountText(row)); const note = el("td", "", noteText(row)); tr.append( el("td", "", row.name), el("td", "", row.spec), basis, amount, el("td", "", row.unit), el("td", "", DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? ""), note, ); cells.set(row.no, { amount, note, input, mode, digits }); tbody.append(tr); } grid.append(thead, tbody); scroller.append(grid); saveButton.addEventListener("click", () => { void (async () => { saveButton.disabled = true; status.textContent = "저장 중…"; const rows = [...cells.keys()] .filter((seq) => stateOf(seq) !== saved.get(seq)) .map((seq) => { const now = current(seq); const base = defaults.get(seq); // 양식과 같은 칸은 null — 서버가 「고친 적 없음」으로 지움. return { seq, formula: now.formula !== base?.formula ? now.formula : null, rounding: roundingKey(now.rounding) !== roundingKey(base?.rounding) ? now.rounding : null, }; }); try { const errors = await onSave(rows); status.textContent = errors.length ? `⚠ 저장했으나 안 서는 줄: ${errors.join(" · ")}` : ""; } catch (error) { status.textContent = error instanceof Error ? error.message : "식을 저장하지 못함"; saveButton.disabled = false; } })(); }); discardButton.addEventListener("click", () => { for (const row of sheet.rows) { const cell = cells.get(row.no); if (!cell) continue; cell.input.value = row.formula ?? ""; cell.mode.value = row.rounding?.mode ?? "none"; cell.digits.value = String(row.rounding?.digits ?? 0); } recompute(); }); const actions = el("div", "b08-sheet__actions"); actions.append(saveButton, discardButton, status); saveButton.disabled = true; discardButton.disabled = true; wrap.append(scroller, actions); return wrap; } export function el( tag: K, className: string, text = "", ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; } export function num(value: number | null | undefined, digits: number): string { if (value === null || value === undefined || Number.isNaN(value)) return "-"; return value.toLocaleString("ko-KR", { minimumFractionDigits: digits, maximumFractionDigits: digits, }); }