Files
Aislo/M01_MasterData/LogicBuilder/M01_LB_Wizard.ts
T

629 lines
23 KiB
TypeScript

/* =============================================================================
* M01_LB_Wizard.ts
* 로직 만들기 판 — 단계 안내 6단계 · openWizard() → LbLogic | null(취소) · 저장은 Entry 몫
* ① 어느 품셈 절 ② 표 칸 역할 ③ 결과 단위 ④ 계산(Calc.ts) ⑤ 항목 연결(Calc.ts) ⑥ 이름 · 결과 종류
* 답은 Build.ts 가 로직 한 줄로 짜고 Blocks.readLogic 이 블록으로 읽음 · 답할 때마다 곁에 예시값
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함 9 · 이 폴더째 지워도 됨
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { type CalcAnswer } from "../M01_MasterData_UI_Logic_Api";
import { calcLater, plainError } from "../EasyCalc/M01_EasyCalc_Api";
import { readLogic, type LbLogic } from "./M01_LB_Blocks";
import { autoLinks, stepCalc, stepLink } from "./M01_LB_Wizard_Calc";
import {
buildRow,
colsOf,
trialOf,
unitOfBase,
type Role,
type WAnswers,
type WCtx,
type WTable,
} from "./M01_LB_Wizard_Build";
import {
get,
tablePreview,
toTable,
type SectionItem,
type TableHead,
} from "./M01_LB_Wizard_Parts";
import "./M01_LB_Wizard.css";
const TEXT = {
Title: ["로직 만들기 — 단계 안내", "Build a logic — step by step"],
Close: ["닫기", "Close"],
Back: ["← 뒤로", "← Back"],
Next: ["다음 →", "Next →"],
Make: ["만들기", "Create"],
StepOf: ["{n} / 6 단계", "Step {n} of 6"],
Q1: ["어느 품셈 절인가요?", "Which section of the standard?"],
Q2: ["표의 칸마다 무엇인가요?", "What is each column?"],
Q3: ["결과는 무슨 단위인가요?", "What unit is the result?"],
Q4: ["더하거나 나눌 것이 있나요?", "Anything to add or divide?"],
Q5: ["값은 어느 항목으로 가나요?", "Where does each value go?"],
Q6: ["이름과 결과 종류", "Name and kind of result"],
Book: ["적용 품셈", "Standard"],
Search: ["절 번호나 이름을 쳐서 찾기 — 예: 13-4-1 메쌓기", "Search — e.g. 13-4-1"],
Loading: ["찾는 중…", "Searching…"],
NoSection: ["찾는 절 없음", "No section"],
SecLine: ["표 {t} 개 · 만든 로직 {l} 개", "{t} tables · {l} logics"],
Tables: ["이 절의 표 — 하나를 고름", "Tables in this section — pick one"],
TableLine: [
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
"{b} · {c} conditions · {v} results · {n} rows",
],
Notes: ["주", "Notes"],
Preview: ["원문 표 미리 보기", "Table preview"],
RoleHint: [
"칸 머리를 누르면 조건 → 고정 → 결과 로 바뀜 · 조건 칸의 선택 목록은 표 줄에서 저절로 만듦",
"Press a header to change its role",
],
Cond: ["조건", "Condition"],
Fixed: ["고정", "Fixed"],
Result: ["결과", "Result"],
Unused: ["쓰지 않음", "Unused"],
Choices: ["선택 목록", "Choices"],
Free: ["숫자 입력", "Number input"],
PickTable: ["표를 하나 골라 주세요", "Pick a table"],
NeedResult: ["결과 칸이 하나는 있어야 함", "At least one result column"],
FixedVal: ["고정값", "Fixed value"],
BaseUnit: ["표 기준 「{b}」", "Table base “{b}”"],
UnitPick: ["결과단위", "Result unit"],
Name: ["이름", "Name"],
KindMoney: ["1 {p} 당 금액", "Amount per {p}"],
KindValue: ["다른 로직이 쓸 값", "A value other logics use"],
ValueCol: ["내보낼 값(결과 칸)", "Value to give"],
ValueUnit: ["값의 단위", "Unit of the value"],
NeedName: ["이름을 적어 주세요", "Enter a name"],
Sample: ["예시값", "Sample values"],
SampleHint: ["값을 바꾸면 곁의 결과가 바로 바뀜", "Change a value to see the result"],
Waiting: ["계산 중…", "Calculating…"],
AmountPer: ["1 {p} 당 약 {m} 원", "About {m} won per {p}"],
ValueIs: ["{c} = {v} {u}", "{c} = {v} {u}"],
NotYet: ["아직 계산할 것이 없음", "Nothing to calculate yet"],
Fail: ["읽지 못함", "Could not read"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(6)).toLocaleString("ko-KR");
const LBL = { preview: t("Preview"), notes: t("Notes") };
const BOOKS = ["산림품셈", "건설품셈"];
const UNITS = ["㎡", "㎥", "m", "km", "ha", "개소", "개", "ton", "본", "100㎡", "100m", "10m"];
const emptyTable = (): WTable => ({
key: "",
이름: "",
기준: "",
출처: "",
조건: {},
값칸: {},
줄: [],
주: [],
});
/** 단계 안내 — 끝나면 LbLogic · 취소(닫기 · Esc)는 null */
export function openWizard(): Promise<LbLogic | null> {
return new Promise((resolve) => {
let settled = false;
const finish = (r: LbLogic | null): void => {
if (!settled) resolve(r);
settled = true;
};
const a: WAnswers = {
book: BOOKS[0],
section: "",
table: emptyTable(),
roles: {},
fixed: {},
options: {},
resultUnit: "원/㎡",
mods: [],
links: [],
extras: [],
name: "",
kind: "금액",
valueCol: "",
};
const ctx: WCtx = { a, sectionTables: [], changed: () => runPreview() };
let step = 1;
let sectionTitle = "";
let trial: Record<string, unknown> = {};
let linked = false;
const main = el("div", { className: "m01-lbw__main" });
const side = el("aside", { className: "m01-lbw__side" });
const nav = el("div", { className: "m01-lbw__nav" });
const ask = el("h4", { className: "m01-lbw__ask" });
const sample = el("div", { className: "m01-lbw__sample" });
const result = el("p", { className: "m01-lbw__result", attrs: { "aria-live": "polite" } });
side.append(
el("h5", { text: t("Sample") }),
el("p", { className: "m01-lbw__muted", text: t("SampleHint") }),
sample,
result,
);
/* ── 예시값 ── */
const previewRow = () => {
const money = step >= 5 && a.kind === "금액" && a.links.some((l) => l.kind !== "없음");
const col = a.valueCol || colsOf(a, "결과")[0];
const row = buildRow({ ...a, kind: money ? "금액" : "값", valueCol: col });
return { row, money, col };
};
let paintedFor = "";
const runPreview = (): void => {
if (step < 2 || !colsOf(a, "결과").length) {
sample.replaceChildren();
result.textContent = t("NotYet");
return;
}
const { row, money, col } = previewRow();
const defaults = trialOf(a, row);
trial = Object.fromEntries(
row.입력.map((i) => [i.이름, i.이름 in trial ? trial[i.이름] : defaults[i.이름]]),
);
const sig = JSON.stringify(row.입력);
if (sig !== paintedFor) {
paintedFor = sig;
sample.replaceChildren(
...row.입력.map((i) => {
const label = el("span", { className: "m01-lbw__muted", text: i.이름 });
if (i.고르기?.length) {
const f = createSelectField({
options: i.고르기.map((v) => ({ value: String(v), text: String(v) })),
value: String(trial[i.이름]),
compact: true,
onChange: (v) => (
(trial[i.이름] = i.고르기!.find((x) => String(x) === v)),
runPreview()
),
});
return el("label", { className: "m01-lbw__line", children: [label, f.root] });
}
const f = createInputField({
type: "text",
value: String(trial[i.이름] ?? ""),
compact: true,
});
f.input.addEventListener("input", () => {
const n = Number(f.input.value);
trial[i.이름] = f.input.value.trim() !== "" && Number.isFinite(n) ? n : f.input.value;
runPreview();
});
return el("label", { className: "m01-lbw__line", children: [label, f.root] });
}),
);
}
result.textContent = t("Waiting");
calcLater(row, trial, (ans: CalcAnswer | null, plain: string | null) => {
if (!ans?.ok) return void (result.textContent = plain ?? t("Fail"));
const per = a.resultUnit.replace(/^원\//, "");
const v = money ? (ans.sums?.["계"] ?? Number(ans.result ?? 0)) : Number(ans.result);
result.textContent = money
? t("AmountPer", { p: per, m: Math.round(v).toLocaleString("ko-KR") })
: t("ValueIs", { c: col, v: fmt(v), u: a.table.값칸[col] ?? "" });
});
};
/* ── 1단계 절 · 표 ── */
const stepSection = (): (() => string | null) => {
const bookBox = createSelectField({
label: t("Book"),
options: BOOKS.map((b) => ({ value: b, text: b })),
value: a.book,
compact: true,
onChange: (v) => ((a.book = v), load()),
});
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m01-lbw__list" });
const tables = el("div", { className: "m01-lbw__tables" });
const preview = el("div", { className: "m01-lbw__preview" });
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") }));
try {
const r = await get<{ sections: SectionItem[] }>("/sections", {
book: a.book,
q: q.input.value.trim(),
limit: "60",
});
if (mine !== seq) return;
list.replaceChildren(
...(r.sections.length
? r.sections.map((s) => {
const b = el("button", {
className: `m01-lbw__row${a.section === s.section && sectionTitle === s.title ? " is-on" : ""}`,
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "m01-lbw__muted",
text: `${s.chapter} · ${t("SecLine", { t: s.tables, l: s.logics })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "m01-lbw__muted", text: t("NoSection") })]),
);
} catch (e) {
if (mine === seq) showToast(plainError(e), "error");
}
};
const pickSection = async (s: SectionItem): Promise<void> => {
a.section = s.section;
sectionTitle = s.title;
a.name = a.name || s.title;
try {
const heads: TableHead[] = [];
for (const group of ["소요량", "계수"])
heads.push(
...(
await get<{ tables: TableHead[] }>("/tables", {
group,
section: s.section,
sub: a.book,
size: "100",
})
).tables,
);
ctx.sectionTables = [];
tables.replaceChildren(
el("h5", { text: t("Tables") }),
...heads.map((h) => {
const b = el("button", {
className: `m01-lbw__row${a.table.key === h.키 ? " is-on" : ""}`,
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "m01-lbw__muted",
text: t("TableLine", {
b: h.기준 || "—",
c: Object.keys(h.조건).length,
v: Object.keys(h.값칸).length,
n: h.count,
}),
}),
],
});
b.addEventListener("click", () => void pickTable(h, heads));
return b;
}),
);
if (heads.length === 1) await pickTable(heads[0], heads);
main.dispatchEvent(new Event("m01lbw"));
} catch (e) {
showToast(plainError(e), "error");
}
};
const pickTable = async (h: TableHead, heads: TableHead[]): Promise<void> => {
const [full, opt] = await Promise.all([
get<{ table: WTable & { 키: string } }>("/table", { file: h.file, key: h.키 }),
get<{ 값: Record<string, (string | number)[]> }>("/table/options", {
file: h.file,
key: h.키,
}),
]);
a.table = toTable(full.table, h.file);
a.roles = Object.fromEntries([
...Object.keys(a.table.조건).map((c) => [c, "조건" as Role]),
...Object.keys(a.table.값칸).map((c) => [c, "결과" as Role]),
]);
a.options = opt.값 ?? {};
a.fixed = {};
a.links = [];
a.mods = [];
a.resultUnit = unitOfBase(a.table.기준);
linked = false;
ctx.sectionTables = await Promise.all(
heads
.filter((x) => x.키 !== h.키)
.map(async (x) =>
toTable(
(
await get<{ table: WTable & { 키: string } }>("/table", {
file: x.file,
key: x.키,
})
).table,
x.file,
),
),
);
tables
.querySelectorAll(".m01-lbw__row")
.forEach((n, i) => n.classList.toggle("is-on", heads[i]?.키 === h.키));
preview.replaceChildren(tablePreview(a.table, LBL));
renderNav();
};
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
main.replaceChildren(bookBox.root, q.root, list, tables, preview);
void load();
if (a.table.key) preview.replaceChildren(tablePreview(a.table, LBL));
return () => (a.table.key ? null : t("PickTable"));
};
/* ── 2단계 칸 역할 ── */
const stepRoles = (): (() => string | null) => {
const box = el("div", { className: "m01-lbw__roles" });
const draw = (): void => {
const cols = [...Object.keys(a.table.조건), ...Object.keys(a.table.값칸)];
const chips = cols.map((c) => {
const isCond = c in a.table.조건;
const role = a.roles[c];
const b = el("button", {
className: `m01-lbw__role is-${role}`,
attrs: { type: "button", title: t("RoleHint") },
children: [
el("strong", { text: c }),
el("span", {
text:
role === "조건"
? t("Cond")
: role === "고정"
? t("Fixed")
: role === "결과"
? t("Result")
: t("Unused"),
}),
],
});
b.addEventListener("click", () => {
a.roles[c] = isCond
? role === "조건"
? "고정"
: "조건"
: role === "결과"
? "쓰지 않음"
: "결과";
if (a.roles[c] === "고정" && a.fixed[c] === undefined)
a.fixed[c] = a.options[c]?.[0] ?? "";
ctx.changed();
draw();
});
const extra: HTMLElement[] = [];
if (role === "조건")
extra.push(
el("span", {
className: "m01-lbw__muted",
text:
a.options[c]?.length && a.table.조건[c] !== "범위"
? `${t("Choices")}: ${a.options[c].join(" · ")}`
: t("Free"),
}),
);
if (role === "고정") {
const list = a.options[c] ?? [];
if (list.length)
extra.push(
createSelectField({
options: list.map((v) => ({ value: String(v), text: String(v) })),
value: String(a.fixed[c]),
compact: true,
ariaLabel: t("FixedVal"),
onChange: (v) => (
(a.fixed[c] = list.find((x) => String(x) === v) ?? v),
ctx.changed()
),
}).root,
);
else {
const f = createInputField({
type: "text",
value: String(a.fixed[c] ?? ""),
compact: true,
});
f.input.addEventListener(
"input",
() => (
(a.fixed[c] =
Number.isFinite(Number(f.input.value)) && f.input.value.trim() !== ""
? Number(f.input.value)
: f.input.value),
ctx.changed()
),
);
extra.push(f.root);
}
}
return el("div", { className: "m01-lbw__rolebox", children: [b, ...extra] });
});
box.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("RoleHint") }),
...chips,
tablePreview(a.table, LBL, a.roles),
);
renderNav();
};
main.replaceChildren(box);
draw();
return () => (colsOf(a, "결과").length ? null : t("NeedResult"));
};
/* ── 3단계 결과 단위 ── */
const stepUnit = (): (() => string | null) => {
const per = a.resultUnit.replace(/^원\//, "");
const opts = [...new Set([per, ...UNITS])];
const box = createSelectField({
label: t("UnitPick"),
options: opts.map((u) => ({ value: u, text: `원/${u}` })),
value: per,
onChange: (v) => ((a.resultUnit = `원/${v}`), ctx.changed()),
});
main.replaceChildren(
el("p", { className: "m01-lbw__muted", text: t("BaseUnit", { b: a.table.기준 || "—" }) }),
box.root,
);
return () => null;
};
/* ── 6단계 이름 · 결과 종류 ── */
const stepName = (): (() => string | null) => {
const name = createInputField({
type: "text",
label: t("Name"),
value: a.name,
placeholder: "예: 돌쌓기 메쌓기",
});
name.input.addEventListener("input", () => ((a.name = name.input.value), renderNav()));
const per = a.resultUnit.replace(/^원\//, "");
const kinds = el("div", { className: "m01-lbw__chips" });
const valueBox = el("div", { className: "m01-lbw__stack" });
const drawKind = (): void => {
for (const [k, label] of [
["금액", t("KindMoney", { p: per })],
["값", t("KindValue")],
] as const) {
const b = el("button", {
className: `m01-lbw__chip${a.kind === k ? " is-on" : ""}`,
attrs: { type: "button" },
text: label,
});
b.addEventListener(
"click",
() => (
(a.kind = k),
(a.valueCol = a.valueCol || colsOf(a, "결과")[0]),
a.kind === "값" &&
a.resultUnit.startsWith("원/") &&
(a.resultUnit = a.table.값칸[a.valueCol] || ""),
drawKind(),
ctx.changed(),
renderNav()
),
);
kinds.append(b);
}
};
const paint = (): void => {
kinds.replaceChildren();
drawKind();
if (a.kind !== "값") return valueBox.replaceChildren();
const col = createSelectField({
label: t("ValueCol"),
options: colsOf(a, "결과").map((c) => ({ value: c, text: c })),
value: a.valueCol,
onChange: (v) => (
(a.valueCol = v),
(a.resultUnit = a.table.값칸[v] || a.resultUnit),
ctx.changed()
),
});
const unit = createInputField({
type: "text",
label: t("ValueUnit"),
value: a.resultUnit,
placeholder: "예: ㎥/시간",
});
unit.input.addEventListener(
"input",
() => ((a.resultUnit = unit.input.value.trim()), ctx.changed()),
);
valueBox.replaceChildren(col.root, unit.root);
};
kinds.addEventListener("click", () => queueMicrotask(paint));
paint();
main.replaceChildren(name.root, kinds, valueBox);
return () => (a.name.trim() ? null : t("NeedName"));
};
/* ── 틀 ── */
let valid: () => string | null = () => null;
const draw = (): void => {
ask.textContent = t(`Q${step}` as TKey);
const body = el("div");
main.replaceChildren(body);
if (step === 1) valid = stepSection();
else if (step === 2) valid = stepRoles();
else if (step === 3) valid = stepUnit();
else if (step === 4) {
stepCalc(ctx, main);
valid = () => null;
} else if (step === 5) {
if (linked) stepLink(ctx, main);
else {
linked = true;
main.replaceChildren(el("p", { className: "m01-lbw__muted", text: t("Loading") }));
void autoLinks(ctx).then(() => step === 5 && stepLink(ctx, main));
}
valid = () => null;
} else valid = stepName();
side.hidden = step === 1;
renderNav();
runPreview();
};
const renderNav = (): void => {
const why = valid();
const back = createButton({
label: t("Back"),
variant: "ghost",
disabled: step === 1,
onClick: () => ((step -= 1), draw()),
});
const next = createButton({
label: step === 6 ? t("Make") : t("Next"),
disabled: !!why,
onClick: () => (step === 6 ? make() : ((step += 1), draw())),
});
nav.replaceChildren(
el("span", {
className: "m01-lbw__muted",
text: `${t("StepOf", { n: step })}${why ? ` · ${why}` : ""}`,
}),
back,
next,
);
};
const make = (): void => {
try {
const row = buildRow({ ...a, valueCol: a.valueCol || colsOf(a, "결과")[0] });
const lb = readLogic(row, { key: null, version: null, isBase: false });
finish(lb);
closeModal();
} catch (e) {
showToast(e instanceof Error ? e.message : t("Fail"), "error");
}
};
main.addEventListener("m01lbw", renderNav);
let closeModal = (): void => undefined;
const modal = openModal({
title: t("Title"),
closeLabel: t("Close"),
dialogClass: "m01-lbw",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
closeModal = close;
body.append(ask, el("div", { className: "m01-lbw__cols", children: [main, side] }), nav);
body.addEventListener(
"keydown",
(ev) => ev.altKey && ev.key === "ArrowLeft" && step > 1 && ((step -= 1), draw()),
);
},
});
void modal;
draw();
});
}