/* ============================================================================= * M01_MasterData_UI_Logic_Edit.ts * 로직 한 줄 고치기 — 실무 일위대가표(호표) 모양 * 머리(이름·결과단위·출처·소유) · 설계에서 받을 값 · 호표 줄 · 중간 값 · 결과 식 · 덧줄 · 끝수 * * 칸을 고치면 `row` 를 그 자리에서 바꾸고 `onChange` 만 부름(다시 그리지 않음). * 줄 더하기·지우기 · 요소 고르기처럼 모양이 바뀔 때만 `rerender`. * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { CalcLine, ElementBrief, HoLine, LogicInput, LogicRow, NamedFormula, } from "./M01_MasterData_UI_Logic_Api"; import { tx } from "./M01_MasterData_UI_Logic_Text"; export type PickMode = "element" | "table"; export type PickDone = (item: ElementBrief, column?: string) => void; export interface EditContext { row: LogicRow; file: string; isNew: boolean; files: string[]; reasons: string[]; prices: Record; /** 마지막 시험 계산의 줄 — 호표 차례와 같음(덧줄은 뒤에 붙음) */ lines: CalcLine[] | null; onChange: () => void; onFile: (file: string) => void; onPick: (mode: PickMode, group: string, done: PickDone) => void; } const KINDS = ["인력", "재료", "기계", "로직"]; const COSTS = ["노무비", "재료비", "경비"]; const COST_OF: Record = { 인력: "노무비", 재료: "재료비", 기계: "경비" }; export function formatNumber(value: unknown): string { if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); if (value === null || value === undefined) return ""; return typeof value === "object" ? JSON.stringify(value) : String(value); } function textBox(value: unknown, onInput: (v: string) => void, className = ""): HTMLInputElement { const input = el("input", { className: `m01-logic__input ${className}`, attrs: { type: "text" }, }); input.value = value === null || value === undefined ? "" : String(value); input.addEventListener("input", () => onInput(input.value)); return input; } function choice( options: string[], value: string | undefined, onPick: (v: string) => void, ): HTMLSelectElement { const select = el("select", { className: "m01-logic__input" }); for (const option of value && !options.includes(value) ? [value, ...options] : options) { select.append(el("option", { text: option, attrs: { value: option } })); } select.value = value ?? options[0]; select.addEventListener("change", () => onPick(select.value)); return select; } function dropButton(onClick: () => void): HTMLButtonElement { const button = el("button", { className: "m01-logic__drop", text: "×", attrs: { type: "button", title: tx("DeleteRow") }, }); button.addEventListener("click", onClick); return button; } function section(title: string, body: HTMLElement, onAdd?: () => void): HTMLElement { const head = el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })], }); if (onAdd) head.append(createButton({ label: tx("AddRow"), variant: "ghost", onClick: onAdd })); return el("section", { className: "m01-logic__section", children: [head, body] }); } function grid(headers: string[], rows: HTMLElement[][], className: string): HTMLElement { const table = el("table", { className: `m01-logic__grid ${className}` }); table.append( el("thead", { children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })] }), ); const body = el("tbody"); for (const cells of rows) body.append(el("tr", { children: cells.map((c) => el("td", { children: [c] })) })); table.append(body); return el("div", { className: "m01-logic__scroll", children: [table] }); } export function qtyKind(qty: string): string { const text = qty.trim(); if (/^-?\d+(\.\d+)?$/.test(text)) return tx("Ho_QtyNumber"); if (text.startsWith("찾기(")) return tx("Ho_QtyTable"); return tx("Ho_QtyFormula"); } export function buildEditor(host: HTMLElement, ctx: EditContext): void { const rerender = (): void => { ctx.onChange(); buildEditor(host, ctx); }; const row = ctx.row; const set = (patch: () => void): void => { patch(); ctx.onChange(); }; host.innerHTML = ""; host.append(head(ctx, set, rerender)); if (ctx.reasons.length) { host.append( el("div", { className: "m01-logic__reasons", children: [ el("strong", { text: tx("Head_Blocked") }), ...ctx.reasons.map((r) => el("div", { text: r })), ], }), ); } host.append(inputsSection(row, set, rerender)); const money = !("결과" in row) && (row.결과단위 ?? "").startsWith("원"); if (money) host.append(hoSection(ctx, set, rerender)); host.append(middleSection(row, set, rerender)); if (!money) { const formula = textBox(row.결과?.식 ?? "", (v) => set(() => { row.결과 = { 식: v }; }), ); host.append(section(tx("Result_Title"), formula)); } else { host.append(extraSection(row, set, rerender)); const rounding = textBox(row.끝수 ?? "", (v) => set(() => { row.끝수 = v.trim() ? v : null; }), ); host.append(section(tx("Rounding"), rounding)); } } function field(label: string, control: HTMLElement, wide = false): HTMLElement { return el("label", { className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`, children: [el("span", { text: label }), control], }); } function head(ctx: EditContext, set: (p: () => void) => void, rerender: () => void): HTMLElement { const row = ctx.row; const file = ctx.isNew ? choice(ctx.files, ctx.file, (v) => ctx.onFile(v)) : el("span", { className: "m01-logic__file", text: ctx.file }); const unit = textBox(row.결과단위, (v) => set(() => (row.결과단위 = v))); unit.addEventListener("change", rerender); // 돈 로직 ↔ 돈 아닌 로직 칸이 바뀜 const note = el("textarea", { className: "m01-logic__input m01-logic__note" }); note.value = row.비고 ?? ""; note.addEventListener("input", () => set(() => { if (note.value.trim()) row.비고 = note.value; else delete row.비고; }), ); return el("div", { className: "m01-logic__head", children: [ field(tx("Head_File"), file, true), field( tx("Head_Key"), textBox(row.열쇠, (v) => set(() => (row.열쇠 = v))), ), field( tx("Head_Name"), textBox(row.이름, (v) => set(() => (row.이름 = v))), ), field(tx("Head_Unit"), unit), field( tx("Head_Source"), textBox(row.출처, (v) => set(() => (row.출처 = v))), ), field( tx("Head_Owner"), choice(["공용", "개인"], row.소유, (v) => set(() => (row.소유 = v))), ), field(tx("Head_Note"), note, true), ], }); } function inputsSection( row: LogicRow, set: (p: () => void) => void, rerender: () => void, ): HTMLElement { const list = row.입력 ?? []; const rows = list.map((spec: LogicInput, i) => { const low = textBox(spec.범위?.[0] ?? "", () => range(), "m01-logic__num"); const high = textBox(spec.범위?.[1] ?? "", () => range(), "m01-logic__num"); const range = (): void => set(() => { if (low.value.trim() === "" && high.value.trim() === "") delete spec.범위; else spec.범위 = [Number(low.value), Number(high.value)]; }); return [ textBox(spec.이름, (v) => set(() => (spec.이름 = v))), textBox(spec.단위 ?? "", (v) => set(() => { if (v.trim()) spec.단위 = v; else delete spec.단위; }), ), textBox((spec.고르기 ?? []).join(", "), (v) => set(() => { const items = v .split(",") .map((x) => x.trim()) .filter(Boolean); if (items.length) spec.고르기 = items; else delete spec.고르기; }), ), el("div", { className: "m01-logic__range", children: [low, el("span", { text: "∼" }), high], }), dropButton(() => { list.splice(i, 1); rerender(); }), ]; }); const headers = [ tx("Head_Name"), tx("Inputs_Unit"), tx("Inputs_Choices"), tx("Inputs_Range"), "", ]; return section(tx("Inputs_Title"), grid(headers, rows, "m01-logic__grid--inputs"), () => { (row.입력 ??= []).push({ 이름: "" }); rerender(); }); } function priceCell(ctx: EditContext, item: HoLine, i: number): HTMLElement { const line = ctx.lines?.[i]; if (line) return el("span", { className: "m01-logic__money", text: formatNumber(line.단가) }); if (item.종류 === "로직" || item.요소.includes("{")) { return el("span", { className: "m01-logic__muted", text: tx("Ho_PriceLater") }); } const brief = ctx.prices[item.요소]; if (brief === undefined) return el("span", { className: "m01-logic__muted", text: "" }); if (brief === null) return el("span", { className: "m01-logic__bad", text: tx("Ho_Missing") }); return el("span", { className: "m01-logic__money", text: formatNumber(brief.값) }); } function hoSection( ctx: EditContext, set: (p: () => void) => void, rerender: () => void, ): HTMLElement { const list = ctx.row.호표 ?? []; const rows = list.map((item: HoLine, i) => { const element = textBox(item.요소, (v) => set(() => (item.요소 = v)), "m01-logic__ref"); const pick = createButton({ label: tx("Ho_Pick"), variant: "ghost", onClick: () => ctx.onPick("element", item.종류, (found) => { const [group, book] = found.ref.split(":"); const key = found.ref.slice(group.length + book.length + 2); item.요소 = group === "로직" ? `로직(${book}:${key})` : found.ref; if (KINDS.includes(group)) item.종류 = group; item.이름 = found.이름 ?? item.이름; if (found.규격) item.규격 = found.규격; else delete item.규격; if (!item.비목 && COST_OF[item.종류]) item.비목 = COST_OF[item.종류]; if (group !== "로직") ctx.prices[found.ref] = found; rerender(); }), }); const brief = ctx.prices[item.요소]; const spec = item.규격 ?? brief?.규격 ?? ""; const qty = textBox(item.수량, (v) => { set(() => (item.수량 = v)); tag.textContent = qtyKind(v); }); const tag = el("span", { className: "m01-logic__tag", text: qtyKind(item.수량) }); const table = createButton({ label: tx("Ho_PickTable"), variant: "ghost", onClick: () => ctx.onPick("table", "소요량", (found, column) => { const ref = found.ref; item.수량 = `찾기(${ref})${column ? `.${column}` : ""}`; rerender(); }), }); const cost = item.종류 === "로직" && !item.비목 ? choice(["", ...COSTS], "", (v) => set(() => (item.비목 = v || undefined))) : choice(COSTS, item.비목, (v) => set(() => (item.비목 = v))); return [ choice(KINDS, item.종류, (v) => { item.종류 = v; rerender(); }), el("div", { className: "m01-logic__pair", children: [element, pick] }), el("div", { className: "m01-logic__stack", children: [ textBox(item.이름 ?? "", (v) => set(() => (item.이름 = v))), el("span", { className: "m01-logic__muted", text: spec }), ], }), textBox(item.단위 ?? "", (v) => set(() => (item.단위 = v)), "m01-logic__unit"), el("div", { className: "m01-logic__pair", children: [tag, qty, table] }), priceCell(ctx, item, i), el("span", { className: "m01-logic__money", text: formatNumber(ctx.lines?.[i]?.금액 ?? "") }), cost, dropButton(() => { list.splice(i, 1); rerender(); }), ]; }); const headers = [ tx("Ho_Kind"), tx("Ho_Element"), tx("Ho_Name"), tx("Ho_Unit"), tx("Ho_Qty"), tx("Ho_Price"), tx("Ho_Amount"), tx("Ho_Cost"), "", ]; return section(tx("Ho_Title"), grid(headers, rows, "m01-logic__grid--ho"), () => { (ctx.row.호표 ??= []).push({ 종류: "인력", 요소: "", 이름: "", 단위: "인", 수량: "", 비목: "노무비", }); rerender(); }); } function formulaRows( list: NamedFormula[], set: (p: () => void) => void, rerender: () => void, withCost: boolean, ): HTMLElement[][] { return list.map((item, i) => { const cells: HTMLElement[] = [ textBox(item.이름, (v) => set(() => (item.이름 = v))), textBox(item.식, (v) => set(() => (item.식 = v)), "m01-logic__formula"), ]; if (withCost) { cells.push( choice(COSTS, item.비목, (v) => set(() => (item.비목 = v))), textBox(item.출처 ?? "", (v) => set(() => (item.출처 = v))), ); } cells.push( dropButton(() => { list.splice(i, 1); rerender(); }), ); return cells; }); } function middleSection( row: LogicRow, set: (p: () => void) => void, rerender: () => void, ): HTMLElement { const list = row.중간 ?? []; const table = grid( [tx("Head_Name"), tx("Formula"), ""], formulaRows(list, set, rerender, false), "", ); return section(tx("Middle_Title"), table, () => { (row.중간 ??= []).push({ 이름: "", 식: "" }); rerender(); }); } function extraSection( row: LogicRow, set: (p: () => void) => void, rerender: () => void, ): HTMLElement { const list = row.덧줄 ?? []; const headers = [tx("Head_Name"), tx("Formula"), tx("Ho_Cost"), tx("Head_Source"), ""]; return section( tx("Extra_Title"), grid(headers, formulaRows(list, set, rerender, true), ""), () => { (row.덧줄 ??= []).push({ 이름: "", 식: "", 비목: "재료비", 출처: "" }); rerender(); }, ); }