feat(m01-sl): 시트 로직 잇기 — 열기 · 바깥 값 · 다이어그램 · 시트 판 · 표/항목 손잡이 · 결과 · 저장(서버 값 대조) · 지우기 · 실행 취소 · 가져오기

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 13:21:15 +09:00
co-authored by Claude Opus 5.5
parent 124c3976d8
commit 499e52ca73
3 changed files with 526 additions and 152 deletions
+122 -152
View File
@@ -1,33 +1,34 @@
/* =============================================================================
* M01_SL_Entry.ts
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 잇기
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄).
* 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음.
* 고치는 화면은 M01_SL_Entry_Paper.ts · SheetLogic 의 다른 파일은 창을 열 때 import() 로 늦게 불러옴.
* 열기 = slGet → 바깥 값(slOutside → applyOutside) → 고치는 화면 · 가져오기 = 기존 로직 줄 + 품셈 표 → importLogicReport.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
import { listEasy, openEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
import type { SlListItem } from "./M01_SL_Api";
import type { SheetLogic } from "./M01_SL_Model";
import type { SlImportTable } from "./M01_SL_Import";
import { fillOutside, loadMods, showPaper, type Mods } from "./M01_SL_Entry_Paper";
import "./M01_SL_Entry.css";
const TEXT = {
Open: ["시트 로직(시험)", "Sheet logic (trial)"],
Title: ["시트 로직", "Sheet logic"],
CloseWindow: ["창 닫기", "Close window"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
Start_Mine: ["내 시트 로직", "My sheet logics"],
Start_MineNone: ["아직 없음", "None yet"],
@@ -38,17 +39,7 @@ const TEXT = {
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Per: ["{u} 당", "per {u}"],
Name: ["이름", "Name"],
NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"],
Unit: ["결과 단위", "Result unit"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Diagram: ["상자와 선", "Boxes and lines"],
Panel: ["고른 상자의 시트", "Sheet of the chosen box"],
Result: ["결과", "Result"],
NotYet: ["준비 중", "Coming soon"],
Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
@@ -69,19 +60,13 @@ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133",
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */
/** 「원/㎡」 → 「1㎡」 · 「100㎡」 은 앞에 1 을 안 붙임 */
const amountOf = (resultUnit: string): string => {
const unit = resultUnit.replace(/^원\//, "");
return /^\d/.test(unit) ? unit : `1${unit}`;
};
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
/* SheetLogic 파일은 늦게 불러옴(import()) — 스프레드시트 엔진이 M01 첫 묶음에 안 들어가게.
* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 빈 목록 · null 로 대신함 — 채워지면 catch 를 지움 */
const loadMine = (): Promise<SlListItem[]> =>
import("./M01_SL_Api").then((m) => m.slList()).catch(() => []);
const blankLogic = (name: string, unit: string): Promise<SheetLogic | null> =>
import("./M01_SL_Model").then((m) => m.newLogic(name, unit)).catch(() => null);
const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
export function createSheetLogicButton(): HTMLButtonElement {
@@ -91,58 +76,137 @@ export function createSheetLogicButton(): HTMLButtonElement {
function openSheetLogic(): void {
let body: HTMLElement;
/** 고치는 화면이 열려 있을 때만 */
let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null;
let paper: ReturnType<typeof showPaper> | null = null;
let logics: EasyListItem[] | null = null;
let query = "";
const mods = loadMods();
const mayLeave = async (): Promise<boolean> =>
!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) || ev.defaultPrevented) 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 unitChoices = (): string[] => {
const count = new Map<string, number>();
for (const x of logics ?? []) {
if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
};
/** 고치는 화면 열기 — 바깥 값을 넣은 뒤 · 새로 · 가져오기는 처음부터 「저장 안 함」 */
const open = async (
M: Mods,
sl: SheetLogic,
saved: boolean,
notes: string[] = [],
): Promise<void> => {
let filled = sl;
try {
const got = await fillOutside(M, sl);
filled = got.sl;
if (got.errors.length) notes = [...notes, te("Outside", { v: got.errors.join(" · ") })];
} catch (e) {
notes = [...notes, te("Outside", { v: M.api.slPlain(e) })];
}
paper = showPaper(M, body, filled, {
saved,
units: unitChoices(),
notes,
onClose: () =>
void (async () => {
if (await mayLeave()) void showStart();
})(),
});
};
const run = (job: (M: Mods) => Promise<void>): void => {
body.replaceChildren(muted(te("Loading")));
void mods.then(job).catch((e: unknown) => {
showToast(te("OpenFailed", { v: plainError(e) }), "error");
void showStart();
});
};
const openSaved = (key: string): void => run(async (M) => open(M, await M.api.slGet(key), true));
const openNew = (): void =>
run(async (M) => open(M, M.model.newLogic("", unitChoices()[0]), false));
/** 기존 로직 가져오기 — 로직 줄 + 그 줄이 쓰는 품셈 표(열 때 받음) → 시트 로직(저장 전) */
const doImport = (key: string): void =>
run(async (M) => {
const { row } = await openEasy(key);
const lb = await import("../LogicBuilder/M01_LB_Api");
const keys = [...new Set(JSON.stringify(row).match(/Q[A-Z]\d{6}/g) ?? [])];
const got = await Promise.all(
keys.map((k) =>
lb.fetchLbTable(k).then(
(t) => [k, t.table] as const,
() => null,
),
),
);
// 서버 표 통째(조건 · 값칸 · 줄 포함) — LB Api 타입이 느슨해 Import 모양으로 봄
const tables = Object.fromEntries(got.filter((x) => x !== null)) as unknown as Record<
string,
SlImportTable
>;
const report = M.imp.importLogicReport(row, { tables });
await open(M, report.sl, false, report.problems);
});
/* ---------- 시작 화면 ---------- */
const importRow = (item: EasyListItem): HTMLElement => {
const listButton = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
const row = el("button", {
className: "m01-sl__item",
attrs: { type: "button", title: item.where },
attrs: { type: "button", title },
children: [
el("span", { className: "m01-sl__item-name", text: item.name }),
el("span", {
className: "m01-sl__item-unit",
text: isMoney(item.resultUnit)
? te("Per", { u: amountOf(item.resultUnit) })
: item.resultUnit,
}),
el("span", { className: "m01-sl__item-name", text: name }),
el("span", { className: "m01-sl__item-unit", text: unit }),
],
});
row.addEventListener("click", notYet);
return row;
};
const mineRow = (item: SlListItem): HTMLElement => {
const row = el("button", {
className: "m01-sl__item",
attrs: { type: "button" },
children: [
el("span", { className: "m01-sl__item-name", text: item.이름 }),
el("span", { className: "m01-sl__item-unit", text: item.결과단위 }),
],
});
row.addEventListener("click", notYet);
row.addEventListener("click", onClick);
return row;
};
const perText = (u: string): string => (isMoney(u) ? te("Per", { u: amountOf(u) }) : u);
const importRow = (item: EasyListItem): HTMLElement =>
listButton(item.name, perText(item.resultUnit), () => doImport(item.key), item.where);
const mineRow = (item: SlListItem): HTMLElement =>
listButton(item.이름, perText(item.결과단위), () => openSaved(item.key), item.saved);
const showStart = async (): Promise<void> => {
paper = null;
const status = el("p", { className: "m01-sl__muted", text: te("Loading") });
const trialHost = el("div", { className: "m01-sl__list" });
const foundHost = el("div", { className: "m01-sl__list" });
const mineHost = el("div", { className: "m01-sl__list" });
void loadMine().then((mine) =>
mineHost.replaceChildren(
...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]),
),
);
const mineHost = el("div", { className: "m01-sl__list", children: [muted(te("Loading"))] });
void mods
.then((M) => M.api.slList())
.then(
(mine) =>
mineHost.replaceChildren(
...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]),
),
(e: unknown) => mineHost.replaceChildren(muted(te("LoadFailed", { v: plainError(e) }))),
);
const search = createInputField({
type: "search",
placeholder: te("Start_Search"),
@@ -177,7 +241,7 @@ function openSheetLogic(): void {
el("div", {
className: "m01-sl__start-col",
children: [
bigButton(te("Start_New"), () => showPaper()),
bigButton(te("Start_New"), openNew),
el("h4", { text: te("Start_Mine") }),
mineHost,
],
@@ -214,88 +278,6 @@ function openSheetLogic(): void {
drawFound();
};
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
const unitChoices = (): string[] => {
const count = new Map<string, number>();
for (const x of logics ?? []) {
if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1);
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
};
/* ---------- 고치는 화면 ---------- */
const showPaper = (): void => {
let dirty = false;
const units = unitChoices();
const mine: { dirty(): boolean; sl: SheetLogic | null } = { dirty: () => dirty, sl: null };
paper = mine;
void blankLogic("", units[0]).then((sl) => (mine.sl = sl));
const unsaved = createTag(te("Unsaved"), "warning");
unsaved.hidden = true;
const touched = (): void => {
dirty = true;
unsaved.hidden = false;
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
onInput: touched,
});
name.root.classList.add("m01-sl__name");
const unit = createSelectField({
label: te("Unit"),
options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
value: units[0],
onChange: touched,
});
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
undo.disabled = true;
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) void showStart();
})(),
});
body.replaceChildren(
el("div", {
className: "m01-sl__strip",
children: [
name.root,
unit.root,
unsaved,
el("div", {
className: "m01-sl__actions",
children: [
undo,
createButton({ label: te("Save"), onClick: notYet }),
createButton({ label: te("Delete"), variant: "danger", onClick: notYet }),
close,
],
}),
],
}),
el("p", { className: "m01-sl__trial-note", text: te("Trial") }),
el("div", {
className: "m01-sl__layout",
children: [
part(te("Diagram"), "m01-sl__diagram"),
part(te("Panel"), "m01-sl__panel"),
part(te("Result"), "m01-sl__result"),
],
}),
);
name.input.focus();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
openModal({
title: te("Title"),
@@ -304,24 +286,12 @@ function openSheetLogic(): void {
beforeClose: mayLeave,
mount: (host) => {
body = host;
(host.parentElement ?? host).addEventListener("keydown", onKey);
void showStart();
},
});
}
const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
/** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */
function part(title: string, className: string): HTMLElement {
return el("section", {
className: `m01-sl__part ${className}`,
children: [
el("h4", { text: title }),
el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }),
],
});
}
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m01-sl__big");