/* ============================================================================= * M01_MasterData_UI_Logic_Wizard.ts * 「새로 만들기」 모달 — 질문 한 번에 하나 · 옆에 지금까지 만든 호표 미리보기 * 걸음 = 이름 → 원문 절 → 중간 값 → 호표 줄 → 설계 입력 → 할증·끝수 → 시험 계산 → 저장 * 시험 계산은 `POST /calc` 에 `row`+`file` 을 실어 보냄(파일에 안 씀) · 저장은 `POST /save` `add` * 걸음별 화면 = `_Wizard_Steps.ts` · 값 고르기 칸 = `_Wizard_Atom.ts` · 식 조립 = `_Wizard_Model.ts` * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { ApiError, fetchLogicFiles, runCalc, saveFiles, type CalcAnswer, type LogicFile, } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { select, textBox } from "./M01_MasterData_UI_Logic_Wizard_Atom"; import { searchLogics } from "./M01_MasterData_UI_Logic_Wizard_Api"; import { atomPlain, buildRow, collectInputs, emptyDraft, isMoney, middleText, qtyPlain, type Draft, } from "./M01_MasterData_UI_Logic_Wizard_Model"; import { STEPS, type Step } from "./M01_MasterData_UI_Logic_Wizard_Steps"; import { tw } from "./M01_MasterData_UI_Logic_Wizard_Text"; import { plainReason } from "./M01_MasterData_UI_Test_Text"; import "./M01_MasterData_UI_Logic_Wizard_Style.css"; export interface WizardOptions { /** 저장이 끝나면 새 키와 함께 — 방식 C 로 여는 자리 */ onSaved?: (key: string) => void; } const fail = (e: unknown): void => showToast(e instanceof Error ? e.message : tw("Failed"), "error"); /** 「새로 만들기」 모달을 엶 */ export function openLogicWizard(options: WizardOptions = {}): void { const d: Draft = emptyDraft(); let files: LogicFile[] = []; let at = 0; let answered = false; const testValues: Record = {}; const steps: Step[] = [ ...STEPS, { key: "S_Test", question: "Q_Test", render: () => testView(), ready: () => answered }, { key: "S_Save", question: "Q_Save", render: () => saveView(), ready: () => false }, ]; const body = el("div", { className: "m01w__body" }); const preview = el("aside", { className: "m01w__preview" }); const head = el("p", { className: "m01w__step" }); const back = createButton({ label: tw("Back"), variant: "ghost", onClick: () => go(at - 1) }); const next = createButton({ label: tw("Next"), onClick: () => go(at + 1) }); const close = (): void => backdrop.remove(); const dialog = el("div", { className: "m01w", attrs: { role: "dialog", "aria-label": tw("Title") }, children: [ el("div", { className: "m01w__top", children: [ el("h2", { text: tw("Title") }), createButton({ label: tw("Close"), variant: "ghost", onClick: close }), ], }), head, el("div", { className: "m01w__main", children: [body, preview] }), el("div", { className: "m01w__nav", children: [back, next] }), ], }); const backdrop = el("div", { className: "m01w__backdrop", children: [dialog] }); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); const changed = (): void => { drawPreview(); next.disabled = !steps[at].ready(d); }; const go = (to: number): void => { at = Math.max(0, Math.min(steps.length - 1, to)); if (at === STEPS.length) answered = false; // 시험 계산 걸음에 들어올 때마다 새로 const step = steps[at]; head.textContent = tw("Step", { n: at + 1, total: steps.length, name: tw(step.key) }); body.replaceChildren(step.render({ d, changed })); back.disabled = at === 0; next.hidden = at === steps.length - 1; changed(); }; /* ── 시험 계산 ── */ const testView = (): HTMLElement => { const out = el("div", { className: "m01w__stack" }); const inputs = collectInputs(d); const fields = inputs.map((s) => { testValues[s.name] ??= s.choices ? String(s.choices[0]) : ""; const meta = d.meta[s.name]; const control = s.choices ? select( s.choices.map((c) => ({ value: String(c), text: String(c) })), testValues[s.name], (v) => (testValues[s.name] = v), s.name, ) : textBox( testValues[s.name], meta?.min && meta?.max ? `${meta.min} ∼ ${meta.max}` : tw("Qty_Number_Ph"), (v) => (testValues[s.name] = v), meta?.unit ? `${s.name} (${meta.unit})` : s.name, ); control.dataset.input = s.name; return control; }); const run = createButton({ label: tw("Test_Run"), onClick: () => { void calc() .then((answer) => { answered = answer.ok; out.replaceChildren(resultView(answer)); changed(); }) .catch(fail); }, }); out.append(el("p", { className: "m01w__muted", text: tw("Test_Empty") })); return el("div", { className: "m01w__page", children: [el("h3", { text: tw("Q_Test") }), ...fields, run, out], }); }; const calc = async (): Promise => { if (!files.length) files = await fetchLogicFiles(); const file = (files.find((f) => f.book === d.book) ?? files[0]).file; const given: Record = {}; for (const s of collectInputs(d)) { const raw = (testValues[s.name] ?? "").trim(); if (raw === "") continue; const option = s.choices?.find((c) => String(c) === raw); given[s.name] = option ?? (Number.isNaN(Number(raw)) ? raw : Number(raw)); } return runCalc({ key: "", inputs: given, row: buildRow(d), file }); }; /* ── 저장 ── */ const saveView = (): HTMLElement => { const own = files.filter((f) => f.file.includes("자체")); const message = el("p", { className: "m01w__muted", text: tw("Save_Owner") }); const errors = el("div", { className: "m01w__reasons", attrs: { hidden: "" } }); let file = own[0]?.file ?? ""; const save = createButton({ label: tw("Save_Do"), disabled: !own.length, onClick: () => void doSave(file, errors), }); return el("div", { className: "m01w__page", children: [ el("h3", { text: tw("Q_Save") }), message, own.length ? select( own.map((f) => ({ value: f.file, text: f.file })), file, (v) => (file = v), tw("Save_File"), ) : el("p", { className: "m01w__bad", text: tw("Save_None") }), errors, save, ], }); }; const doSave = async (file: string, errors: HTMLElement): Promise => { const target = files.find((f) => f.file === file); if (!target) return; errors.hidden = true; try { const row = buildRow(d); await saveFiles([ { file: target.file, version: target.version, changes: [{ op: "add", row }] }, ]); showToast(tw("Save_Done"), "success"); const found = await searchLogics(row.이름); const mine = found.logics.filter((l) => l.이름 === row.이름).map((l) => l.키); close(); if (mine.length) options.onSaved?.(mine[mine.length - 1]); } catch (e) { if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) { const list = (e.detail as { errors?: string[]; stale?: string[] }).errors ?? []; errors.replaceChildren(...list.map((t) => el("div", { text: t }))); errors.hidden = false; } else fail(e); } }; /* ── 옆 미리보기 ── */ const drawPreview = (): void => { const rows: HTMLElement[] = [ el("h4", { text: tw("Preview") }), el("p", { className: "m01w__muted", text: [d.name || "…", d.unit, d.section ? `${d.book} ${d.section}` : ""] .filter(Boolean) .join(" · "), }), ]; for (const m of d.middles) { rows.push( el("div", { className: "m01w__line", text: `${tw("Preview_Middle")} ${m.name} = ${middleText(m)}`, }), ); } if (isMoney(d.unit)) { for (const l of d.lines) { rows.push( el("div", { className: "m01w__line", attrs: { "data-line": l.name }, children: [ el("strong", { text: `${l.kind} · ${l.name}` }), el("span", { className: "m01w__muted", text: `${l.unit} · ${qtyPlain(l.qty)}` }), ], }), ); } } else if (d.result.atom) rows.push(el("div", { className: "m01w__line", text: atomPlain(d.result.atom) })); const inputs = collectInputs(d).map((s) => s.name); if (inputs.length) { rows.push( el("p", { className: "m01w__muted", text: `${tw("Preview_Inputs")}: ${inputs.join(" · ")}`, }), ); } for (const e of d.extras.filter((x) => x.name)) { rows.push( el("div", { className: "m01w__line", text: `${tw("Preview_Extra")} ${e.name} · ${e.base} × ${e.pct}%`, }), ); } preview.replaceChildren(...rows); }; document.body.append(backdrop); void fetchLogicFiles() .then((f) => (files = f)) .catch(fail); go(0); } function resultView(answer: CalcAnswer): HTMLElement { if (!answer.ok) { return el("div", { className: "m01w__reasons", children: [ el("strong", { text: tw("Test_Stopped") }), el("div", { text: plainReason(answer.reason) }), ], }); } const parts: HTMLElement[] = []; if (answer.lines) { parts.push( el("table", { className: "m01w__grid", children: [ el("tbody", { children: answer.lines.map((l) => el("tr", { children: [ el("td", { text: l.이름 }), el("td", { text: `${formatNumber(l.수량)} ${l.단위}` }), el("td", { className: "m01w__money", text: formatNumber(l.금액) }), ], }), ), }), ], }), ); } const total = answer.sums?.["계"] ?? (answer.result as number | undefined); if (total !== undefined) { parts.push( el("p", { className: "m01w__total", attrs: { "data-total": String(total) }, text: `${answer.sums ? tw("Sum_Total") : tw("Sum_Result")} ${formatNumber(total)}`, }), ); } return el("div", { className: "m01w__stack", children: parts }); }