Files
Aislo/A00_Common/formula_doc_screen.ts
T
eomsangdonandClaude Opus 5.5 a003587aca fix(802-3): 선택으로 꺼진 금액 줄 숨김 · 값 식 한 번 · 옮김 군더더기 걷음(브레인 사진 지적)
- 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>
2026-11-07 19:43:00 +09:00

686 lines
22 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.
/* =============================================================================
* 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() }),
};
}