- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌 1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서) - 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로 - 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만 - 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만 - 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
686 lines
22 KiB
TypeScript
686 lines
22 KiB
TypeScript
/* =============================================================================
|
||
* formula_doc_screen.ts
|
||
* 단가산출 기초 수식 문서 화면(PLAN 802-3 · 요구 660~665 · 요구서_664 3 장) — 공용(M01 · 뒤에 M02 = 길 앞머리만 바꿈)
|
||
* 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · 결과/계 · [수식 저장] · [고침 버림](관리자)
|
||
* 가운데 = 격자 두 칸 — 왼쪽 기호 식 · 오른쪽 값 식(`formula_doc_lines.ts`) · 오른쪽 아래 기호 정의(`formula_doc_defs.ts`)
|
||
* 빈 문서에서 시작 — [+ 식 줄] 에 `Q = 3600 × q × K × f × E / Cm` 침 → 새 기호마다 「정의 필요」 → 연결 고름
|
||
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 연결 값은 서버 기록 캐시(`common_util_formula_doc_cache.ts`)
|
||
* · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음) → 캐시에 더해 다시 셈
|
||
* 고침 = 캐시(sessionStorage `opt.cache`)에 쌓고 [수식 저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈 · 파일에 씀)
|
||
* 무엇을 지워도 오류 없음 — 표지(정의 필요 · 순환 · 0 나눔 …) + 금액 「—」
|
||
* ========================================================================== */
|
||
|
||
import { API_BASE_URL } from "@config/config_frontend";
|
||
import { createBadge } from "@ui/ui_template_badge";
|
||
import { createButton } from "@ui/ui_template_button";
|
||
import { eqText } from "@ui/ui_template_equation";
|
||
import { editNumber } from "@ui/ui_template_equation_edit";
|
||
import {
|
||
el,
|
||
hideLoadingOverlay,
|
||
showConfirmDialog,
|
||
showLoadingOverlay,
|
||
showToast,
|
||
} from "@ui/ui_template_elements";
|
||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||
import {
|
||
DocError,
|
||
goodId,
|
||
rename,
|
||
splitId,
|
||
tidy,
|
||
varId,
|
||
type DocVar,
|
||
type FormulaDoc,
|
||
type Link,
|
||
type MoneyLine,
|
||
} from "../common_util/common_util_formula_doc";
|
||
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
|
||
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
|
||
import type { DocTable } from "../common_util/common_util_formula_doc_table";
|
||
import { buildDefs, type Pick } from "./formula_doc_defs";
|
||
import { buildLines, type EditAt } from "./formula_doc_lines";
|
||
import { evalNode, reach, swapLit, tryParse } from "./formula_doc_render";
|
||
import { ft } from "./formula_doc_text";
|
||
import "./formula_doc.css";
|
||
|
||
export interface DocScreenOptions {
|
||
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links · GET /table */
|
||
base: string;
|
||
admin: boolean;
|
||
/** sessionStorage 열쇠 */
|
||
cache: string;
|
||
}
|
||
|
||
interface Loaded {
|
||
문서: FormulaDoc;
|
||
셈: DocAnswer;
|
||
연결: DocRecord;
|
||
새?: boolean;
|
||
}
|
||
|
||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||
|
||
/** 머리 = 식 — 첫 「=」(비교 ==, <=, >=, != 빼고) */
|
||
export function splitLine(text: string): [string | null, string] {
|
||
const m = /^([^=<>!]*?)\s*(?<![<>!=])=(?!=)\s*([\s\S]*)$/.exec(text);
|
||
if (!m) return [null, text.trim()];
|
||
return [m[1].trim(), m[2].trim()];
|
||
}
|
||
|
||
export function createDocScreen(opt: DocScreenOptions) {
|
||
const head = el("div", { className: "fdoc__head" });
|
||
const marks = el("div", { className: "fdoc__marks" });
|
||
const grid = el("div", { className: "fdoc__grid" });
|
||
const root = el("div", {
|
||
className: "fdoc ui-eq",
|
||
attrs: { "data-formula-doc": "" },
|
||
children: [head, marks, grid],
|
||
});
|
||
let key = "";
|
||
let saved = "";
|
||
let fresh = false;
|
||
let doc: FormulaDoc | null = null;
|
||
let answer: DocAnswer | null = null;
|
||
let cache = createDocCache();
|
||
let given: Record<string, unknown> = {};
|
||
let openDef: string | null = null;
|
||
let auto: EditAt = null;
|
||
let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드)
|
||
let seq = 0;
|
||
let asked = "";
|
||
let ctl: AbortController | null = null;
|
||
let waiting = false;
|
||
const tables = new Map<string, Promise<DocTable | null>>();
|
||
|
||
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
|
||
const r = await fetch(`${API_BASE_URL}${opt.base}${path}`, {
|
||
method: body === undefined ? "GET" : "POST",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||
signal,
|
||
});
|
||
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
||
if (!r.ok) throw new Error(String(data.detail ?? r.statusText));
|
||
return data;
|
||
};
|
||
|
||
// ── 캐시(sessionStorage) ──
|
||
const drafts = (): Record<string, FormulaDoc> => {
|
||
try {
|
||
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved;
|
||
const keep = (): void => {
|
||
const all = drafts();
|
||
if (dirty()) all[key] = doc!;
|
||
else delete all[key];
|
||
try {
|
||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||
} catch {
|
||
/* 캐시가 막혀도 화면 안 고침은 남음 */
|
||
}
|
||
};
|
||
|
||
// ── 셈 ──
|
||
const usable = (): Record<string, unknown> => {
|
||
const out: Record<string, unknown> = {};
|
||
for (const v of doc?.변수 ?? []) {
|
||
const k = v.열쇠;
|
||
if (!k || !(k in given)) continue;
|
||
const kind = v.연결?.갈래;
|
||
if (kind === "입력") out[k] = given[k];
|
||
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
|
||
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
|
||
}
|
||
return out;
|
||
};
|
||
const recalc = (): void => {
|
||
if (!doc) return;
|
||
answer = calcDoc(doc, cache.links, usable());
|
||
if (cache.takeMissing()) ask();
|
||
};
|
||
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함(같은 문서 · 입력은 한 번만) */
|
||
const ask = (): void => {
|
||
const snap = JSON.stringify([doc, usable()]);
|
||
if (snap === asked) return;
|
||
asked = snap;
|
||
ctl?.abort();
|
||
ctl = new AbortController();
|
||
const mine = ++seq;
|
||
waiting = true;
|
||
void call<{ 셈: DocAnswer; 연결: DocRecord }>(
|
||
"/preview",
|
||
{ 문서: doc, 입력: usable() },
|
||
ctl.signal,
|
||
)
|
||
.then((got) => {
|
||
if (mine !== seq) return;
|
||
cache.merge(got.연결);
|
||
waiting = false;
|
||
recalc();
|
||
draw();
|
||
})
|
||
.catch(() => {
|
||
if (mine === seq) waiting = false;
|
||
});
|
||
};
|
||
|
||
const change = (fn: (d: FormulaDoc) => void): void => {
|
||
if (!doc) return;
|
||
fn(doc);
|
||
keep();
|
||
recalc();
|
||
draw();
|
||
};
|
||
|
||
// ── 변수 ──
|
||
const ensure = (d: FormulaDoc, id: string): DocVar => {
|
||
const have = (d.변수 ??= []).find((v) => varId(v) === id);
|
||
if (have) return have;
|
||
const [기호, 아래, 위] = splitId(id);
|
||
const v: DocVar = { 열쇠: id, 기호, 아래, 위, 이름: "", 단위: "", 연결: { 갈래: "식" } };
|
||
d.변수.push(v);
|
||
return v;
|
||
};
|
||
const tidyText = (t: string): string => (t.trim() ? tidy(t.trim()) : "");
|
||
|
||
const onLine = (i: number, text: string): void =>
|
||
change((d) => {
|
||
const lines = (d.식 ??= []);
|
||
const line = lines[i];
|
||
if (!line) return;
|
||
if (!text.trim()) {
|
||
lines.splice(i, 1);
|
||
return;
|
||
}
|
||
const [h, body] = splitLine(text);
|
||
const old = String(line.기호 ?? "");
|
||
if (h !== null && h !== old) {
|
||
line.기호 = h;
|
||
const was = (d.변수 ?? []).find((v) => varId(v) === old && (v.연결?.갈래 ?? "식") === "식");
|
||
if (was && goodId(h) && !(d.변수 ?? []).some((v) => varId(v) === h)) {
|
||
const [기호, 아래, 위] = splitId(h);
|
||
Object.assign(was, { 기호, 아래, 위 });
|
||
}
|
||
}
|
||
line.식 = tidyText(body);
|
||
});
|
||
|
||
const onMoney = (i: number, patch: Partial<MoneyLine> | null): void =>
|
||
change((d) => {
|
||
const lines = (d.금액 ??= []);
|
||
if (!patch) {
|
||
lines.splice(i, 1);
|
||
return;
|
||
}
|
||
const line = lines[i];
|
||
for (const [k, v] of Object.entries(patch)) {
|
||
const text = typeof v === "string" && (k === "단가" || k === "수량") ? tidyText(v) : v;
|
||
if (k === "수량" && text === "") delete line.수량;
|
||
else (line as Record<string, unknown>)[k] = text;
|
||
}
|
||
if ("이름" in patch && !line.단가) auto = { kind: "price", i };
|
||
});
|
||
|
||
const onLit = (target: "line" | "price" | "qty", i: number, at: number, value: number): void =>
|
||
change((d) => {
|
||
if (target === "line") d.식![i].식 = swapLit(String(d.식![i].식 ?? ""), at, value);
|
||
else {
|
||
const line = d.금액![i];
|
||
const slot = target === "price" ? "단가" : "수량";
|
||
line[slot] = swapLit(String(line[slot] ?? ""), at, value);
|
||
}
|
||
});
|
||
|
||
const onPickVar = (id: string, num?: HTMLElement): void => {
|
||
const v = doc?.변수?.find((x) => varId(x) === id);
|
||
if (opt.admin && num && v?.연결?.갈래 === "숫자") {
|
||
editNumber(num, (t) => {
|
||
const n = Number(t.replace(/,/g, ""));
|
||
change((d) => (ensure(d, id).연결 = { ...v.연결, 값: Number.isFinite(n) ? n : t }));
|
||
});
|
||
return;
|
||
}
|
||
openDef = id;
|
||
draw();
|
||
grid.querySelector(`[data-def="${CSS.escape(id)}"]`)?.scrollIntoView({ block: "nearest" });
|
||
};
|
||
|
||
const tableOf = (k: string): Promise<DocTable | null> => {
|
||
if (!tables.has(k))
|
||
tables.set(
|
||
k,
|
||
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
|
||
.then((t) => {
|
||
if (t.끊김) return null;
|
||
cache.putTable(k, t);
|
||
return t;
|
||
})
|
||
.catch(() => null),
|
||
);
|
||
return tables.get(k)!;
|
||
};
|
||
|
||
const subDoc = (k: string): FormulaDoc | null => {
|
||
try {
|
||
return cache.links.doc(k) ?? null;
|
||
} catch {
|
||
cache.takeMissing();
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/** 단가산출 연결 펼침 — 그 문서의 식 · 값 식(읽기만) */
|
||
const subBlock = (id: string): HTMLElement | null => {
|
||
const link = doc?.변수?.find((v) => varId(v) === id)?.연결;
|
||
if (link?.갈래 !== "단가산출" || !link.키 || link.키.includes("{")) return null;
|
||
const sub = subDoc(link.키);
|
||
if (!sub) return null;
|
||
const inputs: Record<string, unknown> = {};
|
||
for (const [k, t] of Object.entries(link.입력 ?? {})) {
|
||
const n = tryParse(t);
|
||
const v = n ? evalNode(n, answer?.값 ?? {}) : undefined;
|
||
if (v !== undefined) inputs[k] = v;
|
||
}
|
||
const got = calcDoc(sub, cache.links, inputs);
|
||
cache.takeMissing();
|
||
const box = el("div", { className: "fdoc__grid fdoc__sub" });
|
||
box.append(
|
||
...buildLines({
|
||
admin: false,
|
||
doc: sub,
|
||
answer: got,
|
||
vars: new Map((sub.변수 ?? []).map((v) => [varId(v), v])),
|
||
marks: new Map(),
|
||
auto: null,
|
||
onLine: () => undefined,
|
||
onExpr: () => undefined,
|
||
onMoney: () => undefined,
|
||
onLit: () => undefined,
|
||
onPickVar: () => undefined,
|
||
onAddLine: () => undefined,
|
||
onAddMoney: () => undefined,
|
||
onGroupName: () => undefined,
|
||
onRedraw: () => undefined,
|
||
}),
|
||
);
|
||
if (sub.결과) {
|
||
const r = got.결과;
|
||
box.append(
|
||
el("div", { className: "fdoc__cell fdoc__total", text: ft("Result") }),
|
||
el("div", {
|
||
className: "fdoc__cell fdoc__total",
|
||
text: typeof r === "number" ? `${eqText(r)} ${sub.결과단위 ?? ""}` : "—",
|
||
}),
|
||
);
|
||
}
|
||
return box;
|
||
};
|
||
|
||
// ── 그림 ──
|
||
const optionBar = (): HTMLElement[] => {
|
||
const out: HTMLElement[] = [];
|
||
for (const v of doc?.변수 ?? []) {
|
||
const kind = v.연결?.갈래;
|
||
const k = v.열쇠;
|
||
if (!k || (kind !== "선택" && kind !== "입력")) continue;
|
||
const label = v.이름 || varId(v);
|
||
const now = k in given ? given[k] : v.연결?.값;
|
||
const set = (x: unknown): void => {
|
||
given[k] = x;
|
||
recalc();
|
||
draw();
|
||
};
|
||
if (kind === "선택") {
|
||
const list = (v.연결?.목록 ?? []).map(String);
|
||
const f = createSelectField({
|
||
label,
|
||
options: list.map((x) => ({ value: x, text: x })),
|
||
value: String(now ?? ""),
|
||
compact: true,
|
||
size: 28,
|
||
onChange: (x) => set(x),
|
||
});
|
||
f.root.dataset.option = k;
|
||
out.push(f.root);
|
||
} else {
|
||
const f = createInputField({
|
||
label,
|
||
value: now === undefined || now === null ? "" : String(now),
|
||
compact: true,
|
||
size: 28,
|
||
unit: v.단위 || undefined,
|
||
});
|
||
f.input.addEventListener("change", () => {
|
||
const t = f.input.value.trim();
|
||
const n = Number(t.replace(/,/g, ""));
|
||
set(t !== "" && Number.isFinite(n) ? n : t);
|
||
});
|
||
f.root.dataset.option = k;
|
||
out.push(f.root);
|
||
}
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const headLine = (): HTMLElement => {
|
||
const a = answer;
|
||
const bits: (HTMLElement | string)[] = [];
|
||
if (doc?.결과) {
|
||
const r = a?.결과;
|
||
bits.push(
|
||
el("strong", { text: ft("Result") }),
|
||
el("span", {
|
||
className: "fdoc__headval",
|
||
attrs: { "data-total": "" },
|
||
text: typeof r === "number" ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—",
|
||
}),
|
||
);
|
||
} else {
|
||
const total = a?.계;
|
||
bits.push(
|
||
el("strong", { text: a?.머리 === "합계" ? ft("Total") : ft("Sum") }),
|
||
el("span", {
|
||
className: "fdoc__headval",
|
||
attrs: { "data-total": "" },
|
||
text: typeof total === "number" ? `${eqText(total)} ${doc?.결과단위 ?? ""}` : "—",
|
||
}),
|
||
);
|
||
}
|
||
if (opt.admin && doc && !(doc.금액 ?? []).length) {
|
||
const f = createInputField({
|
||
placeholder: ft("Result_Sym"),
|
||
value: doc.결과 ?? "",
|
||
compact: true,
|
||
size: 24,
|
||
});
|
||
f.input.addEventListener("change", () =>
|
||
change((d) => {
|
||
const t = f.input.value.trim();
|
||
if (t) d.결과 = t;
|
||
else delete d.결과;
|
||
}),
|
||
);
|
||
f.root.classList.add("fdoc__resultsym");
|
||
bits.push(f.root);
|
||
}
|
||
if (waiting)
|
||
bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Loading") }));
|
||
if (fresh && !dirty())
|
||
bits.push(el("span", { className: "ui-text ui-text--muted", text: ft("Fresh") }));
|
||
if (dirty()) bits.push(createBadge(ft("Dirty"), "warning"));
|
||
const tools = opt.admin
|
||
? [
|
||
createButton({
|
||
label: ft("Discard"),
|
||
variant: "ghost",
|
||
size: 28,
|
||
disabled: !dirty(),
|
||
onClick: () => void discard(),
|
||
}),
|
||
createButton({
|
||
label: ft("Save"),
|
||
variant: "filled",
|
||
size: 28,
|
||
disabled: !dirty(),
|
||
onClick: () => void save(),
|
||
}),
|
||
]
|
||
: [];
|
||
return el("div", {
|
||
className: "fdoc__sum",
|
||
children: [
|
||
el("div", { className: "fdoc__sumleft", children: bits }),
|
||
el("div", { className: "fdoc__tools", children: tools }),
|
||
],
|
||
});
|
||
};
|
||
|
||
const markBox = (): void => {
|
||
const list = answer?.표지 ?? [];
|
||
marks.replaceChildren();
|
||
if (!list.length) return;
|
||
const count = new Map<string, number>();
|
||
for (const m of list) count.set(m.갈래, (count.get(m.갈래) ?? 0) + 1);
|
||
marks.append(
|
||
...[...count].map(([k, n]) =>
|
||
createBadge(`${k} ${n}`, k === "빈 문서" ? "neutral" : "warning"),
|
||
),
|
||
...list
|
||
.slice(0, 4)
|
||
.map((m) => el("span", { className: "ui-text ui-text--muted fdoc__marktext", text: m.글 })),
|
||
);
|
||
};
|
||
|
||
const draw = (): void => {
|
||
if (!doc || !answer) {
|
||
head.replaceChildren();
|
||
marks.replaceChildren();
|
||
grid.replaceChildren();
|
||
return;
|
||
}
|
||
const vals = answer.값 ?? {};
|
||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||
const lineSet = new Set((doc.식 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean));
|
||
const shown = reach(doc, vals, showOff);
|
||
const markMap = new Map<string, string>();
|
||
for (const m of answer.표지 ?? []) {
|
||
const at = m.기호 || (m as { 자리?: string }).자리;
|
||
if (at && !markMap.has(at)) markMap.set(at, m.글);
|
||
}
|
||
const options = optionBar();
|
||
head.replaceChildren(
|
||
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
|
||
headLine(),
|
||
...(opt.admin
|
||
? [el("p", { className: "ui-text ui-text--muted fdoc__hint", text: ft("Hint") })]
|
||
: []),
|
||
);
|
||
markBox();
|
||
const lines = buildLines({
|
||
admin: opt.admin,
|
||
showOff,
|
||
onShowOff: () => {
|
||
showOff = !showOff;
|
||
draw();
|
||
},
|
||
doc,
|
||
answer,
|
||
vars,
|
||
marks: markMap,
|
||
auto,
|
||
onLine,
|
||
onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))),
|
||
onMoney,
|
||
onLit,
|
||
onPickVar,
|
||
onAddLine: () => {
|
||
auto = { kind: "line", i: (doc!.식 ?? []).length };
|
||
change((d) => (d.식 ??= []).push({ 기호: "", 식: "" }));
|
||
},
|
||
onAddMoney: (g) => {
|
||
auto = { kind: "name", i: (doc!.금액 ?? []).length };
|
||
change((d) =>
|
||
(d.금액 ??= []).push({ 이름: "", 비목: "노무비", 단가: "", ...(g ? { 묶음: g } : {}) }),
|
||
);
|
||
},
|
||
onGroupName: (old, now) =>
|
||
change((d) =>
|
||
(d.금액 ?? []).forEach((l) => {
|
||
if ((l.묶음 ?? "") !== old) return;
|
||
if (now) l.묶음 = now;
|
||
else delete l.묶음;
|
||
}),
|
||
),
|
||
onRedraw: () => redraw(),
|
||
});
|
||
auto = null;
|
||
const defs = buildDefs({
|
||
admin: opt.admin,
|
||
vars,
|
||
names: cache.names,
|
||
lines: lineSet,
|
||
shown,
|
||
unused: [...vars.keys()].filter((id) => !shown.includes(id)),
|
||
need: new Set(
|
||
shown.filter((id) => !lineSet.has(id) && (vars.get(id)?.연결?.갈래 ?? "식") === "식"),
|
||
),
|
||
open: openDef,
|
||
onOpen: (id) => {
|
||
openDef = id;
|
||
draw();
|
||
},
|
||
setLink: (id, link: Link) =>
|
||
change((d) => {
|
||
const clean = Object.fromEntries(Object.entries(link).filter(([, x]) => x !== undefined));
|
||
ensure(d, id).연결 = clean as Link;
|
||
}),
|
||
setVar: (id, patch) => change((d) => Object.assign(ensure(d, id), patch)),
|
||
rename: (id, sign, low, up) => {
|
||
const copy = clone(doc!);
|
||
try {
|
||
rename(copy, id, sign, low, up);
|
||
} catch (e) {
|
||
if (e instanceof DocError) return ft("Bad_Sign");
|
||
throw e;
|
||
}
|
||
openDef = varId({ 기호: sign, 아래: low, 위: up });
|
||
change((d) => Object.assign(d, copy));
|
||
return null;
|
||
},
|
||
dropVar: (id) => {
|
||
openDef = null;
|
||
change((d) => {
|
||
d.변수 = (d.변수 ?? []).filter((v) => varId(v) !== id);
|
||
d.식 = (d.식 ?? []).filter((l) => l.기호 !== id);
|
||
});
|
||
},
|
||
makeLine: (id) => {
|
||
if (lineSet.has(id)) return;
|
||
auto = { kind: "line", i: (doc!.식 ?? []).length };
|
||
change((d) => (d.식 ??= []).push({ 기호: id, 식: "" }));
|
||
},
|
||
ask: (kind, words, group) =>
|
||
call<{ 후보: Pick[]; 더있음: boolean }>("/links", { 갈래: kind, 검색: words, 분류: group }),
|
||
table: tableOf,
|
||
subInputs: (k) =>
|
||
(subDoc(k)?.변수 ?? [])
|
||
.filter((v) => ["선택", "입력", "숫자"].includes(v.연결?.갈래 ?? "") && v.열쇠)
|
||
.map((v) => v.열쇠!),
|
||
sub: subBlock,
|
||
});
|
||
grid.replaceChildren(
|
||
el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }),
|
||
el("div", { className: "fdoc__colhead", text: ft("Val_Head") }),
|
||
...lines,
|
||
el("div"),
|
||
defs,
|
||
);
|
||
};
|
||
|
||
/** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */
|
||
const redraw = (): void => {
|
||
if (!doc) return;
|
||
const before = JSON.stringify(doc);
|
||
doc.식 = (doc.식 ?? []).filter((l) => String(l.기호 ?? "") || String(l.식 ?? ""));
|
||
doc.금액 = (doc.금액 ?? []).filter((l) => l.이름 || l.단가 || l.수량);
|
||
if (JSON.stringify(doc) !== before) {
|
||
keep();
|
||
recalc();
|
||
}
|
||
draw();
|
||
};
|
||
|
||
const take = (got: Loaded): void => {
|
||
cache.merge(got.연결);
|
||
saved = JSON.stringify(got.문서);
|
||
doc = clone(got.문서);
|
||
};
|
||
|
||
const save = async (): Promise<void> => {
|
||
if (!doc) return;
|
||
showLoadingOverlay();
|
||
try {
|
||
const got = await call<Loaded>("/save", { 문서: doc });
|
||
take(got);
|
||
fresh = false;
|
||
keep();
|
||
recalc();
|
||
draw();
|
||
showToast(ft("Saved"), "success");
|
||
} catch (e) {
|
||
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
|
||
} finally {
|
||
hideLoadingOverlay();
|
||
}
|
||
};
|
||
|
||
const discard = async (): Promise<void> => {
|
||
if (!doc || !dirty()) return;
|
||
doc = JSON.parse(saved) as FormulaDoc;
|
||
openDef = null;
|
||
keep();
|
||
recalc();
|
||
draw();
|
||
};
|
||
|
||
/** 문서 열기 — 같은 키면 그대로(다시 그림만) */
|
||
const open = async (next: string): Promise<void> => {
|
||
if (next === key && doc) return;
|
||
key = next;
|
||
doc = answer = null;
|
||
given = {};
|
||
showOff = false;
|
||
openDef = null;
|
||
asked = "";
|
||
cache = createDocCache();
|
||
tables.clear();
|
||
draw();
|
||
if (!next) {
|
||
head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: ft("Need_Save") }));
|
||
return;
|
||
}
|
||
const mine = ++seq;
|
||
try {
|
||
const got = await call<Loaded>(`?key=${encodeURIComponent(next)}`);
|
||
if (mine !== seq || key !== next) return;
|
||
take(got);
|
||
fresh = !!got.새;
|
||
const draft = drafts()[next];
|
||
if (draft) doc = clone(draft);
|
||
recalc();
|
||
draw();
|
||
} catch (e) {
|
||
if (mine === seq)
|
||
head.replaceChildren(
|
||
el("p", {
|
||
className: "ui-text ui-text--muted",
|
||
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
|
||
}),
|
||
);
|
||
}
|
||
};
|
||
|
||
const canLeave = async (): Promise<boolean> => {
|
||
if (!dirty()) return true;
|
||
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
|
||
doc = JSON.parse(saved) as FormulaDoc;
|
||
keep();
|
||
return true;
|
||
};
|
||
|
||
return {
|
||
root,
|
||
/** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */
|
||
draw: (next: string): void => void open(next),
|
||
canLeave,
|
||
/** 시험 · 확인용 — 지금 문서 · 셈 */
|
||
state: () => ({ doc, answer, dirty: dirty() }),
|
||
};
|
||
}
|