Files
Aislo/M01_MasterData/M01_MasterData_UI_Logic_Wizard.ts
T
eomsangdonandClaude Sonnet 5 f458da7f20 feat(M01): 로직 화면에 「새로 만들기」 단추 · 저장 뒤 방식 C 로 열기 · 테스트 컨테이너와 방식 A·B 화면 걷음
- 로직 화면 바의 새로 만들기 단추가 모달을 엶 · 저장·본떠 저장 뒤 새 자체 로직을 흐름 그림으로 바로 엶
- 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
2026-09-21 09:57:17 +09:00

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_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 });
}