Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
694 lines
26 KiB
TypeScript
694 lines
26 KiB
TypeScript
/* =============================================================================
|
|
* 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, 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,
|
|
);
|
|
|
|
/** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 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<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 | 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<void>; undo(): void } | null = null;
|
|
let logics: EasyListItem[] | null = null;
|
|
let query = "";
|
|
let view: View = "sentence";
|
|
|
|
const mayLeave = async (): Promise<boolean> =>
|
|
!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<void> => {
|
|
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<void> => {
|
|
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<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");
|
|
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<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 (!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<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() }), 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<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", {
|
|
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;
|
|
}
|