From 46f8b7611d7c92b9867a5ff14ab039f71aeb0e1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:17:58 +0900 Subject: [PATCH 01/46] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B3=B4=EA=B8=B0=20=EA=B0=88=EB=9E=98=20=EC=A2=81?= =?UTF-8?q?=EC=9D=80=20=EC=B9=B8=20=E2=80=94=20=ED=91=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=A4=84=EB=A7=88=EB=8B=A4=20=EC=B9=B4=EB=93=9C=EB=A1=9C(?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=20=EB=84=B7=20=ED=95=9C=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B8=20=EC=9D=B4=EB=A6=84=20=EC=9C=84=EC=97=90=20=EC=9E=91?= =?UTF-8?q?=EA=B2=8C=20=C2=B7=20=ED=95=A9=EA=B3=84=20=EA=B5=B5=EA=B2=8C)?= =?UTF-8?q?=20=C2=B7=20=EA=B0=80=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 컨테이너 쿼리(.m02-labview 640px 이하)만 · 넓은 상세 표는 그대로 - Labor_Rows: 금액 · 수 칸에 data-label · 차례 · 단위 · 코드 · 규격 칸 이름표 - 확인: 면적 패널 320 · 420 · 560 에서 scrollWidth = clientWidth(담은 로직 · 자재목록) · 밝음 · 어두움 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 89 +++++++++++++++++++ .../M02_MasterTemplete_Labor_Rows.ts | 45 +++++++--- 2 files changed, 120 insertions(+), 14 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 2ddad1ccc..8d18b5f90 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -122,3 +122,92 @@ margin: 0; font-size: var(--text-body-sm); } + +/* 보기 갈래가 좁으면(면적산출 오른쪽 패널 등) 표를 줄마다 카드로 — 가로 스크롤 없이. + 줄 = 이름 한 줄(차례 · 단위 곁) → 금액 넷 한 줄(칸 이름은 `data-label` · 합계만 굵게) · + 자재 = 코드 · 이름 · 규격 한 줄 → 단위 · 수량 · 기준 단가 · 반영 금액 한 줄. 넓은 상세는 그대로 */ +.m02-labview { + container: m02-labview / inline-size; +} + +@container m02-labview (max-width: 640px) { + .m02-labrow, + .m02-labrow tbody, + .m02-labrow tfoot { + display: block; + width: 100%; + } + + .m02-labrow thead { + display: none; + } + + .m02-labrow tr { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 2px var(--spacing-8); + padding: var(--spacing-8) 0; + border-bottom: 1px solid var(--color-border); + } + + .m02-labrow td { + display: block; + min-width: 0; + padding: 0; + border: 0; + overflow-wrap: anywhere; + } + + /* 이름 줄 — 차례 · 이름 · 결과 단위(자재 = 코드 · 이름 · 규격) */ + .m02-labrow .m02-labrow__name { + display: flex; + flex-direction: column; + grid-column: 1 / -1; + order: 1; + min-width: 0; + } + + .m02-labrow .m02-labrow__no, + .m02-labrow .m02-labrow__unit:not([data-label]), + .m02-labrow .m02-labrow__code, + .m02-labrow .m02-labrow__spec { + grid-column: span 2; + color: var(--color-text-secondary); + font-size: var(--text-caption); + } + + .m02-labrow .m02-labrow__unit:not([data-label]), + .m02-labrow .m02-labrow__spec { + text-align: right; + } + + .m02-labrow__sum .m02-labrow__no, + .m02-labrow__sum .m02-labrow__unit { + display: none; + } + + /* 금액 · 수 칸 — 칸 이름을 위에 작게 */ + .m02-labrow [data-label] { + order: 2; + text-align: right; + white-space: normal; + font-size: var(--text-caption); + } + + .m02-labrow [data-label]::before { + content: attr(data-label); + display: block; + color: var(--color-text-secondary); + font-size: var(--text-caption); + } + + .m02-labrow .m02-labrow__money:last-of-type .m02-labrow__applied { + font-weight: 700; + } + + .m02-labrow__move { + grid-column: 1 / -1; + order: 3; + text-align: right; + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 9e3bc3fbe..df7c2515d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -46,16 +46,18 @@ export function qtyText( return `${tc("Labor_Qty")} ${cell} = ${v}`; } -/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 */ +/** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, applied: number | null | undefined, wait: boolean, + label: string, ): HTMLElement { const show = (v: number | null | undefined): string => wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); return el("td", { className: "m02-labrow__money", + attrs: { "data-label": label }, children: [ el("small", { className: "m02-labrow__base", @@ -134,7 +136,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement className: on ? "m02-labrow__row is-open" : "m02-labrow__row", attrs: { "data-labor-logic": row.로직 }, children: [ - el("td", { text: String(i + 1) }), + el("td", { className: "m02-labrow__no", text: String(i + 1) }), el("td", { className: "m02-labrow__name", children: [ @@ -160,8 +162,8 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []), ], }), - el("td", { text: unit }), - ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait)), + el("td", { className: "m02-labrow__unit", text: unit }), + ...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))), ...(move && remove ? [ el("td", { @@ -186,11 +188,11 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement el("tr", { className: "m02-labrow__sum", children: [ - el("td", { text: "" }), - el("td", { text: tc("Prev_Sum") }), - el("td", { text: "" }), + el("td", { className: "m02-labrow__no", text: "" }), + el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { className: "m02-labrow__unit", text: "" }), ...COSTS.map((c) => - moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait), + moneyCell(sum(data.calc?.기준합, c), sum(data.calc?.반영합, c), wait, tc(HEAD[c])), ), ...(tools ? [el("td", { text: "" })] : []), ], @@ -229,7 +231,7 @@ export function matTable(data: MatTableData, on?: Omit): HT className: on ? "m02-labrow__row is-open" : "m02-labrow__row", attrs: { "data-labor-mat": mat.코드 }, children: [ - el("td", { text: mat.코드 }), + el("td", { className: "m02-labrow__code", text: mat.코드 }), el("td", { className: "m02-labrow__name", children: [ @@ -239,15 +241,30 @@ export function matTable(data: MatTableData, on?: Omit): HT : []), ], }), - el("td", { text: mat.규격 ?? "" }), - el("td", { text: mat.단위 ?? "" }), + el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }), + el("td", { + className: "m02-labrow__unit", + text: mat.단위 ?? "", + attrs: { "data-label": tc("Labor_Unit") }, + }), el("td", { className: "m02-labrow__num", text: qty, - attrs: { title: qtyText(line, mat.단위 ?? "", data.cells) }, + attrs: { + title: qtyText(line, mat.단위 ?? "", data.cells), + "data-label": tc("Labor_Qty"), + }, + }), + el("td", { + className: "m02-labrow__num", + text: show(line?.기준단가), + attrs: { "data-label": tc("Labor_BasePrice") }, + }), + el("td", { + className: "m02-labrow__num", + text: show(line?.반영), + attrs: { "data-label": tc("Labor_AppliedSum") }, }), - el("td", { className: "m02-labrow__num", text: show(line?.기준단가) }), - el("td", { className: "m02-labrow__num", text: show(line?.반영) }), ...(remove ? [ el("td", { From c535de7b315193cdb67f65231e934065f6531acf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:00 +0900 Subject: [PATCH 02/46] =?UTF-8?q?fix(M02):=20=EB=A9=B4=EC=A0=81=20?= =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=ED=8C=A8=EB=84=90=20=EB=B9=88=20?= =?UTF-8?q?=EC=B9=B8=20=3D=20=EC=88=98=EB=9F=89=20=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=84=9C=EB=B2=84=20area=5Fvalues=20=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EA=B7=9C=EC=B9=99(=EC=8B=9D=20=EC=B9=B8?= =?UTF-8?q?=20=3D=20=EA=B3=84=EC=82=B0=20=EC=88=98=20=C2=B7=20=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=96=20=3D=20=EC=B9=B8=20=EA=B0=92=20=C2=B7=20=EB=B9=88=20?= =?UTF-8?q?=C2=B7=20=EA=B8=80=20=EB=B9=A0=EC=A7=90)=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=88=98=EB=9F=89=20A1=20=3D=20=EB=B9=88=20=EC=B9=B8=E3=80=8D?= =?UTF-8?q?=20=C2=B7=20=EB=B0=98=EC=98=81=20=E2=80=94=20(PLAN=2039-8=20?= =?UTF-8?q?=E2=91=A1)?= 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 | 6 ++- .../M02_MasterTemplete_AreaLink.ts | 2 + .../M02_MasterTemplete_AreaPanel.ts | 24 ++++++++--- .../M02_MasterTemplete_Labor_Rows.ts | 9 +++- .../M02_MasterTemplete_Labor_Text.ts | 1 + resources/tester/test_m02_area_panel.py | 41 +++++++++++++++++++ resources/tester/test_m02_labor_values.py | 2 + 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index d452f6b95..54cd49c06 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -76,7 +76,7 @@ export interface SpreadsheetHandle { setDoc(book: Workbook): void; /** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */ recalc(): CalcValues; - /** 알약 키 → 연결 칸 값(빈 칸 0) */ + /** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */ getLinkedValues(): Record; /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · * 주소가 틀리거나 읽기만이면 false */ @@ -289,7 +289,9 @@ export function createSpreadsheet( for (const s of ctx.book.시트) for (const l of s.연결 ?? []) { const p = parseA1(l.칸); - if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); + const cell = s.칸[l.칸]; + const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === ""); + if (p && !empty) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); } return out; }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 3f1df113f..6ba71c3c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -15,6 +15,7 @@ const TEXT = { close: "닫기", now: "지금 연결", none: "연결 없음", + empty: "빈 칸", picked: "지금 고른 칸으로 ({value})", address: "칸 주소", hint: "예 B6", @@ -51,6 +52,7 @@ function linkedText(opts: AreaLinkOptions): string | null { .find((l) => l.키 === opts.key); if (!link) return null; const value = opts.sheet.getLinkedValues()[opts.key]; + if (!value) return `${link.칸} = ${TEXT.empty}`; return [link.칸, "=", cellText(value), opts.info.단위 ?? ""].join(" ").trim(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts index 39c55433d..37c68493a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.ts @@ -47,13 +47,27 @@ export interface AreaPanel { destroy: () => void; } -/** 시트 알약 값 → 수만(`{키: 수}`) — 수 아닌 칸은 빠짐(「수량 없음」) */ +/** 칸 값 → 수(쉼표 뗌) — 빈 글 · 글 · 참거짓은 null(서버 `M02_Labor_Doc._number` 와 같음) */ +function toNumber(raw: unknown): number | null { + if (raw === null || raw === undefined || typeof raw === "boolean") return null; + const text = String(raw).replace(/,/g, "").trim(); + const n = Number(text); + return text !== "" && Number.isFinite(n) ? n : null; +} + +/** 시트 알약 값 → `{키: 수}` — 서버 `M02_Labor_Doc.area_values` 와 같은 규칙(짝): 식 칸 = 계산 값의 수 · + * 그 밖 = 칸 값 · 빈 칸 · 글은 빠짐(「수량 없음」 · 반영 「—」) */ function quantities(sheet: SpreadsheetHandle | null): Record { const out: Record = {}; - for (const [key, v] of Object.entries(sheet?.getLinkedValues() ?? {})) { - const n = "수" in v ? Number(v.수) : NaN; - if (Number.isFinite(n)) out[key] = n; - } + if (!sheet) return out; + const values = sheet.getLinkedValues(); + for (const s of sheet.getDoc().시트) + for (const l of s.연결 ?? []) { + const cell = s.칸[l.칸]; + const v = values[l.키]; + const n = toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값); + if (n !== null) out[l.키] = n; + } return out; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 9e3bc3fbe..a3c3e8484 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -33,13 +33,18 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); -/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · 없으면 빈 글 */ +/** 수량 글 — 「수량 손 3 개소」 · 「수량 B6 = 12.5 ㎡」(칸 = 그 알약을 이은 면적산출 칸) · + * 이은 칸이 비었으면 「수량 A1 = 빈 칸」(수량 없음 · 서버와 같음) · 아니면 빈 글 */ export function qtyText( line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, cells: ReadonlyMap = new Map(), ): string { - if (!line || line.수량 === null || line.수량 === undefined) return ""; + if (!line) return ""; + if (line.수량 === null || line.수량 === undefined) { + const at = line.알약 ? cells.get(line.알약) : undefined; + return at ? `${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}` : ""; + } const v = `${num(line.수량)}${unit ? ` ${unit}` : ""}`; if (line.수량출처 === "손") return `${tc("Labor_Qty")} ${tc("In_QtyHand")} ${v}`; const cell = (line.알약 && cells.get(line.알약)) || tc("In_QtyCell"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index cba1e88bb..31ecc6c84 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -75,6 +75,7 @@ const TEXT = { In_Memo: ["메모", "Memo"], In_Qty: ["수량", "Quantity"], In_QtyCell: ["면적산출 칸", "Area sheet cell"], + In_QtyEmpty: ["빈 칸", "empty cell"], In_QtyHand: ["손 입력", "Manual"], In_QtyLinked: ["이은 칸 {value}", "Linked cell {value}"], In_QtyNoLink: ["이은 칸 없음 — 면적산출에서 알약을 칸에 이음", "No linked cell"], diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index 2b8e56228..fb81c0d7a 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -47,3 +47,44 @@ def test_칸_지정_모달은_setLink_만(): assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link assert "saveTemplate" not in link # 저장은 [저장] + + +def test_빈_칸은_수량_없음_서버와_같음(): + """39-8 ② — 빈 칸에 이은 알약 = 수량 없음(반영 「—」 · 「수량 A1 = 빈 칸」) · 0 칸 = 0 · 값 칸 = 값.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + doc = { + "시트": [ + { + "id": "s1", + "칸": {"A2": {"값": 0}, "A3": {"값": "1,250"}, "A4": {"값": "글"}}, + "연결": [ + {"키": k, "칸": c} + for k, c in (("빈", "A1"), ("영", "A2"), ("값", "A3"), ("글", "A4")) + ], + } + ] + } + assert {k: float(v) for k, v in labor.area_values(doc).items()} == {"영": 0, "값": 1250} + panel = _text("M02_MasterTemplete_AreaPanel.ts") + body = panel.split("function quantities")[1].split("\n}\n")[0] + assert 'toNumber(cell?.식 !== undefined ? (v && "수" in v ? v.수 : null) : cell?.값)' in body + assert 'String(raw).replace(/,/g, "").trim()' in panel and 'typeof raw === "boolean"' in panel + sheet = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") + assert 'const empty = !cell || (cell.식 === undefined && (cell.값 ?? "") === "");' in sheet + rows = _text("M02_MasterTemplete_Labor_Rows.ts") + assert '${tc("Labor_Qty")} ${at} = ${tc("In_QtyEmpty")}' in rows + assert "if (!value) return `${link.칸} = ${TEXT.empty}`;" in _text( + "M02_MasterTemplete_AreaLink.ts" + ) + + +def test_카드_알약_취향은_계정_받은_뒤_올림(): + """39-8 ③ — 화면이 뜰 때 브라우저 옛 값이 계정 값을 덮지 않게 · 카드는 열 때마다 취향을 다시 읽음.""" + state = (ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + push = state.split("function pushUiPrefs")[1].split("export function syncUiPrefs")[0] + assert "void (prefsPull ?? Promise.resolve())" in push and "prefs: localPrefs()" in push + assert "prefsPull = pullUiPrefs();" in state + cards = _text("M02_MasterTemplete_ItemCards.ts") + load = cards.split("async function load(")[1] + assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 2d96b1d94..5915821cb 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -131,6 +131,7 @@ console.log(JSON.stringify([ m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"), m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells), m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyText({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡"), ])); """ @@ -148,5 +149,6 @@ def test_수량_글은_이은_칸_주소를_보임() -> None: "수량 B6 = 20 ㎡", "수량 면적산출 칸 = 20 ㎡", "수량 손 입력 3 ㎡", + "수량 B6 = 빈 칸", # 이은 칸이 비었음 = 수량 없음(39-8 ② · 서버 area_values 와 같음) "", ] From 88ce573e6301766fed71dcc83be8b428938eea90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:01 +0900 Subject: [PATCH 03/46] =?UTF-8?q?fix(M02):=20=EC=B9=B4=EB=93=9C=20[?= =?UTF-8?q?=EC=95=8C=EC=95=BD]=20=EC=B7=A8=ED=96=A5=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=EB=94=B0=EB=A6=84=20=E2=80=94=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=B7=A8=ED=96=A5=20=EB=B0=9B=EC=9D=80=20=EB=92=A4=EC=97=90=20?= =?UTF-8?q?=EC=98=AC=EB=A6=BC(=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EC=98=9B=20=EA=B0=92=EC=9D=B4=20=EB=8D=AE=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C)=20=C2=B7=20=EC=B9=B4=EB=93=9C=EB=8A=94=20=EC=97=B4?= =?UTF-8?q?=20=EB=95=8C=EB=A7=88=EB=8B=A4=20=EB=8B=A4=EC=8B=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=9D=8C=20(PLAN=2039-8=20=E2=91=A2)?= 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/b_page_state.ts | 27 +++++++++++++------ .../M02_MasterTemplete_ItemCards.ts | 1 + 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 3e3f493ea..2c411ffe8 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -299,6 +299,8 @@ const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs"; const PREFS_PUSH_DELAY_MS = 1500; let applyingServerPrefs = false; let prefsPushTimer: ReturnType | null = null; +/** 계정 취향 받기 — 받기 전에 올리면 이 브라우저 옛 값이 계정 값을 덮으므로 올리기는 이것 뒤에 */ +let prefsPull: Promise | null = null; function localPrefs(): Record { const out: Record = {}; @@ -320,17 +322,26 @@ function pushUiPrefs(): void { if (prefsPushTimer) clearTimeout(prefsPushTimer); prefsPushTimer = setTimeout(() => { prefsPushTimer = null; - void fetch(PREFS_ENDPOINT, { - method: "PUT", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prefs: localPrefs() }), - }).catch(() => undefined); + void (prefsPull ?? Promise.resolve()) + .then(() => + fetch(PREFS_ENDPOINT, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prefs: localPrefs() }), + }), + ) + .catch(() => undefined); }, PREFS_PUSH_DELAY_MS); } -/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */ -export async function syncUiPrefs(): Promise { +/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번(올리기는 받은 뒤로 미룸). */ +export function syncUiPrefs(): Promise { + prefsPull = pullUiPrefs(); + return prefsPull; +} + +async function pullUiPrefs(): Promise { let stored: Record = {}; try { const response = await fetch(PREFS_ENDPOINT, { credentials: "include" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 1a3faea5d..deb7368db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -478,6 +478,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { return; } root.hidden = false; + paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름 if (col.id !== column) started.clear(); column = col.id; label = col.label; From 68f1c20e65fb7c4605e3b5feee7f24faa96948f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:22:09 +0900 Subject: [PATCH 04/46] =?UTF-8?q?fix(M01):=20=EC=9E=90=EC=9E=AC=20=C2=B7?= =?UTF-8?q?=20=EC=A7=81=EC=A2=85=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EB=82=B1=EB=A7=90=EC=9D=B4=20=ED=82=A4?= =?UTF-8?q?(MT=E2=80=A6)=EC=99=80=20=EA=B0=99=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=A7=9E=EC=9D=8C(39-8=20=C2=B7=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B8=80=20=E3=80=8C=EC=9D=B4=EB=A6=84=20=C2=B7=20?= =?UTF-8?q?=EA=B7=9C=EA=B2=A9=20=C2=B7=20=EC=BD=94=EB=93=9C=E3=80=8D=20?= =?UTF-8?q?=EC=99=80=20=EB=A7=9E=EC=B6=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이름 · 규격 낱말 AND · 띄어쓰기는 그대로 · 원본 자료는 읽기만 - M02 일위대가 [자재 추가] · M01 자재 고르기가 같은 길(/api/m01/pick) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M01_MasterData/M01_MasterData_Store.py | 4 ++-- resources/tester/test_m01_api.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Store.py b/M01_MasterData/M01_MasterData_Store.py index 8f7b2d789..0e95c8319 100644 --- a/M01_MasterData/M01_MasterData_Store.py +++ b/M01_MasterData/M01_MasterData_Store.py @@ -470,7 +470,7 @@ def _lowest(row: dict) -> object: def pick(kind: str, q: str, limit: int) -> dict: """고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종 - (미확보·추정 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄.""" + (미확보·추정 빼고 모든 구분)(ref = 키) · 낱말마다 이름·규격에 들어 있거나 키와 같은 줄.""" if kind not in _PICK: raise StoreError(404, f"없는 고르기 「{kind}」") words = [_squash(w) for w in q.split()] @@ -481,7 +481,7 @@ def pick(kind: str, q: str, limit: int) -> dict: ): continue text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}") - if all(w in text for w in words): + if all(w in text or w == _squash(row.get("키")) for w in words): hits.append( { "ref": str(row.get("키")), diff --git a/resources/tester/test_m01_api.py b/resources/tester/test_m01_api.py index 094462d2d..de50867eb 100644 --- a/resources/tester/test_m01_api.py +++ b/resources/tester/test_m01_api.py @@ -341,6 +341,13 @@ def test_고르기_자재품목_직종(client: TestClient) -> None: market = _get(client, "/api/m01/pick", kind="price", q="H형강 관급", limit=50) # 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨 assert any(i["관급"] and i["값"] > 0 for i in market["items"]) + # 키로도 찾음(찾기 칸 글 「이름 · 규격 · 코드」) · 이름 낱말 AND 는 그대로 + cement = _get(client, "/api/m01/pick", kind="price", q="시멘트 전국")["items"] + code = cement[0]["ref"] + by_code = _get(client, "/api/m01/pick", kind="price", q=code.lower())["items"] + assert [i["ref"] for i in by_code] == [code] + assert _get(client, "/api/m01/pick", kind="price", q=f"시멘트 {code}")["total"] == 1 + assert _get(client, "/api/m01/pick", kind="price", q=f"없는낱말 {code}")["total"] == 0 job = _get(client, "/api/m01/pick", kind="job", q="보통인부") assert job["items"][0]["값"] > 0 and job["items"][0]["ref"] == key_of("LB:1002") assert _get(client, "/api/m01/pick", kind="job", q="CAD설계사")["items"][0]["ref"] == key_of( From 26f12e081f04878fe4e97fb7aa0cfece2bfd4719 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:25:35 +0900 Subject: [PATCH 05/46] =?UTF-8?q?fix(M02):=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=E3=80=8C=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EC=97=90=EC=84=9C=20=EC=9E=87=EA=B8=B0=E3=80=8D=20?= =?UTF-8?q?=E2=80=94=20=E3=80=8C=EC=A0=80=EC=9E=A5=20=EC=95=88=20=ED=95=9C?= =?UTF-8?q?=20=EA=B3=A0=EC=B9=A8=E3=80=8D=20=EB=AC=BC=EC=9D=8C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=B7=A8=EC=86=8C=EB=A9=B4=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=C2=B7=20=EB=84=A3=EB=8D=98=20=EA=B0=92=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C(=EC=98=AE=EA=B2=A8=20=EA=B0=94?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=EB=8B=AB=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - goArea 가 옮겨 갔는지(참 · 거짓)를 돌려줌 · UI_Main labor 갈래 · 자재 수량 모달도 같은 길 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor_Detail.ts | 6 ++-- .../M02_MasterTemplete_Labor_Input.ts | 35 ++++++++----------- .../M02_MasterTemplete_UI_Main.ts | 4 ++- 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 33f599bfe..35704c6f4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -27,7 +27,7 @@ import { diagramTitle, renderComboDiagram, } from "./M02_MasterTemplete_Labor_Diagram"; -import { openLaborInput, openQtyModal } from "./M02_MasterTemplete_Labor_Input"; +import { openLaborInput, openQtyModal, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -44,8 +44,8 @@ export interface LaborDetailOptions { readOnly: boolean; /** 저장 안 한 고침이 생기거나 사라짐 */ onChanged: (dirty: boolean) => void; - /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(없으면 단추 없음) */ - goArea: (() => void) | null; + /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */ + goArea: GoArea | null; } export interface LaborDetailHandle { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index 13afe0850..ce7aa9db1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -17,6 +17,15 @@ import "./M02_MasterTemplete_Labor.css"; const fill = (text: string, value: string): string => text.replace("{value}", value); +/** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */ +export type GoArea = () => Promise; + +/** 옮겨 갔을 때만 모달을 닫음 — 취소면 모달 · 넣던 값 그대로 */ +const leaveTo = + (go: GoArea, close: () => void): (() => void) => + () => + void go().then((moved) => moved && close()); + export interface QtySeat { /** 지금 수량 */ qty: LaborQty; @@ -116,8 +125,8 @@ export interface LaborInputOptions { memo: string | null; seat: QtySeat; readOnly: boolean; - /** 「면적산출에서 잇기」 — 모달을 닫고 면적산출 상세로(없으면 단추 없음) */ - goArea: (() => void) | null; + /** 「면적산출에서 잇기」 — 면적산출 상세로 감(옮겨 가면 참 · 「저장 안 한 고침」 물음에서 취소면 거짓 · 없으면 단추 없음) */ + goArea: GoArea | null; done: (next: LaborInputResult) => void; } @@ -165,15 +174,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 && - (() => { - close(); - opt.goArea?.(); - }), - ); + const qty = qtyPart(opt.seat, opt.readOnly, opt.goArea && leaveTo(opt.goArea, close)); const ok = (): void => { if (bad.size) return showToast(tc("In_Bad"), "error"); const 수량 = qty.read(); @@ -217,7 +218,7 @@ export function openQtyModal( title: string, seat: QtySeat, readOnly: boolean, - goArea: (() => void) | null, + goArea: GoArea | null, done: (qty: LaborQty) => void, ): void { openModal({ @@ -225,15 +226,7 @@ export function openQtyModal( closeLabel: tc("In_Close"), dialogClass: "m02-modal m02-labin", mount: (body, close) => { - const qty = qtyPart( - seat, - readOnly, - goArea && - (() => { - close(); - goArea(); - }), - ); + const qty = qtyPart(seat, readOnly, goArea && leaveTo(goArea, close)); const ok = (): void => { const 수량 = qty.read(); if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 7637780c2..450fc31a7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -370,7 +370,9 @@ export function createMain( readOnly, onChanged: (d) => (dirty = d && !readOnly), goArea: async () => { - if (await confirmLeave()) void open({ ...at, kind: "area" }); + if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로 + void open({ ...at, kind: "area" }); + return true; }, }); return; From 4e1b7cc5ef2c80128b2968de3e8d67a09967e003 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:29:58 +0900 Subject: [PATCH 06/46] =?UTF-8?q?fix(M02):=20=EB=82=98=EB=AC=B4=EA=B0=80?= =?UTF-8?q?=20=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=A2=8C=EC=B8=A1=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EC=97=B4=20=EC=A4=84=EB=8F=84=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=B0=9B=EC=9D=8C(39-8=20H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 onChanged 한 길 — 집계표 다시 열기와 함께 side.refresh(열 줄 · 번호) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts | 5 ++++- resources/tester/test_m02_labor_tree.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index b41320543..cfa2c9eeb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -97,7 +97,10 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer, item.열), settle: () => main.settle(), - onChanged: (colId) => main.openTable(layer, colId, true), + // 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표 + 좌측 하위 열 줄 · 번호 + onChanged: async (colId) => { + await Promise.all([main.openTable(layer, colId, true), side.refresh()]); + }, }); const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index e77d15a6d..50f51fcad 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -53,3 +53,13 @@ def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page + + +def test_나무가_바뀌면_좌측_하위_열_줄도_다시_받음(): + """39-8 H1 — 새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기 = 나무 `apply` → + `onChanged` 한 길 · 집계표만 다시 열면 좌측 하위 줄이 새로고침 전까지 안 따라옴.""" + page = _text("M02_MasterTemplete_UI_Page.ts") + changed = page.split("onChanged: async (colId) => {")[1].split("},")[0] + assert "main.openTable(layer, colId, true)" in changed and "side.refresh()" in changed + panel = _text("M02_MasterTemplete_TreePanel.ts") + assert panel.count("await opt.onChanged(") == 1 # 나무 고침은 모두 apply 한 곳을 거침 From 31d9a099be894e96f8637e71313b963a1d2327fb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:30:36 +0900 Subject: [PATCH 07/46] =?UTF-8?q?fix(M02):=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20=E2=80=94=20=EA=B7=B8=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EB=AC=B8=EC=84=9C=EA=B0=80=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A4=84=EC=9D=80=20=ED=9D=90=EB=A6=AC=EA=B2=8C=20?= =?UTF-8?q?=C2=B7=20=EB=88=84=EB=A5=B4=EB=A9=B4=20=EA=B9=8C=EB=8B=AD=20?= =?UTF-8?q?=ED=95=9C=20=EC=A4=84=20=C2=B7=20=ED=99=95=EC=9D=B8=20=EC=B0=BD?= =?UTF-8?q?=20=C2=B7=20=EB=B3=B5=EC=82=AC=20=EC=95=88=20=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-8 H3 — 옮기기 전 층(일위대가 labor 없음 등)에서 고르면 「없는 양식」 으로 실패하던 것 · missingPart(도면 · 면적산출 · 일위대가 · 항목 통째는 막지 않음). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 34 ++++++++++++++++--- .../M02_MasterTemplete_ItemPick.css | 6 ++++ .../M02_MasterTemplete_ItemPick.ts | 2 ++ resources/tester/helper_m02_item_list.cjs | 4 +++ resources/tester/test_m02_item_list.py | 9 +++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index 4d015ca6b..c52f0345c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -7,7 +7,7 @@ * 찾기 = 보이는 칸 글 전부에서 띄어쓰기 AND(나무 찾기와 같은 `matchText`). * ========================================================================== */ -import { createInputField, el } from "@ui/ui_template_elements"; +import { createInputField, el, showToast } from "@ui/ui_template_elements"; import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; import "../M01_MasterData/M01_MasterData_UI_Style.css"; @@ -89,6 +89,16 @@ export const ITEM_COLS: Record[]> = { ], }; +const PART_NAME = { structure: "도면", area: "면적산출", labor: "일위대가" } as const; + +/** 그 부분 문서가 없는 줄의 까닭(항목 하나 복사 · 39-8 H3) — 옮기기 전 층의 일위대가처럼 파일이 없으면 + * 가져올 것이 없음 · 항목 통째(복사 생성)는 없는 문서를 빈 문서로 세워 막지 않음 · 있으면 null */ +export function missingPart(kind: ListKind, row: ItemRow): string | null { + if (kind === "item") return null; + const has = kind === "labor" ? !!row.labor : !!row.수정일?.[kind]; + return has ? null : `이 항목에는 ${PART_NAME[kind]} 문서가 없음`; +} + /** 찾기 · 쪽 — 보일 줄 · 맞춘 쪽 · 맞는 줄 수(화면 밖 · 시험이 부름) */ export function pageOf( rows: R[], @@ -131,7 +141,12 @@ export function buildAllItems( export interface ItemListHandle { root: HTMLElement; /** 줄 · 칸 · 누를 일을 바꿔 그림(찾는 글은 그대로 · 쪽은 1) */ - show: (rows: R[], cols: Col[], onPick: (row: R) => void) => void; + show: ( + rows: R[], + cols: Col[], + onPick: (row: R) => void, + blocked?: (row: R) => string | null, + ) => void; /** 표 자리에 글 한 줄(불러오는 중 · 못 받음) */ message: (text?: string) => void; } @@ -147,7 +162,12 @@ export function buildItemList(): ItemListHandle { grid.replaceChildren(el("p", { className: "m01-master__empty", text })); }; - const show = (rows: R[], cols: Col[], onPick: (row: R) => void): void => { + const show = ( + rows: R[], + cols: Col[], + onPick: (row: R) => void, + blocked?: (row: R) => string | null, + ): void => { page = 1; draw = (): void => { const got = pageOf(rows, cols, search.value, page); @@ -167,7 +187,13 @@ export function buildItemList(): ItemListHandle { return el("td", { text, attrs: { title: text } }); }), }); - tr.addEventListener("click", () => onPick(row)); + // 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함) + const why = blocked?.(row) ?? null; + if (why) { + tr.classList.add("is-blocked"); + tr.title = why; + } + tr.addEventListener("click", () => (why ? showToast(why, "info") : onPick(row))); return tr; }); grid.replaceChildren( diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css index a6713f59b..de02c066e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.css @@ -33,3 +33,9 @@ flex-wrap: wrap; gap: var(--spacing-8); } + +/* 그 부분 문서가 없는 줄(39-8 H3) — 흐리게 · 누르면 까닭만 */ +.m02-itemlist .m01-master__table-row.is-blocked { + opacity: 0.45; + cursor: not-allowed; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 4fd1b1215..7d010b5f8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -15,6 +15,7 @@ import { buildItemList, ITEM_COLS, loadItems, + missingPart, type Col, type ItemRow, type ListKind, @@ -141,6 +142,7 @@ export function buildPicker(opt: PickerOptions): { root: HTMLElement } { rows.filter((row) => !opt.skip?.(from, row)), ITEM_COLS[opt.kind], (row) => opt.onPick({ from, row }), + (row) => missingPart(opt.kind, row), // 그 부분 문서 없는 줄 = 흐림 · 까닭(39-8 H3) ); } catch (error) { if (mine === seq) list.message(TEXT.failed.replace("{value}", why(error))); diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs index 875074b62..dcf2777a7 100644 --- a/resources/tester/helper_m02_item_list.cjs +++ b/resources/tester/helper_m02_item_list.cjs @@ -61,5 +61,9 @@ process.stdout.write( ), pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, + // 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음 + missing: ["structure", "area", "labor", "item"].map((k) => + rows.map((r) => m.missingPart(k, r)), + ), }), ); diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index ccdfa1f68..935b8576c 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -67,6 +67,15 @@ def test_찾기_쪽(ts): assert ts["pageOver"] == 3 and ts["pageEmpty"] == 1 +def test_부분_문서_없는_줄_까닭(ts): + """39-8 H3 — 그 부분 문서가 없으면 고르기 막음(흐림 · 까닭) · 항목 통째는 막지 않음.""" + structure, area, labor, item = ts["missing"] + assert structure == [None, None, "이 항목에는 도면 문서가 없음"] + assert area == ["이 항목에는 면적산출 문서가 없음", None, "이 항목에는 면적산출 문서가 없음"] + assert labor == [None, None, "이 항목에는 일위대가 문서가 없음"] + assert item == [None, None, None] + + def test_서버_목록_칸_수정일_labor(world): t = Tree(_as(world), "personal") t.do(op="add-item", 경로=["배수"], 이름="측구", 단위="m") From 524dc8febc94a68bd01b0b94b4a6b8ae575a4be7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:37:24 +0900 Subject: [PATCH 08/46] =?UTF-8?q?fix(M02):=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EC=B8=B5=20=EC=9D=BD=EA=B8=B0=EB=8F=84=20=EC=B8=B5=20?= =?UTF-8?q?=EA=B8=B8=20=ED=95=9C=20=EB=B2=8C=20=E2=80=94=20=ED=9A=8C?= =?UTF-8?q?=EC=82=AC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20=E3=80=8C=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EA=B6=8C=ED=95=9C=E3=80=8D=20=EB=A7=89?= =?UTF-8?q?=ED=9E=98=20=ED=92=82=20(39-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Api_Fetch: 시스템 층도 /layers/system/… (양식 목록 · 읽기 · 저장 · 지우기 · 번호 · 장부 · 새 도면 · 복사) — 옛 /api/m02/templates/* 부름 0 - 서버: GET /layers/system/structure-options(읽기 로그인 누구나 · 고쳐 씀은 옛 길 그대로) · 층 양식 목록에 쓰는곳(지우기 확인 전 막음) - 「〇〇로 복사…」 폴더 창 칸 글 「복사할 폴더」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 20 +++-- .../M02_MasterTemplete_Router_Items.py | 8 ++ .../M02_MasterTemplete_Router_Layers.py | 2 + .../M02_MasterTemplete_TreePanel_CopyTo.ts | 4 +- .../M02_MasterTemplete_TreePanel_Dialogs.ts | 6 +- resources/tester/test_m02_system_read.py | 76 +++++++++++++++++++ ui_template/ui_template_locale_m2.ts | 1 + 7 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 resources/tester/test_m02_system_read.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 4c34f3e76..5bc26211c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -54,7 +54,8 @@ function reason(detail: unknown, status: number): string { return lines.filter(Boolean).join(" · ") || `HTTP ${status}`; } -/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) · +/** 지금 화면의 층 — 시스템 · 회사 · 개인 모두 `/layers/{층}` 아래(구조물집계표 · 조합 길 모두 · 읽기는 + * 로그인 누구나 · 쓰기는 서버 층 권한 · 옛 `/api/m02/templates/*` 는 시스템 관리자 전용이라 안 씀) · * `owner` = 남의 층을 볼 때 주인(회사 `{C}` · 개인 `{C}/{U}` · 자기 층 = null · PLAN 27-1c) — * 지금 층 길에 `?owner=` 로 붙음(M02 화면을 떠나면 페이지가 시스템 · null 로 되돌림) */ let activeLayer: Layer = "system"; @@ -73,24 +74,20 @@ const owned = (layer: Layer, path: string): string => ? `${path}${path.includes("?") ? "&" : "?"}owner=${enc(activeOwner)}` : path; const scoped = (path: string, init: RequestInit = {}): Promise => - call( - owned(activeLayer, `${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`), - init, - ); + call(owned(activeLayer, `/layers/${activeLayer}${path}`), init); const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : ""); -const head = (layer: Layer): string => - layer === "system" ? "/templates" : `/layers/${layer}/templates`; +const head = (layer: Layer): string => `/layers/${layer}/templates`; const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string => owned(layer, `${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`); -/** 시스템 길은 배열 · 층 길은 `{층, 양식: [...]}` — 둘 다 목록으로 */ +/** 층 길은 `{층, 양식: [...]}` · 옛 시스템 길은 배열 — 둘 다 목록으로 */ export const listTemplates = async ( layer: Layer, projectId: string | null, ): Promise => { const got = await call( - layer === "system" ? head(layer) : owned(layer, `${head(layer)}${q(projectId)}`), + owned(layer, `${head(layer)}${layer === "system" ? "" : q(projectId)}`), ); return Array.isArray(got) ? got : got.양식; }; @@ -114,7 +111,7 @@ export const saveTemplate = ( /** 시스템 층만 지움 */ export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> => - call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); + call(`${head("system")}/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" }); /** 개인·회사 층에서 지움 — 판이 맞을 때만(다르면 409) */ export const deleteLayerTemplate = ( @@ -204,7 +201,8 @@ export interface OptionsDoc { 종류: OptionKind[]; } -export const fetchStructureOptions = (): Promise => call("/structure-options"); +export const fetchStructureOptions = (): Promise => + call("/layers/system/structure-options"); // 읽기 = 로그인 누구나 · 고쳐 씀은 옛 길(시스템 관리자) /** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index ceaf54a23..89fb986df 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -288,6 +288,14 @@ def get_numbers(layer: Layer, owner: str | None = None, session: Session = Depen return _call(_root(session, layer, owner), store.structure_numbers) +@router.get("/layers/system/structure-options") +def get_structure_options(session: Session = Depends(verify_session)) -> dict: + """구조물 옵션 보이기 읽기 — 로그인 누구나(마스터 한 벌 · 고쳐 씀은 옛 길 시스템 관리자만).""" + from M02_MasterTemplete import M02_MasterTemplete_Options as options + + return _call(_root(session, "system"), options.listing) + + @router.get("/layers/{layer}/structure-combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), store.read_combos) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 52667b2fe..639f400c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -276,6 +276,8 @@ async def list_layer( manifest = await asyncio.to_thread(layers.read_manifest, folder) for row in rows: row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") + if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 + row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts index 38b03c5f6..ae6ab3e8b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_CopyTo.ts @@ -4,6 +4,7 @@ * 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움. * ========================================================================== */ +import { t as L } from "@ui/ui_template_locale"; import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch"; import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model"; @@ -22,5 +23,6 @@ export async function askCopyFolder( ): Promise { const view = await fetchTree(to.layer, to.owner).catch(() => null); const folders = view ? listFolders(buildFolders(view)) : []; - return askFolder(to.label.replace("{n}", String(n)), folders, () => false, []); + const title = to.label.replace("{n}", String(n)); + return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder")); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts index ab090480f..73c701f68 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -142,12 +142,14 @@ export function askNewItem(opt: { }); } -/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */ +/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 · + * `fieldLabel` = 칸 글(복사는 「복사할 폴더」) */ export function askFolder( title: string, folders: string[][], blocked: (path: string[]) => boolean, current: string[], + fieldLabel: string = L("M02_TreeMoveTo"), ): Promise { return new Promise((resolve) => { let done = false; @@ -164,7 +166,7 @@ export function askFolder( beforeClose: () => (finish(null), true), mount: (body, close) => { const field = createSelectField({ - label: L("M02_TreeMoveTo"), + label: fieldLabel, options: choices.map((path, i) => ({ value: String(i), text: path.length ? path.join(" / ") : L("M02_TreeTop"), diff --git a/resources/tester/test_m02_system_read.py b/resources/tester/test_m02_system_read.py new file mode 100644 index 000000000..b86aaf039 --- /dev/null +++ b/resources/tester/test_m02_system_read.py @@ -0,0 +1,76 @@ +"""마스터 층 읽기는 층 길 한 벌(39-8 · sub1 검증 흠 H2) — 회사 관리자 · 일반도 `/layers/system/…` 로 +표 · 카드 · 일위대가 · 번호 · 장부 · 옵션을 읽음(200) · 쓰기는 서버가 그대로 막음(403). + +시스템 층 = 견본 표(임시 폴더) · storage = 임시 폴더. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +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 + +SYS = "/api/m02/layers/system" +TABLE = f"{SYS}/templates/table/구조물집계표" + + +@pytest.fixture() +def world(tmp_path, 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") + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + app = FastAPI() + app.include_router(items_router.router) + app.include_router(layers_router.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "session": session} + + +@pytest.mark.parametrize("role", ["USER", "ADMIN", "SYSTEM_ADMIN"]) +def test_세_계정_모두_마스터_층_읽기_200(world, role): + world["session"]["role"] = role + client = world["client"] + for url in ( + f"{SYS}/templates", + TABLE, + f"{SYS}/templates/structure/rv", + f"{SYS}/templates/labor/rv", + f"{SYS}/templates/area/rv", + f"{SYS}/structures/numbers", + f"{SYS}/structure-combos", + f"{SYS}/structure-options", + ): + assert client.get(url).status_code == 200, url + rows = client.get(f"{SYS}/templates").json()["양식"] + table = next(r for r in rows if (r["종류"], r["이름"]) == ("table", "구조물집계표")) + assert table["쓰는곳"] # 지우기 확인 창 전에 막는 글 + + +@pytest.mark.parametrize("role", ["USER", "ADMIN"]) +def test_회사_관리자_일반은_마스터_쓰기_403(world, role): + world["session"]["role"] = role + client = world["client"] + got = client.get(TABLE).json() + put = client.put(TABLE, json={"판": got["판"], "문서": got["문서"]}) + assert put.status_code == 403 + assert client.post(f"{SYS}/structures/c1").status_code == 403 + assert client.post(f"{SYS}/labor/rv/copy?from=rv").status_code == 403 + gone = client.delete(f"{SYS}/templates/area/rv?판=x") + assert gone.status_code == 403 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 670d97500..78aa7c1c7 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -143,6 +143,7 @@ export const ui_locales_m2 = { M02_TreeItemName: ["항목 이름", "Item name"], M02_TreeUnit: ["단위", "Unit"], M02_TreeMoveTo: ["옮길 폴더", "Move to"], + M02_TreeCopyFolder: ["복사할 폴더", "Copy to folder"], M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"], M02_TreeOk: ["확인", "OK"], M02_TreeCancel: ["취소", "Cancel"], From 26a24889e6f8a183f023d797fd6c561415f5425d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 21:49:28 +0900 Subject: [PATCH 09/46] =?UTF-8?q?test(M02):=20=EC=B8=B5=20=EA=B8=B8=20?= =?UTF-8?q?=EC=A0=91=EB=91=90=EC=96=B4=20=EC=8B=9C=ED=97=98=20=E2=80=94=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B8=B5=EB=8F=84=20/layers/syst?= =?UTF-8?q?em/=E2=80=A6=20=EA=BC=B4=20=EA=B8=B0=EB=8C=80=20(39-8=20H2=20?= =?UTF-8?q?=EB=92=A4)?= 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_01EYJJRCfHfdCGNhmXTjQKV7 --- resources/tester/test_m02_layer_screen.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 80e1bf0f8..9e3fb2227 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -206,7 +206,9 @@ def test_층_단추_셋_남의_회사_사람_나무(): def test_층_길_접두어와_고정_풀림(): api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts") - assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api + # 시스템 층도 층 길 한 벌(39-8 H2 · 옛 /templates/* 는 시스템 관리자 전용) + assert "owned(activeLayer, `/layers/${activeLayer}${path}`)" in api + assert 'activeLayer === "system" ? "" :' not in api for path in ("/structures/numbers", "/structure-combos"): assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path assert "/layers/personal/items/copy" in api From d4458ec8949d148bb3feb6c586dde043b55568df Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:05:58 +0900 Subject: [PATCH 10/46] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=AA=A9=EB=A1=9D=20=ED=95=A9=EA=B3=84=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=EC=83=88=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EB=91=90=20=EB=8B=A8=EC=B6=94=20=E2=80=94=20=EC=88=98=EB=9F=89?= =?UTF-8?q?=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EA=B8=B0=EC=A4=80=20=ED=95=A9?= =?UTF-8?q?(=EC=9E=91=EC=9D=80=20=EA=B8=80=20=E3=80=8C=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=E3=80=8D)=20=C2=B7=20=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=200=20=E3=80=8C=E2=80=94=E3=80=8D=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=3D=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=8B=A8=EC=9C=84=20=C2=B7=20=EB=88=84?= =?UTF-8?q?=EB=A5=B8=20=EB=8B=A8=EC=B6=94=EA=B0=80=20=EC=A7=84=ED=95=98?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 39-8 B2 · B3 · B4 — 서버 목록 칸 labor 에 기준 · 단위 · laborSum · Col note · buildTwoWay choose. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemList.ts | 35 ++++++++++++++++--- .../M02_MasterTemplete_ItemPick.ts | 15 ++++++-- .../M02_MasterTemplete_Items.py | 22 ++++++++---- resources/tester/helper_m02_item_list.cjs | 9 ++++- resources/tester/test_m02_item_list.py | 33 ++++++++++++++--- 5 files changed, 96 insertions(+), 18 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts index c52f0345c..1df2b007c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemList.ts @@ -22,12 +22,21 @@ export interface ItemRow extends TreeItem { 도번?: string | null; 면적번?: string | null; 수정일?: Partial>; - labor?: { 번호: string | null; 로직수: number; 합계: number | null } | null; + labor?: { + 번호: string | null; + 로직수: number; + /** 수량 반영 합 · 기준(단가) 합 · 일위대가 문서 단위 */ + 합계: number | null; + 기준?: number | null; + 단위?: string | null; + } | null; } export interface Col { head: string; get: (row: R) => string; + /** 값 뒤 작은 글(예 「기준」) — 없으면 없음 */ + note?: (row: R) => string; } const TEXT = { @@ -51,6 +60,17 @@ const latest = (row: ItemRow): string => .pop(), ); +/** 일위대가 합계(39-8 B2) — 수량 반영 합이 있으면 그것 · 없으면 기준(단가) 합 + 작은 글 「기준」 · + * 담은 로직 0 · 계산값 없음 = 「—」 */ +export function laborSum(row: ItemRow): { text: string; note: string } { + const l = row.labor; + if (!l?.로직수) return { text: DASH, note: "" }; + if (l.합계) return { text: money(l.합계), note: "" }; + return typeof l.기준 === "number" + ? { text: money(l.기준), note: "기준" } + : { text: DASH, note: "" }; +} + const NAME: Col = { head: "이름", get: (r) => leafName(r) }; const FOLDER: Col = { head: "폴더", get: (r) => folderOf(r).join(" / ") }; const UNIT: Col = { head: "단위", get: (r) => r.단위 ?? "" }; @@ -73,9 +93,9 @@ export const ITEM_COLS: Record[]> = { { head: "일위대가번", get: (r) => r.labor?.번호 || DASH }, NAME, FOLDER, - UNIT, + { head: "단위", get: (r) => r.labor?.단위 ?? "" }, // 일위대가 문서 단위(39-8 B3) { head: "담은 로직 수", get: (r) => (r.labor ? String(r.labor.로직수) : DASH) }, - { head: "합계", get: (r) => money(r.labor?.합계) }, + { head: "합계", get: (r) => laborSum(r).text, note: (r) => laborSum(r).note }, changed("labor"), ], item: [ @@ -184,7 +204,14 @@ export function buildItemList(): ItemListHandle { className: "m01-master__table-row", children: cols.map((c) => { const text = c.get(row); - return el("td", { text, attrs: { title: text } }); + const note = c.note?.(row) ?? ""; + return el("td", { + text, + attrs: { title: note ? `${text} ${note}` : text }, + children: note + ? [el("small", { className: "m01-master__muted", text: ` ${note}` })] + : [], + }); }), }); // 고를 수 없는 줄 — 흐리게 · 누르면 까닭 한 줄(고르기 안 함) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 7d010b5f8..34deef6f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -185,10 +185,21 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement { const copy = createButton({ label: TEXT.makeCopy, variant: "ghost" }); const below = el("div", { className: "m02-itempick__below" }); let busy = false; - fresh.addEventListener("click", opt.onNew); + /** 누른 단추 = 지금 화면 — 진한 단추가 고른 쪽 · 복사 생성 목록은 그쪽일 때만 보임 */ + const choose = (which: HTMLElement): void => { + for (const b of [fresh, copy]) { + b.classList.toggle("ui-btn--filled", b === which); + b.classList.toggle("ui-btn--ghost", b !== which); + } + below.hidden = which !== copy; + }; + fresh.addEventListener("click", () => { + choose(fresh); + opt.onNew(); + }); copy.addEventListener("click", () => { + choose(copy); if (below.firstChild) return; - copy.classList.add("is-active"); const picker = buildPicker({ ...opt.picker, onPick: (picked) => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index b3899ec42..4edfb4f89 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -86,9 +86,19 @@ def items(folder: Path) -> list[dict[str, Any]]: return out +def _number(sums: Any) -> float | None: + """`계산값` 합 칸의 `계` — 수 아닌 값(옛 꼴) · 없음 = null(「—」).""" + total = sums.get("계") if isinstance(sums, dict) else None + try: + return None if total is None or isinstance(total, bool) else float(total) + except (TypeError, ValueError): + return None + + def _list_extras(folder: Path, col: str) -> dict[str, Any]: """목록 칸(39-5) — `수정일` = 도면 · 면적산출 · 일위대가 파일 시각 · `labor` = 일위대가 문서의 - 번호 · 담은 로직 수 · 합계(`계산값.반영합.계` · 없으면 null) — 파일이 없으면 칸 없음.""" + 번호 · 담은 로직 수 · 합계(`계산값.반영합.계`) · 기준(`계산값.기준합.계`) · 단위 — + 파일이 없으면 칸 없음.""" stamps: dict[str, str] = {} for kind in ("structure", "area", "labor"): path = folder / kind / f"{col}.json" @@ -103,15 +113,13 @@ def _list_extras(folder: Path, col: str) -> dict[str, Any]: doc = json.loads(path.read_bytes()) except ValueError: doc = {} - total = ((doc.get("계산값") or {}).get("반영합") or {}).get("계") - try: - total = None if total is None or isinstance(total, bool) else float(total) - except (TypeError, ValueError): - total = None # 수 아닌 값(옛 꼴)은 「—」 + calc = doc.get("계산값") or {} labor = { "번호": doc.get("일위대가번"), "로직수": len(doc.get("담은로직") or []), - "합계": total, + "합계": _number(calc.get("반영합")), + "기준": _number(calc.get("기준합")), # 수량 없는 일위대가 = 단가 합(39-8 B2) + "단위": doc.get("단위"), # 일위대가 문서 단위(구조물 열 단위 아님 · B3) } return {"수정일": stamps, "labor": labor} diff --git a/resources/tester/helper_m02_item_list.cjs b/resources/tester/helper_m02_item_list.cjs index dcf2777a7..b1f6baee2 100644 --- a/resources/tester/helper_m02_item_list.cjs +++ b/resources/tester/helper_m02_item_list.cjs @@ -33,7 +33,7 @@ const row = (열, 경로, extra = {}) => ({ }); const rows = [ row("c1", ["기슭막이", "메쌓기", "1.2m"], { - labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4 }, + labor: { 번호: "SU-000001", 로직수: 3, 합계: 1234567.4, 기준: 99, 단위: "개소" }, 수정일: { structure: "2026-10-01T09:00:00", labor: "2026-10-04T11:00:00" }, }), row("c2", ["기슭막이", "찰쌓기"], { labor: { 번호: "SU-000002", 로직수: 0, 합계: null } }), @@ -62,6 +62,13 @@ process.stdout.write( pageOver: m.pageOf(many, m.ITEM_COLS.structure, "", 9).page, pageEmpty: m.pageOf([], m.ITEM_COLS.structure, "", 2).page, // 그 부분 문서 없는 줄(39-8 H3) — c1 = 일위대가 있음 · 면적 수정일 없음 · c3 = 수정일 · 일위대가 없음 + sums: [ + rows[0], // 반영 합 있음 + { labor: { 로직수: 14, 합계: 0, 기준: 167746.6 } }, // 수량 없음 = 기준 + rows[1], // 담은 로직 0 + { labor: { 로직수: 2, 합계: null, 기준: null } }, // 계산값 없음 + rows[2], // 일위대가 없음 + ].map((r) => m.laborSum(r)), missing: ["structure", "area", "labor", "item"].map((k) => rows.map((r) => m.missingPart(k, r)), ), diff --git a/resources/tester/test_m02_item_list.py b/resources/tester/test_m02_item_list.py index 935b8576c..604f3d7ca 100644 --- a/resources/tester/test_m02_item_list.py +++ b/resources/tester/test_m02_item_list.py @@ -54,11 +54,23 @@ def test_칸_값_빈_번호_합계_날짜(ts): s, labor = ts["cells"]["structure"], ts["cells"]["labor"] assert s[0] == ["SD-c1", "1.2m", "기슭막이 / 메쌓기", "m", "2026-10-01"] assert s[2][0] == "—" and s[2][2] == "" # 빈 도번 · 맨 위 항목(폴더 없음) - assert labor[0][3:] == ["m", "3", "1,234,567", "2026-10-04"] # 합계 원 단위 반올림 + assert labor[0][3:] == ["개소", "3", "1,234,567", "2026-10-04"] # 일위대가 단위 · 원 단위 assert labor[1][5] == "—" and labor[2][0] == labor[2][4] == "—" # 합계 없음 · 일위대가 없음 assert ts["cells"]["item"][0][-1] == "2026-10-04" # 항목 통째 = 가장 늦게 고친 날 +def test_일위대가_합계_반영_기준_빈(ts): + """39-8 B2 — 반영 합이 있으면 그것 · 없으면 기준 합 + 「기준」 · + 담은 로직 0 · 계산값 없음 = 「—」.""" + assert ts["sums"] == [ + {"text": "1,234,567", "note": ""}, + {"text": "167,747", "note": "기준"}, + {"text": "—", "note": ""}, + {"text": "—", "note": ""}, + {"text": "—", "note": ""}, + ] + + def test_찾기_쪽(ts): assert ts["find"] == ["c2"] # 낱말 둘 AND(폴더 · 이름 칸) assert ts["findLabor"] == ["c1"] # 번호 칸도 찾음 @@ -91,14 +103,27 @@ def test_서버_목록_칸_수정일_labor(world): doc = { "일위대가번": "SU-000001", "담은로직": [{"로직": "GF000219"}, {"로직": "GF000137"}], - "계산값": {"반영합": {"계": 12345.6}}, + "단위": "개소", + "계산값": {"반영합": {"계": 12345.6}, "기준합": {"계": 2469.12}}, } (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") (root / "labor" / f"{wall['열']}.json").write_text("{깨짐", encoding="utf-8") rows = {r["열"]: r for r in t.client.get(t.url).json()["items"]} - assert rows[ditch["열"]]["labor"] == {"번호": "SU-000001", "로직수": 2, "합계": 12345.6} + assert rows[ditch["열"]]["labor"] == { + "번호": "SU-000001", + "로직수": 2, + "합계": 12345.6, + "기준": 2469.12, + "단위": "개소", + } assert "labor" in rows[ditch["열"]]["수정일"] - assert rows[wall["열"]]["labor"] == {"번호": None, "로직수": 0, "합계": None} + assert rows[wall["열"]]["labor"] == { + "번호": None, + "로직수": 0, + "합계": None, + "기준": None, + "단위": None, + } doc["계산값"] = {"반영합": {"계": "글"}} (root / "labor" / f"{ditch['열']}.json").write_text(json.dumps(doc), encoding="utf-8") again = {r["열"]: r for r in t.client.get(t.url).json()["items"]} From 6541b6583488e2e5fbff59336de5f1658f82eeb2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:08:06 +0900 Subject: [PATCH 11/46] =?UTF-8?q?fix(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=ED=91=9C=20=E3=80=8C=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=E3=80=8D=20=EC=A4=84=20=3D=20=EA=B7=B8=20?= =?UTF-8?q?=EC=97=B4=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=EB=B2=88(SU)?= =?UTF-8?q?=20=E2=80=94=20=EC=98=9B=20=EC=A1=B0=ED=95=A9=20=ED=82=A4(UA)?= =?UTF-8?q?=20=EC=B9=B8=20=EC=95=88=20=EC=9D=BD=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EA=B8=80=20=EC=A4=84(=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 부품: 일위대가 줄도 번호 글 줄(손으로 못 침 · laborLabel) · 조회 후 선택이 열 칸을 안 고침 - 누르면 다른 줄과 같은 길(일위대가 복사해 가져오기) · 열의 옛 「일위대가」 값은 파일에 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../M02_MasterTemplete_UI_Main.ts | 1 + resources/tester/test_sheet_drawing_select.py | 16 +++++++++++++++ ui_template/sheet/ui_template_sheet.ts | 15 ++++++-------- ui_template/sheet/ui_template_sheet_ops.ts | 6 +++--- ui_template/sheet/ui_template_sheet_render.ts | 20 ++++++++++--------- 5 files changed, 37 insertions(+), 21 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 450fc31a7..d70207bdb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -329,6 +329,7 @@ export function createMain( onChange: () => (dirty = !readOnly), ...(struct ? { + laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? "", drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "", areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "", combo: { diff --git a/resources/tester/test_sheet_drawing_select.py b/resources/tester/test_sheet_drawing_select.py index 2909841c4..133f770b1 100644 --- a/resources/tester/test_sheet_drawing_select.py +++ b/resources/tester/test_sheet_drawing_select.py @@ -8,6 +8,7 @@ master 만 · 단위 줄 바로 아래 차례(키보드) · project 에는 없 from __future__ import annotations import json +import re import shutil import subprocess from pathlib import Path @@ -70,3 +71,18 @@ def test_text_column_pick_rows_blank(): assert _run()["blank"] == [ [True, False], [True, False], [True, False], [False, False], [False, False], ] # fmt: skip + + +def test_labor_row_shows_su_number(): + """마스터 일위대가 줄 = 그 열 일위대가번(SU) — 열의 옛 `일위대가` 칸(UA 키)은 안 읽음.""" + ops = OPS.read_text(encoding="utf-8") + assert "REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]" in ops + render = (OPS.parent / "ui_template_sheet_render.ts").read_text(encoding="utf-8") + assert re.search(r"key === KEY_PRICE\s*\?\s*state\.laborLabel", render) + assert "col.일위대가" not in render + sheet = (OPS.parent / "ui_template_sheet.ts").read_text(encoding="utf-8") + assert "col.일위대가" not in sheet # 읽기 · 쓰기 · 조회 후 선택 모두 안 건드림 + main = (ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_UI_Main.ts").read_text( + encoding="utf-8" + ) + assert 'laborLabel: (colId: string) => struct.numbers()[colId]?.일위대가번 ?? ""' in main diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index 35372ff8a..b8f38ebe6 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -25,7 +25,6 @@ import { addLevel, deleteColumn, isBlankPick, - KEY_PRICE, KEY_UNIT, navRows, PICK_ROWS, @@ -54,6 +53,8 @@ export interface SheetOptions { /** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */ lockHead?: boolean; onChange?: (doc: SheetDoc) => void; + /** 일위대가 줄 칸 글(일위대가번 SU · 열 id) — `drawingLabel` 과 같음(열의 옛 `일위대가` 칸은 안 읽음). */ + laborLabel?: (colId: string) => string; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; /** 면적산출 줄 칸 글(번호) — `drawingLabel` 과 같음. */ @@ -61,8 +62,7 @@ export interface SheetOptions { /** 칸을 고름 — 누르기 · 방향키 · Tab. */ onSelect?: (sel: SheetSelection) => void; /** 조회 후 선택 — master 일위대가 · 도면 · 면적산출 칸을 두 번 누르기 · Enter · F2 로 부름. - * 글을 돌려주면 일위대가 칸에 넣음 · 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()`. - * 없으면 일위대가는 글로 고침 · 번호 글 줄은 읽기만. */ + * 번호 글 줄은 부른 쪽이 가져온 뒤 `recalc()` · 없으면 읽기만. */ onPick?: (sel: SheetSelection) => Promise | void; /** 구조물 조합 표(PLAN 31-1) — 단위 줄 · 안내 글 없음 · 열 폭 자동 · 고른 열은 가장 깊은 이름 칸을 밝힘 · * `onHeadEdit` 를 주면 이름 칸을 두 번 누르기 · F2 · Enter 로 고쳐 부른 쪽에 넘김(표 문서는 안 고침 — @@ -87,6 +87,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio mode: opts.mode, result: { 계산: {}, 합계: {}, 오류: [] }, sel: null, + laborLabel: opts.laborLabel, drawingLabel: opts.drawingLabel, areaLabel: opts.areaLabel, pick: !!opts.onPick, @@ -236,7 +237,6 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio // ── 칸 고치기 ───────────────────────────────────────────────────── const rawText = (key: string, col: SheetColumn): string => { if (key === KEY_UNIT) return col.단위 ?? ""; - if (key === KEY_PRICE) return col.일위대가 ?? ""; if (REF_ROWS.includes(key)) return refText(state, key, col.id); const row = state.doc.줄.find((r) => `d:${r.id}` === key); const formula = row?.식?.[col.id] ?? col.식; @@ -248,15 +248,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio if (ro || REF_ROWS.includes(key)) return; // 읽기만 const value = text.trim(); if (key === KEY_UNIT) col.단위 = value || null; - else if (key === KEY_PRICE) col.일위대가 = value || null; else setCell(state.doc, key.slice(2), col, text); changed(); }; - /** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */ + /** 조회 후 선택 — 부른 쪽이 가져옴(번호 글 줄 · 문서는 안 고침) */ const pick = async (key: string, colId: string): Promise => { - const got = await opts.onPick?.(selectionOf(state.doc, key, colId)); - const col = colOf(colId); - if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got); + await opts.onPick?.(selectionOf(state.doc, key, colId)); root.focus({ preventScroll: true }); }; /** 칸 고치기 — 두 번 누르기 = 글 전부 고름 · 바로 치기(initial) = 그 글자로 바꿈 · Enter · F2(atEnd) = 커서를 글 끝에 */ diff --git a/ui_template/sheet/ui_template_sheet_ops.ts b/ui_template/sheet/ui_template_sheet_ops.ts index a3d807da6..0f0a51ea2 100644 --- a/ui_template/sheet/ui_template_sheet_ops.ts +++ b/ui_template/sheet/ui_template_sheet_ops.ts @@ -22,11 +22,11 @@ export const KEY_UNIT = "s:unit"; export const KEY_PRICE = "s:price"; export const KEY_DRAWING = "s:drawing"; export const KEY_AREA = "s:area"; -/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ +/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 일위대가 · 도면 · 면적산출 줄은 손으로 못 침(번호 글). */ export const MASTER_ROWS = [KEY_PRICE, KEY_DRAWING, KEY_AREA] as const; export type MasterRowKey = (typeof MASTER_ROWS)[number]; -/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임. */ -export const REF_ROWS: readonly string[] = [KEY_DRAWING, KEY_AREA]; +/** 번호 글 줄 — 부른 쪽이 준 글(`refLabel`)만 보임(일위대가 = SU · 열의 옛 `일위대가` 칸은 안 읽음). */ +export const REF_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_AREA]; /** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */ export const PICK_ROWS: readonly string[] = MASTER_ROWS; diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 3b5113938..e9992bbf7 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -17,6 +17,7 @@ import { isBlankPick, isCalcColumn, KEY_AREA, + KEY_PRICE, KEY_UNIT, MASTER_ROWS, type MasterRowKey, @@ -38,7 +39,8 @@ export interface RenderState { mode: SheetMode; result: SheetResult; sel: { r: string; c: string } | null; - /** 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + /** 일위대가 · 도면 · 면적산출 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */ + laborLabel?: (colId: string) => string; drawingLabel?: (colId: string) => string; areaLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ @@ -51,9 +53,13 @@ export interface RenderState { headEditable?: boolean; } -/** 도면 · 면적산출 줄 칸 글. */ +/** 일위대가 · 도면 · 면적산출 줄 칸 글(번호). */ export const refText = (state: RenderState, key: string, colId: string): string => - (key === KEY_AREA ? state.areaLabel : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); + (key === KEY_AREA + ? state.areaLabel + : key === KEY_PRICE + ? state.laborLabel + : state.drawingLabel)?.(colId)?.trim() || st("Drawing_None"); /** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */ const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map): number => @@ -68,7 +74,7 @@ function fitMeta(state: RenderState, cols: SheetColumn[], mins: Map refText(state, key, col.id))]; + const texts = REF_ROWS.map((key) => refText(state, key, col.id)); const need = Math.ceil(Math.max(...texts.map((t) => textWidth(t, font)))) + 14; mins.set(col.id, Math.max(mins.get(col.id) ?? 0, need)); } @@ -191,11 +197,7 @@ export function renderSheet(state: RenderState): HTMLElement { const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] }); cols.forEach((col, j) => { const blank = isBlankPick(key, col); - const text = blank - ? "" - : REF_ROWS.includes(key) - ? refText(state, key, col.id) - : (col.일위대가 ?? ""); + const text = blank ? "" : refText(state, key, col.id); // 마스터 줄 = 모두 번호 글 const td = bodyCell(key, col, j, text); td.classList.remove("is-num"); if (state.pick && PICK_ROWS.includes(key) && !blank) td.classList.add("is-pick"); From 7cdc7b08da5fd5a61f4b2af7265a72557f690b52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:18:59 +0900 Subject: [PATCH 12/46] =?UTF-8?q?chore(plan):=2040=EC=9E=A5=20M02=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8=202=EC=B0=A8=20=E2=80=94=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9A=94=EA=B5=AC=20=EC=9B=90=EB=AC=B8?= 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_01FJqWQpoB1RE7hiaQ2wH8qV --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 From 43b1c12765d1dfbfd03f55df02f0a6dd07ffc2b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:20:03 +0900 Subject: [PATCH 13/46] auto: 2026-10-04 22:20 (ESD_LAPTOP) --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 From 4347d25c99793df4c8e98228188504a8af4593f2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:20:04 +0900 Subject: [PATCH 14/46] auto: 2026-10-04 22:20 (ESD_LAPTOP) --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 From 27fcbde040c0229e941ca0fdab5fa0d8b60343b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:20:26 +0900 Subject: [PATCH 15/46] auto: 2026-10-04 22:20 (ESD_LAPTOP) --- .../M02_구조물조합개편/사용자요구_1004_2.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/raw/M02_구조물조합개편/사용자요구_1004_2.md diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md new file mode 100644 index 000000000..30d4b5a29 --- /dev/null +++ b/docs/raw/M02_구조물조합개편/사용자요구_1004_2.md @@ -0,0 +1,21 @@ +# M02 구조물 조합 개편 2차 — 사용자 요구 (2026-10-04 밤 · 원문 그대로) + +구조물 옵션보이기는 확인했음. 무시할것. + +다음 수정내용 + 1. 일위대가 상세 + - 담은 로직 이라는 명칭을 단가산출 로직 으로 변경 + - 로직들을 선택하면 나오는 모달 창에서 드롭다운 메뉴에 자동값 xx 항목이 나옴. 있을 필요가 있는지 확인 + - 결과 단위의 좌측에 수량 산출 열을 추가하고 결과단위 당 기준 수량을 넣어주고 아래에는 수량산출의 값을 수기입력이나 엑셀모달을 보여줘서 선택할수 있게 반영. + - 로직의 로직 추가, 올리기 내리기 빼기 버튼의 양식은 알약 양식이이 아니라 버튼양식으로 변경, + - 자재 추가도 동일하게 버튼 으로 + 2. 구조물 조합 상세 + - 일위대가 미리보기에서 복사해 가져오기를 가져오기로 변경, 일위대가 수정을 수정으로 이름변경 + - 도면정보에서 도면수정을 수정으로 이름 변경 + - 일위대가 미리보기에서 항목/수량산출/단위/노무비/재료비/경비/합계로 두줄을 하나의 항목 형태로 변경하고 자세한 내용은 상세내용에서 확인하게 변경(위내용 참고) + - 구조물 조합 제목 행에 있는 새항목의 버튼 양식을 알약에서 버튼으로 변경 + - 도면정보/일위대가/면적산출 컨테이너의 구조를 3열 구조로 변경 + - 도면정보/일위대가/면적산출 컨테이너 별로 삭제도 있어야하는게 아닌지. + 3. 면적산출 상세 + - 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가 + - 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지 From 00cb6db66f697276b0f043de7b7b19dce2b33a8b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:21:23 +0900 Subject: [PATCH 16/46] =?UTF-8?q?feat(M02):=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=ED=95=98=EB=82=98=20=EB=B9=84=EC=9A=B0=EA=B8=B0=20=EA=B8=B8=20?= =?UTF-8?q?=E2=80=94=20=EB=8F=84=EB=A9=B4=20=C2=B7=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EC=9D=84=20=EC=83=88=20=EC=97=B4=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=B9=88=20=EB=AC=B8=EC=84=9C=EB=A1=9C=20(40-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/m02/layers/{층}/{structures|labor|area}/{열}/clear(owner) · 번호 · 열 그대로 · 받는 층 쓰기 권한(_root) - Store.clear · 면적을 비우면 그 열 일위대가 계산값 다시 · 장부 맞춤 - 화면 함수 Api_Fetch clearPart Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 13 ++ .../M02_MasterTemplete_Router_Items.py | 14 ++ .../M02_MasterTemplete_Store.py | 15 ++ resources/tester/test_m02_part_clear.py | 134 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 resources/tester/test_m02_part_clear.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 5bc26211c..2b1982f93 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -226,6 +226,19 @@ export const copyPartFrom = async ( }); }; +/** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */ +export const clearPart = ( + kind: "structure" | "labor" | "area", + column: string, +): Promise => + call( + owned( + activeLayer, + `/layers/${activeLayer}/${kind === "structure" ? "structures" : kind}/${enc(column)}/clear`, + ), + { method: "POST" }, + ); + /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 89fb986df..413e5409c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -317,6 +317,20 @@ def _copy_practice(column: str, key: str) -> dict[str, Any]: return store.copy_from("labor", column, key, body=labor.from_combo(key)) +@router.post("/layers/{layer}/{kind}/{column}/clear") +def post_clear( + layer: Layer, + kind: Literal["structures", "labor", "area"], + column: str, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """항목 하나 비우기(40-4 · 카드 [삭제]) — 빈 문서 · 번호 · 열 그대로 · 받는 층 쓰기 권한.""" + folder = _root(session, layer, owner, write=True) + part = "structure" if kind == "structures" else kind + return _call(folder, store.clear, part, column) + + @router.post("/layers/{layer}/{kind}/{column}/copy") def post_copy( layer: Layer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5bfe9e227..0873d5d5e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -351,6 +351,21 @@ def create_structure(column: str) -> dict[str, Any]: return read("structure", column) +def clear(kind: str, column: str) -> dict[str, Any]: + """항목 하나 비우기(40-4) — 새 열 만들 때와 같은 빈 문서로 다시 씀 · 번호 · 열 그대로 · + 없는 파일은 404 · 면적산출을 비우면 그 열 일위대가 계산값도 다시 · 장부 맞춤.""" + if kind not in _KEY: + raise StoreError(404, f"없는 종류 「{kind}」") + number = read(kind, column)["문서"].get(_KEY[kind]) + with _LOCK: + atomic_write_json(_path(kind, column), {**_blank(kind, column), _KEY[kind]: number}) + info = _info(kind, _path(kind, column)) + if kind == "area": + _refresh_labor(column) + sync_combos() + return info + + def copy_from( kind: str, column: str, diff --git a/resources/tester/test_m02_part_clear.py b/resources/tester/test_m02_part_clear.py new file mode 100644 index 000000000..79a393f25 --- /dev/null +++ b/resources/tester/test_m02_part_clear.py @@ -0,0 +1,134 @@ +"""항목 하나 비우기(PLAN 40-4 · 카드 [삭제]) — `POST /api/m02/layers/{층}/{종류}/{열}/clear`. + +새 열과 같은 빈 문서 · 번호 · 열 그대로 · 권한 = 받는 층 쓰기(`_root`). +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage · 로직은 M01 마스터(읽기만). +""" + +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.common_util_auth import verify_session +from config import config_system +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_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TEN = "GC000421" +URL = "/api/m02/layers/{}/{}/{}/clear" +NUMBER = { + "structures": ("structure", "도번"), + "labor": ("labor", "일위대가번"), + "area": ("area", "면적번"), +} + + +def _fill(folder: Path, cid: str) -> None: + """세 문서를 채움 — 도면 선 · 일위대가 로직 · 면적 칸 + 알약 연결.""" + with store.at(folder): + for kind in ("structure", "labor", "area"): + store._ensure(kind, cid) + got = store.read("structure", cid) + doc = got["문서"] + doc["도면"]["entities"] = [{"type": "line"}] + store.write("structure", cid, got["판"], doc) + got = store.read("labor", cid) + store.write("labor", cid, got["판"], {**got["문서"], "담은로직": [{"로직": TEN}]}) + got = store.read("area", cid) + sheet = got["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": "사석 수상고르기|㎡", "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", cid, got["판"], 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") + _fill(system, "rv") + personal = layers.personal_dir(7, 42) + items_store.ensure(personal) + with store.at(personal): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": "a", "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + _fill(personal, "a") + 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, "personal": personal} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _doc(folder: Path, kind: str, cid: str) -> dict: + return json.loads((folder / kind / f"{cid}.json").read_bytes()) + + +@pytest.mark.parametrize("kind", ["structures", "labor", "area"]) +def test_세_종류_빈_문서_번호_열_그대로(world, kind): + system = world["system"] + part, key = NUMBER[kind] + before = _doc(system, part, "rv") + got = _as(world, "SYSTEM_ADMIN").post(URL.format("system", kind, "rv")) + assert got.status_code == 200, got.text + after = _doc(system, part, "rv") + with store.at(system): + blank = store._blank(part, "rv") + assert after == {**blank, key: before[key]} # 새 열과 같은 빈 문서 + 번호 + assert after["열"] == "rv" and after[key] + with store.at(system): # 장부 번호 그대로 + row = next(r for r in store.read_combos()["줄"] if r["열"] == "rv") + assert row[key] == before[key] + + +def test_면적_비우면_일위대가_계산값_수량_없음으로(world): + system = world["system"] + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "칸" + _as(world, "SYSTEM_ADMIN").post(URL.format("system", "area", "rv")) + assert _doc(system, "labor", "rv")["계산값"]["줄"][0]["수량출처"] == "없음" + assert _doc(system, "labor", "rv")["담은로직"][0]["로직"] == TEN # 일위대가는 그대로 + + +def test_권한_일반_마스터_403_남의_개인_403_내_개인_됨(world): + system, personal = world["system"], world["personal"] + client = _as(world) + assert client.post(URL.format("system", "labor", "rv")).status_code == 403 + assert _doc(system, "labor", "rv")["담은로직"] # 안 지워짐 + other = URL.format("personal", "labor", "a") + "?owner=7/99" + assert client.post(other).status_code == 403 + assert client.post(URL.format("personal", "labor", "a")).status_code == 200 + assert _doc(personal, "labor", "a")["담은로직"] == [] + + +def test_회사_관리자는_동료_개인_층_비움(world): + personal = world["personal"] + client = _as(world, "ADMIN", user=41) + got = client.post(URL.format("personal", "structures", "a") + "?owner=7/42") + assert got.status_code == 200, got.text + assert _doc(personal, "structure", "a")["도면"]["entities"] == [] + + +def test_없는_열_404_모르는_종류_422(world): + client = _as(world, "SYSTEM_ADMIN") + assert client.post(URL.format("system", "labor", "없는열")).status_code == 404 + assert client.post(URL.format("system", "material", "rv")).status_code == 422 From fda2527c4b7525db2eb39d03ebb5c85b66db71a9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:23:27 +0900 Subject: [PATCH 17/46] =?UTF-8?q?feat(M02):=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=B6=80=ED=92=88=20AreaCellPick=20=E2=80=94=20=EA=B7=B8=20?= =?UTF-8?q?=EC=97=B4=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=EB=A7=8C=20=EC=8B=9C=ED=8A=B8=20=C2=B7=20=EC=B9=B8=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=20=3D=20=EC=A3=BC=EC=86=8C=20=C2=B7=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=92=80=EA=B8=B0=20=C2=B7=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20linkAreaCell(=EB=A9=B4=EC=A0=81=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=C2=B7=20=EC=95=8C=EC=95=BD=20=ED=82=A4=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B8)=20(PLAN=2040-3=20=E2=91=A0)?= 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 --- .../spreadsheet/spreadsheet_link_set.ts | 19 +++ .../M02_MasterTemplete_AreaCellPick.ts | 117 ++++++++++++++++++ .../M02_MasterTemplete_AreaPanel.css | 23 ++++ .../spreadsheet/spreadsheet_link_set_entry.ts | 13 +- resources/tester/test_m02_area_panel.py | 11 ++ resources/tester/test_spreadsheet_set_link.py | 2 +- 6 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 8f740992c..65822f7be 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -53,3 +53,22 @@ export function linkCommand( if (!commands.length) return null; return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } + +/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ +export function linkAreaCell( + book: T, + key: string, + cell: string | null, + info: LinkInfo = {}, +): 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); + if (command === false) return null; + const next = structuredClone(book); + const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; + for (const c of list) + if (c.종류 === "시트통째") next.시트 = next.시트.map((s) => (s.id === c.시트.id ? c.시트 : s)); + return next; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts new file mode 100644 index 000000000..8012ac0e6 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * M02_MasterTemplete_AreaCellPick.ts + * 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌. + * 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어 + * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). + * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 + * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import "./M02_MasterTemplete_AreaPanel.css"; + +export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set"; + +const TEXT = { + title: "엑셀에서 고르기 — {value}", + close: "닫기", + hint: "칸을 누르면 그 칸으로 이음 · Enter 도 됨", + now: "지금 연결 {value}", + none: "지금 연결 없음", + unlink: "연결 풀기", + loading: "면적산출을 읽는 중…", + failed: "면적산출을 못 읽음 — {value}", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + +export interface AreaCellPickOptions { + /** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */ + doc?: Workbook | null; + layer: Layer; + /** 집계표 열 id */ + column: string; + /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */ + pill: string; + /** 모달 제목에 쓸 줄 이름 */ + label: string; +} + +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ +export type AreaCellPick = { 칸: string | null } | null; + +export function pickAreaCell(opts: AreaCellPickOptions): Promise { + return new Promise((resolve) => { + let handle: SpreadsheetHandle | null = null; + let done = false; + let picked = "A1"; + let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 + const finish = (value: AreaCellPick): void => { + if (done) return; + done = true; + handle?.destroy(); + handle = null; + resolve(value); + }; + const { close } = openModal({ + title: fill(TEXT.title, opts.label), + closeLabel: TEXT.close, + dialogClass: "m02-modal m02-cellpick", + beforeClose: () => (finish(null), true), + mount: (body) => { + const head = el("p", { className: "m02-cellpick__now", text: TEXT.loading }); + const unlink = createButton({ + label: TEXT.unlink, + variant: "ghost", + size: "sm", + onClick: () => (finish({ 칸: null }), close()), + }); + unlink.hidden = true; + const host = el("div", { className: "m02-cellpick__sheet" }); + body.append( + el("div", { className: "m02-cellpick__head", children: [head, unlink] }), + el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), + host, + ); + const choose = (): void => (finish({ 칸: picked }), close()); + void (async () => { + try { + const [{ createSpreadsheet }, book] = await Promise.all([ + import("../A00_Common/spreadsheet/spreadsheet"), + opts.doc + ? Promise.resolve(opts.doc) + : readTemplate(opts.layer, "area", opts.column, null).then( + (got) => got.문서 as Workbook, + ), + ]); + if (done) return; + const at = book.시트.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), { + readOnly: true, + tabs: false, + onSelect: (s) => { + picked = s.칸; + moved = true; + }, + }); + // 칸을 눌러 뗌 = 그 칸(처음 고름 A1 · 화살표 옮김은 안 닫음) + 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(); + }); + host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + } catch (error) { + if (done) return; + head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); + } + })(); + }, + }); + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css index 9f7c0e18d..36e932845 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaPanel.css @@ -62,3 +62,26 @@ flex: 1 1 auto; margin: 0; } + +/* 엑셀 칸 고르기 모달 — 머리(지금 연결 · [연결 풀기]) · 알림 · 읽기만 시트(높이를 정해야 시트가 보임) */ +.m02-cellpick { + width: min(960px, 92vw); +} + +.m02-cellpick__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); +} + +.m02-cellpick__now, +.m02-cellpick__hint { + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m02-cellpick__sheet { + height: min(60vh, 520px); + min-height: 240px; +} diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index ce15d5b73..032bfb659 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -1,7 +1,7 @@ /* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; -import { linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -89,4 +89,15 @@ eq("빈 주소", linkCommand(b, "s1", "깔기|㎡", ""), false); eq("없는 시트", linkCommand(b, "s9", "깔기|㎡", "A1"), false); eq("문서 그대로", links(b), links(book())); +// linkAreaCell — 새 문서(받은 문서 그대로) · 활성 시트 · 번호 칸 그대로 · 틀린 주소 null · 풀기 +b = { ...book(), 면적번: "SA-000001" } as Workbook & { 면적번: string }; +const before = JSON.stringify(b); +const got = linkAreaCell(b, "철근|ton", "c9", { 이름: "철근", 단위: "ton" }); +eq("받은 문서 그대로", JSON.stringify(b), before); +eq("새 문서 연결", got?.시트[0].연결?.[1], { 키: "철근|ton", 칸: "C9", 이름: "철근", 단위: "ton" }); +eq("번호 그대로", (got as { 면적번?: string } | null)?.면적번, "SA-000001"); +eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); +eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); +eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index fb81c0d7a..d9a79db7c 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -88,3 +88,14 @@ def test_카드_알약_취향은_계정_받은_뒤_올림(): cards = _text("M02_MasterTemplete_ItemCards.ts") load = cards.split("async function load(")[1] assert 'paintPills(readStateRaw("m02-card-pills") === "true");' in load + + +def test_엑셀_칸_고르기_모달_부품(): + """40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함.""" + 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 "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 13e3ba179..000430baf 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 13 + assert result["pass"] == 19 def test_손잡이_setLink_몸(): From 1c0868b7880d93b490fa9a717381be7372791f5e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:24:35 +0900 Subject: [PATCH 18/46] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EB=B3=B4=EA=B8=B0=20=EA=B0=88=EB=9E=98=20=ED=95=9C?= =?UTF-8?q?=20=EC=A4=84=20=ED=91=9C(compact=20=C2=B7=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=9A=A9)=20=E2=80=94=20=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=EB=85=B8=EB=AC=B4=EB=B9=84=20=C2=B7=20?= =?UTF-8?q?=EC=9E=AC=EB=A3=8C=EB=B9=84=20=C2=B7=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=C2=B7=20=ED=95=A9=EA=B3=84=20=C2=B7=20=EA=B8=88=EC=95=A1=20=3D?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=C2=B7=20=EC=97=86=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=ED=9D=90=EB=A6=AC=EA=B2=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=20=EC=A4=84=20=EA=B0=99=EC=9D=80=20=ED=91=9C?= =?UTF-8?q?=20=EC=95=84=EB=9E=98(40-1=20=E2=91=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createLaborView(onRow, { compact: true }) · Labor_Rows compactTable · baseQtyText(결과단위 기준 수량) · qtyShort(손 5 ㎥ · B6 = 12.5 · —) - 400px 이하면 결과 단위 칸 접음 · 300 · 320 · 360 · 420 · 560 에서 가로 넘침 없음 - 시험: test_m02_labor_values 에 수량산출 글 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 53 +++++++ .../M02_MasterTemplete_Labor_Rows.ts | 133 ++++++++++++++++++ .../M02_MasterTemplete_Labor_Text.ts | 2 + .../M02_MasterTemplete_Labor_View.ts | 17 ++- resources/tester/test_m02_labor_values.py | 12 +- 5 files changed, 214 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 8d18b5f90..964c7ddcd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -211,3 +211,56 @@ text-align: right; } } + +/* 한 줄 표(카드용 · 40-1) — 금액 = 수량 반영 값 · 반영이 없으면 기준 값 흐리게 */ +.m02-labline { + width: 100%; + font-size: var(--text-caption); +} + +.m02-labline th, +.m02-labline td { + padding: var(--spacing-4) var(--spacing-6, 6px); +} + +.m02-labline th:nth-child(n + 4), +.m02-labline .m02-labrow__num { + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.m02-labline .m02-labrow__name { + display: table-cell; + min-width: 0; + overflow-wrap: anywhere; +} + +.m02-labline .m02-labrow__qty, +.m02-labline .m02-labrow__unit { + color: var(--color-text-secondary); + overflow-wrap: anywhere; +} + +.m02-labline .is-base { + color: var(--color-text-secondary); + font-style: italic; +} + +.m02-labline .m02-labrow__sum td { + font-weight: 600; + border-top: 2px solid var(--color-border); +} + +/* 아주 좁으면(카드 3열 · 400px 이하) 결과 단위 칸을 접음 — 수량산출 글에 단위가 같이 보임 */ +@container m02-labview (max-width: 400px) { + .m02-labline th:nth-child(3), + .m02-labline .m02-labrow__unit { + display: none; + } + + .m02-labline th, + .m02-labline td { + padding: var(--spacing-4) 3px; + } +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index e41adbce8..efa456a74 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -51,6 +51,28 @@ export function qtyText( return `${tc("Labor_Qty")} ${cell} = ${v}`; } +/** 결과단위 기준 수량 — 분모 앞 수 · 분모(「10 ㎡」 · 「1 ㎥」) · 분모 없으면 빈 글 */ +export function baseQtyText( + line: { 배?: number | null; 분모?: string | null } | undefined, +): string { + if (!line?.분모) return ""; + return `${num(line.배 || 1)} ${line.분모}`; +} + +/** 지금 수량 짧은 글 — 「손 5 ㎥」 · 「B6 = 12.5」 · 「B6 = 빈 칸」 · 없으면 「—」 */ +export function qtyShort( + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, + unit: string, + cells: ReadonlyMap = new Map(), +): string { + const at = line?.알약 ? cells.get(line.알약) : undefined; + if (!line || line.수량 === null || line.수량 === undefined) + return at ? `${at} = ${tc("In_QtyEmpty")}` : "—"; + if (line.수량출처 === "손") + return `${tc("Labor_Hand")} ${num(line.수량)}${unit ? ` ${unit}` : ""}`; + return `${at ?? tc("In_QtyCell")} = ${num(line.수량)}`; +} + /** 금액 칸 하나 — 위 기준(작게) · 아래 반영 · `label` = 좁은 칸에서 칸 이름(머리 줄 대신) */ function moneyCell( base: number | null | undefined, @@ -307,3 +329,114 @@ export function matTable(data: MatTableData, on?: Omit): HT ? table(heads, trs, []) : el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") }); } + +export interface CompactData { + rows: LaborRow[]; + mats: LaborMat[]; + logics: Map; + calc: LaborCalc | null; + fail?: string | null; + cells?: ReadonlyMap; +} + +/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 · + * 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */ +export function compactTable(data: CompactData): HTMLElement { + const wait = !data.calc && !data.fail; + const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement => + el("td", { + className: `m02-labrow__num${base ? " is-base" : ""}`, + text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v), + attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) }, + }); + const heads = [ + tc("Col_Name"), + tc("Labor_QtyCol"), + tc("Col_Unit"), + ...COSTS.map((c) => tc(HEAD[c])), + ]; + const lines = data.rows.map((row) => { + const line = data.calc?.줄.find((l) => l.로직 === row.로직); + const money = line?.반영 ?? line?.기준; + const base = !line?.반영 && !!line?.기준; + const logic = data.logics.get(row.로직); + return el("tr", { + className: "m02-labrow__row", + attrs: { "data-labor-logic": row.로직 }, + children: [ + el("td", { + className: "m02-labrow__name", + text: logic?.이름 ?? row.로직, + attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` }, + }), + el("td", { + className: "m02-labrow__qty", + text: qtyShort(line, line?.분모 ?? "", data.cells), + attrs: { "data-label": tc("Labor_QtyCol") }, + }), + el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }), + ...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))), + ], + }); + }); + const mats = data.mats.map((mat) => { + const line = data.calc?.자재.find((m) => m.코드 === mat.코드); + const has = line?.반영 !== null && line?.반영 !== undefined; + const v = has ? line?.반영 : line?.기준단가; + return el("tr", { + className: "m02-labrow__row is-mat", + attrs: { "data-labor-mat": mat.코드 }, + children: [ + el("td", { + className: "m02-labrow__name", + text: mat.이름 ?? mat.코드, + attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") }, + }), + el("td", { + className: "m02-labrow__qty", + text: qtyShort(line, mat.단위 ?? "", data.cells), + attrs: { "data-label": tc("Labor_QtyCol") }, + }), + el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }), + el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }), + cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)), + el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }), + cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)), + ], + }); + }); + const applied = data.calc?.반영합; + const anyApplied = !!applied && (applied.계 ?? 0) !== 0; + const total = anyApplied ? applied : data.calc?.기준합; + const foot = + data.rows.length + data.mats.length + ? [ + el("tr", { + className: "m02-labrow__sum", + children: [ + 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) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))), + ], + }), + ] + : []; + return data.rows.length + data.mats.length + ? el("div", { + className: "ui-general-block__table-wrap m02-labcompact", + children: [ + el("table", { + className: "ui-general-block__table m02-labline", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })], + }), + el("tbody", { children: [...lines, ...mats] }), + ...(foot.length ? [el("tfoot", { children: foot })] : []), + ], + }), + ], + }) + : el("p", { className: "m02-labrow__muted", text: tc("NoLogic") }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 31ecc6c84..bc09137eb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -56,6 +56,8 @@ const TEXT = { Labor_NoMat: ["담은 자재 없음", "No materials yet"], Labor_AddMat: ["자재 추가", "Add material"], Labor_Qty: ["수량", "Quantity"], + Labor_QtyCol: ["수량산출", "Quantity"], + Labor_Hand: ["손", "Manual"], Labor_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index 9547beba0..517e9efcc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -6,6 +6,7 @@ * · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 * 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결) * (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」) + * · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1) * · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음 * ========================================================================== */ @@ -20,7 +21,7 @@ import { type LaborDoc, } from "./M02_MasterTemplete_Labor_Api"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; -import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; @@ -46,7 +47,15 @@ export interface LaborViewHandle { destroy: () => void; } -export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle { +/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */ +export interface LaborViewOptions { + compact?: boolean; +} + +export function createLaborView( + onRow?: (row: LaborViewRow) => void, + opts: LaborViewOptions = {}, +): LaborViewHandle { const root = el("div", { className: "m02-labview" }); const logics = new Map(); let doc: LaborDoc | null = null; @@ -84,6 +93,10 @@ export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewH unit: mat.단위 ?? "", })?.(); }; + if (opts.compact) + return void root.replaceChildren( + compactTable({ rows: d.담은로직, mats: d.자재, logics, calc, fail, cells }), + ); root.replaceChildren( el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }), logicTable( diff --git a/resources/tester/test_m02_labor_values.py b/resources/tester/test_m02_labor_values.py index 5915821cb..be396caab 100644 --- a/resources/tester/test_m02_labor_values.py +++ b/resources/tester/test_m02_labor_values.py @@ -127,6 +127,13 @@ function load(f) { const m = load(path.join(root, "M02_MasterTemplete", "M02_MasterTemplete_Labor_Rows.ts")); const cells = new Map([["돌쌓기|㎡", "B6"]]); console.log(JSON.stringify([ + m.baseQtyText({ 배: 10, 분모: "㎡" }), + m.baseQtyText({ 배: 1, 분모: "㎥" }), + m.baseQtyText({ 배: 1, 분모: null }), + m.qtyShort({ 수량: 5, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎥", cells), + m.qtyShort({ 수량: 12.5, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyShort({ 수량: null, 수량출처: "없음", 알약: "돌쌓기|㎡" }, "㎡", cells), + m.qtyShort({ 수량: null, 수량출처: "없음", 알약: "없는|㎡" }, "㎡", cells), m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡", cells), m.qtyText({ 수량: 20, 수량출처: "칸", 알약: "돌쌓기|㎡" }, "㎡"), m.qtyText({ 수량: 3, 수량출처: "손", 알약: "돌쌓기|㎡" }, "㎡", cells), @@ -145,7 +152,10 @@ def test_수량_글은_이은_칸_주소를_보임() -> None: [node, "-e", ROWS_SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" ) assert out.returncode == 0, out.stderr - assert json.loads(out.stdout) == [ + got = json.loads(out.stdout) + # 수량산출 열(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 짧은 글 + assert got[:7] == ["10 ㎡", "1 ㎥", "", "손 5 ㎥", "B6 = 12.5", "B6 = 빈 칸", "—"] + assert got[7:] == [ "수량 B6 = 20 ㎡", "수량 면적산출 칸 = 20 ㎡", "수량 손 입력 3 ㎡", From 74a524202ec837a95f84d754e2aed3c6c10fbe01 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:26:00 +0900 Subject: [PATCH 19/46] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=EC=B9=B4=EB=93=9C=20=EC=85=8B=203?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EB=8B=A8=EC=B6=94=20=E3=80=8C=EA=B0=80?= =?UTF-8?q?=EC=A0=B8=EC=98=A4=EA=B8=B0=E3=80=8D=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=88=98=EC=A0=95=E3=80=8D=20=C2=B7=20=E3=80=8C=EC=83=88=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=E3=80=8D=20=EA=B3=B5=EC=9A=A9=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=ED=81=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40-2 ①~③ — 상세 칸 960px 아래 1열(상세 칸 폭 container query) · 카드 [수정] 툴팁에 무엇을 고치는지 · 「새 항목」 = 저장과 같은 크기. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 13 +++++++------ .../M02_MasterTemplete_Structure.css | 16 +++++++++++----- M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts | 2 +- resources/tester/test_m02_card_shell.py | 2 +- resources/tester/test_m02_tree_paste.py | 2 +- 5 files changed, 21 insertions(+), 14 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index deb7368db..04f748bf9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -56,10 +56,10 @@ export const PART_WORD: Record = { const TEXT = { drawingInfo: "도면 정보", - edit: "도면 수정", + edit: "수정", // 카드 셋 같은 글(40-2) — 무엇을 고치는지는 툴팁 noNumber: "도번 없음", noDrawing: "도면 없음", - editSheet: "{value} 수정", + editTip: "{value} 수정 — 전체 편집 화면", noSheetDoc: "{value} 없음", noFieldNumber: "{value} 없음", noSheet: "스프레드시트를 못 띄움 — {value}", @@ -68,7 +68,7 @@ const TEXT = { sheetHelp: "단축키 — 칸을 고르고 바로 침", pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", - take: "복사해 가져오기", + take: "가져오기", takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", makeNew: "새로 만들기", @@ -209,10 +209,11 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { kind === "structure" ? DRAWING_KEYS : SHEET_KEYS, ); const edit = createButton({ - label: kind === "structure" ? TEXT.edit : fill(TEXT.editSheet, PART_WORD[kind]), + label: TEXT.edit, variant: "ghost", size: "sm", }); + edit.title = fill(TEXT.editTip, PART_WORD[kind]); edit.addEventListener("click", () => (kind === "labor" ? opts.labor?.() : opts.onEdit(kind))); const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); take.title = fill(TEXT.takeTip, PART_WORD[kind]); @@ -325,8 +326,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets]; const defaults: Record = { structure: TEXT.edit, - area: fill(TEXT.editSheet, PART_WORD.area), - labor: fill(TEXT.editSheet, PART_WORD.labor), + area: TEXT.edit, + labor: TEXT.edit, }; const root = el("div", { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index fb4c86d4a..133551038 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -1,4 +1,4 @@ -/* M02 구조물집계표 아래 설계 컨테이너 — 카드 셋(도면 정보 · 면적산출 · 재료산출) · +/* M02 구조물집계표 아래 설계 컨테이너 — 카드 셋(도면 정보 · 일위대가 · 면적산출) · 카드 모양은 좌측 패널과 같은 공용 ui-sidebar-*(ui_template_overlay.css). */ /* 표 밖 형제 자리 — 비어 있을 때 메인 간격이 벌어지지 않게 칸을 지움. */ @@ -6,8 +6,8 @@ display: contents; } -/* 카드 넷(도면 정보 · 면적산출 · 재료산출 · 선택 입력) = 2행 2열 · 카드 높이 = 화면 높이에 맞춰 크게(PLAN 31-6) · - 칸이 좁으면(720px 아래) 1열 */ +/* 카드 셋(도면 정보 · 일위대가 · 면적산출) = 1행 3열(40-2) · 카드 높이 = 화면 높이에 맞춰 크게(PLAN 31-6) · + 상세 칸이 좁으면 1열 */ .m02-struct { /* 카드 본문은 카드 높이를 채움 — 넘치면 카드 안에서 스크롤 */ --m02-struct-body-min: 0; @@ -17,13 +17,19 @@ display: grid; flex: none; grid-auto-rows: var(--m02-struct-card-h); - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); /* 도면 · 일위대가 · 면적산출(40-2) */ padding-top: var(--spacing-12); border-top: 1px solid var(--color-border); min-width: 0; } -@media (max-width: 720px) { +/* 상세 칸이 좁으면(카드 하나 300px 아래) 1열 — 좌측 패널을 접어도 맞게 상세 칸 폭으로 잼 + (카드 자리 `m02-main__extra` 는 상자 없음 — 상세 칸이 잣대) */ +.m02-main:has(> .m02-main__extra > .m02-struct) { + container-type: inline-size; +} + +@container (max-width: 960px) { .m02-struct { grid-template-columns: minmax(0, 1fr); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index d70207bdb..64343243f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -113,7 +113,7 @@ export interface MainExtras { /** 상세 제목 줄 오른쪽 끝 「새 …」 단추 — 공용 모양 `m02-main__new`(일위대가 「새 조합」과 같게 · 30-5) */ export function newItemButton(run: () => void, label = L("M02_TreeNewItem")): HTMLElement { - const button = createButton({ label, variant: "filled", size: "sm" }); + const button = createButton({ label, variant: "filled" }); // 공용 버튼 크기(40-2 · 저장과 같음) button.classList.add("m02-main__new"); button.addEventListener("click", run); return button; diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 086b74207..ae89a8b8e 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, take, edit);" in cards # 복사해 가져오기(39-5) + assert "shell.tools.append(help, saver.button, take, edit);" in cards # 가져오기(39-5 · 40-2) assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 67b2786eb..9b40d26dd 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -138,7 +138,7 @@ def test_항목_추가_창_카드_격자_표_접기(): assert "일위대가:" not in panel and "pickCombo" not in panel + dlg # 39-1d — 고르기 줄 없음 assert "askNewItem({" in panel and "M02_TreeUnit" not in panel css = (m02 / "M02_MasterTemplete_Structure.css").read_text("utf-8") - assert "grid-template-columns: repeat(2, minmax(0, 1fr));" in css + assert "grid-template-columns: repeat(3, minmax(0, 1fr));" in css # 카드 셋 3열(40-2) assert "grid-auto-rows: var(--m02-struct-card-h);" in css assert "--m02-struct-card-h: min(640px, 70vh);" in css # 31-6 화면 높이에 맞춤 assert "flex: 1 0 var(--m02-struct-grid-min);" in css # 엑셀 격자가 카드 높이를 채움 From fd4830556e6a9134fc0849c878cd185e612f7606 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:26:05 +0900 Subject: [PATCH 20/46] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=E3=80=8C=EB=8B=B4=EC=9D=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=E3=80=8D=20=E2=86=92=20=E3=80=8C=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=E3=80=8D=20=E2=80=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20=EA=B0=88=EB=9E=98(?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=20=ED=8C=A8=EB=84=90)=20=EC=A0=9C=EB=AA=A9?= =?UTF-8?q?=20=C2=B7=20=EB=B9=88=20=EA=B8=80=20=C2=B7=20=EA=B0=99=EC=9D=80?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=95=8C=EB=A6=BC=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8(40-1=20=E2=91=A1)?= 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_01MgChuAcyBqUkhHMTXomQC7 --- M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts | 2 +- M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts index fb987d593..9c2ab01b1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Diagram.ts @@ -25,7 +25,7 @@ const TEXT = { "가운데 단추 또는 Space + 끌기로 판 옮김 · Ctrl + 휠로 확대", "Middle button or Space + drag to move · Ctrl + wheel to zoom", ], - None: ["담은 로직이 없음", "No logics yet"], + None: ["단가산출 로직이 없음", "No logics yet"], Open: [ "누르면 이 로직을 단가산출 로직 화면에서 엶", "Open this logic in the unit-price logic screen", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index bc09137eb..5e8742a8c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -24,7 +24,7 @@ const TEXT = { NeedName: ["이름을 적어야 저장됨", "A name is required"], Load_Failed: ["불러오지 못함", "Load failed"], Origin_Self: ["원본", "Original"], - Logics_Title: ["담은 로직", "Logics in it"], + Logics_Title: ["단가산출 로직", "Unit-price logics"], Col_Order: ["차례", "No."], Col_Name: ["이름", "Name"], Col_Unit: ["결과 단위", "Result unit"], @@ -33,7 +33,7 @@ const TEXT = { Down: ["내리기", "Down"], Remove: ["빼기", "Remove"], AddLogic: ["로직 추가", "Add logic"], - NoLogic: ["담은 로직 없음", "No logics yet"], + NoLogic: ["단가산출 로직 없음", "No logics yet"], Open: ["열기", "Open"], Pick_Title: ["로직 고르기", "Pick a logic"], Pick_Search: ["이름·키로 찾기", "Search by name or key"], @@ -70,7 +70,7 @@ const TEXT = { ], Labor_Code: ["코드", "Code"], Labor_Spec: ["규격", "Spec"], - Labor_Already: ["이미 담은 로직 — {value}", "Already added — {value}"], + Labor_Already: ["이미 담은 단가산출 로직 — {value}", "Already added — {value}"], Labor_AlreadyMat: ["이미 담은 자재 — {value}", "Already added — {value}"], In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], From 5a9491bd6964c1f314581ab27abe8c3fc062af99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:28:55 +0900 Subject: [PATCH 21/46] =?UTF-8?q?chore(plan):=2041=EC=9E=A5=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=91=9C=20=E2=86=92=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=20=E2=80=94=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9A=94=EA=B5=AC=20=EC=9B=90=EB=AC=B8=20=EB=8D=94=ED=95=A8?= 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_01FJqWQpoB1RE7hiaQ2wH8qV --- 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 3ad007683ced9f382af2180d4761b9b3d24cfbdc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:29:26 +0900 Subject: [PATCH 22/46] =?UTF-8?q?feat(spreadsheet):=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EB=81=8C=EC=96=B4=20=EC=B9=B8=EC=97=90=20=EB=86=93=EA=B8=B0?= =?UTF-8?q?=20=C2=B7=20=EC=9D=B4=EC=9D=80=20=EC=95=8C=EC=95=BD=20=EC=83=89?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=E2=80=94=20=ED=82=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=ED=95=B4=EC=A7=80=EB=8A=94=20=EC=83=89(24=20=EC=83=89?= =?UTF-8?q?=20=C2=B7=20=EB=91=90=20=ED=85=8C=EB=A7=88=20=EB=B0=9D=EA=B8=B0?= =?UTF-8?q?)=20=C2=B7=20=EC=9D=B4=EC=9D=80=20=EC=B9=B8=20=EB=B0=B0?= =?UTF-8?q?=EA=B2=BD=20=EA=B0=99=EC=9D=80=20=EC=83=89=20=EC=98=85=EA=B2=8C?= =?UTF-8?q?=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EC=95=8C=EC=95=BD=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=20=EB=86=93=EC=9C=BC=EB=A9=B4=20=EB=B0=94=EA=BF=80?= =?UTF-8?q?=EC=A7=80=20=EB=AC=BC=EC=9D=8C=20=C2=B7=20=EB=B0=98=EC=A7=9D?= =?UTF-8?q?=EC=9E=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=2040-3=20?= =?UTF-8?q?=E2=91=A1=20=E2=91=A2)?= 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 --- .../spreadsheet/spreadsheet_link_color.ts | 38 +++++++ A00_Common/spreadsheet/spreadsheet_links.css | 55 ++++++++-- A00_Common/spreadsheet/spreadsheet_links.ts | 103 +++++++++++++++--- .../spreadsheet/spreadsheet_link_set_entry.ts | 24 ++++ resources/tester/test_spreadsheet_set_link.py | 18 ++- 5 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_color.ts diff --git a/A00_Common/spreadsheet/spreadsheet_link_color.ts b/A00_Common/spreadsheet/spreadsheet_link_color.ts new file mode 100644 index 000000000..7e77b8a02 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_color.ts @@ -0,0 +1,38 @@ +/* ============================================================================= + * spreadsheet_link_color.ts (주인 D) + * 이은 알약 색 — 알약 키로 정해지는 색상(hue). 알약 · 이은 칸 배경 · 칸 세모가 같은 색(PLAN 40-3). + * 칸 24 개(30° 바퀴 12 + 15° 어긋난 바퀴 12) — 키 해시로 첫 자리 · 겹치면 다음 빈 자리(같은 문서 안에서 서로 다르게). + * 밝기 · 채도는 테마마다 CSS(`spreadsheet_links.css` `--ss-link-l`) · 여기는 색상만 · DOM 모름(시험이 Node 로 부름). + * ========================================================================== */ + +const SLOTS = 24; + +/** FNV-1a 32비트 — 키 글자로 늘 같은 수 */ +function hash(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +/** 자리 → 색상(°) — 0~11 = 30° 간격 · 12~23 = 그 사이(15° 어긋남) */ +const hueOf = (slot: number): number => (slot % 12) * 30 + (slot >= 12 ? 15 : 0); + +/** 이은 알약 키들 → 키마다 색상(°) — 키 차례와 무관(정렬해 자리 배정) · 24 개 넘으면 다시 돎 */ +export function linkHues(keys: Iterable): Map { + const out = new Map(); + const used = new Set(); + for (const key of [...new Set(keys)].sort()) { + if (used.size >= SLOTS) used.clear(); + const start = hash(key) % 12; + // 첫 바퀴(30°)를 다 쓴 뒤에야 둘째 바퀴 — 적을 때 서로 30° 넘게 벌어짐 + let slot = start; + for (let i = 1; i < SLOTS && used.has(slot); i += 1) + slot = (i < 12 ? 0 : 12) + ((start + i) % 12); + used.add(slot); + out.set(key, hueOf(slot)); + } + return out; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index b61fdf11e..edecb144f 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -33,14 +33,55 @@ color: var(--color-accent); } -/* 연결됨 — 흐림(연결 현황) */ -.ss-link.is-linked { - opacity: 0.5; - border-style: dashed; +/* 이은 알약 색(40-3) — 색상 = 알약 키(`--ss-link-h` · spreadsheet_link_color) · 밝기 · 옅기 = 테마 */ +.ss { + --ss-link-s: 70%; + --ss-link-l: 31%; + --ss-link-fill: 0.16; } -.ss-link.is-linked:hover { - opacity: 0.8; +:root[data-theme="dark"] .ss { + --ss-link-l: 66%; + --ss-link-fill: 0.26; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .ss { + --ss-link-l: 66%; + --ss-link-fill: 0.26; + } +} + +/* 연결됨 — 알약 색 테두리 · 왼쪽 띠 · 옅은 배경(글은 본문 색 그대로 · 읽힘) */ +.ss-link.is-linked { + border-color: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill)); + box-shadow: inset 4px 0 0 hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); +} + +.ss-link.is-linked code { + color: inherit; +} + +.ss-link[draggable="true"] { + cursor: grab; +} + +/* 이은 칸 — 알약 색 옅게(칸 글 위 · 누름은 칸으로 지나감) */ +.ss-link-cell { + position: absolute; + z-index: 2; + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill)); + pointer-events: none; +} + +/* 알약을 끌 때 놓일 칸 */ +.ss-link-drop { + position: absolute; + z-index: 4; + box-sizing: border-box; + border: 2px dashed var(--color-accent); + pointer-events: none; } .ss-link--add { @@ -55,7 +96,7 @@ width: 0; height: 0; border-top: 6px solid transparent; - border-left: 6px solid var(--color-accent); + border-left: 6px solid hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); pointer-events: none; } diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 2890bef60..45e9a1572 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -1,8 +1,9 @@ /* ============================================================================= * spreadsheet_links.ts (주인 D) * 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). - * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) · - * 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. + * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(놓은 칸에 다른 알약이 있으면 + * 바꿀지 물음 · 40-3) · 이은 알약 = 키로 정해지는 색(`spreadsheet_link_color`) · 이은 칸 배경 같은 색 옅게 + + * 칸 왼아래 작은 세모 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. * 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] · * 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음). * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 @@ -11,9 +12,10 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showToast } from "@ui/ui_template_elements"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; +import { linkHues } from "./spreadsheet_link_color"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -55,8 +57,10 @@ export interface Links extends PartHandle { } const TEXT = { - bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기", - free: "칸을 고른 채 누르면 이 칸에 연결", + bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기", + free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결", + swap: "{cell} 은 이미 「{name}」 알약 칸 — 이 알약으로 바꿈?", + swapOk: "바꿈", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", remove: "목록에서 빼기", @@ -136,6 +140,9 @@ export function mountLinks( /** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */ const items: LinkItem[] = opts ? [...opts.항목] : []; let dots: HTMLElement[] = []; + /** 끄는 중인 알약 · 놓을 칸 표시 */ + let dragging: LinkItem | null = null; + const dropMark = el("div", { className: "ss-link-drop" }); let pick: HTMLElement | null = null; /** 키 → 연결(시트 · 줄) */ @@ -153,10 +160,11 @@ export function mountLinks( ctx.dispatch({ 종류: "시트통째", 시트: next }); } - function link(item: LinkItem): void { + /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 */ + function link(item: LinkItem, at?: string): void { if (ctx.readOnly) return; const sheet = ctx.sheet(); - const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c); + 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.단위; @@ -184,7 +192,11 @@ export function mountLinks( const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i }))); - function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) { + function pill( + item: LinkItem, + hit: { sheet: Sheet; link: CellLink } | undefined, + hue: number | undefined, + ) { const where = hit ? hit.sheet.id === ctx.selection.시트 ? hit.link.칸 @@ -199,7 +211,19 @@ export function mountLinks( ...(where ? [el("code", { text: where })] : []), ], }); - b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠 + if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); + b.draggable = !ctx.readOnly; + b.addEventListener("dragstart", (e) => { + dragging = item; + e.dataTransfer?.setData("text/plain", item.키); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "link"; + }); + b.addEventListener("dragend", () => { + dragging = null; + dropMark.remove(); + }); + // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) + if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item))); const unlink = (): void => { if (hit) @@ -274,7 +298,8 @@ export function mountLinks( ? ofMaterial(link.자재) : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); - const pills = shown.map((item) => pill(item, map.get(item.키))); + const hues = linkHues(map.keys()); + const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키))); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -287,23 +312,67 @@ export function mountLinks( bar.replaceChildren(...pills); } - /** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */ + /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ function placeDots(): void { for (const d of dots) d.remove(); dots = []; + const hues = linkHues(found().keys()); for (const l of ctx.sheet().연결 ?? []) { const p = parseA1(l.칸); 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.키 } }); + 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`; - layer.append(dot); - dots.push(dot); + 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); } } + // ── 알약 끌어 칸에 놓기(40-3) ────────────────────────────────────────── + /** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */ + function cellAt(e: DragEvent): { r: number; c: number } | null { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null; + } + const onDragOver = (e: DragEvent): void => { + const at = dragging && !ctx.readOnly ? cellAt(e) : null; + if (!at) return void dropMark.remove(); + e.preventDefault(); // 놓을 수 있음 + if (e.dataTransfer) e.dataTransfer.dropEffect = "link"; + const { layer, box } = slot(ctx, at.r, at.c); + dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; + if (dropMark.parentElement !== layer) layer.append(dropMark); + }; + const onDragLeave = (e: DragEvent): void => { + if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove(); + }; + const onDrop = async (e: DragEvent): Promise => { + const item = dragging; + const at = item && !ctx.readOnly ? cellAt(e) : null; + dropMark.remove(); + 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, 칸); + }; + // ── 자재 고르기 창 ────────────────────────────────────────────────────── function closePick(): void { pick?.remove(); @@ -384,6 +453,10 @@ export function mountLinks( const onZoom = (): void => placeDots(); ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom); + const onDropEvent = (e: DragEvent): void => void onDrop(e); + ctx.grid.root.addEventListener("dragover", onDragOver); + ctx.grid.root.addEventListener("dragleave", onDragLeave); + ctx.grid.root.addEventListener("drop", onDropEvent); return { root: opts ? bar : null, @@ -394,6 +467,10 @@ export function mountLinks( }, destroy() { ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom); + ctx.grid.root.removeEventListener("dragover", onDragOver); + ctx.grid.root.removeEventListener("dragleave", onDragLeave); + ctx.grid.root.removeEventListener("drop", onDropEvent); + dropMark.remove(); closePick(); for (const d of dots) d.remove(); menu.element.remove(); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index 032bfb659..a2ba19fb1 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -1,6 +1,7 @@ /* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. * 결과 = 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 type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; @@ -100,4 +101,27 @@ eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); +// linkHues — 키로 정해짐 · 차례 무관 · 24 개까지 서로 다름 · 12 개까지 30° 바퀴 · 15° 눈금 +const keys = Array.from({ length: 24 }, (_, i) => `알약${i}|㎡`); +const h1 = linkHues(keys); +const h2 = linkHues([...keys].reverse()); +eq("차례 무관", [...h1.entries()].sort(), [...h2.entries()].sort()); +eq("24 개 서로 다름", new Set(h1.values()).size, 24); +eq( + "15° 눈금", + [...h1.values()].every((h) => h % 15 === 0 && h >= 0 && h < 360), + true, +); +const few = linkHues(keys.slice(0, 12)); +eq( + "12 개 = 30° 바퀴", + [...few.values()].every((h) => h % 30 === 0), + true, +); +eq( + "같은 키 같은 색", + linkHues(["철근|ton", "깔기|㎡"]).get("철근|ton"), + linkHues(["철근|ton"]).get("철근|ton"), +); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 000430baf..554ca3aa9 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 19 + assert result["pass"] == 24 def test_손잡이_setLink_몸(): @@ -66,3 +66,19 @@ def test_손잡이_setLink_몸(): assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) assert "loadLinks();" in body + + +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 '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( + encoding="utf-8" + ) + assert ( + "--ss-link-l: 31%;" in css and css.count("--ss-link-l: 66%;") == 2 + ) # 밝음 · 어두움(설정 · 시스템) + assert "ss-link-flash" in css # 반짝임 그대로 From ad90e7226006fb4193c6449a3ad6b01ef57eb286 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:29:27 +0900 Subject: [PATCH 23/46] auto: 2026-10-04 22:29 (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 e3236558bdbadb323ac5d0d27c536f91d5f00b7c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:37:22 +0900 Subject: [PATCH 24/46] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=EC=B9=B4=EB=93=9C=20[=EC=82=AD?= =?UTF-8?q?=EC=A0=9C]=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=ED=95=9C=20=EC=A4=84=20=ED=91=9C=20?= =?UTF-8?q?=E2=80=94=20=EA=B7=B8=20=ED=95=AD=EB=AA=A9=20=EB=B9=84=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0(=EB=B2=88=ED=98=B8=20=C2=B7=20=EC=97=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=AC=BC=EC=9D=8C=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=20=C2=B7=20=EB=B9=84=EC=9A=B4=20=EB=92=A4=20=EB=B9=88?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C)=20=C2=B7=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=8B=A8=EC=B6=94=20=EC=A4=84=20=EB=B0=94?= =?UTF-8?q?=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40-2 ④ ⑤ — clearPart(40-4) 잇기 · 찬 카드 · 쓸 수 있을 때만 · 실패 = 카드 안 까닭 · Labor_View compact(40-1). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 40 +++++++++++++++++-- .../M02_MasterTemplete_Structure.css | 5 ++- .../M02_MasterTemplete_Structure.ts | 5 +++ resources/tester/test_m02_card_shell.py | 2 +- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 04f748bf9..f62db757e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -69,6 +69,10 @@ const TEXT = { pills: "알약", pillsTip: "아래 알약 줄 보임 · 숨김", take: "가져오기", + clear: "삭제", + clearTip: "이 항목의 {value} 을(를) 비움 — 번호 · 열은 그대로", + clearAsk: "「{name}」 의 {value} 을(를) 비울까? — 번호 · 열은 그대로 · 되돌릴 수 없음", + clearFailed: "비우지 못함 — {value}", takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", makeNew: "새로 만들기", @@ -117,6 +121,9 @@ export interface ItemCardsOptions { sheetEdit?: SheetEditOptions; /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ onImport?: (kind: CardKind) => void; + /** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 · + * 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */ + onClear?: (kind: CardKind) => Promise; /** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 · * [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */ labor?: () => void; @@ -174,6 +181,8 @@ interface Card { edit: HTMLButtonElement; /** 「복사해 가져오기」 — 찬 카드만 */ take: HTMLButtonElement; + /** [삭제] — 찬 카드만 · 쓸 수 있을 때만 */ + del: HTMLButtonElement; no: HTMLElement; name: HTMLElement; } @@ -218,14 +227,35 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const take = createButton({ label: TEXT.take, variant: "ghost", size: "sm" }); take.title = fill(TEXT.takeTip, PART_WORD[kind]); take.addEventListener("click", () => opts.onImport?.(kind)); - shell.tools.append(help, saver.button, take, edit); - return { kind, shell, saver, help, edit, take, no, name }; + const del = createButton({ label: TEXT.clear, variant: "ghost", size: "sm" }); + del.title = fill(TEXT.clearTip, PART_WORD[kind]); + del.addEventListener("click", () => void clearCard(kind)); + shell.tools.append(help, saver.button, take, edit, del); + return { kind, shell, saver, help, edit, take, del, no, name }; }; + /** 카드 [삭제](40-2) — 물음 한 번(저장 안 한 카드 고침도 함께 버려짐) → 비움 → 카드 다시 읽음(빈 카드가 결과) */ + async function clearCard(kind: CardKind): Promise { + if (!opts.onClear || !column) return; + const ask = fill(TEXT.clearAsk, PART_WORD[kind]).replace("{name}", label); + if (!(await showConfirmDialog(ask))) return; + try { + await opts.onClear(kind); + } catch (error) { + // 알림은 카드 틀 안(토스트 안 씀) — 까닭 + [다시 불러오기] + const card = cards.find((c) => c.kind === kind); + return void card?.shell.fail(fill(TEXT.clearFailed, why(error)), reload); + } + for (const c of cards) c.saver.mark(false); + started.delete(kind); + reload(); + } + /** 빈 카드(39-5) — 고칠 수 있고(가져오기 길이 있는 M02 화면) [새로 만들기] 를 안 눌렀으면 두 단추 · 아니면 미리보기 */ const blank = (c: Card, empty: boolean): boolean => { const show = empty && opts.editable() && !!opts.onImport && !started.has(c.kind); c.take.hidden = show || !opts.editable() || !opts.onImport; + c.del.hidden = show || !opts.editable() || !opts.onClear; // 빈 카드엔 비울 것이 없음 return show; }; /** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */ @@ -307,7 +337,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const paintLabor = async (at: string, mine: number): Promise => { if (!laborCard) return; try { - laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(); + // 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 금액 넷 · 40-2) — 자세한 것은 [수정] 상세 + laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(undefined, { + compact: true, + }); } catch (error) { if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload); return; @@ -345,6 +378,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit; c.saver.button.hidden = c.help.hidden = !can; c.edit.hidden = !opts.editable(); + c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김 } pills.hidden = !sheetEdit; }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index 133551038..aabaaa5e8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -90,7 +90,10 @@ .m02-struct__card-tools { display: inline-flex; - flex: none; + flex: 0 1 auto; /* 좁은 카드(1열 · 400px)에서 단추가 많으면 줄 바꿈 — 카드 밖으로 안 넘침(40-2) */ + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; align-items: center; gap: var(--spacing-8); margin-left: auto; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index e03126161..44750848b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -17,6 +17,7 @@ import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { + clearPart, copyPartFrom, fetchLaborPills, fetchStructureNumbers, @@ -222,6 +223,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane warnOnFail: (id) => !!numbers[id], // 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 집계표 줄 「조회 후 선택」 과 같은 길 onImport: (kind) => void pickCopy(kind, shown ?? undefined), + // 카드 [삭제](40-2) — 그 항목 비우기(서버 clear 길 · 번호 · 열 그대로 · 쓰기 권한 없으면 403) + onClear: async (kind) => { + if (column) await clearPart(kind, column); + }, labor: () => void openEdit("labor"), // 일위대가 카드(보기 갈래 · 39-3) — 도면 다음 // 면적산출 카드 미리보기 수정(31-3) — 전체 편집기와 같은 알약 · 저장 길 sheetEdit: { diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index ae89a8b8e..aae597a1d 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -40,7 +40,7 @@ def test_저장_단추_알림_한_벌(): def test_네_카드가_틀을_씀_수정_단추_한_모양(): cards = _text("M02_MasterTemplete_ItemCards.ts") assert "const shell = cardShell({ title, body, sub: [no, name] });" in cards - assert "shell.tools.append(help, saver.button, take, edit);" in cards # 가져오기(39-5 · 40-2) + assert "shell.tools.append(help, saver.button, take, edit, del);" in cards # 40-2 assert 'variant: "filled"' not in cards # [수정] 은 모두 빈 단추(ghost) assert "c.saver.button.hidden = c.help.hidden = !can;" in cards # [저장] · ? 늘 보임 assert "DRAWING_KEYS" in cards and "SHEET_KEYS" in cards From d9276467b1bdbd7fd0e3b80b021c5a6094e55d1b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:37:28 +0900 Subject: [PATCH 25/46] auto: 2026-10-04 22:37 (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 e70fc184aea3588507ad19957b5d496c44d5e5a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:39:46 +0900 Subject: [PATCH 26/46] =?UTF-8?q?fix(M02):=20=EB=84=A3=EC=9D=84=20?= =?UTF-8?q?=EA=B0=92=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B9=B8=20=E3=80=8C?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EA=B0=92=20xx=E3=80=8D=20=EB=B9=88=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=EC=97=86=EC=95=B0=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EA=B0=92(=EC=B2=AB=20=EC=84=A0=ED=83=9D=EC=A7=80)?= =?UTF-8?q?=EC=9D=84=20=EA=B3=A0=EB=A5=B8=20=EC=B1=84=20=ED=9D=90=EB=A6=AC?= =?UTF-8?q?=EA=B2=8C=20=C2=B7=20=EC=A0=80=EC=9E=A5=EC=9D=80=20=EB=B0=94?= =?UTF-8?q?=EA=BF=80=20=EB=95=8C=EB=A7=8C(40-1=20=E2=91=A2=20=C2=B7=20?= =?UTF-8?q?=EB=B8=8C=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 27/46] =?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 28/46] 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 29/46] =?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 c426fa2ad6a62d5882d08b668094fb5778de9e53 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:43:40 +0900 Subject: [PATCH 30/46] =?UTF-8?q?feat(B07=20CAD):=20=ED=91=9C=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=EB=8F=84=EA=B5=AC=20=C2=B7=20=ED=91=9C=20=EB=A9=94?= =?UTF-8?q?=EC=8B=9C=EC=A7=80=20place-table=20=C2=B7=20update-table=20(41-?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 tools/annotate/place-table-tool.ts — PLACETABLE: 부모가 보낸 표를 커서에 붙여 미리보기 · 누르면 그 자리(왼쪽 위) · Esc 그만 · 놓기 = 되돌리기 한 걸음 - 새 integration/aislo-table-bridge.ts — place-table{entity, link} = style.연결 단 표로 놓기 시작 · update-table{link, cells} = 연결 같은 표 칸만 갈기(자리 · 크기 그대로) → table-updated{link, count} · 확정 · 보기 전용이면 안 함 - TableEntity style.연결 · replaceCells · bridge 갈래 한 줄 · 명령 PLACETABLE - 시험 aislo-table-bridge.test.ts(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../src/commands/commands.annotate.ts | 9 ++ .../openwebcad/src/entities/TableEntity.ts | 7 ++ .../src/integration/aislo-drawing-bridge.ts | 2 + .../integration/aislo-table-bridge.test.ts | 114 ++++++++++++++++++ .../src/integration/aislo-table-bridge.ts | 80 ++++++++++++ B07_DesignDetail/openwebcad/src/tools.ts | 1 + .../src/tools/annotate/place-table-tool.ts | 49 ++++++++ 7 files changed, 262 insertions(+) create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts create mode 100644 B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts index c738faebc..e2583de55 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.annotate.ts @@ -29,6 +29,7 @@ import { revCloudToolStateMachine, updateDimensions, } from '../tools/annotate/leader-table-tools'; +import { placeTableToolStateMachine } from '../tools/annotate/place-table-tool'; import { tableColumnToolStateMachine, tableEditToolStateMachine, @@ -324,6 +325,14 @@ export const TABLE_COMMANDS: CadCommand[] = [ tool: Tool.TABLEUNMERGE, machine: tableUnmergeToolStateMachine, }, + { + id: 'PLACETABLE', + label: '표 놓기', + glyph: '▦', + hint: '부모 화면이 보낸 표(면적산출 범위)를 누른 자리에 놓는다', + tool: Tool.PLACETABLE, + machine: placeTableToolStateMachine, + }, ]; export const MARKUP_COMMANDS: CadCommand[] = [ diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts index 93e5ec55a..5662184dd 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -34,6 +34,8 @@ export interface TableStyleOptions { textColor: string; /** 문자를 칸 좌우 끝에서 띄우는 거리 */ padding: number; + /** 면적산출에서 보낸 표의 연결 id(M02 41장) — 옮기기 · 복사(clone)에도 남아 [업데이트]가 이 표를 찾는다 */ + 연결?: string; } export const DEFAULT_TABLE_STYLE: TableStyleOptions = { @@ -97,6 +99,11 @@ export class TableEntity implements Entity { } // ── 쓰기 (표 편집 명령이 쓴다) + /** 칸 내용만 통째로 갈아 끼운다 — 자리 · 열 너비 · 행 높이는 그대로(넘치는 칸은 버리고 모자라면 빈 칸) */ + public replaceCells(cells: TableCells): void { + this.cells = normalizeCells(cells, this.rowHeights.length, this.columnWidths.length); + } + public setCell(row: number, column: number, patch: Partial): void { const current = this.cells[row]?.[column]; if (!current) return; // 병합에 먹힌 자리는 직접 고치지 않는다 diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts index 29f4bea06..47ecd4f5b 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-drawing-bridge.ts @@ -33,6 +33,7 @@ import { toast } from 'react-toastify'; import { runCommandInput } from '../commands/run-command.ts'; import { setRecoveryScope } from '../helpers/autosave.ts'; import { registerBoxResizeDrag } from './box-resize-drag.ts'; +import { handleTableMessage } from './aislo-table-bridge.ts'; export const AISLO_DRAWING_LOAD_MESSAGE = 'aislo:b08:load-drawing'; export const AISLO_DRAWING_READY_MESSAGE = 'aislo:b08:drawing-ready'; @@ -190,6 +191,7 @@ function applyFramePreview(): void { export function registerAisloDrawingBridge() { window.addEventListener('message', async (event: MessageEvent) => { if (event.origin !== window.location.origin || event.source !== window.parent) return; + if (await handleTableMessage(event.data, notifyParent)) return; // 표 놓기 · 갈기(M02 41장) const message = event.data as Partial; if (message.type === AISLO_DRAWING_SAVE_REQUEST_MESSAGE) { try { diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts new file mode 100644 index 000000000..4057df8d7 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -0,0 +1,114 @@ +import { Point } from '@flatten-js/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TableEntity } from '../entities/TableEntity.ts'; +import { getEntities, isDrawingDirty, setEntities, setLayers } from '../state.ts'; +import { getPendingTable, setPendingTable, tableAt } from '../tools/annotate/place-table-tool.ts'; +import { + AISLO_PLACE_TABLE_MESSAGE, + AISLO_TABLE_UPDATED_MESSAGE, + AISLO_UPDATE_TABLE_MESSAGE, + handleTableMessage, + updateLinkedTables, +} from './aislo-table-bridge.ts'; + +const run = vi.hoisted(() => vi.fn()); +vi.mock('../commands/run-command.ts', () => ({ runCommandInput: run })); + +const layer = { id: 'layer-1', isLocked: false, isVisible: true, name: '작업' }; + +function table(link?: string): TableEntity { + return new TableEntity( + layer.id, + new Point(10, 20), + [30, 15], + [5, 5], + [ + [{ text: '항목', colSpan: 2, key: 'S1!A1' }, null], + [ + { text: '면적', key: 'S1!A2' }, + { text: '12.5', align: 'right', key: 'S1!B2' }, + ], + ], + { fontSize: 2.5, ...(link ? { 연결: link } : {}) } + ); +} + +beforeEach(() => { + setLayers([layer]); + setEntities([]); + setPendingTable(null); + run.mockClear(); +}); + +describe('표 놓기(PLACETABLE)', () => { + it('미리보기 · 놓을 표는 커서 자리(왼쪽 위)에 같은 칸 · 크기 · 연결로', () => { + const placed = tableAt(table('T1'), new Point(100, 200), layer.id); + expect([placed.getOrigin().x, placed.getOrigin().y]).toEqual([100, 200]); + expect(placed.getColumnWidths()).toEqual([30, 15]); + expect(placed.getCell(1, 1)?.text).toBe('12.5'); + expect(placed.getStyle().연결).toBe('T1'); + }); + + it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => { + const notify = vi.fn(); + const handled = await handleTableMessage( + { type: AISLO_PLACE_TABLE_MESSAGE, entity: await table().toJson(), link: 'T7' }, + notify + ); + expect(handled).toBe(true); + expect(run).toHaveBeenCalledWith('PLACETABLE'); + expect(getPendingTable()?.getStyle().연결).toBe('T7'); + expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2); + expect(notify).not.toHaveBeenCalled(); + }); + + it('옮기기 · 복사(clone) · JSON 왕복에도 style.연결이 남음', async () => { + const linked = table('T1'); + expect(linked.clone().getStyle().연결).toBe('T1'); + const json = await linked.toJson(); + if (!json) throw new Error('표 JSON 없음'); + const back = await TableEntity.fromJson(json); + expect(back.getStyle().연결).toBe('T1'); + }); +}); + +describe('표 칸 갈기(update-table)', () => { + it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => { + const linked = table('T1'); + const other = table('T2'); + setEntities([linked, other]); + const count = updateLinkedTables('T1', [ + [{ text: '항목(새)', colSpan: 2 }, null], + [{ text: '면적' }, { text: '99.0', align: 'right' }], + ]); + expect(count).toBe(1); + expect(linked.getCell(1, 1)?.text).toBe('99.0'); + expect(linked.getCell(0, 1)).toBeNull(); // 병합 그대로 + expect(other.getCell(1, 1)?.text).toBe('12.5'); + expect([linked.getOrigin().x, linked.getColumnWidths()]).toEqual([10, [30, 15]]); + expect(isDrawingDirty()).toBe(true); + }); + + it('넘치는 칸은 버리고 모자란 칸은 빈 칸', () => { + const linked = table('T1'); + setEntities([linked]); + updateLinkedTables('T1', [[{ text: 'a' }, { text: 'b' }, { text: '넘침' }]]); + expect(linked.getCells().map((row) => row.map((cell) => cell?.text ?? null))).toEqual([ + ['a', 'b'], + ['', ''], + ]); + }); + + it('update-table 메시지 = table-updated{link, count} · 없는 연결은 0 · 도면 그대로', async () => { + setEntities([table('T1')]); + const before = getEntities(); + const notify = vi.fn(); + await handleTableMessage({ type: AISLO_UPDATE_TABLE_MESSAGE, link: 'T9', cells: [] }, notify); + expect(notify).toHaveBeenCalledWith(AISLO_TABLE_UPDATED_MESSAGE, { link: 'T9', count: 0 }); + expect(getEntities()).toBe(before); + }); + + it('표 메시지가 아니면 false', async () => { + expect(await handleTableMessage({ type: 'aislo:b08:save-request' }, vi.fn())).toBe(false); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts new file mode 100644 index 000000000..6ac5c13d1 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts @@ -0,0 +1,80 @@ +/** + * 부모 화면 ↔ CAD 표 메시지(M02 41장 · 면적산출 범위 → 도면 표). 짝 = `ui_template/cad_host/cad_host.ts`. + * place-table{entity, link} 표 JSON(TableEntity 꼴) → `style.연결 = link` 로 표 놓기 도구(PLACETABLE) 시작 + * update-table{link, cells} `style.연결 === link` 인 표의 칸만 갈아 끼움(자리 · 크기 그대로 · 되돌리기 한 걸음) + * → 부모에 table-updated{link, count} + * 놓기 · 갈기 모두 `setEntities(…, true)` 를 거쳐 drawing-changed 가 간다. 확정 · 보기 전용이면 안 함. + */ +import { toast } from 'react-toastify'; +import { runCommandInput } from '../commands/run-command.ts'; +import type { JsonEntity } from '../entities/Entity.ts'; +import { TableEntity } from '../entities/TableEntity.ts'; +import type { TableCells } from '../helpers/table-geometry.ts'; +import { getEntities, isDrawingReadOnly, isViewOnly, setEntities } from '../state.ts'; +import { setPendingTable } from '../tools/annotate/place-table-tool.ts'; + +export const AISLO_PLACE_TABLE_MESSAGE = 'aislo:b08:place-table'; +export const AISLO_UPDATE_TABLE_MESSAGE = 'aislo:b08:update-table'; +export const AISLO_TABLE_UPDATED_MESSAGE = 'aislo:b08:table-updated'; + +/** 두 메시지를 한 꼴로 받음 — place = entity · link · update = link · cells */ +interface TableMessage { + type?: string; + entity?: JsonEntity; + link?: string; + cells?: TableCells; +} + +/** 이 표가 그 연결의 것인가 */ +export const isLinkedTable = (entity: unknown, link: string): entity is TableEntity => + entity instanceof TableEntity && entity.getStyle().연결 === link; + +/** 연결 표마다 칸을 갈아 끼움 — 바꾼 표 수(0 이면 도면을 건드리지 않음) */ +export function updateLinkedTables(link: string, cells: TableCells): number { + const tables = getEntities().filter((entity) => isLinkedTable(entity, link)); + for (const table of tables) table.replaceCells(cells); + if (tables.length) setEntities([...getEntities()], true); + return tables.length; +} + +/** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */ +export async function startPlaceTable(entity: JsonEntity, link: string): Promise { + const table = await TableEntity.fromJson(entity as Parameters[0]); + const style = table.getStyle(); + setPendingTable( + new TableEntity( + table.layerId, + table.getOrigin(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells(), + { ...style, 연결: link } + ) + ); + runCommandInput('PLACETABLE'); +} + +const blocked = (): boolean => { + if (!isDrawingReadOnly() && !isViewOnly()) return false; + toast.info('보기 전용 도면 — 표를 넣거나 고치려면 [수정]을 먼저 누르세요.'); + return true; +}; + +/** 표 메시지면 처리하고 true — 아니면 false(다른 메시지 갈래로) */ +export async function handleTableMessage( + data: unknown, + notify: (type: string, payload: Record) => void +): Promise { + const message = data as TableMessage | null; + if (!message || typeof message !== 'object') return false; + if (message.type === AISLO_PLACE_TABLE_MESSAGE && message.entity && message.link) { + if (!blocked()) await startPlaceTable(message.entity, message.link); + return true; + } + if (message.type === AISLO_UPDATE_TABLE_MESSAGE && message.link && message.cells) { + const count = blocked() ? 0 : updateLinkedTables(message.link, message.cells); + notify(AISLO_TABLE_UPDATED_MESSAGE, { link: message.link, count }); + return true; + } + return false; +} diff --git a/B07_DesignDetail/openwebcad/src/tools.ts b/B07_DesignDetail/openwebcad/src/tools.ts index 1f051dab2..941badd93 100644 --- a/B07_DesignDetail/openwebcad/src/tools.ts +++ b/B07_DesignDetail/openwebcad/src/tools.ts @@ -113,6 +113,7 @@ export enum Tool { MLEADERSTYLE = 'MLEADERSTYLE', MLEADERALIGN = 'MLEADERALIGN', TABLE = 'TABLE', + PLACETABLE = 'PLACETABLE', TABLESTYLE = 'TABLESTYLE', TABLEEDIT = 'TABLEEDIT', TABLEROW = 'TABLEROW', diff --git a/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts new file mode 100644 index 000000000..ce5331048 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/tools/annotate/place-table-tool.ts @@ -0,0 +1,49 @@ +/** + * 표 놓기(PLACETABLE · M02 41장) — 부모 화면이 보낸 표(면적산출 범위)를 커서에 붙여 따라오게 + * 그리고, 누르면 그 자리(표 왼쪽 위)에 놓는다. Esc = 그만 · 놓기는 되돌리기 한 걸음. + * 놓을 표는 브리지(`integration/aislo-table-bridge.ts`)가 `setPendingTable` 로 맡긴 뒤 이 명령을 켠다. + */ +import type { Point } from '@flatten-js/core'; +import { TableEntity } from '../../entities/TableEntity'; +import { addEntities, getActiveLayerId } from '../../state'; +import { Tool } from '../../tools'; +import { createSequenceTool } from '../factories/sequence-tool'; + +let pending: TableEntity | null = null; + +/** 다음 PLACETABLE 이 놓을 표 — 놓으면 비워진다 */ +export function setPendingTable(table: TableEntity | null): void { + pending = table; +} + +export function getPendingTable(): TableEntity | null { + return pending; +} + +/** `table` 과 같은 꼴(칸 · 크기 · style.연결)을 `at`(왼쪽 위)에 둔 새 표 */ +export function tableAt(table: TableEntity, at: Point, layerId: string): TableEntity { + const placed = new TableEntity( + layerId, + at.clone(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells().map((row) => row.map((cell) => (cell ? { ...cell } : null))), + table.getStyle() + ); + placed.lineColor = table.lineColor; + placed.lineWidth = table.lineWidth; + return placed; +} + +export const placeTableToolStateMachine = createSequenceTool({ + tool: Tool.PLACETABLE, + steps: [{ kind: 'point', instructions: '표를 놓을 자리(왼쪽 위)를 누르십시오 · Esc 그만.' }], + helpers: true, + restart: false, + preview: (input) => (pending ? [tableAt(pending, input.cursor, getActiveLayerId())] : []), + commit: (input) => { + if (!pending) return; + addEntities([tableAt(pending, input.point(0), getActiveLayerId())], true); + pending = null; + }, +}); From c7a8ed7cf9a9c72115c5f619b5aaeccb67283a06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:44:14 +0900 Subject: [PATCH 31/46] =?UTF-8?q?fix(M02):=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=E2=80=94=20=ED=95=9C=20=EC=A4=84=20?= =?UTF-8?q?=ED=91=9C=20=EB=A8=B8=EB=A6=AC=20=E3=80=8C=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=C2=B7=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=C2=B7=20=E2=80=A6=E3=80=8D(=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EA=B8=80)=20=C2=B7=20=EB=82=A8=EC=9D=80?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EA=B8=80=20=E3=80=8C=EB=8B=B4=EC=9D=80?= =?UTF-8?q?=20=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 0bc5606638a2c2cf30f1195039c9785479089c79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:44:36 +0900 Subject: [PATCH 32/46] =?UTF-8?q?feat(M02):=20CAD=20=ED=91=9C=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=B9=B8=20=EA=B0=88=EA=B8=B0=20=ED=98=B8?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=95=A8=EC=88=98=20=E2=80=94=20cad=5Fhos?= =?UTF-8?q?t=20placeTable=20=C2=B7=20updateTable=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EC=86=90=EC=9E=A1=EC=9D=B4=20(41-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cad_host: placeTable(entity, link) · updateTable(link, cells) → 바꾼 표 수 · 도면이 열리기 전이면 열린 뒤 보냄(싣기가 덮지 않게) · 5 초 넘으면 실패 - M02_MasterTemplete_Drawing: DrawingTemplateHandle 에 같은 함수 둘 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../M02_MasterTemplete_Drawing.ts | 5 ++ ui_template/cad_host/cad_host.ts | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts index 7650e1d2a..8aa61bc40 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Drawing.ts @@ -77,6 +77,9 @@ function migrateOldRecovery(options: DrawingTemplateOptions, scope: string): voi export interface DrawingTemplateHandle { getDoc: () => Promise; + /** 표 놓기 · 칸 갈기(41장) — `cad_host` 그대로 */ + placeTable: (entity: Record, link: string) => void; + updateTable: (link: string, cells: unknown[][]) => Promise; destroy: () => void; } @@ -277,6 +280,8 @@ export function mountDrawingTemplate( return { getDoc, + placeTable: cad.placeTable, + updateTable: cad.updateTable, destroy: () => { areaModal?.close(); cad.destroy(); diff --git a/ui_template/cad_host/cad_host.ts b/ui_template/cad_host/cad_host.ts index 88c27c69e..db2f74a87 100644 --- a/ui_template/cad_host/cad_host.ts +++ b/ui_template/cad_host/cad_host.ts @@ -28,6 +28,10 @@ const CAD_HOST_SAVE_MESSAGE = "aislo:b08:host-save"; * CAD 안 react-toastify는 모양·자리가 달라 한 화면에 두 종류가 섞여 보였다. */ const CAD_TOAST_MESSAGE = "aislo:b08:toast"; const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action"; +/** 표(M02 41장) — 짝 = CAD `integration/aislo-table-bridge.ts` */ +const CAD_PLACE_TABLE_MESSAGE = "aislo:b08:place-table"; +const CAD_UPDATE_TABLE_MESSAGE = "aislo:b08:update-table"; +const CAD_TABLE_UPDATED_MESSAGE = "aislo:b08:table-updated"; /** CAD 가 주고받는 도면 JSON — 도형·층 밖의 칸은 페이지마다 다르다. */ export interface CadHostDrawing { @@ -93,6 +97,11 @@ export interface CadHost { ) => void; /** CAD 의 지금 편집본을 받는다. */ requestSave: () => Promise>; + /** 표 놓기(41장) — `entity` = 표 JSON(TableEntity 꼴) · CAD 가 `style.연결 = link` 로 커서에 붙여 놓게 함. + * 도면이 아직 안 열렸으면 열린 뒤 보냄 · 놓기는 사용자가 누를 때 · 저장은 [저장]. */ + placeTable: (entity: Record, link: string) => void; + /** 표 칸 갈기(41장) — `style.연결 === link` 인 표의 칸만(자리 · 크기 그대로) · 돌려받는 수 = 바꾼 표 수(0 = 없음). */ + updateTable: (link: string, cells: unknown[][]) => Promise; /** 메시지 듣기를 멈춘다 — 페이지를 떠날 때. */ destroy: () => void; } @@ -177,6 +186,35 @@ export function createCadHost[] = []; + const postAfterLoad = (message: Record): void => { + if (drawingLoaded) post(message); + else afterLoad.push(message); + }; + const tableWaits = new Map void)[]>(); + + const placeTable: CadHost["placeTable"] = (entity, link) => + postAfterLoad({ type: CAD_PLACE_TABLE_MESSAGE, entity, link }); + + const updateTable: CadHost["updateTable"] = (link, cells) => + new Promise((resolve, reject) => { + const waits = tableWaits.get(link) ?? []; + waits.push(resolve); + tableWaits.set(link, waits); + postAfterLoad({ type: CAD_UPDATE_TABLE_MESSAGE, link, cells }); + window.setTimeout(() => { + const left = tableWaits.get(link) ?? []; + if (!left.includes(resolve)) return; + tableWaits.set( + link, + left.filter((one) => one !== resolve), + ); + reject(new Error("CAD 표 갈기 응답 시간이 초과되었습니다.")); + }, 5000); + }); + const load: CadHost["load"] = ( drawing, meta, @@ -185,6 +223,7 @@ export function createCadHost { pendingLoad = { drawing, meta, frameEdit, frameFields, extra }; + drawingLoaded = false; // 새로 싣는 중 — 표는 열린 뒤에 if (!cadReady) return; post({ type: CAD_LOAD_MESSAGE, drawing, meta, frameEdit, frameFields, ...extra }); pendingLoad = undefined; @@ -215,6 +254,8 @@ export function createCadHost { stopLoadWatchdog(); window.removeEventListener("message", onMessage); From 3ded39875fb22e2f55898a51ef07e77c5248a9bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:45:09 +0900 Subject: [PATCH 33/46] =?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 34/46] =?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 35/46] =?UTF-8?q?feat(spreadsheet):=20=EC=B9=B8=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EC=97=90=20=EC=95=8C=EC=95=BD=20=EC=97=AC?= =?UTF-8?q?=EB=9F=BF=20=E2=80=94=20=EB=8B=A4=EB=A5=B8=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EC=B9=B8=EC=9D=B4=EB=A9=B4=20[=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0]=20=C2=B7=20[=EB=B0=94=EA=BE=B8=EA=B8=B0]=20?= =?UTF-8?q?=C2=B7=20[=EC=B7=A8=EC=86=8C]=20=EB=AC=BC=EC=9D=8C(=EC=95=8C?= =?UTF-8?q?=EC=95=BD=20=EB=88=84=EB=A6=84=20=C2=B7=20=EB=81=8C=EC=96=B4=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=20=C2=B7=20=EC=B9=B8=20=EC=A7=80=EC=A0=95=20?= =?UTF-8?q?=C2=B7=20=EC=97=91=EC=85=80=20=EA=B3=A0=EB=A5=B4=EA=B8=B0)=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=20=EC=83=89=20=3D=20=EB=A8=BC=EC=A0=80=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=80=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EC=98=A4?= =?UTF-8?q?=EB=A5=B8=EC=9C=84=20=EC=88=98=20=C2=B7=20=EB=A7=88=EC=9A=B0?= =?UTF-8?q?=EC=8A=A4=20=EC=98=AC=EB=A6=AC=EB=A9=B4=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EB=93=A4=20=C2=B7=20linkAreaCell=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20(PLAN=2040=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( From cc63e49ab301eea1d3bb80634bc2a306c7eb1334 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:48:34 +0900 Subject: [PATCH 36/46] =?UTF-8?q?feat(B07):=20DXF=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20=ED=91=9C=20=3D=20=EC=84=A0=20+=20?= =?UTF-8?q?=EA=B8=80=20=E2=80=94=20=EC=B9=B8=20=EC=A0=95=EB=A0=AC=20=C2=B7?= =?UTF-8?q?=20=EB=B3=91=ED=95=A9=20=EC=9E=90=EB=A6=AC=20=EB=81=8A=EA=B9=80?= =?UTF-8?q?=20=C2=B7=20B07=20=EC=88=98=EB=9F=89=ED=91=9C=EB=8F=84=20?= =?UTF-8?q?=EB=82=98=EC=98=B4=20(41-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frame_Export: Table 갈래 table_parts(경계선 · 글 자리) · 정렬 MIDDLE_LEFT · CENTER · RIGHT · 높이 = 칸 → 표 글자 크기 · 전에는 Table 이 조용히 빠짐 - 짝 = openwebcad table-geometry.ts(두 파일 머리) · 거울 시험 test_b07_frame_export_table.py + helper_b07_table_geometry.cjs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../B07_DesignDetail_Engine_Frame_Export.py | 105 ++++++++++++++++ .../openwebcad/src/helpers/table-geometry.ts | 3 + .../tester/helper_b07_table_geometry.cjs | 41 ++++++ .../tester/test_b07_frame_export_table.py | 119 ++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 resources/tester/helper_b07_table_geometry.cjs create mode 100644 resources/tester/test_b07_frame_export_table.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py index 535fdaf22..e95c3b958 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Frame_Export.py @@ -6,6 +6,10 @@ DWG 가 필요하면 LibreDWG 의 `dxf2dwg` 를 **별도 실행 파일로** 불 그림(Image)은 DXF 에 그대로 담을 수 없어(외부 파일 참조 방식) 내보내지 않는다. 대신 몇 개를 건너뛰었는지 세어 화면이 알릴 수 있게 돌려준다. + +표(Table · B07 수량표 · M02 41장 면적산출 표)는 선 + 글로 풀어 쓴다. 짝(CLAUDE.md 5장 ①): +`table_parts` ↔ openwebcad `src/helpers/table-geometry.ts`(`tableBorders` · `cellRect` · +`cellTextAnchor`) — 거울 시험 `resources/tester/test_b07_frame_export_table.py`. """ from __future__ import annotations @@ -31,6 +35,14 @@ logger = logging.getLogger(__name__) _DXF_VERSION = "R2018" _DWG_SOURCE_VERSION = "R2004" _DEFAULT_TEXT_MM = 3.0 +#: 표 기본 글자 크기 · 여백 — openwebcad `DEFAULT_TABLE_STYLE` 과 같게 +_TABLE_FONT = 2.2 +_TABLE_PADDING = 1.0 +_ALIGN = { + "left": TextEntityAlignment.MIDDLE_LEFT, + "right": TextEntityAlignment.MIDDLE_RIGHT, + "center": TextEntityAlignment.MIDDLE_CENTER, +} def _xy(point: Any) -> tuple[float, float] | None: @@ -47,6 +59,96 @@ def _layer_name(raw: Any) -> str: return name[:255] or "0" +def _span(value: Any) -> int: + """병합 칸 수 — TS `Math.max(1, Math.round(v ?? 1))` 와 같게(반올림은 .5 올림).""" + number = float(value) if isinstance(value, (int, float)) else 1.0 + return max(1, math.floor(number + 0.5)) + + +def _owners(cells: list, rows: int, columns: int) -> list[list[str | None]]: + owners: list[list[str | None]] = [[None] * columns for _ in range(rows)] + for row in range(rows): + for column in range(columns): + cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None + if not isinstance(cell, dict): + continue + for r in range(row, min(rows, row + _span(cell.get("rowSpan")))): + for c in range(column, min(columns, column + _span(cell.get("colSpan")))): + owners[r][c] = f"{row}:{column}" + return owners + + +def table_parts(shape: dict[str, Any]) -> tuple[list[tuple], list[dict[str, Any]]]: + """표 → (경계선 [(x1, y1, x2, y2)], 글 [{text, x, y, align, height}]) — 원점 = 왼쪽 위 · 아래로. + + 바깥 테두리는 늘 · 안쪽 선은 양옆(위아래)이 같은 병합 칸이면 끊음 · 글은 칸 가운데 높이 · + 정렬에 따라 여백만큼 안쪽.""" + origin = _xy(shape.get("origin")) or (0.0, 0.0) + widths = [float(w) for w in shape.get("columnWidths") or []] + heights = [float(h) for h in shape.get("rowHeights") or []] + cells = shape.get("cells") or [] + style = shape.get("style") or {} + rows, columns = len(heights), len(widths) + if not rows or not columns: + return [], [] + xs, ys = [origin[0]], [origin[1]] + for width in widths: + xs.append(xs[-1] + width) + for height in heights: + ys.append(ys[-1] - height) + owners = _owners(cells, rows, columns) + lines = [ + (xs[0], ys[0], xs[columns], ys[0]), + (xs[0], ys[rows], xs[columns], ys[rows]), + (xs[0], ys[0], xs[0], ys[rows]), + (xs[columns], ys[0], xs[columns], ys[rows]), + ] + for column in range(1, columns): + for row in range(rows): + left, right = owners[row][column - 1], owners[row][column] + if left is None or left != right: + lines.append((xs[column], ys[row], xs[column], ys[row + 1])) + for row in range(1, rows): + for column in range(columns): + above, below = owners[row - 1][column], owners[row][column] + if above is None or above != below: + lines.append((xs[column], ys[row], xs[column + 1], ys[row])) + raw = style.get("padding") + padding = float(raw) if isinstance(raw, (int, float)) else _TABLE_PADDING + texts = [] + for row in range(rows): + for column in range(columns): + cell = cells[row][column] if row < len(cells) and column < len(cells[row]) else None + if not isinstance(cell, dict) or not cell.get("text"): + continue + right = xs[min(columns, column + _span(cell.get("colSpan")))] + bottom = ys[min(rows, row + _span(cell.get("rowSpan")))] + align = cell.get("align") if cell.get("align") in _ALIGN else "center" + x = {"left": xs[column] + padding, "right": right - padding}.get( + align, (xs[column] + right) / 2 + ) + height = cell.get("fontSize") or style.get("fontSize") or _TABLE_FONT + texts.append( + { + "text": str(cell["text"]), + "x": x, + "y": (ys[row] + bottom) / 2, + "align": align, + "height": float(height), + } + ) + return lines, texts + + +def _add_table(space: Any, shape: dict[str, Any], attribs: dict[str, Any]) -> None: + lines, texts = table_parts(shape) + for x1, y1, x2, y2 in lines: + space.add_line((x1, y1), (x2, y2), dxfattribs=attribs) + for one in texts: + text = space.add_text(one["text"], dxfattribs={**attribs, "height": one["height"]}) + text.set_placement((one["x"], one["y"]), align=_ALIGN[one["align"]]) + + def _add_entity( space: Any, entity: dict[str, Any], layers: set[str], skipped: dict[str, int] ) -> None: @@ -65,6 +167,9 @@ def _add_entity( # 그림은 DXF 가 외부 파일을 가리키는 방식이라 그대로 옮기지 못한다. skipped["Image"] = skipped.get("Image", 0) + 1 return + if kind == "Table": # 표 = 선 + 글(칸 정렬 반영) + _add_table(space, shape, attribs) + return start, end = _xy(shape.get("startPoint")), _xy(shape.get("endPoint")) if start and end: diff --git a/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts b/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts index aa74e65fd..8b416674f 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/table-geometry.ts @@ -4,6 +4,9 @@ * * 표는 셀 격자와 셀 문자만 책임진다. 표 위에 얹는 그림(종단표의 종단곡선 표시선 같은 것)은 * 별도 엔티티로 겹쳐 그린다 — 그래야 나중에 종단표도 이 표 객체로 옮길 수 있다. + * + * 짝(CLAUDE.md 5장 ①): 서버 DXF 내보내기 `B07_DesignDetail_Engine_Frame_Export.table_parts` + * (`tableBorders` · `cellRect` · `cellTextAnchor`) — 거울 시험 `test_b07_frame_export_table.py`. */ export interface TableCell { diff --git a/resources/tester/helper_b07_table_geometry.cjs b/resources/tester/helper_b07_table_geometry.cjs new file mode 100644 index 000000000..a6eeab295 --- /dev/null +++ b/resources/tester/helper_b07_table_geometry.cjs @@ -0,0 +1,41 @@ +/* 표 기하(`openwebcad/src/helpers/table-geometry.ts`) 거울 헬퍼 — 서버 DXF `table_parts` 와 대조. + * stdin JSON [표 shapeData] → stdout JSON [{lines, texts}] (TS 규칙 그대로). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "B07_DesignDetail", + "openwebcad", + "src", + "helpers", + "table-geometry.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const g = box.exports; + +const shapes = JSON.parse(fs.readFileSync(0, "utf8")); +const out = shapes.map((s) => { + const lines = g + .tableBorders(s.origin, s.columnWidths, s.rowHeights, s.cells) + .map((b) => [b.x1, b.y1, b.x2, b.y2]); + const padding = s.style?.padding ?? 1; + const texts = []; + for (let row = 0; row < s.rowHeights.length; row += 1) + for (let column = 0; column < s.columnWidths.length; column += 1) { + const cell = s.cells[row]?.[column]; + if (!cell?.text) continue; + const rect = g.cellRect(s.origin, s.columnWidths, s.rowHeights, row, column, cell); + const at = g.cellTextAnchor(rect, cell.align, padding); + texts.push({ text: cell.text, x: at.x, y: at.y, align: cell.align ?? "center" }); + } + return { lines, texts }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_b07_frame_export_table.py b/resources/tester/test_b07_frame_export_table.py new file mode 100644 index 000000000..ebf2dc9b5 --- /dev/null +++ b/resources/tester/test_b07_frame_export_table.py @@ -0,0 +1,119 @@ +"""DXF 내보내기 표 = 선 + 글(PLAN 41-2) — `B07_DesignDetail_Engine_Frame_Export`. + +- 거울: `table_parts` 경계선 · 글 자리 = openwebcad `table-geometry.ts`(Node 헬퍼) — + 병합 · 정렬 · 여백 +- DXF: M02 면적산출 표 · B07 수량표(`table_entity`) 모두 LINE · TEXT 로 나옴(전에는 조용히 빠짐) · + 글 정렬(MIDDLE_LEFT · CENTER · RIGHT) · 높이 = 칸 글자 크기 → 표 글자 크기 +""" + +from __future__ import annotations + +import json +import os +import subprocess + +import ezdxf +import pytest + +from B07_DesignDetail import B07_DesignDetail_Engine_Frame_Export as export +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity + +HERE = os.path.dirname(os.path.abspath(__file__)) + +AREA_TABLE = { + "origin": {"x": 10, "y": 50}, + "columnWidths": [30, 15, 15], + "rowHeights": [5, 5, 5], + "cells": [ + [{"text": "항목", "colSpan": 3}, None, None], + [ + {"text": "면적", "rowSpan": 2, "align": "left"}, + {"text": "12.5", "align": "right"}, + {"text": "㎡"}, + ], + [None, {"text": "1,250", "align": "right", "fontSize": 3}, {"text": ""}], + ], + "style": {"fontSize": 2.5, "padding": 1, "연결": "T1"}, +} +PLAIN = { + "origin": {"x": 0, "y": 0}, + "columnWidths": [10, 10], + "rowHeights": [4], + "cells": [[{"text": "a"}, {"text": "b", "align": "left"}]], + "style": {"padding": 0}, +} + + +def _mirror(shapes: list[dict]) -> list[dict]: + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b07_table_geometry.cjs")], + input=json.dumps(shapes, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_거울_경계선_글_자리가_TS_와_같음(): + shapes = [AREA_TABLE, PLAIN] + for shape, ts in zip(shapes, _mirror(shapes)): + lines, texts = export.table_parts(shape) + flat = [value for line in lines for value in line] + assert flat == pytest.approx([value for line in ts["lines"] for value in line]) + assert len(lines) == len(ts["lines"]) + assert [(t["text"], t["x"], t["y"], t["align"]) for t in texts] == [ + (t["text"], pytest.approx(t["x"]), pytest.approx(t["y"]), t["align"]) + for t in ts["texts"] + ] + + +def test_병합_자리는_선을_끊음(): + lines, _ = export.table_parts(AREA_TABLE) + # 바깥 4 · 안쪽 세로 4(1행 병합 자리 끊김) · 가로 5(「면적」 세로 병합 자리 끊김) + vertical = [line for line in lines[4:] if line[0] == line[2]] + horizontal = [line for line in lines[4:] if line[1] == line[3]] + assert len(vertical) == 4 and len(horizontal) == 5 + + +def _dxf(entities: list[dict], tmp_path) -> ezdxf.document.Drawing: + raw, skipped = export.drawing_to_dxf({"entities": entities, "layers": []}) + assert skipped == {} + path = tmp_path / "out.dxf" + path.write_bytes(raw) + return ezdxf.readfile(path) + + +def test_면적산출_표가_LINE_TEXT_로_나옴(tmp_path): + doc = _dxf([{"type": "Table", "layerId": "기본", "shapeData": AREA_TABLE}], tmp_path) + lines, texts = export.table_parts(AREA_TABLE) + space = doc.modelspace() + assert len(space.query("LINE")) == len(lines) == 13 + found = {t.dxf.text: t for t in space.query("TEXT")} + assert set(found) == {"항목", "면적", "12.5", "㎡", "1,250"} and len(texts) == 5 + assert found["면적"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_LEFT + assert found["12.5"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_RIGHT + assert found["항목"].get_placement()[0] == ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER + assert found["1,250"].dxf.height == 3 and found["항목"].dxf.height == 2.5 + + +def test_B07_수량표도_같이_나옴(tmp_path): + qtable = table_entity( + "t:qtable", + (0, 0), + [20, 20], + [5, 5], + [ + [{"text": "토공", "colSpan": 2}, None], + [{"text": "절토"}, {"text": "12.30", "align": "right"}], + ], + "b08-cross-table", + "#ffffff", + 2.0, + "#ffffff", + ) + space = _dxf([qtable], tmp_path).modelspace() + assert len(space.query("LINE")) == 7 # 바깥 4 · 세로 1(1행 병합 자리 끊김) · 가로 2 + assert sorted(t.dxf.text for t in space.query("TEXT")) == ["12.30", "절토", "토공"] From 4db230bfffd1d6fedadc7da99e94e353f963b3ed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:53:25 +0900 Subject: [PATCH 37/46] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20[=EC=97=91=EC=85=80=EC=97=90=EC=84=9C=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0]=20=E2=80=94=20=EA=B3=A0=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=20=EB=8B=B5=EC=9D=98=20=EB=B0=94=EA=BE=B8=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?linkAreaCell=20=EB=8B=A4=EC=84=AF=EC=A7=B8=20=EC=9D=B8=EC=9E=90?= =?UTF-8?q?=EB=A1=9C(=EA=B8=B0=EB=B3=B8=20=ED=95=A8=EA=BB=98=20=C2=B7=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B8=B0=EB=A9=B4=20=EA=B7=B8=20=EC=B9=B8=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=95=8C=EC=95=BD=20=EB=BA=8C=20=C2=B7=20?= =?UTF-8?q?40=20=EC=9D=B4=EC=9D=8C)?= 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_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor_Detail.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 21fc12072..b72958ff9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -196,10 +196,16 @@ export function mountLaborDetail( }); if (!got || !alive) return false; const info = pills.get(pill); - const next = linkAreaCell(area.doc, pill, got.칸, { - ...(info?.이름 ? { 이름: info.이름 } : {}), - ...(info?.단위 ? { 단위: info.단위 } : {}), - }); + const next = linkAreaCell( + area.doc, + pill, + got.칸, + { + ...(info?.이름 ? { 이름: info.이름 } : {}), + ...(info?.단위 ? { 단위: info.단위 } : {}), + }, + got.바꾸기 === true, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 고르기 모달 안) + ); if (!next) { showToast(tc("Labor_BadCell"), "error"); return false; From b2aa2a987e5ed682c44b0078f4c621b9efd629a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:56:28 +0900 Subject: [PATCH 38/46] =?UTF-8?q?fix(B07=20CAD):=20=ED=91=9C=20=EB=86=93?= =?UTF-8?q?=EA=B8=B0=20=E2=80=94=20=EB=B3=B4=EB=82=B8=20=ED=91=9C=20?= =?UTF-8?q?=EC=84=A0=20=EC=83=89=20=C2=B7=20=EA=B5=B5=EA=B8=B0=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20(41-2)?= 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_01UoSr2sqvYtBf1dtQdus88s --- .../integration/aislo-table-bridge.test.ts | 7 ++++++- .../src/integration/aislo-table-bridge.ts | 20 +++++++++---------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts index 4057df8d7..f11cab527 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -52,13 +52,18 @@ describe('표 놓기(PLACETABLE)', () => { it('place-table 메시지 = 연결을 단 표를 맡기고 PLACETABLE 을 켬', async () => { const notify = vi.fn(); const handled = await handleTableMessage( - { type: AISLO_PLACE_TABLE_MESSAGE, entity: await table().toJson(), link: 'T7' }, + { + type: AISLO_PLACE_TABLE_MESSAGE, + entity: { ...(await table().toJson()), lineColor: '#123456' }, + link: 'T7', + }, notify ); expect(handled).toBe(true); expect(run).toHaveBeenCalledWith('PLACETABLE'); expect(getPendingTable()?.getStyle().연결).toBe('T7'); expect(getPendingTable()?.getCell(0, 0)?.colSpan).toBe(2); + expect(getPendingTable()?.lineColor).toBe('#123456'); expect(notify).not.toHaveBeenCalled(); }); diff --git a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts index 6ac5c13d1..4965308e9 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.ts @@ -40,17 +40,17 @@ export function updateLinkedTables(link: string, cells: TableCells): number { /** 표 JSON 을 놓기 도구에 맡기고 도구를 켬 */ export async function startPlaceTable(entity: JsonEntity, link: string): Promise { const table = await TableEntity.fromJson(entity as Parameters[0]); - const style = table.getStyle(); - setPendingTable( - new TableEntity( - table.layerId, - table.getOrigin(), - table.getColumnWidths(), - table.getRowHeights(), - table.getCells(), - { ...style, 연결: link } - ) + const linked = new TableEntity( + table.layerId, + table.getOrigin(), + table.getColumnWidths(), + table.getRowHeights(), + table.getCells(), + { ...table.getStyle(), 연결: link } ); + linked.lineColor = table.lineColor; // 보낸 선 색 · 굵기 그대로 + linked.lineWidth = table.lineWidth; + setPendingTable(linked); runCommandInput('PLACETABLE'); } From 48a746d5b7f219f156f62ece220d3e066be5429d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:00:05 +0900 Subject: [PATCH 39/46] =?UTF-8?q?fix(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=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=E2=80=94=20=EB=A9=B4=EC=A0=81=20=EC=83=81=EC=84=B8=EA=B0=80=20?= =?UTF-8?q?=EC=84=A0=20=EB=92=A4=EC=97=90=EB=8F=84=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=B2=AB=20=EA=B3=A0=EB=A6=84=20=EC=9C=A0=EC=A7=80=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC(41-3)?= 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_01BVPp2o6Mh2FZJr72yW3ofZ --- M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index 3f8878431..cb51508ff 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -65,6 +65,7 @@ const TEXT = { saveFirst: "저장 안 한 고침이 있음 — 저장하고 보낼까요?", saveLabel: "저장하고 보내기", failed: "표를 못 만듦 — {value}", + noRange: "보낼 범위를 시트에서 먼저 고름", place: "도면에서 표를 놓을 자리를 누름(Esc = 그만)", noPlace: "이 도면 화면은 표 놓기를 아직 못 함", stale: "면적산출 값이 바뀐 표 {n}개", @@ -175,7 +176,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { }); async function send(at: Selection): Promise { - if (!range) return; + if (!range) return showToast(TEXT.noRange, "error"); if (opt.isDirty()) { if (!(await showConfirmDialog(TEXT.saveFirst, TEXT.saveLabel))) return; await opt.save(); @@ -247,7 +248,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { return { select: (s) => (range = { 시트: s.시트, 범위: s.범위 }), onArea: (at, readOnly, slot) => { - range = null; + // 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림 if (readOnly || at.layer === "project") return; const button = createButton({ label: TEXT.send, variant: "ghost", size: "sm" }); button.title = TEXT.sendTip; @@ -258,6 +259,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { onDrawing: (raw, at, doc) => { const handle = raw as TableHandle; shown = `${at.layer}:${at.name}`; + range = null; // 면적 상세를 떠남 fresh = new Map(); latest = new Map(); const give = From be2e677d27077413178f1f348b29cf0cd20d483c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:00:27 +0900 Subject: [PATCH 40/46] =?UTF-8?q?test(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=EC=A0=80=EC=9E=A5=20=EA=BC=B4=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=97=B0=EA=B2=B0=ED=91=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=EA=B0=88=EA=B3=A0=EB=A6=AC(toDrawing.with?= =?UTF-8?q?Links)=20=EA=B0=90=EC=8C=88(41-3)?= 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_01BVPp2o6Mh2FZJr72yW3ofZ --- resources/tester/test_m02_structure_panel.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 67678a9d9..be79312be 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -92,7 +92,11 @@ def test_집계표만_컨테이너를_붙임(): body, ) # 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로 - assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body + # 도면 칸만 갈아 끼움 · 맨 위 연결표는 도면 표에 맞춤(41-3 `toDrawing.withLinks`) + assert re.search( + r'toDrawing\.withLinks\(\{\s*\.\.\.\(loaded as StructureDoc\),\s*도면: edited as StructureDoc\["도면"\],\s*\}\)', + body, + ) @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") From 2563272c69d2864f447e0e01edb959ea22819033 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:30:40 +0900 Subject: [PATCH 41/46] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=B9=B4=EB=93=9C=20=EB=B9=88=20=EC=B1=84=20?= =?UTF-8?q?=EB=82=A8=EC=9D=8C=20=E2=80=94=20=EC=B9=B4=EB=93=9C=20=E3=80=8C?= =?UTF-8?q?=EB=B6=88=EB=9F=AC=EC=98=A4=EB=8A=94=20=EC=A4=91=E3=80=8D=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=97=90=EC=84=9C=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20=EB=BA=8C(?= =?UTF-8?q?=EB=AA=B8=20Labor=5FView=20=EB=A5=BC=20=EB=8D=AE=EC=97=88?= =?UTF-8?q?=EB=8B=A4=20=EC=A7=80=EC=9A=B0=EB=8D=98=20=EA=B2=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 40 · 41 끝 검증 D1 — 도면 · 면적 읽기가 0.3초 넘을 때 · 일위대가 몸은 제 「불러오는 중」. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts | 5 +++-- resources/tester/test_ui_loading.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index f62db757e..3e6a63430 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -532,8 +532,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.host.replaceChildren(); } const source = opts.source(); - // 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) - const stops = cards.map((c) => c.shell.loading()); + // 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌: + // 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1) + const stops = cards.filter((c) => c !== laborCard).map((c) => c.shell.loading()); const [drawing, ...books] = await Promise.allSettled([ source.read("structure", col.id), ...sheets.map((c) => source.read(c.kind, col.id)), diff --git a/resources/tester/test_ui_loading.py b/resources/tester/test_ui_loading.py index 8ea31872e..f065511eb 100644 --- a/resources/tester/test_ui_loading.py +++ b/resources/tester/test_ui_loading.py @@ -82,5 +82,7 @@ def test_실패_글과_다시_불러오기_M02_자리(): assert "const stop = showLoading(view);" in practice assert "loadingError(why(error), () => void drawList())" in practice assert "loadingError(why(error), () => void drawOne(key))" in practice - assert "cards.map((c) => c.shell.loading())" in _text(M02 / "M02_MasterTemplete_ItemCards.ts") + cards = _text(M02 / "M02_MasterTemplete_ItemCards.ts") + # 일위대가 카드는 뺌 — 몸(Labor_View)을 덮었다 지우면 빈 카드(40 D1) + assert "cards.filter((c) => c !== laborCard).map((c) => c.shell.loading())" in cards assert "loading: () => showLoading(host)," in _text(M02 / "M02_MasterTemplete_CardShell.ts") From 18d097c745ad6443f332a7b5f5e06b4fe97978a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:32:37 +0900 Subject: [PATCH 42/46] =?UTF-8?q?fix(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=ED=95=9C=20=EC=A4=84=20=ED=91=9C=20=EC=95=84?= =?UTF-8?q?=EC=A3=BC=20=EC=A2=81=EC=9D=80=20=EC=B9=B4=EB=93=9C(340px=20?= =?UTF-8?q?=EC=9D=B4=ED=95=98)=20=E2=80=94=20=EC=A4=84=EB=A7=88=EB=8B=A4?= =?UTF-8?q?=20=EB=91=90=20=EC=A4=84(=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/=20=EA=B8=88=EC=95=A1?= =?UTF-8?q?=20=EB=84=B7)=20=C2=B7=20=EA=B0=80=EB=A1=9C=20=EB=84=98?= =?UTF-8?q?=EC=B9=A8=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 머리 글 그대로 · 같은 꼴 두 줄 · 금액 11px · 카드 260 · 280 · 316 · 360 · 400 · 560 에서 scrollWidth = clientWidth · 잘린 칸 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 6bccbd95a..220c81a2a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -282,6 +282,52 @@ } } +/* 더 좁으면(카드 320px 안팎 · 340px 이하) 줄마다 두 줄 — 윗줄 항목(3칸) · 수량산출(1칸) · 아랫줄 금액 넷 · + 머리 줄도 같은 꼴(글은 그대로) · 가로 넘침 없음 */ +@container m02-labview (max-width: 340px) { + .m02-labline, + .m02-labline thead, + .m02-labline tbody, + .m02-labline tfoot { + display: block; + width: 100%; + } + + .m02-labline tr { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--color-border); + } + + .m02-labline th, + .m02-labline td { + display: block; + min-width: 0; + padding: 2px; + border: 0; + } + + .m02-labline th:first-child, + .m02-labline .m02-labrow__name { + grid-column: span 3; + } + + .m02-labline th:nth-child(2), + .m02-labline .m02-labrow__qty { + text-align: right; + white-space: normal; + } + + .m02-labline th:nth-child(3), + .m02-labline .m02-labrow__unit { + display: none; + } + + .m02-labline .m02-labrow__num { + font-size: 11px; + } +} + /* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */ .m02-labrow__qty { white-space: nowrap; From 6a721484c9eeda9d7d963d37ee12193654502846 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:34:18 +0900 Subject: [PATCH 43/46] =?UTF-8?q?fix(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=ED=9D=A0=20D3=20?= =?UTF-8?q?=C2=B7=20D4=20=E2=80=94=20=EB=B9=88=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=A7=89=EC=9D=8C=20=C2=B7=20=EB=86=93=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=8B=A4=EB=A6=AC=EB=8A=94=20=ED=91=9C=EB=8F=84=20=EB=96=A0?= =?UTF-8?q?=EB=82=A0=20=EB=95=8C=20=EB=AC=BC=EC=9D=8C(41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D3 보이는 글이 없는 범위 = 크기 창 전에 「보낼 칸에 내용이 없음」 · 도면 안 엶 - D4 보낸 표 놓기 전 떠남(다시 열기 · 머리 메뉴 · 돌아가기) = 「저장 안 한 고침」 물음(취소 = 그대로 놓기 · 확인 = 버림) — busy 에 toDrawing.waiting · 확인 때 drop Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_AreaToDrawing.ts | 32 +++++++++++++++---- .../M02_MasterTemplete_UI_Main.ts | 3 +- resources/tester/test_m02_area_to_drawing.py | 24 ++++++++++++-- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts index cb51508ff..0d3bcc8d2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts @@ -66,6 +66,7 @@ const TEXT = { saveLabel: "저장하고 보내기", failed: "표를 못 만듦 — {value}", noRange: "보낼 범위를 시트에서 먼저 고름", + empty: "보낼 칸에 내용이 없음", place: "도면에서 표를 놓을 자리를 누름(Esc = 그만)", noPlace: "이 도면 화면은 표 놓기를 아직 못 함", stale: "면적산출 값이 바뀐 표 {n}개", @@ -129,6 +130,10 @@ export interface AreaToDrawing { onArea: (at: Selection, readOnly: boolean, slot: HTMLElement) => void; /** 도면 상세가 섰음 — 넘겨받은 표 놓기 · 연결표 다름 알림 */ onDrawing: (handle: unknown, at: Selection, doc: StructureDoc) => void; + /** 보낸 표 놓기 기다리는 중 — 편집 칸 「저장 안 한 고침」 에 듦 */ + waiting: () => boolean; + /** 떠나기로 함(물음 [확인]) — 기다리던 표 버림 */ + drop: () => void; /** 도면 저장 문서 — 맨 위 `연결표` 를 도면의 표에 맞춤 */ withLinks: (doc: StructureDoc) => StructureDoc; } @@ -141,6 +146,8 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { let fresh = new Map(); let latest = new Map>(); let shown: string | null = null; + /** 보낸 표가 도면에 놓이길 기다림 — 떠날 때 「저장 안 한 고침」 물음(`waiting`) */ + let waiting = false; /** 글자 크기 작은 창 — 보내기면 크기 · 닫으면 null */ const askSize = (spec: { 범위: string }): Promise => @@ -183,15 +190,20 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { if (opt.isDirty()) return; // 저장 실패 — 알림은 저장 길이 띄움 } const picked = range; + const get = (size: number): Promise => + fetchTable(at.layer, at.name, { 시트: picked.시트, 범위: picked.범위, 글자크기: size }).catch( + (error: unknown) => (showToast(fill(TEXT.failed, "value", why(error)), "error"), null), + ); + // 보이는 글이 하나도 없는 범위(빈 칸만)는 크기를 묻기 전에 막음 + let got = await get(SIZE); + if (!got) return; + if (!Object.values(got.값 ?? {}).some((v) => String(v ?? "").trim())) { + return showToast(TEXT.empty, "error"); + } const size = await askSize(picked); if (size === null) return; - const spec: LinkSpec = { 시트: picked.시트, 범위: picked.범위, 글자크기: size }; - let got: AreaTable; - try { - got = await fetchTable(at.layer, at.name, spec); - } catch (error) { - return showToast(fill(TEXT.failed, "value", why(error)), "error"); - } + if (size !== SIZE) got = await get(size); + if (!got) return; pending = { layer: at.layer, column: at.name, got, 글자크기: size }; await opt.open({ layer: at.layer, @@ -247,7 +259,10 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { return { select: (s) => (range = { 시트: s.시트, 범위: s.범위 }), + waiting: () => waiting, + drop: () => (waiting = false), onArea: (at, readOnly, slot) => { + waiting = false; // 범위는 그대로 — 시트가 처음 고름(A1)을 이 갈고리보다 먼저 알림 if (readOnly || at.layer === "project") return; const button = createButton({ label: TEXT.send, variant: "ghost", size: "sm" }); @@ -257,6 +272,7 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { slot.append(button); }, onDrawing: (raw, at, doc) => { + waiting = false; const handle = raw as TableHandle; shown = `${at.layer}:${at.name}`; range = null; // 면적 상세를 떠남 @@ -273,12 +289,14 @@ export function createAreaToDrawing(opt: AreaToDrawingOptions): AreaToDrawing { const style = { ...(표.shapeData.style ?? {}), [LINK]: id }; fresh.set(id, { 연결: id, 시트, 범위, 글자크기: give.글자크기, 값 }); handle.placeTable({ ...표, shapeData: { ...표.shapeData, style } }, id); + waiting = true; // 놓기 전 떠나도 물음(놓으면 도면 고침 · [저장] 때 풂) showToast(TEXT.place, "info"); } } void checkStale(handle, at, doc); }, withLinks: (doc) => { + waiting = false; const old = new Map((doc.연결표 ?? []).map((row) => [row.연결, row])); const rows: TableLink[] = []; for (const e of doc.도면?.entities ?? []) { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 7e6d10079..13630fac5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -185,7 +185,7 @@ export function createMain( let seq = 0; let dirty = false; /** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 31-3) */ - const busy = (): boolean => dirty || !!panel?.dirty(); + const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting(); // + 놓기 기다리는 표(41) const guard = (event: BeforeUnloadEvent): void => { if (!root.isConnected) return window.removeEventListener("beforeunload", guard); @@ -204,6 +204,7 @@ export function createMain( return false; } dirty = false; + toDrawing.drop(); return true; }; setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음 diff --git a/resources/tester/test_m02_area_to_drawing.py b/resources/tester/test_m02_area_to_drawing.py index b0cf649be..b0e1a1923 100644 --- a/resources/tester/test_m02_area_to_drawing.py +++ b/resources/tester/test_m02_area_to_drawing.py @@ -94,6 +94,9 @@ globalThis.fetch = async (url, init) => { 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])); + const waitPlaced = ad.waiting(); // 놓기 기다림 = 저장 안 한 고침 + ad.drop(); + const waitDropped = ad.waiting(); await new Promise((r) => setTimeout(r, 20)); const notice = notices.at(-1) ?? []; const upd = notice.find((x) => x && x.on); @@ -109,7 +112,14 @@ globalThis.fetch = async (url, init) => { 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 })); + // 빈 칸만 고른 범위 — 크기를 묻기 전에 막고 알림 · 도면 안 엶 + now = [["", " "]]; + const openedBefore = opened.length; + ad.select({ 시트: "산출", 칸: "E1", 범위: "E1:F1" }); + await slot.kids[0].on.click(); + await new Promise((r) => setTimeout(r, 20)); + const empty = { opened: opened.length - openedBefore, toast: toasts.at(-1) }; + 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, waitPlaced, waitDropped, waitSaved: ad.waiting(), empty })); })(); """ @@ -163,4 +173,14 @@ def test_다른_열_도면에는_안_놓음(got): def test_편집_칸은_갈고리만(): main = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts").read_text("utf-8") - assert main.count("toDrawing.") == 4 # select · onArea · onDrawing · withLinks + # select · onArea · onDrawing · withLinks · waiting(저장 안 한 고침) · drop(떠나기 확인) + assert main.count("toDrawing.") == 6 + assert "|| toDrawing.waiting()" in main.split("const busy = ")[1].splitlines()[0] + + +def test_놓기_기다림은_저장_안_한_고침(got): + assert got["waitPlaced"] is True and got["waitDropped"] is False and got["waitSaved"] is False + + +def test_빈_범위는_막고_알림(got): + assert got["empty"] == {"opened": 0, "toast": ["error", "보낼 칸에 내용이 없음"]} From 51cfb6ff444fb418c5e2f9ed720e282d4b1e2bb5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:46:21 +0900 Subject: [PATCH 44/46] =?UTF-8?q?chore(plan):=20=EB=81=9D=EB=82=9C=2039~41?= =?UTF-8?q?=EC=9E=A5(M02=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EA=B0=9C=ED=8E=B8=201=20=C2=B7=202=EC=B0=A8=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=91=9C=20?= =?UTF-8?q?=E2=86=92=20=EB=8F=84=EB=A9=B4=20=ED=91=9C)=EC=9D=84=20docs/raw?= =?UTF-8?q?/plans=20=EB=A1=9C=20=EC=9D=B4=EA=B4=80=20=E2=80=94=20PLAN.md?= =?UTF-8?q?=20=EB=8A=94=201=EC=9E=A5=20=C2=B7=2032=EC=9E=A5=20=C2=B7=2042?= =?UTF-8?q?=EC=9E=A5(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=80=EC=A6=9D)?= 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_01FJqWQpoB1RE7hiaQ2wH8qV --- ...10-04_plan_M02구조물조합개편_2차_도면표.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md diff --git a/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md b/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md new file mode 100644 index 000000000..03f77c17e --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md @@ -0,0 +1,78 @@ +# 지난 계획 — 39~41장 (2026-10-04) + +M02 구조물 조합 개편(일위대가 = 구조물마다 · 재료산출 · 선택 입력 지움 · 실무문서 · 권한 · 오른쪽 클릭) · 2차 수정(단가산출 로직 · 수량산출 열 · 카드 3열 · 삭제 · 알약 끌어 놓기 · 색) · 면적산출 표 → 도면 표. 검증은 사용자(10-4). + +## 32장에서 39 로 넘긴 줄 + +- [X] 선택 입력 정리(조사 10-1 끝 · 고침은 사용자 지시 뒤) — 일위대가 「넣을 값」과 구조물 「선택 입력」 이름 · 값 칸 규칙 두 벌 · 사라진 입력 안 알림 · 항목마다 자리 다시 고름 · 39 로 넘김(10-4 · 선택 입력 지움 · 값 칸 규칙 한 벌 · 사라진 값 알림은 39-3) +- [X] 로직 지우기 서버 검사가 마스터 층 조합만 봄(10-1 sub4) — 회사 · 개인 조합에 담긴 로직도 막기(`M01_MasterData_Store_Combo.py:194` · `Store_Cache.py:220` · 쉬운 길은 화면에서 미리 찾아 알림) · 39 로 넘김(10-4 · 39-1b 로직 지우기 검사 = 모든 층 labor) + +## 39. M02 구조물 조합 개편 (사용자 지시 10-4) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004.md`(2 일위대가 → 구조물 조합 하위 · 3 이름 · 4 구분선 · 5 권한 · 6 항목 변경 · 9 오른쪽 클릭) +- 사용자 확정(일위대가): 구조물 조합에 일위대가 · 도면 · 면적산출이 엮임 · 구조물 조합 복사 = 일위대가 새 키(로직 · 값 같이 복사) · 새 구조물 조합 = 미리보기에서 「새로 만들기」 / 「복사 생성」(목록 · 기본 탭 = 일반 개인 · 회사 관리자 개인 · 시스템 관리자 마스터 · 탭으로 다른 곳) · 항목 하나만 다른 것을 복사해 바꿈(저장 뒤에도) · 매칭 안 된 일위대가 조합 = 마스터 「실무문서」 항목으로 따로(나중에 한 번에 지움) +- 사용자 확정(10-4): 원본 단가산출 로직(resources/master_data 로직 · 표 · 요소 · 마스터 일위대가조합.json)은 절대 손대지 않음(읽기만) · 입력값은 사용자 · 설계자가 일위대가에 정해 저장 → 프로젝트로 복사해 씀 +- 정함(브레인): 조사 먼저(창마다 자기 몫 · 코드 안 고침) → 브레인이 설계 정함(이 절에 줄 더함) → 구현 · 조사 보고 = 각 창 `tmp/조사_39-0*.md` + +- [X] 39-0 조사(보고 여덟 · 정함 = `docs/raw/M02_구조물조합개편/설계_정함.md`) — A 좌측 패널 · 이름 · 구분선(sub5) · B 자료 틀(일위대가 입력값 · 자재목록 · 수량 연결 자리 · sub7) · C 일위대가 상세(sub2) · D 면적산출(sub3) · E 권한(sub4) · F 오른쪽 클릭(sub8) · G 재료산출 · 선택 입력 지우기 영향(sub6) · H 「전체 항목」 목록 화면(sub1) +- [X] 39-9a 오른쪽 클릭 막기(sub8) — 정함(브레인): 공용 진입 한 곳 창 캡처 듣기 · 대상 주소 `/^[bm]\d/` · 글 입력 칸(읽기 전용 포함)은 브라우저 메뉴 살림 · 입력 칸 밖 고른 글은 막음 · CAD 는 cad_host iframe load 때 같은 함수 · 남은 막기 코드 그대로(주석 셋만) · 근거 sub8 `tmp/조사_39-0F.md` — 확인 = 새 시험 test_common_native_menu.py · ORCA `defaultPrevented` 수치(B01 · B05 · B07 · M01 · M02 참 · 입력 칸 · A01 거짓) + - 검증완료(sub8 · 0f8efaa42) — 시험 4 통과 · ORCA 5183 B01 · B05(노선 편집 패널 포함) · B07 · M01 · M02 본문 · 헤더 · 모달 · CAD iframe 리본 · 패널 · 캔버스 막힘 참 · 입력 칸(CAD 안 포함) · A01 거짓 · CAD 오른쪽 단추 확정 그대로 +- [X] 39-9b 메뉴 공통 양식(sub8) — 공용 CSS 한 벌(지도 · 표 메뉴 · 나무 메뉴 · 상태줄 흠) · 「한 번에 하나」 장부 합침 · 조건 만들기 = 칸 메뉴 끝 한 줄(가로채기 지움) · 부품 API 합치기는 안 함 — 확인 = 메뉴마다 폭 · 그림자 · 글자 수치 같음 · 스프레드시트 하네스 시험 + - 검증완료(sub8 · 277311955) — B04 관 · B05 종단 · 배수 · 3D · 칸 · 상태줄 · M02 나무 · 조건 만들기 메뉴 폭 160 · 글자 13 · 여백 8/12 · 모서리 8 · 그림자 같음(밝음 · 어두움) · 나무 ↔ 칸 메뉴 한 번에 하나 · 시험 52 통과 · 조건 만들기를 쓰는 곳(M01 흐름 식 칸)은 칸 메뉴 없는 미리보기 편집기 → 같은 공용 메뉴에 이 줄만 · 시트 탭 메뉴는 탭 켠 화면이 없어 화면 확인 못 함(같은 부품) +- [X] 39-1a 자료 틀(sub7) — labor 종류 · SU 번호 · 문서 검사 · 계산(기준 ③ 서버 · 반영 ① 짝 · [저장] 때 계산값) · `_PER` 1,000 · calc · pills 길 — 확인 = 새 시험 test_m02_labor_doc.py · 거울 시험 · 기존 store · layers 시험 +- [X] 39-1b 옮기기 · 복사 · 프로젝트(sub7) — 옮기기 스크립트(시스템 층 바로 · storage 는 사본 뒤 브레인) · copy_from 층 넘김 + 실무문서 조합 → 일위대가 · 구조물 복사 = 새 SU — 확인 = 옮기기 시험(UP10-000001 → 열 둘) · tree_paste · item 시험 + - 검증완료(sub7) — 새 시험 labor_doc 19 · 거울 3 · migrate 4 · labor_copy 6 + store · structure · structure_combo · item_keys · layers_store · tree_paste · combo_layers · combo_branch · item_inputs · layer_access · M01 combo · cache 통과 · typecheck · 8009 실제 길: item1 SU-000001 기준합 2,658,983 · 알약 13 · 칸 값 3 → 수량출처 칸 · 시스템 층 옮김 item1←UA000008 · item3←UA000001 · item2 빈 · storage 층 안 돌림 · B8 은 sub8(39-1c) · 은퇴 길(use-combo · 갈라 내기 · preview)은 화면이 아직 불러 남김 · 로직 지우기 검사는 ecc4a651a 로 먼저 함(39-1d 와 겹침) +- [X] 39-1d 은퇴 · 로직 지우기 검사(sub4) — 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서 · 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 길과 화면 부름 정리(쓰는 곳 0 확인 뒤) · 남의 층 기록 = 성공한 쓰기만 — 확인 = 로직 지우기 시험(회사 · 개인 labor 에 담긴 로직 막힘) · grep 0 + - 검증완료 — 로직 지우기 시험(회사 · 개인 · 마스터 labor · 실무문서 → 422 · 어디서 쓰는지 알림 · 다 빼면 지워짐 · 임시 사본) · M01 시험 56 · 조합 층 14 · 권한 · 양식 18 통과 · 남의 층 기록 = 성공한 쓰기만(409 · 403 안 남김) +- [X] 39-1d 남은 은퇴(sub4 · sub3 D3 · sub5 받은 뒤) — 쓰는 곳이 남아 못 지움: 갈라 내기 `branch_combo` · 키 적기 `use_combo`(Branch.py · Router.py · Router_Items 길 · Api_Fetch `branchCombo` · `setColumnCombo` ← Combo.ts · ComboPanel.ts) · `fetchUsableCombos` · `/combos/all` ← ComboPanel · Structure · 조합 만들기 · 지우기 길(POST · DELETE) ← 죽은 Labor_Edit.ts(sub2) · 조합 시험 · `Combo_Store.edit`(sub5) · Labor_Api `newCombo` · `editCombo` · `deleteCombo` · `fetchAllCombos` · `editComboTree*` · `pasteCombos` · `fetchLibLogic` · `fetchLibAuto`(sub2 · sub5) + - 검증완료 — 지움: Branch.py · 갈라 내기 · use-combo(옛 · 층 길) · 조합 만들기 · 지우기 · 알약 두 길 · grep 0 · typecheck · 시험 m01_combo · combo_inputs 29 · combo_layers 9 · combo_branch · layers_store 통과 · 남김: /combos/all · fetchUsableCombos(항목 추가 창 일위대가 고르기 pickCombo) · 집계표 열 「일위대가」 칸(그 창이 씀) · 목록 · 키 읽기(Practice · ItemPick) · 실무문서 통째 지우기 · Combo_Store create · delete · pills(sub5 · 시험 · Labor_Doc 알약) +- [X] 39-1c 프로젝트 층(sub8 · 39-1a 뒤) — 스냅샷 일위대가 = labor 문서 · 입력값.json 첫 값 = labor 입력값 · 단가.json 자재 MT · 계산 반영 같은 함수 · B05 고르기 모달 카드 = 도면 · 일위대가(C 보기 부품) · 면적산출 + 값 카드 — 확인 = item 시험 · 고르기 모달 값 → /calc 합계 + - 검증완료(sub8 · e1ad3d362 · 1baf761c4) — 시험 67 통과(새 test_m02_item_labor 5 · picker 20 · copy · inputs) · ORCA 5183 sub8_검증 B05 고르기 모달 카드 도면 · 일위대가 · 면적산출 · 값 넣고 저장 → /calc 반영 합 337,721.97 = 손셈 = 원본 층 계산 · 빈 값 = 멈춘 로직 1 · 빠진 입력 1(붉게) · 저장 안 하고 뒤로 = 버릴지 물음 · 일위대가 쓰기 400 · 밝음 · 어두움 +- [X] 39-1e 프로젝트 면적산출 알약 = labor 알약(sub8 · 39-7b 뒤) — 옛 입력연결 알약 대신 스냅샷 labor 알약 · 정함(브레인): 프로젝트 계산은 빠진 입력이면 그 로직 멈춤 그대로(입력 유도 · 원본 층은 자동값) — 확인 = 고르기 모달 면적 편집 알약 수 = labor 알약 수 · sub6 G3(c704f1898)이 함께 바꿈 · 39-8 에서 확인 +- [X] 39-0i 통합(sub5) — dev 에 sub2 · sub7 · sub3 · sub1 · sub4 · sub8 합침(Api_Fetch · Store_Cache 로직 지우기 검사 두 벌 → sub4 하나 · Structure.ts import) → sub6 이 자기 충돌 풂 → 모두 받기 · 통합 1 f0b8882a8 · 통합 2(sub6 · sub1) 진행 +- [X] 39-2 좌측(sub5) — 일위대가 컨테이너 · 나무 지움 · 하위 차례 도면 → 일위대가 → 면적산출 · 수평선 · 「전체 항목」 줄 · 이름 「일위대가」 · 실무문서 컨테이너(시스템 관리자) · 나무 서버 길 지움 · 대시보드 카드 — 확인 = typecheck · ORCA 5180 줄 수 · 세 역할 화면 + - 검증완료(sub5) — typecheck · 고친 시험 · ORCA 5180 시스템 관리자: 하위 차례 · 수평선(밝음 · 어두움) · 전체 항목 셋 · 일위대가 열 줄 3(SU 는 39-1b 옮기기 뒤 · 지금 「없음」) · 실무문서 37 · 상세 · 지우기 물음 취소 · 회사 · 개인 층엔 실무문서 없음 · 회사 관리자 · 일반은 서버 403 시험(계정 화면은 못 봄) +- [X] 39-3 일위대가 상세(sub2) — labor 종류 상세 · 입력 모달 · 작은 글 · 금액 두 줄 · 자재목록 · 미리 보기 지움 · 다이어그램 · 보기 갈래 부품 · 입력 부품 옮김 — 확인 = 거울 · 다이어그램 시험 · ORCA 5177 값 넣고 저장 → 다시 열기 + - 검증완료(43a59a689 · d847f724c · 3ad7678e1 · 5b4aed1b2) — 시험 통과 · ORCA 5177 item1: 기준 60,984 × 손 5 = 반영 304,922 · 자재 6,090 × 10 = 60,900 · 합 365,822 = 저장 계산값 · 다시 열어 값 남음 · 범위 밖 · 빈 손 수량 · 같은 로직 막음 · 떠날 때 물음 · 어두움 · 남음 = 읽기만 화면(권한 계정) 미확인 · 검증용 labor/item1.json 지움 +- [X] 39-4 면적산출(sub3) — 알약 켬끔(기본 꺼짐) · 오른쪽 패널 = 담은 로직 + 자재목록 · 칸 지정 모달 · setLink · 자재 알약 · 조합 패널 지움 — 확인 = setLink 시험 · ORCA 5178 칸 잇기 → 금액 즉시 · 검증완료(sub3 · 시험 31 · ORCA 5178: 카드 알약 줄 기본 0px → 켬 147px → 다시 열어도 그대로 · 상세 늘 보임 · 오른쪽 패널 로직 14 줄 · C3 = 12.5 잇기 → 반영 726,535 · 20 → 1,162,456 바로 · 「수량 C3 = 12.5」 · 알약 ↔ 모달 같은 연결 · 빈 · 틀린 주소 막음 · 풀기 · 어두움 · 저장 안 하고 옮김 물음 · 옛 조합 패널 쓰는 곳 0) · 검증 대기 +- [X] 39-5 목록 · 고르기 · 카드(sub1) — ItemList · 전체 항목 화면 · 상세 제목 M01 꼴 · 새 항목 두 단추 · 복사 생성 탭 · 카드 셋 · 빈 카드 두 단추 · 복사해 가져오기 — 확인 = item_list 시험 · ORCA 5174 복사 생성 → 새 SU + - 검증완료(18a063925 · 134a727c9 · 364d22ff6 · 6cf72bbf1 · 6f8470890 · 6d46dda54) — 시험 통과 · ORCA 5174 마스터: 전체 항목 줄 수 = 좌측 줄 수(3 · 3 · 3) · 제목 18px · 탭 = 시스템 관리자 다섯(기본 마스터) · 일반 셋(기본 개인) · 빈 사람 = 빈 목록 · 복사 생성 item1 → SS000055 · SD-000004 · SA-000004 · SU-000004(로직 14) · 폴더 안 같은 이름 「ㄴㄴ (2)」 · 실무문서 UA000001 → SU-000002 로직 3(번호 그대로) · 개인 → 마스터 면적산출(SA-000002 그대로) · 카드 차례 도면 → 일위대가 → 면적산출 · 어두움 · 마스터 사본 되돌림(차이 0) · 남음 = 재료산출 · 선택 입력 카드 지움은 sub6 받은 뒤 · 읽기만 계정 화면 미확인 +- [X] 39-6 권한(sub4) — 맡은 회사 규칙 · 사람 나무 · 첫 쓰기 뼈대 · 「〇〇로 복사…」 폴더 · 감사 기록 · 실무문서 문 · apply · sources · readableSources — 확인 = layer_access · 새 layer_manage 시험 · ORCA 세 계정 + - 검증완료 — 시험 layer_access 4 · template_layers · combo_layers 14 · layer_screen(39-6 몫) 통과 · 관련 M02 시험 66 통과 · API 세 계정 25/25 · ORCA 회사 관리자 · 일반 · 시스템 관리자(고객 회사 3) 사람 나무 · 「〇〇로 복사…」 폴더 창 · 밝음 · 어두움 · 쓰기 성공 길은 임시 storage 시험(새 파일 대신 layer_access 에 더함) +- [X] 39-7a 지우기 G1 · G2(sub6) — 자재 조합 · 재료산출 지움 · pickMaterial 옮김 — 확인 = typecheck · check_master · 고친 시험 · ORCA 5181 카드 · 줄 +- [X] 39-7b 지우기 G3(sub6 · 39-3 입력 모달 뒤) — 선택 입력 카드 · inputs · 자리 · 알약 길 지움 — 확인 = 프로젝트 고르기 값 → /calc 합계 그대로 + - 검증완료(sub6 · 54ce67ceb · 61d2ed599 · 79e4bba1b · c704f1898): typecheck 0 · check_master 틀 0건 · 고친 대상 시험 초록(test_m02_card_shell 1건은 origin/dev 자체 빨강) · ORCA 5181 좌측 = 구조물 도면 · 일위대가 · 면적산출 · 집계표 줄 = 일위대가 · 도면 · 면적산출 · 카드 = 도면 정보 · 면적산출(선택 입력 없음) · 면적산출 편집 알약 = 일위대가 알약 · 옵션 제목 밝음 · 어두움 · /calc = 시험(test_item_copy_complete)으로 · 프로젝트 고르기 모달 면적 알약 = 복사본 labor 알약으로 바꿈(39-1e 몫과 겹침) +- [X] 39-1f 새 항목 창 일위대가 고르기 없앰(sub4) — 새 구조물 일위대가 = 늘 빈 문서 · 카드 [새로 만들기] / [복사 생성] · 열 「일위대가」 새로 쓰기 없앰(든 값은 옮기기 뒤 정리) — 확인 = 창 줄 수 · 새 열 빈 labor · sub4 8087c7306 · 창 칸 둘 · 새 열 labor 빈 문서 · 시험 64 +- [X] 39-8 끝 검증 — 흐름 셋(새 구조물 새로 만들기 · 복사 생성 · 항목 하나 복사) × 세 역할 · 일위대가 값 → 금액 · 면적 칸 → 반영 · 실무문서 · storage 옮기기 뒤 확인 · 검증완료(가 sub1 19/22 · 나 sub2 26/27 → 흠 ①~④ · H1~H3 · X3 모두 고침) · main 합침 e6fe5232e · storage 옮기기(브레인 · 사본 tmp/storage_templates_39.zip · labor 5 · 두 번째 「그대로 5」) · 남은 빨강: spreadsheet 늦게 불러오기 시험 1(흐름도 DataFlow · 39 밖) · 검증 대기 +- [X] 39-10 요구 대조(브레인 · 사용자 요청 10-4) — 끝난 뒤 사용자 대화 입력 원문(요구 2~6 · 9 · 답 1~4 · 원본 로직 손대지 않음 · 입력값 복사 흐름) 줄마다 반영 여부 · 근거(화면 · 파일 · 수치) 표 · 요구 31 줄 모두 반영(흠 B1~B4 고친 뒤) · 표 = 보고 Artifact · 검증 대기 +- [X] 39-11 브레인 실제 검증(사용자 요청 10-4) — 끝난 뒤 브레인이 사용자처럼 브라우저 조작(진짜 마우스 · 사진 · 세 역할) · 보고 Artifact · 세 계정 · 진짜 마우스 · 사진(sub5 서버 s01~s15 · 메인 m01~m04) · 흠 B1(표 일위대가 줄 SU) · B2 · B3(전체 항목 합계 · 단위) · B4(새 항목 단추) 고침 → main 11ef83985 · 저장 안 함 · 검증 대기 + +## 40. M02 구조물 조합 개편 2차 (사용자 지시 10-4 밤) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004_2.md` · 「구조물 옵션 보이기」 칸은 그대로(사용자 확인) +- 정함(브레인): 「담은 로직」 → 「단가산출 로직」(상세 · 카드 · 면적 패널 모두) · 수량산출 열 = 위 결과단위 기준 수량(분모 · 예 「10 ㎡」) · 아래 수량(손 · 칸 · 「—」) · 누르면 [손 입력] / [엑셀에서 고르기](그 열 면적산출 시트 모달 · 칸 누르면 그 로직 알약을 그 칸에 이음 · 일위대가 [저장] 때 면적 문서도 함께 저장) · 버튼 = 공용 버튼 양식 · 카드 단추 「가져오기」 · 「수정」(면적산출 카드도 같은 이름) · 카드 [삭제] = 그 항목 비우기(번호 · 열 그대로 · 물음 한 번) · 일위대가 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 · 없으면 기준 값 흐리게) · 알약 색 = 알약 키로 자동(두 테마) · 이은 칸 배경 같은 색 옅게 · 반짝임 그대로 + +- [X] 40-1 일위대가 상세(sub2) — 이름 「단가산출 로직」 · 「자동값 xx」 조사 → 브레인 정함 → 고침 · 수량산출 열 + 손 입력 · 엑셀 모달 잇기(sub3 부품) · 로직 추가 · 올리기 · 내리기 · 빼기 · 자재 추가 = 버튼 · Labor_View 한 줄 표 갈래(카드용) — 확인 = 시험 · ORCA 수량 칸 손 · 엑셀 고름 → 반영 값 · 저장 → 다시 열기 + - 검증완료(1c0868b78 · fd4830556 · e70fc184a · 35ccc26af) — 시험 34 통과 · ORCA 5177 SU-000003: 손 5 → 79,280 × 5 = 396,398 · 엑셀 B6(12.5) → 4,523 × 12.5 = 56,537 · 저장 → 면적 연결 「노체포설|㎥ → B6」 · 손 5 · 반영합 = 화면 · 다시 열기 같음 · 저장 안 하고 떠나면 물음 · 고르기 칸 「자동값」 줄 없음(B05 값 카드 확인 상자 그대로) · 한 줄 표 300~560 · 면적 패널 320~560 가로 넘침 없음 · 밝음 · 어두움 · 검증용 마스터 2 파일 되씀 +- [X] 40-2 구조물 조합 상세(sub1) — 카드 단추 「가져오기」 · 「수정」 · 일위대가 카드 한 줄 표(sub2 갈래) · 「새 항목」 버튼 · 카드 셋 3열 · 카드 [삭제](비우기) — 확인 = ORCA 3열 · 삭제 → 빈 카드 · 시험 + - 검증완료(74a524202 · e3236558b) — 시험 53 통과 · ORCA 127.0.0.1:5174 폭 1600 = 3열(카드 402px · 높이 628 같음) · 1000 · 400 = 1열 · 가로 넘침 0 · 400 에서 면적 카드 단추 여섯 줄 바꿈(카드 밖 0) · 단추 「수정」 · 「가져오기」 · 「삭제」 · 「새 항목」 높이 32 · 글 14px(저장과 같음) · item1 일위대가 [삭제] → SU-000001 · item1 그대로 · 담은로직 14 → 0 · 빈 카드 두 단추 · 면적산출 [삭제] → SA-000001 그대로 · 칸 · 연결 0 · 일반 계정 마스터 · 회사 카드 단추 0 · 어두움 · 마스터 사본 되씀(차이 0) · 남음 = 도면 [삭제] 는 마스터 도면이 다 비어 화면 확인 못 함(서버 길은 40-4 시험) +- [X] 40-3 면적산출 상세(sub3) — 알약 끌어 칸에 놓기 추가 · 알약 색 자동 · 이은 칸 같은 색 · 반짝임 그대로 · 엑셀 칸 고르기 모달 부품(sub2 용) — 확인 = 끌어 놓기 → 연결 · 색 수치 · 두 테마 · 검증완료(sub3 · 시험 16 + 하네스 15 · ORCA 5178: 칸 고르기 모달 C5 · 풀기 · 그만둠 · 끌어 C3 → 연결 · Ctrl+Z 풂 · F14 에 놓기 = 물음(취소 그대로 · 바꿈 = 갈아끼움) · 색 24 서로 다름 · 테두리 대비 최소 밝음 3.40 · 어두움 3.55 · 글 8.85 · 6.35 · 카드 · 상세 둘 다 · 마스터 변화 0) · 검증 대기 +- [X] 40-4 서버 비우기 길(sub7) — 항목(도면 · 일위대가 · 면적산출) 비우기 = 빈 문서로 씀(번호 · 열 그대로 · 권한 규칙 그대로) — 확인 = 시험(세 종류 · 권한 403) + - 검증완료(sub7) — test_m02_part_clear 7(세 종류 빈 문서 = 새 열 꼴 · 번호 · 장부 그대로 · 면적 비우면 일위대가 수량 「없음」 · 일반 마스터 403 · 남의 개인 403 · 회사 관리자 동료 개인 200 · 없는 열 404 · 모르는 종류 422) · typecheck · 00cb6db66 · 화면 함수 clearPart +- [x] 40-5 합치기 · 끝 검증 · 메인 반영 · 브레인 요구 대조 · 실제 검증 · 보고 · main 795b861da(40 · 41 함께 · 흠 D1~D4 고침 · CAD 빌드 · 시험 90) · 끝 검증 · 브레인 실제 검증은 사용자가 하기로(10-4) · 검증 대기 + +## 41. 면적산출 표 → 도면 표 (사용자 지시 10-4 밤) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004_2.md` 끝 「추가 기능」 — 면적산출에서 범위를 골라 도면으로 보내면 도면에 표가 들어가고 칸 내용이 따라감(어려우면 [업데이트] 단추) · 표 여러 개 · 자리는 도면에서 사용자가 끌어 정함 +- 정함(브레인): 조사 먼저(CAD 표 · 묶음 꼴 · 호스트 ↔ CAD 메시지 · 끌어 놓기 · 값 따라가기 길) → 브레인이 설계 정함 → 구현 · 원본 단가산출 로직 자료 손대지 않음 + +- [X] 41-0 조사(sub8) — 보고 `tmp/조사_41-0.md` · 끝(CAD 표 객체 TableEntity 있음 · 미리보기 틀 있음 · DXF 표 빠짐 흠) + +- 정함(브레인 · 조사 추천 그대로): 표 = CAD 표 객체(TableEntity · 한 덩어리) · 따라가기 = 면적 [저장] 때 서버가 그 열 도면 표 칸 글을 다시 적음 + 도면을 열 때 기록 값 ≠ 지금 값이면 알림 · [업데이트] · 연결 표지 = 표 style.연결(표 id) · 칸 key 「시트!A1」 · 보낼 꼴 = 표시 글(형식 적용) · 병합 · 가로 정렬 · 굵게 · 열 너비 비율(채움 · 테두리색 · 글자색 안 보냄) · 크기 = 표 글자 크기 하나(기본 2.5 · 보낼 때 바꿀 수 있음) · 넘김 = 같은 페이지 안(캐시 · 서버 안 씀) · 시작은 면적 상세 [도면으로 보내기] 단추(칸 메뉴는 뒤) · DXF 내보내기에 표 = 선 + 글 + +- [x] 41-1 서버(sub7) — 범위 → 표 칸 변환(Node 묶음 formatValue 다시 씀 · 새 M02_Area_Table.py) · POST /api/m02/layers/{층}/area/{열}/table · 면적 저장 때 도면 표 맞춤(마스터 · 회사 · 개인 층 + 프로젝트 층 길) — 확인 = 시험(형식 · 병합 · 한도 · 권한 · 저장 뒤 표 글 바뀜) + - 검증완료(sub7) — test_m02_area_table 10(형식 「1,234.50」 · 식 칸 · 굵게 · 가로 · 병합 rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행 3 · 열 D 뺌 · 빈 칸 · 한도 171>100 · 없는 시트 · 범위 꼴 · 길 200 · 저장 안 함 · 남의 개인 403 · 없는 열 404 · 면적 저장 뒤 T1 칸 「10.00」 · 자리 · 크기 그대로 · 다른 표 그대로 · 표 없는 T2 기록 값 갱신 · 프로젝트 층 맞춤) + store · structure · template_layers · labor · clear · copy · spreadsheet_store 74 통과 · typecheck · 8009 실제 길 200 · 3ded39875 +- [x] 41-2 CAD · 호스트(sub8) — 메시지 place-table · update-table · 「표 놓기」 도구(커서 따라오는 미리보기 · 누르면 놓임 · Esc · 되돌리기) · cad_host · Drawing.ts 함수 · CAD 빌드 · DXF 표 = 선 + 글 — 확인 = vitest · ORCA 놓기 · DXF LINE · TEXT 수 + - 검증완료(sub8 · c426fa2ad · 0bc560663 · cc63e49ab · b2aa2a987) — vitest 8(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결) · DXF 시험 4(거울 = TS 표 기하 · 면적 표 LINE 13 · TEXT 5 · 정렬 L/C/R · B07 수량표 LINE 7 · TEXT 3 — 전에는 표가 빠짐) · ORCA 5183 M02 도면 상세: 미리보기가 커서 따라옴 · 누르면 놓임(drawing-changed) · Esc 그만 · Ctrl+Z 1→0 · 다시하기 · MOVE(새 id) 뒤 저장 JSON style.연결 T1 남음 · update-table T1 = 1 · 없는 연결 0 · 자리 · 너비 그대로 · 밝음 · 어두움 · 저장 안 하고 나감 · 자동백업 지움 +- [x] 41-3 화면(sub5) — 면적 상세 [도면으로 보내기](고른 범위 · 글자 크기) → 그 열 도면 상세로 넘겨 놓기 · [저장] 때 연결표 기록 · 다시 열 때 다름 알림 + [업데이트] · 표 여러 개 — 확인 = ORCA 범위 고름 → 보냄 → 끌어 놓음 → 저장 → 면적 값 바꿔 저장 → 도면 표 글 바뀜 + - 검증완료(sub5) — typecheck · test_m02_area_to_drawing 7 · structure_panel · ORCA 5180 마스터: A1:C4 보냄 → 놓음 → 저장(표 T1 · 연결표 T1) · 면적 A1 · B1 · C2(식) 저장 → 도면 표 글 길이 · 12.5 · 25 · 같으면 알림 0 · 기록 값 옛 값이면 「바뀐 표 1개」 + [업데이트] → 저장 값 갱신 · 표 셋(T1~T3 · 글자 5 · 2.5) · T3 지움 → 도면 · 연결표 둘 다 빠짐 · 마스터 사본 되씀(git 빈 상태) · 권한 없는 층 단추 0 은 시험으로만 From b7a0235c8b2ff837de9817eccd0b389017e44a7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:49:48 +0900 Subject: [PATCH 45/46] auto: 2026-10-04 23:49 (ESD_LAPTOP) --- ...10-04_plan_M02구조물조합개편_2차_도면표.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md diff --git a/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md b/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md new file mode 100644 index 000000000..03f77c17e --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md @@ -0,0 +1,78 @@ +# 지난 계획 — 39~41장 (2026-10-04) + +M02 구조물 조합 개편(일위대가 = 구조물마다 · 재료산출 · 선택 입력 지움 · 실무문서 · 권한 · 오른쪽 클릭) · 2차 수정(단가산출 로직 · 수량산출 열 · 카드 3열 · 삭제 · 알약 끌어 놓기 · 색) · 면적산출 표 → 도면 표. 검증은 사용자(10-4). + +## 32장에서 39 로 넘긴 줄 + +- [X] 선택 입력 정리(조사 10-1 끝 · 고침은 사용자 지시 뒤) — 일위대가 「넣을 값」과 구조물 「선택 입력」 이름 · 값 칸 규칙 두 벌 · 사라진 입력 안 알림 · 항목마다 자리 다시 고름 · 39 로 넘김(10-4 · 선택 입력 지움 · 값 칸 규칙 한 벌 · 사라진 값 알림은 39-3) +- [X] 로직 지우기 서버 검사가 마스터 층 조합만 봄(10-1 sub4) — 회사 · 개인 조합에 담긴 로직도 막기(`M01_MasterData_Store_Combo.py:194` · `Store_Cache.py:220` · 쉬운 길은 화면에서 미리 찾아 알림) · 39 로 넘김(10-4 · 39-1b 로직 지우기 검사 = 모든 층 labor) + +## 39. M02 구조물 조합 개편 (사용자 지시 10-4) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004.md`(2 일위대가 → 구조물 조합 하위 · 3 이름 · 4 구분선 · 5 권한 · 6 항목 변경 · 9 오른쪽 클릭) +- 사용자 확정(일위대가): 구조물 조합에 일위대가 · 도면 · 면적산출이 엮임 · 구조물 조합 복사 = 일위대가 새 키(로직 · 값 같이 복사) · 새 구조물 조합 = 미리보기에서 「새로 만들기」 / 「복사 생성」(목록 · 기본 탭 = 일반 개인 · 회사 관리자 개인 · 시스템 관리자 마스터 · 탭으로 다른 곳) · 항목 하나만 다른 것을 복사해 바꿈(저장 뒤에도) · 매칭 안 된 일위대가 조합 = 마스터 「실무문서」 항목으로 따로(나중에 한 번에 지움) +- 사용자 확정(10-4): 원본 단가산출 로직(resources/master_data 로직 · 표 · 요소 · 마스터 일위대가조합.json)은 절대 손대지 않음(읽기만) · 입력값은 사용자 · 설계자가 일위대가에 정해 저장 → 프로젝트로 복사해 씀 +- 정함(브레인): 조사 먼저(창마다 자기 몫 · 코드 안 고침) → 브레인이 설계 정함(이 절에 줄 더함) → 구현 · 조사 보고 = 각 창 `tmp/조사_39-0*.md` + +- [X] 39-0 조사(보고 여덟 · 정함 = `docs/raw/M02_구조물조합개편/설계_정함.md`) — A 좌측 패널 · 이름 · 구분선(sub5) · B 자료 틀(일위대가 입력값 · 자재목록 · 수량 연결 자리 · sub7) · C 일위대가 상세(sub2) · D 면적산출(sub3) · E 권한(sub4) · F 오른쪽 클릭(sub8) · G 재료산출 · 선택 입력 지우기 영향(sub6) · H 「전체 항목」 목록 화면(sub1) +- [X] 39-9a 오른쪽 클릭 막기(sub8) — 정함(브레인): 공용 진입 한 곳 창 캡처 듣기 · 대상 주소 `/^[bm]\d/` · 글 입력 칸(읽기 전용 포함)은 브라우저 메뉴 살림 · 입력 칸 밖 고른 글은 막음 · CAD 는 cad_host iframe load 때 같은 함수 · 남은 막기 코드 그대로(주석 셋만) · 근거 sub8 `tmp/조사_39-0F.md` — 확인 = 새 시험 test_common_native_menu.py · ORCA `defaultPrevented` 수치(B01 · B05 · B07 · M01 · M02 참 · 입력 칸 · A01 거짓) + - 검증완료(sub8 · 0f8efaa42) — 시험 4 통과 · ORCA 5183 B01 · B05(노선 편집 패널 포함) · B07 · M01 · M02 본문 · 헤더 · 모달 · CAD iframe 리본 · 패널 · 캔버스 막힘 참 · 입력 칸(CAD 안 포함) · A01 거짓 · CAD 오른쪽 단추 확정 그대로 +- [X] 39-9b 메뉴 공통 양식(sub8) — 공용 CSS 한 벌(지도 · 표 메뉴 · 나무 메뉴 · 상태줄 흠) · 「한 번에 하나」 장부 합침 · 조건 만들기 = 칸 메뉴 끝 한 줄(가로채기 지움) · 부품 API 합치기는 안 함 — 확인 = 메뉴마다 폭 · 그림자 · 글자 수치 같음 · 스프레드시트 하네스 시험 + - 검증완료(sub8 · 277311955) — B04 관 · B05 종단 · 배수 · 3D · 칸 · 상태줄 · M02 나무 · 조건 만들기 메뉴 폭 160 · 글자 13 · 여백 8/12 · 모서리 8 · 그림자 같음(밝음 · 어두움) · 나무 ↔ 칸 메뉴 한 번에 하나 · 시험 52 통과 · 조건 만들기를 쓰는 곳(M01 흐름 식 칸)은 칸 메뉴 없는 미리보기 편집기 → 같은 공용 메뉴에 이 줄만 · 시트 탭 메뉴는 탭 켠 화면이 없어 화면 확인 못 함(같은 부품) +- [X] 39-1a 자료 틀(sub7) — labor 종류 · SU 번호 · 문서 검사 · 계산(기준 ③ 서버 · 반영 ① 짝 · [저장] 때 계산값) · `_PER` 1,000 · calc · pills 길 — 확인 = 새 시험 test_m02_labor_doc.py · 거울 시험 · 기존 store · layers 시험 +- [X] 39-1b 옮기기 · 복사 · 프로젝트(sub7) — 옮기기 스크립트(시스템 층 바로 · storage 는 사본 뒤 브레인) · copy_from 층 넘김 + 실무문서 조합 → 일위대가 · 구조물 복사 = 새 SU — 확인 = 옮기기 시험(UP10-000001 → 열 둘) · tree_paste · item 시험 + - 검증완료(sub7) — 새 시험 labor_doc 19 · 거울 3 · migrate 4 · labor_copy 6 + store · structure · structure_combo · item_keys · layers_store · tree_paste · combo_layers · combo_branch · item_inputs · layer_access · M01 combo · cache 통과 · typecheck · 8009 실제 길: item1 SU-000001 기준합 2,658,983 · 알약 13 · 칸 값 3 → 수량출처 칸 · 시스템 층 옮김 item1←UA000008 · item3←UA000001 · item2 빈 · storage 층 안 돌림 · B8 은 sub8(39-1c) · 은퇴 길(use-combo · 갈라 내기 · preview)은 화면이 아직 불러 남김 · 로직 지우기 검사는 ecc4a651a 로 먼저 함(39-1d 와 겹침) +- [X] 39-1d 은퇴 · 로직 지우기 검사(sub4) — 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서 · 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 길과 화면 부름 정리(쓰는 곳 0 확인 뒤) · 남의 층 기록 = 성공한 쓰기만 — 확인 = 로직 지우기 시험(회사 · 개인 labor 에 담긴 로직 막힘) · grep 0 + - 검증완료 — 로직 지우기 시험(회사 · 개인 · 마스터 labor · 실무문서 → 422 · 어디서 쓰는지 알림 · 다 빼면 지워짐 · 임시 사본) · M01 시험 56 · 조합 층 14 · 권한 · 양식 18 통과 · 남의 층 기록 = 성공한 쓰기만(409 · 403 안 남김) +- [X] 39-1d 남은 은퇴(sub4 · sub3 D3 · sub5 받은 뒤) — 쓰는 곳이 남아 못 지움: 갈라 내기 `branch_combo` · 키 적기 `use_combo`(Branch.py · Router.py · Router_Items 길 · Api_Fetch `branchCombo` · `setColumnCombo` ← Combo.ts · ComboPanel.ts) · `fetchUsableCombos` · `/combos/all` ← ComboPanel · Structure · 조합 만들기 · 지우기 길(POST · DELETE) ← 죽은 Labor_Edit.ts(sub2) · 조합 시험 · `Combo_Store.edit`(sub5) · Labor_Api `newCombo` · `editCombo` · `deleteCombo` · `fetchAllCombos` · `editComboTree*` · `pasteCombos` · `fetchLibLogic` · `fetchLibAuto`(sub2 · sub5) + - 검증완료 — 지움: Branch.py · 갈라 내기 · use-combo(옛 · 층 길) · 조합 만들기 · 지우기 · 알약 두 길 · grep 0 · typecheck · 시험 m01_combo · combo_inputs 29 · combo_layers 9 · combo_branch · layers_store 통과 · 남김: /combos/all · fetchUsableCombos(항목 추가 창 일위대가 고르기 pickCombo) · 집계표 열 「일위대가」 칸(그 창이 씀) · 목록 · 키 읽기(Practice · ItemPick) · 실무문서 통째 지우기 · Combo_Store create · delete · pills(sub5 · 시험 · Labor_Doc 알약) +- [X] 39-1c 프로젝트 층(sub8 · 39-1a 뒤) — 스냅샷 일위대가 = labor 문서 · 입력값.json 첫 값 = labor 입력값 · 단가.json 자재 MT · 계산 반영 같은 함수 · B05 고르기 모달 카드 = 도면 · 일위대가(C 보기 부품) · 면적산출 + 값 카드 — 확인 = item 시험 · 고르기 모달 값 → /calc 합계 + - 검증완료(sub8 · e1ad3d362 · 1baf761c4) — 시험 67 통과(새 test_m02_item_labor 5 · picker 20 · copy · inputs) · ORCA 5183 sub8_검증 B05 고르기 모달 카드 도면 · 일위대가 · 면적산출 · 값 넣고 저장 → /calc 반영 합 337,721.97 = 손셈 = 원본 층 계산 · 빈 값 = 멈춘 로직 1 · 빠진 입력 1(붉게) · 저장 안 하고 뒤로 = 버릴지 물음 · 일위대가 쓰기 400 · 밝음 · 어두움 +- [X] 39-1e 프로젝트 면적산출 알약 = labor 알약(sub8 · 39-7b 뒤) — 옛 입력연결 알약 대신 스냅샷 labor 알약 · 정함(브레인): 프로젝트 계산은 빠진 입력이면 그 로직 멈춤 그대로(입력 유도 · 원본 층은 자동값) — 확인 = 고르기 모달 면적 편집 알약 수 = labor 알약 수 · sub6 G3(c704f1898)이 함께 바꿈 · 39-8 에서 확인 +- [X] 39-0i 통합(sub5) — dev 에 sub2 · sub7 · sub3 · sub1 · sub4 · sub8 합침(Api_Fetch · Store_Cache 로직 지우기 검사 두 벌 → sub4 하나 · Structure.ts import) → sub6 이 자기 충돌 풂 → 모두 받기 · 통합 1 f0b8882a8 · 통합 2(sub6 · sub1) 진행 +- [X] 39-2 좌측(sub5) — 일위대가 컨테이너 · 나무 지움 · 하위 차례 도면 → 일위대가 → 면적산출 · 수평선 · 「전체 항목」 줄 · 이름 「일위대가」 · 실무문서 컨테이너(시스템 관리자) · 나무 서버 길 지움 · 대시보드 카드 — 확인 = typecheck · ORCA 5180 줄 수 · 세 역할 화면 + - 검증완료(sub5) — typecheck · 고친 시험 · ORCA 5180 시스템 관리자: 하위 차례 · 수평선(밝음 · 어두움) · 전체 항목 셋 · 일위대가 열 줄 3(SU 는 39-1b 옮기기 뒤 · 지금 「없음」) · 실무문서 37 · 상세 · 지우기 물음 취소 · 회사 · 개인 층엔 실무문서 없음 · 회사 관리자 · 일반은 서버 403 시험(계정 화면은 못 봄) +- [X] 39-3 일위대가 상세(sub2) — labor 종류 상세 · 입력 모달 · 작은 글 · 금액 두 줄 · 자재목록 · 미리 보기 지움 · 다이어그램 · 보기 갈래 부품 · 입력 부품 옮김 — 확인 = 거울 · 다이어그램 시험 · ORCA 5177 값 넣고 저장 → 다시 열기 + - 검증완료(43a59a689 · d847f724c · 3ad7678e1 · 5b4aed1b2) — 시험 통과 · ORCA 5177 item1: 기준 60,984 × 손 5 = 반영 304,922 · 자재 6,090 × 10 = 60,900 · 합 365,822 = 저장 계산값 · 다시 열어 값 남음 · 범위 밖 · 빈 손 수량 · 같은 로직 막음 · 떠날 때 물음 · 어두움 · 남음 = 읽기만 화면(권한 계정) 미확인 · 검증용 labor/item1.json 지움 +- [X] 39-4 면적산출(sub3) — 알약 켬끔(기본 꺼짐) · 오른쪽 패널 = 담은 로직 + 자재목록 · 칸 지정 모달 · setLink · 자재 알약 · 조합 패널 지움 — 확인 = setLink 시험 · ORCA 5178 칸 잇기 → 금액 즉시 · 검증완료(sub3 · 시험 31 · ORCA 5178: 카드 알약 줄 기본 0px → 켬 147px → 다시 열어도 그대로 · 상세 늘 보임 · 오른쪽 패널 로직 14 줄 · C3 = 12.5 잇기 → 반영 726,535 · 20 → 1,162,456 바로 · 「수량 C3 = 12.5」 · 알약 ↔ 모달 같은 연결 · 빈 · 틀린 주소 막음 · 풀기 · 어두움 · 저장 안 하고 옮김 물음 · 옛 조합 패널 쓰는 곳 0) · 검증 대기 +- [X] 39-5 목록 · 고르기 · 카드(sub1) — ItemList · 전체 항목 화면 · 상세 제목 M01 꼴 · 새 항목 두 단추 · 복사 생성 탭 · 카드 셋 · 빈 카드 두 단추 · 복사해 가져오기 — 확인 = item_list 시험 · ORCA 5174 복사 생성 → 새 SU + - 검증완료(18a063925 · 134a727c9 · 364d22ff6 · 6cf72bbf1 · 6f8470890 · 6d46dda54) — 시험 통과 · ORCA 5174 마스터: 전체 항목 줄 수 = 좌측 줄 수(3 · 3 · 3) · 제목 18px · 탭 = 시스템 관리자 다섯(기본 마스터) · 일반 셋(기본 개인) · 빈 사람 = 빈 목록 · 복사 생성 item1 → SS000055 · SD-000004 · SA-000004 · SU-000004(로직 14) · 폴더 안 같은 이름 「ㄴㄴ (2)」 · 실무문서 UA000001 → SU-000002 로직 3(번호 그대로) · 개인 → 마스터 면적산출(SA-000002 그대로) · 카드 차례 도면 → 일위대가 → 면적산출 · 어두움 · 마스터 사본 되돌림(차이 0) · 남음 = 재료산출 · 선택 입력 카드 지움은 sub6 받은 뒤 · 읽기만 계정 화면 미확인 +- [X] 39-6 권한(sub4) — 맡은 회사 규칙 · 사람 나무 · 첫 쓰기 뼈대 · 「〇〇로 복사…」 폴더 · 감사 기록 · 실무문서 문 · apply · sources · readableSources — 확인 = layer_access · 새 layer_manage 시험 · ORCA 세 계정 + - 검증완료 — 시험 layer_access 4 · template_layers · combo_layers 14 · layer_screen(39-6 몫) 통과 · 관련 M02 시험 66 통과 · API 세 계정 25/25 · ORCA 회사 관리자 · 일반 · 시스템 관리자(고객 회사 3) 사람 나무 · 「〇〇로 복사…」 폴더 창 · 밝음 · 어두움 · 쓰기 성공 길은 임시 storage 시험(새 파일 대신 layer_access 에 더함) +- [X] 39-7a 지우기 G1 · G2(sub6) — 자재 조합 · 재료산출 지움 · pickMaterial 옮김 — 확인 = typecheck · check_master · 고친 시험 · ORCA 5181 카드 · 줄 +- [X] 39-7b 지우기 G3(sub6 · 39-3 입력 모달 뒤) — 선택 입력 카드 · inputs · 자리 · 알약 길 지움 — 확인 = 프로젝트 고르기 값 → /calc 합계 그대로 + - 검증완료(sub6 · 54ce67ceb · 61d2ed599 · 79e4bba1b · c704f1898): typecheck 0 · check_master 틀 0건 · 고친 대상 시험 초록(test_m02_card_shell 1건은 origin/dev 자체 빨강) · ORCA 5181 좌측 = 구조물 도면 · 일위대가 · 면적산출 · 집계표 줄 = 일위대가 · 도면 · 면적산출 · 카드 = 도면 정보 · 면적산출(선택 입력 없음) · 면적산출 편집 알약 = 일위대가 알약 · 옵션 제목 밝음 · 어두움 · /calc = 시험(test_item_copy_complete)으로 · 프로젝트 고르기 모달 면적 알약 = 복사본 labor 알약으로 바꿈(39-1e 몫과 겹침) +- [X] 39-1f 새 항목 창 일위대가 고르기 없앰(sub4) — 새 구조물 일위대가 = 늘 빈 문서 · 카드 [새로 만들기] / [복사 생성] · 열 「일위대가」 새로 쓰기 없앰(든 값은 옮기기 뒤 정리) — 확인 = 창 줄 수 · 새 열 빈 labor · sub4 8087c7306 · 창 칸 둘 · 새 열 labor 빈 문서 · 시험 64 +- [X] 39-8 끝 검증 — 흐름 셋(새 구조물 새로 만들기 · 복사 생성 · 항목 하나 복사) × 세 역할 · 일위대가 값 → 금액 · 면적 칸 → 반영 · 실무문서 · storage 옮기기 뒤 확인 · 검증완료(가 sub1 19/22 · 나 sub2 26/27 → 흠 ①~④ · H1~H3 · X3 모두 고침) · main 합침 e6fe5232e · storage 옮기기(브레인 · 사본 tmp/storage_templates_39.zip · labor 5 · 두 번째 「그대로 5」) · 남은 빨강: spreadsheet 늦게 불러오기 시험 1(흐름도 DataFlow · 39 밖) · 검증 대기 +- [X] 39-10 요구 대조(브레인 · 사용자 요청 10-4) — 끝난 뒤 사용자 대화 입력 원문(요구 2~6 · 9 · 답 1~4 · 원본 로직 손대지 않음 · 입력값 복사 흐름) 줄마다 반영 여부 · 근거(화면 · 파일 · 수치) 표 · 요구 31 줄 모두 반영(흠 B1~B4 고친 뒤) · 표 = 보고 Artifact · 검증 대기 +- [X] 39-11 브레인 실제 검증(사용자 요청 10-4) — 끝난 뒤 브레인이 사용자처럼 브라우저 조작(진짜 마우스 · 사진 · 세 역할) · 보고 Artifact · 세 계정 · 진짜 마우스 · 사진(sub5 서버 s01~s15 · 메인 m01~m04) · 흠 B1(표 일위대가 줄 SU) · B2 · B3(전체 항목 합계 · 단위) · B4(새 항목 단추) 고침 → main 11ef83985 · 저장 안 함 · 검증 대기 + +## 40. M02 구조물 조합 개편 2차 (사용자 지시 10-4 밤) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004_2.md` · 「구조물 옵션 보이기」 칸은 그대로(사용자 확인) +- 정함(브레인): 「담은 로직」 → 「단가산출 로직」(상세 · 카드 · 면적 패널 모두) · 수량산출 열 = 위 결과단위 기준 수량(분모 · 예 「10 ㎡」) · 아래 수량(손 · 칸 · 「—」) · 누르면 [손 입력] / [엑셀에서 고르기](그 열 면적산출 시트 모달 · 칸 누르면 그 로직 알약을 그 칸에 이음 · 일위대가 [저장] 때 면적 문서도 함께 저장) · 버튼 = 공용 버튼 양식 · 카드 단추 「가져오기」 · 「수정」(면적산출 카드도 같은 이름) · 카드 [삭제] = 그 항목 비우기(번호 · 열 그대로 · 물음 한 번) · 일위대가 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 · 없으면 기준 값 흐리게) · 알약 색 = 알약 키로 자동(두 테마) · 이은 칸 배경 같은 색 옅게 · 반짝임 그대로 + +- [X] 40-1 일위대가 상세(sub2) — 이름 「단가산출 로직」 · 「자동값 xx」 조사 → 브레인 정함 → 고침 · 수량산출 열 + 손 입력 · 엑셀 모달 잇기(sub3 부품) · 로직 추가 · 올리기 · 내리기 · 빼기 · 자재 추가 = 버튼 · Labor_View 한 줄 표 갈래(카드용) — 확인 = 시험 · ORCA 수량 칸 손 · 엑셀 고름 → 반영 값 · 저장 → 다시 열기 + - 검증완료(1c0868b78 · fd4830556 · e70fc184a · 35ccc26af) — 시험 34 통과 · ORCA 5177 SU-000003: 손 5 → 79,280 × 5 = 396,398 · 엑셀 B6(12.5) → 4,523 × 12.5 = 56,537 · 저장 → 면적 연결 「노체포설|㎥ → B6」 · 손 5 · 반영합 = 화면 · 다시 열기 같음 · 저장 안 하고 떠나면 물음 · 고르기 칸 「자동값」 줄 없음(B05 값 카드 확인 상자 그대로) · 한 줄 표 300~560 · 면적 패널 320~560 가로 넘침 없음 · 밝음 · 어두움 · 검증용 마스터 2 파일 되씀 +- [X] 40-2 구조물 조합 상세(sub1) — 카드 단추 「가져오기」 · 「수정」 · 일위대가 카드 한 줄 표(sub2 갈래) · 「새 항목」 버튼 · 카드 셋 3열 · 카드 [삭제](비우기) — 확인 = ORCA 3열 · 삭제 → 빈 카드 · 시험 + - 검증완료(74a524202 · e3236558b) — 시험 53 통과 · ORCA 127.0.0.1:5174 폭 1600 = 3열(카드 402px · 높이 628 같음) · 1000 · 400 = 1열 · 가로 넘침 0 · 400 에서 면적 카드 단추 여섯 줄 바꿈(카드 밖 0) · 단추 「수정」 · 「가져오기」 · 「삭제」 · 「새 항목」 높이 32 · 글 14px(저장과 같음) · item1 일위대가 [삭제] → SU-000001 · item1 그대로 · 담은로직 14 → 0 · 빈 카드 두 단추 · 면적산출 [삭제] → SA-000001 그대로 · 칸 · 연결 0 · 일반 계정 마스터 · 회사 카드 단추 0 · 어두움 · 마스터 사본 되씀(차이 0) · 남음 = 도면 [삭제] 는 마스터 도면이 다 비어 화면 확인 못 함(서버 길은 40-4 시험) +- [X] 40-3 면적산출 상세(sub3) — 알약 끌어 칸에 놓기 추가 · 알약 색 자동 · 이은 칸 같은 색 · 반짝임 그대로 · 엑셀 칸 고르기 모달 부품(sub2 용) — 확인 = 끌어 놓기 → 연결 · 색 수치 · 두 테마 · 검증완료(sub3 · 시험 16 + 하네스 15 · ORCA 5178: 칸 고르기 모달 C5 · 풀기 · 그만둠 · 끌어 C3 → 연결 · Ctrl+Z 풂 · F14 에 놓기 = 물음(취소 그대로 · 바꿈 = 갈아끼움) · 색 24 서로 다름 · 테두리 대비 최소 밝음 3.40 · 어두움 3.55 · 글 8.85 · 6.35 · 카드 · 상세 둘 다 · 마스터 변화 0) · 검증 대기 +- [X] 40-4 서버 비우기 길(sub7) — 항목(도면 · 일위대가 · 면적산출) 비우기 = 빈 문서로 씀(번호 · 열 그대로 · 권한 규칙 그대로) — 확인 = 시험(세 종류 · 권한 403) + - 검증완료(sub7) — test_m02_part_clear 7(세 종류 빈 문서 = 새 열 꼴 · 번호 · 장부 그대로 · 면적 비우면 일위대가 수량 「없음」 · 일반 마스터 403 · 남의 개인 403 · 회사 관리자 동료 개인 200 · 없는 열 404 · 모르는 종류 422) · typecheck · 00cb6db66 · 화면 함수 clearPart +- [x] 40-5 합치기 · 끝 검증 · 메인 반영 · 브레인 요구 대조 · 실제 검증 · 보고 · main 795b861da(40 · 41 함께 · 흠 D1~D4 고침 · CAD 빌드 · 시험 90) · 끝 검증 · 브레인 실제 검증은 사용자가 하기로(10-4) · 검증 대기 + +## 41. 면적산출 표 → 도면 표 (사용자 지시 10-4 밤) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004_2.md` 끝 「추가 기능」 — 면적산출에서 범위를 골라 도면으로 보내면 도면에 표가 들어가고 칸 내용이 따라감(어려우면 [업데이트] 단추) · 표 여러 개 · 자리는 도면에서 사용자가 끌어 정함 +- 정함(브레인): 조사 먼저(CAD 표 · 묶음 꼴 · 호스트 ↔ CAD 메시지 · 끌어 놓기 · 값 따라가기 길) → 브레인이 설계 정함 → 구현 · 원본 단가산출 로직 자료 손대지 않음 + +- [X] 41-0 조사(sub8) — 보고 `tmp/조사_41-0.md` · 끝(CAD 표 객체 TableEntity 있음 · 미리보기 틀 있음 · DXF 표 빠짐 흠) + +- 정함(브레인 · 조사 추천 그대로): 표 = CAD 표 객체(TableEntity · 한 덩어리) · 따라가기 = 면적 [저장] 때 서버가 그 열 도면 표 칸 글을 다시 적음 + 도면을 열 때 기록 값 ≠ 지금 값이면 알림 · [업데이트] · 연결 표지 = 표 style.연결(표 id) · 칸 key 「시트!A1」 · 보낼 꼴 = 표시 글(형식 적용) · 병합 · 가로 정렬 · 굵게 · 열 너비 비율(채움 · 테두리색 · 글자색 안 보냄) · 크기 = 표 글자 크기 하나(기본 2.5 · 보낼 때 바꿀 수 있음) · 넘김 = 같은 페이지 안(캐시 · 서버 안 씀) · 시작은 면적 상세 [도면으로 보내기] 단추(칸 메뉴는 뒤) · DXF 내보내기에 표 = 선 + 글 + +- [x] 41-1 서버(sub7) — 범위 → 표 칸 변환(Node 묶음 formatValue 다시 씀 · 새 M02_Area_Table.py) · POST /api/m02/layers/{층}/area/{열}/table · 면적 저장 때 도면 표 맞춤(마스터 · 회사 · 개인 층 + 프로젝트 층 길) — 확인 = 시험(형식 · 병합 · 한도 · 권한 · 저장 뒤 표 글 바뀜) + - 검증완료(sub7) — test_m02_area_table 10(형식 「1,234.50」 · 식 칸 · 굵게 · 가로 · 병합 rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행 3 · 열 D 뺌 · 빈 칸 · 한도 171>100 · 없는 시트 · 범위 꼴 · 길 200 · 저장 안 함 · 남의 개인 403 · 없는 열 404 · 면적 저장 뒤 T1 칸 「10.00」 · 자리 · 크기 그대로 · 다른 표 그대로 · 표 없는 T2 기록 값 갱신 · 프로젝트 층 맞춤) + store · structure · template_layers · labor · clear · copy · spreadsheet_store 74 통과 · typecheck · 8009 실제 길 200 · 3ded39875 +- [x] 41-2 CAD · 호스트(sub8) — 메시지 place-table · update-table · 「표 놓기」 도구(커서 따라오는 미리보기 · 누르면 놓임 · Esc · 되돌리기) · cad_host · Drawing.ts 함수 · CAD 빌드 · DXF 표 = 선 + 글 — 확인 = vitest · ORCA 놓기 · DXF LINE · TEXT 수 + - 검증완료(sub8 · c426fa2ad · 0bc560663 · cc63e49ab · b2aa2a987) — vitest 8(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결) · DXF 시험 4(거울 = TS 표 기하 · 면적 표 LINE 13 · TEXT 5 · 정렬 L/C/R · B07 수량표 LINE 7 · TEXT 3 — 전에는 표가 빠짐) · ORCA 5183 M02 도면 상세: 미리보기가 커서 따라옴 · 누르면 놓임(drawing-changed) · Esc 그만 · Ctrl+Z 1→0 · 다시하기 · MOVE(새 id) 뒤 저장 JSON style.연결 T1 남음 · update-table T1 = 1 · 없는 연결 0 · 자리 · 너비 그대로 · 밝음 · 어두움 · 저장 안 하고 나감 · 자동백업 지움 +- [x] 41-3 화면(sub5) — 면적 상세 [도면으로 보내기](고른 범위 · 글자 크기) → 그 열 도면 상세로 넘겨 놓기 · [저장] 때 연결표 기록 · 다시 열 때 다름 알림 + [업데이트] · 표 여러 개 — 확인 = ORCA 범위 고름 → 보냄 → 끌어 놓음 → 저장 → 면적 값 바꿔 저장 → 도면 표 글 바뀜 + - 검증완료(sub5) — typecheck · test_m02_area_to_drawing 7 · structure_panel · ORCA 5180 마스터: A1:C4 보냄 → 놓음 → 저장(표 T1 · 연결표 T1) · 면적 A1 · B1 · C2(식) 저장 → 도면 표 글 길이 · 12.5 · 25 · 같으면 알림 0 · 기록 값 옛 값이면 「바뀐 표 1개」 + [업데이트] → 저장 값 갱신 · 표 셋(T1~T3 · 글자 5 · 2.5) · T3 지움 → 도면 · 연결표 둘 다 빠짐 · 마스터 사본 되씀(git 빈 상태) · 권한 없는 층 단추 0 은 시험으로만 From da49db8ce73d2a9a91d47bd86d077bf9a3019196 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 23:49:54 +0900 Subject: [PATCH 46/46] auto: 2026-10-04 23:49 (ESD_LAPTOP) --- ...10-04_plan_M02구조물조합개편_2차_도면표.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md diff --git a/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md b/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md new file mode 100644 index 000000000..03f77c17e --- /dev/null +++ b/docs/raw/plans/2026-10-04_plan_M02구조물조합개편_2차_도면표.md @@ -0,0 +1,78 @@ +# 지난 계획 — 39~41장 (2026-10-04) + +M02 구조물 조합 개편(일위대가 = 구조물마다 · 재료산출 · 선택 입력 지움 · 실무문서 · 권한 · 오른쪽 클릭) · 2차 수정(단가산출 로직 · 수량산출 열 · 카드 3열 · 삭제 · 알약 끌어 놓기 · 색) · 면적산출 표 → 도면 표. 검증은 사용자(10-4). + +## 32장에서 39 로 넘긴 줄 + +- [X] 선택 입력 정리(조사 10-1 끝 · 고침은 사용자 지시 뒤) — 일위대가 「넣을 값」과 구조물 「선택 입력」 이름 · 값 칸 규칙 두 벌 · 사라진 입력 안 알림 · 항목마다 자리 다시 고름 · 39 로 넘김(10-4 · 선택 입력 지움 · 값 칸 규칙 한 벌 · 사라진 값 알림은 39-3) +- [X] 로직 지우기 서버 검사가 마스터 층 조합만 봄(10-1 sub4) — 회사 · 개인 조합에 담긴 로직도 막기(`M01_MasterData_Store_Combo.py:194` · `Store_Cache.py:220` · 쉬운 길은 화면에서 미리 찾아 알림) · 39 로 넘김(10-4 · 39-1b 로직 지우기 검사 = 모든 층 labor) + +## 39. M02 구조물 조합 개편 (사용자 지시 10-4) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004.md`(2 일위대가 → 구조물 조합 하위 · 3 이름 · 4 구분선 · 5 권한 · 6 항목 변경 · 9 오른쪽 클릭) +- 사용자 확정(일위대가): 구조물 조합에 일위대가 · 도면 · 면적산출이 엮임 · 구조물 조합 복사 = 일위대가 새 키(로직 · 값 같이 복사) · 새 구조물 조합 = 미리보기에서 「새로 만들기」 / 「복사 생성」(목록 · 기본 탭 = 일반 개인 · 회사 관리자 개인 · 시스템 관리자 마스터 · 탭으로 다른 곳) · 항목 하나만 다른 것을 복사해 바꿈(저장 뒤에도) · 매칭 안 된 일위대가 조합 = 마스터 「실무문서」 항목으로 따로(나중에 한 번에 지움) +- 사용자 확정(10-4): 원본 단가산출 로직(resources/master_data 로직 · 표 · 요소 · 마스터 일위대가조합.json)은 절대 손대지 않음(읽기만) · 입력값은 사용자 · 설계자가 일위대가에 정해 저장 → 프로젝트로 복사해 씀 +- 정함(브레인): 조사 먼저(창마다 자기 몫 · 코드 안 고침) → 브레인이 설계 정함(이 절에 줄 더함) → 구현 · 조사 보고 = 각 창 `tmp/조사_39-0*.md` + +- [X] 39-0 조사(보고 여덟 · 정함 = `docs/raw/M02_구조물조합개편/설계_정함.md`) — A 좌측 패널 · 이름 · 구분선(sub5) · B 자료 틀(일위대가 입력값 · 자재목록 · 수량 연결 자리 · sub7) · C 일위대가 상세(sub2) · D 면적산출(sub3) · E 권한(sub4) · F 오른쪽 클릭(sub8) · G 재료산출 · 선택 입력 지우기 영향(sub6) · H 「전체 항목」 목록 화면(sub1) +- [X] 39-9a 오른쪽 클릭 막기(sub8) — 정함(브레인): 공용 진입 한 곳 창 캡처 듣기 · 대상 주소 `/^[bm]\d/` · 글 입력 칸(읽기 전용 포함)은 브라우저 메뉴 살림 · 입력 칸 밖 고른 글은 막음 · CAD 는 cad_host iframe load 때 같은 함수 · 남은 막기 코드 그대로(주석 셋만) · 근거 sub8 `tmp/조사_39-0F.md` — 확인 = 새 시험 test_common_native_menu.py · ORCA `defaultPrevented` 수치(B01 · B05 · B07 · M01 · M02 참 · 입력 칸 · A01 거짓) + - 검증완료(sub8 · 0f8efaa42) — 시험 4 통과 · ORCA 5183 B01 · B05(노선 편집 패널 포함) · B07 · M01 · M02 본문 · 헤더 · 모달 · CAD iframe 리본 · 패널 · 캔버스 막힘 참 · 입력 칸(CAD 안 포함) · A01 거짓 · CAD 오른쪽 단추 확정 그대로 +- [X] 39-9b 메뉴 공통 양식(sub8) — 공용 CSS 한 벌(지도 · 표 메뉴 · 나무 메뉴 · 상태줄 흠) · 「한 번에 하나」 장부 합침 · 조건 만들기 = 칸 메뉴 끝 한 줄(가로채기 지움) · 부품 API 합치기는 안 함 — 확인 = 메뉴마다 폭 · 그림자 · 글자 수치 같음 · 스프레드시트 하네스 시험 + - 검증완료(sub8 · 277311955) — B04 관 · B05 종단 · 배수 · 3D · 칸 · 상태줄 · M02 나무 · 조건 만들기 메뉴 폭 160 · 글자 13 · 여백 8/12 · 모서리 8 · 그림자 같음(밝음 · 어두움) · 나무 ↔ 칸 메뉴 한 번에 하나 · 시험 52 통과 · 조건 만들기를 쓰는 곳(M01 흐름 식 칸)은 칸 메뉴 없는 미리보기 편집기 → 같은 공용 메뉴에 이 줄만 · 시트 탭 메뉴는 탭 켠 화면이 없어 화면 확인 못 함(같은 부품) +- [X] 39-1a 자료 틀(sub7) — labor 종류 · SU 번호 · 문서 검사 · 계산(기준 ③ 서버 · 반영 ① 짝 · [저장] 때 계산값) · `_PER` 1,000 · calc · pills 길 — 확인 = 새 시험 test_m02_labor_doc.py · 거울 시험 · 기존 store · layers 시험 +- [X] 39-1b 옮기기 · 복사 · 프로젝트(sub7) — 옮기기 스크립트(시스템 층 바로 · storage 는 사본 뒤 브레인) · copy_from 층 넘김 + 실무문서 조합 → 일위대가 · 구조물 복사 = 새 SU — 확인 = 옮기기 시험(UP10-000001 → 열 둘) · tree_paste · item 시험 + - 검증완료(sub7) — 새 시험 labor_doc 19 · 거울 3 · migrate 4 · labor_copy 6 + store · structure · structure_combo · item_keys · layers_store · tree_paste · combo_layers · combo_branch · item_inputs · layer_access · M01 combo · cache 통과 · typecheck · 8009 실제 길: item1 SU-000001 기준합 2,658,983 · 알약 13 · 칸 값 3 → 수량출처 칸 · 시스템 층 옮김 item1←UA000008 · item3←UA000001 · item2 빈 · storage 층 안 돌림 · B8 은 sub8(39-1c) · 은퇴 길(use-combo · 갈라 내기 · preview)은 화면이 아직 불러 남김 · 로직 지우기 검사는 ecc4a651a 로 먼저 함(39-1d 와 겹침) +- [X] 39-1d 은퇴 · 로직 지우기 검사(sub4) — 로직 지우기 검사 = 모든 층 labor 담은로직 + 실무문서 · 일위대가 갈라 내기 · 키 적기(use-combo) · 조합 고치기 길과 화면 부름 정리(쓰는 곳 0 확인 뒤) · 남의 층 기록 = 성공한 쓰기만 — 확인 = 로직 지우기 시험(회사 · 개인 labor 에 담긴 로직 막힘) · grep 0 + - 검증완료 — 로직 지우기 시험(회사 · 개인 · 마스터 labor · 실무문서 → 422 · 어디서 쓰는지 알림 · 다 빼면 지워짐 · 임시 사본) · M01 시험 56 · 조합 층 14 · 권한 · 양식 18 통과 · 남의 층 기록 = 성공한 쓰기만(409 · 403 안 남김) +- [X] 39-1d 남은 은퇴(sub4 · sub3 D3 · sub5 받은 뒤) — 쓰는 곳이 남아 못 지움: 갈라 내기 `branch_combo` · 키 적기 `use_combo`(Branch.py · Router.py · Router_Items 길 · Api_Fetch `branchCombo` · `setColumnCombo` ← Combo.ts · ComboPanel.ts) · `fetchUsableCombos` · `/combos/all` ← ComboPanel · Structure · 조합 만들기 · 지우기 길(POST · DELETE) ← 죽은 Labor_Edit.ts(sub2) · 조합 시험 · `Combo_Store.edit`(sub5) · Labor_Api `newCombo` · `editCombo` · `deleteCombo` · `fetchAllCombos` · `editComboTree*` · `pasteCombos` · `fetchLibLogic` · `fetchLibAuto`(sub2 · sub5) + - 검증완료 — 지움: Branch.py · 갈라 내기 · use-combo(옛 · 층 길) · 조합 만들기 · 지우기 · 알약 두 길 · grep 0 · typecheck · 시험 m01_combo · combo_inputs 29 · combo_layers 9 · combo_branch · layers_store 통과 · 남김: /combos/all · fetchUsableCombos(항목 추가 창 일위대가 고르기 pickCombo) · 집계표 열 「일위대가」 칸(그 창이 씀) · 목록 · 키 읽기(Practice · ItemPick) · 실무문서 통째 지우기 · Combo_Store create · delete · pills(sub5 · 시험 · Labor_Doc 알약) +- [X] 39-1c 프로젝트 층(sub8 · 39-1a 뒤) — 스냅샷 일위대가 = labor 문서 · 입력값.json 첫 값 = labor 입력값 · 단가.json 자재 MT · 계산 반영 같은 함수 · B05 고르기 모달 카드 = 도면 · 일위대가(C 보기 부품) · 면적산출 + 값 카드 — 확인 = item 시험 · 고르기 모달 값 → /calc 합계 + - 검증완료(sub8 · e1ad3d362 · 1baf761c4) — 시험 67 통과(새 test_m02_item_labor 5 · picker 20 · copy · inputs) · ORCA 5183 sub8_검증 B05 고르기 모달 카드 도면 · 일위대가 · 면적산출 · 값 넣고 저장 → /calc 반영 합 337,721.97 = 손셈 = 원본 층 계산 · 빈 값 = 멈춘 로직 1 · 빠진 입력 1(붉게) · 저장 안 하고 뒤로 = 버릴지 물음 · 일위대가 쓰기 400 · 밝음 · 어두움 +- [X] 39-1e 프로젝트 면적산출 알약 = labor 알약(sub8 · 39-7b 뒤) — 옛 입력연결 알약 대신 스냅샷 labor 알약 · 정함(브레인): 프로젝트 계산은 빠진 입력이면 그 로직 멈춤 그대로(입력 유도 · 원본 층은 자동값) — 확인 = 고르기 모달 면적 편집 알약 수 = labor 알약 수 · sub6 G3(c704f1898)이 함께 바꿈 · 39-8 에서 확인 +- [X] 39-0i 통합(sub5) — dev 에 sub2 · sub7 · sub3 · sub1 · sub4 · sub8 합침(Api_Fetch · Store_Cache 로직 지우기 검사 두 벌 → sub4 하나 · Structure.ts import) → sub6 이 자기 충돌 풂 → 모두 받기 · 통합 1 f0b8882a8 · 통합 2(sub6 · sub1) 진행 +- [X] 39-2 좌측(sub5) — 일위대가 컨테이너 · 나무 지움 · 하위 차례 도면 → 일위대가 → 면적산출 · 수평선 · 「전체 항목」 줄 · 이름 「일위대가」 · 실무문서 컨테이너(시스템 관리자) · 나무 서버 길 지움 · 대시보드 카드 — 확인 = typecheck · ORCA 5180 줄 수 · 세 역할 화면 + - 검증완료(sub5) — typecheck · 고친 시험 · ORCA 5180 시스템 관리자: 하위 차례 · 수평선(밝음 · 어두움) · 전체 항목 셋 · 일위대가 열 줄 3(SU 는 39-1b 옮기기 뒤 · 지금 「없음」) · 실무문서 37 · 상세 · 지우기 물음 취소 · 회사 · 개인 층엔 실무문서 없음 · 회사 관리자 · 일반은 서버 403 시험(계정 화면은 못 봄) +- [X] 39-3 일위대가 상세(sub2) — labor 종류 상세 · 입력 모달 · 작은 글 · 금액 두 줄 · 자재목록 · 미리 보기 지움 · 다이어그램 · 보기 갈래 부품 · 입력 부품 옮김 — 확인 = 거울 · 다이어그램 시험 · ORCA 5177 값 넣고 저장 → 다시 열기 + - 검증완료(43a59a689 · d847f724c · 3ad7678e1 · 5b4aed1b2) — 시험 통과 · ORCA 5177 item1: 기준 60,984 × 손 5 = 반영 304,922 · 자재 6,090 × 10 = 60,900 · 합 365,822 = 저장 계산값 · 다시 열어 값 남음 · 범위 밖 · 빈 손 수량 · 같은 로직 막음 · 떠날 때 물음 · 어두움 · 남음 = 읽기만 화면(권한 계정) 미확인 · 검증용 labor/item1.json 지움 +- [X] 39-4 면적산출(sub3) — 알약 켬끔(기본 꺼짐) · 오른쪽 패널 = 담은 로직 + 자재목록 · 칸 지정 모달 · setLink · 자재 알약 · 조합 패널 지움 — 확인 = setLink 시험 · ORCA 5178 칸 잇기 → 금액 즉시 · 검증완료(sub3 · 시험 31 · ORCA 5178: 카드 알약 줄 기본 0px → 켬 147px → 다시 열어도 그대로 · 상세 늘 보임 · 오른쪽 패널 로직 14 줄 · C3 = 12.5 잇기 → 반영 726,535 · 20 → 1,162,456 바로 · 「수량 C3 = 12.5」 · 알약 ↔ 모달 같은 연결 · 빈 · 틀린 주소 막음 · 풀기 · 어두움 · 저장 안 하고 옮김 물음 · 옛 조합 패널 쓰는 곳 0) · 검증 대기 +- [X] 39-5 목록 · 고르기 · 카드(sub1) — ItemList · 전체 항목 화면 · 상세 제목 M01 꼴 · 새 항목 두 단추 · 복사 생성 탭 · 카드 셋 · 빈 카드 두 단추 · 복사해 가져오기 — 확인 = item_list 시험 · ORCA 5174 복사 생성 → 새 SU + - 검증완료(18a063925 · 134a727c9 · 364d22ff6 · 6cf72bbf1 · 6f8470890 · 6d46dda54) — 시험 통과 · ORCA 5174 마스터: 전체 항목 줄 수 = 좌측 줄 수(3 · 3 · 3) · 제목 18px · 탭 = 시스템 관리자 다섯(기본 마스터) · 일반 셋(기본 개인) · 빈 사람 = 빈 목록 · 복사 생성 item1 → SS000055 · SD-000004 · SA-000004 · SU-000004(로직 14) · 폴더 안 같은 이름 「ㄴㄴ (2)」 · 실무문서 UA000001 → SU-000002 로직 3(번호 그대로) · 개인 → 마스터 면적산출(SA-000002 그대로) · 카드 차례 도면 → 일위대가 → 면적산출 · 어두움 · 마스터 사본 되돌림(차이 0) · 남음 = 재료산출 · 선택 입력 카드 지움은 sub6 받은 뒤 · 읽기만 계정 화면 미확인 +- [X] 39-6 권한(sub4) — 맡은 회사 규칙 · 사람 나무 · 첫 쓰기 뼈대 · 「〇〇로 복사…」 폴더 · 감사 기록 · 실무문서 문 · apply · sources · readableSources — 확인 = layer_access · 새 layer_manage 시험 · ORCA 세 계정 + - 검증완료 — 시험 layer_access 4 · template_layers · combo_layers 14 · layer_screen(39-6 몫) 통과 · 관련 M02 시험 66 통과 · API 세 계정 25/25 · ORCA 회사 관리자 · 일반 · 시스템 관리자(고객 회사 3) 사람 나무 · 「〇〇로 복사…」 폴더 창 · 밝음 · 어두움 · 쓰기 성공 길은 임시 storage 시험(새 파일 대신 layer_access 에 더함) +- [X] 39-7a 지우기 G1 · G2(sub6) — 자재 조합 · 재료산출 지움 · pickMaterial 옮김 — 확인 = typecheck · check_master · 고친 시험 · ORCA 5181 카드 · 줄 +- [X] 39-7b 지우기 G3(sub6 · 39-3 입력 모달 뒤) — 선택 입력 카드 · inputs · 자리 · 알약 길 지움 — 확인 = 프로젝트 고르기 값 → /calc 합계 그대로 + - 검증완료(sub6 · 54ce67ceb · 61d2ed599 · 79e4bba1b · c704f1898): typecheck 0 · check_master 틀 0건 · 고친 대상 시험 초록(test_m02_card_shell 1건은 origin/dev 자체 빨강) · ORCA 5181 좌측 = 구조물 도면 · 일위대가 · 면적산출 · 집계표 줄 = 일위대가 · 도면 · 면적산출 · 카드 = 도면 정보 · 면적산출(선택 입력 없음) · 면적산출 편집 알약 = 일위대가 알약 · 옵션 제목 밝음 · 어두움 · /calc = 시험(test_item_copy_complete)으로 · 프로젝트 고르기 모달 면적 알약 = 복사본 labor 알약으로 바꿈(39-1e 몫과 겹침) +- [X] 39-1f 새 항목 창 일위대가 고르기 없앰(sub4) — 새 구조물 일위대가 = 늘 빈 문서 · 카드 [새로 만들기] / [복사 생성] · 열 「일위대가」 새로 쓰기 없앰(든 값은 옮기기 뒤 정리) — 확인 = 창 줄 수 · 새 열 빈 labor · sub4 8087c7306 · 창 칸 둘 · 새 열 labor 빈 문서 · 시험 64 +- [X] 39-8 끝 검증 — 흐름 셋(새 구조물 새로 만들기 · 복사 생성 · 항목 하나 복사) × 세 역할 · 일위대가 값 → 금액 · 면적 칸 → 반영 · 실무문서 · storage 옮기기 뒤 확인 · 검증완료(가 sub1 19/22 · 나 sub2 26/27 → 흠 ①~④ · H1~H3 · X3 모두 고침) · main 합침 e6fe5232e · storage 옮기기(브레인 · 사본 tmp/storage_templates_39.zip · labor 5 · 두 번째 「그대로 5」) · 남은 빨강: spreadsheet 늦게 불러오기 시험 1(흐름도 DataFlow · 39 밖) · 검증 대기 +- [X] 39-10 요구 대조(브레인 · 사용자 요청 10-4) — 끝난 뒤 사용자 대화 입력 원문(요구 2~6 · 9 · 답 1~4 · 원본 로직 손대지 않음 · 입력값 복사 흐름) 줄마다 반영 여부 · 근거(화면 · 파일 · 수치) 표 · 요구 31 줄 모두 반영(흠 B1~B4 고친 뒤) · 표 = 보고 Artifact · 검증 대기 +- [X] 39-11 브레인 실제 검증(사용자 요청 10-4) — 끝난 뒤 브레인이 사용자처럼 브라우저 조작(진짜 마우스 · 사진 · 세 역할) · 보고 Artifact · 세 계정 · 진짜 마우스 · 사진(sub5 서버 s01~s15 · 메인 m01~m04) · 흠 B1(표 일위대가 줄 SU) · B2 · B3(전체 항목 합계 · 단위) · B4(새 항목 단추) 고침 → main 11ef83985 · 저장 안 함 · 검증 대기 + +## 40. M02 구조물 조합 개편 2차 (사용자 지시 10-4 밤) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004_2.md` · 「구조물 옵션 보이기」 칸은 그대로(사용자 확인) +- 정함(브레인): 「담은 로직」 → 「단가산출 로직」(상세 · 카드 · 면적 패널 모두) · 수량산출 열 = 위 결과단위 기준 수량(분모 · 예 「10 ㎡」) · 아래 수량(손 · 칸 · 「—」) · 누르면 [손 입력] / [엑셀에서 고르기](그 열 면적산출 시트 모달 · 칸 누르면 그 로직 알약을 그 칸에 이음 · 일위대가 [저장] 때 면적 문서도 함께 저장) · 버튼 = 공용 버튼 양식 · 카드 단추 「가져오기」 · 「수정」(면적산출 카드도 같은 이름) · 카드 [삭제] = 그 항목 비우기(번호 · 열 그대로 · 물음 한 번) · 일위대가 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 · 없으면 기준 값 흐리게) · 알약 색 = 알약 키로 자동(두 테마) · 이은 칸 배경 같은 색 옅게 · 반짝임 그대로 + +- [X] 40-1 일위대가 상세(sub2) — 이름 「단가산출 로직」 · 「자동값 xx」 조사 → 브레인 정함 → 고침 · 수량산출 열 + 손 입력 · 엑셀 모달 잇기(sub3 부품) · 로직 추가 · 올리기 · 내리기 · 빼기 · 자재 추가 = 버튼 · Labor_View 한 줄 표 갈래(카드용) — 확인 = 시험 · ORCA 수량 칸 손 · 엑셀 고름 → 반영 값 · 저장 → 다시 열기 + - 검증완료(1c0868b78 · fd4830556 · e70fc184a · 35ccc26af) — 시험 34 통과 · ORCA 5177 SU-000003: 손 5 → 79,280 × 5 = 396,398 · 엑셀 B6(12.5) → 4,523 × 12.5 = 56,537 · 저장 → 면적 연결 「노체포설|㎥ → B6」 · 손 5 · 반영합 = 화면 · 다시 열기 같음 · 저장 안 하고 떠나면 물음 · 고르기 칸 「자동값」 줄 없음(B05 값 카드 확인 상자 그대로) · 한 줄 표 300~560 · 면적 패널 320~560 가로 넘침 없음 · 밝음 · 어두움 · 검증용 마스터 2 파일 되씀 +- [X] 40-2 구조물 조합 상세(sub1) — 카드 단추 「가져오기」 · 「수정」 · 일위대가 카드 한 줄 표(sub2 갈래) · 「새 항목」 버튼 · 카드 셋 3열 · 카드 [삭제](비우기) — 확인 = ORCA 3열 · 삭제 → 빈 카드 · 시험 + - 검증완료(74a524202 · e3236558b) — 시험 53 통과 · ORCA 127.0.0.1:5174 폭 1600 = 3열(카드 402px · 높이 628 같음) · 1000 · 400 = 1열 · 가로 넘침 0 · 400 에서 면적 카드 단추 여섯 줄 바꿈(카드 밖 0) · 단추 「수정」 · 「가져오기」 · 「삭제」 · 「새 항목」 높이 32 · 글 14px(저장과 같음) · item1 일위대가 [삭제] → SU-000001 · item1 그대로 · 담은로직 14 → 0 · 빈 카드 두 단추 · 면적산출 [삭제] → SA-000001 그대로 · 칸 · 연결 0 · 일반 계정 마스터 · 회사 카드 단추 0 · 어두움 · 마스터 사본 되씀(차이 0) · 남음 = 도면 [삭제] 는 마스터 도면이 다 비어 화면 확인 못 함(서버 길은 40-4 시험) +- [X] 40-3 면적산출 상세(sub3) — 알약 끌어 칸에 놓기 추가 · 알약 색 자동 · 이은 칸 같은 색 · 반짝임 그대로 · 엑셀 칸 고르기 모달 부품(sub2 용) — 확인 = 끌어 놓기 → 연결 · 색 수치 · 두 테마 · 검증완료(sub3 · 시험 16 + 하네스 15 · ORCA 5178: 칸 고르기 모달 C5 · 풀기 · 그만둠 · 끌어 C3 → 연결 · Ctrl+Z 풂 · F14 에 놓기 = 물음(취소 그대로 · 바꿈 = 갈아끼움) · 색 24 서로 다름 · 테두리 대비 최소 밝음 3.40 · 어두움 3.55 · 글 8.85 · 6.35 · 카드 · 상세 둘 다 · 마스터 변화 0) · 검증 대기 +- [X] 40-4 서버 비우기 길(sub7) — 항목(도면 · 일위대가 · 면적산출) 비우기 = 빈 문서로 씀(번호 · 열 그대로 · 권한 규칙 그대로) — 확인 = 시험(세 종류 · 권한 403) + - 검증완료(sub7) — test_m02_part_clear 7(세 종류 빈 문서 = 새 열 꼴 · 번호 · 장부 그대로 · 면적 비우면 일위대가 수량 「없음」 · 일반 마스터 403 · 남의 개인 403 · 회사 관리자 동료 개인 200 · 없는 열 404 · 모르는 종류 422) · typecheck · 00cb6db66 · 화면 함수 clearPart +- [x] 40-5 합치기 · 끝 검증 · 메인 반영 · 브레인 요구 대조 · 실제 검증 · 보고 · main 795b861da(40 · 41 함께 · 흠 D1~D4 고침 · CAD 빌드 · 시험 90) · 끝 검증 · 브레인 실제 검증은 사용자가 하기로(10-4) · 검증 대기 + +## 41. 면적산출 표 → 도면 표 (사용자 지시 10-4 밤) + +- 사용자 요구 원문 = `docs/raw/M02_구조물조합개편/사용자요구_1004_2.md` 끝 「추가 기능」 — 면적산출에서 범위를 골라 도면으로 보내면 도면에 표가 들어가고 칸 내용이 따라감(어려우면 [업데이트] 단추) · 표 여러 개 · 자리는 도면에서 사용자가 끌어 정함 +- 정함(브레인): 조사 먼저(CAD 표 · 묶음 꼴 · 호스트 ↔ CAD 메시지 · 끌어 놓기 · 값 따라가기 길) → 브레인이 설계 정함 → 구현 · 원본 단가산출 로직 자료 손대지 않음 + +- [X] 41-0 조사(sub8) — 보고 `tmp/조사_41-0.md` · 끝(CAD 표 객체 TableEntity 있음 · 미리보기 틀 있음 · DXF 표 빠짐 흠) + +- 정함(브레인 · 조사 추천 그대로): 표 = CAD 표 객체(TableEntity · 한 덩어리) · 따라가기 = 면적 [저장] 때 서버가 그 열 도면 표 칸 글을 다시 적음 + 도면을 열 때 기록 값 ≠ 지금 값이면 알림 · [업데이트] · 연결 표지 = 표 style.연결(표 id) · 칸 key 「시트!A1」 · 보낼 꼴 = 표시 글(형식 적용) · 병합 · 가로 정렬 · 굵게 · 열 너비 비율(채움 · 테두리색 · 글자색 안 보냄) · 크기 = 표 글자 크기 하나(기본 2.5 · 보낼 때 바꿀 수 있음) · 넘김 = 같은 페이지 안(캐시 · 서버 안 씀) · 시작은 면적 상세 [도면으로 보내기] 단추(칸 메뉴는 뒤) · DXF 내보내기에 표 = 선 + 글 + +- [x] 41-1 서버(sub7) — 범위 → 표 칸 변환(Node 묶음 formatValue 다시 씀 · 새 M02_Area_Table.py) · POST /api/m02/layers/{층}/area/{열}/table · 면적 저장 때 도면 표 맞춤(마스터 · 회사 · 개인 층 + 프로젝트 층 길) — 확인 = 시험(형식 · 병합 · 한도 · 권한 · 저장 뒤 표 글 바뀜) + - 검증완료(sub7) — test_m02_area_table 10(형식 「1,234.50」 · 식 칸 · 굵게 · 가로 · 병합 rowSpan · 범위 밖 병합 잘라냄 · 숨긴 행 3 · 열 D 뺌 · 빈 칸 · 한도 171>100 · 없는 시트 · 범위 꼴 · 길 200 · 저장 안 함 · 남의 개인 403 · 없는 열 404 · 면적 저장 뒤 T1 칸 「10.00」 · 자리 · 크기 그대로 · 다른 표 그대로 · 표 없는 T2 기록 값 갱신 · 프로젝트 층 맞춤) + store · structure · template_layers · labor · clear · copy · spreadsheet_store 74 통과 · typecheck · 8009 실제 길 200 · 3ded39875 +- [x] 41-2 CAD · 호스트(sub8) — 메시지 place-table · update-table · 「표 놓기」 도구(커서 따라오는 미리보기 · 누르면 놓임 · Esc · 되돌리기) · cad_host · Drawing.ts 함수 · CAD 빌드 · DXF 표 = 선 + 글 — 확인 = vitest · ORCA 놓기 · DXF LINE · TEXT 수 + - 검증완료(sub8 · c426fa2ad · 0bc560663 · cc63e49ab · b2aa2a987) — vitest 8(놓기 · 연결 남음 · 칸 갈기 · 넘침 · 없는 연결) · DXF 시험 4(거울 = TS 표 기하 · 면적 표 LINE 13 · TEXT 5 · 정렬 L/C/R · B07 수량표 LINE 7 · TEXT 3 — 전에는 표가 빠짐) · ORCA 5183 M02 도면 상세: 미리보기가 커서 따라옴 · 누르면 놓임(drawing-changed) · Esc 그만 · Ctrl+Z 1→0 · 다시하기 · MOVE(새 id) 뒤 저장 JSON style.연결 T1 남음 · update-table T1 = 1 · 없는 연결 0 · 자리 · 너비 그대로 · 밝음 · 어두움 · 저장 안 하고 나감 · 자동백업 지움 +- [x] 41-3 화면(sub5) — 면적 상세 [도면으로 보내기](고른 범위 · 글자 크기) → 그 열 도면 상세로 넘겨 놓기 · [저장] 때 연결표 기록 · 다시 열 때 다름 알림 + [업데이트] · 표 여러 개 — 확인 = ORCA 범위 고름 → 보냄 → 끌어 놓음 → 저장 → 면적 값 바꿔 저장 → 도면 표 글 바뀜 + - 검증완료(sub5) — typecheck · test_m02_area_to_drawing 7 · structure_panel · ORCA 5180 마스터: A1:C4 보냄 → 놓음 → 저장(표 T1 · 연결표 T1) · 면적 A1 · B1 · C2(식) 저장 → 도면 표 글 길이 · 12.5 · 25 · 같으면 알림 0 · 기록 값 옛 값이면 「바뀐 표 1개」 + [업데이트] → 저장 값 갱신 · 표 셋(T1~T3 · 글자 5 · 2.5) · T3 지움 → 도면 · 연결표 둘 다 빠짐 · 마스터 사본 되씀(git 빈 상태) · 권한 없는 층 단추 0 은 시험으로만