Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* M01_SL_Entry.ts
|
|
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠
|
|
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄).
|
|
* 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음.
|
|
* ========================================================================== */
|
|
|
|
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 { slList, type SlListItem } from "./M01_SL_Api";
|
|
import { newLogic, type SheetLogic } from "./M01_SL_Model";
|
|
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}"],
|
|
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
|
Start_Mine: ["내 시트 로직", "My sheet logics"],
|
|
Start_MineNone: ["아직 없음", "None yet"],
|
|
Start_New: ["새로 만들기(빈 판)", "New (blank)"],
|
|
Start_Import: ["기존 로직 가져오기", "Import an existing logic"],
|
|
Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"],
|
|
Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"],
|
|
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"],
|
|
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,
|
|
);
|
|
|
|
/** 「기존 로직 가져오기」 에 먼저 보이는 시험 로직 여섯(정함 9) */
|
|
const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"];
|
|
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
|
|
const SHOWN = 50;
|
|
|
|
/** 「원/㎡」 → 「㎡」 · 「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("원/");
|
|
|
|
/* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */
|
|
const loadMine = (): Promise<SlListItem[]> => slList().catch(() => []);
|
|
const blankLogic = (name: string, unit: string): SheetLogic | null => {
|
|
try {
|
|
return newLogic(name, unit);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
|
export function createSheetLogicButton(): HTMLButtonElement {
|
|
return createButton({ label: te("Open"), variant: "filled", onClick: openSheetLogic });
|
|
}
|
|
|
|
function openSheetLogic(): void {
|
|
let body: HTMLElement;
|
|
/** 고치는 화면이 열려 있을 때만 */
|
|
let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null;
|
|
let logics: EasyListItem[] | null = null;
|
|
let query = "";
|
|
|
|
const mayLeave = async (): Promise<boolean> =>
|
|
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
|
const notYet = (): void => showToast(te("NotYet"), "info");
|
|
|
|
/* ---------- 시작 화면 ---------- */
|
|
|
|
const importRow = (item: EasyListItem): HTMLElement => {
|
|
const row = el("button", {
|
|
className: "m01-sl__item",
|
|
attrs: { type: "button", title: item.where },
|
|
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,
|
|
}),
|
|
],
|
|
});
|
|
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);
|
|
return row;
|
|
};
|
|
|
|
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 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(importRow));
|
|
};
|
|
|
|
body.replaceChildren(
|
|
el("p", { className: "m01-sl__trial-note", text: te("Trial") }),
|
|
el("div", {
|
|
className: "m01-sl__start",
|
|
children: [
|
|
el("div", {
|
|
className: "m01-sl__start-col",
|
|
children: [
|
|
bigButton(te("Start_New"), () => showPaper()),
|
|
el("h4", { text: te("Start_Mine") }),
|
|
mineHost,
|
|
],
|
|
}),
|
|
el("div", {
|
|
className: "m01-sl__start-col",
|
|
children: [
|
|
el("h4", { text: te("Start_Import") }),
|
|
el("h5", { text: te("Start_Trial") }),
|
|
trialHost,
|
|
search.root,
|
|
status,
|
|
foundHost,
|
|
],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
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 ? [importRow(item)] : [];
|
|
}),
|
|
);
|
|
status.textContent = "";
|
|
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();
|
|
paper = { dirty: () => dirty, sl: blankLogic("", units[0]) };
|
|
|
|
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"),
|
|
closeLabel: te("CloseWindow"),
|
|
dialogClass: "m01-sl",
|
|
beforeClose: mayLeave,
|
|
mount: (host) => {
|
|
body = host;
|
|
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");
|
|
return button;
|
|
}
|