/* ============================================================================= * M01_LB_Entry.ts * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). * 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임. * 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createTag, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; import { calcLater, copyEasy, listEasy, openEasy, plainError, type EasyListItem, } from "../EasyCalc/M01_EasyCalc_Api"; import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; import { lbCalcLater, type LbTables } from "./M01_LB_Api"; import { readLogic, type LbLogic } from "./M01_LB_Blocks"; import "./M01_LB_Entry.css"; const TEXT = { Open: ["로직 만들기(시험)", "Logic builder (trial)"], Title: ["로직 만들기", "Logic builder"], CloseWindow: ["창 닫기", "Close window"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], Start_Found: ["찾은 것", "Found"], Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], Start_None: ["찾은 것 없음", "Nothing found"], Start_Field: ["현장 일위대가", "Field unit prices"], Start_FieldNone: ["아직 없음", "None yet"], Start_Wizard: ["단계 안내로 새로 만들기", "Create step by step"], Standard: ["표준", "Standard"], Field: ["현장", "Field"], Per: ["{u} 당", "per {u}"], Name: ["이름", "Name"], Applied: ["적용 품셈", "Applied standard"], Undo: ["실행 취소", "Undo"], Save: ["저장", "Save"], CopyEdit: ["복사 편집", "Copy and edit"], Copying: ["현장 일위대가로 복사하는 중", "Copying to a field unit price"], Copied: ["현장 일위대가로 복사했음 — 이제 고칠 수 있음", "Copied — you can edit it now"], Unsaved: ["저장 안 함", "Not saved"], StandardNote: [ "표준은 읽기만 — 고치려면 「복사 편집」으로 현장 일위대가를 만듦", "Standard items are read-only — use “Copy and edit” to make a field copy", ], Col_Source: ["원문", "Source"], Col_Basis: ["산출근거", "Basis"], Col_Result: ["결과", "Result"], View_Sentence: ["문장", "Sentences"], View_Grid: ["격자", "Grid"], Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], Value: ["결과 {v} {u}", "Result {v} {u}"], Examples: ["예시값", "Example values"], Examples_Note: [ "예시값은 계산을 보려는 값 — 저장 안 됨", "Example values are only for trying — not saved", ], Pick: ["선택", "Choose"], Sheet: ["일위대가표", "Unit-cost sheet"], NotYet: ["준비 중", "Coming soon"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", ], Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], } as const; type TextKey = keyof typeof TEXT; const te = (key: TextKey, vars: Record = {}): string => Object.entries(vars).reduce( (text, [k, v]) => text.replace(`{${k}}`, String(v)), (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, ); /** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 10) */ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; /** 찾은 것 목록에 한 번에 보이는 줄 수 */ const SHOWN = 50; /** 「원/㎡」 → 「㎡」 */ const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); /** 「㎡」 → 「1㎡」 · 「100㎡」 은 그대로 */ const amountOf = (resultUnit: string): string => { const unit = unitOf(resultUnit); return /^\d/.test(unit) ? unit : `1${unit}`; }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); /** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */ interface Paper { key: string; version: string; isBase: boolean; row: LogicRow; trial: Record; lb: LbLogic | null; tables: LbTables; } /* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); } catch { return null; } }; const calcWith = ( p: Paper, onDone: (ans: CalcAnswer | null, plain: string | null) => void, ): void => { try { lbCalcLater(p.row, p.trial, p.tables, onDone); } catch { calcLater(p.row, p.trial, onDone); } }; /** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createLogicBuilderButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder }); } function openLogicBuilder(): void { let body: HTMLElement; let paper: Paper | null = null; let dirty = false; let logics: EasyListItem[] | null = null; let query = ""; let view: "sentence" | "grid" = "sentence"; const mayLeave = async (): Promise => !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); const notYet = (): void => showToast(te("NotYet"), "info"); /* ---------- 시작 화면 ---------- */ const listRow = (item: EasyListItem): HTMLElement => { const row = el("button", { className: "m01-lb__item", attrs: { type: "button", title: item.where }, children: [ el("span", { className: "m01-lb__item-name", text: item.name }), createTag(item.isBase ? te("Standard") : te("Field"), item.isBase ? "neutral" : "accent"), el("span", { className: "m01-lb__item-unit", text: isMoney(item.resultUnit) ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), ], }); row.addEventListener("click", () => void openPaper(item.key)); return row; }; const showStart = async (): Promise => { paper = null; dirty = false; const status = el("p", { className: "m01-lb__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-lb__list" }); const fieldHost = el("div", { className: "m01-lb__list" }); const foundHost = el("div", { className: "m01-lb__list" }); const search = createInputField({ type: "search", placeholder: te("Start_Search"), value: query, onInput: (v) => { query = v; drawFound(); }, }); const drawFound = (): void => { if (!logics) return; const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const hits = words.length ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) : []; status.textContent = !words.length ? "" : !hits.length ? te("Start_None") : hits.length > SHOWN ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) : ""; foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow)); }; body.replaceChildren( el("div", { className: "m01-lb__start", children: [ el("div", { className: "m01-lb__start-col", children: [ el("h4", { text: te("Start_Trial") }), trialHost, search.root, el("h4", { text: te("Start_Found") }), status, foundHost, ], }), el("div", { className: "m01-lb__start-col", children: [ bigButton(te("Start_Wizard"), notYet), el("h4", { text: te("Start_Field") }), fieldHost, ], }), ], }), ); search.input.focus(); try { logics ??= await listEasy(""); } catch (error) { status.textContent = te("LoadFailed", { v: plainError(error) }); return; } const byKey = new Map(logics.map((x) => [x.key, x])); trialHost.replaceChildren( ...TRIAL_KEYS.flatMap((k) => { const item = byKey.get(k); return item ? [listRow(item)] : []; }), ); const field = logics.filter((x) => !x.isBase); fieldHost.replaceChildren( ...(field.length ? field.map(listRow) : [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]), ); drawFound(); }; /* ---------- 세 칸 화면 ---------- */ const openPaper = async (key: string): Promise => { body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") })); try { const opened = await openEasy(key); const lb = blocksOf(opened.row, key, opened.version, opened.isBase); paper = { key, ...opened, lb, tables: {} }; dirty = false; showPaper(); } catch (error) { showToast(plainError(error), "error"); void showStart(); } }; const showPaper = (): void => { const p = paper; if (!p) return; let ans: CalcAnswer | null = null; let busy = false; const unsaved = createTag(te("Unsaved"), "warning"); unsaved.hidden = !dirty; const total = el("div", { className: "m01-lb__total" }); const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } }); const sheetHost = el("div"); const examplesHost = el("div", { className: "m01-lb__examples" }); const sourceHost = el("div", { className: "m01-lb__slot" }); const basisHost = el("div", { className: "m01-lb__slot" }); const name = createInputField({ label: te("Name"), value: p.row.이름 }); name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음 name.root.classList.add("m01-lb__name"); const drawResult = (pending: boolean): void => { const unit = p.row.결과단위 ?? ""; const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; total.textContent = isMoney(unit) ? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) }) : te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) }); total.classList.toggle("is-pending", pending); sheetHost.classList.toggle("is-pending", pending); renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans); }; const calc = (): void => { drawResult(true); calcWith(p, (next, plain) => { ans = next; note.textContent = plain ?? ""; drawResult(false); }); }; const doCopy = async (): Promise => { if (busy) return; busy = true; note.textContent = te("Copying"); try { const saved = await copyEasy(p.key); paper = { key: saved.key, version: saved.version, isBase: false, row: saved.row, trial: p.trial, lb: blocksOf(saved.row, saved.key, saved.version, false), tables: {}, }; logics = null; showToast(te("Copied"), "success"); showPaper(); } catch (error) { note.textContent = plainError(error); } finally { busy = false; } }; const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); undo.disabled = true; const actions = el("div", { className: "m01-lb__actions", children: [ ...(p.isBase ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })] : [undo, createButton({ label: te("Save"), onClick: notYet })]), createButton({ label: te("Close"), variant: "ghost", onClick: () => void (async () => { if (await mayLeave()) void showStart(); })(), }), ], }); const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => { const b = createButton({ label, variant: view === which ? "filled" : "ghost", size: "sm", onClick: () => { view = which; showPaper(); }, }); b.setAttribute("aria-pressed", String(view === which)); return b; }; sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => { p.trial = next; calc(); }); body.replaceChildren( el("div", { className: "m01-lb__strip", children: [ name.root, createTag(p.isBase ? te("Standard") : te("Field"), p.isBase ? "neutral" : "accent"), el("span", { className: "m01-lb__applied", text: `${te("Applied")} · ${p.row.출처 || "—"}`, }), unsaved, actions, ], }), note, ...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []), el("div", { className: "m01-lb__cols", children: [ column(te("Col_Source"), [sourceHost]), column( te("Col_Basis"), [basisHost], [viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))], ), column(te("Col_Result"), [ total, examplesHost, el("h5", { text: te("Sheet") }), sheetHost, ]), ], }), ); calc(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), closeLabel: te("CloseWindow"), dialogClass: "m01-lb", beforeClose: mayLeave, mount: (host) => { body = host; void showStart(); }, }); } /** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement { return el("section", { className: "m01-lb__col", children: [ el("div", { className: "m01-lb__col-head", children: [el("h4", { text: title }), ...tools], }), el("div", { className: "m01-lb__col-body", children }), ], }); } /** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ function drawExamples( host: HTMLElement, inputs: LogicInput[], trial: Record, onChange: (trial: Record) => void, ): void { host.hidden = !inputs.length; if (!inputs.length) return void host.replaceChildren(); const state = { ...trial }; const field = (spec: LogicInput): HTMLElement => { const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; let box: HTMLInputElement | HTMLSelectElement; if (spec.고르기?.length) { box = el("select", { className: "ui-select ui-select--sm", children: [ el("option", { attrs: { value: "" }, text: te("Pick") }), ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), ], }); box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); box.addEventListener("change", () => { const picked = spec.고르기?.find((v) => String(v) === box.value); if (picked === undefined) delete state[spec.이름]; else state[spec.이름] = picked; onChange({ ...state }); }); } else { box = el("input", { className: "ui-input ui-input--sm", attrs: { type: "number", ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), }, }); box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); box.addEventListener("change", () => { if (box.value === "") delete state[spec.이름]; else state[spec.이름] = Number(box.value); onChange({ ...state }); }); } box.setAttribute("aria-label", spec.이름); return el("label", { className: "m01-lb__example", children: [el("span", { text: label }), box], }); }; host.replaceChildren( el("strong", { text: te("Examples") }), el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), el("p", { className: "m01-lb__muted", text: te("Examples_Note") }), ); } function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-lb__big"); return button; }