diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts index 4ef848b28..f6a7a6f79 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts @@ -16,7 +16,13 @@ import { createBadge } from "@ui/ui_template_badge"; import { createButton, createIconButton } from "@ui/ui_template_button"; import { createEqChain } from "@ui/ui_template_equation_line"; import { createEqSum } from "@ui/ui_template_equation_sum"; -import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation"; +import { + eqText, + type EqLine, + type EqSumHooks, + type EqTree, + type EqVarRow, +} from "@ui/ui_template_equation"; import { el, hideLoadingOverlay, @@ -119,6 +125,8 @@ export interface EqPage { keep: (edits: EqEdit[]) => void; values: () => Record; setValue: (name: string, value: string | null) => void; + /** 소계 표 덧단추 자리(796-2e M02 더한 기초 [연결 끊기]) */ + sum?: EqSumHooks; } export interface LabEquationOptions { @@ -458,7 +466,7 @@ export function createLabEquation(opt: LabEquationOptions) { return; } const edit = canEdit(); - const sum = createEqSum(eq, open); + const sum = createEqSum(eq, open, page?.sum); body.replaceChildren( createEqChain(eq.줄, { path: "", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts index c2f3e4bd9..74c4ddf09 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Equation.ts @@ -5,11 +5,14 @@ * 저장 = 상세 [저장](서버가 다시 얹어 검사 · 셈) · 옵션 = 상세 옵션 값(`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, showToast } from "@ui/ui_template_elements"; +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"; @@ -28,6 +31,19 @@ const TEXT = { 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; const t = (k: keyof typeof TEXT, n = ""): string => (TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", n); @@ -50,6 +66,7 @@ interface CopyReply { 후: Record | null; 바뀐자원?: unknown[]; 못얹은?: { cid: string; 까닭: string }[]; + 끊은기초?: string[]; } export interface SkEquationOptions { @@ -72,6 +89,14 @@ export function createSangeunEquation(opt: SkEquationOptions) { 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 | undefined => + doc().더한줄?.[k] as Record | undefined; + const post = (path: string, body: unknown, signal?: AbortSignal) => + call(`/layers/${layer}/sangeun/${path}${q}`, { + method: "POST", + body: JSON.stringify(body), + signal, + }); /** 고침 = 저장되는 SK 만(일위대가 줄 모달 `줄모드` = 보기만 — 줄로 못 옮김) · 처음 그릴 때 정함 */ const make = (d: SkEquationDraw) => createLabEquation({ @@ -79,6 +104,7 @@ export function createSangeunEquation(opt: SkEquationOptions) { blocked: () => false, onSaved: () => undefined, page: { + sum: blockHooks, ask: (suffix, body, signal) => call(`/layers/${layer}/sangeun/equation${suffix}${q}`, { method: "POST", @@ -96,6 +122,7 @@ export function createSangeunEquation(opt: SkEquationOptions) { }, }); let lab: ReturnType | 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 { @@ -107,9 +134,11 @@ export function createSangeunEquation(opt: SkEquationOptions) { doc()[JOB] = got.문서[JOB]; const sum = (v: Record | 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(" · ")}` : ""), + (lost.length ? ` · ${t("Lost")} ${lost.map((x) => x.cid).join(" · ")}` : "") + + (kept.length ? ` · ${t("KeptCut", kept.join(" · "))}` : ""), lost.length ? "warning" : "success", ); opt.touch(); @@ -118,6 +147,90 @@ export function createSangeunEquation(opt: SkEquationOptions) { } } + /** 더한 기초 연결 끊기 · 다시 잇기(796-2e) — 서버가 고친 그 더한줄만 화면 문서에 끼움 */ + async function link(k: number, how: "unlink" | "relink"): Promise { + const ask = t(how === "unlink" ? "AskUnlink" : "AskRelink"); + if (!(await showConfirmDialog(ask, t(how === "unlink" ? "Unlink" : "Relink")))) return; + try { + const got = await post(`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>(); + function blockLab(k: number): ReturnType { + 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 | 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?.사본; diff --git a/resources/tester/test_799_1_subtotals.py b/resources/tester/test_799_1_subtotals.py index 792ac1fda..83870dfa7 100644 --- a/resources/tester/test_799_1_subtotals.py +++ b/resources/tester/test_799_1_subtotals.py @@ -99,4 +99,4 @@ def test_화면_부품이_서버_칸을_읽음(): for name in ("노무비", "재료비", "경비", "끝수", "합계", "자원", "덧줄"): assert name in ts line = Path("M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8") - assert "createEqSum(eq, open)" in line + assert "createEqSum(eq, open" in line # 796-2e 덧단추 자리 인자가 뒤에 붙음 diff --git a/ui_template/ui_template_equation.ts b/ui_template/ui_template_equation.ts index d670473c8..e06283f2e 100644 --- a/ui_template/ui_template_equation.ts +++ b/ui_template/ui_template_equation.ts @@ -65,6 +65,16 @@ export interface EqSumRow { 밖금액?: unknown; /** 더한 기초의 수식(접힘 블록) */ 나무?: EqTree | null; + /** 더한 기초 차례(M02 SK 더한줄 · 연결 끊기 796-2e) */ + 블록?: number; + /** 연결을 끊은 기초(이 SK 안 자기 사본) */ + 끊음?: boolean; +} + +/** 소계 표 덧단추 자리(796-2e) — 페이지가 줄 머리 단추 · 접힘 블록 속을 바꿔 끼움 */ +export interface EqSumHooks { + head?: (row: EqSumRow, at: number) => HTMLElement[]; + body?: (row: EqSumRow, at: number) => HTMLElement | null; } /** 로직 하나의 수식(줄 · 변수표 · 합계 · 소계) */ diff --git a/ui_template/ui_template_equation_sum.ts b/ui_template/ui_template_equation_sum.ts index 4441cde14..cadeab3e4 100644 --- a/ui_template/ui_template_equation_sum.ts +++ b/ui_template/ui_template_equation_sum.ts @@ -4,12 +4,13 @@ * 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합) * 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림 * M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0 - * 쓰는 법 = `createEqSum(tree, open?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 + * 쓰는 법 = `createEqSum(tree, open?, hooks?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 · + * `hooks` = 페이지가 더한 기초 줄 머리 단추(`head`) · 접힘 속(`body`)을 바꿔 끼움(796-2e 연결 끊기) * ========================================================================== */ import { createBadge, type BadgeTone } from "./ui_template_badge"; import { createButton } from "./ui_template_button"; -import { eqText, type EqSumRow, type EqTree } from "./ui_template_equation"; +import { eqText, type EqSumHooks, type EqSumRow, type EqTree } from "./ui_template_equation"; import { createEqChain } from "./ui_template_equation_line"; const COLS = ["노무비", "재료비", "경비", "계"] as const; @@ -40,7 +41,7 @@ function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unkn } /** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */ -function itemRow(r: EqSumRow, open: Set, at: number): HTMLElement { +function itemRow(r: EqSumRow, open: Set, at: number, hooks?: EqSumHooks): HTMLElement { const item = node("div", "ui-eq__sum-item"); const line = node("div", "ui-eq__sum-row"); line.dataset.eqSumKind = r.갈래; @@ -64,12 +65,13 @@ function itemRow(r: EqSumRow, open: Set, at: number): HTMLElement { open.add(id); if (!body.childElementCount) body.append( - createEqChain(tree.줄, { - path: id, - open, - resultUnit: tree.결과단위, - numbered: false, - }), + hooks?.body?.(r, at) ?? + createEqChain(tree.줄, { + path: id, + open, + resultUnit: tree.결과단위, + numbered: false, + }), ); } else open.delete(id); item.dataset.open = on ? "1" : ""; @@ -82,6 +84,7 @@ function itemRow(r: EqSumRow, open: Set, at: number): HTMLElement { const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : ""; name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 })); } + name.append(...(hooks?.head?.(r, at) ?? [])); if (r.합밖) { name.append(createBadge("합 밖", "neutral", { size: 18 })); line.dataset.eqSumOut = "1"; @@ -105,6 +108,7 @@ function itemRow(r: EqSumRow, open: Set, at: number): HTMLElement { export function createEqSum( tree: Pick, open: Set = new Set(), + hooks?: EqSumHooks, ): HTMLElement | null { const sub = tree.소계; if (!sub?.줄.length) return null; @@ -115,7 +119,7 @@ export function createEqSum( for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k)); box.append(head); sub.줄.forEach((r, at) => { - if (r.상태) box.append(itemRow(r, open, at)); + if (r.상태) box.append(itemRow(r, open, at, hooks)); else box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k])); });