Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts
T
eomsangdonandClaude Sonnet 5.5 c47e6dc832 feat(796-2e-2): 더한 기초 블록 [연결 끊기] · [다시 잇기] 화면 — 수식 탭 소계 블록 머리 상태 · 단추 · 끊은 블록 속 수식 고침
- 소계 표 부품에 덧단추 자리(EqSumHooks head · body) · EqPage.sum 으로 페이지가 끼움
- M02 수식 탭: 블록 머리 「연결」 · 「끊음」 + [연결 끊기] · [다시 잇기](확인창) · 끊은 블록 속 = 같은 수식 부품 · [단가 갱신] · [재생성] 보고에 끊은 기초 한 줄
- test_799_1_subtotals 낡은 문자열 비교(createEqSum(eq, open) 통째)를 인자 앞부분 비교로 고침 — 부품 호출에 덧단추 인자가 붙어서

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:47:37 +09:00

287 lines
12 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_Sangeun_Equation.ts
* M02 단가산출(SK) 「수식」 보기(PLAN 796-2b · 요구 655 · 656 · 657 · 요구서_655 0 장) — M01 수식 탭 부품(`createLabEquation`)을 페이지 갈래로.
* 길 = `POST /layers/{층}/sangeun/equation`(+ `/preview`) · 몸 = 화면 문서(캐시) · 고침 목록 = 문서 `수식고침`(사본 위 기록) ·
* 저장 = 상세 [저장](서버가 다시 얹어 검사 · 셈) · 옵션 = 상세 옵션 값(`onInput`)
* 위 줄 = 로직 사본 — 「M01 새 판 있음」 배지 · [단가 갱신] · [재생성](`…/sangeun/copy/price|regen` → 문서 `사본할일` · [저장] 때 서버 적용)
* 가져온 식 = 「엑셀 원래」 금액 글(셈 답 `원래` = 엑셀 금액)
* 더한 기초 블록(796-2e · 요구 659-3) — 머리 「연결」 + [연결 끊기](`…/sangeun/block/unlink` → 그 더한줄 `끊음` = 이 SK 안 자기 사본) ·
* 「끊음」 + [다시 잇기](`…/block/relink` → 고친 것 버림 · 원본 따라감) · 끊은 블록 속 = 같은 수식 부품(길 몸 `블록`) · 고침 = 그 더한줄 `수식고침`
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import type { EqSumHooks, EqSumRow } from "@ui/ui_template_equation";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { createToolbar } from "@ui/ui_template_toolbar";
import { formatMoney } from "../common_util/common_util_money";
import type { LogicRow } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { createLabEquation } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Equation";
import type { EqEdit } from "../M01_MasterData/logic/M01_Logic_Equation_Model";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
const TEXT = {
Copy: ["로직 사본", "Logic copy"],
NewM01: ["M01 새 판 있음", "New M01 version"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Job: ["{n} · [저장] 때 적용", "{n} · applied on save"],
Excel: ["엑셀 원래", "Excel original"],
Lost: ["못 얹은 고침", "Not re-applied"],
Linked: ["연결", "Linked"],
Cut: ["끊음", "Unlinked"],
Unlink: ["연결 끊기", "Unlink"],
Relink: ["다시 잇기", "Relink"],
AskUnlink: [
"이 기초를 이 단가산출 안 사본으로 굳힘 — 끊은 뒤 원본(M01) 바뀜은 따라오지 않음 (금액은 그대로)",
"Freeze this basis as a copy inside this item — later source changes will not follow (amount unchanged)",
],
AskRelink: [
"이 기초에서 고친 것을 버리고 원본(M01)을 따라감으로 돌아감",
"Discard edits to this basis and follow the source again",
],
KeptCut: ["끊은 기초 {n} 는 줄 그대로 · 자원 단가만", "Unlinked {n}: prices only"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT, n = ""): string =>
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", n);
const KEY = "수식고침";
const JOB = "사본할일";
interface CopyMark {
M01새판?: boolean;
지운키?: string[];
}
type Answer = SkAnswer & {
사본?: CopyMark;
원래?: Record<string, number> | null;
가져온?: unknown;
};
interface CopyReply {
문서: SkDoc;
전: Record<string, number> | null;
후: Record<string, number> | null;
바뀐자원?: unknown[];
못얹은?: { cid: string; 까닭: string }[];
끊은기초?: string[];
}
export interface SkEquationOptions {
scope: { layer: Layer; owner: string | null };
edit: boolean;
touch: () => void;
onInput: (name: string, value: string) => void;
}
export interface SkEquationDraw {
doc: SkDoc;
ans: SkAnswer | null;
base: LogicRow | null;
values: Record<string, string>;
}
export function createSangeunEquation(opt: SkEquationOptions) {
const { layer, owner } = opt.scope;
const q = owner && layer !== "system" ? `?owner=${encodeURIComponent(owner)}` : "";
const bar = el("div", { attrs: { "data-sk-copy": "" } });
let last: SkEquationDraw | null = null;
const doc = (): SkDoc => last?.doc ?? ({} as SkDoc);
const added = (k: number): Record<string, unknown> | undefined =>
doc().더한줄?.[k] as Record<string, unknown> | undefined;
const post = <T>(path: string, body: unknown, signal?: AbortSignal) =>
call<T>(`/layers/${layer}/sangeun/${path}${q}`, {
method: "POST",
body: JSON.stringify(body),
signal,
});
/** 고침 = 저장되는 SK 만(일위대가 줄 모달 `줄모드` = 보기만 — 줄로 못 옮김) · 처음 그릴 때 정함 */
const make = (d: SkEquationDraw) =>
createLabEquation({
admin: opt.edit && d.doc.줄모드 !== true,
blocked: () => false,
onSaved: () => undefined,
page: {
sum: blockHooks,
ask: (suffix, body, signal) =>
call(`/layers/${layer}/sangeun/equation${suffix}${q}`, {
method: "POST",
body: JSON.stringify({ ...body, 문서: doc() }),
signal,
}),
edits: () => (doc()[KEY] as EqEdit[] | undefined) ?? [],
keep: (edits) => {
if (edits.length) doc()[KEY] = edits;
else delete doc()[KEY];
opt.touch();
},
values: () => last?.values ?? {},
setValue: (name, value) => opt.onInput(name, value ?? ""),
},
});
let lab: ReturnType<typeof make> | null = null;
const NO_ROW = { 입력: [] } as unknown as LogicRow; // 끊은 기초 옵션 칸 = 안 그림
const root = el("div", { attrs: { "data-sk-equation": "" }, children: [bar] });
async function job(how: "price" | "regen"): Promise<void> {
try {
const got = await call<CopyReply>(`/layers/${layer}/sangeun/copy/${how}${q}`, {
method: "POST",
body: JSON.stringify({ 문서: doc(), 입력값: last?.values ?? {} }),
});
doc()[JOB] = got.문서[JOB];
const sum = (v: Record<string, number> | null): string => formatMoney(v?.계 ?? 0);
const lost = got.못얹은 ?? [];
const kept = got.끊은기초 ?? [];
showToast(
`${t(how === "price" ? "Price" : "Regen")} ${sum(got.전)} → ${sum(got.후)}` +
(lost.length ? ` · ${t("Lost")} ${lost.map((x) => x.cid).join(" · ")}` : "") +
(kept.length ? ` · ${t("KeptCut", kept.join(" · "))}` : ""),
lost.length ? "warning" : "success",
);
opt.touch();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
/** 더한 기초 연결 끊기 · 다시 잇기(796-2e) — 서버가 고친 그 더한줄만 화면 문서에 끼움 */
async function link(k: number, how: "unlink" | "relink"): Promise<void> {
const ask = t(how === "unlink" ? "AskUnlink" : "AskRelink");
if (!(await showConfirmDialog(ask, t(how === "unlink" ? "Unlink" : "Relink")))) return;
try {
const got = await post<CopyReply>(`block/${how}`, { 문서: doc(), 차례: k });
const one = got.문서.더한줄?.[k];
const list = doc().더한줄;
if (!one || !list) return;
list[k] = one;
blocks.delete(k);
showToast(
`${t(how === "unlink" ? "Unlink" : "Relink")} ${formatMoney(got.전?.계 ?? 0)} → ${formatMoney(got.후?.계 ?? 0)}`,
"success",
);
opt.touch();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
}
}
/** 끊은 기초 속 수식 — 기초마다 한 부품(다시 그려도 고르던 줄 · 쓰던 글 그대로) */
const blocks = new Map<number, ReturnType<typeof make>>();
function blockLab(k: number): ReturnType<typeof make> {
let got = blocks.get(k);
if (got) return got;
got = createLabEquation({
admin: true,
blocked: () => false,
onSaved: () => undefined,
page: {
ask: (suffix, body, signal) =>
post(`equation${suffix}`, { ...body, 문서: doc(), 블록: k }, signal),
edits: () => (added(k)?.[KEY] as EqEdit[] | undefined) ?? [],
keep: (edits) => {
const one = added(k);
if (!one) return;
if (edits.length) one[KEY] = edits;
else delete one[KEY];
opt.touch();
},
values: () => (added(k)?.입력값 as Record<string, string> | undefined) ?? {},
setValue: () => undefined,
},
});
blocks.set(k, got);
return got;
}
const blockHooks: EqSumHooks = {
head: (r: EqSumRow): HTMLElement[] => {
const k = r.블록;
if (k === undefined || !r.나무) return [];
const out: HTMLElement[] = [
createBadge(t(r.끊음 ? "Cut" : "Linked"), r.끊음 ? "warning" : "neutral", { size: 18 }),
];
if (opt.edit && doc().줄모드 !== true)
out.push(
createButton({
label: t(r.끊음 ? "Relink" : "Unlink"),
variant: "ghost",
size: 22,
onClick: () => void link(k, r.끊음 ? "relink" : "unlink"),
}),
);
return out;
},
body: (r: EqSumRow): HTMLElement | null => {
const k = r.블록;
const one = k === undefined ? undefined : added(k);
if (k === undefined || !r.끊음 || !one || !opt.edit || doc().줄모드 === true) return null;
const version = JSON.stringify([
one.끊음 && (one.끊음 as { 시점?: string }).시점,
one.입력값,
one.수량,
doc().입력값,
last?.values,
]);
const part = blockLab(k);
part.draw(`${String(doc().키 ?? "")}#${k}`, version, NO_ROW);
return part.root;
},
};
function drawBar(d: SkEquationDraw): void {
const ans = d.ans as Answer | null;
const mark = ans?.사본;
const pending = d.doc[JOB] as string | undefined;
const items: (HTMLElement | "divider" | "spacer")[] = [];
if (ans && "가져온" in ans)
items.push(createText(`${t("Excel")} ${formatMoney(ans.원래?.계 ?? 0)}`, "muted", 14));
if (mark) {
items.push(createText(t("Copy"), "muted", 14));
if (mark.M01새판) items.push(createBadge(t("NewM01"), "warning"));
if (pending) items.push(createBadge(t("Job", pending), "info"));
if (opt.edit) {
items.push(
"spacer",
createButton({
label: t("Price"),
variant: "ghost",
size: 24,
onClick: () => void job("price"),
}),
createButton({
label: t("Regen"),
variant: "ghost",
size: 24,
onClick: () => void job("regen"),
}),
);
}
}
bar.replaceChildren(
...(items.length ? [createToolbar({ items, size: 28, label: t("Copy") })] : []),
);
}
return {
root,
draw: (d: SkEquationDraw): void => {
last = d;
drawBar(d);
if (!d.base) return;
if (!lab) root.append((lab = make(d)).root);
const { [KEY]: _edits, ...rest } = d.doc;
lab.draw(String(d.doc.키 ?? ""), JSON.stringify([rest, d.values]), d.base);
},
/** [저장] 뒤 — 할일 칸은 서버가 적용 · 뗌(상세 `getDoc` 이 복사한 뒤) */
saved: (): void =>
queueMicrotask(() => {
if (!last?.doc[JOB]) return;
delete last.doc[JOB];
drawBar(last);
}),
};
}