feat(m01-lb): 로직 만들기 판 잇기 — 원문 · 문장/격자 · 결과(예시값 · 일위대가표 · 빈틈 · 기대값 시험표) · 저장 · 복사 편집 · 삭제 · 실행 취소 · 단계 안내

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
This commit is contained in:
2026-10-01 12:04:23 +09:00
co-authored by Claude Opus 5.5
parent 3eb66e5aa9
commit 8742cd10f9
3 changed files with 657 additions and 146 deletions
@@ -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;
}
+345 -146
View File
@@ -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<string, unknown>;
lb: LbLogic | null;
tables: LbTables;
changed: Set<string>;
}
/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */
interface Snap {
lb: LbLogic | null;
tables: LbTables;
changed: Set<string>;
}
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<void>; undo(): void } | null = null;
let logics: EasyListItem[] | null = null;
let query = "";
let view: "sentence" | "grid" = "sentence";
let view: View = "sentence";
const mayLeave = async (): Promise<boolean> =>
!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<void> => {
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<void> => {
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<void> => {
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<Paper, "tables" | "changed">): 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<string, unknown>[] } | 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<string, unknown>[] } | 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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>,
onChange: (trial: Record<string, unknown>) => 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");
@@ -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, string | number> = {}): 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<string, unknown>,
onChange: (trial: Record<string, unknown>) => 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, unknown>): 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<string, unknown>;
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<void> => {
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();
}