- 로직 화면 바의 새로 만들기 단추가 모달을 엶 · 저장·본떠 저장 뒤 새 자체 로직을 흐름 그림으로 바로 엶 - UI_Test* 다섯 파일 삭제 · 왼쪽 「테스트」 줄 제거 · 모달의 까닭 글은 UI_Logic_Text 것을 씀 - 견본 10개 키 목록은 시험(test_m01_test_container)으로 옮김 · 흐름 그림 시험의 옛 파일 이름을 바로잡음 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
280 lines
9.5 KiB
TypeScript
280 lines
9.5 KiB
TypeScript
/* =============================================================================
|
||
* 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_Logic_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 });
|
||
}
|