- 까닭 — 수식 화면 고침 조건이 「관리자 + 로직 키」 · 신규 로직은 저장 전 키가 없어 영역 누름 처리가 아예 안 붙음(GC000211 같은 저장된 로직은 누름 됨) - M01 저장 전 새 로직도 고침 · 고친 문서는 캐시 「""」 열쇠 · 미리 셈은 임시 키 · [수식 저장] · [고침 버림]은 키가 생긴 뒤 - 페이지 [저장]으로 새 키가 생기면 고친 문서를 그 키(빈 문서)로 이어 감(이름 · 단위 = 새 로직) · 신규 로직을 다시 열면 옛 고침 걷음 - M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만 그대로 - 편집 중 줄 = 그 줄 너비 전부(빈 줄 입력 칸이 2em 으로 줄던 것 · 「a =」 「3」 꺾임 고침) - 서버 · 계산기 · 문서 꼴 그대로 · 새 라이브러리 없음 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
668 lines
23 KiB
TypeScript
668 lines
23 KiB
TypeScript
/* =============================================================================
|
||
* formula_doc_screen.ts
|
||
* 단가산출 기초 수식 문서 화면(PLAN 802-3 · 803-2a · 요구 660~667 · 요구서_664 3 장) — 공용(M01 · M02 = 길 앞머리만 바꿈)
|
||
* 위 = 옵션 가로(선택 · 입력 기호 · 바꾸면 바로 다시 그림 · 문서는 안 바뀜) · [고침 버림] · [수식 저장](관리자) — 설명 글 없음
|
||
* 가운데 = 세 영역 가로 — 기호 식(하나의 수식 영역 · 줄바꿈) | 값 식(같은 높이) | 기호 정의(`formula_doc_defs.ts`)
|
||
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 — 단가산출 상세 탭 부품 · `formula_doc_foot.ts`)
|
||
* 기호 식 줄 누름 = 그 자리 편집기(`formula_doc_math.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 { 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,
|
||
blankDoc,
|
||
rename,
|
||
varId,
|
||
type FormulaDoc,
|
||
type Link,
|
||
} 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 { editLit, ensureVar, splitLine, sweep } from "./formula_doc_edit";
|
||
import { docFoot } from "./formula_doc_foot";
|
||
import { buildLines, offLines } from "./formula_doc_lines";
|
||
import { mathToolbar } from "./formula_doc_math";
|
||
import { buildOptions } from "./formula_doc_options";
|
||
import { reach } from "./formula_doc_render";
|
||
import { createRows } from "./formula_doc_rows";
|
||
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 grid = el("div", { className: "fdoc__grid" });
|
||
const side = el("div", { className: "fdoc__side" });
|
||
const foot = el("div", { className: "fdoc__footslot" });
|
||
const root = el("div", {
|
||
className: "fdoc ui-eq",
|
||
attrs: { "data-formula-doc": "" },
|
||
children: [head, el("div", { className: "fdoc__areas", children: [grid, side] }), foot],
|
||
});
|
||
let key = "";
|
||
let saved = "";
|
||
let doc: FormulaDoc | null = null;
|
||
let answer: DocAnswer | null = null;
|
||
let cache = createDocCache();
|
||
let given: Record<string, unknown> = {};
|
||
let openDef: string | null = null;
|
||
let showOff = false; // 선택으로 꺼진 금액 줄(고침 모드)
|
||
let seq = 0;
|
||
let asked = "";
|
||
let ctl: AbortController | null = null;
|
||
const tables = new Map<string, Promise<DocTable | null>>();
|
||
const rows = createRows({
|
||
doc: () => doc,
|
||
change: (fn) => change(fn),
|
||
settle: () => {
|
||
keep();
|
||
recalc();
|
||
},
|
||
draw: () => draw(),
|
||
root,
|
||
});
|
||
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;
|
||
void call<{ 셈: DocAnswer; 연결: DocRecord; 부름?: Docs }>(
|
||
"/preview",
|
||
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
|
||
ctl.signal,
|
||
)
|
||
.then((got) => {
|
||
if (mine !== seq) return;
|
||
cache.merge(got.연결);
|
||
calls = { ...calls, ...(got.부름 ?? {}) };
|
||
cache.merge({ 문서: cut });
|
||
recalc();
|
||
draw();
|
||
})
|
||
.catch(() => undefined);
|
||
};
|
||
|
||
const change = (fn: (d: FormulaDoc) => void): void => {
|
||
if (!doc) return;
|
||
fn(doc);
|
||
keep();
|
||
recalc();
|
||
draw();
|
||
};
|
||
|
||
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 toolRow = (edit: boolean): HTMLElement => {
|
||
const bits: HTMLElement[] = [];
|
||
if (edit && 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 (dirty()) bits.push(createBadge(ft("Dirty"), "warning"));
|
||
if (cp) bits.push(...copyBadges(mark, opt.admin));
|
||
const off = doc && answer ? offLines(doc, answer).size : 0;
|
||
const busy = dirty() || !!rows.editing();
|
||
// 편집 중 누름 — 편집기 커서를 안 뺏음(저장 · 버림이 편집 중 글을 먼저 받음)
|
||
const hold = (b: HTMLButtonElement): HTMLButtonElement => {
|
||
b.addEventListener("mousedown", (ev) => ev.preventDefault());
|
||
return b;
|
||
};
|
||
const tools = edit
|
||
? [
|
||
...(off
|
||
? [
|
||
createButton({
|
||
label: `${showOff ? ft("Hide_Off") : ft("Show_Off")} ${off}`,
|
||
variant: "ghost",
|
||
size: 28,
|
||
onClick: () => {
|
||
showOff = !showOff;
|
||
draw();
|
||
},
|
||
}),
|
||
]
|
||
: []),
|
||
...(cp ? copyTools(true, (job) => void runJob(job)) : []),
|
||
hold(
|
||
createButton({
|
||
label: ft("Discard"),
|
||
variant: "ghost",
|
||
size: 28,
|
||
disabled: !busy,
|
||
onClick: () => void discard(),
|
||
}),
|
||
),
|
||
hold(
|
||
createButton({
|
||
label: ft("Save"),
|
||
variant: "filled",
|
||
size: 28,
|
||
disabled: !busy,
|
||
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 draw = (): void => {
|
||
rows.beforeDraw();
|
||
if (!doc || !answer) {
|
||
[head, grid, side, foot].forEach((x) => x.replaceChildren());
|
||
return;
|
||
}
|
||
// M01 저장 전 새 로직(키 없음)도 고침 — [수식 저장] · [고침 버림]은 키가 생긴 뒤(페이지 [저장] → 새 키로 이어 감)
|
||
// M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만
|
||
const edit = opt.admin && (!!key || !cp);
|
||
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 })] : []),
|
||
toolRow(edit && !!key),
|
||
);
|
||
const { cells, order } = buildLines({
|
||
admin: edit,
|
||
showOff,
|
||
doc,
|
||
answer,
|
||
vars,
|
||
marks: markMap,
|
||
editing: rows.editing(),
|
||
mount: rows.mount,
|
||
onEditAt: rows.editAt,
|
||
onLit: (w, i, at, v) => change((d) => editLit(d, w, i, at, v)),
|
||
onPickVar,
|
||
onGroupName: (old, now) =>
|
||
change((d) =>
|
||
(d.금액 ?? []).forEach((l) => {
|
||
if ((l.묶음 ?? "") !== old) return;
|
||
if (now) l.묶음 = now;
|
||
else delete l.묶음;
|
||
}),
|
||
),
|
||
onRedraw: () => redraw(),
|
||
});
|
||
rows.setOrder(order);
|
||
const defs = buildDefs({
|
||
admin: edit,
|
||
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;
|
||
const i = (doc!.식 ?? []).length;
|
||
change((d) => (d.식 ??= []).push({ 기호: id, 식: "" }));
|
||
rows.editAt({ kind: "line", i });
|
||
},
|
||
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,
|
||
});
|
||
const symHead = el("div", {
|
||
className: "fdoc__colhead fdoc__symhead",
|
||
children: [
|
||
el("span", { text: ft("Sym_Head") }),
|
||
...(rows.editing() ? [mathToolbar(rows.editingMoney() ? rows.splitGroup : undefined)] : []),
|
||
],
|
||
});
|
||
grid.replaceChildren(
|
||
symHead,
|
||
el("div", { className: "fdoc__colhead", text: ft("Val_Head") }),
|
||
...cells,
|
||
);
|
||
side.replaceChildren(defs);
|
||
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
|
||
};
|
||
|
||
/** 고침 취소 — 빈 새 줄은 걷고 다시 그림 */
|
||
const redraw = (): void => {
|
||
if (!doc) return;
|
||
const before = JSON.stringify(doc);
|
||
sweep(doc);
|
||
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?: Record<string, unknown>, done = ft("Saved")): Promise<void> => {
|
||
if (!doc) return;
|
||
rows.flush();
|
||
if (!send && !dirty()) return void draw();
|
||
send ??= body();
|
||
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);
|
||
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> => {
|
||
rows.reset();
|
||
if (!doc || !dirty()) return void draw();
|
||
back();
|
||
openDef = null;
|
||
keep();
|
||
recalc();
|
||
draw();
|
||
};
|
||
|
||
/** 문서 열기 — 같은 키면 그대로(다시 그림만) */
|
||
const open = async (next: string): Promise<void> => {
|
||
if (next === key && doc) return;
|
||
rows.reset();
|
||
const was = key;
|
||
key = next;
|
||
doc = answer = null;
|
||
given = {};
|
||
showOff = false;
|
||
openDef = null;
|
||
asked = "";
|
||
cache = createDocCache();
|
||
tables.clear();
|
||
[cut, calls, mark, savedCut, version] = [{}, {}, undefined, "{}", ""];
|
||
draw();
|
||
if (!next) {
|
||
// 저장 전 새 로직 — 세 영역 빈 화면(누르면 입력 · 고친 것은 캐시 "" 열쇠)
|
||
doc = blankDoc();
|
||
saved = JSON.stringify(doc);
|
||
keep(); // 옛 새 로직 고침(「""」 열쇠) 걷음
|
||
recalc();
|
||
return draw();
|
||
}
|
||
const mine = ++seq;
|
||
try {
|
||
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
|
||
if (mine !== seq || key !== next) return;
|
||
take(got);
|
||
const all = drafts();
|
||
// 저장 전 새 로직에서 고친 문서 — 페이지 [저장]으로 생긴 새 키(빈 문서)로 이어 감
|
||
if (!was && got.새 && all[""] && !all[next]) {
|
||
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
|
||
delete all[""];
|
||
sessionStorage.setItem(opt.cache, JSON.stringify(all));
|
||
}
|
||
const draft = all[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> => {
|
||
rows.flush();
|
||
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() }),
|
||
};
|
||
}
|