Files
Aislo/A00_Common/formula_doc_screen.ts
T

678 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.
/* =============================================================================
* 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 나눔 …) + 금액 「—」
* M02 사본(`opt.copy` · 802-4b-2) — 길 앞머리 = SK 마다(`/m02/layers/{층}/sangeun/{키}/doc`) · 옵션 = 문서 값(SK 고른 값 · 저장) ·
* [단가 갱신] · [재생성] · 「M01 새 판 있음」(`formula_doc_copy.ts`) · 단가산출 연결 끊기 · 다시 잇기(`formula_doc_sub.ts`) ·
* 저장 몸 {문서, 끊음, 판} · `admin` = 층 쓰기 권한(없으면 읽기만 · 옵션도 잠금)
* ========================================================================== */
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 } from "@ui/ui_template_input";
import {
DocError,
rename,
varId,
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 {
askJob,
copyBadges,
copyTools,
type CopyJob,
type CopyMark,
type CopyOptions,
type JobReply,
} from "./formula_doc_copy";
import { buildDefs, type Pick } from "./formula_doc_defs";
import { editLine, editLit, editMoney, ensureVar, splitLine, tidyText } from "./formula_doc_edit";
import { buildLines, type EditAt } from "./formula_doc_lines";
import { buildOptions } from "./formula_doc_options";
import { reach } from "./formula_doc_render";
import { subBlock } from "./formula_doc_sub";
import { ft } from "./formula_doc_text";
export { splitLine };
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;
/** M02 사본(SK) — 길 앞머리 · 판 · 끊음 · 할일 */
copy?: CopyOptions;
}
type Docs = Record<string, FormulaDoc>;
interface Loaded {
문서: FormulaDoc;
셈: DocAnswer;
연결: DocRecord;
새?: boolean;
부름?: Docs;
끊음?: Docs;
사본?: CopyMark;
판?: string;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
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 cp = opt.copy;
let cut: Docs = {}; // 사본 — 끊은 단가산출 문서(이 사본 안 · 고칠 수 있음)
let savedCut = "{}";
let calls: Docs = {}; // 사본 — 부름 문서(다시 잇기 = 이 문서로)
let mark: CopyMark | undefined;
let version = "";
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : 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) {
const why = data.detail;
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
status: r.status,
});
}
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 || JSON.stringify(cut) !== savedCut);
const cutKey = (): string => `${key}#끊음`;
const keep = (): void => {
const all = drafts();
if (dirty()) all[key] = doc!;
else delete all[key];
if (cp && dirty()) all[cutKey()] = cut as unknown as FormulaDoc;
else delete all[cutKey()];
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; 부름?: Docs }>(
"/preview",
cp ? { 문서: doc, 끊음: cut } : { 문서: doc, 입력: usable() },
ctl.signal,
)
.then((got) => {
if (mine !== seq) return;
cache.merge(got.연결);
calls = { ...calls, ...(got.부름 ?? {}) };
cache.merge({ 문서: cut });
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();
};
// ── 고침(`formula_doc_edit.ts`) ──
const onMoney = (i: number, patch: Partial<MoneyLine> | null): void =>
change((d) => {
editMoney(d, i, patch);
if (patch && "이름" in patch && !d.금액?.[i]?.단가) auto = { kind: "price", i };
});
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) => (ensureVar(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 changeCut = (k: string, fn: (d: FormulaDoc) => void): void => {
if (!cut[k]) return;
fn(cut[k]);
cache.merge({ 문서: { [k]: cut[k] } });
keep();
recalc();
draw();
};
const onCut = (k: string): void => {
const from = subDoc(k);
if (!from) return;
cut = { ...cut, [k]: clone(from) };
changeCut(k, () => undefined);
};
const onJoin = async (k: string): Promise<void> => {
if (!(await showConfirmDialog(ft("Ask_Relink"), ft("Relink")))) return;
const { [k]: _gone, ...rest } = cut;
cut = rest;
if (calls[k]) cache.merge({ 문서: { [k]: calls[k] } });
else asked = ""; // 부름 문서를 아직 모름 — 뒤에서 서버에 물음
keep();
recalc();
draw();
};
const sub = (id: string): HTMLElement | null =>
subBlock(id, {
doc,
answer,
cache,
...(cp
? {
copy: {
edit: opt.admin,
has: (k: string) => k in calls || k in cut,
cut: (k: string) => cut[k],
onCut,
onJoin: (k: string) => void onJoin(k),
change: changeCut,
},
}
: {}),
});
// ── 그림 ──
/** 옵션 가로 — M01 = 화면 값만(`given`) · 사본 = 문서 값(SK 고른 값 · 저장) */
const optionBar = (): HTMLElement[] =>
buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
const at = doc?.변수?.indexOf(v) ?? -1; // 옵션 열쇠 ≠ 기호 — 그 정의 자리에 값
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
given[k] = x;
recalc();
draw();
});
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"));
if (cp) bits.push(...copyBadges(mark, opt.admin));
const tools = opt.admin
? [
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
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: (i, t) => change((d) => editLine(d, i, t)),
onExpr: (i, t) => change((d) => (d.식![i].식 = tidyText(t))),
onMoney,
onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)),
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));
ensureVar(d, id).연결 = clean as Link;
}),
setVar: (id, patch) => change((d) => Object.assign(ensureVar(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,
});
grid.replaceChildren(
el("div", { className: "fdoc__colhead", text: ft("Sym_Head") }),
el("div", { className: "fdoc__colhead", text: ft("Val_Head") }),
...lines,
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.문서);
if (!cp) return;
calls = got.부름 ?? {};
cut = clone(got.끊음 ?? {});
savedCut = JSON.stringify(cut);
mark = got.사본;
version = got.판 ?? "";
};
const body = (): Record<string, unknown> =>
cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc };
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
const runJob = async (job: CopyJob): Promise<void> => {
if (!doc || !cp) return;
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
showLoadingOverlay();
let got: JobReply;
try {
got = await call<JobReply>("/preview", { 할일: job });
} catch (e) {
hideLoadingOverlay();
return void showToast(
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
"error",
);
}
hideLoadingOverlay();
const names = new Map(Object.entries({ ...calls, ...cut }).map(([k, d]) => [k, d.이름 ?? ""]));
if (await askJob(job, got, names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
};
const save = async (send = body(), done = ft("Saved")): Promise<void> => {
if (!doc) return;
showLoadingOverlay();
try {
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
// 사본 판 바뀜(페이지 [저장] 등) — 수식 사본이 그대로면 새 판으로 한 번 더
if (!cp || e.status !== 409) throw e;
const now = await call<Loaded>("");
if (
JSON.stringify([now.문서, now.끊음 ?? {}]) !==
JSON.stringify([JSON.parse(saved), JSON.parse(savedCut)])
)
throw e;
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
});
take(got);
fresh = false;
keep();
recalc();
draw();
if (cp && got.판) cp.onSaved?.(got.판);
showToast(done, "success");
} catch (e) {
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
} finally {
hideLoadingOverlay();
}
};
const back = (): void => {
doc = JSON.parse(saved) as FormulaDoc;
cut = JSON.parse(savedCut) as Docs;
cache.merge({ 문서: { ...calls, ...cut } });
};
const discard = async (): Promise<void> => {
if (!doc || !dirty()) return;
back();
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();
[cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""];
draw();
if (!next) {
const why = cp ? ft("No_Key") : ft("Need_Save");
head.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: why }));
return;
}
const mine = ++seq;
try {
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
if (mine !== seq || key !== next) return;
take(got);
fresh = !!got.새;
const draft = drafts()[next];
if (draft) doc = clone(draft);
const kept = cp ? (drafts()[`${next}#끊음`] as unknown as Docs | undefined) : undefined;
if (kept) cache.merge({ 문서: (cut = clone(kept)) });
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;
back();
keep();
return true;
};
return {
root,
/** 로직 키로 그림(빈 키 = 비움) — 다른 인자는 옛 수식 탭 꼴 호환 */
draw: (next: string): void => void open(next),
canLeave,
/** 시험 · 확인용 — 지금 문서 · 셈 */
state: () => ({ doc, answer, dirty: dirty() }),
};
}