From e70fc184aea3588507ad19957b5d496c44d5e5a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:39:46 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(M02):=20=EB=84=A3=EC=9D=84=20=EA=B0=92?= =?UTF-8?q?=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B9=B8=20=E3=80=8C=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EA=B0=92=20xx=E3=80=8D=20=EB=B9=88=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=EC=A7=80=20=EC=97=86=EC=95=B0=20=E2=80=94=20=EA=B0=92?= =?UTF-8?q?=EC=9D=B4=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EA=B0=92(=EC=B2=AB=20=EC=84=A0=ED=83=9D=EC=A7=80)=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B8=20=EC=B1=84=20=ED=9D=90=EB=A6=AC=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EC=A0=80=EC=9E=A5=EC=9D=80=20=EB=B0=94=EA=BF=80=20?= =?UTF-8?q?=EB=95=8C=EB=A7=8C(40-1=20=E2=91=A2=20=C2=B7=20=EB=B8=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B8=20=EC=A0=95=ED=95=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 자동값도 없는 고르기 칸만 「—」 빈 선택지 · 범위 · 글 칸은 그대로(빈 칸 = 자동값 자리표시) - B05 고르기 모달 값 카드 확인 상자 받아들임 그대로(확인 false → true · 고르면 확인) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Values.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index 1171ce553..ac0fbabe6 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -22,7 +22,7 @@ import "./M02_Labor_Values.css"; const fill = (text: string, values: Record): string => text.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? "")); -const CONTROL_TEXT = { auto: "자동값", outOfRange: "범위 {value} 밖" }; +const CONTROL_TEXT = { outOfRange: "범위 {value} 밖" }; const autoValue = (input: ItemInput): string | number | null => { const auto = input.자동값; @@ -60,17 +60,20 @@ export function createValueControl( }, }) ) as HTMLInputElement | HTMLSelectElement; + // 고르기 칸 — 「자동값 xx」 빈 선택지는 두지 않음(40-1 ③ · 자동값 = 늘 첫 선택지) · 값이 없으면 자동값을 + // 고른 채 흐리게 보이고 저장은 사용자가 바꿀 때만 · 자동값도 없으면 「—」 빈 선택지 하나 if (control instanceof HTMLSelectElement) { control.append( - el("option", { - attrs: { value: "" }, - text: auto === null ? "—" : `${CONTROL_TEXT.auto} ${String(auto)}`, - }), + ...(auto === null ? [el("option", { attrs: { value: "" }, text: "—" })] : []), ...choices.map((c) => el("option", { attrs: { value: c.값 }, text: c.이름 })), ); } const empty = value === null || value === ""; - control.value = empty ? "" : String(value); + control.value = empty + ? control instanceof HTMLSelectElement && auto !== null + ? String(auto) + : "" + : String(value); control.classList.toggle("is-auto", empty); const error = el("span", { className: "m02-inputval__error" }); const out: ValueControl = { control, error, wasBad: false }; @@ -81,7 +84,7 @@ export function createValueControl( error.textContent = bad ? fill(CONTROL_TEXT.outOfRange, { value: `${range?.min ?? ""}~${range?.max ?? ""}` }) : ""; - control.classList.toggle("is-auto", raw === ""); + control.classList.toggle("is-auto", raw === ""); // 고르기 칸은 고르면 늘 저장 값 onChange(storedOf(input, raw), bad); }); return out; From 35ccc26afae84d12e07781fffe5a1fce1587405e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:39:46 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=20=EC=97=B4=20?= =?UTF-8?q?=E2=80=94=20=EC=9C=84=20=EA=B2=B0=EA=B3=BC=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=88=98=EB=9F=89=20=C2=B7=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=EC=A7=80=EA=B8=88=20=EC=88=98=EB=9F=89=20=C2=B7=20?= =?UTF-8?q?=EB=88=84=EB=A5=B4=EB=A9=B4=20=EC=86=90=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?/=20=EC=97=91=EC=85=80=EC=97=90=EC=84=9C=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0(=EB=A9=B4=EC=A0=81=20=EC=B9=B8=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=C2=B7=20[=EC=A0=80=EC=9E=A5]=20=EB=95=8C=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=20=EB=AC=B8=EC=84=9C=20=ED=95=A8=EA=BB=98)=20=C2=B7?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B3=B5=EC=9A=A9=20=EC=96=91=EC=8B=9D?= =?UTF-8?q?(40-1=20=E2=91=A3=20=E2=91=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Labor_Rows 수량산출 칸(qtyCell) · 결과 단위 왼쪽 · 좁은 칸은 윗줄 - Labor_Input 수량 칸에 [엑셀에서 고르기](sub3 pickAreaCell) · 이으면 손 수량 지움 · 모달 닫음 - Labor_Detail: 면적 문서 · 판을 들고 linkAreaCell · recalcWorkbook · 저장 안 한 연결은 calc 에 칸 값으로 · saveArea - UI_Main labor 갈래: [저장] = 면적 문서 먼저 → 일위대가 판 다시 읽음 → 일위대가(서버가 면적 저장 때 일위대가 계산값을 다시 적어 판이 바뀜) - 로직 추가 · 자재 추가 · 올리기 · 내리기 · 빼기 = 기본 크기 버튼(sm 알약 꼴 아님) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 41 +++++++ .../M02_MasterTemplete_Labor_Detail.ts | 113 ++++++++++++++++-- .../M02_MasterTemplete_Labor_Input.ts | 24 +++- .../M02_MasterTemplete_Labor_Rows.ts | 40 ++++++- .../M02_MasterTemplete_Labor_Text.ts | 5 + .../M02_MasterTemplete_UI_Main.ts | 12 +- 6 files changed, 219 insertions(+), 16 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 964c7ddcd..6bccbd95a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -181,6 +181,23 @@ text-align: right; } + /* 수량산출 칸 — 윗줄에(차례 · 단위와 한 줄) · 칸 이름 없이 */ + .m02-labrow:not(.m02-labline) tr:has(.m02-labrow__qty) .m02-labrow__no, + .m02-labrow tr:has(.m02-labrow__qty) .m02-labrow__unit:not([data-label]) { + grid-column: span 1; + } + + .m02-labrow .m02-labrow__qty { + order: 0; + grid-column: span 2; + text-align: left; + } + + .m02-labrow .m02-labrow__qty::before { + content: none; + } + + .m02-labrow__sum .m02-labrow__qty, .m02-labrow__sum .m02-labrow__no, .m02-labrow__sum .m02-labrow__unit { display: none; @@ -264,3 +281,27 @@ padding: var(--spacing-4) 3px; } } + +/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ +.m02-labrow__qty { + white-space: nowrap; + vertical-align: top; +} + +.m02-labrow__qtybtn { + display: block; + width: 100%; + padding: 2px var(--spacing-4); + border: 1px dashed var(--color-border); + border-radius: var(--radius-sm, 4px); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-labrow__qtybtn:hover { + border-color: var(--color-primary); + background: var(--color-mist-violet); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 35704c6f4..21fc12072 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -11,7 +11,15 @@ import { createButton, createInputField, el, showToast } from "@ui/ui_template_e import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; -import { readTemplate, type ItemLogic, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; +import { + readTemplate, + saveTemplate, + type ItemLogic, + type Layer, +} from "./M02_MasterTemplete_Api_Fetch"; +import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -50,6 +58,9 @@ export interface LaborDetailOptions { export interface LaborDetailHandle { getDoc: () => LaborDoc; + /** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) — + * 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */ + saveArea: () => Promise; destroy: () => void; } @@ -72,6 +83,12 @@ export function mountLaborDetail( const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); const given = (): Record => Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); + /** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */ + let area: { doc: Workbook; 판: string } | null = null; + let areaDirty = false; + /** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */ + let pills = new Map(); + const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved; const logicHost = el("div"); const matHost = el("div"); @@ -112,7 +129,7 @@ export function mountLaborDetail( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) - edit ? { open: openRow, move, remove: removeRow } : { open: openRow }, + edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow }, ), ); matHost.replaceChildren( @@ -133,9 +150,13 @@ export function mountLaborDetail( fail = null; draw(); try { - const got = await calcLabor(opt.layer, opt.column, cur); + // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) + const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; calc = got; + pills = new Map( + (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), + ); } catch (error) { if (!alive || mine !== seq) return; fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); @@ -146,7 +167,7 @@ export function mountLaborDetail( /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ const changed = (qtyOnly = false): void => { - opt.onChanged(JSON.stringify(cur) !== saved); + opt.onChanged(dirty()); if (qtyOnly && calc) { calc = settle(calc, cur, given()); draw(); @@ -160,6 +181,36 @@ export function mountLaborDetail( return `${at.칸} = ${at.값 === null ? "—" : at.값.toLocaleString("ko-KR")}${unit ? ` ${unit}` : ""}`; }; + /** [엑셀에서 고르기] — 그 열 면적산출 칸을 골라 그 알약을 이음(화면 문서 · 저장은 [저장]) · 이었으면 참 */ + const pickCell = async (pill: string | null | undefined, label: string): Promise => { + if (!pill || !area) { + showToast(tc(area ? "Labor_PickWait" : "Labor_NoArea"), "error"); + return false; + } + const got = await pickAreaCell({ + doc: area.doc, + layer: opt.layer, + column: opt.column, + pill, + label, + }); + if (!got || !alive) return false; + const info = pills.get(pill); + const next = linkAreaCell(area.doc, pill, got.칸, { + ...(info?.이름 ? { 이름: info.이름 } : {}), + ...(info?.단위 ? { 단위: info.단위 } : {}), + }); + if (!next) { + showToast(tc("Labor_BadCell"), "error"); + return false; + } + next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈 + area.doc = next; + links = areaLinks(next); + areaDirty = true; + return true; + }; + /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); @@ -200,6 +251,7 @@ export function mountLaborDetail( qty: row.수량, linked: linkedText(line?.알약, line?.분모 ?? ""), unit: line?.분모 ?? "", + pick: () => pickRow(i), }, readOnly: !edit, goArea: opt.goArea, @@ -214,6 +266,37 @@ export function mountLaborDetail( }); } + /** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */ + async function pickRow(i: number): Promise { + const row = cur.담은로직[i]; + const logic = logics.get(row.로직); + if (!(await pickCell(lineOf(row.로직)?.알약, logic?.이름 ?? row.로직))) return false; + row.수량 = null; + changed(true); + return true; + } + + /** 수량산출 칸 누름 — 손 입력 · 엑셀에서 고르기 */ + function openQty(i: number): void { + const row = cur.담은로직[i]; + const line = lineOf(row.로직); + openQtyModal( + `${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(), + { + qty: row.수량, + linked: linkedText(line?.알약, line?.분모 ?? ""), + unit: line?.분모 ?? "", + pick: () => pickRow(i), + }, + !edit, + opt.goArea, + (qty) => { + row.수량 = qty; + changed(true); + }, + ); + } + function openMat(i: number): void { const mat = cur.자재[i]; const line = matOf(mat.코드); @@ -223,6 +306,12 @@ export function mountLaborDetail( qty: mat.수량, linked: linkedText(line?.알약, mat.단위 ?? ""), unit: mat.단위 ?? "", + pick: async () => { + if (!(await pickCell(line?.알약, mat.이름 ?? mat.코드))) return false; + mat.수량 = null; + changed(true); + return true; + }, }, !edit, opt.goArea, @@ -236,7 +325,6 @@ export function mountLaborDetail( const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", - size: "sm", onClick: () => openLogicPicker((x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) @@ -248,7 +336,6 @@ export function mountLaborDetail( const addMat = createButton({ label: tc("Labor_AddMat"), variant: "filled", - size: "sm", onClick: () => pickMaterial((m) => { if (cur.자재.some((x) => x.코드 === m.코드)) @@ -268,7 +355,7 @@ export function mountLaborDetail( unit.input.setAttribute("data-labor-field", "단위"); unit.input.addEventListener("input", () => { cur.단위 = unit.input.value.trim() || null; - opt.onChanged(JSON.stringify(cur) !== saved); + opt.onChanged(dirty()); }); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); @@ -306,7 +393,10 @@ export function mountLaborDetail( void Promise.all([ loadLogics(), readTemplate(opt.layer, "area", opt.column, null).then( - (got) => (links = areaLinks(got.문서)), + (got) => { + area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; + links = areaLinks(got.문서); + }, () => undefined, ), ]).then(() => { @@ -315,6 +405,13 @@ export function mountLaborDetail( return { getDoc: () => cur, + saveArea: async () => { + if (!area || !areaDirty) return false; + const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); + area.판 = info.판; + areaDirty = false; + return true; + }, destroy: () => { alive = false; window.clearTimeout(timer); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index ce7aa9db1..2b0b7c445 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -33,6 +33,8 @@ export interface QtySeat { linked: string | null; /** 손 입력 단위 표기(분모 · 자재 단위) */ unit: string; + /** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */ + pick?: (() => Promise) | null; } interface QtyPart { @@ -42,7 +44,12 @@ interface QtyPart { } /** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ -function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): QtyPart { +function qtyPart( + seat: QtySeat, + readOnly: boolean, + goArea: (() => void) | null, + close: () => void, +): QtyPart { const name = `m02-labin-qty-${Math.random().toString(36).slice(2)}`; const radio = (value: "cell" | "hand"): HTMLInputElement => { const input = el("input", { attrs: { type: "radio", name, value } }) as HTMLInputElement; @@ -67,6 +74,16 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): const go = goArea ? createButton({ label: tc("In_QtyGoArea"), variant: "ghost", size: "sm", onClick: goArea }) : null; + const pickFn = seat.pick; + const pick = + pickFn && !readOnly + ? createButton({ + label: tc("In_QtyPick"), + variant: "ghost", + size: "sm", + onClick: () => void pickFn().then((linked) => linked && close()), + }) + : null; const line = (input: HTMLInputElement, label: string, ...rest: HTMLElement[]): HTMLElement => el("label", { className: "m02-labin__qty-line", @@ -83,6 +100,7 @@ function qtyPart(seat: QtySeat, readOnly: boolean, goArea: (() => void) | null): className: "m02-labin__muted", text: seat.linked ? fill(tc("In_QtyLinked"), seat.linked) : tc("In_QtyNoLink"), }), + ...(pick ? [pick] : []), ...(go ? [go] : []), ), line(hand, tc("In_QtyHand"), number, el("small", { text: seat.unit })), @@ -174,7 +192,7 @@ export function openLaborInput(opt: LaborInputOptions): void { }) as HTMLInputElement; memo.value = opt.memo ?? ""; memo.readOnly = opt.readOnly; - const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close)); + const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close), close); const ok = (): void => { if (bad.size) return showToast(tc("In_Bad"), "error"); const 수량 = qty.read(); @@ -226,7 +244,7 @@ export function openQtyModal( closeLabel: tc("In_Close"), dialogClass: "m02-modal m02-labin", mount: (body, close) => { - const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close)); + const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close), close); const ok = (): void => { const 수량 = qty.read(); if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index efa456a74..976a9976a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -73,6 +73,36 @@ export function qtyShort( return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`; } +/** 수량산출 칸(40-1) — 위 결과단위 기준 수량(「10 ㎡」) · 아래 지금 수량(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) · + * `open` 이 있으면 단추(줄 누름과 따로) */ +function qtyCell( + line: LaborCalcLine | undefined, + cells: ReadonlyMap | undefined, + open: (() => void) | null, +): HTMLElement { + const parts = [ + el("small", { className: "m02-labrow__base", text: baseQtyText(line) || "—" }), + el("span", { className: "m02-labrow__applied", text: qtyShort(line, line?.분모 ?? "", cells) }), + ]; + const body = open + ? el("button", { + className: "m02-labrow__qtybtn", + attrs: { type: "button", title: tc("Labor_QtyPick"), "data-labor-qty-open": "" }, + children: parts, + }) + : el("span", { children: parts }); + if (open) + body.addEventListener("click", (e) => { + e.stopPropagation(); // 줄 누름(입력 모달)과 따로 + open(); + }); + return el("td", { + className: "m02-labrow__qty", + attrs: { "data-label": tc("Labor_QtyCol") }, + children: [body], + }); +} + /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, @@ -120,6 +150,8 @@ const table = (heads: string[], rows: HTMLElement[], foot: HTMLElement[]): HTMLE /** 줄 누름 · 순서 · 빼기 — 순서 · 빼기가 없으면 그 칸 없음 */ export interface RowHandlers { open: (i: number) => void; + /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ + qty?: (i: number) => void; move?: (i: number, d: number) => void; remove?: (i: number) => void; } @@ -146,14 +178,12 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const line = lineOf(row.로직); const unit = logic?.결과단위 ?? ""; const values = valueLine(logic?.입력 ?? [], row.입력값); - const qty = qtyText(line, line?.분모 ?? "", data.cells); - const note = [values.text, qty].filter(Boolean).join(" · "); + const note = values.text; const why = line?.까닭 ?? (data.fail && i === 0 ? data.fail : null); const small = (label: string, run: () => void): HTMLElement => createButton({ label, variant: "ghost", - size: "sm", onClick: (e) => { e.stopPropagation(); // 줄 누름(입력 모달)과 따로 run(); @@ -189,6 +219,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []), ], }), + qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null), el("td", { className: "m02-labrow__unit", text: unit }), ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))), ...(move && remove @@ -217,6 +248,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), ...COSTS.map((c) => moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])), @@ -229,6 +261,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement const heads = [ tc("Col_Order"), tc("Col_Name"), + tc("Labor_QtyCol"), tc("Col_Unit"), ...COSTS.map((c) => tc(HEAD[c])), ...(tools ? [tc("Col_Move")] : []), @@ -300,7 +333,6 @@ export function matTable(data: MatTableData, on?: Omit): HT createButton({ label: tc("Remove"), variant: "ghost", - size: "sm", onClick: (e) => { e.stopPropagation(); remove(i); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 5e8742a8c..d1df35571 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -58,6 +58,11 @@ const TEXT = { Labor_Qty: ["수량", "Quantity"], Labor_QtyCol: ["수량산출", "Quantity"], Labor_Hand: ["손", "Manual"], + Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"], + In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"], + Labor_PickWait: ["계산이 끝난 뒤 고름", "Wait for the calculation"], + Labor_NoArea: ["이 열 면적산출을 못 읽음", "Area sheet not loaded"], + Labor_BadCell: ["칸 주소가 틀림", "Invalid cell"], Labor_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 64343243f..1d1307d7d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -365,7 +365,7 @@ export function createMain( if (at.kind === "labor") { // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); - editor = mountLaborDetail(host, doc, { + const labor = mountLaborDetail(host, doc, { layer: at.layer, column: at.name, readOnly, @@ -376,6 +376,16 @@ export function createMain( return true; }, }); + // [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값) + editor = { + getDoc: async () => { + // 면적 저장이 일위대가 계산값을 다시 적어 판이 바뀜 — 판을 다시 읽어 409 를 막음 + if (await labor.saveArea()) + version = (await readTemplate(at.layer, "labor", at.name, at.projectId)).판 ?? ""; + return labor.getDoc(); + }, + destroy: labor.destroy, + }; return; } if (at.kind === "area") { From b31468787a5eb34d00942b34f35d8dce03735f24 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:39:53 +0900 Subject: [PATCH 3/8] auto: 2026-10-04 22:39 (ESD_LAPTOP) --- docs/raw/M02_구조물조합개편/사용자요구_1004_2.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md index 30d4b5a29..3651b8c87 100644 --- a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -19,3 +19,7 @@ 3. 면적산출 상세 - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 + +## 추가 기능 (10-4 밤 · 원문 그대로) + +추가기능 부여. 면적산출에서 특정 영역을 선정하고 도면영역으로 전달을 할 경우 도면에 테이블을 자동으로 넣고 셀의 내용을 싱크하는 기능을 부여. 만약 동기화가 어려운 경우 업데이트 버튼을 추가해도 무관, 복수개의 테이블이 넘어갈수도 잇으며 위치는 도면에서 사용자가 드래그하여 지정 From 408cd7b6c79e410860777462ef23bed75afd346a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:42:38 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=B2=94=EC=9C=84=20=E2=86=92=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=ED=91=9C=20=ED=99=94=EB=A9=B4=20=ED=8B=80(41-3)=20=E2=80=94?= =?UTF-8?q?=20[=EB=8F=84=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0]=20=C2=B7=20=EA=B0=99=EC=9D=80=20=EC=97=B4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=B4=EC=97=90=20=EB=84=98=EA=B2=A8=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=97=B0=EA=B2=B0=ED=91=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EB=8B=A4=EB=A6=84=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?[=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_MasterTemplete_AreaToDrawing.ts — 범위 · 글자 크기(기본 2.5) 창 · 저장 안 한 고침 먼저 저장 · 표 변환 길(41-1) · placeTable · updateTable(41-2 손잡이) · 표 여럿 = T번호 - UI_Main 갈고리(select · onArea · onDrawing · withLinks) · StructureDoc 연결표 칸 - 시험 test_m02_area_to_drawing.py(Node) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Api_Fetch.ts | 2 + .../M02_MasterTemplete_AreaToDrawing.ts | 281 ++++++++++++++++++ .../M02_MasterTemplete_UI_Main.ts | 13 +- resources/tester/test_m02_area_to_drawing.py | 162 ++++++++++ 4 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts create mode 100644 resources/tester/test_m02_area_to_drawing.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 2b1982f93..c3d2e182e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -135,6 +135,8 @@ export interface StructureDoc { 열: string; 도번: string; 도면: { entities: unknown[] } & Record; + /** 면적산출 → 도면 표 연결 기록(41-3 · `AreaToDrawing`) — 표 style.연결 = id · 값 = 마지막 칸 글 */ + 연결표?: { id: string; 시트: string; 범위: string; 글자크기: number; 값: (string | null)[][] }[]; } /** 시스템 층 구조물 도면 목록 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts new file mode 100644 index 000000000..bc2f21bf5 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -0,0 +1,281 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaToDrawing.ts + * 면적산출 범위 → 도면 표(PLAN 41-3) — 편집 칸(`UI_Main`)은 갈고리만 · 몸은 여기. + * ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5) + * → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열 + * 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2) + * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {id(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글)} · 표 style.연결 = id · + * 도면에서 지운 표는 기록에서 뺌 + * ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`) + * ④ 표 여러 개 — id 마다 한 줄 + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +/** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */ +interface TableCell { + text?: string; + [key: string]: unknown; +} +/** CAD 표 객체 JSON(`TableEntity.toJson` 꼴에서 쓰는 것만) */ +interface TableEntityJson { + type: string; + shapeData: { cells: (TableCell | null)[][]; style?: Record } & Record< + string, + unknown + >; + [key: string]: unknown; +} +/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막으로 도면에 적힌 칸 글 · 다름 알림 기준) */ +export type TableLink = NonNullable[number]; +type LinkSpec = Omit; + +/** 도면 손잡이에 더해지는 표 함수(41-2) — 없으면 단추 · 알림이 조용히 빠짐 */ +interface TableHandle { + placeTable?: (entity: TableEntityJson) => void; + updateTable?: (link: string, cells: TableEntityJson["shapeData"]["cells"]) => void; +} + +const TEXT = { + send: "도면으로 보내기", + sendTip: "고른 범위를 이 열 도면에 표로 넣음 — 놓을 자리는 도면에서 누름", + title: "도면으로 보내기", + range: "범위", + size: "글자 크기", + ok: "보내기", + close: "닫기", + badSize: "글자 크기는 0 보다 큰 수", + saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?", + saveLabel: "저장하고 보내기", + failed: "표를 못 만듦 — {value}", + place: "도면에서 표를 놓을 자리를 누름(Esc = 그만)", + noPlace: "이 도면 화면은 표 놓기를 아직 못 함", + stale: "면적산출 값이 바뀐 표 {n}개", + update: "업데이트", + updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음", +} as const; + +const SIZE = 2.5; // 표 글자 크기 기본(정함 41) +const LINK = "연결"; // 표 style 안 표지 칸 + +const fill = (text: string, key: string, value: string | number): string => + text.replace(`{${key}}`, String(value)); +const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); +const enc = encodeURIComponent; + +/** 범위 → 표 객체(41-1 서버 · 저장 안 함) — 지금 보는 층 · 주인 */ +async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise { + const owner = getActiveOwner(); + const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; + const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/table${q}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(spec), + }); + const data = (await res.json().catch(() => ({}))) as { + detail?: unknown; + entity?: TableEntityJson; + } & Partial; + if (!res.ok) { + const d = data.detail; + throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`); + } + return (data.entity ?? data) as TableEntityJson; +} + +const isTable = (e: unknown): e is TableEntityJson => + !!e && typeof e === "object" && (e as { type?: unknown }).type === "Table"; +const linkOf = (e: TableEntityJson): string | null => { + const id = e.shapeData?.style?.[LINK]; + return typeof id === "string" && id ? id : null; +}; +const textsOf = (cells: TableEntityJson["shapeData"]["cells"]): (string | null)[][] => + (cells ?? []).map((row) => (row ?? []).map((c) => (c ? String(c.text ?? "") : null))); +const sameTexts = (a: (string | null)[][], b: (string | null)[][]): boolean => + JSON.stringify(a) === JSON.stringify(b); + +export interface AreaToDrawingOptions { + /** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */ + open: (sel: Selection) => Promise; + /** 지금 화면 [저장] */ + save: () => Promise; + /** 저장 안 한 고침이 남았나 */ + isDirty: () => boolean; + /** 편집 칸 알림 줄 */ + showNotice: (nodes: (HTMLElement | string)[]) => void; +} + +export interface AreaToDrawing { + /** 면적 시트 고름(`onSelect`) — 범위를 들고 있음 */ + select: (s: { 시트: string; 범위: string }) => void; + /** 면적 상세가 섰음 — 머리 칸에 [도면으로 보내기](고칠 수 있는 층 · 프로젝트 층 아님) */ + onArea: (at: Selection, readOnly: boolean, slot: HTMLElement) => void; + /** 도면 상세가 섰음 — 넘겨받은 표 놓기 · 연결표 다름 알림 */ + onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void; + /** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */ + withLinks: (doc: StructureDoc) => StructureDoc; +} + +export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { + let range: { 시트: string; 범위: string } | null = null; + /** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */ + let pending: { layer: Layer; column: string; entity: TableEntityJson; spec: LinkSpec } | null = + null; + /** 이 도면에서 새로 놓은 표의 연결(저장 때 기록) · 열어 둔 도면 */ + let fresh = new Map(); + let shown: string | null = null; + + /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ + const askSize = (spec: { 범위: string }): Promise => + new Promise((resolve) => { + let done = false; + const finish = (v: number | null): void => { + if (!done) resolve(v); + done = true; + }; + const size = createInputField({ label: TEXT.size, type: "number", value: String(SIZE) }); + size.input.step = "0.5"; + size.input.min = "0.1"; + const { close } = openModal({ + title: TEXT.title, + closeLabel: TEXT.close, + dialogClass: "m02-modal", + beforeClose: async () => (finish(null), true), + mount: (body) => { + const ok = createButton({ label: TEXT.ok, variant: "filled" }); + ok.addEventListener("click", () => { + const v = Number(size.input.value); + if (!(v > 0)) return showToast(TEXT.badSize, "error"); + finish(v); + close(); + }); + size.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) ok.click(); + }); + body.append(el("p", { text: `${TEXT.range} ${spec.범위}` }), size.root, ok); + size.input.select(); + }, + }); + }); + + async function send(at: Selection): Promise { + if (!range) return; + if (opt.isDirty()) { + if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return; + await opt.save(); + if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움 + } + const picked = range; + const size = await askSize(picked); + if (size === null) return; + const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size }; + let entity: TableEntityJson; + try { + entity = await fetchTable(at.layer, at.name, spec); + } catch (error) { + return showToast(fill(TEXT.failed, "value", why(error)), "error"); + } + pending = { layer: at.layer, column: at.name, entity, spec }; + await opt.open({ + layer: at.layer, + projectId: null, + kind: "structure", + name: at.name, + label: at.label, + }); + } + + /** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */ + const nextId = (doc: StructureDoc, taken: Set): string => { + for (const row of doc.연결표 ?? []) taken.add(row.id); + for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? ""); + let n = 1; + while (taken.has(`T${n}`)) n += 1; + return `T${n}`; + }; + + /** 연결표마다 지금 면적 값 → 마지막 값과 다르면 알림 + [업데이트] */ + async function checkStale(handle: TableHandle, at: Selection, doc: StructureDoc): Promise { + const rows = doc.연결표 ?? []; + if (!rows.length || !handle.updateTable) return; + const key = `${at.layer}:${at.name}`; + const got = await Promise.all( + rows.map((row) => + fetchTable(at.layer, at.name, row).then( + (e) => ({ row, e }), + () => null, + ), + ), + ); + if (shown !== key) return; // 그 사이 다른 화면 + const stale = got.filter( + (x): x is { row: TableLink; e: TableEntityJson } => + !!x && !sameTexts(x.row.값 ?? [], textsOf(x.e.shapeData.cells)), + ); + if (!stale.length) return; + const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" }); + update.addEventListener("click", () => { + for (const { row, e } of stale) handle.updateTable?.(row.id, e.shapeData.cells); + opt.showNotice([]); + showToast(fill(TEXT.updated, "n", stale.length), "success"); + }); + opt.showNotice([fill(TEXT.stale, "n", stale.length), update]); + } + + return { + select: (s) => (range = { 시트: s.시트, 범위: s.범위 }), + onArea: (at, readOnly, slot) => { + range = null; + if (readOnly || at.layer === "project") return; + const button = createButton({ label: TEXT.send, variant: "ghost", size: "sm" }); + button.title = TEXT.sendTip; + button.dataset.areaToDrawing = ""; + button.addEventListener("click", () => void send(at)); + slot.append(button); + }, + onDrawing: (raw, at, doc) => { + const handle = raw as TableHandle; + shown = `${at.layer}:${at.name}`; + fresh = new Map(); + const give = + pending && pending.layer === at.layer && pending.column === at.name ? pending : null; + pending = null; + if (give) { + if (!handle.placeTable) showToast(TEXT.noPlace, "error"); + else { + const id = nextId(doc, new Set()); + const style = { ...(give.entity.shapeData.style ?? {}), [LINK]: id }; + fresh.set(id, give.spec); + handle.placeTable({ ...give.entity, shapeData: { ...give.entity.shapeData, style } }); + showToast(TEXT.place, "info"); + } + } + void checkStale(handle, at, doc); + }, + withLinks: (doc) => { + const old = new Map((doc.연결표 ?? []).map((row) => [row.id, row])); + const rows: TableLink[] = []; + for (const e of doc.도면?.entities ?? []) { + if (!isTable(e)) continue; + const id = linkOf(e); + const spec = id ? (old.get(id) ?? fresh.get(id)) : undefined; + if (!id || !spec || rows.some((r) => r.id === id)) continue; + const { 시트, 범위, 글자크기 } = spec; + rows.push({ id, 시트, 범위, 글자크기, 값: textsOf(e.shapeData.cells) }); + } + // 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로) + return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc; + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 64343243f..7b711db23 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -28,6 +28,7 @@ import { } from "./M02_MasterTemplete_Structure"; import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; +import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; export interface Selection { @@ -220,6 +221,9 @@ export function createMain( back.hidden = !sel || !(sel.kind in NUMBER_FIELD); }; + // 면적산출 범위 → 도면 표(41-3) — 몸은 `AreaToDrawing` · 여기는 갈고리만 + const toDrawing = createAreaToDrawing({ open, save: doSave, isDirty: busy, showNotice }); + const drop = (): void => { editor?.destroy(); editor = null; @@ -275,12 +279,13 @@ export function createMain( dirty = !readOnly; apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함 }, - onSelect: (s) => (picked = s.칸), + onSelect: (s) => ((picked = s.칸), toDrawing.select(s)), links, }, ); editor = handle; sheetHandle = handle; + toDrawing.onArea(at, readOnly, slot); // 머리 [도면으로 보내기] if (at.layer === "project") return; if (kind === "area") { // 오른쪽 일위대가 패널 — 담은 로직 + 자재목록(기준 · 반영) · 줄 누름 = 칸 지정 모달 @@ -401,6 +406,7 @@ export function createMain( originMarker: at.kind === "structure", noArea: at.kind === "structure", }); + if (at.kind === "structure") toDrawing.onDrawing(editor, at, doc as StructureDoc); return; } empty(L("M02_NoDrawing")); @@ -460,7 +466,10 @@ export function createMain( // 면적산출은 스프레드시트 문서로 통째 갈아 끼우되 번호는 계약 밖(엔진이 모름)이라 옮겨 붙임. const doc = at.kind === "structure" && loaded - ? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] } + ? toDrawing.withLinks({ + ...(loaded as StructureDoc), + 도면: edited as StructureDoc["도면"], + }) : at.kind === "area" && loaded ? { ...(loaded as Record), ...(edited as Record) } : edited; diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py new file mode 100644 index 000000000..f7fb6c79d --- /dev/null +++ b/resources/tester/test_m02_area_to_drawing.py @@ -0,0 +1,162 @@ +"""면적산출 범위 → 도면 표(PLAN 41-3 · `M02_MasterTemplete_AreaToDrawing.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 면적 상세 [도면으로 보내기] = 고칠 수 있는 층만(읽기 · 프로젝트 층 단추 0). + - 보냄 → 표 변환 길(POST …/area/{열}/table) → 같은 열 도면 상세를 엶 → placeTable(style.연결 = T번호). + - 다른 열 도면이면 안 놓음 · 기존 기록 · 표지와 안 겹치는 id(T1 이 있으면 T2). + - [저장] 문서 `연결표` = 도면에 남은 표만(지운 표는 빠짐) · 값 = 그 표 칸 글 · 기록 없던 도면은 칸 안 만듦. + - 다시 열 때 지금 면적 값 ≠ 마지막 값이면 알림 + [업데이트] → updateTable(id, 칸). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const src = fs.readFileSync(process.argv[1] + "/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts", "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +// 화면 부품 · 길은 가짜 — 단추 · 창 · 알림만 흉내 +const made = []; +const button = (o) => { + const b = { label: o.label, title: "", dataset: {}, on: {}, addEventListener(k, f) { this.on[k] = f; } }; + made.push(b); + return b; +}; +const toasts = []; +const stubs = { + "@config/config_frontend": { API_BASE_URL: "/api" }, + "@ui/ui_template_elements": { + createButton: button, + createInputField: (o) => ({ root: {}, input: { value: o.value, step: "", min: "", select() {}, addEventListener() {} } }), + el: (tag, o) => ({ tag, ...o }), + showConfirmDialog: async () => true, + showToast: (m, k) => toasts.push([k, m]), + }, + "@ui/ui_template_modal": { + // 창이 뜨자마자 [보내기] + openModal: (o) => { + const body = { parts: [], append(...x) { this.parts.push(...x); } }; + const res = { close() {} }; + setTimeout(() => o.mount(body, res.close), 0); + setTimeout(() => made.at(-1).on.click(), 1); + return res; + }, + }, + "./M02_MasterTemplete_Api_Fetch": { getActiveOwner: () => null }, +}; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (n) => stubs[n] ?? require(n)); +const { createAreaToDrawing } = box.exports; + +const table = (texts, style = {}) => ({ + type: "Table", + shapeData: { cells: texts.map((r) => r.map((t) => ({ text: t }))), style }, +}); +let now = [["1.5", "2"]]; +const calls = []; +globalThis.fetch = async (url, init) => { + calls.push([url, JSON.parse(init.body)]); + return { ok: true, json: async () => table(now) }; +}; + +(async () => { + const opened = []; + const notices = []; + const ad = createAreaToDrawing({ + open: async (s) => opened.push(s), + save: async () => {}, + isDirty: () => false, + showNotice: (n) => notices.push(n), + }); + const slot = { kids: [], append(x) { this.kids.push(x); } }; + const area = { layer: "system", projectId: null, kind: "area", name: "item1", label: "기슭막이" }; + ad.onArea({ ...area, layer: "project" }, false, slot); + ad.onArea(area, true, slot); + const ro = slot.kids.length; + ad.onArea(area, false, slot); + ad.select({ 시트: "산출", 칸: "B2", 범위: "B2:C2" }); + await slot.kids[0].on.click(); + await new Promise((r) => setTimeout(r, 20)); + const sent = { calls: calls.slice(), opened: opened.slice() }; + + // 같은 열 도면 — 기록 T1 이 있으니 새 표는 T2 + const placed = []; + const handle = { placeTable: (e) => placed.push(e), updateTable: (id, c) => placed.push(["u", id, c]) }; + const old = { id: "T1", 시트: "산출", 범위: "A1:A1", 글자크기: 3, 값: [["옛"]] }; + const drawing = (ents, links) => ({ 양식: "구조물도면", 종류: "structure", 판: 1, 열: "item1", 도번: "SD-1", 도면: { entities: ents }, ...(links ? { 연결표: links } : {}) }); + ad.onDrawing(handle, { ...area, kind: "structure" }, drawing([table([["옛"]], { 연결: "T1" })], [old])); + await new Promise((r) => setTimeout(r, 20)); + const notice = notices.at(-1) ?? []; + const upd = notice.find((x) => x && x.on); + if (upd) upd.on.click(); + + // 저장 — T1 은 지움 · T2 만 남음 · 값 = 표 칸 글 + const saved = ad.withLinks(drawing([{ type: "Line" }, table([["9", "8"]], { 연결: "T2" })], [old])); + const plain = ad.withLinks(drawing([table([["x"]])])); + // 다른 열 도면이면 안 놓음 + const before = placed.length; + ad.select({ 시트: "산출", 칸: "A1", 범위: "A1" }); + await slot.kids[0].on.click(); + await new Promise((r) => setTimeout(r, 20)); + ad.onDrawing(handle, { ...area, kind: "structure", name: "item2" }, drawing([])); + console.log(JSON.stringify({ ro, buttons: slot.kids.length, sent, placed, notice: notice.map((x) => (typeof x === "string" ? x : x.label)), saved: saved.연결표, plain: "연결표" in plain, other: placed.length - before })); +})(); +""" + + +@pytest.fixture(scope="module") +def got(): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + proc = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def test_단추는_고칠_수_있는_층만(got): + assert got["ro"] == 0 and got["buttons"] == 1 + + +def test_보냄은_표_변환_길_뒤_같은_열_도면을_엶(got): + url, body = got["sent"]["calls"][0] + assert url == "/api/m02/layers/system/area/item1/table" + assert body == {"시트": "산출", "범위": "B2:C2", "글자크기": 2.5} + assert got["sent"]["opened"][0]["kind"] == "structure" + assert got["sent"]["opened"][0]["name"] == "item1" + + +def test_놓을_표는_겹치지_않는_연결_id(got): + first = got["placed"][0] + assert first["shapeData"]["style"]["연결"] == "T2" + + +def test_다시_열면_다름_알림과_업데이트(got): + assert got["notice"][0].startswith("면적산출 값이 바뀐 표 1") + assert got["notice"][1] == "업데이트" + assert ["u", "T1", [[{"text": "1.5"}, {"text": "2"}]]] in got["placed"] + + +def test_저장_연결표는_남은_표만(got): + assert got["saved"] == [ + {"id": "T2", "시트": "산출", "범위": "B2:C2", "글자크기": 2.5, "값": [["9", "8"]]} + ] + assert got["plain"] is False + + +def test_다른_열_도면에는_안_놓음(got): + assert got["other"] == 0 + + +def test_편집_칸은_갈고리만(): + main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") + assert main.count("toDrawing.") == 4 # select · onArea · onDrawing · withLinks From c7a8ed7cf9a9c72115c5f619b5aaeccb67283a06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:44:14 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(M02):=20=EC=9D=B4=EB=A6=84=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=E2=80=94=20=ED=95=9C=20=EC=A4=84=20=ED=91=9C=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=E3=80=8C=ED=95=AD=EB=AA=A9=20=C2=B7=20?= =?UTF-8?q?=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=C2=B7=20=E2=80=A6=E3=80=8D(=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B8=80)=20=C2=B7=20=EB=82=A8=EC=9D=80=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B8=80=20=E3=80=8C=EB=8B=B4=EC=9D=80=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=E3=80=8D=20=E2=86=92=20=E3=80=8C=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=20=EB=A1=9C=EC=A7=81=E3=80=8D(40-1?= =?UTF-8?q?=20=EB=81=9D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 한 줄 표(compact) 머리만 · 상세 표 머리는 그대로 - 글 줄만: 넣을 값 빈 글 · 전체 항목 · 복사 생성 「단가산출 로직 수」 · 면적산출 알약 없음 · 실무문서 locale · B05 고르기 모달 일위대가 카드 · 면적 알약 없음 · 일위대가 문서 검사 글(「단가산출 로직 n째」) - 시험 기대값: test_m02_item_list 머리 글 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_Labor_Doc.py | 2 +- M02_MasterTemplete/M02_Labor_Values.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_ItemList.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts | 5 +++-- M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts | 1 + M02_MasterTemplete/M02_MasterTemplete_Structure.ts | 2 +- resources/tester/test_m02_item_list.py | 2 +- ui_template/ui_template_item_picker_edit.ts | 2 +- ui_template/ui_template_item_picker_labor.ts | 2 +- ui_template/ui_template_locale_m2.ts | 2 +- 11 files changed, 13 insertions(+), 11 deletions(-) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 60be6edbe..eaf3979d3 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -67,7 +67,7 @@ def clean(doc: Any) -> dict[str, Any]: whole = m01.loaded()[1] lines, seen = [], set() for at, one in enumerate(doc.get("담은로직") or [], start=1): - where = f"담은 로직 {at}째" + where = f"단가산출 로직 {at}째" if not isinstance(one, dict): raise _bad(f"{where} · 묶음 아님") code = str(one.get("로직") or "") diff --git a/M02_MasterTemplete/M02_Labor_Values.ts b/M02_MasterTemplete/M02_Labor_Values.ts index ac0fbabe6..e70f8b978 100644 --- a/M02_MasterTemplete/M02_Labor_Values.ts +++ b/M02_MasterTemplete/M02_Labor_Values.ts @@ -127,7 +127,7 @@ const VALUE_TEXT = { title: "넣을 값", missing: "빠진 로직 {value}", edit: "넣을 값 수정", - none: "넣을 값이 없음 — 담은 로직에 입력이 없음", + none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음", auto: "자동값", unconfirmed: "확인 안 함", confirmed: "✓ 확인함", diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 1df2b007c..a51a88c67 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -94,7 +94,7 @@ export const ITEM_COLS: Record[]> = { NAME, FOLDER, { head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3) - { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, + { head: "단가산출 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, { head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note }, changed("labor"), ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 34deef6f7..30fbaea13 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -62,7 +62,7 @@ const PRACTICE_COLS: Col[] = [ { head: "구분", get: (c) => c.구분 ?? "" }, { head: "상세구분", get: (c) => c.상세구분 ?? "" }, { head: "단위", get: (c) => c.단위 ?? "" }, - { head: "담은 로직 수", get: (c) => String(c.count) }, + { head: "단가산출 로직 수", get: (c) => String(c.count) }, ]; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 976a9976a..5189b76b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -381,10 +381,11 @@ export function compactTable(data: CompactData): HTMLElement { text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v), attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) }, }); + // 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계) const heads = [ - tc("Col_Name"), + tc("Labor_Item"), tc("Labor_QtyCol"), - tc("Col_Unit"), + tc("Labor_Unit"), ...COSTS.map((c) => tc(HEAD[c])), ]; const lines = data.rows.map((row) => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index d1df35571..cc1b30de5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -57,6 +57,7 @@ const TEXT = { Labor_AddMat: ["자재 추가", "Add material"], Labor_Qty: ["수량", "Quantity"], Labor_QtyCol: ["수량산출", "Quantity"], + Labor_Item: ["항목", "Item"], Labor_Hand: ["손", "Manual"], Labor_QtyPick: ["누르면 손 입력 · 엑셀에서 고르기", "Manual or pick a cell"], In_QtyPick: ["엑셀에서 고르기", "Pick in sheet"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 44750848b..b51a094e2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -51,7 +51,7 @@ const TEXT = { pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", copyFailed: "복사 실패 — {value}", - noPills: "이 열 일위대가에 담은 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", + noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", discardCards: "카드(도면 · 면적산출)에 저장 안 한 고침이 있음 — 버리고 넘어갈까?", }; diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 604f3d7ca..45d69c367 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -43,7 +43,7 @@ def test_종류마다_칸(ts): "이름", "폴더", "단위", - "담은 로직 수", + "단가산출 로직 수", "합계", "고친 날", ] diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index 892c5a84e..0958442c7 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -36,7 +36,7 @@ const TEXT = { loadFailed: "복사본을 못 읽음 — {value}", discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?", outOfRange: "범위 밖 값이 있어 저장하지 못함", - noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 담은 로직 · 자재가 없음", + noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음", drawing: "도면", area: "면적산출", inputs: "넣을 값", diff --git a/ui_template/ui_template_item_picker_labor.ts b/ui_template/ui_template_item_picker_labor.ts index 69f14eaf1..2666dbebc 100644 --- a/ui_template/ui_template_item_picker_labor.ts +++ b/ui_template/ui_template_item_picker_labor.ts @@ -14,7 +14,7 @@ const TEXT = { title: "일위대가", none: "일위대가 없음", failed: "일위대가를 못 읽음 — {value}", - logics: "담은 로직 {value}개", + logics: "단가산출 로직 {value}개", materials: "자재 {value}개", base: "기준 합 {value}원", reflected: "반영 합 {value}원", diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 78aa7c1c7..e6817a238 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -181,7 +181,7 @@ export const ui_locales_m2 = { M02_PracticeLogics: ["로직 수", "Logics"], M02_PracticeOrder: ["차례", "No."], M02_PracticeMemo: ["메모", "Memo"], - M02_PracticeNoLogic: ["담은 로직 없음", "No logics"], + M02_PracticeNoLogic: ["단가산출 로직 없음", "No logics"], M02_PracticeBack: ["목록으로", "Back to list"], M02_PracticeDrop: ["통째 지우기", "Delete all"], M02_PracticeDropAsk: ["실무문서 {n}개를 모두 지울까요?", "Delete all {n} practice documents?"], From 3ded39875fb22e2f55898a51ef07e77c5248a9bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:45:09 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(M02):=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=B2=94=EC=9C=84=20=E2=86=92=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=ED=91=9C=20=EC=84=9C=EB=B2=84=20=E2=80=94=20Node=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=20=EB=B3=80=ED=99=98=20=C2=B7=20=ED=91=9C=20?= =?UTF-8?q?=EA=B8=B8=20=C2=B7=20=EB=A9=B4=EC=A0=81=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=95=8C=20=EB=8F=84=EB=A9=B4=20=ED=91=9C=20=EC=B9=B8=20?= =?UTF-8?q?=EA=B8=80=20=EB=A7=9E=EC=B6=A4=20(41-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common_util_spreadsheet_node.ts areaTable: 시트 · 범위 → CAD 표 객체(표시 글 = formatValue · cellStyleAt · 병합 → colSpan/rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행열 뺌 · 가로 정렬 · 굵게 · 열 너비 · 행 높이 비율 · 칸 수 한도 · 칸 key 「시트id!A1」 · style.연결 빈 글) - 파이썬은 부르기만(common_util_spreadsheet.area_tables · 새 M02_Area_Table.py) - POST /api/m02/layers/{층}/area/{열}/table {시트, 범위, 글자크기} → {표, 시트, 범위, 값} · 저장 안 함 · 층 읽기 권한 - 면적 저장(Store.write · 비우기 · 복사 · 프로젝트 층 길) 뒤 그 열 도면 연결표마다 표 칸 글 다시 · 마지막 값 갱신 · 표 없으면 기록 둠 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- M02_MasterTemplete/M02_Area_Table.py | 84 ++++++++ .../M02_MasterTemplete_Router_Items.py | 23 ++ .../M02_MasterTemplete_Router_Layers.py | 9 + .../M02_MasterTemplete_Store.py | 10 + common_util/common_util_spreadsheet.py | 11 + common_util/common_util_spreadsheet_node.ts | 150 ++++++++++++- resources/tester/test_m02_area_table.py | 201 ++++++++++++++++++ 7 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 M02_MasterTemplete/M02_Area_Table.py create mode 100644 resources/tester/test_m02_area_table.py diff --git a/M02_MasterTemplete/M02_Area_Table.py b/M02_MasterTemplete/M02_Area_Table.py new file mode 100644 index 000000000..3dcea0a1c --- /dev/null +++ b/M02_MasterTemplete/M02_Area_Table.py @@ -0,0 +1,84 @@ +"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환은 Node 번들 한 벌(`common_util_spreadsheet_node.ts` +`areaTable` · 화면 표시 글과 같은 코드) · 여기는 부르기 · 도면 표 맞춤만(파이썬 이식 없음). + +도면 문서(`structure/<열>.json`) 맨 위 `연결표` = `[{연결, 시트, 범위, 글자크기, 값}]` — 도면 표 +`shapeData.style.연결` 이 `연결` 과 같은 표를 면적 [저장] 때 칸 글로 다시 적음(자리 · 크기 그대로 · +칸 수가 바뀌면 열 너비 · 행 높이도 새로) · `값` = 마지막 칸 글(`{시트id!A1: 글}` · 다름 알림용). +도면에 그 표가 없으면 기록은 그대로 둠. 층 = `store.at(층 폴더)` · 권한은 부르는 쪽. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from common_util import common_util_spreadsheet as sheet_node +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +logger = logging.getLogger(__name__) +LINKS = "연결표" + + +def to_table(column: str, sheet: str, rng: str, font: float | None = None) -> dict[str, Any]: + """그 열 면적산출(저장본) 시트 · 범위 → `{표, 시트, 범위, 값}` — 저장 안 함 · 못 바꾸면 400.""" + book = store.read("area", column)["문서"] + try: + got = sheet_node.area_tables([{"book": book, "시트": sheet, "범위": rng, "글자크기": font}]) + except sheet_node.RecalcError as e: + raise store.StoreError(503, str(e)) from e + if not got[0].get("ok"): + raise store.StoreError(400, got[0].get("까닭")) + return {k: got[0][k] for k in ("표", "시트", "범위", "값")} + + +def _style_link(entity: Any) -> Any: + if not isinstance(entity, dict) or entity.get("type") != "Table": + return None + return ((entity.get("shapeData") or {}).get("style") or {}).get("연결") + + +def refresh_drawing(column: str) -> list[str]: + """지금 층 그 열 도면의 연결표마다 칸 글 다시 · 마지막 값 갱신 → 칸을 간 표의 `연결` 목록.""" + try: + drawing, area = store._path("structure", column), store._path("area", column) + except store.StoreError: + return [] + if not drawing.is_file() or not area.is_file(): + return [] + doc = json.loads(drawing.read_bytes()) + links = [x for x in doc.get(LINKS) or [] if isinstance(x, dict) and x.get("연결")] + if not links: + return [] + book = json.loads(area.read_bytes()) + asks = [ + {"book": book, "시트": x.get("시트"), "범위": x.get("범위"), "글자크기": x.get("글자크기")} + for x in links + ] + try: + results = sheet_node.area_tables(asks) + except sheet_node.RecalcError as e: + logger.warning("도면 표 맞춤 건너뜀 %s — %s", column, e) + return [] + entities = (doc.get("도면") or {}).get("entities") or [] + done: list[str] = [] + for link, got in zip(links, results): + if not got.get("ok"): + continue + link["값"] = got["값"] + new = got["표"]["shapeData"] + for entity in entities: + if _style_link(entity) != link["연결"]: + continue + shape = entity["shapeData"] + if (len(shape.get("rowHeights") or []), len(shape.get("columnWidths") or [])) != ( + len(new["rowHeights"]), + len(new["columnWidths"]), + ): + shape["rowHeights"], shape["columnWidths"] = new["rowHeights"], new["columnWidths"] + shape["cells"] = new["cells"] + done.append(link["연결"]) + with store._LOCK: + atomic_write_json(drawing, doc) + return done diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 413e5409c..3b709f945 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -317,6 +317,29 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: return store.copy_from("labor", column, key, body=labor.from_combo(key)) +class AreaTableBody(BaseModel): + """면적산출 범위 → 도면 표(41-1) — `시트` = 시트 id · 이름 · `글자크기` 기본 2.5.""" + + 시트: str + 범위: str + 글자크기: float | None = None + + +@router.post("/layers/{layer}/area/{column}/table") +def post_area_table( + layer: Layer, + column: str, + body: AreaTableBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """그 열 면적산출(저장본) 범위 → CAD 표 `{표, 시트, 범위, 값}` — 저장 안 함 · 층 읽기 권한.""" + from M02_MasterTemplete import M02_Area_Table as area_table + + folder = _root(session, layer, owner) + return _call(folder, area_table.to_table, column, body.시트, body.범위, body.글자크기) + + @router.post("/layers/{layer}/{kind}/{column}/clear") def post_clear( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 639f400c2..ac26926b7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -361,9 +361,18 @@ async def save_layer_template( ) from error except ValueError as error: raise _bad(error) from error + if kind == "area": # 그 열 도면 연결표 칸 글도(41-1 · 시스템 · 회사 · 개인 층은 Store.write 가) + await asyncio.to_thread(_refresh_drawing, folder, name) return {"종류": kind, "이름": name, "판": version, "층": layer} +def _refresh_drawing(folder: Path, column: str) -> None: + from M02_MasterTemplete import M02_Area_Table as area_table + + with store.at(folder): + area_table.refresh_drawing(column) + + def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict: with store.at(folder): try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 0873d5d5e..6fcb2f702 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -170,6 +170,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> info = _info(kind, path) if kind == "area": _refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도 + _refresh_drawing(name) # 도면 연결표 칸 글도(41-1) if kind == "table" and name == STRUCTURE_TABLE: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: @@ -362,6 +363,7 @@ def clear(kind: str, column: str) -> dict[str, Any]: info = _info(kind, _path(kind, column)) if kind == "area": _refresh_labor(column) + _refresh_drawing(column) sync_combos() return info @@ -391,6 +393,7 @@ def copy_from( info = _info(kind, _path(kind, column)) if kind == "area": _refresh_labor(column) + _refresh_drawing(column) return info @@ -482,6 +485,13 @@ def _server(kind: str, doc: Any) -> Any: return doc +def _refresh_drawing(column: str) -> None: + """그 열 도면의 면적 연결표 칸 글을 지금 면적 값으로(`M02_Area_Table` · 41-1).""" + from M02_MasterTemplete import M02_Area_Table as area_table + + area_table.refresh_drawing(column) + + def _refresh_labor(column: str) -> None: """그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠.""" path = _path("labor", column) diff --git a/common_util/common_util_spreadsheet.py b/common_util/common_util_spreadsheet.py index 17044da69..68afa4218 100644 --- a/common_util/common_util_spreadsheet.py +++ b/common_util/common_util_spreadsheet.py @@ -54,3 +54,14 @@ def with_server_values(book: dict[str, Any]) -> dict[str, Any]: clean.pop("계산값", None) clean["계산값"] = recalc_workbooks([clean])[0] return clean + + +def area_tables(asks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """면적산출 시트 · 범위 → CAD 표 객체(41-1 · 변환은 번들 `areaTable` 한 벌) — 물음 차례 그대로 + `{ok, 표, 시트, 범위, 값}` 또는 `{ok: False, 까닭}`. 번들을 못 돌리면 `RecalcError`.""" + if _stale() and not build_bundle(NPM_SCRIPT): + raise RecalcError("스프레드시트 계산 번들을 못 만듦") + out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"tables": asks}) + if out is None: + raise RecalcError("스프레드시트 계산 번들 실행 실패") + return out["tables"] diff --git a/common_util/common_util_spreadsheet_node.ts b/common_util/common_util_spreadsheet_node.ts index 420f7448a..fbf7a7736 100644 --- a/common_util/common_util_spreadsheet_node.ts +++ b/common_util/common_util_spreadsheet_node.ts @@ -6,15 +6,145 @@ * 파이썬 껍데기 = `common_util_spreadsheet.py` · 번들 = `npm run build:spreadsheet`. * * 실행: node <번들> <입력.json> <출력.json> - * 입력 { books: Workbook[] } - * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 한 권이 죽어도 나머지는 풂 + * 입력 { books?: Workbook[], tables?: TableAsk[] } + * 출력 { results: ({ ok: true, 계산값 } | { ok: false, 까닭 })[], + * tables: ({ ok: true, 표, 시트, 범위, 값 } | { ok: false, 까닭 })[] } — 입력 차례 그대로 · 하나가 죽어도 나머지는 풂 + * 표(41-1) = 면적산출 시트 · 범위 → CAD 표 객체(TableEntity 꼴) — 표시 글(숫자 형식) · 병합 · 가로 정렬 · 굵게 · + * 열 너비 · 행 높이 비율 · 숨긴 행열 뺌 · 칸 key 「시트id!A1」 · style.연결 빈 글(화면이 붙임) · 저장 안 함. * 끝 코드: 0 성공 / 2 인자 오류 * ⚠ `A00_Common/spreadsheet/` 밖에 둠 — node:fs 가 브라우저 번들로 새지 않게. * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; +import { + colName, + parseRange, + rangeToA1, + rangesOverlap, + toA1, +} from "../A00_Common/spreadsheet/spreadsheet_address"; +import { cellStyleAt } from "../A00_Common/spreadsheet/spreadsheet_cellstyle"; +import { createCalcEngine } from "../A00_Common/spreadsheet/spreadsheet_graph"; +import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "../A00_Common/spreadsheet/spreadsheet_headers"; +import { formatValue } from "../A00_Common/spreadsheet/spreadsheet_numfmt"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; -import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; +import type { CellRange, HAlign, Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; + +/** 표 하나 물음 — `시트` = 시트 id 또는 이름 · `범위` = `B3:F12` · `글자크기` = 도면 단위(기본 2.5) */ +interface TableAsk { + book: Workbook; + 시트: string; + 범위: string; + 글자크기?: number; + 한도?: number; +} + +/** CAD `TableCell`(openwebcad `helpers/table-geometry.ts`) 꼴 */ +interface TableCell { + text: string; + colSpan?: number; + rowSpan?: number; + align?: "left" | "center" | "right"; + bold?: boolean; + key: string; +} + +const FONT = 2.5; +const LIMIT = 2000; +/** 엑셀 글자 폭 1 = 글자 크기 × 0.7 · 기본 행 높이(15pt) = 글자 크기 × 1.8 */ +const COL_K = 0.7; +const ROW_K = 1.8; +const ALIGN: Partial> = { + left: "left", + center: "center", + right: "right", + distributed: "center", + centerContinuous: "center", +}; + +function areaTable(ask: TableAsk) { + const book = ask.book; + const sheet = + book.시트.find((s) => s.id === ask.시트) ?? book.시트.find((s) => s.이름 === ask.시트); + if (!sheet) throw new Error(`없는 시트 「${ask.시트}」`); + const range = parseRange(ask.범위); + if (!range) throw new Error(`범위 꼴 아님 「${ask.범위}」`); + const font = ask.글자크기 && ask.글자크기 > 0 ? ask.글자크기 : FONT; + const limit = ask.한도 ?? LIMIT; + const span = (a: number, b: number): number[] => + Array.from({ length: b - a + 1 }, (_, i) => a + i); + const rowShown = (r: number): boolean => { + const info = sheet.행?.[String(r + 1)]; + return !info?.숨김 && !info?.걸러짐; + }; + const colShown = (c: number): boolean => !sheet.열?.[colName(c)]?.숨김; + if ((range.r1 - range.r0 + 1) * (range.c1 - range.c0 + 1) > limit * 4) + throw new Error(`칸이 너무 많음 — 한도 ${limit}`); // 숨김을 세기 전 큰 범위 막음 + const rows = span(range.r0, range.r1).filter(rowShown); + const cols = span(range.c0, range.c1).filter(colShown); + if (!rows.length || !cols.length) throw new Error("보이는 칸이 없음"); + if (rows.length * cols.length > limit) + throw new Error(`칸이 너무 많음 ${rows.length * cols.length} — 한도 ${limit}`); + const engine = createCalcEngine(book); + const merges: { whole: CellRange; part: CellRange }[] = []; + for (const text of sheet.병합 ?? []) { + const whole = parseRange(text); + if (!whole || !rangesOverlap(whole, range)) continue; + const part = { + r0: Math.max(whole.r0, range.r0), + c0: Math.max(whole.c0, range.c0), + r1: Math.min(whole.r1, range.r1), + c1: Math.min(whole.c1, range.c1), + }; // 범위 밖 병합은 잘라 냄 + merges.push({ whole, part }); + } + const mergeAt = (r: number, c: number) => + merges.find(({ part }) => r >= part.r0 && r <= part.r1 && c >= part.c0 && c <= part.c1); + const 값: Record = {}; + const cellOf = (r: number, c: number, at: { r: number; c: number }): TableCell => { + const style = cellStyleAt(book, sheet, at.r, at.c); + const has = sheet.칸[toA1(at.r, at.c)]; + const shown = + has && (has.값 !== undefined || has.식 !== undefined) + ? formatValue(engine.value(sheet.id, at.r, at.c), style.형식) + : { 글: "", 정렬: "left" as const }; + const key = `${sheet.id}!${toA1(r, c)}`; + 값[key] = shown.글; + const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]); + return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key }; + }; + const cells = rows.map((r) => + cols.map((c): TableCell | null => { + const merge = mergeAt(r, c); + if (!merge) return cellOf(r, c, { r, c }); + const inRows = rows.filter((x) => x >= merge.part.r0 && x <= merge.part.r1); + const inCols = cols.filter((x) => x >= merge.part.c0 && x <= merge.part.c1); + if (r !== inRows[0] || c !== inCols[0]) return null; // 앞 칸 병합에 먹힌 자리 + const cell = cellOf(r, c, { r: merge.whole.r0, c: merge.whole.c0 }); // 글 = 병합 왼위 칸 + return { + ...cell, + ...(inCols.length > 1 ? { colSpan: inCols.length } : {}), + ...(inRows.length > 1 ? { rowSpan: inRows.length } : {}), + }; + }), + ); + const colChars = sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS; + const rowPt = sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT; + const 표 = { + type: "Table", + lineWidth: 1, + shapeData: { + origin: { x: 0, y: 0 }, + columnWidths: cols.map((c) => (sheet.열?.[colName(c)]?.폭 ?? colChars) * font * COL_K), + rowHeights: rows.map( + (r) => ((sheet.행?.[String(r + 1)]?.높이 ?? rowPt) / DEFAULT_ROW_PT) * font * ROW_K, + ), + cells, + style: { fontSize: font, padding: 1, 연결: "" }, + }, + }; + return { 표, 시트: sheet.id, 범위: rangeToA1(range), 값 }; +} const [inputPath, outputPath] = process.argv.slice(2); if (!inputPath || !outputPath) { @@ -22,7 +152,10 @@ if (!inputPath || !outputPath) { process.exit(2); } -const input = JSON.parse(readFileSync(inputPath, "utf8")) as { books?: Workbook[] }; +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + books?: Workbook[]; + tables?: TableAsk[]; +}; const results = (input.books ?? []).map((book) => { try { return { ok: true, 계산값: recalcWorkbook(book) }; @@ -30,4 +163,11 @@ const results = (input.books ?? []).map((book) => { return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; } }); -writeFileSync(outputPath, JSON.stringify({ results })); +const tables = (input.tables ?? []).map((ask) => { + try { + return { ok: true, ...areaTable(ask) }; + } catch (error) { + return { ok: false, 까닭: error instanceof Error ? error.message : String(error) }; + } +}); +writeFileSync(outputPath, JSON.stringify({ results, tables })); diff --git a/resources/tester/test_m02_area_table.py b/resources/tester/test_m02_area_table.py new file mode 100644 index 000000000..0f93efab7 --- /dev/null +++ b/resources/tester/test_m02_area_table.py @@ -0,0 +1,201 @@ +"""면적산출 범위 → 도면 표(PLAN 41-1) — 변환(Node 번들 `areaTable`) · 길 · 저장 때 도면 표 맞춤. + +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 마스터 양식 · storage 는 안 씀. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_spreadsheet as sheet_node +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_Area_Table as area_table +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +SHEET = { + "id": "s1", + "이름": "면적산출", + "칸": { + "A1": {"값": "제목", "서식": 2}, + "B1": {"값": 1234.5, "서식": 1}, + "A2": {"값": "합"}, + "B2": {"식": "B1*2", "서식": 1}, + "A3": {"값": "숨김행"}, + "C1": {"값": "x"}, + }, + "병합": ["A1:A2", "C1:E5"], + "행": {"3": {"숨김": True}}, + "열": {"D": {"숨김": True}, "B": {"폭": 12}}, +} +BOOK = { + "종류": "통합문서", + "판": 1, + "열": "rv", + "서식": [{}, {"형식": "#,##0.00", "굵게": True}, {"가로": "center"}], + "시트": [SHEET], + "활성": "s1", +} + + +def _one(**ask) -> dict: + return sheet_node.area_tables([{"book": BOOK, **ask}])[0] + + +def test_표시_글_형식_굵게_정렬_칸_key(): + got = _one(시트="면적산출", 범위="A1:B2") + cells = got["표"]["shapeData"]["cells"] + assert cells[0][1] == {"text": "1,234.50", "align": "right", "bold": True, "key": "s1!B1"} + assert cells[1][1]["text"] == "2,469.00" # 식 칸 = 풀이 값에 형식 + assert cells[0][0]["align"] == "center" # 가로 서식 + assert (got["시트"], got["범위"]) == ("s1", "A1:B2") + assert got["값"]["s1!B2"] == "2,469.00" + assert got["표"]["shapeData"]["style"] == {"fontSize": 2.5, "padding": 1, "연결": ""} + + +def test_병합_잘라냄_숨긴_행열_뺌_빈_칸(): + shape = _one(시트="s1", 범위="A1:D4", 글자크기=5)["표"]["shapeData"] + cells = shape["cells"] + assert len(cells) == 3 and all(len(row) == 3 for row in cells) # 행 3 · 열 D 뺌 + assert cells[0][0]["rowSpan"] == 2 and cells[1][0] is None + assert cells[0][2]["rowSpan"] == 3 and "colSpan" not in cells[0][2] # C1:E5 → C1:C4(D 숨김) + assert cells[1][2] is None and cells[2][2] is None + assert cells[2][0] == {"text": "", "align": "left", "key": "s1!A4"} # 빈 칸 + assert shape["style"]["fontSize"] == 5 + assert shape["columnWidths"][1] == pytest.approx(12 * 5 * 0.7) # 열 폭 비율 + assert shape["rowHeights"] == [pytest.approx(5 * 1.8)] * 3 + + +@pytest.mark.parametrize( + "ask, why", + [ + ({"시트": "없음", "범위": "A1"}, "없는 시트"), + ({"시트": "s1", "범위": "가나"}, "범위 꼴 아님"), + ({"시트": "s1", "범위": "A3"}, "보이는 칸이 없음"), + ({"시트": "s1", "범위": "A1:Z200", "한도": 100}, "칸이 너무 많음"), + ({"시트": "s1", "범위": "A1:J20", "한도": 100}, "칸이 너무 많음 171"), + ], +) +def test_못_바꾸는_범위는_까닭(ask, why): + got = _one(**ask) + assert not got["ok"] and why in got["까닭"] + + +# ── 길 · 저장 때 맞춤 ────────────────────────────────────────────────── +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + (tmp_path / "storage").mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + with store.at(system): + store.create_structure("rv") + got = store.read("area", "rv") + store.write("area", "rv", got["판"], {**got["문서"], "서식": BOOK["서식"], "시트": [SHEET]}) + items_store.ensure(layers.personal_dir(7, 99)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session, "system": system} + + +URL = "/api/m02/layers/{}/area/{}/table" + + +def test_길_읽기_권한이면_표_저장_안_함_남의_층_403_없는_열_404(world): + client, system = world["client"], world["system"] + before = (system / "area" / "rv.json").read_bytes() + got = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "A1:B2"}) + assert got.status_code == 200, got.text + assert got.json()["표"]["shapeData"]["cells"][0][1]["text"] == "1,234.50" + assert (system / "area" / "rv.json").read_bytes() == before # 저장 안 함 + bad = client.post(URL.format("system", "rv"), json={"시트": "s1", "범위": "가"}) + assert bad.status_code == 400 + other = client.post( + URL.format("personal", "rv") + "?owner=7/99", json={"시트": "s1", "범위": "A1"} + ) + assert other.status_code == 403 + assert ( + client.post(URL.format("system", "없는열"), json={"시트": "s1", "범위": "A1"}).status_code + == 404 + ) + + +def _link_drawing(folder: Path) -> None: + """도면에 연결표 둘(T1 = 표 있음 · T2 = 표 없음) + 낡은 칸 글의 T1 표 하나 · 다른 표 하나.""" + with store.at(folder): + got = store.read("structure", "rv") + doc = got["문서"] + stale = { + "type": "Table", + "shapeData": { + "origin": {"x": 10, "y": 20}, + "columnWidths": [9, 9], + "rowHeights": [3, 3], + "cells": [[{"text": "낡음"}, {"text": "낡음"}], [None, {"text": "낡음"}]], + "style": {"fontSize": 2.5, "연결": "T1"}, + }, + } + other = json.loads(json.dumps(stale)) + other["shapeData"]["style"]["연결"] = "남의표" + doc["도면"]["entities"] = [stale, other] + doc["연결표"] = [ + {"연결": "T1", "시트": "s1", "범위": "A1:B2", "글자크기": 2.5, "값": {}}, + {"연결": "T2", "시트": "s1", "범위": "B1", "값": {}}, + ] + store.write("structure", "rv", got["판"], doc) + + +def test_면적_저장_뒤_도면_표_글_바뀜_자리_크기_그대로_표_없는_기록은_둠(world): + system = world["system"] + _link_drawing(system) + with store.at(system): + got = store.read("area", "rv") + got["문서"]["시트"][0]["칸"]["B1"] = {"값": 10, "서식": 1} + store.write("area", "rv", got["판"], got["문서"]) + doc = store.read("structure", "rv")["문서"] + t1, other = doc["도면"]["entities"] + shape = t1["shapeData"] + assert shape["cells"][0][1]["text"] == "10.00" and shape["cells"][1][1]["text"] == "20.00" + assert (shape["origin"], shape["columnWidths"], shape["rowHeights"]) == ( + {"x": 10, "y": 20}, + [9, 9], + [3, 3], + ) # 칸 수 같으면 크기 그대로 + assert other["shapeData"]["cells"][0][0]["text"] == "낡음" # 다른 연결 표는 안 건드림 + t1_rec, t2_rec = doc["연결표"] + assert t1_rec["값"]["s1!B2"] == "20.00" and t2_rec["값"] == {"s1!B1": "10.00"} # 표 없어도 기록 + + +def test_프로젝트_층_길도_같은_맞춤(world, tmp_path): + project = tmp_path / "project" + import shutil + + shutil.copytree(world["system"], project) # 프로젝트 작업본 꼴(같은 종류 폴더) + _link_drawing(project) + area = json.loads((project / "area" / "rv.json").read_bytes()) + area["시트"][0]["칸"]["B1"] = {"값": 7, "서식": 1} + (project / "area" / "rv.json").write_text( + json.dumps(area, ensure_ascii=False), encoding="utf-8" + ) + layers_router._refresh_drawing(project, "rv") + doc = json.loads((project / "structure" / "rv.json").read_bytes()) + assert doc["도면"]["entities"][0]["shapeData"]["cells"][0][1]["text"] == "7.00" + system_doc = json.loads((world["system"] / "structure" / "rv.json").read_bytes()) + assert "연결표" not in system_doc # 시스템 층은 그대로 + assert area_table.refresh_drawing("없는열") == [] From 4a264d961e1b30762aef1d7d2c8745ff6aa02a7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:47:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat(M02):=20=EB=8F=84=EB=A9=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EB=82=B4=EA=B8=B0=20=E2=80=94=2041-1=20?= =?UTF-8?q?=ED=91=9C=20=EB=B3=80=ED=99=98=20=EB=8B=B5=20=C2=B7=2041-2=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=20=EC=86=90=EC=9E=A1=EC=9D=B4=20=EA=BC=B4?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=A4(41-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 답 {표, 시트, 범위, 값} · 연결표 줄 = {연결, 시트, 범위, 글자크기, 값(시트!A1: 글)} · 다름 = 값 견줌 - placeTable(표, 연결) · [업데이트] = updateTable 바꾼 표만 값 갱신 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Api_Fetch.ts | 10 +- .../M02_MasterTemplete_AreaToDrawing.ts | 94 +++++++++++-------- resources/tester/test_m02_area_to_drawing.py | 22 +++-- 3 files changed, 74 insertions(+), 52 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index c3d2e182e..03b7a9d51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -135,8 +135,14 @@ export interface StructureDoc { 열: string; 도번: string; 도면: { entities: unknown[] } & Record; - /** 면적산출 → 도면 표 연결 기록(41-3 · `AreaToDrawing`) — 표 style.연결 = id · 값 = 마지막 칸 글 */ - 연결표?: { id: string; 시트: string; 범위: string; 글자크기: number; 값: (string | null)[][] }[]; + /** 면적산출 → 도면 표 연결 기록(41 · `AreaToDrawing` · `M02_Area_Table`) — 표 style.연결 = 연결 · 값 = 마지막 칸 글 */ + 연결표?: { + 연결: string; + 시트: string; + 범위: string; + 글자크기: number; + 값: Record; + }[]; } /** 시스템 층 구조물 도면 목록 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index bc2f21bf5..3f8878431 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -4,8 +4,8 @@ * ① 면적 상세 머리 [도면으로 보내기] — 고른 범위 · 저장 안 한 고침은 먼저 저장 물음 · 작은 창 글자 크기(기본 2.5) * → 서버 표 변환 길(`POST /layers/{층}/area/{열}/table` · 41-1)로 표 객체 받음 → 같은 페이지에서 그 열 * 도면 상세로 옮기며 넘김(닫힘 변수 · 캐시 · 서버 안 씀) → 도면 손잡이 `placeTable`(놓기 도구 · 41-2) - * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {id(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글)} · 표 style.연결 = id · - * 도면에서 지운 표는 기록에서 뺌 + * ② 도면 [저장] — 문서 맨 위 `연결표[]` = {연결(T1…), 시트, 범위, 글자크기, 값(마지막 칸 글 `{시트!A1: 글}`)} · + * 표 style.연결 = 연결 · 도면에서 지운 표는 기록에서 뺌(면적 [저장] 때 서버가 칸 글 · 값을 다시 적음 · 41-1) * ③ 도면을 열 때 — 연결표마다 지금 면적 값과 마지막 값이 다르면 알림 한 줄 + [업데이트](`updateTable`) * ④ 표 여러 개 — id 마다 한 줄 * ========================================================================== */ @@ -36,14 +36,21 @@ interface TableEntityJson { >; [key: string]: unknown; } -/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막으로 도면에 적힌 칸 글 · 다름 알림 기준) */ +/** 연결 기록 한 줄 — 도면 문서 맨 위 `연결표[]`(값 = 마지막 칸 글 · 다름 알림 기준) */ export type TableLink = NonNullable[number]; -type LinkSpec = Omit; +type LinkSpec = Pick; +/** 표 변환 길 답(41-1) — 시트 = 시트 id */ +interface AreaTable { + 표: TableEntityJson; + 시트: string; + 범위: string; + 값: Record; +} -/** 도면 손잡이에 더해지는 표 함수(41-2) — 없으면 단추 · 알림이 조용히 빠짐 */ +/** 도면 손잡이에 더해지는 표 함수(41-2 · `DrawingTemplateHandle`) — 없으면 놓기 · 알림이 빠짐 */ interface TableHandle { - placeTable?: (entity: TableEntityJson) => void; - updateTable?: (link: string, cells: TableEntityJson["shapeData"]["cells"]) => void; + placeTable?: (entity: Record, link: string) => void; + updateTable?: (link: string, cells: unknown[][]) => Promise; } const TEXT = { @@ -73,25 +80,22 @@ const fill = (text: string, key: string, value: string | number): string => const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const enc = encodeURIComponent; -/** 범위 → 표 객체(41-1 서버 · 저장 안 함) — 지금 보는 층 · 주인 */ -async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise { +/** 범위 → 표 객체 · 칸 글(41-1 서버 · 저장본 면적 · 저장 안 함) — 지금 보는 층 · 주인 */ +async function fetchTable(layer: Layer, column: string, spec: LinkSpec): Promise { const owner = getActiveOwner(); const q = owner && layer !== "system" && layer !== "project" ? `?owner=${enc(owner)}` : ""; const res = await fetch(`${API_BASE_URL}/m02/layers/${layer}/area/${enc(column)}/table${q}`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(spec), + body: JSON.stringify({ 시트: spec.시트, 범위: spec.범위, 글자크기: spec.글자크기 }), }); - const data = (await res.json().catch(() => ({}))) as { - detail?: unknown; - entity?: TableEntityJson; - } & Partial; - if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & Partial; + if (!res.ok || !data.표) { const d = data.detail; throw new Error(typeof d === "string" ? d : `HTTP ${res.status}`); } - return (data.entity ?? data) as TableEntityJson; + return data as AreaTable; } const isTable = (e: unknown): e is TableEntityJson => @@ -100,10 +104,11 @@ const linkOf = (e: TableEntityJson): string | null => { const id = e.shapeData?.style?.[LINK]; return typeof id === "string" && id ? id : null; }; -const textsOf = (cells: TableEntityJson["shapeData"]["cells"]): (string | null)[][] => - (cells ?? []).map((row) => (row ?? []).map((c) => (c ? String(c.text ?? "") : null))); -const sameTexts = (a: (string | null)[][], b: (string | null)[][]): boolean => - JSON.stringify(a) === JSON.stringify(b); +/** 칸 글 묶음 견줌 — 열쇠 차례와 무관 */ +const sameValues = (a: Record = {}, b: Record = {}): boolean => { + const ka = Object.keys(a); + return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]); +}; export interface AreaToDrawingOptions { /** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */ @@ -130,10 +135,10 @@ export interface AreaToDrawing { export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { let range: { 시트: string; 범위: string } | null = null; /** 면적 상세 → 도면 상세로 넘길 표(같은 열 · 같은 층일 때만 놓음) */ - let pending: { layer: Layer; column: string; entity: TableEntityJson; spec: LinkSpec } | null = - null; - /** 이 도면에서 새로 놓은 표의 연결(저장 때 기록) · 열어 둔 도면 */ - let fresh = new Map(); + let pending: { layer: Layer; column: string; got: AreaTable; 글자크기: number } | null = null; + /** 이 도면에서 새로 놓은 표 · [업데이트]로 바뀐 값(저장 때 기록) · 열어 둔 도면 */ + let fresh = new Map(); + let latest = new Map>(); let shown: string | null = null; /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ @@ -180,13 +185,13 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { const size = await askSize(picked); if (size === null) return; const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size }; - let entity: TableEntityJson; + let got: AreaTable; try { - entity = await fetchTable(at.layer, at.name, spec); + got = await fetchTable(at.layer, at.name, spec); } catch (error) { return showToast(fill(TEXT.failed, "value", why(error)), "error"); } - pending = { layer: at.layer, column: at.name, entity, spec }; + pending = { layer: at.layer, column: at.name, got, 글자크기: size }; await opt.open({ layer: at.layer, projectId: null, @@ -198,7 +203,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { /** 다음 연결 id — 기록 · 도면 표지에 없는 가장 작은 T번호 */ const nextId = (doc: StructureDoc, taken: Set): string => { - for (const row of doc.연결표 ?? []) taken.add(row.id); + for (const row of doc.연결표 ?? []) taken.add(row.연결); for (const e of doc.도면?.entities ?? []) if (isTable(e)) taken.add(linkOf(e) ?? ""); let n = 1; while (taken.has(`T${n}`)) n += 1; @@ -220,15 +225,21 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { ); if (shown !== key) return; // 그 사이 다른 화면 const stale = got.filter( - (x): x is { row: TableLink; e: TableEntityJson } => - !!x && !sameTexts(x.row.값 ?? [], textsOf(x.e.shapeData.cells)), + (x): x is { row: TableLink; e: AreaTable } => !!x && !sameValues(x.row.값, x.e.값), ); if (!stale.length) return; const update = createButton({ label: TEXT.update, variant: "filled", size: "sm" }); - update.addEventListener("click", () => { - for (const { row, e } of stale) handle.updateTable?.(row.id, e.shapeData.cells); + update.addEventListener("click", async () => { + update.disabled = true; + let n = 0; + for (const { row, e } of stale) { + const changed = (await handle.updateTable?.(row.연결, e.표.shapeData.cells)) ?? 0; + if (changed) latest.set(row.연결, e.값); + n += changed ? 1 : 0; + } + if (shown !== key) return; opt.showNotice([]); - showToast(fill(TEXT.updated, "n", stale.length), "success"); + showToast(fill(TEXT.updated, "n", n), "success"); }); opt.showNotice([fill(TEXT.stale, "n", stale.length), update]); } @@ -248,6 +259,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { const handle = raw as TableHandle; shown = `${at.layer}:${at.name}`; fresh = new Map(); + latest = new Map(); const give = pending && pending.layer === at.layer && pending.column === at.name ? pending : null; pending = null; @@ -255,24 +267,24 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { if (!handle.placeTable) showToast(TEXT.noPlace, "error"); else { const id = nextId(doc, new Set()); - const style = { ...(give.entity.shapeData.style ?? {}), [LINK]: id }; - fresh.set(id, give.spec); - handle.placeTable({ ...give.entity, shapeData: { ...give.entity.shapeData, style } }); + const { 표, 시트, 범위, 값 } = give.got; + const style = { ...(표.shapeData.style ?? {}), [LINK]: id }; + fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 }); + handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id); showToast(TEXT.place, "info"); } } void checkStale(handle, at, doc); }, withLinks: (doc) => { - const old = new Map((doc.연결표 ?? []).map((row) => [row.id, row])); + const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row])); const rows: TableLink[] = []; for (const e of doc.도면?.entities ?? []) { if (!isTable(e)) continue; const id = linkOf(e); - const spec = id ? (old.get(id) ?? fresh.get(id)) : undefined; - if (!id || !spec || rows.some((r) => r.id === id)) continue; - const { 시트, 범위, 글자크기 } = spec; - rows.push({ id, 시트, 범위, 글자크기, 값: textsOf(e.shapeData.cells) }); + const row = id ? (old.get(id) ?? fresh.get(id)) : undefined; + if (!id || !row || rows.some((r) => r.연결 === id)) continue; + rows.push({ ...row, 값: latest.get(id) ?? row.값 }); } // 기록이 없던 도면은 칸을 안 만듦(옛 문서 그대로) return rows.length || doc.연결표 ? { ...doc, 연결표: rows } : doc; diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index f7fb6c79d..b0cf649be 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -64,7 +64,8 @@ let now = [["1.5", "2"]]; const calls = []; globalThis.fetch = async (url, init) => { calls.push([url, JSON.parse(init.body)]); - return { ok: true, json: async () => table(now) }; + const b = JSON.parse(init.body); + return { ok: true, json: async () => ({ 표: table(now), 시트: "s1", 범위: b.범위, 값: { "s1!B2": now[0][0], "s1!C2": now[0][1] } }) }; }; (async () => { @@ -89,17 +90,18 @@ globalThis.fetch = async (url, init) => { // 같은 열 도면 — 기록 T1 이 있으니 새 표는 T2 const placed = []; - const handle = { placeTable: (e) => placed.push(e), updateTable: (id, c) => placed.push(["u", id, c]) }; - const old = { id: "T1", 시트: "산출", 범위: "A1:A1", 글자크기: 3, 값: [["옛"]] }; + const handle = { placeTable: (e, id) => placed.push([e, id]), updateTable: async (id, c) => (placed.push(["u", id, c]), 1) }; + const old = { 연결: "T1", 시트: "s1", 범위: "A1:A1", 글자크기: 3, 값: { "s1!A1": "옛" } }; const drawing = (ents, links) => ({ 양식: "구조물도면", 종류: "structure", 판: 1, 열: "item1", 도번: "SD-1", 도면: { entities: ents }, ...(links ? { 연결표: links } : {}) }); ad.onDrawing(handle, { ...area, kind: "structure" }, drawing([table([["옛"]], { 연결: "T1" })], [old])); await new Promise((r) => setTimeout(r, 20)); const notice = notices.at(-1) ?? []; const upd = notice.find((x) => x && x.on); - if (upd) upd.on.click(); + if (upd) await upd.on.click(); - // 저장 — T1 은 지움 · T2 만 남음 · 값 = 표 칸 글 - const saved = ad.withLinks(drawing([{ type: "Line" }, table([["9", "8"]], { 연결: "T2" })], [old])); + // 저장 — T1(업데이트로 값 바뀜) · T2 · 지운 T3 기록은 빠짐 + const t3 = { ...old, 연결: "T3" }; + const saved = ad.withLinks(drawing([{ type: "Line" }, table([["옛"]], { 연결: "T1" }), table([["9", "8"]], { 연결: "T2" })], [old, t3])); const plain = ad.withLinks(drawing([table([["x"]])])); // 다른 열 도면이면 안 놓음 const before = placed.length; @@ -136,8 +138,8 @@ def test_보냄은_표_변환_길_뒤_같은_열_도면을_엶(got): def test_놓을_표는_겹치지_않는_연결_id(got): - first = got["placed"][0] - assert first["shapeData"]["style"]["연결"] == "T2" + entity, link = got["placed"][0] + assert link == "T2" and entity["shapeData"]["style"]["연결"] == "T2" def test_다시_열면_다름_알림과_업데이트(got): @@ -147,8 +149,10 @@ def test_다시_열면_다름_알림과_업데이트(got): def test_저장_연결표는_남은_표만(got): + now = {"s1!B2": "1.5", "s1!C2": "2"} assert got["saved"] == [ - {"id": "T2", "시트": "산출", "범위": "B2:C2", "글자크기": 2.5, "값": [["9", "8"]]} + {"연결": "T1", "시트": "s1", "범위": "A1:A1", "글자크기": 3, "값": now}, # [업데이트] 값 + {"연결": "T2", "시트": "s1", "범위": "B2:C2", "글자크기": 2.5, "값": now}, ] assert got["plain"] is False From 3d6f59e494598e39117ddc7b64c6421f3d390ab1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:48:12 +0900 Subject: [PATCH 8/8] =?UTF-8?q?feat(spreadsheet):=20=EC=B9=B8=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EC=97=90=20=EC=95=8C=EC=95=BD=20=EC=97=AC=EB=9F=BF=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=EB=A5=B8=20=EC=95=8C=EC=95=BD=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20[=ED=95=A8=EA=BB=98=20=EC=9E=87=EA=B8=B0]?= =?UTF-8?q?=20=C2=B7=20[=EB=B0=94=EA=BE=B8=EA=B8=B0]=20=C2=B7=20[=EC=B7=A8?= =?UTF-8?q?=EC=86=8C]=20=EB=AC=BC=EC=9D=8C(=EC=95=8C=EC=95=BD=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EB=81=8C=EC=96=B4=20=EB=86=93=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=20=EC=A7=80=EC=A0=95=20=C2=B7=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EA=B3=A0=EB=A5=B4=EA=B8=B0)=20=C2=B7=20=EC=B9=B8?= =?UTF-8?q?=20=EC=83=89=20=3D=20=EB=A8=BC=EC=A0=80=20=EC=9D=B4=EC=9D=80=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=C2=B7=20=EC=98=A4=EB=A5=B8=EC=9C=84=20?= =?UTF-8?q?=EC=88=98=20=C2=B7=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EC=98=AC?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=EC=9D=B4=EB=A6=84=EB=93=A4=20=C2=B7=20lin?= =?UTF-8?q?kAreaCell=20=ED=95=A8=EA=BB=98=20=EA=B8=B0=EB=B3=B8=20(PLAN=204?= =?UTF-8?q?0=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet.ts | 8 +- .../spreadsheet/spreadsheet_link_ask.ts | 65 ++++++++++++++ .../spreadsheet/spreadsheet_link_set.ts | 31 +++++-- A00_Common/spreadsheet/spreadsheet_links.css | 18 ++++ A00_Common/spreadsheet/spreadsheet_links.ts | 87 ++++++++++++------- .../M02_MasterTemplete_AreaCellPick.ts | 35 ++++++-- .../M02_MasterTemplete_AreaLink.ts | 24 +++-- .../spreadsheet/spreadsheet_link_set_entry.ts | 34 +++++++- resources/tester/test_m02_area_panel.py | 11 ++- resources/tester/test_spreadsheet_set_link.py | 18 ++-- 10 files changed, 264 insertions(+), 67 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_ask.ts diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 54cd49c06..dfb65ccc5 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -79,8 +79,8 @@ export interface SpreadsheetHandle { /** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */ getLinkedValues(): Record; /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · - * 주소가 틀리거나 읽기만이면 false */ - setLink(key: string, cell: string | null, info?: LinkInfo): boolean; + * 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean; destroy(): void; } @@ -295,9 +295,9 @@ export function createSpreadsheet( } return out; }, - setLink(key, cell, info) { + setLink(key, cell, info, replace) { if (readOnly) return false; - const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace); if (command === false) return false; if (command) dispatch(command); loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 diff --git a/A00_Common/spreadsheet/spreadsheet_link_ask.ts b/A00_Common/spreadsheet/spreadsheet_link_ask.ts new file mode 100644 index 000000000..ab83067d1 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_ask.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * spreadsheet_link_ask.ts (주인 D) + * 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리). + * 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 · + * 모양은 공용 확인창(`ui-confirm`) 그대로. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; + +const TEXT = { + ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?", + together: "함께 잇기", + replace: "바꾸기", + cancel: "취소", +}; + +/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */ +export type ShareChoice = "함께" | "바꾸기" | null; + +export function askShare(cell: string, names: string[]): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (choice: ShareChoice): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve(choice); + }; + const together = createButton({ + label: TEXT.together, + variant: "filled", + onClick: () => done("함께"), + }); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done(null); + }); + const message = TEXT.ask + .replace("{cell}", cell) + .replace("{names}", names.map((n) => `「${n}」`).join(" · ")); + overlay.append( + el("div", { + className: "ui-confirm__panel", + children: [ + el("p", { className: "ui-confirm__message", text: message }), + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }), + createButton({ + label: TEXT.replace, + variant: "ghost", + onClick: () => done("바꾸기"), + }), + together, + ], + }), + ], + }), + ); + document.body.append(overlay); + together.focus(); // Enter = 함께 잇기(기본) + }); +} diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 65822f7be..0a2db64b1 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -1,8 +1,8 @@ /* ============================================================================= * spreadsheet_link_set.ts (주인 D) * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). - * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · - * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러 + * 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). * ========================================================================== */ import { parseA1, toA1 } from "./spreadsheet_address"; @@ -15,13 +15,28 @@ export interface LinkInfo { 자재?: LinkMaterial; } -/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */ +export function normA1(cell: string): string | null { + const at = parseA1(cell.trim().toUpperCase()); + return at ? toA1(at.r, at.c) : null; +} + +/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */ +export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] { + const a1 = normA1(cell); + const sheet = book.시트.find((s) => s.id === sheetId); + return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key); +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false · + * `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */ export function linkCommand( book: Workbook, sheetId: string, key: string, cell: string | null, info: LinkInfo = {}, + replace = false, ): Command | null | false { const at = cell === null ? null : parseA1(cell.trim().toUpperCase()); if (cell !== null && !at) return false; @@ -29,7 +44,8 @@ export function linkCommand( const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key); const home = book.시트.find((s) => s.id === sheetId); if (a1 && !home) return false; - if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 + const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0; + if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null; if (one) { const 이름 = info.이름 ?? old?.이름; @@ -42,7 +58,7 @@ export function linkCommand( const commands: Command[] = []; for (const s of book.시트) { const was = s.연결 ?? []; - const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1)); + const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1)); const next = s === home && one ? [...rest, one] : rest; if (next.length === was.length && next.every((l, i) => l === was[i])) continue; const copy = structuredClone(s); @@ -55,16 +71,17 @@ export function linkCommand( } /** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · - * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ export function linkAreaCell( book: T, key: string, cell: string | null, info: LinkInfo = {}, + replace = false, ): T | null { const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0]; if (!sheet) return null; - const command = linkCommand(book, sheet.id, key, cell, info); + const command = linkCommand(book, sheet.id, key, cell, info, replace); if (command === false) return null; const next = structuredClone(book); const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index edecb144f..01a82b2c9 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -177,3 +177,21 @@ opacity: 0; } } + +/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */ +.ss-link-count { + position: absolute; + z-index: 3; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); + color: var(--color-canvas); + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + transform: translate(-100%, 0); + pointer-events: none; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 45e9a1572..978a9c8b4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -1,9 +1,11 @@ /* ============================================================================= * spreadsheet_links.ts (주인 D) * 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). - * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(놓은 칸에 다른 알약이 있으면 - * 바꿀지 물음 · 40-3) · 이은 알약 = 키로 정해지는 색(`spreadsheet_link_color`) · 이은 칸 배경 같은 색 옅게 + - * 칸 왼아래 작은 세모 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. + * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 — + * 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는 + * 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 · + * 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · + * 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. * 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] · * 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음). * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 @@ -12,10 +14,12 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; +import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; +import { linkCommand, linksAt } from "./spreadsheet_link_set"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -59,8 +63,6 @@ export interface Links extends PartHandle { const TEXT = { bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기", free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결", - swap: "{cell} 은 이미 「{name}」 알약 칸 — 이 알약으로 바꿈?", - swapOk: "바꿈", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", remove: "목록에서 빼기", @@ -160,16 +162,22 @@ export function mountLinks( ctx.dispatch({ 종류: "시트통째", 시트: next }); } - /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 */ - function link(item: LinkItem, at?: string): void { + /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */ + async function link(item: LinkItem, at?: string): Promise { if (ctx.readOnly) return; const sheet = ctx.sheet(); const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c); - const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키); - const one: CellLink = { 키: item.키, 이름: item.이름, 칸 }; - if (item.단위) one.단위 = item.단위; - if (item.자재) one.자재 = item.자재; - put(sheet, [...rest, one]); + const others = linksAt(ctx.book, sheet.id, 칸, item.키); + const choice = others.length + ? await askShare( + 칸, + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice) return back(); + const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 }; + const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기"); + if (command) ctx.dispatch(command); back(); } @@ -224,7 +232,7 @@ export function mountLinks( }); // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); - b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item))); + b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); const unlink = (): void => { if (hit) put( @@ -317,22 +325,46 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); - for (const l of ctx.sheet().연결 ?? []) { - const p = parseA1(l.칸); + // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 + const byCell = new Map(); + for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); + for (const [a1, list] of byCell) { + const p = parseA1(a1); if (!p) continue; const { layer, box } = slot(ctx, p.r, p.c); if (!box.w || !box.h) continue; // 숨긴 행열 - const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": l.키 } }); + const first = list[0]; + const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } }); tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; const dot = el("div", { className: "ss-link-dot" }); dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; - for (const d of [tint, dot]) d.style.setProperty("--ss-link-h", String(hues.get(l.키) ?? 0)); - layer.append(tint, dot); - dots.push(tint, dot); + const marks = [tint, dot]; + if (list.length > 1) { + const count = el("div", { className: "ss-link-count", text: String(list.length) }); + count.style.left = `${box.x + box.w}px`; + count.style.top = `${box.y}px`; + marks.push(count); + } + for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0)); + layer.append(...marks); + dots.push(...marks); } } + /** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */ + let tipped = false; + const onHover = (e: MouseEvent): void => { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null; + const names = a1 + ? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키) + : []; + if (names.length) ctx.grid.root.title = names.join(" · "); + else if (tipped) ctx.grid.root.removeAttribute("title"); + tipped = names.length > 0; + }; + // ── 알약 끌어 칸에 놓기(40-3) ────────────────────────────────────────── /** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */ function cellAt(e: DragEvent): { r: number; c: number } | null { @@ -358,19 +390,8 @@ export function mountLinks( dragging = null; if (!item || !at) return; e.preventDefault(); - const 칸 = toA1(at.r, at.c); - const other = (ctx.sheet().연결 ?? []).find((l) => l.칸 === 칸 && l.키 !== item.키); - const name = other?.이름 ?? other?.키 ?? ""; - if ( - other && - !(await showConfirmDialog( - TEXT.swap.replace("{cell}", 칸).replace("{name}", name), - TEXT.swapOk, - )) - ) - return back(); ctx.select(selectCell(ctx.sheet(), at.r, at.c), true); - link(item, 칸); + await link(item, toA1(at.r, at.c)); }; // ── 자재 고르기 창 ────────────────────────────────────────────────────── @@ -457,6 +478,7 @@ export function mountLinks( ctx.grid.root.addEventListener("dragover", onDragOver); ctx.grid.root.addEventListener("dragleave", onDragLeave); ctx.grid.root.addEventListener("drop", onDropEvent); + ctx.grid.root.addEventListener("mousemove", onHover); return { root: opts ? bar : null, @@ -470,6 +492,7 @@ export function mountLinks( ctx.grid.root.removeEventListener("dragover", onDragOver); ctx.grid.root.removeEventListener("dragleave", onDragLeave); ctx.grid.root.removeEventListener("drop", onDropEvent); + ctx.grid.root.removeEventListener("mousemove", onHover); dropMark.remove(); closePick(); for (const d of dots) d.remove(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts index 8012ac0e6..712cdba99 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -5,10 +5,13 @@ * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; +import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import "./M02_MasterTemplete_AreaPanel.css"; @@ -40,8 +43,9 @@ export interface AreaCellPickOptions { label: string; } -/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ -export type AreaCellPick = { 칸: string | null } | null; +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) · + * `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ +export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null; export function pickAreaCell(opts: AreaCellPickOptions): Promise { return new Promise((resolve) => { @@ -76,10 +80,24 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), host, ); - const choose = (): void => (finish({ 칸: picked }), close()); + let book: Workbook | null = null; + const choose = async (): Promise => { + const at = picked; + const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id; + const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : []; + const choice = others.length + ? await askShare( + at, + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice || done) return; + finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at }); + close(); + }; void (async () => { try { - const [{ createSpreadsheet }, book] = await Promise.all([ + const [{ createSpreadsheet }, got] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), opts.doc ? Promise.resolve(opts.doc) @@ -88,10 +106,11 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { ), ]); if (done) return; - const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; + book = got; + const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; head.textContent = at ? fill(TEXT.now, at) : TEXT.none; unlink.hidden = !at; - handle = createSpreadsheet(host, structuredClone(book), { + handle = createSpreadsheet(host, structuredClone(got), { readOnly: true, tabs: false, onSelect: (s) => { @@ -103,9 +122,9 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { const grid = host.querySelector(".ss-grid-host"); grid?.addEventListener("mousedown", () => (moved = false), true); grid?.addEventListener("mouseup", (e) => { - if ((e as MouseEvent).button === 0 && moved) choose(); + if ((e as MouseEvent).button === 0 && moved) void choose(); }); - host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + host.addEventListener("keydown", (e) => e.key === "Enter" && void choose()); } catch (error) { if (done) return; head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 6ba71c3c2..7219678a7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -8,6 +8,8 @@ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; +import { linksAt, normA1 } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; const TEXT = { @@ -68,8 +70,21 @@ export function openAreaLink(opts: AreaLinkOptions): void { now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; unlink.disabled = opts.readOnly || !text; }; - const done = (cell: string | null): boolean => { - if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + /** 잇기 · 풀기 — 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음(취소 = 모달 그대로) · 틀린 주소 false */ + const done = async (cell: string | null): Promise => { + const a1 = cell === null ? null : normA1(cell); + if (cell !== null && !a1) return false; + const book = opts.sheet.getDoc(); + const sheetId = (book.시트.find((s) => s.id === book.활성) ?? book.시트[0])?.id ?? ""; + const others = a1 ? linksAt(book, sheetId, a1, opts.key) : []; + const choice = others.length + ? await askShare( + a1 ?? "", + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice) return true; + if (!opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")) return false; close(); return true; }; @@ -85,9 +100,8 @@ export function openAreaLink(opts: AreaLinkOptions): void { label: TEXT.link, variant: "ghost", size: "sm", - onClick: () => { - if (!done(address.input.value)) address.setError(TEXT.bad); - }, + onClick: () => + void done(address.input.value).then((ok) => ok || address.setError(TEXT.bad)), }); const unlink = createButton({ label: TEXT.unlink, diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index a2ba19fb1..45acbf3be 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -2,7 +2,11 @@ * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color"; -import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { + linkAreaCell, + linkCommand, + linksAt, +} from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -58,10 +62,34 @@ b = book(); run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); -// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +// 같은 칸에 다른 키 — 기본 = 함께(칸 하나에 알약 여럿 · 40 마무리) · 바꾸기 = 그 칸 옛 키 빠짐 b = book(); run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); -eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); +eq("칸 하나 알약 여럿", b.시트[0].연결, [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }, +]); +eq( + "그 칸 다른 알약", + linksAt(b, "s1", " b6 ", "새|m").map((l) => l.키), + ["깔기|㎡"], +); +b = book(); +const undoSwap = run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }, true)); +eq("바꾸기", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); +run(b, undoSwap); +eq("바꾸기 되돌림", links(b), links(book())); +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6")); +eq("함께 둔 칸에 바꾸기 = 명령 있음", !!linkCommand(b, "s1", "새|m", "B6", {}, true), true); +eq( + "면적 문서 함께 · 바꾸기", + [ + linkAreaCell(book(), "새|m", "B6")!.시트[0].연결!.length, + linkAreaCell(book(), "새|m", "B6", {}, true)!.시트[0].연결!.length, + ], + [2, 1], +); // 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 b = book(); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index d9a79db7c..e53925a02 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -42,7 +42,10 @@ def test_패널_몸은_보기_갈래_줄_누름은_칸_지정(): def test_칸_지정_모달은_setLink_만(): link = _text("M02_MasterTemplete_AreaLink.ts") - assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link + assert 'opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")' in link + assert ( + "linksAt(book, sheetId, a1, opts.key)" in link and "askShare(" in link + ) # 칸 하나 알약 여럿 assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link @@ -95,7 +98,9 @@ def test_엑셀_칸_고르기_모달_부품(): pick = _text("M02_MasterTemplete_AreaCellPick.ts") assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick - assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick - assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick + assert "finish({ 칸: null })" in pick + assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick + assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick assert "handle?.destroy();" in pick assert "saveTemplate" not in pick diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 554ca3aa9..fbb65186c 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,15 +55,18 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 24 + assert result["pass"] == 29 def test_손잡이_setLink_몸(): src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") - assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src - body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert ( + "setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;" + in src + ) + body = src.split(" setLink(key, cell, info, replace) {")[1].split(" destroy() {")[0] assert "if (readOnly) return false;" in body - assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace)" in body assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) assert "loadLinks();" in body @@ -72,7 +75,12 @@ def test_알약_끌어_놓기_색(): """40-3 ② ③ — 알약 끌어 칸에 놓기(다른 알약 칸이면 물음) · 이은 알약 · 칸 = 키 색 · 두 테마 밝기.""" src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8") assert "b.draggable = !ctx.readOnly;" in src and 'addEventListener("drop", onDropEvent)' in src - assert "showConfirmDialog(" in src and "link(item, 칸);" in src + assert "await link(item, toA1(at.r, at.c));" in src + # 칸 하나에 알약 여럿 — 다른 알약 칸이면 함께 · 바꾸기 · 취소 물음(알약 누름 · 끌어 놓기 한 길) + body = src.split("async function link(")[1].split("\n }\n")[0] + assert "linksAt(ctx.book, sheet.id, 칸, item.키)" in body and "askShare(" in body + assert 'choice === "바꾸기"' in body + assert 'className: "ss-link-count"' in src and 'ctx.grid.root.title = names.join(" · ")' in src assert 'if (!b.draggable) b.addEventListener("mousedown"' in src # 끌기 막지 않음 assert "const hues = linkHues(found().keys());" in src and 'className: "ss-link-cell"' in src css = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.css").read_text(