/* ============================================================================= * M01_MasterData_UI_Logic_Wizard_Steps.ts * 「새로 만들기」 걸음별 화면 — 이름 · 원문 절 · 중간 값 · 호표 줄 · 설계 입력 · 할증·끝수 * 한 걸음 = `{name, render(ctx), ready(draft)}` · 시험 계산·저장은 모달 본체(`Wizard.ts`)가 얹음 * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { searchElements, searchPrice } from "./M01_MasterData_UI_Logic_Api"; import { fetchSectionTables, searchJobs, type TableBrief, } from "./M01_MasterData_UI_Logic_Wizard_Api"; import { atomEditor, select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom"; import { BASES, COSTS, COST_OF, atomReady, collectInputs, emptyAtom, emptyQty, isMoney, qtyPlain, qtyReady, type Branch, type Draft, type Line, type LineKind, type Middle, } from "./M01_MasterData_UI_Logic_Wizard_Model"; import { tw, type WizardTextKey } from "./M01_MasterData_UI_Logic_Wizard_Text"; export interface Ctx { d: Draft; /** 값이 바뀜 — 미리보기·다음 단추 다시 셈 */ changed: () => void; } export interface Step { key: WizardTextKey; question: WizardTextKey; render: (ctx: Ctx) => HTMLElement; ready: (d: Draft) => boolean; } const page = (question: WizardTextKey, ...body: (HTMLElement | string)[]): HTMLElement => el("div", { className: "m01w__page", children: [el("h3", { text: tw(question) }), ...body], }); const note = (text: string): HTMLElement => el("p", { className: "m01w__muted", text }); const fail = (e: unknown): void => showToast(e instanceof Error ? e.message : tw("Failed"), "error"); /* ── 1. 이름 ─────────────────────────────────────────────────────────── */ const UNITS = ["원/㎡", "원/m", "원/㎥", "원/개소", "원/본", "원/톤"]; const nameStep: Step = { key: "S_Name", question: "Q_Name", ready: (d) => d.name.trim() !== "" && d.unit.trim() !== "", render: ({ d, changed }) => { const other = !UNITS.includes(d.unit); const own = el("div"); const drawOwn = (on: boolean): void => own.replaceChildren( ...(on ? [ textBox(d.unit, tw("Unit_Own_Ph"), (v) => { d.unit = v; changed(); }), ] : []), ); drawOwn(other); return page( "Q_Name", textBox(d.name, tw("Name_Ph"), (v) => { d.name = v; changed(); }), el("h4", { text: tw("Q_Unit") }), select( [...UNITS, tw("Unit_Other")].map((u) => ({ value: u, text: u })), other ? tw("Unit_Other") : d.unit, (v) => { const isOther = v === tw("Unit_Other"); d.unit = isOther ? "" : v; drawOwn(isOther); changed(); }, ), own, note(tw("Unit_Money")), ); }, }; /* ── 2. 원문 절 ──────────────────────────────────────────────────────── */ const sourceStep: Step = { key: "S_Source", question: "Q_Source", ready: (d) => d.section.trim() !== "", render: ({ d, changed }) => { const list = el("div", { className: "m01w__stack" }); const box = createInputField({ value: d.section, placeholder: tw("Section_Ph"), type: "text", }); const draw = (found: TableBrief[], searched: boolean): void => { list.replaceChildren( ...(found.length ? found.map((t) => tableRow(t, d, changed)) : searched ? [note(tw("Section_None")), note(tw("Section_Skip"))] : []), ); }; const find = (): void => { d.section = box.input.value.trim(); d.tables = []; changed(); if (!d.section) return draw([], false); void fetchSectionTables(d.book, d.section) .then((found) => { d.tables = [...found]; // 처음에는 이 절의 표를 모두 씀 — 빼려면 체크를 풂 draw(found, true); changed(); }) .catch(fail); }; box.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find()); box.input.addEventListener("input", () => { d.section = box.input.value.trim(); changed(); }); if (d.section && !d.tables.length) find(); else draw(d.tables, d.tables.length > 0); return page( "Q_Source", select( ["산림품셈", "건설품셈"].map((b) => ({ value: b, text: b })), d.book, (v) => { d.book = v; find(); }, tw("Book"), ), el("div", { className: "m01w__inline", children: [box.root, createButton({ label: tw("Section_Find"), onClick: find })], }), list, ); }, }; function tableRow(t: TableBrief, d: Draft, changed: () => void): HTMLElement { const check = el("input", { attrs: { type: "checkbox", "data-table": t.키 } }); check.checked = d.tables.some((x) => x.키 === t.키); check.addEventListener("change", () => { d.tables = check.checked ? [...d.tables, t] : d.tables.filter((x) => x.키 !== t.키); changed(); }); const usage = t.용도?.대상?.join("·") ?? ""; return el("label", { className: "m01w__table", children: [ check, el("div", { className: "m01w__stack", children: [ el("strong", { text: `${t.이름} · ${t.키}` }), el("span", { className: "m01w__muted", text: [ t.기준 ? `${tw("Table_Base")} ${t.기준}` : "", `${tw("Table_Cols")} ${Object.keys(t.값칸 ?? {}).join("·")}`, usage ? `${tw("Table_Usage")} ${usage}` : "", ] .filter(Boolean) .join(" · "), }), ], }), ], }); } /* ── 3. 중간 값(조건 나누기) ─────────────────────────────────────────── */ const newMiddle = (): Middle => ({ name: "", branches: [], otherwise: emptyAtom() }); const middleStep: Step = { key: "S_Middle", question: "Q_Middle", ready: () => true, render: ({ d, changed }) => { const done = el("div", { className: "m01w__stack" }); const form = el("div", { className: "m01w__stack" }); let cur = newMiddle(); const drawDone = (): void => done.replaceChildren( ...d.middles.map((m, i) => el("div", { className: "m01w__inline", children: [ el("strong", { text: m.name }), el("span", { className: "m01w__muted", text: `${m.branches.length + 1}갈래` }), createButton({ label: tw("Line_Del"), variant: "ghost", onClick: () => { d.middles.splice(i, 1); drawDone(); changed(); }, }), ], }), ), ); const drawForm = (): void => { const rows = cur.branches.map((b) => branchRow(b, d.tables, changed)); form.replaceChildren( textBox( cur.name, tw("Middle_Name"), (v) => { cur.name = v.trim(); }, tw("Middle_Name"), ), ...rows, createButton({ label: tw("Middle_AddIf"), variant: "ghost", onClick: () => { cur.branches.push({ input: "", op: "<=", than: "", then: emptyAtom() }); drawForm(); }, }), el("strong", { text: tw("Middle_Else") }), atomEditor(cur.otherwise, d.tables, changed, ["value", "table", "input"]), createButton({ label: tw("Middle_Done"), onClick: () => { const ok = cur.name && atomReady(cur.otherwise) && cur.branches.every((b) => b.input && b.than.trim() && atomReady(b.then)); if (!ok) return showToast(tw("Middle_Need"), "warning"); d.middles.push(cur); cur = newMiddle(); drawDone(); drawForm(); changed(); }, }), ); }; drawDone(); drawForm(); return page("Q_Middle", note(tw("Middle_Note")), done, form); }, }; function branchRow(b: Branch, tables: TableBrief[], changed: () => void): HTMLElement { return el("div", { className: "m01w__branch", children: [ el("span", { text: tw("Middle_If") }), textBox(b.input, tw("Qty_Name"), (v) => (b.input = v.trim())), select( ["<=", "<", ">=", ">"].map((o) => ({ value: o, text: o })), b.op, (v) => (b.op = v as Branch["op"]), ), textBox(b.than, tw("Qty_Number_Ph"), (v) => (b.than = v)), el("span", { text: tw("Middle_Then") }), atomEditor(b.then, tables, changed, ["value", "table", "input"]), ], }); } /* ── 4. 호표 줄 ──────────────────────────────────────────────────────── */ interface Found { ref: string; 이름?: string; 규격?: string; 단위?: unknown; 구분?: string; 상세구분?: string; } /** 찾기 → 구분 → 상세구분 → 후보 — 후보를 누르면 `onPick` */ function pickBox( search: (q: string) => Promise, onPick: (f: Found) => void, start: string, ): HTMLElement { const box = createInputField({ value: start, placeholder: tw("Line_Find"), type: "search" }); const list = el("div", { className: "m01w__found" }); const shown = el("p", { className: "m01w__picked" }); const filters = el("div", { className: "m01w__inline" }); let all: Found[] = []; let sub = ""; let detail = ""; const distinct = (rows: Found[], k: "구분" | "상세구분"): string[] => [ ...new Set(rows.map((r) => r[k]).filter((v): v is string => !!v)), ]; const draw = (): void => { const inSub = all.filter((f) => !sub || f.구분 === sub); const inDetail = inSub.filter((f) => !detail || f.상세구분 === detail); const all_ = { value: "", text: "(전체)" }; filters.replaceChildren( select( [all_, ...distinct(all, "구분").map((v) => ({ value: v, text: v }))], sub, (v) => ((sub = v), (detail = ""), draw()), tw("Line_Sub"), ), select( [all_, ...distinct(inSub, "상세구분").map((v) => ({ value: v, text: v }))], detail, (v) => ((detail = v), draw()), tw("Line_Detail"), ), ); list.replaceChildren( ...inDetail.slice(0, 30).map((f) => { const row = el("button", { className: "m01w__row", attrs: { type: "button", "data-ref": f.ref }, text: `${f.이름 ?? ""} ${f.규격 ?? ""}`.trim() + ` · ${f.구분 ?? ""} ${f.상세구분 ?? ""}`, }); row.addEventListener("click", () => pick(f)); return row; }), ); }; const pick = (f: Found): void => { shown.textContent = `✔ ${f.이름 ?? ""} ${f.규격 ?? ""}`.trim(); onPick(f); }; const load = (auto: boolean): void => { const q = box.input.value.trim(); void search(q) .then((rows) => { all = rows; sub = detail = ""; draw(); const exact = rows.filter((r) => r.이름 === q); if (auto && exact.length === 1) pick(exact[0]); }) .catch(fail); }; let timer = 0; box.input.addEventListener("input", () => { window.clearTimeout(timer); timer = window.setTimeout(() => load(false), 250); }); load(true); return el("div", { className: "m01w__stack", children: [box.root, filters, list, shown] }); } const KINDS: { kind: LineKind; label: WizardTextKey }[] = [ { kind: "인력", label: "K_job" }, { kind: "재료", label: "K_mat" }, { kind: "기계", label: "K_eq" }, { kind: "로직", label: "K_logic" }, ]; const blankLine = (kind: LineKind = "인력"): Line => ({ kind, ref: "", args: "", name: "", unit: kind === "인력" ? "인" : kind === "기계" ? "시간" : "", qty: emptyQty(), cost: COST_OF[kind], }); const timesPicker = (d: Draft, q: Line["qty"], changed: () => void): HTMLElement => select( [ { value: "", text: tw("Qty_NoTimes") }, ...d.middles.map((m) => ({ value: m.name, text: m.name })), ], q.times, (v) => { q.times = v; changed(); }, tw("Qty_Times"), ); const linesStep: Step = { key: "S_Lines", question: "Q_Lines", ready: (d) => (isMoney(d.unit) ? d.lines.length > 0 : qtyReady(d.result)), render: ({ d, changed }) => { if (!isMoney(d.unit)) { return page( "Result_Q", atomEditor(d.result.atom, d.tables, changed), timesPicker(d, d.result, changed), ); } const done = el("div", { className: "m01w__stack" }); const form = el("div", { className: "m01w__stack" }); const chips = el("div", { className: "m01w__inline" }); let cur = blankLine(); const drawDone = (): void => done.replaceChildren( ...(d.lines.length ? d.lines.map((l, i) => el("div", { className: "m01w__inline m01w__done", children: [ el("strong", { text: `${l.kind} · ${l.name}` }), el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }), createButton({ label: tw("Line_Del"), variant: "ghost", onClick: () => { d.lines.splice(i, 1); drawDone(); changed(); }, }), ], }), ) : [note(tw("Line_None"))]), ); const drawForm = (start = ""): void => { let unitBox: HTMLInputElement | null = null; const picker = (): HTMLElement => { const set = (f: Found): void => { cur.name = `${f.이름 ?? ""}${cur.kind === "기계" && f.규격 ? ` ${f.규격}` : ""}`.trim(); if (cur.kind === "재료") { cur.ref = { 구분: f.구분 ?? "", ...(f.상세구분 ? { 상세구분: f.상세구분 } : {}) }; if (typeof f.단위 === "string" && f.단위) cur.unit = f.단위; if (unitBox) unitBox.value = cur.unit; } else cur.ref = f.ref; }; if (cur.kind === "로직") { const atom = { kind: "logic" as const, key: "", args: "" }; const bridge = (): void => { cur.ref = atom.key; cur.args = atom.args; cur.name ||= atom.key; }; return atomEditor(atom, d.tables, bridge, ["logic"]); } const search = cur.kind === "인력" ? (q: string) => searchJobs(q).then((r) => r.items as Found[]) : cur.kind === "재료" ? (q: string) => searchPrice(q).then((r) => r.items as Found[]) : (q: string) => searchElements("기계", q).then((r) => r.items as Found[]); return pickBox(search, set, start); }; const unit = createInputField({ value: cur.unit, placeholder: tw("Line_Unit"), type: "text", }); unit.input.addEventListener("input", () => (cur.unit = unit.input.value.trim())); unitBox = unit.input; form.replaceChildren( select( KINDS.map((k) => ({ value: k.kind, text: tw(k.label) })), cur.kind, (v) => { cur = blankLine(v as LineKind); drawForm(); }, tw("Line_Kind"), ), picker(), unit.root, el("strong", { text: tw("Line_Qty") }), atomEditor(cur.qty.atom, d.tables, changed), timesPicker(d, cur.qty, changed), select( ["", ...COSTS].map((c) => ({ value: c, text: c || "(자동)" })), cur.cost, (v) => (cur.cost = v), tw("Line_Cost"), ), createButton({ label: tw("Line_Add"), onClick: () => { if (!cur.ref || !qtyReady(cur.qty)) return showToast(tw("Line_Need"), "warning"); d.lines.push(cur); cur = blankLine(cur.kind); drawDone(); drawForm(); changed(); }, }), ); }; // 표의 값 칸에서 바로 줄 넣기 — 값 칸 이름으로 찾아 두고 수량은 그 표 칸으로 const drawChips = (): void => chips.replaceChildren( ...d.tables.flatMap((t) => Object.keys(t.값칸 ?? {}).map((col) => createButton({ label: `${col} (${t.키})`, variant: "pill", onClick: () => { const target = t.용도?.대상 ?? []; const kind: LineKind = target.includes("기계") ? "기계" : target.includes("재료") ? "재료" : "인력"; cur = blankLine(kind); cur.qty.atom = { kind: "table", table: t.키, col, cond: {} }; drawForm(col); }, }), ), ), ); drawDone(); drawForm(); drawChips(); return page( "Q_Lines", done, d.tables.length ? el("strong", { text: tw("Line_FromTable") }) : "", chips, form, ); }, }; /* ── 5. 설계 입력 ────────────────────────────────────────────────────── */ const inputsStep: Step = { key: "S_Inputs", question: "Q_Inputs", ready: () => true, render: ({ d, changed }) => { const shapes = collectInputs(d); if (!shapes.length) return page("Q_Inputs", note(tw("Inputs_None"))); const cards = shapes.map((s) => { const meta = (d.meta[s.name] ??= { desc: "", unit: "", min: "", max: "" }); const range = s.choices ? [note(`${tw("In_Pick")} · ${s.choices.join(" · ")}`)] : [ note(tw("In_Number")), el("div", { className: "m01w__inline", children: [ textBox(meta.min, tw("In_Range"), (v) => ((meta.min = v), changed())), textBox(meta.max, tw("In_Range"), (v) => ((meta.max = v), changed())), ], }), ]; return el("div", { className: "m01w__card", attrs: { "data-input": s.name }, children: [ el("strong", { text: s.name }), textBox(meta.desc, tw("In_Desc"), (v) => ((meta.desc = v), changed())), textBox(meta.unit, tw("In_Unit"), (v) => ((meta.unit = v), changed())), ...range, ], }); }); return page("Q_Inputs", ...cards); }, }; /* ── 6. 할증·덧줄 · 끝수 ─────────────────────────────────────────────── */ const extraStep: Step = { key: "S_Extra", question: "Q_Extra", ready: () => true, render: ({ d, changed }) => { const money = isMoney(d.unit); if (!money) return page("Q_Extra", note("돈이 아닌 로직 — 덧줄·끝수 없음")); const list = el("div", { className: "m01w__stack" }); const draw = (): void => list.replaceChildren( ...d.extras.map((e, i) => el("div", { className: "m01w__inline m01w__card", children: [ textBox(e.name, tw("Extra_Name"), (v) => ((e.name = v), changed())), select( Object.keys(BASES).map((b) => ({ value: b, text: b })), e.base, (v) => ((e.base = v), changed()), tw("Extra_Base"), ), textBox(e.pct, tw("Extra_Pct"), (v) => ((e.pct = v), changed())), select( COSTS.map((c) => ({ value: c, text: c })), e.cost, (v) => ((e.cost = v), changed()), tw("Extra_Cost"), ), createButton({ label: tw("Line_Del"), variant: "ghost", onClick: () => { d.extras.splice(i, 1); draw(); changed(); }, }), ], }), ), ); draw(); return page( "Q_Extra", list, createButton({ label: tw("Extra_Add"), variant: "ghost", onClick: () => { d.extras.push({ name: "", base: "노무비", pct: "", cost: "경비" }); draw(); changed(); }, }), el("h4", { text: tw("Round_Title") }), select( [ { value: "", text: tw("Round_None") }, { value: "원 미만 버림", text: tw("Round_Down") }, { value: "원 미만 반올림", text: tw("Round_Half") }, ], d.round, (v) => { d.round = v; changed(); }, ), note(tw("Round_Note")), ); }, }; export const STEPS: Step[] = [nameStep, sourceStep, middleStep, linesStep, inputsStep, extraStep];