678 lines
23 KiB
TypeScript
678 lines
23 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 나눔 …) + 금액 「—」
|
||
* 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() }),
|
||
};
|
||
}
|