Files
Aislo/M01_MasterData/SheetLogic/M01_SL_Entry_Paper.ts
T

466 lines
17 KiB
TypeScript

/* =============================================================================
* 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"),
import("../../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(([model, ev, api, diagram, panel, table, item, result, imp, recalc]) => ({
model,
ev,
api,
diagram,
panel,
table,
item,
result,
imp,
recalc,
}));
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()}`;
// 아직 고르지 않은 상자(빈 번호)는 묻지 않음
const it = p.item as { key?: string; table?: string; element?: string };
if (!(it.key ?? it.table ?? it.element)) return;
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 });
}
// 넘길 입력값이 수식 칸이면 계산값이 있어야 함(없으면 null 로 넘어가 단가가 0)
M.imp.outsideOf(sl, M.recalc.recalcWorkbook(sl.workbook)).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 };
}
/* 시트 판 높이 — 그 시트가 쓰는 줄 수 + 두 줄(안쪽 세로 밀기 없게 · 사용자 요청)
* ponytail: 편집기 줄 높이 · 띠 높이를 숫자로 둠 — 편집기 꾸밈이 바뀌면 같이 고침 */
const ROW_PX = 20;
const CHROME_PX = 26 + 24 + 24 + 20; // 위 띠 · 칸 머리 · 아래 상태줄 · 가로 밀기 막대
function sheetHeight(sheet: { 칸: Record<string, unknown> } | null): number {
const rows = Object.keys(sheet?.칸 ?? {}).reduce(
(most, a1) => Math.max(most, Number(a1.replace(/^[A-Z]+/, "")) || 0),
0,
);
return CHROME_PX + (Math.max(rows, 6) + 2) * ROW_PX;
}
/** 서버가 다시 셈한 합계 — 결과 상자 「계」 알약 칸의 서버 계산값(수식이 아니면 칸 값) */
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 sigOf = (x: SheetLogic): string =>
JSON.stringify(outsidePlaces(M, x).map((p) => [p.sheet, p.cell, p.item]));
let outsideSig = sigOf(first);
let outsideTimer: ReturnType<typeof setTimeout> | undefined;
/** 바깥 값 다시 받기 — 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
const refreshOutside = (): void => {
const sig = sigOf(sl);
if (sig === outsideSig) return;
outsideSig = sig;
clearTimeout(outsideTimer);
outsideTimer = setTimeout(() => {
const from = sl;
void fillOutside(M, from).then(({ sl: next, errors }) => {
if (sl !== from) {
// 그 사이 또 바뀜 — 지금 것으로 다시 받음(옛 답 · 옛 까닭은 버림)
outsideSig = "";
return refreshOutside();
}
if (savedSl === from) savedSl = next;
sl = next;
outsideSig = sigOf(next);
notes = errors;
redraw();
});
}, 300);
};
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" });
// 시트 판은 Panel 이 제 상태로 쥠 — 안을 갈아끼우지 않고 숨기기만(갈아끼우면 Panel 이 편집기가 있다고 여김)
const panelHost = el("div");
const pickHint = muted(tp("PickBox"));
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 editFromDiagram = (next: SheetLogic): void => {
edit(next, true);
setTimeout(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;
editFromDiagram(got.sl);
return null;
},
disconnect: (edgeId: string): void => editFromDiagram(M.model.disconnect(sl, edgeId)),
add: (kind: SlKind): void => {
const got = M.model.addNode(sl, kind, kindName(kind));
selected = got.node.id;
editFromDiagram(got.sl);
},
remove: (nodeId: string): void => {
if (selected === nodeId) selected = null;
editFromDiagram(M.model.removeNode(sl, nodeId));
},
};
/** 항목 단가 — 고른 뒤 서버에서 받아 넣음(같은 조작의 뒤끝이라 기록을 더 쌓지 않음) */
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();
}
});
};
/* ponytail: 「다른 로직」 상자 손잡이(sub5 renderLogicTools)가 올라오기 전엔 없음 — 올라오면 바로 M.item.renderLogicTools 로 부름 */
const logicTools = (M.item as { renderLogicTools?: typeof M.item.renderItemTools })
.renderLogicTools;
const drawPanel = (): void => {
const node = sl.nodes.find((n) => n.id === selected);
if (!node) {
panelHost.hidden = true;
pickHint.hidden = false;
toolsHost.replaceChildren();
return;
}
panelHost.hidden = false;
pickHint.hidden = true;
toolsHost.className = ""; // 표 손잡이가 제 꾸밈 이름을 붙임 — 다른 종류로 바뀌면 지움
panelHost.style.setProperty("--sl-sheet-h", `${sheetHeight(M.model.nodeSheet(sl, node.id))}px`);
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 if (node.kind === "logic" && logicTools)
logicTools(toolsHost, sl, node.id, {
change: (next) => {
edit(next, true);
loadPrices(node.id);
},
});
else toolsHost.replaceChildren();
};
const redraw = (): void => {
values = evalNow();
refreshOutside();
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: [pickHint, 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 };
}