- 수식 영역: 블록 묶음은 그 블록 식 줄 바로 아래(createEqChain after) · 남은 묶음 · 소 계 · 끝수 · 합계는 맨 끝 - 금액 셈 그대로(보이기만) · 묶음 소계 합 + 끝수 = 합계 시험 추가(test_800_2) - 정의 영역: 로직 부르기 정의의 이름 되풀이 걷음 · 상수 연결 설명 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
617 lines
23 KiB
TypeScript
617 lines
23 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
},
|
||
};
|
||
}
|