From 94e330a5f3169e73b217fd32e8601bc2ec546c92 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:05:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(748-0):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=91=98=EC=A7=B8=20=EA=B8=B0=EC=B4=88=20=E2=80=94?= =?UTF-8?q?=20=EC=84=B8=EB=B6=80=20=ED=92=88=20M01=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?=C2=B7=20=EB=A8=B8=EB=A6=AC=20=EC=88=98=EB=9F=89=20=EC=B9=B8=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=85=8B(=EA=B8=B0=EC=B4=88=20=EB=B9=BC=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=8D=94=ED=95=9C=20=ED=95=AD=EB=AA=A9=20=EB=B9=BC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=8D=94=ED=95=9C=20=ED=95=AD=EB=AA=A9=20=EC=88=98?= =?UTF-8?q?=EB=9F=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 둘째 기초 세부 줄 품 칸 = 서버 `원문`(0.871 그대로) · 금액 · 비목은 상자 합 몫 그대로 · 세부 고침도 원문 몫(머리 수량 · 화면배 안 나눔) - 더한 기초 머리 상자(새 `Sangeun_BaseHead`) — 기초마다 이름 · 수량(기준단위 몇 개) 칸 · [되돌림] · [기초 빼기] - 새 `logic/M02_Sangeun_Undo` — 뺀더한줄 기억 · 되살림(열쇠 당김 · 밂) · 처음수량 되돌림 · 넣을 때 처음수량 적음 - 되살림 띠에 뺀 더한줄도 · 일위대가 줄 패치 뺀더한줄 이음(RowSk · LinePatch) - 낡은 시험 고침: test_727 세부 고침 ÷수량 기대 → 원문 그대로 · test_717_2b 모델 인라인에 Undo 추가 - 새 시험 test_748_0_sk_undo(거울 helper_748_0) --- .../M01_MasterData_UI_LogicLab_Ho.ts | 2 +- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 2 + .../M02_MasterTemplete_Labor_RowSk.ts | 8 +- .../M02_MasterTemplete_Sangeun.css | 14 +++ .../M02_MasterTemplete_Sangeun_BaseHead.ts | 92 ++++++++++++++++ .../M02_MasterTemplete_Sangeun_Detail.ts | 30 +++++- .../logic/M02_Sangeun_Cover_Model.ts | 45 +++++--- M02_MasterTemplete/logic/M02_Sangeun_Undo.ts | 85 +++++++++++++++ resources/tester/helper_748_0_sk_undo.cjs | 102 ++++++++++++++++++ .../tester/test_717_2b_sangeun_detail.py | 3 +- resources/tester/test_727_sk_base_add.py | 5 +- resources/tester/test_748_0_sk_undo.py | 40 +++++++ ui_template/ui_template_locale_m2.ts | 4 + 13 files changed, 412 insertions(+), 20 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_BaseHead.ts create mode 100644 M02_MasterTemplete/logic/M02_Sangeun_Undo.ts create mode 100644 resources/tester/helper_748_0_sk_undo.cjs create mode 100644 resources/tester/test_748_0_sk_undo.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 4229f4f45..988efffe4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -258,7 +258,7 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi const line = e.line; const edit = ctx.edit; const perUnit = (k: string): number | null => - line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null; + line && line.수량 ? ((line.원문?.비목 ?? line.비목)?.[k] ?? 0) / line.수량 : null; // 세부 줄 = 원문 몫(744-1) openMaterialModal({ unitFrag: e.unitFrag, unitRef: line?.품목?.키 ?? e.unitRef, diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index cfd0e310d..edaa85033 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -103,6 +103,8 @@ export interface CalcLine { 규격?: string; /** 못 셈 줄(725 · M02 단가산출) — 금액 없음 까닭 */ 까닭?: string; + /** (M02 744-1) 둘째 기초 세부 줄 — `수량`은 M01 원문 · 이 칸은 1 기준단위 몫 비목(금액 · 비목은 머리 수량 곱한 상자 몫) */ + 원문?: { 비목?: Record }; } /** % 덧줄 표기(149-4) — 율 있으면 수량 = 율 · 단위 % · 이름 뒤 규격 괄호 · 금액은 그대로(M01 · M02 한 벌) */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts index ed0081ecb..8cc11acac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts @@ -47,7 +47,12 @@ export function mountRowSk( ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임) } if (!alive) return; - const doc = { ...lineDoc(o.row, ids, before), 이름: o.title, 단위: o.unit || "식" }; + const doc = { + ...lineDoc(o.row, ids, before), + ...(before?.뺀더한줄 ? { 뺀더한줄: before.뺀더한줄 } : {}), // 되살림 기억(744-1 · 셈엔 안 듦) + 이름: o.title, + 단위: o.unit || "식", + }; const box = el("div", { attrs: { "data-row-sk": "" } }); host.replaceChildren(box); handle = mountSangeunDetail(box, doc, { @@ -66,6 +71,7 @@ export function mountRowSk( 덮개: d.덮개 as LinePatch["덮개"], 뺀줄: d.뺀줄, 더한줄: d.더한줄, + 뺀더한줄: d.뺀더한줄, }); const values = o.row.참조 ? values0 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css index 88d5bf2f3..601764046 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css @@ -30,6 +30,7 @@ } .m02-sk__tags, +.m02-sk__basehead, .m02-sk__removed, .m02-sk__removed-item { display: flex; @@ -42,6 +43,19 @@ gap: 8px; } +/* 748-0 둘째 기초 머리 줄 — 이름 · 수량 칸 · 단추 한 줄 */ +.m02-sk__basehead { + gap: var(--spacing-8); + align-items: flex-end; +} + +.m02-sk__basename { + flex: 0 1 16em; + min-width: 0; + align-self: center; + font-weight: var(--font-weight-bold); +} + /* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */ .m02-sk__cover td.is-edited { background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_BaseHead.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_BaseHead.ts new file mode 100644 index 000000000..999d6eea0 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_BaseHead.ts @@ -0,0 +1,92 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_BaseHead.ts + * 단가산출(SK) 둘째 기초 머리 줄(PLAN 748-0) — 기초마다 한 줄: 이름 · 「수량(기준단위 몇 개)」 칸 하나 · + * [되돌림](넣을 때 수량으로) · [기초 빼기]. 세부 품은 M01 원문 그대로 보임(머리 수량은 이 칸 하나로). + * 값은 문서 `더한줄[k].수량`(화면배 t 로 나눈 화면 값) — 금액은 서버가 셈 · 여기는 칸 · 단추만. + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { createCard } from "@ui/ui_template_card"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { t as L } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import type { SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model"; + +export interface BaseHeadOptions { + doc: SkDoc; + ans: SkAnswer | null; + edit: boolean; + /** 칸에서 고른 새 수량(화면 값) */ + onQty: (k: number, qty: number) => void; + onReset: (k: number) => void; + onDrop: (k: number, name: string) => void; +} + +const shown = (v: number): string => String(Number(v.toPrecision(8))); +const same = (a: number, b: number): boolean => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a)); + +/** 둘째 기초(더한줄 로직)마다 머리 줄 한 상자 — 없으면 빈 목록 */ +export function baseHeads(o: BaseHeadOptions): HTMLElement[] { + const t = o.ans?.화면배 ?? 1; + const rows = (o.doc.더한줄 ?? []).flatMap((one, k) => { + if (one.종류 !== "로직") return []; + const line = o.ans?.줄.find((x) => x.cid === `add:#${k}`); + const name = line?.이름 || String(one.로직 ?? ""); + const box = createInputField({ + label: L("M02_SangeunBaseQty"), + value: shown(one.수량 / t), + compact: true, + size: 28, + }); + box.input.inputMode = "decimal"; + box.input.disabled = !o.edit; + box.input.dataset.baseQty = String(k); + box.input.addEventListener("change", () => { + const v = Number(box.input.value.replaceAll(",", "").trim()); + if (box.input.value.trim() === "" || !Number.isFinite(v) || v < 0) + return box.setError(L("M02_SangeunBaseQtyBad")); + box.setError(undefined); + if (!same(v, one.수량 / t)) o.onQty(k, v); + }); + const first = one.처음수량; + return [ + el("div", { + className: "m02-sk__basehead", + attrs: { "data-base-head": String(k) }, + children: [ + el("span", { className: "m02-sk__basename", text: name }), + ...(line?.꼬리표 ? [createBadge(line.꼬리표, "neutral")] : []), + box.root, + ...(line?.단위 ? [createText(line.단위, "muted")] : []), + ...(o.edit && first != null && !same(first, one.수량) + ? [ + createButton({ + label: L("M02_SangeunQtyUndo"), + variant: "ghost", + size: 28, + onClick: () => o.onReset(k), + }), + ] + : []), + ...(o.edit + ? [ + createButton({ + label: L("M02_SangeunDropBase"), + variant: "danger-ghost", + size: 28, + onClick: () => o.onDrop(k, name), + }), + ] + : []), + ], + }), + ]; + }); + if (!rows.length) return []; + const card = createCard({ title: L("M02_SangeunBases"), pad: 12, body: rows }); + card.classList.add("m01lab__ho-box"); // 상자 제목 · 안 간격 = 공용 상세 상자와 같음 + card.dataset.baseHeads = ""; + return [card]; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 6262c473e..b0e55e5b9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -36,6 +36,7 @@ import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Det import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; @@ -44,6 +45,7 @@ import { dropBase, dropLine, dropText, + goneRows, hoView, removedSubs, restoreLine, @@ -57,6 +59,7 @@ import { type SkAnswer, type SkDoc, } from "./logic/M02_Sangeun_Cover_Model"; +import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo"; export interface SangeunDetailOptions { layer: Layer; @@ -108,7 +111,7 @@ export function mountSangeunDetail( if ("종류" in line && line.종류 === "로직") return void addBase(line); doc.더한줄 = [ ...(doc.더한줄 ?? []), - line as unknown as NonNullable[number], + withFirst(line as unknown as NonNullable[number]), ]; touch(); }), @@ -129,7 +132,7 @@ export function mountSangeunDetail( if (!alive) return; doc.더한줄 = [ ...(doc.더한줄 ?? []), - { ...line, 수량: qty } as unknown as NonNullable[number], + withFirst({ ...line, 수량: qty } as unknown as NonNullable[number]), ]; touch(); } @@ -306,6 +309,7 @@ export function mountSangeunDetail( const removed = [ ...(doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })), ...removedSubs(ans), + ...goneRows(doc), // 뺀 더한줄(744-1) ]; const row = base ? { ...base.logic, 입력: [...(base.logic.입력 ?? []), ...extra] } : standIn(); const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null; @@ -318,6 +322,22 @@ export function mountSangeunDetail( attrs: { "data-sk-cover": "" }, children: [ ...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())), + ...baseHeads({ + doc, + ans, + edit, + onQty: (k, qty) => { + const one = doc.더한줄?.[k]; + if (one) setQty(one, qty * (ans?.화면배 ?? 1)); + touch(); + }, + onReset: (k) => { + const one = doc.더한줄?.[k]; + if (one) resetQty(one); + touch(); + }, + onDrop: (k, name) => (dropBase(doc, k, name), touch()), + }), ...buildHo({ row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row, prices: base?.prices ?? {}, @@ -394,7 +414,11 @@ export function mountSangeunDetail( label: L("M02_SangeunDropBase"), variant: "danger-ghost", size: 28, - onClick: () => (close(), dropBase(doc, sub.k), touch()), + onClick: () => ( + close(), + dropBase(doc, sub.k, ans?.줄.find((x) => x.cid === `add:#${sub.k}`)?.이름), + touch() + ), }), ] : []; diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index de6ae19bd..cd2e502f0 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -15,6 +15,7 @@ import { bornOfCid } from "./M02_Born_Tag"; import type { SheetGroup } from "../../A00_Common/calc_sheet"; +import { forgetAdd, goneName, reviveAdd, setQty, resetQty } from "./M02_Sangeun_Undo"; export type CoverPart = "labor" | "material" | "expense"; export const PART_OF: Record = { @@ -41,6 +42,8 @@ export interface SkAdded { 덮개?: Record; 뺀줄?: string[]; 바탕판?: string; + /** 넣을 때 수량(744-1) — [되돌림] 값 */ + 처음수량?: number; [k: string]: unknown; } @@ -55,6 +58,8 @@ export interface SkDoc { 덮개?: Record; 더한줄?: SkAdded[]; 뺀줄?: string[]; + /** 뺀 더한줄 기억(744-1) — [되살림] = 그 차례에 다시 끼움 */ + 뺀더한줄?: { 차례: number; 줄: SkAdded }[]; 고름?: Record; 자재표시?: Record; 바탕판?: string; @@ -80,6 +85,8 @@ export interface SkLine { 꼬리표?: string; /** 세부 줄 — 덮기 전 1 기준단위 품 · 단가 */ 바탕값?: { qty?: number; 단가?: number } | null; + /** 둘째 기초 세부 줄(744-1) — 그 기초 1 기준단위 몫(M01 원문 · 머리 수량 안 곱함) · 화면 품 칸 */ + 원문?: { 수량: number | null; 금액: number | null; 비목?: Record }; } export interface SkMat { @@ -268,7 +275,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))), apartPick: one?.종류 === "재료", sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null, - t: pct ? 1 : t, + t: pct || at ? 1 : t, // 세부 줄 품 = M01 원문 그대로(744-1) — 화면배 안 곱함 }; }; for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l)); @@ -315,15 +322,15 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene if (r.added !== null) { const one = doc.더한줄?.[r.added]; if (!one) return; - if (c?.qty != null) one.수량 = c.qty * r.t; + if (c?.qty != null) setQty(one, c.qty * r.t); + else if (!c) resetQty(one); // [되돌림] = 넣을 때 수량(744-1) if (price == null) delete one.단가; else one.단가 = price; return; } const cover: SkCover = {}; - // 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로) - const raw = c?.qty == null ? c?.qty : c.qty * r.t; - const qty = r.sub && raw != null && r.sub.scale ? raw / r.sub.scale : raw; + // 세부 줄 품 = 화면 품 그대로(M01 원문 = 1 기준단위 몫 · 744-1) — 화면배 · 머리 수량 안 곱함 + const qty = c?.qty == null ? c?.qty : c.qty * r.t; const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로 // ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함) if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty; @@ -342,7 +349,7 @@ export function dropLine(doc: SkDoc, r: CoverRow): void { return; } if (r.added !== null) { - doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added); + forgetAdd(doc, r.added, r.이름); // 뺀더한줄 기억 — 744-1 [되살림] return; } doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid]; @@ -363,6 +370,8 @@ export function setApart(doc: SkDoc, r: CoverRow, on: boolean): void { /** 뺀 줄 되살림 — 세부 열쇠면 그 더한줄의 뺀줄 */ export function restoreLine(doc: SkDoc, cid: string): void { + const gone = /^gone:(\d+)$/.exec(cid); // 뺀 더한줄(744-1) — 그 차례에 다시 끼움 + if (gone) return reviveAdd(doc, Number(gone[1])); const at = subOf(cid); const one = at ? doc.더한줄?.[at.k] : undefined; if (one && at) { @@ -373,11 +382,15 @@ export function restoreLine(doc: SkDoc, cid: string): void { doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid); } -/** 둘째 기초 통째 빼기 — 그 더한줄 지움 */ -export function dropBase(doc: SkDoc, k: number): void { - doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== k); +/** 둘째 기초 통째 빼기 — 그 더한줄을 뺌(기억 744-1 · 되살림 띠 글 = `name`) */ +export function dropBase(doc: SkDoc, k: number, name = ""): void { + forgetAdd(doc, k, name); } +/** 뺀 더한줄 되살림 띠 항목 — 열쇠 `gone:n` */ +export const goneRows = (doc: SkDoc): { cid: string; 이름: string }[] => + (doc.뺀더한줄 ?? []).map((g, n) => ({ cid: `gone:${n}`, 이름: goneName(g.줄) })); + const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }); const num = (v: number | null): string => (v == null ? "—" : NUM.format(v)); @@ -465,6 +478,9 @@ export function hoView( const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v); return parts.length === 1 ? parts[0][0] : undefined; }; + /** 세부 줄 = 수량만 M01 원문(금액 · 비목은 상자 합 몫 그대로) */ + const shown = (l?: SkLine): SkLine | undefined => + l?.원문 ? { ...l, 수량: l.원문.수량 ?? l.수량 } : l; const ho: [string, HoItem][] = []; const extra: [string, HoExtra][] = []; for (const cid of flatIds(ans)) { @@ -479,7 +495,7 @@ export function hoView( } if (at) { const kind = KIND_OF[at.cid.split(":")[0]] ?? "로직"; - const qty = String(l?.율 ?? l?.수량 ?? ""); + const qty = String(l?.율 ?? l?.원문?.수량 ?? l?.수량 ?? ""); // 세부 품 = M01 원문(744-1) const item = { 종류: kind, 요소: "", 이름: name, 단위: l?.단위 ?? "", 수량: qty }; ho.push([cid, { ...item, 비목: single(l), ...tag }]); continue; @@ -514,8 +530,8 @@ export function hoView( if (!c) return; const l = lineBy.get(cid); const out: LineCover = {}; - const per = (at?.cid ?? cid).startsWith("pct:") ? 1 : t; // 율 = 그대로 - if (c.qty != null) out.qty = (at ? c.qty * Number(owner?.수량 ?? 1) : c.qty) / per; + const per = at || cid.startsWith("pct:") ? 1 : t; // 율 · 세부 품 = 그대로 + if (c.qty != null) out.qty = c.qty / per; else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined; for (const k of PARTS) if (c[k] != null) out[k] = c[k]; covers[i] = out; @@ -523,7 +539,7 @@ export function hoView( return { 호표: ho.map(([, x]) => x), 덧줄: extra.map(([, x]) => x), - lines: ans.줄.length ? cids.map((c) => lineBy.get(c)) : null, + lines: ans.줄.length ? cids.map((c) => shown(lineBy.get(c))) : null, cids, covers, }; @@ -575,6 +591,8 @@ export interface LinePatch { 덮개?: Record; 뺀줄?: string[]; 더한줄?: unknown[]; + /** 뺀 더한줄 기억(744-1) */ + 뺀더한줄?: SkDoc["뺀더한줄"]; } /** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */ @@ -640,6 +658,7 @@ export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | nul ...(Object.keys(covers).length ? { 덮개: covers } : {}), ...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}), ...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}), + ...(patch?.뺀더한줄?.length ? { 뺀더한줄: [...patch.뺀더한줄] } : {}), }; return Object.keys(out).length ? out : null; } diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts b/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts new file mode 100644 index 000000000..112df8804 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Sangeun_Undo.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_Sangeun_Undo.ts + * 단가산출(SK) 되돌리기 기억(PLAN 744-1 서버 · 748-0 화면) — 더한 기초 · 더한 항목 빼기 → 문서 `뺀더한줄` + * `[{차례, 줄}]`(되살림 = 그 차례에 다시 끼움) · 더한 항목 수량 → `처음수량`(넣을 때 값) 되돌림. + * 더한줄 지움 · 끼움 때 뒤 더한줄 열쇠 `add:#j[/…]`(덮개 · 고름 · 자재표시)를 한 칸씩 당김 · 밂. + * 금액은 늘 서버가 셈 — 여기는 문서 칸 옮김만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +import type { SkAdded, SkDoc } from "./M02_Sangeun_Cover_Model"; + +/* ── 순수 셈 ── */ +const KEYED = ["덮개", "고름", "자재표시"] as const; +const KEY_RE = /^add:#(\d+)(.*)$/; + +/** 문서 열쇠 맵의 `add:#j…` 를 옮김 — `drop` = 지운 j(그 열쇠는 버림) · 뒤 j 는 한 칸 당김 / `push` = 끼운 j 부터 한 칸 밂 */ +function moveKeys(doc: SkDoc, mode: { drop: number } | { push: number }): void { + for (const name of KEYED) { + const map = doc[name] as Record | undefined; + if (!map) continue; + const out: Record = {}; + for (const [key, value] of Object.entries(map)) { + const hit = KEY_RE.exec(key); + if (!hit) { + out[key] = value; + continue; + } + const j = Number(hit[1]); + if ("drop" in mode) { + if (j === mode.drop) continue; + out[j > mode.drop ? `add:#${j - 1}${hit[2]}` : key] = value; + } else out[j >= mode.push ? `add:#${j + 1}${hit[2]}` : key] = value; + } + (doc as Record)[name] = out; + } +} + +/** 더한줄 k 를 빼고 기억에 둠 — 이름은 되살림 띠 글(서버가 저장 때 다시 적음) */ +export function forgetAdd(doc: SkDoc, k: number, name: string): void { + const one = doc.더한줄?.[k]; + if (!one) return; + doc.뺀더한줄 = [ + ...(doc.뺀더한줄 ?? []), + { 차례: k, 줄: JSON.parse(JSON.stringify({ ...one, ...(name ? { 이름: name } : {}) })) }, + ]; + doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== k); + moveKeys(doc, { drop: k }); +} + +/** 기억 n 번째를 그 차례에 다시 끼움(차례가 줄 수보다 크면 맨 끝) */ +export function reviveAdd(doc: SkDoc, n: number): void { + const gone = doc.뺀더한줄?.[n]; + if (!gone) return; + const list = [...(doc.더한줄 ?? [])]; + const at = Math.min(Math.max(gone.차례, 0), list.length); + list.splice(at, 0, gone.줄); + doc.더한줄 = list; + moveKeys(doc, { push: at }); + const rest = (doc.뺀더한줄 ?? []).filter((_, i) => i !== n); + if (rest.length) doc.뺀더한줄 = rest; + else delete doc.뺀더한줄; +} + +/** 되살림 띠 글 — 줄 이름 · 없으면 로직 · 요소 · 참조 키 */ +export function goneName(one: SkAdded): string { + const ref = (one.참조 as { 키?: string } | undefined)?.키; + return String(one.이름 || one.로직 || one.요소 || ref || ""); +} + +/** 더한줄 수량 고침 — 처음 값(옛 문서엔 없음)을 먼저 적어 둠 */ +export function setQty(one: SkAdded, qty: number): void { + one.처음수량 ??= one.수량; + one.수량 = qty; +} + +/** 더한줄 수량 처음 값으로 — 처음 값 모르면 그대로 */ +export function resetQty(one: SkAdded): void { + if (one.처음수량 != null) one.수량 = one.처음수량; +} + +/** 넣을 때 수량 적어 둠 */ +export const withFirst = (line: T): T & { 처음수량: number } => ({ + ...line, + 처음수량: line.수량, +}); +/* ── 순수 셈 끝 ── */ diff --git a/resources/tester/helper_748_0_sk_undo.cjs b/resources/tester/helper_748_0_sk_undo.cjs new file mode 100644 index 000000000..da05453e1 --- /dev/null +++ b/resources/tester/helper_748_0_sk_undo.cjs @@ -0,0 +1,102 @@ +/* PLAN 748-0 헬퍼 — SK 고침 꼴(`logic/M02_Sangeun_Cover_Model.ts` · `M02_Sangeun_Undo.ts`): + * 둘째 기초 세부 품 = M01 원문 · 고침 = 원문 몫 그대로 · 빼기 기억(뺀더한줄) · 되살림 · 수량 처음값 되돌림. + * 짝 = `test_748_0_sk_undo.py`. 결과 = 표준출력 JSON. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const { load } = makeLoader({ root: path.join(__dirname, "..", "..") }); +const m = load("M02_MasterTemplete", "logic", "M02_Sangeun_Cover_Model.ts"); + +// 둘째 기초 GF 한 줄 — 원문 0.871 · 머리 수량 0.01 이면 상자 몫 0.00871 +const detail = { + cid: "add:#1/labor:LB000101#0", + 이름: "벌목부", + 단위: "인", + 수량: 0.00871, + 단가: 100000, + 금액: 871, + 비목: { 노무비: 871 }, + 원문: { 수량: 0.871, 금액: 87100, 비목: { 노무비: 87100 } }, + 바탕값: { qty: 0.871, 단가: 100000 }, +}; +const ans = { + 기준: { 계: 871 }, + 줄: [ + { + cid: "labor:LB000002#0", + 이름: "보통인부", + 단위: "인", + 수량: 1, + 단가: 100, + 금액: 100, + 비목: { 노무비: 100 }, + }, + { + cid: "add:#0", + 이름: "추가 인력", + 단위: "인", + 수량: 2, + 단가: 100, + 금액: 200, + 비목: { 노무비: 200 }, + }, + { + cid: "add:#1", + 이름: "둘째 기초", + 단위: "100㎥", + 수량: 0.01, + 단가: null, + 금액: 871, + 비목: { 노무비: 871 }, + 펼침: [detail], + }, + ], + 정체: ["labor:LB000002#0", "add:#0", "add:#1"], + 짝잃음: [], + 바탕판: "", +}; +const doc = { + 단위: "㎥", + 바탕: { 로직: "GF000099" }, + 더한줄: [ + { 종류: "인력", 요소: "LB000002", 수량: 2, 처음수량: 2 }, + { 종류: "로직", 로직: "GF000316", 수량: 0.01, 처음수량: 0.01 }, + ], + 고름: { "add:#1": "MT1" }, +}; +const out = {}; +const rows = m.rowsOf(ans, null, doc); +const sub = rows.find((r) => r.cid === detail.cid); +const view = m.hoView(ans, null, null, doc); +const at = view.cids.indexOf(detail.cid); +out.shownQty = view.호표[at].수량; // 원문 그대로 +out.lineQty = view.lines[at].수량; +out.lineAmount = view.lines[at].금액; // 상자 몫 그대로 +out.t = sub.t; +// 세부 품 고침 — 화면 0.9 = 문서 덮개 0.9(머리 수량 안 나눔) +m.setLineCover(doc, sub, { qty: 0.9 }, false); +out.cover = doc.더한줄[1].덮개; +out.coverShown = m.hoView(ans, null, null, doc).covers[at].qty; +// 더한 항목 수량 고침 → 처음값 되돌림 +const man = m.rowsOf(ans, null, doc).find((r) => r.cid === "add:#0"); +m.setLineCover(doc, man, { qty: 5 }, false); +out.manQty = doc.더한줄[0].수량; +m.setLineCover(doc, man, null, false); +out.manBack = doc.더한줄[0].수량; +// 더한 항목 빼기 → 기억 + 열쇠 당김 → 되살림 +m.dropLine(doc, man); +out.afterDrop = { adds: doc.더한줄.length, gone: doc.뺀더한줄, pick: doc.고름 }; +out.goneRows = m.goneRows(doc); +m.restoreLine(doc, "gone:0"); +out.afterBack = { + adds: doc.더한줄.map((x) => x.요소 ?? x.로직), + gone: doc.뺀더한줄 ?? null, + pick: doc.고름, +}; +// 둘째 기초 통째 빼기 → 되살림 +m.dropBase(doc, 1, "둘째 기초"); +out.baseGone = { adds: doc.더한줄.length, name: m.goneRows(doc)[0].이름, pick: doc.고름 }; +m.restoreLine(doc, "gone:0"); +out.baseBack = doc.더한줄.map((x) => x.요소 ?? x.로직); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index c63c353a7..3489bc31b 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -35,7 +35,8 @@ def _text(p: Path) -> str: def _node(body: str) -> object: born = _text(M02 / "logic" / "M02_Born_Tag.ts") # 740-2 — 모델이 쓰는 꼬리표 글(import 줄은 뺌) model = re.sub(r"^import .*$", "", _text(MODEL), flags=re.M) - src = born + "\n" + model + "\n" + body + undo = re.sub(r"^import .*$", "", _text(M02 / "logic" / "M02_Sangeun_Undo.ts"), flags=re.M) + src = born + "\n" + undo + "\n" + model + "\n" + body out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src], diff --git a/resources/tester/test_727_sk_base_add.py b/resources/tester/test_727_sk_base_add.py index ad29b9a54..897e56073 100644 --- a/resources/tester/test_727_sk_base_add.py +++ b/resources/tester/test_727_sk_base_add.py @@ -209,6 +209,7 @@ def _model(ans, doc, script): return _node( [ "M02_MasterTemplete/logic/M02_Born_Tag.ts", + "M02_MasterTemplete/logic/M02_Sangeun_Undo.ts", "M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts", ], body, @@ -242,7 +243,9 @@ console.log(JSON.stringify({ assert "add:#0" not in got["cids"] and "add:#0/labor:LB000036#0" in got["cids"] assert got["sub"]["sub"] == {"k": 0, "cid": "labor:LB000036#0", "scale": 2} assert got["sub"]["수량"] == pytest.approx(0.036) and got["sub"]["묶음"] == "인력" - assert got["covered"]["덮개"]["labor:LB000036#0"]["qty"] == pytest.approx(0.036) # ÷ 수량 2 + assert got["covered"]["덮개"]["labor:LB000036#0"]["qty"] == pytest.approx( + 0.072 + ) # 화면 품 그대로(M01 원문 몫 · 748-0 — 수량 2 로 안 나눔) assert got["covered"]["덮개"]["labor:LB000036#0"]["원값"] == {"qty": 0.018, "단가": 233250} assert got["dropped"] == DROP and got["back"] is None assert got["tags"] == ["9-12-1", "9-12-1", "3-2-1", "3-2-1"] # 바탕 둘 · 둘째 기초 인력 둘 diff --git a/resources/tester/test_748_0_sk_undo.py b/resources/tester/test_748_0_sk_undo.py new file mode 100644 index 000000000..c1746cb27 --- /dev/null +++ b/resources/tester/test_748_0_sk_undo.py @@ -0,0 +1,40 @@ +"""748-0 지킴 — SK 둘째 기초 세부 품 = M01 원문(머리 수량 안 곱함) · 고침 꼴 · 빼기 기억 · 되살림 · 수량 처음값. +(서버 짝 = `test_744_1_sk_detail_undo.py`)""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +def test_세부_원문_품_고침_빼기_되살림_처음값(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_748_0_sk_undo.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert float(out["shownQty"]) == pytest.approx(0.871) # 곱하지 않은 M01 원문 + assert out["lineQty"] == pytest.approx(0.871) + assert out["lineAmount"] == pytest.approx(871) # 상자 몫은 그대로 + assert out["t"] == 1 + assert out["cover"]["labor:LB000101#0"]["qty"] == pytest.approx(0.9) # 머리 수량 안 나눔 + assert out["coverShown"] == pytest.approx(0.9) # 다시 그려도 같음 + assert out["manQty"] == 5 and out["manBack"] == 2 # [되돌림] = 넣을 때 수량 + drop = out["afterDrop"] + assert drop["adds"] == 1 and drop["gone"][0]["차례"] == 0 + assert drop["pick"] == {"add:#0": "MT1"} # 뒤 더한줄 열쇠 당김 + assert out["goneRows"][0]["cid"] == "gone:0" + back = out["afterBack"] + assert back["adds"] == ["LB000002", "GF000316"] and back["gone"] is None + assert back["pick"] == {"add:#1": "MT1"} # 되살림 = 밂 + assert out["baseGone"] == {"adds": 1, "name": "둘째 기초", "pick": {}} + assert out["baseBack"] == ["LB000002", "GF000316"] diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 21da41e5d..f892dae58 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -182,6 +182,10 @@ export const ui_locales_m2 = { M02_SangeunAddLine: ["항목 추가", "Add item"], M02_SangeunDrop: ["빼기", "Remove"], M02_SangeunDropBase: ["기초 빼기", "Remove base"], + M02_SangeunBases: ["더한 기초", "Added bases"], + M02_SangeunBaseQty: ["수량(기준단위 몇 개)", "Quantity (base units)"], + M02_SangeunBaseQtyBad: ["0 이상 수만", "Numbers ≥ 0 only"], + M02_SangeunQtyUndo: ["되돌림", "Undo"], M02_SangeunRestore: ["되살리기", "Restore"], M02_SangeunWas: ["바탕", "Base"], M02_SangeunEditedHere: ["단가산출에서 고침", "Edited in this analysis"], From 6af23d42e1e724bd22665e97081c031d68b85784 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 22:10:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(748-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EC=A4=84=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=20=E2=80=94=20=EC=A0=9C=EB=AA=A9=20=EC=98=A4=EB=A5=B8=EC=AA=BD?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20=ED=82=A4=20=C2=B7=20[=EC=A0=81?= =?UTF-8?q?=EC=9A=A9]=20[=EC=82=AD=EC=A0=9C]=20[=EB=8B=AB=EA=B8=B0]=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=C2=B7=20=EC=9A=94=EC=86=8C=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=A0=95=EB=B3=B4=20=EC=B9=B8=20=EA=B1=B7=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제목 오른쪽 (연결 키 · 예 보통인부 (LB000002)) — 요소 · 고른 품목 · 부른 로직 키 - [적용] · [삭제](옛 빼기 이름 바꿈)를 모달 머리 [닫기] 왼쪽으로 · [기초 빼기] · 별도계상은 고침 칸 아래 그대로 - 고침 칸이 있는 모달은 요소 단가 정보 칸(이름 · 키 · 값 · 출처 · 마스터 보기) 걷음 — 자재품목 물가 넷 · 후보 목록 · 한 줄 풀이는 그대로 - M01 단가산출 기초 상세 모달도 같은 꼴([적용] [닫기]) - 낡은 시험 고침: test_722 모달 edit 인자(close, head) Co-Authored-By: Claude Sonnet 5.5 --- .../M01_MasterData_UI_LogicLab_Cover.ts | 12 +++++++- .../M01_MasterData_UI_LogicLab_Ho.ts | 12 ++++---- .../M01_MasterData_UI_LogicLab_Modal.ts | 28 +++++++++++++------ .../M01_MasterData_UI_LogicLab_Style.css | 7 +++++ .../M02_MasterTemplete_Sangeun_Detail.ts | 10 +++++-- resources/tester/test_722_ho_detail.py | 2 +- ui_template/ui_template_locale_m2.ts | 2 +- 7 files changed, 53 insertions(+), 20 deletions(-) diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts index b1841a53f..deb5fec0e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts @@ -93,6 +93,8 @@ export interface CoverFormOptions { /** 「m02」 = 덮개 보관 안내 */ scope?: "m01" | "m02"; onApply: (cover: LineCover | null) => void; + /** 모달 머리 단추 자리(닫기 왼쪽 · 748-1) — 주면 [적용] 이 거기로 */ + head?: HTMLElement; } const num = (raw: string): number | null => { @@ -140,10 +142,17 @@ export function coverForm(o: CoverFormOptions): HTMLElement { error.hidden = !bad; if (!bad) o.onApply(Object.keys(next).length ? next : null); }; + const applyButton = createButton({ + label: tc("Apply"), + variant: "filled", + size: 32, + onClick: apply, + }); + o.head?.append(applyButton); const actions = el("div", { className: "m01lab-cover__actions", children: [ - createButton({ label: tc("Apply"), variant: "filled", size: 32, onClick: apply }), + ...(o.head ? [] : [applyButton]), ...(o.cover ? [ createButton({ @@ -156,6 +165,7 @@ export function coverForm(o: CoverFormOptions): HTMLElement { : []), ], }); + actions.hidden = !actions.childElementCount; return el("section", { className: "m01lab-cover", attrs: { "data-cover-form": "" }, diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index 988efffe4..1af682d93 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -122,8 +122,8 @@ export interface HoEdit { covers: Record; set: (idx: number, cover: LineCover | null) => void; scope?: "m01" | "m02"; - /** (M02) 모달 고침 칸 아래 덧 동작(줄 빼기 · 별도계상 등) */ - more?: (idx: number, close: () => void) => HTMLElement[]; + /** (M02) 모달 덧 동작 — `head` = 닫기 왼쪽 단추(삭제) · `body` = 고침 칸 아래(기초 빼기 · 별도계상 등) */ + more?: (idx: number, close: () => void) => { head: HTMLElement[]; body: HTMLElement[] }; } /** 고친 줄 꼬리표 — M01 = 시험 고침 · M02 = 변경 항목 · 고침 없음 = "" */ @@ -279,8 +279,9 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi // 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만) ...(edit && (e.extra ? edit.scope === "m02" : item) ? { - edit: (close: () => void) => { + edit: (close: () => void, head: HTMLElement) => { const form = coverForm({ + head, qty: line ? pctView(line).qty : null, prices: e.extra ? {} @@ -295,8 +296,9 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi scope: edit.scope, onApply: (c) => (close(), edit.set(e.idx, c)), }); - const more = edit.more?.(e.idx, close) ?? []; - return more.length ? el("div", { children: [form, ...more] }) : form; + const more = edit.more?.(e.idx, close); + head.append(...(more?.head ?? [])); + return more?.body.length ? el("div", { children: [form, ...more.body] }) : form; }, } : {}), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index d6e8dede0..0e1f69ead 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -92,8 +92,8 @@ export interface ModalTarget { usedSlot?: string; /** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */ onPick?: (item: { ref: string; label: string }) => void; - /** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 */ - edit?: (close: () => void) => HTMLElement; + /** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 · 머리 단추 자리(닫기 왼쪽 · 748-1) */ + edit?: (close: () => void, head: HTMLElement) => HTMLElement; } /** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */ @@ -287,7 +287,10 @@ function priceView(ref: string, brief: ElementBrief, usedSlot?: string): HTMLEle } /** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움(닫기 함수도 받음) */ -function openDialog(title: string, mount: (body: HTMLElement, close: () => void) => void): void { +function openDialog( + title: string, + mount: (body: HTMLElement, close: () => void, head: HTMLElement) => void, +): void { openModal({ title, closeLabel: tl("Modal_Close"), @@ -428,7 +431,7 @@ function pickedOf(cond: PickCond, values: Record, fallback: stri /** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) · * 재료 고르기 줄(`cond`)은 잡힌 품목 단가 + 후보 목록 */ export function openMaterialModal(target: ModalTarget): void { - openDialog(target.title, (body, close) => { + openDialog(target.title, (body, close, head) => { const cond = target.cond; const element = target.element ?? ""; const base = element.split(".")[0]; @@ -445,6 +448,15 @@ export function openMaterialModal(target: ModalTarget): void { ? target.unitRef || pickedOf(cond, target.values, known?.ref ?? "") : element; const logicKey = /^로직\((\w+)/.exec(element)?.[1] ?? ""; + // 748-1 연결 키 = 제목 오른쪽 (괄호) · 고침 칸이 있으면 요소 단가 정보 칸은 없음(키 · 값은 제목 · 단가 칸에) + const key = logicKey || (cond ? priceRef : element.includes("{") ? target.unitRef : base); + if (key) + body + .closest(".ui-modal") + ?.querySelector("h3") + ?.append(el("span", { className: "m01lab__modalkey", text: ` (${key})` })); + const info = (ref: string, brief: ElementBrief, slot?: string): HTMLElement => + target.edit && !ref.startsWith("MT") ? el("span") : priceView(ref, brief, slot); const sum = el("p", { className: "m01lab__sum" }); const setSum = (price: ElementBrief | null | undefined): void => { const unit = price ? `${tl("Modal_UnitPrice")} ${formatPrice(price.값)} × ` : ""; @@ -457,9 +469,7 @@ export function openMaterialModal(target: ModalTarget): void { /** `prices` 에 있으면 그대로 · 없으면 키로 찾아 옴 — 못 찾으면 「관리자 값 미확정」 */ const lookup = (ref: string, label: string): void => { const show = (brief: ElementBrief | null): void => { - rate.replaceChildren( - brief ? priceView(ref, brief) : muted(`${label} — ${tl("Pill_NoValue")}`), - ); + rate.replaceChildren(brief ? info(ref, brief) : muted(`${label} — ${tl("Pill_NoValue")}`)); setSum(brief); }; rate.append(muted("…")); @@ -471,7 +481,7 @@ export function openMaterialModal(target: ModalTarget): void { .catch(() => show(null)); }; if (known && priceRef) { - rate.append(priceView(priceRef, known, target.usedSlot)); + rate.append(info(priceRef, known, target.usedSlot)); } else if (logicKey) { rate.append( el("p", { @@ -505,7 +515,7 @@ export function openMaterialModal(target: ModalTarget): void { lookup(element, element); // 더한 줄(M02) 등 `prices` 에 없는 요소 — 키로 찾아 옴(725-4) } body.replaceChildren( - ...(target.edit ? [target.edit(close)] : []), + ...(target.edit ? [target.edit(close, head)] : []), rate, ...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []), muted(tl("Modal_Sum")), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index ed1d313d3..43d0a8ac4 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -121,6 +121,13 @@ overflow: auto; } +/* 748-1 모달 제목 오른쪽 연결 키 — 제목보다 가볍게 */ +.m01lab__modalkey { + font-size: var(--text-body); + font-weight: var(--font-weight-regular, 400); + color: var(--color-text-secondary); +} + .m01lab__modal-body { display: flex; flex-direction: column; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index b0e55e5b9..187e032ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -399,11 +399,11 @@ export function mountSangeunDetail( }, more: (idx, close) => { const r = at(idx); - if (!r) return []; + if (!r) return { head: [], body: [] }; const drop = createButton({ label: L("M02_SangeunDrop"), variant: "danger-ghost", - size: 28, + size: 32, onClick: () => (close(), dropLine(doc, r), touch()), }); drop.dataset.skDrop = ""; @@ -432,7 +432,11 @@ export function mountSangeunDetail( }).root, ] : []; - return [el("div", { className: "m02-sk__tags", children: [...apart, drop, ...whole] })]; + const rest = [...apart, ...whole]; + return { + head: [drop], + body: rest.length ? [el("div", { className: "m02-sk__tags", children: rest })] : [], + }; }, }; } diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py index b13834ea6..34d4eed23 100644 --- a/resources/tester/test_722_ho_detail.py +++ b/resources/tester/test_722_ho_detail.py @@ -69,7 +69,7 @@ def test_시험_고침은_캐시_밖(): cover = _text(COVER) assert "drafts" not in cover and "touch" not in cover and "/save" not in cover modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts") - assert "...(target.edit ? [target.edit(close)] : [])" in modal + assert "...(target.edit ? [target.edit(close, head)] : [])" in modal @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index f892dae58..1c91f2ef9 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -180,7 +180,7 @@ export const ui_locales_m2 = { M02_SangeunSwapWeight: ["단위중량", "Unit weight"], M02_SangeunSwapPick: ["고르세요", "Choose"], M02_SangeunAddLine: ["항목 추가", "Add item"], - M02_SangeunDrop: ["빼기", "Remove"], + M02_SangeunDrop: ["삭제", "Delete"], M02_SangeunDropBase: ["기초 빼기", "Remove base"], M02_SangeunBases: ["더한 기초", "Added bases"], M02_SangeunBaseQty: ["수량(기준단위 몇 개)", "Quantity (base units)"],