/* ============================================================================= * M01_LB_Entry.ts * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). * 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid) * · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본) * → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내. * 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5). * ========================================================================== */ 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 { copyEasy, deleteEasy, editEasy, listEasy, newEasy, openEasy, plainError, usesOf, type EasyListItem, } from "../EasyCalc/M01_EasyCalc_Api"; import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api"; import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks"; import { lookupHits } from "./M01_LB_Check"; import { drawExamples, renderChecks } from "./M01_LB_Entry_Result"; import { renderGrid } from "./M01_LB_Grid"; import { renderSource, type LbRole } from "./M01_LB_Source"; import { renderSteps } from "./M01_LB_Steps"; import { openWizard } from "./M01_LB_Wizard"; 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}"], Sheet: ["일위대가표", "Unit-cost sheet"], NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"], Blocked: ["못 읽은 곳:", "Not readable:"], Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"], Role_NoInput: [ "「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함", "To use “{v}” as a condition, add an input with the same name", ], NoName: ["이름을 넣어 주세요", "Enter a name"], Saving: ["저장 중", "Saving"], Saved: ["저장했음", "Saved"], SaveFailed: ["저장 못 함", "Not saved"], Delete: ["삭제", "Delete"], Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], Deleted: ["삭제했음", "Deleted"], Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"], Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"], 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 = 원문 표 사본 · changed = 고친 표 */ interface Paper { key: string | null; version: string | null; isBase: boolean; row: LogicRow; trial: Record; lb: LbLogic | null; tables: LbTables; changed: Set; } /** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */ interface Snap { lb: LbLogic | null; tables: LbTables; changed: Set; } type View = "sentence" | "grid"; /** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */ const blocksOf = ( row: LogicRow, key: string | null, version: string | null, isBase: boolean, ): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); } catch { return null; } }; /** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createLogicBuilderButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder }); } function openLogicBuilder(): void { let body: HTMLElement; /** 세 칸 화면이 열려 있을 때만 */ let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; let view: View = "sentence"; const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); /** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */ const onKey = (ev: KeyboardEvent): void => { if (!paper || !(ev.ctrlKey || ev.metaKey)) return; const key = ev.key.toLowerCase(); if (key === "z" && !ev.shiftKey) { ev.preventDefault(); paper.undo(); } else if (key === "s") { ev.preventDefault(); void paper.save(); } }; /* ---------- 시작 화면 ---------- */ 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; 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"), () => void openNew()), 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) : [muted(te("Start_FieldNone"))]), ); drawFound(); }; /* ---------- 세 칸 화면 ---------- */ const openPaper = async (key: string): Promise => { body.replaceChildren(muted(te("Loading"))); try { const o = await openEasy(key); const lb = blocksOf(o.row, key, o.version, o.isBase); showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial }); } catch (error) { showToast(plainError(error), "error"); void showStart(); } }; const openNew = async (): Promise => { const lb = await openWizard(); if (!lb) return; const row = writeLogic(lb); showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) }); }; const showPaper = (first: Omit): void => { const p: Paper = { ...first, tables: {}, changed: new Set() }; let ans: CalcAnswer | null = null; let busy = false; const savedLb = p.key ? p.lb : null; let stack: Snap[] = []; const readOnly = p.isBase || !p.lb; paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() }; const unsaved = createTag(te("Unsaved"), "warning"); 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 checksHost = el("div"); const sourceHost = el("div", { className: "m01-lb__slot" }); const basisHost = el("div", { className: "m01-lb__slot" }); const blockedNote = el("p", { className: "m01-lb__note" }); const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row); const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? ""; const tableName = (key: string): string | null => (p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null; /** 서버에 넘길 표 — 고친 표만 */ const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]])); const name = createInputField({ label: te("Name"), value: p.lb?.doc.name ?? p.row.이름, onInput: (v) => { if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } }); }, }); name.input.disabled = readOnly; name.root.classList.add("m01-lb__name"); /** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */ const later = (host: HTMLElement, draw: () => void, answer: boolean): void => { const a = document.activeElement; const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement; if (answer && typing && host.contains(a)) { host.addEventListener("focusout", () => setTimeout(draw), { once: true }); return; } draw(); }; const drawSource = (): void => { if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks"))); renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), { edit: onTableEdit, role: onRole, }); }; const drawBasis = (): void => { if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks"))); const on = { change: (lb: LbLogic) => edit(lb) }; if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName }); else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial }); }; /** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */ const draw = (pending: boolean, answer = false): void => { const unit = unitNow(); const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; total.textContent = isMoney(unit) ? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) }) : te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) }); total.classList.toggle("is-pending", pending); sheetHost.classList.toggle("is-pending", pending); unsaved.hidden = !paper?.dirty(); undo.disabled = !stack.length; blockedNote.textContent = p.lb?.blocked.length ? `${te("Blocked")} ${p.lb.blocked.join(" · ")}` : ""; renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans); later(sourceHost, drawSource, answer); later(basisHost, drawBasis, answer); }; const calc = (): void => { draw(true); lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => { ans = next; note.textContent = plain ?? ""; draw(false, true); }); }; const drawExamplesNow = (): void => drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => { p.trial = next; calc(); }); /** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */ const edit = (next: LbLogic): void => { if (readOnly || next === p.lb) return; stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; const inputsChanged = next.inputs !== p.lb?.inputs; p.lb = next; if (inputsChanged) { p.trial = { ...autoTrial(next.inputs), ...p.trial }; drawExamplesNow(); } void loadTables(); calc(); }; /** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */ const onTableEdit = (e: TableEdit): void => { const t = p.tables[e.table] as { 줄: Record[] } | undefined; if (!t) return; stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r)); p.tables = { ...p.tables, [e.table]: { ...t, 줄 } }; p.changed = new Set([...p.changed, e.table]); calc(); }; /** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */ const onRole = (table: string, col: string, role: LbRole): void => { const lb = p.lb; if (!lb || readOnly) return; if (role === "결과") return void (note.textContent = te("Role_Result")); const hits = lookupHits(lb, p.tables, p.trial); const rows = (p.tables[table] as { 줄?: Record[] } | undefined)?.줄; let next = lb; lb.lookups.forEach((lk, i) => { if (lk.table !== table || lk.col === col) return; const id = next.lookups[i]?.id ?? lk.id; if (role === "조건") { if (lb.inputs.some((x) => x.이름 === col)) next = setCond(next, id, col, { k: "input", name: col }); return; } const hit = hits[lk.id]; const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col]; const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell; if (typeof value === "number" || typeof value === "string") next = setCond(next, id, col, { k: "fixed", value }); }); if (next === lb) note.textContent = te("Role_NoInput", { v: col }); else edit(next); }; /** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */ const loadTables = async (): Promise => { const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])]; const want = keys.filter((k) => !(k in p.tables)); if (!want.length) return; const got = await Promise.all( want.map((k) => fetchLbTable(k).then( (t) => [k, t.table] as const, () => [k, undefined] as const, ), ), ); p.tables = { ...p.tables, ...Object.fromEntries(got) }; drawSource(); drawBasis(); }; const doUndo = (): void => { const back = stack[stack.length - 1]; if (!back) return; stack = stack.slice(0, -1); ({ lb: p.lb, tables: p.tables, changed: p.changed } = back); name.input.value = p.lb?.doc.name ?? p.row.이름; drawExamplesNow(); calc(); }; /** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */ const reopen = (row: LogicRow, key: string, version: string): void => { const lb = blocksOf(row, key, version, false); showPaper({ key, version, isBase: false, row, lb, trial: p.trial }); }; const doSave = async (): Promise => { if (readOnly || busy || !p.lb) return; const row = writeLogic(p.lb); if (!row.이름.trim()) return void (note.textContent = te("NoName")); busy = true; note.textContent = te("Saving"); try { const saved = p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row); logics = null; showToast(te("Saved"), "success"); reopen(saved.row, saved.key, saved.version); } catch (error) { note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; } finally { busy = false; } }; const doCopy = async (): Promise => { if (!p.key || busy) return; busy = true; note.textContent = te("Copying"); try { const saved = await copyEasy(p.key); logics = null; showToast(te("Copied"), "success"); reopen(saved.row, saved.key, saved.version); } catch (error) { note.textContent = plainError(error); } finally { busy = false; } }; const doDelete = async (): Promise => { if (!p.key || !p.version || p.isBase || busy) return; busy = true; note.textContent = te("Uses_Wait"); try { const uses = (await usesOf(p.key)).filter((u) => u.key); if (uses.length) { note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") }); return; } note.textContent = ""; const ask = te("Delete_Ask", { v: rowNow().이름 }); if (!(await showConfirmDialog(ask, te("Delete")))) return; await deleteEasy(p.key, p.version); logics = null; paper = null; showToast(te("Deleted"), "success"); void showStart(); } catch (error) { note.textContent = plainError(error); } finally { busy = false; } }; const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); const close = createButton({ label: te("Close"), variant: "ghost", onClick: () => void (async () => { if (await mayLeave()) void showStart(); })(), }); const fieldActions = [ undo, createButton({ label: te("Save"), onClick: () => void doSave() }), ...(p.key ? [ createButton({ label: te("Delete"), variant: "danger", onClick: () => void doDelete(), }), ] : []), close, ]; const actions = el("div", { className: "m01-lb__actions", children: p.isBase ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close] : fieldActions, }); const viewButtons = (["sentence", "grid"] as View[]).map((which) => { const b = createButton({ label: te(which === "sentence" ? "View_Sentence" : "View_Grid"), variant: view === which ? "filled" : "ghost", size: "sm", onClick: () => { view = which; for (const x of viewButtons) { const on = x.dataset.view === view; x.classList.toggle("ui-btn--filled", on); x.classList.toggle("ui-btn--ghost", !on); x.setAttribute("aria-pressed", String(on)); } drawBasis(); }, }); b.dataset.view = which; b.setAttribute("aria-pressed", String(view === which)); return b; }); 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 ? [muted(te("StandardNote"))] : []), el("div", { className: "m01-lb__cols", children: [ column(te("Col_Source"), [sourceHost]), column(te("Col_Basis"), [blockedNote, basisHost], viewButtons), column(te("Col_Result"), [ total, examplesHost, el("h5", { text: te("Sheet") }), sheetHost, checksHost, ]), ], }), ); drawExamplesNow(); if (p.lb) { renderChecks(checksHost, { lb: () => p.lb as LbLogic, tables: () => p.tables, trial: () => p.trial, ans: () => ans, draftKey: p.key ?? "새", }); } void loadTables(); calc(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), closeLabel: te("CloseWindow"), dialogClass: "m01-lb", beforeClose: mayLeave, mount: (host) => { body = host; (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } /** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */ function autoTrial(inputs: LogicInput[]): Record { const out: Record = {}; for (const spec of inputs) { if (String(spec.단위 ?? "").includes("원")) continue; out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); } return out; } const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text }); /** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */ 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 }), ], }); } function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-lb__big"); return button; }