Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts
T

280 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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, runCalc, type CalcAnswer } 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 { createLogic } 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 at = 0;
let answered = false;
const testValues: Record<string, string> = {};
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 onCopied = (key: string): void => {
close();
options.onSaved?.(key);
};
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, onCopied }));
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<CalcAnswer> => {
const given: Record<string, unknown> = {};
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) });
};
/* ── 저장 ── */
const saveView = (): HTMLElement => {
const errors = el("div", { className: "m01w__reasons", attrs: { hidden: "" } });
return el("div", {
className: "m01w__page",
children: [
el("h3", { text: tw("Q_Save") }),
el("p", { className: "m01w__muted", text: tw("Save_Owner") }),
errors,
createButton({ label: tw("Save_Do"), onClick: () => void doSave(errors) }),
],
});
};
const doSave = async (errors: HTMLElement): Promise<void> => {
errors.hidden = true;
try {
const made = await createLogic(buildRow(d));
showToast(tw("Save_Done"), "success");
close();
options.onSaved?.(made.key);
} catch (e) {
if (e instanceof ApiError && typeof e.detail === "object" && e.detail !== null) {
const list = (e.detail as { errors?: 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);
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 });
}