Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts
T

311 lines
10 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,
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<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 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<CalcAnswer> => {
if (!files.length) files = await fetchLogicFiles();
const file = (files.find((f) => f.book === d.book) ?? files[0]).file;
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), 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<void> => {
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 });
}