Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts
T
eomsangdonandClaude Sonnet 5.5 eaadb48a2b feat(796-2a): 수식 탭 화면 — 공통 수식 부품 · M01 수식 탭(첫 탭) · 고침 · 변수 창 · 옵션 가로
- ui_template_equation(.ts · _line.ts · .css): 분수 · 첨자 · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이 · 부품 모음 견본 한 줄
- M01 LogicLab_Equation: 옵션 가로(도구줄 + 값 칸 · 찾기형 · 말줄임) · 줄 고르면 식 칸 + 숨은 아이콘 줄 · 서버 미리 셈 다시 그림 · 고침 목록 캐시 → [수식 저장](개정 확인) · 떠나기 전 버림 물음
- 변수 창(이름 · 기호 · 위 · 아래첨자 · 단위 · 값 · 보이는 자리) · 변수 더함 · 지움(쓰는 곳 있으면 서버 까닭 그대로)
- 보기 고르기 = 수식 첫 탭 · 단가산출 상세 · 시트 · 인력재료경비 · 시험계산 · 텍스트 수식은 뒤로(걷지 않음)
- test_796_2_equation_screen 새로 · test_794_1 기대 갱신(첫 탭 수식 · LogicLab 700 줄 상한 — 수식 이음 14 줄)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:46:28 +09:00

514 lines
18 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)
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
* 고침 = 줄을 고르면 그 줄만 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사) + 숨은 아이콘 줄(분수 · 괄호 · 첨자 · %)
* · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createEqChain } from "@ui/ui_template_equation_line";
import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation";
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 { openAddDialog, openVarDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import {
formulaEdit,
inputsOf,
insertInto,
type EqEdit,
type InsertKind,
} 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"],
Now: ["지금", "Now"],
Before: ["고치기 전", "Before"],
Blocked: [
"시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)",
"Unsaved edits elsewhere — save or discard first (showing saved version)",
],
Options: ["옵션", "Options"],
Wait: ["수식을 불러오는 중", "Loading equation"],
Close: ["식 닫기", "Close formula"],
Hint: ["줄을 눌러 고침 · 키보드 + − × ÷ / ( )", "Click a line to edit · keyboard + − × ÷ / ( )"],
Frac: ["분수 (위) / (아래)", "Fraction"],
Paren: ["괄호", "Parentheses"],
Sub: ["아래첨자 _", "Subscript"],
Sup: ["위첨자 ^", "Superscript"],
Percent: ["% (/ 100)", "Percent"],
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 {};
}
};
export interface LabEquationOptions {
/** 시스템 관리자 — 고침 도구 */
admin: boolean;
/** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */
blocked: () => boolean;
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
onSaved: () => void;
}
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 selected: string | null = null;
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 body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(inputs), 고침: edits });
const persist = (): void => {
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 = edits.length
? await post<Read>("/logic/equation/preview", body0(), mine.signal)
: await post<Read>("/logic/equation", 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 post<Read>("/logic/equation/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 (v === null || v === "") delete inputs[spec.이름];
else inputs[spec.이름] = String(v);
askAgain();
};
let control: HTMLElement;
if ((spec.고르기?.length ?? 0) > BIG) {
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
const box = createInputField({
value: inputs[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, inputs[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 ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [
["frac", "a⁄b", "Frac"],
["paren", "( )", "Paren"],
["sub", "x₂", "Sub"],
["sup", "x²", "Sup"],
["percent", "%", "Percent"],
];
const check =
(line: EqLine) =>
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
const got = await post<Read>(
"/logic/equation/preview",
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
return {
되나: !!got.되나,
까닭: got.까닭 ?? [],
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
식: got.되나 ? text : null,
};
};
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
function editor(line: EqLine, id: string): HTMLElement {
const shown = line.입력글 ?? line.식;
const cell = createFormulaCell({
key: id,
spot: id,
shown,
origin: shown,
drafts,
check: check(line),
put: (text) => {
if (text === null) return;
void commit([formulaEdit(line, text)]).then((bad) => {
if (bad.length) showToast(bad.join(" · "), "error");
});
},
className: "m01-eq__fx",
});
const input = (): HTMLInputElement | null => cell.querySelector("input");
const icons = ICONS.map(([kind, glyph, label]) =>
createIconButton({
glyph,
title: t(label),
size: 24,
onClick: () => {
const box = input();
if (!box) return;
const from = box.selectionStart ?? box.value.length;
const got = insertInto(kind, box.value, from, box.selectionEnd ?? from);
box.value = got.text;
box.dispatchEvent(new Event("input", { bubbles: true }));
box.focus();
box.setSelectionRange(got.caret, got.caret);
},
}),
);
const add = createButton({
label: t("Add"),
variant: "ghost",
size: 24,
iconStart: "plus",
onClick: () =>
openAddDialog({
mids: (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠),
commit,
}),
});
const close = createButton({
label: t("Close"),
variant: "ghost",
size: 24,
onClick: () => ((selected = null), paint()),
});
// 아이콘 줄 — 고른 줄에만(평소 숨김)
const tools = createToolbar({
items: [...icons, "divider", add, "spacer", close],
size: 24,
label: t("Hint"),
});
tools.dataset.eqTools = id;
const wrap = el("div", { className: "m01-eq__edit", children: [cell, tools] });
wrap.dataset.eqEditor = id;
queueMicrotask(() => input()?.focus());
return wrap;
}
function varDialog(node: { 열쇠?: string }): void {
const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠);
if (!v || !canEdit()) return;
openVarDialog({
row: v,
value: inputs[v.열쇠] ?? "",
commit,
onValue: (value) => {
if (value) inputs[v.열쇠] = value;
else delete inputs[v.열쇠];
drawOptions();
void refresh();
},
onDropped: (k) => {
delete inputs[k];
drawOptions();
},
});
}
// ── 그림 ──────────────────────────────────────────────────────────
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("Edits", edits.length), "warning"),
createButton({
label: t("Discard"),
variant: "ghost",
size: 24,
onClick: () => void discard(),
}),
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();
body.replaceChildren(
createEqChain(eq.줄, {
path: "",
open,
selected,
resultUnit: eq.결과단위,
onSelect: edit
? (_line, id) => {
selected = selected === id ? null : id;
paint();
}
: undefined,
onVar: edit ? varDialog : undefined,
slot: edit ? editor : undefined,
}),
);
}
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, selected] = [[], 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 = [];
selected = null;
drafts.clear();
persist();
await refresh();
drawOptions();
}
return {
root,
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
inputs = {};
edits = loadCache()[nextKey] ?? [];
open.clear();
drafts.clear();
paint();
drawOptions();
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;
},
};
}