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:
@@ -167,3 +167,14 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m01-sl__note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m01-sl__note:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
* M01_SL_Entry.ts
|
* M01_SL_Entry.ts
|
||||||
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠
|
* 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면(내 시트 로직 · 새로 · 기존 로직 가져오기) · 잇기
|
||||||
* 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.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 { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
import {
|
import {
|
||||||
createButton,
|
createButton,
|
||||||
createInputField,
|
createInputField,
|
||||||
createSelectField,
|
|
||||||
createTag,
|
|
||||||
el,
|
el,
|
||||||
showConfirmDialog,
|
showConfirmDialog,
|
||||||
showToast,
|
showToast,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { openModal } from "@ui/ui_template_modal";
|
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 { SlListItem } from "./M01_SL_Api";
|
||||||
import type { SheetLogic } from "./M01_SL_Model";
|
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";
|
import "./M01_SL_Entry.css";
|
||||||
|
|
||||||
const TEXT = {
|
const TEXT = {
|
||||||
Open: ["시트 로직(시험)", "Sheet logic (trial)"],
|
Open: ["시트 로직(시험)", "Sheet logic (trial)"],
|
||||||
Title: ["시트 로직", "Sheet logic"],
|
Title: ["시트 로직", "Sheet logic"],
|
||||||
CloseWindow: ["창 닫기", "Close window"],
|
CloseWindow: ["창 닫기", "Close window"],
|
||||||
Close: ["닫기", "Close"],
|
|
||||||
Loading: ["불러오는 중", "Loading"],
|
Loading: ["불러오는 중", "Loading"],
|
||||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||||
|
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
|
||||||
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||||
Start_Mine: ["내 시트 로직", "My sheet logics"],
|
Start_Mine: ["내 시트 로직", "My sheet logics"],
|
||||||
Start_MineNone: ["아직 없음", "None yet"],
|
Start_MineNone: ["아직 없음", "None yet"],
|
||||||
@@ -38,17 +39,7 @@ const TEXT = {
|
|||||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||||
Per: ["{u} 당", "per {u}"],
|
Per: ["{u} 당", "per {u}"],
|
||||||
Name: ["이름", "Name"],
|
Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"],
|
||||||
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: [
|
Leave_Ask: [
|
||||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||||
"There are unsaved changes — close without saving?",
|
"There are unsaved changes — close without saving?",
|
||||||
@@ -69,19 +60,13 @@ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133",
|
|||||||
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
|
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
|
||||||
const SHOWN = 50;
|
const SHOWN = 50;
|
||||||
|
|
||||||
/** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */
|
/** 「원/㎡」 → 「1㎡」 · 「100㎡」 은 앞에 1 을 안 붙임 */
|
||||||
const amountOf = (resultUnit: string): string => {
|
const amountOf = (resultUnit: string): string => {
|
||||||
const unit = resultUnit.replace(/^원\//, "");
|
const unit = resultUnit.replace(/^원\//, "");
|
||||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||||
};
|
};
|
||||||
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
|
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
|
||||||
|
const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
|
||||||
/* 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);
|
|
||||||
|
|
||||||
/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||||
export function createSheetLogicButton(): HTMLButtonElement {
|
export function createSheetLogicButton(): HTMLButtonElement {
|
||||||
@@ -91,58 +76,137 @@ export function createSheetLogicButton(): HTMLButtonElement {
|
|||||||
function openSheetLogic(): void {
|
function openSheetLogic(): void {
|
||||||
let body: HTMLElement;
|
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 logics: EasyListItem[] | null = null;
|
||||||
let query = "";
|
let query = "";
|
||||||
|
const mods = loadMods();
|
||||||
|
|
||||||
const mayLeave = async (): Promise<boolean> =>
|
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) || 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", {
|
const row = el("button", {
|
||||||
className: "m01-sl__item",
|
className: "m01-sl__item",
|
||||||
attrs: { type: "button", title: item.where },
|
attrs: { type: "button", title },
|
||||||
children: [
|
children: [
|
||||||
el("span", { className: "m01-sl__item-name", text: item.name }),
|
el("span", { className: "m01-sl__item-name", text: name }),
|
||||||
el("span", {
|
el("span", { className: "m01-sl__item-unit", text: unit }),
|
||||||
className: "m01-sl__item-unit",
|
|
||||||
text: isMoney(item.resultUnit)
|
|
||||||
? te("Per", { u: amountOf(item.resultUnit) })
|
|
||||||
: item.resultUnit,
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
row.addEventListener("click", notYet);
|
row.addEventListener("click", onClick);
|
||||||
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;
|
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> => {
|
const showStart = async (): Promise<void> => {
|
||||||
paper = null;
|
paper = null;
|
||||||
const status = el("p", { className: "m01-sl__muted", text: te("Loading") });
|
const status = el("p", { className: "m01-sl__muted", text: te("Loading") });
|
||||||
const trialHost = el("div", { className: "m01-sl__list" });
|
const trialHost = el("div", { className: "m01-sl__list" });
|
||||||
const foundHost = el("div", { className: "m01-sl__list" });
|
const foundHost = el("div", { className: "m01-sl__list" });
|
||||||
const mineHost = el("div", { className: "m01-sl__list" });
|
const mineHost = el("div", { className: "m01-sl__list", children: [muted(te("Loading"))] });
|
||||||
void loadMine().then((mine) =>
|
void mods
|
||||||
mineHost.replaceChildren(
|
.then((M) => M.api.slList())
|
||||||
...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]),
|
.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({
|
const search = createInputField({
|
||||||
type: "search",
|
type: "search",
|
||||||
placeholder: te("Start_Search"),
|
placeholder: te("Start_Search"),
|
||||||
@@ -177,7 +241,7 @@ function openSheetLogic(): void {
|
|||||||
el("div", {
|
el("div", {
|
||||||
className: "m01-sl__start-col",
|
className: "m01-sl__start-col",
|
||||||
children: [
|
children: [
|
||||||
bigButton(te("Start_New"), () => showPaper()),
|
bigButton(te("Start_New"), openNew),
|
||||||
el("h4", { text: te("Start_Mine") }),
|
el("h4", { text: te("Start_Mine") }),
|
||||||
mineHost,
|
mineHost,
|
||||||
],
|
],
|
||||||
@@ -214,88 +278,6 @@ function openSheetLogic(): void {
|
|||||||
drawFound();
|
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 를 부름
|
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||||
openModal({
|
openModal({
|
||||||
title: te("Title"),
|
title: te("Title"),
|
||||||
@@ -304,24 +286,12 @@ function openSheetLogic(): void {
|
|||||||
beforeClose: mayLeave,
|
beforeClose: mayLeave,
|
||||||
mount: (host) => {
|
mount: (host) => {
|
||||||
body = host;
|
body = host;
|
||||||
|
(host.parentElement ?? host).addEventListener("keydown", onKey);
|
||||||
void showStart();
|
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 {
|
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||||
const button = createButton({ label, variant: "filled", onClick });
|
const button = createButton({ label, variant: "filled", onClick });
|
||||||
button.classList.add("m01-sl__big");
|
button.classList.add("m01-sl__big");
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* M01_SL_Entry_Paper.ts
|
||||||
|
* 시트 로직 고치는 화면 — 위 띠 · 위 가로 다이어그램 · 아래 시트 판 + 종류별 손잡이 · 결과 · 잇기
|
||||||
|
* 바뀔 때마다 기록(객체 그대로) → evalLogic(화면 즉시) → 저장 slSave(서버가 다시 셈한 값으로 바꿔 보임)
|
||||||
|
* SheetLogic 의 다른 파일은 Entry 가 import() 로 늦게 불러 `mods` 로 넘김(엔진이 M01 첫 묶음에 안 들어가게).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
|
import {
|
||||||
|
createButton,
|
||||||
|
createInputField,
|
||||||
|
createSelectField,
|
||||||
|
createTag,
|
||||||
|
el,
|
||||||
|
showConfirmDialog,
|
||||||
|
showToast,
|
||||||
|
} from "@ui/ui_template_elements";
|
||||||
|
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
|
||||||
|
import type { SlOutsideAt, SlQuery } from "./M01_SL_Api";
|
||||||
|
import type { SlEvalResult, SlOutsideValue } from "./M01_SL_Eval";
|
||||||
|
import type { SheetLogic, SlKind } from "./M01_SL_Model";
|
||||||
|
|
||||||
|
/** SheetLogic 의 다른 파일 — 창을 열 때 한 번에 불러옴 */
|
||||||
|
export const loadMods = () =>
|
||||||
|
Promise.all([
|
||||||
|
import("./M01_SL_Model"),
|
||||||
|
import("./M01_SL_Eval"),
|
||||||
|
import("./M01_SL_Api"),
|
||||||
|
import("./M01_SL_Diagram"),
|
||||||
|
import("./M01_SL_Panel"),
|
||||||
|
import("./M01_SL_Table"),
|
||||||
|
import("./M01_SL_Item"),
|
||||||
|
import("./M01_SL_Result"),
|
||||||
|
import("./M01_SL_Import"),
|
||||||
|
]).then(([model, ev, api, diagram, panel, table, item, result, imp]) => ({
|
||||||
|
model,
|
||||||
|
ev,
|
||||||
|
api,
|
||||||
|
diagram,
|
||||||
|
panel,
|
||||||
|
table,
|
||||||
|
item,
|
||||||
|
result,
|
||||||
|
imp,
|
||||||
|
}));
|
||||||
|
export type Mods = Awaited<ReturnType<typeof loadMods>>;
|
||||||
|
|
||||||
|
const TEXT = {
|
||||||
|
Name: ["이름", "Name"],
|
||||||
|
NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"],
|
||||||
|
Unit: ["결과 단위", "Result unit"],
|
||||||
|
Undo: ["실행 취소", "Undo"],
|
||||||
|
Save: ["저장", "Save"],
|
||||||
|
Delete: ["지우기", "Delete"],
|
||||||
|
Close: ["닫기", "Close"],
|
||||||
|
Unsaved: ["저장 안 함", "Not saved"],
|
||||||
|
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
|
||||||
|
Diagram: ["상자와 선", "Boxes and lines"],
|
||||||
|
Panel: ["고른 상자의 시트", "Sheet of the chosen box"],
|
||||||
|
PickBox: [
|
||||||
|
"위에서 상자를 고르면 그 상자의 시트가 여기 보임",
|
||||||
|
"Choose a box above to see its sheet",
|
||||||
|
],
|
||||||
|
Result: ["결과", "Result"],
|
||||||
|
NoName: ["이름을 넣어 주세요", "Enter a name"],
|
||||||
|
SaveAnyway: [
|
||||||
|
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
|
||||||
|
"There are issues — {v} · Save anyway?",
|
||||||
|
],
|
||||||
|
Saving: ["저장 중", "Saving"],
|
||||||
|
Saved: ["저장했음", "Saved"],
|
||||||
|
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
|
||||||
|
Differs: [
|
||||||
|
"저장 때 다시 셈한 값 {s} 원 — 화면 값 {c} 원과 다름",
|
||||||
|
"Recalculated on save: {s} — differs from the screen value {c}",
|
||||||
|
],
|
||||||
|
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
|
||||||
|
Deleted: ["지웠음", "Deleted"],
|
||||||
|
Outside: ["바깥 값을 못 받음 — {v}", "Couldn't get outside values — {v}"],
|
||||||
|
Kind_input: ["입력값", "Input"],
|
||||||
|
Kind_const: ["고정값", "Fixed value"],
|
||||||
|
Kind_table: ["표", "Table"],
|
||||||
|
Kind_calc: ["계산", "Calculation"],
|
||||||
|
Kind_item: ["항목", "Item"],
|
||||||
|
Kind_logic: ["다른 로직", "Other logic"],
|
||||||
|
Kind_result: ["결과", "Result"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type TextKey = keyof typeof TEXT;
|
||||||
|
|
||||||
|
const tp = (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 muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text });
|
||||||
|
|
||||||
|
/** 바깥 값 자리 — 항목 단가 · 출처가 있는 알약 · 가져온 로직의 바깥 값(같은 칸은 앞의 것) */
|
||||||
|
export function outsidePlaces(M: Mods, sl: SheetLogic): SlOutsideAt[] {
|
||||||
|
const at = new Map<string, SlOutsideAt>();
|
||||||
|
const put = (p: SlOutsideAt): void => {
|
||||||
|
const k = `${p.sheet}!${p.cell.toUpperCase()}`;
|
||||||
|
if (!at.has(k)) at.set(k, p);
|
||||||
|
};
|
||||||
|
M.item.itemOutsides(sl).forEach(put);
|
||||||
|
for (const c of M.ev.outsideCells(sl)) {
|
||||||
|
const node = sl.nodes.find((n) => n.id === c.node);
|
||||||
|
if (node && c.source)
|
||||||
|
put({ item: { id: c.id, ...c.source } as SlQuery, sheet: node.sheetId, cell: c.cell });
|
||||||
|
}
|
||||||
|
M.imp.outsideOf(sl).forEach(put);
|
||||||
|
return [...at.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 열 때 바깥 값 넣기 — 받은 것만 넣고 못 받은 것은 쉬운 말 목록 */
|
||||||
|
export async function fillOutside(
|
||||||
|
M: Mods,
|
||||||
|
sl: SheetLogic,
|
||||||
|
): Promise<{ sl: SheetLogic; errors: string[] }> {
|
||||||
|
const places = outsidePlaces(M, sl);
|
||||||
|
if (!places.length) return { sl, errors: [] };
|
||||||
|
const answers = await M.api.slOutside(places.map((p) => p.item));
|
||||||
|
const values: SlOutsideValue[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const p of places) {
|
||||||
|
const node = sl.nodes.find((n) => n.sheetId === p.sheet);
|
||||||
|
const a = answers.find((x) => x.id === p.item.id);
|
||||||
|
if (!node) continue;
|
||||||
|
if (!a?.ok) errors.push(`${node.name} ${p.cell}${a?.reason ? ` — ${a.reason}` : ""}`);
|
||||||
|
else values.push({ node: node.id, cell: p.cell, value: a.rows ?? a.value ?? null });
|
||||||
|
}
|
||||||
|
return { sl: M.ev.applyOutside(sl, values), errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */
|
||||||
|
function serverTotal(sl: SheetLogic): number | null {
|
||||||
|
const box = sl.nodes.find((n) => n.kind === "result");
|
||||||
|
const pill = box?.pillsOut.find((p) => p.name === "계");
|
||||||
|
if (!box || !pill) return null;
|
||||||
|
const cell = pill.cell.toUpperCase();
|
||||||
|
const got = sl.workbook.계산값?.[box.sheetId]?.[cell];
|
||||||
|
if (got && "수" in got) return Number(got.수);
|
||||||
|
const plain = sl.workbook.시트.find((s) => s.id === box.sheetId)?.칸[cell]?.값;
|
||||||
|
return typeof plain === "number" ? plain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaperCtx {
|
||||||
|
/** 저장해 둔 것을 열었으면 true — 새로 · 가져오기는 처음부터 「저장 안 함」 */
|
||||||
|
saved: boolean;
|
||||||
|
units: string[];
|
||||||
|
notes: string[];
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 고치는 화면 — 돌려준 손잡이로 Entry 가 닫기 물음 · Ctrl+Z · Ctrl+S 를 부름 */
|
||||||
|
export function showPaper(
|
||||||
|
M: Mods,
|
||||||
|
host: HTMLElement,
|
||||||
|
first: SheetLogic,
|
||||||
|
ctx: PaperCtx,
|
||||||
|
): { dirty(): boolean; save(): Promise<void>; undo(): void } {
|
||||||
|
let sl = first;
|
||||||
|
let savedSl: SheetLogic | null = ctx.saved ? first : null;
|
||||||
|
let stack: SheetLogic[] = [];
|
||||||
|
let selected: string | null = first.nodes[0]?.id ?? null;
|
||||||
|
let values: SlEvalResult;
|
||||||
|
let busy = false;
|
||||||
|
let notes = ctx.notes;
|
||||||
|
|
||||||
|
const unsaved = createTag(tp("Unsaved"), "warning");
|
||||||
|
const note = el("p", { className: "m01-sl__note", attrs: { role: "status" } });
|
||||||
|
const diagramHost = el("div", { className: "m01-sl__part-body" });
|
||||||
|
const panelHost = el("div");
|
||||||
|
const toolsHost = el("div");
|
||||||
|
const resultHost = el("div", { className: "m01-sl__part-body" });
|
||||||
|
|
||||||
|
const evalNow = (): SlEvalResult => {
|
||||||
|
try {
|
||||||
|
return M.ev.evalLogic(sl);
|
||||||
|
} catch (e) {
|
||||||
|
const none = { 노무비: null, 재료비: null, 경비: null, 계: null };
|
||||||
|
return { pills: {}, pillsIn: {}, result: none, errors: [M.api.slPlain(e)] };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 바뀐 것을 받아 쌓음 — 구조가 바뀌면 비율 바탕을 다시 엮음 */
|
||||||
|
const edit = (next: SheetLogic, structural = false): void => {
|
||||||
|
if (next === sl) return;
|
||||||
|
stack = [...stack, sl];
|
||||||
|
sl = structural ? M.item.refreshBases(next) : next;
|
||||||
|
redraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const name = createInputField({
|
||||||
|
label: tp("Name"),
|
||||||
|
placeholder: tp("NamePlaceholder"),
|
||||||
|
value: sl.name,
|
||||||
|
onInput: (v) => edit({ ...sl, name: v }),
|
||||||
|
});
|
||||||
|
name.root.classList.add("m01-sl__name");
|
||||||
|
const units = ctx.units.includes(sl.resultUnit) ? ctx.units : [sl.resultUnit, ...ctx.units];
|
||||||
|
const unit = createSelectField({
|
||||||
|
label: tp("Unit"),
|
||||||
|
options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||||
|
value: sl.resultUnit,
|
||||||
|
onChange: (v) => edit({ ...sl, resultUnit: v }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const kindName = (k: SlKind): string => tp(`Kind_${k}` as TextKey);
|
||||||
|
const diagramOn = {
|
||||||
|
select: (id: string | null): void => {
|
||||||
|
selected = id;
|
||||||
|
drawPanel();
|
||||||
|
},
|
||||||
|
connect: (from: Parameters<Mods["model"]["connect"]>[1], to: typeof from): string | null => {
|
||||||
|
const got = M.model.connect(sl, from, to);
|
||||||
|
if (got.why) return got.why;
|
||||||
|
edit(got.sl, true);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
disconnect: (edgeId: string): void => edit(M.model.disconnect(sl, edgeId), true),
|
||||||
|
add: (kind: SlKind): void => {
|
||||||
|
const got = M.model.addNode(sl, kind, kindName(kind));
|
||||||
|
selected = got.node.id;
|
||||||
|
edit(got.sl, true);
|
||||||
|
},
|
||||||
|
remove: (nodeId: string): void => {
|
||||||
|
if (selected === nodeId) selected = null;
|
||||||
|
edit(M.model.removeNode(sl, nodeId), true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */
|
||||||
|
const loadPrices = (nodeId: string): void => {
|
||||||
|
void M.item.loadPrices(sl, nodeId).then(({ sl: next, why }) => {
|
||||||
|
if (why) note.textContent = why;
|
||||||
|
if (next !== sl) {
|
||||||
|
sl = next;
|
||||||
|
redraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawPanel = (): void => {
|
||||||
|
const node = sl.nodes.find((n) => n.id === selected);
|
||||||
|
if (!node) {
|
||||||
|
panelHost.replaceChildren(muted(tp("PickBox")));
|
||||||
|
toolsHost.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
M.panel.renderPanel(panelHost, sl, node.id, {
|
||||||
|
change: (next) => edit(next),
|
||||||
|
pill: (nodeId, cell, dir, pillName) => edit(M.model.setPill(sl, nodeId, cell, dir, pillName)),
|
||||||
|
});
|
||||||
|
if (node.kind === "table")
|
||||||
|
M.table.renderTableTools(toolsHost, sl, node.id, { change: (next) => edit(next, true) });
|
||||||
|
else if (node.kind === "item")
|
||||||
|
M.item.renderItemTools(toolsHost, sl, node.id, {
|
||||||
|
change: (next) => {
|
||||||
|
edit(next, true);
|
||||||
|
loadPrices(node.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
else toolsHost.replaceChildren();
|
||||||
|
};
|
||||||
|
|
||||||
|
const redraw = (): void => {
|
||||||
|
values = evalNow();
|
||||||
|
unsaved.hidden = sl === savedSl;
|
||||||
|
undo.disabled = !stack.length;
|
||||||
|
deleteButton.hidden = !sl.key;
|
||||||
|
const shown = Object.fromEntries(
|
||||||
|
sl.nodes.map((n) => [n.id, { ...values.pillsIn[n.id], ...values.pills[n.id] }]),
|
||||||
|
);
|
||||||
|
M.diagram.renderDiagram(diagramHost, sl, diagramOn, { values: shown, selected });
|
||||||
|
drawPanel();
|
||||||
|
M.result.renderResult(resultHost, sl, values, { change: (next) => edit(next) });
|
||||||
|
note.textContent = [...notes, ...values.errors].slice(0, 4).join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
if (busy) return;
|
||||||
|
if (!sl.name.trim()) {
|
||||||
|
note.textContent = tp("NoName");
|
||||||
|
return void name.input.focus();
|
||||||
|
}
|
||||||
|
let problems: string[] = [];
|
||||||
|
try {
|
||||||
|
problems = M.ev.checkLogic(sl);
|
||||||
|
} catch {
|
||||||
|
problems = [];
|
||||||
|
}
|
||||||
|
const ask = tp("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
|
||||||
|
if (problems.length && !(await showConfirmDialog(ask, tp("Save")))) return;
|
||||||
|
busy = true;
|
||||||
|
note.textContent = tp("Saving");
|
||||||
|
const screen = values.result.계;
|
||||||
|
try {
|
||||||
|
const got = await M.api.slSave({ ...sl, outside: outsidePlaces(M, sl) });
|
||||||
|
const server = serverTotal(got.logic);
|
||||||
|
sl = got.logic;
|
||||||
|
savedSl = sl;
|
||||||
|
stack = [];
|
||||||
|
notes = [];
|
||||||
|
redraw();
|
||||||
|
const differs = server !== null && screen !== null && Math.abs(server - screen) > 0.5;
|
||||||
|
if (differs) notes = [tp("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
|
||||||
|
note.textContent = [...notes, ...values.errors].join("\n");
|
||||||
|
showToast(tp("Saved"), "success");
|
||||||
|
} catch (e) {
|
||||||
|
note.textContent = tp("SaveFailed", { v: M.api.slPlain(e) });
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doUndo = (): void => {
|
||||||
|
const back = stack[stack.length - 1];
|
||||||
|
if (!back) return;
|
||||||
|
stack = stack.slice(0, -1);
|
||||||
|
sl = back;
|
||||||
|
name.input.value = sl.name;
|
||||||
|
unit.select.value = sl.resultUnit;
|
||||||
|
redraw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDelete = async (): Promise<void> => {
|
||||||
|
if (!sl.key || busy) return;
|
||||||
|
if (!(await showConfirmDialog(tp("Delete_Ask", { v: sl.name }), tp("Delete")))) return;
|
||||||
|
try {
|
||||||
|
await M.api.slDelete(sl.key);
|
||||||
|
savedSl = sl;
|
||||||
|
showToast(tp("Deleted"), "success");
|
||||||
|
ctx.onClose();
|
||||||
|
} catch (e) {
|
||||||
|
note.textContent = M.api.slPlain(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const undo = createButton({ label: tp("Undo"), variant: "ghost", onClick: doUndo });
|
||||||
|
const deleteButton = createButton({
|
||||||
|
label: tp("Delete"),
|
||||||
|
variant: "danger",
|
||||||
|
onClick: () => void doDelete(),
|
||||||
|
});
|
||||||
|
const close = createButton({ label: tp("Close"), variant: "ghost", onClick: ctx.onClose });
|
||||||
|
|
||||||
|
host.replaceChildren(
|
||||||
|
el("div", {
|
||||||
|
className: "m01-sl__strip",
|
||||||
|
children: [
|
||||||
|
name.root,
|
||||||
|
unit.root,
|
||||||
|
unsaved,
|
||||||
|
el("div", {
|
||||||
|
className: "m01-sl__actions",
|
||||||
|
children: [
|
||||||
|
undo,
|
||||||
|
createButton({ label: tp("Save"), onClick: () => void save() }),
|
||||||
|
deleteButton,
|
||||||
|
close,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
el("p", { className: "m01-sl__trial-note", text: tp("Trial") }),
|
||||||
|
note,
|
||||||
|
el("div", {
|
||||||
|
className: "m01-sl__layout",
|
||||||
|
children: [
|
||||||
|
el("section", {
|
||||||
|
className: "m01-sl__part m01-sl__diagram",
|
||||||
|
children: [el("h4", { text: tp("Diagram") }), diagramHost],
|
||||||
|
}),
|
||||||
|
el("section", {
|
||||||
|
className: "m01-sl__part m01-sl__panel",
|
||||||
|
children: [
|
||||||
|
el("h4", { text: tp("Panel") }),
|
||||||
|
el("div", { className: "m01-sl__part-body", children: [toolsHost, panelHost] }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
el("section", {
|
||||||
|
className: "m01-sl__part m01-sl__result",
|
||||||
|
children: [el("h4", { text: tp("Result") }), resultHost],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
redraw();
|
||||||
|
if (!sl.name) name.input.focus();
|
||||||
|
return { dirty: () => sl !== savedSl, save, undo: doUndo };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user