diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css index 9975c9b32..26f9e736e 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.css +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.css @@ -201,3 +201,42 @@ overflow: auto; } } + +/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */ +.m01-lb__check { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lb__check-tools { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-lb__check-out ul { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px auto auto; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row .is-ok { + color: var(--color-success, var(--color-text)); + font-weight: 600; +} + +.m01-lb__expect-row .is-bad { + color: var(--color-danger); + font-weight: 600; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts index e68ed3189..792c39700 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -1,9 +1,11 @@ /* ============================================================================= * M01_LB_Entry.ts - * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠 + * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임. - * 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음. + * 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid) + * · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본) + * → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내. + * 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -21,14 +23,24 @@ 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 { lbCalcLater, type LbTables } from "./M01_LB_Api"; -import { readLogic, type LbLogic } from "./M01_LB_Blocks"; +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 = { @@ -68,14 +80,23 @@ const TEXT = { 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"], + 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?", @@ -105,19 +126,32 @@ const amountOf = (resultUnit: string): string => { }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */ +/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */ interface Paper { - key: string; - version: string; + 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, version: string, isBase: boolean): LbLogic | null => { +/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */ +const blocksOf = ( + row: LogicRow, + key: string | null, + version: string | null, + isBase: boolean, +): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); } catch { @@ -132,16 +166,27 @@ export function createLogicBuilderButton(): HTMLButtonElement { function openLogicBuilder(): void { let body: HTMLElement; - let paper: Paper | null = null; - let dirty = false; + /** 세 칸 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; - let view: "sentence" | "grid" = "sentence"; + let view: View = "sentence"; const mayLeave = async (): Promise => - !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - const notYet = (): void => showToast(te("NotYet"), "info"); + /** 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(); + } + }; /* ---------- 시작 화면 ---------- */ @@ -166,7 +211,6 @@ function openLogicBuilder(): void { 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" }); @@ -215,7 +259,7 @@ function openLogicBuilder(): void { el("div", { className: "m01-lb__start-col", children: [ - bigButton(te("Start_Wizard"), notYet), + bigButton(te("Start_Wizard"), () => void openNew()), el("h4", { text: te("Start_Field") }), fieldHost, ], @@ -240,9 +284,7 @@ function openLogicBuilder(): void { ); const field = logics.filter((x) => !x.isBase); fieldHost.replaceChildren( - ...(field.length - ? field.map(listRow) - : [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]), + ...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]), ); drawFound(); }; @@ -250,76 +292,232 @@ function openLogicBuilder(): void { /* ---------- 세 칸 화면 ---------- */ const openPaper = async (key: string): Promise => { - body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") })); + body.replaceChildren(muted(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(); + 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 showPaper = (): void => { - const p = paper; - if (!p) return; + 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"); - 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 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 name = createInputField({ label: te("Name"), value: p.row.이름 }); - name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음 + 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 drawResult = (pending: boolean): void => { - const unit = p.row.결과단위 ?? ""; - const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; + /** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */ + 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: value === undefined ? "—" : formatMoney(value) }) - : te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) }); + ? 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); - renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans); + 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 => { - drawResult(true); - lbCalcLater(p.row, p.trial, p.tables, (next, plain) => { + draw(true); + lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => { ans = next; note.textContent = plain ?? ""; - drawResult(false); + 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 (busy) return; + if (!p.key || 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(); + reopen(saved.row, saved.key, saved.version); } catch (error) { note.textContent = plainError(error); } finally { @@ -327,44 +525,80 @@ function openLogicBuilder(): void { } }; - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; + 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() })] - : [undo, createButton({ label: te("Save"), onClick: notYet })]), - createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }), - ], + children: p.isBase + ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close] + : fieldActions, }); - const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => { + const viewButtons = (["sentence", "grid"] as View[]).map((which) => { const b = createButton({ - label, + label: te(which === "sentence" ? "View_Sentence" : "View_Grid"), variant: view === which ? "filled" : "ghost", size: "sm", onClick: () => { view = which; - showPaper(); + 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; - }; - - 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( @@ -382,25 +616,33 @@ function openLogicBuilder(): void { ], }), note, - ...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []), + ...(p.isBase ? [muted(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_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(); }; @@ -412,11 +654,24 @@ function openLogicBuilder(): void { 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", { @@ -431,62 +686,6 @@ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = [ }); } -/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 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"); diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts new file mode 100644 index 000000000..14c22a068 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * M01_LB_Entry_Result.ts + * 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름) + * 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbTables } from "./M01_LB_Api"; +import type { LbLogic } from "./M01_LB_Blocks"; +import { + gapSkipped, + gaps, + loadExpectRows, + pickValues, + runExpect, + saveExpectRows, + type ExpectRow, +} from "./M01_LB_Check"; + +const TEXT = { + Examples: ["예시값", "Example values"], + Examples_Note: [ + "예시값은 계산을 보려는 값 — 저장 안 됨", + "Example values are only for trying — not saved", + ], + Pick: ["선택", "Choose"], + Gaps: ["빈틈 검사", "Check for gaps"], + Gaps_None: [ + "빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음", + "No gaps — every combination finds one row", + ], + Gaps_No: ["해당 없음", "No match"], + Gaps_Dup: ["중복", "Duplicate"], + Gaps_Rows: ["줄 {v}", "rows {v}"], + Gaps_Skip: [ + "못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음", + "{n} lookups not checked — numeric inputs", + ], + Gaps_More: ["그 밖 {n} 건", "{n} more"], + Expect: ["기대값 시험표", "Expected-value table"], + Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"], + Expect_Run: ["모두 돌리기", "Run all"], + Expect_Total: ["합계", "Total"], + Expect_Ok: ["맞음", "Match"], + Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"], + Expect_Del: ["삭제", "Delete"], + Expect_None: [ + "줄 없음 — 예시값을 정한 뒤 한 줄 더하기", + "No rows — set examples, then add a row", + ], + Expect_Note: [ + "시험표는 이 화면에만 남음 — 저장 안 됨", + "The table stays on this screen only — not saved", + ], +} as const; + +type TextKey = keyof typeof TEXT; + +const tr = (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, + ); + +/** 볼 값 이름 — 합계는 「계」 · 값 결과는 「결과」 */ +const totalKey = (ans: CalcAnswer | null): "계" | "결과" => + ans?.ok && ans.result !== undefined ? "결과" : "계"; + +/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ +export 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: tr("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: tr("Examples") }), + el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), + el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }), + ); +} + +const condText = (conds: Record): string => + Object.entries(conds) + .map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`) + .join(" · "); + +/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */ +export function renderChecks( + host: HTMLElement, + s: { + lb(): LbLogic; + tables(): LbTables; + trial(): Record; + ans(): CalcAnswer | null; + draftKey: string; + }, +): void { + const gapHost = el("div", { className: "m01-lb__check-out" }); + const rowsHost = el("div", { className: "m01-lb__check-out" }); + let rows: ExpectRow[] = loadExpectRows(s.draftKey); + let results: (boolean | number | null)[] = []; + const keep = (): void => saveExpectRows(s.draftKey, rows); + + const runGaps = (): void => { + const found = gaps(s.lb(), s.tables()); + const skipped = gapSkipped(s.lb(), s.tables()).length; + const lines = found.slice(0, 20).map((g) => + el("li", { + text: [ + g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"), + condText(g.conds), + g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "", + ] + .filter(Boolean) + .join(" — "), + }), + ); + gapHost.replaceChildren( + found.length + ? el("ul", { children: lines }) + : el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }), + ...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []), + ...(skipped + ? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })] + : []), + ); + }; + + const drawRows = (): void => { + if (!rows.length) { + rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") })); + return; + } + rowsHost.replaceChildren( + ...rows.map((row, i) => { + const key = Object.keys(row.expect)[0] ?? "계"; + const box = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "number", "aria-label": tr("Expect_Total") }, + }); + box.value = String(row.expect[key] ?? ""); + box.addEventListener("change", () => { + row.expect = { [key]: Number(box.value) }; + results[i] = null; + keep(); + }); + const r = results[i]; + return el("div", { + className: "m01-lb__expect-row", + children: [ + el("span", { text: condText(row.inputs) || "—" }), + box, + el("span", { + className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "", + text: + r === true + ? tr("Expect_Ok") + : typeof r === "number" + ? tr("Expect_Bad", { v: formatNumber(r) }) + : "", + }), + createButton({ + label: tr("Expect_Del"), + variant: "ghost", + size: "sm", + onClick: () => { + rows = rows.filter((_, j) => j !== i); + results = results.filter((_, j) => j !== i); + keep(); + drawRows(); + }, + }), + ], + }); + }), + ); + }; + + const addRow = (): void => { + const ans = s.ans(); + const key = totalKey(ans); + const now = ans?.ok ? pickValues(ans)[key] : undefined; + rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }]; + keep(); + drawRows(); + }; + + const runRows = async (): Promise => { + const got = await runExpect(s.lb(), s.tables(), rows); + results = got.map((g, i) => { + const key = Object.keys(rows[i].expect)[0] ?? "계"; + return g.ok ? true : (g.got[key] ?? null); + }); + drawRows(); + }; + + host.replaceChildren( + el("div", { + className: "m01-lb__check", + children: [ + createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }), + gapHost, + el("strong", { text: tr("Expect") }), + el("div", { + className: "m01-lb__check-tools", + children: [ + createButton({ + label: tr("Expect_Add"), + variant: "ghost", + size: "sm", + onClick: addRow, + }), + createButton({ + label: tr("Expect_Run"), + variant: "ghost", + size: "sm", + onClick: () => void runRows(), + }), + ], + }), + rowsHost, + el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }), + ], + }), + ); + drawRows(); +}