Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts
T
eomsangdonandClaude Opus 5.5 2376e70fbb revert(802-1): 801-1 wip 옛 나무 위 고침 걷음 — 요구 664 수식 문서로 다시 짬
- 걷음: 43784a4c0 의 옛 수식 나무 위 고침(서버 · 화면 · css) = 그 앞 판으로 되돌림
- 남김: master_equation_sign.py · ui_template_equation_text.ts · ui_template_equation_edit.ts(다시 쓸 조각 · 지금 안 부름) · text 는 `입력글` 을 제 타입에
- 지움: test_801_1_equation_inline.py — 걷은 옛 나무 고침을 재는 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:55:58 +09:00

617 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M01_MasterData_UI_LogicLab_Equation.ts
* M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이).
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
* 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createEqChain } from "@ui/ui_template_equation_line";
import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs";
import { createEqSum } from "@ui/ui_template_equation_sum";
import {
eqText,
type EqLine,
type EqSumHooks,
type EqNode,
type EqTree,
} from "@ui/ui_template_equation";
import { lineId } from "@ui/ui_template_equation_line";
import {
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
import {
draftRow,
renameEditor,
type Draft,
type DefsHost,
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model";
import "./M01_MasterData_UI_LogicLab_Equation.css";
const TEXT = {
Title: ["수식", "Equation"],
Save: ["수식 저장", "Save equation"],
Discard: ["수식 고침 버림", "Discard edits"],
Add: ["변수 더함", "Add variable"],
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
EditsPage: ["수식 고침 {n} 건 · 사본 위", "{n} equation edits · on copy"],
DiscardPage: ["수식 원래로", "Back to copy"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before"],
Blocked: [
"시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)",
"Unsaved edits elsewhere — save or discard first (showing saved version)",
],
Options: ["옵션", "Options"],
Wait: ["수식을 불러오는 중", "Loading equation"],
Hint: [
"식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )",
"Click a formula to edit · keyboard + − × ÷ / ( )",
],
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
Leave_Ok: ["버림", "Discard"],
Saved: ["수식을 저장함", "Equation saved"],
Total: ["계", "Total"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT, n = 0): string =>
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n));
interface Read {
ok?: boolean;
reason?: string;
수식?: EqTree;
되나?: boolean;
까닭?: string[];
줄?: LogicRow;
전?: Record<string, unknown>;
후?: Record<string, unknown>;
}
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText);
return data;
}
const CACHE = "m01_equation_edits";
const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록)
const loadCache = (): Record<string, EqEdit[]> => {
try {
return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record<string, EqEdit[]>;
} catch {
return {};
}
};
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
export interface EqPage {
ask: <T>(
suffix: "" | "/preview" | "/links",
body: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<T>;
edits: () => EqEdit[];
keep: (edits: EqEdit[]) => void;
values: () => Record<string, string>;
setValue: (name: string, value: string | null) => void;
/** 소계 표 덧단추 자리(796-2e M02 더한 기초 [연결 끊기]) */
sum?: EqSumHooks;
}
export interface LabEquationOptions {
/** 시스템 관리자 — 고침 도구 */
admin: boolean;
/** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */
blocked: () => boolean;
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
onSaved: () => void;
/** 없으면 M01(길 `/m01/logic/equation` · 캐시 sessionStorage · [수식 저장]) */
page?: EqPage;
}
/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */
interface PendingLink {
line: EqLine;
text: string;
symbols: string[];
rows: Map<string, Draft>;
apply: HTMLElement;
}
export function createLabEquation(opt: LabEquationOptions) {
const bar = el("div", { className: "m01-eq__bar" });
const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } });
const notice = el("div", { className: "m01-eq__notice" });
const body = el("div", { className: "m01-eq__body" });
const root = el("div", {
className: "m01-eq",
attrs: { "data-lab-equation": "" },
children: [bar, optionsBar, notice, body],
});
let key = "";
let version = "";
let row: LogicRow | null = null;
let rowNow: LogicRow | null = null;
let eq: EqTree | null = null;
let edits: EqEdit[] = [];
let inputs: Record<string, string> = {};
let before: Record<string, unknown> | null = null;
let why = "";
let editingLine: string | null = null;
let defEditing: string | null = null;
let pending: PendingLink | null = null;
let defsBox: HTMLElement = el("div");
let ctl: AbortController | null = null;
let timer = 0;
let paintedBlocked = false;
const open = new Set<string>();
const drafts = new Map<string, string>();
const canEdit = (): boolean => opt.admin && !opt.blocked();
const page = opt.page;
const vals = (): Record<string, string> => page?.values() ?? inputs;
const ask = <T>(
suffix: "" | "/preview" | "/links",
body: Record<string, unknown>,
signal?: AbortSignal,
) =>
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
const persist = (): void => {
if (page) return page.keep(edits);
const all = loadCache();
if (edits.length) all[key] = edits;
else delete all[key];
try {
sessionStorage.setItem(CACHE, JSON.stringify(all));
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
/** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */
async function refresh(): Promise<void> {
ctl?.abort();
const mine = (ctl = new AbortController());
try {
const got = await ask<Read>(edits.length ? "/preview" : "", body0(), mine.signal);
if (mine !== ctl) return;
if (got.되나 === false || got.ok === false) {
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
} else {
why = "";
eq = got.수식 ?? null;
rowNow = got.줄 ?? null;
before = edits.length ? (got.전 ?? null) : null;
}
} catch (error) {
if (mine.signal.aborted) return;
why = error instanceof Error ? error.message : String(error);
}
paint();
}
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
async function commit(list: EqEdit[]): Promise<string[]> {
const got = await ask<Read>("/preview", {
...body0(),
고침: [...edits, ...list],
}).catch((error: unknown): Read => ({
되나: false,
까닭: [error instanceof Error ? error.message : String(error)],
}));
if (!got.되나) return got.까닭 ?? [];
edits = [...edits, ...list];
persist();
ctl?.abort();
why = "";
eq = got.수식 ?? eq;
rowNow = got.줄 ?? rowNow;
before = got.전 ?? before;
paint();
drawOptions();
return [];
}
const askAgain = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void refresh(), 150);
};
// ── 옵션 가로 ──────────────────────────────────────────────────────
function optionNode(spec: NonNullable<LogicRow["입력"]>[number], auto: unknown): HTMLElement {
const item: ItemInput = {
이름: spec.이름,
단위: spec.단위,
고르기: spec.고르기,
범위: spec.범위,
자동값: auto,
};
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
const set = (v: string | number | null): void => {
if (page) return page.setValue(spec.이름, v === null || v === "" ? null : String(v));
if (v === null || v === "") delete inputs[spec.이름];
else inputs[spec.이름] = String(v);
askAgain();
};
let control: HTMLElement;
if ((spec.고르기?.length ?? 0) > BIG) {
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
const box = createInputField({
value: vals()[spec.이름] ?? "",
compact: true,
size: 24,
placeholder: auto === null || auto === undefined ? "" : String(auto),
});
const list = el("datalist", {
attrs: { id: `m01-eq-list-${spec.이름}` },
children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })),
});
box.input.setAttribute("list", list.id);
box.input.title = box.input.value || box.input.placeholder;
box.input.addEventListener("change", () => {
box.input.title = box.input.value;
set(box.input.value.trim());
});
control = el("div", { children: [box.root, list] });
} else {
const made = createValueControl(item, vals()[spec.이름] ?? null, (v) => set(v));
const tip = (): void => {
const c = made.control;
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
};
made.control.addEventListener("change", tip);
made.control.addEventListener("input", tip);
tip();
control = el("div", { children: [made.control, made.error] });
}
control.classList.add("m01-eq__ctl");
const cell = el("div", { className: "m01-eq__opt", children: [label, control] });
cell.dataset.eqOption = spec.이름;
return cell;
}
function drawOptions(): void {
const specs = (rowNow ?? row)?.입력 ?? [];
const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v]));
optionsBar.replaceChildren(
...(specs.length
? [
createToolbar({
items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)),
size: 24,
label: t("Options"),
}),
]
: []),
);
}
// ── 바로 고침 · 연결 지정 ──────────────────────────────────────────
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
const defsHost: DefsHost = {
commit,
links: (body) => ask("/links", body),
onDropped: (k) => {
if (page) page.setValue(k, null);
else {
delete inputs[k];
drawOptions();
}
},
};
function buildDefs(): HTMLElement {
const edit = canEdit();
const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠);
return createEqDefs(eq?.정의 ?? [], {
editing: defEditing,
onEdit: edit
? (d) => {
defEditing = defEditing === defSign(d) ? null : defSign(d);
drawDefs();
}
: undefined,
editor: (d) =>
renameEditor(d, defsHost, () => {
defEditing = null;
drawDefs();
}),
drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [],
head: edit
? [
createButton({
label: t("Add"),
variant: "ghost",
size: 24,
iconStart: "plus",
onClick: () => openAddDialog({ mids, commit }),
}),
]
: [],
});
}
function drawDefs(): void {
const next = buildDefs();
defsBox.replaceWith(next);
defsBox = next;
}
/** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */
function setPending(line: EqLine, text: string, miss: string[]): void {
if (!miss.length) {
if (!pending) return;
pending = null;
return drawDefs();
}
if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) {
[pending.line, pending.text] = [line, text];
return;
}
const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const));
const apply = el("div", {
className: "m01-eq__def-actions",
children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })],
});
pending = { line, text, symbols: miss, rows, apply };
drawDefs();
defsBox.scrollTo({ top: 0 });
}
async function applyPending(): Promise<void> {
const p = pending;
if (!p) return;
const list: EqEdit[] = [];
let missing = false;
for (const d of p.rows.values()) {
const made = d.read();
d.say(typeof made === "string" ? made : "");
if (typeof made === "string") missing = true;
else list.push(made);
}
if (missing) return;
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
drafts.delete(lineId(p.line));
[pending, editingLine] = [null, null];
paint();
}
const check =
(line: EqLine) =>
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
const got = await ask<Read>(
"/preview",
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []);
setPending(line, text, miss);
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
return {
되나: !!got.되나,
까닭: miss.length
? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`]
: (got.까닭 ?? []),
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
식: got.되나 ? text : null,
};
};
/** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */
function editor(line: EqLine, id: string): HTMLElement {
const shown = line.입력글 ?? line.식;
const close = (): void => {
[editingLine, pending] = [null, null];
paint();
};
const cell = createFormulaCell({
key: id,
spot: id,
shown,
origin: shown,
drafts,
check: check(line),
put: (text) => {
if (text === null) return pending ? undefined : close();
void commit([formulaEdit(line, text)]).then((bad) => {
if (bad.length) showToast(bad.join(" · "), "error");
else close();
});
},
className: "m01-eq__fx",
});
const input = cell.querySelector("input");
// 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로
cell.addEventListener("focusout", () =>
window.setTimeout(() => {
if (editingLine === id && !pending && !cell.contains(document.activeElement)) {
if ((input?.value.trim() ?? shown) === shown) close();
}
}, 120),
);
queueMicrotask(() => input?.focus());
return cell;
}
const showDef = (node: EqNode): void => flashEqDef(defsBox, node);
// ── 그림 ──────────────────────────────────────────────────────────
function drawBar(): void {
const sums = eq?.합계 ?? {};
const mark = total(sums);
const now = sums[mark];
const old = before?.[mark];
const diff = Number(now) - Number(old);
const items: (HTMLElement | "divider" | "spacer")[] = [
createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14),
];
if (before) {
items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14));
if (Number.isFinite(diff) && diff)
items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning"));
}
if (canEdit()) {
items.push("spacer");
if (edits.length) {
items.push(
createBadge(t(page ? "EditsPage" : "Edits", edits.length), "warning"),
createButton({
label: t(page ? "DiscardPage" : "Discard"),
variant: "ghost",
size: 24,
onClick: () => void discard(),
}),
...(page
? []
: [createButton({ label: t("Save"), size: 24, onClick: () => void save() })]),
);
} else items.push(createText(t("Hint"), "muted", 12));
}
bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") }));
}
function paint(): void {
paintedBlocked = opt.blocked();
drawBar();
notice.replaceChildren(
...(paintedBlocked && opt.admin
? [createBanner({ tone: "warning", text: t("Blocked") })]
: []),
...(why ? [createBanner({ tone: "danger", text: why })] : []),
);
if (!eq) {
body.replaceChildren(createText(t("Wait"), "muted"));
return;
}
const edit = canEdit();
const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠);
const sum = createEqSum(eq, open, page?.sum, keys);
const left = el("div", {
className: "m01-eq__left",
children: [
createEqChain(eq.줄, {
path: "",
open,
resultUnit: eq.결과단위,
onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함)
editing: editingLine,
onEdit: edit
? (_line, id) => {
editingLine = id;
paint();
}
: undefined,
editor: edit ? editor : undefined,
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
}),
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
],
});
defsBox = buildDefs();
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
}
async function save(): Promise<void> {
if (!canEdit() || !edits.length) return;
showLoadingOverlay();
try {
const done = await saveRevised((ok) =>
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
);
if (!done) return; // 개정 확인 취소 = 고침 그대로
[edits, editingLine] = [[], null];
persist();
showToast(t("Saved"), "success");
await refresh();
drawOptions();
opt.onSaved();
} catch (error) {
const detail =
error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null;
showToast(
detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)),
"error",
);
} finally {
hideLoadingOverlay();
}
}
async function discard(): Promise<void> {
edits = [];
[editingLine, defEditing, pending] = [null, null, null];
drafts.clear();
persist();
await refresh();
drawOptions();
}
return {
root,
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
const moved = nextVersion !== version; // 판 · 페이지 문서 · 옵션 바뀜 = 다시 셈
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null];
[defEditing, pending] = [null, null];
inputs = {};
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
open.clear();
drafts.clear();
paint();
drawOptions();
void refresh().then(drawOptions);
} else if (moved) {
if (page) edits = page.edits();
void refresh().then(drawOptions);
} else if (opt.blocked() !== paintedBlocked) paint();
},
dirty: (): boolean => edits.length > 0,
/** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */
canLeave: async (): Promise<boolean> => {
if (!edits.length) return true;
if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false;
await discard();
return true;
},
};
}