/* ============================================================================= * 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 => 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 { 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 = {}; 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 => { 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 => { 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 => { const [full, opt] = await Promise.all([ get<{ table: WTable & { 키: string } }>("/table", { file: h.file, key: h.키 }), get<{ 값: Record }>("/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(); }); }