From b1dbd9c3cb331fb43e2279b91f1827238c683455 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:26:53 +0900 Subject: [PATCH 01/12] =?UTF-8?q?fix(B07=20CAD):=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EB=A7=8C=EB=93=A0=20=ED=91=9C=20=EC=84=A0=20?= =?UTF-8?q?=EC=83=89=20=C2=B7=20=EA=B5=B5=EA=B8=B0=20=C2=B7=20id=20?= =?UTF-8?q?=EB=B9=84=EB=A9=B4=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=E2=80=94=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=20=C2=B7=20=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20=EC=97=B4=EA=B8=B0=20?= =?UTF-8?q?=ED=95=9C=20=EA=B3=B3=20(43-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TableEntity.fromJson: lineColor 비면 자동색 #fff · lineWidth 비면 1 · id 비면 새 id(면적산출 → 도면 표 변환 답에 세 칸이 없음 → 저장 JSON 선 색 빠지던 흠 E2) - 시험 aislo-table-bridge.test.ts 에 선 색 없는 표 놓기 · 저장 JSON 선 색 · 다시 읽기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../openwebcad/src/entities/TableEntity.ts | 8 ++++--- .../integration/aislo-table-bridge.test.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts index 5662184dd..33767ed58 100644 --- a/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts +++ b/B07_DesignDetail/openwebcad/src/entities/TableEntity.ts @@ -351,9 +351,11 @@ export class TableEntity implements Entity { data.cells, data.style ); - table.id = jsonEntity.id; - table.lineColor = jsonEntity.lineColor; - table.lineWidth = jsonEntity.lineWidth; + // 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의 + // 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다. + table.id = jsonEntity.id || table.id; + table.lineColor = jsonEntity.lineColor || table.lineColor; + table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth; table.lineDash = jsonEntity.lineDash; return table; } 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 f11cab527..741cf8246 100644 --- a/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts +++ b/B07_DesignDetail/openwebcad/src/integration/aislo-table-bridge.test.ts @@ -77,6 +77,28 @@ describe('표 놓기(PLACETABLE)', () => { }); }); +describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => { + it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => { + const json = await table('T8').toJson(); + if (!json) throw new Error('표 JSON 없음'); + const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json; + await handleTableMessage( + { type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' }, + vi.fn() + ); + const pending = getPendingTable(); + expect(pending?.lineColor).toBe('#fff'); + expect(pending?.lineWidth).toBe(1); + const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id); + placed.lineColor = pending?.lineColor ?? ''; + const saved = await placed.toJson(); + expect(saved?.lineColor).toBe('#fff'); + const back = await TableEntity.fromJson(bare as typeof json); + expect(back.id).toBeTruthy(); + expect(back.lineColor).toBe('#fff'); + }); +}); + describe('표 칸 갈기(update-table)', () => { it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => { const linked = table('T1'); From 5067ee4478241c90fddd2800fc837a3fcb74f44a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:27:47 +0900 Subject: [PATCH 02/12] =?UTF-8?q?chore(M02):=203=EC=B0=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9A=94=EA=B5=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=9B=90=EB=AC=B8(=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20=EC=97=86?= =?UTF-8?q?=EC=95=B0=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0)?= 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_구조물조합개편/사용자요구_1005.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1005.md b/docs/raw/M02_구조물조합개편/사용자요구_1005.md index d872c1c82..bea9b003e 100644 --- a/docs/raw/M02_구조물조합개편/사용자요구_1005.md +++ b/docs/raw/M02_구조물조합개편/사용자요구_1005.md @@ -8,3 +8,7 @@ 4. 구조물조합에서 일위대가 미리보기에서 일위대가 상세화면의 단가산출 로직(순서와 로직추가는 제외)과 자재목록을 그대로 보여주게 하되 기능은 유지(수량산출 연결창, 옵션지정 모달창)하고 수평스크롤을 가능하게 하기 5. 일위대가의 재료도 2열구조여야 할것같은데.... 6. 도면정보, 일위대가, 면적산출을 접근 후 제목의 돌아가기 버튼은 알약이 아니라 일반 버튼으로 + +## 추가 (10-5 새벽 · 원문 그대로) + +추가 항목이 하나더 있어. 구조물 조합에서의 미리보고 3개 컨테이너에 새로만들기와 복사 생성로직이 필요없겠네. 구조물 조합의 새항목 생성을 하면 당연히 새로운 항목들이 자동으로 생성되잖아. 그후 가져오기를 하면 당연히 값을 가져와서 덮어쓰면 되겠어. 그리고 일위대가와 면적산출에서 가져오기를 하면 도면은 개별로 작성되는게 맞지만 일위대가와 면적산출은 연결되어 있는정보니 같이 가져오는게 맞겠어. 알약연결 포함해서. 이 내용도 추가해줘. From e2a938f2cfe90fc4f38585f6804b6042202c911b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:28:37 +0900 Subject: [PATCH 03/12] =?UTF-8?q?fix(M02):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20[=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0]=20?= =?UTF-8?q?=3D=20=EC=9D=BC=EB=B0=98=20=EB=8B=A8=EC=B6=94=20=C2=B7=20?= =?UTF-8?q?=EB=A8=B8=EB=A6=AC=20=EB=8B=A8=EC=B6=94=20=EB=8C=80=EB=B9=84=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4(43-5=20=C2=B7=20E1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도면 · 일위대가 · 면적산출 상세 [돌아가기] = 공용 기본(filled · 32px · 머리 단추와 같은 높이 · ghost sm 아님) - E1 대비 1.11 = ORCA 탭에서 단추 color 전환이 멈춘 채 읽힌 착오 · 전환 끄고 재면 어두움 8.01 · 밝음 16.65 → 색 고침 없음 - 시나리오 resources/tester/ui_checks/43-5_돌아가기_대비.js · 시험 한 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_UI_Main.ts | 4 +- resources/tester/test_m02_structure_panel.py | 7 ++ .../tester/ui_checks/43-5_돌아가기_대비.js | 96 +++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 resources/tester/ui_checks/43-5_돌아가기_대비.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 13630fac5..77d6b3146 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -130,8 +130,8 @@ export function createMain( const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") }); const reload = createButton({ label: L("M02_Reload"), variant: "ghost" }); const save = createButton({ label: L("M02_Save"), variant: "filled" }); - // 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) - const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" }); + // 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님) + const back = createButton({ label: L("M02_Back") }); const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴 const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); const host = el("div", { className: "m02-main__host" }); diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index be79312be..d73ce71e0 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -238,3 +238,10 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): body = main.split('back.addEventListener("click"')[1] assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body assert "sheet?.selectColumn(column)" in body + + +def test_상세_머리_돌아가기는_일반_단추(): + """43-5 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] = 공용 기본 단추(filled · 같은 높이 · + 알약 · ghost · sm 아님) · 대비는 ui_checks/43-5 시나리오(어두움 8.01).""" + main = _text(MAIN) + assert 'const back = createButton({ label: L("M02_Back") });' in main diff --git a/resources/tester/ui_checks/43-5_돌아가기_대비.js b/resources/tester/ui_checks/43-5_돌아가기_대비.js new file mode 100644 index 000000000..7207f3c96 --- /dev/null +++ b/resources/tester/ui_checks/43-5_돌아가기_대비.js @@ -0,0 +1,96 @@ +/* 43-5 · E1 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] 꼴 · 머리 단추 글자 대비(밝음 · 어두움) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 기대 수치: + * - 세 상세 모두 back.cls = "ui-btn ui-btn--filled"(ghost · sm 없음) · back.h = 머리 다른 단추 높이(±1px) + * - 밝음 · 어두움(앱 ☾ 단추) 모두 머리 보이는 단추 대비 ≥ 4.5 + * - 끝나면 처음 테마로 되돌림(theme.end = theme.start) + * ⚠ 단추 color 전환(120ms)이 ORCA 탭에선 0ms 에 멈춰 옛 색이 읽힘(1.11 착오 · E1) — 재는 동안 전환을 끔 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const rgb = (s) => (s.match(/[\d.]+/g) || []).map(Number); + const lum = ([r, g, b]) => { + const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); + }; + /** 실제로 보이는 바탕 — 투명이면 조상으로 */ + const bgOf = (el) => { + for (let e = el; e; e = e.parentElement) { + const c = rgb(getComputedStyle(e).backgroundColor); + if (c.length >= 3 && (c.length < 4 || c[3] > 0.5)) return c.slice(0, 3); + } + return [255, 255, 255]; + }; + const ratio = (el) => { + const a = lum(rgb(getComputedStyle(el).color).slice(0, 3)); + const b = lum(bgOf(el)); + return +((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)).toFixed(2); + }; + const theme = () => document.documentElement.getAttribute("data-theme") || "auto"; + const moon = () => + [...document.querySelectorAll("button")].find((b) => /^[☾☀]$/.test(b.textContent.trim())); + const headButtons = () => + [...document.querySelectorAll(".m02-main__head button")].filter( + (b) => !b.hidden && b.offsetParent, + ); + const openRow = async (sectionTitle, prefix) => { + const sec = [...document.querySelectorAll(".m02-side__summary > section")].find( + (s) => s.querySelector("h3").textContent.trim() === sectionTitle, + ); + sec.classList.remove("is-collapsed"); + [...sec.querySelectorAll("button")].find((b) => b.textContent.startsWith(prefix)).click(); + await w(5000); + }; + const measure = () => { + const back = [...document.querySelectorAll(".m02-main__head button")].find( + (b) => b.textContent.trim() === "돌아가기", + ); + const others = headButtons().filter((b) => b !== back); + return { + title: document.querySelector(".m02-main__title")?.textContent, + back: back && { + cls: back.className, + h: Math.round(back.getBoundingClientRect().height), + radius: getComputedStyle(back).borderRadius, + }, + otherH: others.map((b) => Math.round(b.getBoundingClientRect().height)), + contrast: Object.fromEntries( + headButtons().map((b) => [b.textContent.trim() || b.title, ratio(b)]), + ), + }; + }; + const still = document.createElement("style"); + still.textContent = "*,*::before,*::after{transition:none!important}"; + document.head.append(still); + const start = theme(); + const out = { theme: { start }, pages: {} }; + for (const [name, sec, prefix] of [ + ["도면", "구조물 도면", "SD-000001"], + ["일위대가", "일위대가", "SU-000001"], + ["면적산출", "면적산출", "SA-000001"], + ]) { + await openRow(sec, prefix); + const light = measure(); + moon()?.click(); + await w(400); + const dark = { theme: theme(), ...measure() }; + moon()?.click(); + await w(400); + out.pages[name] = { light, dark }; + } + if (theme() !== start) moon()?.click(); + out.theme.end = theme(); + still.remove(); + const all = Object.values(out.pages).flatMap((p) => [ + ...Object.values(p.light.contrast), + ...Object.values(p.dark.contrast), + ]); + out.minContrast = Math.min(...all); + out.backOk = Object.values(out.pages).every( + (p) => + p.light.back?.cls === "ui-btn ui-btn--filled" && + p.light.otherH.every((h) => Math.abs(h - p.light.back.h) <= 1), + ); + return JSON.stringify(out); +})(); From 848f8bbc931663547e5f18a17d9bc5c731e5ccb5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:33:02 +0900 Subject: [PATCH 04/12] =?UTF-8?q?test(M02):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=2043-6=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=ED=91=9C=20=EC=84=A0=20=EC=83=89=20=E2=80=94=20orc?= =?UTF-8?q?a=20eval=20=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=86=93=EA=B8=B0?= =?UTF-8?q?=20=E2=86=92=20=EC=A0=80=EC=9E=A5=20JSON=20=EC=84=A0=20?= =?UTF-8?q?=EC=83=89=20=C2=B7=20=EB=90=98=EB=8F=8C=EB=A6=BC=20(43-6)?= 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 --- .../tester/ui_checks/43-6_도면표_선색.js | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 resources/tester/ui_checks/43-6_도면표_선색.js diff --git a/resources/tester/ui_checks/43-6_도면표_선색.js b/resources/tester/ui_checks/43-6_도면표_선색.js new file mode 100644 index 000000000..15e939a3f --- /dev/null +++ b/resources/tester/ui_checks/43-6_도면표_선색.js @@ -0,0 +1,67 @@ +/* 43-6 도면 표 선 색(흠 E2) — orca eval 한 번에 도는 시나리오(읽기만 · 저장 안 함). + * 시작 화면: M02 · 시스템 관리자 · 구조물 도면 상세(마스터 item1 · CAD 열림 · 보기 전용 아님). + * 하는 일: 면적산출 범위 → 표 변환(POST …/area/item1/table · 답에 lineColor 없음) → CAD 에 place-table → + * 커서 옮김 → 누름(놓기) → save-request 로 CAD 도면 JSON 받음 → Ctrl+Z 로 되돌림. + * 기대: 변환 답 lineColor 없음(answerHasColor false) · 놓인 표 1 · 저장 JSON 표 lineColor = "#fff" · + * lineWidth 1 · id 있음 · 되돌린 뒤 표 수 = 시작 수. + */ +(async () => { + const W = (t) => new Promise((r) => setTimeout(r, t)); + const f = document.querySelector("iframe"); + const w = f.contentWindow; + const cad = w.__aisloCad; + const before = cad.entities().filter((e) => e.type === "Table").length; + const area = await ( + await fetch("/api/m02/layers/system/templates/area/item1", { credentials: "include" }) + ).json(); + const sheet = area.문서.시트[0].id; + const got = await ( + await fetch("/api/m02/layers/system/area/item1/table", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ 시트: sheet, 범위: "A1:B2", 글자크기: 2.5 }), + }) + ).json(); + const entity = got.표; + w.postMessage({ type: "aislo:b08:place-table", entity, link: "T43" }, location.origin); + await W(600); + const c = f.contentDocument.querySelector("canvas"); + const r = c.getBoundingClientRect(); + const at = { bubbles: true, button: 0, clientX: r.left + 320, clientY: r.top + 260 }; + c.dispatchEvent(new MouseEvent("mousemove", at)); + await W(window.__hold ?? 300); // 사진 찍을 때는 __hold 를 늘림 + c.dispatchEvent(new MouseEvent("mousedown", at)); + c.dispatchEvent(new MouseEvent("mouseup", at)); + await W(500); + const placed = cad.entities().filter((e) => e.type === "Table").length - before; + let saved = null; + const take = (e) => { + if (e.data && e.data.type === "aislo:b08:save-response") saved = e.data.drawing; + }; + window.addEventListener("message", take); + w.postMessage({ type: "aislo:b08:save-request" }, location.origin); + await W(1000); + window.removeEventListener("message", take); + const table = (saved?.entities ?? []).find( + (e) => e.type === "Table" && e.shapeData?.style?.연결 === "T43", + ); + f.contentDocument.body.dispatchEvent( + new KeyboardEvent("keydown", { + key: "z", + code: "KeyZ", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + await W(400); + return JSON.stringify({ + answerHasColor: "lineColor" in entity, + placed, + lineColor: table?.lineColor ?? null, + lineWidth: table?.lineWidth ?? null, + hasId: !!table?.id, + afterUndo: cad.entities().filter((e) => e.type === "Table").length === before, + }); +})(); From bc1314e94caac02ac4fc29c485f32b8400e2152f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:33:28 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=9E=90=EC=9E=AC=20=EC=A4=84=202=ED=96=89=20?= =?UTF-8?q?=C2=B7=20=EA=B0=80=EA=B2=A9=20=EB=8B=A4=EC=84=AF(43-3)=20?= =?UTF-8?q?=E2=80=94=20M01=20=EA=B0=92=20=EC=97=B4=20=EB=8B=A4=EC=84=AF(?= =?UTF-8?q?=EB=AC=BC=EA=B0=80=EC=9E=90=EB=A3=8C=20=C2=B7=20=EC=9C=A0?= =?UTF-8?q?=ED=86=B5=EB=AC=BC=EA=B0=80=20=C2=B7=20=EB=AC=BC=EA=B0=80?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=C2=B7=20=EA=B1=B0=EB=9E=98=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=20=C2=B7=20=EA=B4=80=EA=B8=89)=20=C2=B7=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EB=8B=A8=EA=B0=80=20=EC=B9=B8=20=EA=B0=95=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 calc 답 자재 줄에 가격 · 기준칸(마스터 · 회사 · 개인 층 calc · 프로젝트 복사본 calc 같은 함수 material_price) · 원본 자료는 읽기만 - 자재목록 표 = 코드 · 이름 · 규격 · 수량산출(위 1 단위 / 아래 지금 수량) · 단위 · 가격 다섯(빈 것 「—」) · 기준단가 / 반영 금액 - 화면 판정 시나리오 resources/tester/ui_checks/43-3_자재가격.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- M02_MasterTemplete/M02_Item_Prices.py | 7 +- M02_MasterTemplete/M02_Labor_Doc.py | 40 +++++++---- .../M02_MasterTemplete_Labor.css | 7 ++ .../M02_MasterTemplete_Labor_Calc.ts | 4 ++ .../M02_MasterTemplete_Labor_Rows.ts | 52 +++++++------- resources/tester/test_m02_item_labor.py | 3 + resources/tester/test_m02_labor_doc.py | 3 + resources/tester/ui_checks/43-3_자재가격.js | 70 +++++++++++++++++++ 8 files changed, 145 insertions(+), 41 deletions(-) create mode 100644 resources/tester/ui_checks/43-3_자재가격.js diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index a5ae96b66..df2b05f5c 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -325,11 +325,8 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An mats = [] for one in doc.get("자재") or []: code = str(one.get("코드") or "") - try: - price, _ = mm.element(snapshot, code, {}, one.get("단위") or None) - except mf.FormulaError: - price = None - mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price}) + got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3) + mats.append({"코드": code, "알약": labor_doc.material_key(code), **got}) settled = labor_doc.settle( to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area) ) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index eaf3979d3..1dd338ff5 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -5,7 +5,8 @@ 수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}` (손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값). 금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은 -반영 없음 · 자재 기준 단가 = `master_material.element` 기본값. +반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) · +기준단가가 된 칸 이름(`기준칸`)도 실음(43-3). 짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` — 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`. """ @@ -247,6 +248,24 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)}) +def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: + """자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element` + (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만.""" + row = master.get(code) or {} + prices = {slot: row.get(slot) for slot in mm.SLOTS} + try: + price, source = mm.element(master, code, {}, unit or None) + except mf.FormulaError as e: + return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)} + slot = str(source).split(".")[-1] if source else None + return { + "기준단가": price, + "가격": prices, + "기준칸": slot if slot in mm.SLOTS else None, + "까닭": None if price is not None else "단가 없음", + } + + def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: """`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) — 줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음).""" @@ -277,17 +296,14 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) continue lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) - mats = [] - for one in doc.get("자재") or []: - try: - price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None) - except mf.FormulaError as e: - price, why = None, str(e) - else: - why = None if price is not None else "단가 없음" - mats.append( - {"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why} - ) + mats = [ + { + "코드": one["코드"], + "알약": material_key(one["코드"]), + **material_price(whole, one["코드"], one.get("단위")), + } + for one in doc.get("자재") or [] + ] have = _area_of(doc.get("열")) if given is None else given return settle(_json({"줄": lines, "자재": mats}), doc, have) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 220c81a2a..b581f596b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -58,6 +58,13 @@ font-size: var(--text-caption); } +/* 자재 가격 다섯 — 기준단가가 된 칸(43-3) */ +.m02-labrow__price.is-base-price { + color: var(--color-accent); + font-weight: 700; + box-shadow: inset 0 -2px 0 var(--color-accent); +} + .m02-labrow__applied { display: block; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index dcff3da3c..dd9da6fe9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -28,6 +28,10 @@ export interface CalcMaterial { 코드: string; 알약?: string | null; 기준단가: number | null; + /** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */ + 가격?: Record; + /** 기준단가가 된 열 이름 — 없으면 null */ + 기준칸?: string | null; 수량?: number | null; 수량출처?: QtySource; 반영?: number | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 5189b76b1..37d415b69 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -3,7 +3,8 @@ * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. * 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄. - * 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」). + * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). + * 금액은 calc 답(없으면 「…」). * 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음). * ========================================================================== */ @@ -278,7 +279,12 @@ export interface MatTableData { cells?: ReadonlyMap; } -/** 자재목록 표 — 보기 갈래는 `on` 없이 */ +/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */ +export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const; + +/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금 + * 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) · + * 보기 갈래는 `on` 없이 */ export function matTable(data: MatTableData, on?: Omit): HTMLElement { const wait = !data.calc && !data.fail; const remove = on?.remove; @@ -286,7 +292,20 @@ export function matTable(data: MatTableData, on?: Omit): HT wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v); const trs = data.mats.map((mat, i) => { const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드); - const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량); + // 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1 + const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine; + const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => { + const base = !!line?.기준칸 && line.기준칸 === slot; + return el("td", { + className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`, + text: show(line?.가격?.[slot]), + attrs: { + "data-label": slot, + "data-price-slot": slot, + ...(base ? { title: tc("Labor_BasePrice") } : {}), + }, + }); + }; const tr = el("tr", { className: on ? "m02-labrow__row is-open" : "m02-labrow__row", attrs: { "data-labor-mat": mat.코드 }, @@ -302,29 +321,14 @@ export function matTable(data: MatTableData, on?: Omit): HT ], }), el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }), + qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null), 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), - "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") }, - }), + ...PRICE_SLOTS.map(priceCell), + moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")), ...(remove ? [ el("td", { @@ -351,10 +355,10 @@ export function matTable(data: MatTableData, on?: Omit): HT tc("Labor_Code"), tc("Col_Name"), tc("Labor_Spec"), + tc("Labor_QtyCol"), tc("Labor_Unit"), - tc("Labor_Qty"), - tc("Labor_BasePrice"), - tc("Labor_AppliedSum"), + ...PRICE_SLOTS, + `${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`, ...(remove ? [tc("Col_Move")] : []), ]; return data.mats.length diff --git a/resources/tester/test_m02_item_labor.py b/resources/tester/test_m02_item_labor.py index ca3f2b4cb..90c0f831c 100644 --- a/resources/tester/test_m02_item_labor.py +++ b/resources/tester/test_m02_item_labor.py @@ -181,6 +181,9 @@ def test_계산_반영_합은_손셈_원본과_같음(world): (mat,) = got["자재"] assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"] assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY) + assert ( + mat["가격"][mat["기준칸"]] == mat["기준단가"] + ) # 복사본 단가 묶음으로도 가격 다섯 · 기준칸(43-3) total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"] assert got["반영합"]["계"] == pytest.approx(total) # 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것 diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index 9f2cd52b1..adee730ee 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -98,6 +98,9 @@ def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): assert (thousand["수량"], thousand["수량출처"]) == (1, "손") mat = got["자재"][0] assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000) + # 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름 + assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] + assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000 nothing = labor.calc(doc, {})["줄"][0] assert (nothing["수량출처"], nothing["반영"]) == ("없음", None) diff --git a/resources/tester/ui_checks/43-3_자재가격.js b/resources/tester/ui_checks/43-3_자재가격.js new file mode 100644 index 000000000..694b78810 --- /dev/null +++ b/resources/tester/ui_checks/43-3_자재가격.js @@ -0,0 +1,70 @@ +// 43-3 자재 줄 2행 · 가격 다섯 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎(async 식 · JSON 돌려줌). +// 시작 화면 = M02 마스터 템플릿(#/m02-master-template · 시스템 관리자 · 마스터 층). +// 하는 일 = 좌측 일위대가 SU-000001 상세 → [자재 추가] → MT004177(시멘트, 전국) 고름 → 줄 수치 읽음 → [빼기]로 되돌림(저장 안 함). +// 기대 = heads 에 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · prices = 6,545 · 6,727 · 6,090 · 6,181 · — · +// base = 물가정보(굵게 · 강조) · money.base = 6,090 · qty.base = 「1 포」 · after = 0(빼기 뒤 줄 없음). +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const got = fn(); + if (got) return got; + await wait(150); + } + return null; + }; + const CODE = "MT004177"; + const group = [...document.querySelectorAll(".m02-side__group")].find((s) => + s.querySelector(".ui-collapsible__title")?.textContent.trim().startsWith("일위대가"), + ); + if (!group) return JSON.stringify({ error: "좌측 일위대가 없음" }); + if (group.classList.contains("is-collapsed")) + group.querySelector(".ui-collapsible__title").click(); + const su = [...group.querySelectorAll("button")].find((b) => + b.textContent.startsWith("SU-000001"), + ); + su?.click(); + const add = await until(() => + [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === "자재 추가"), + ); + if (!add) return JSON.stringify({ error: "자재 추가 단추 없음" }); + add.click(); + const box = await until(() => document.querySelector(".m02-pick input")); + let item = null; + for (let tries = 0; tries < 3 && !item; tries += 1) { + // 서버가 막 다시 떴으면 첫 찾기가 늦음 — 다시 침(찾기 칸 글 = 코드) + box.value = tries ? `${CODE} ` : CODE; + box.dispatchEvent(new Event("input", { bubbles: true })); + item = await until(() => { + const items = [...document.querySelectorAll(".m02-pick__item")]; + return items.length === 1 && items[0].dataset.material === CODE ? items[0] : null; + }, 4000); + } + if (!item) return JSON.stringify({ error: "고르기 창에 코드 줄 없음" }); + item.click(); + const row = await until(() => { + const tr = document.querySelector(`tr[data-labor-mat="${CODE}"]`); + return tr && !tr.textContent.includes("…") ? tr : null; + }, 8000); + if (!row) return JSON.stringify({ error: "자재 줄이 안 섬(계산 대기)" }); + const table = row.closest("table"); + const out = { + heads: [...table.querySelectorAll("thead th")].map((th) => th.textContent.trim()), + prices: [...row.querySelectorAll("[data-price-slot]")].map((td) => td.textContent.trim()), + base: [...row.querySelectorAll(".is-base-price")].map((td) => td.dataset.priceSlot), + baseWeight: getComputedStyle(row.querySelector(".is-base-price") ?? row).fontWeight, + qty: { + base: row.querySelector(".m02-labrow__qty .m02-labrow__base")?.textContent.trim(), + applied: row.querySelector(".m02-labrow__qty .m02-labrow__applied")?.textContent.trim(), + }, + money: { + base: row.querySelector(".m02-labrow__money .m02-labrow__base")?.textContent.trim(), + applied: row.querySelector(".m02-labrow__money .m02-labrow__applied")?.textContent.trim(), + }, + }; + // 되돌림 — 그 줄 [빼기](저장 안 함) + [...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "빼기")?.click(); + await wait(400); + out.after = document.querySelectorAll(`tr[data-labor-mat="${CODE}"]`).length; + return JSON.stringify(out); +})(); From ca4fcf0364a1a86ef2f5b82eb722d2039d99cee6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:33:55 +0900 Subject: [PATCH 06/12] =?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=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=3D=20=EC=83=81=EC=84=B8=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=E2=80=94=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=A1=9C=EC=A7=81=202=ED=96=89=20=ED=91=9C=20+=20?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=EB=AA=A9=EB=A1=9D(Labor=5FDetail=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B0=88=EB=9E=98=20=C2=B7=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=C2=B7=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=C2=B7=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=B9=B8=20=EC=97=86=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B9=BC=EA=B8=B0=20=C2=B7=20=EC=9E=90=EC=9E=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C)=20=C2=B7=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20[=EC=A0=80=EC=9E=A5]=20=C2=B7=20Ctrl+S?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 43-4 — 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달(상세와 같은 함수) · 저장 차례 = 면적 먼저 → 일위대가 · 표 감싸개 안 가로 스크롤 · 40-2 한 줄 표(compactTable · Labor_View compact · m02-labline css) 지움 · 빼기만 있는 줄도 끝 칸 · 화면 시나리오 ui_checks/43-4. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 73 +++++++---- .../M02_MasterTemplete_Labor.css | 99 -------------- .../M02_MasterTemplete_Labor_Detail.ts | 27 ++-- .../M02_MasterTemplete_Labor_Rows.ts | 124 +----------------- .../M02_MasterTemplete_Labor_View.ts | 19 +-- resources/tester/test_m02_card_shell.py | 19 +++ .../tester/ui_checks/43-4_일위대가_카드.js | 96 ++++++++++++++ 7 files changed, 195 insertions(+), 262 deletions(-) create mode 100644 resources/tester/ui_checks/43-4_일위대가_카드.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 3e6a63430..08bcefb1f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -19,7 +19,12 @@ import type { SpreadsheetHandle, Workbook, } from "../A00_Common/spreadsheet/spreadsheet"; -import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch"; +import { + readTemplate, + saveTemplate, + type StructureDoc, + type StructureNumbers, +} from "./M02_MasterTemplete_Api_Fetch"; import { cardShell, helpButton, @@ -35,7 +40,7 @@ import { type DrawingCardHandle, } from "./M02_MasterTemplete_DrawingCard"; import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource"; -import type { LaborViewHandle } from "./M02_MasterTemplete_Labor_View"; +import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail"; import "./M02_MasterTemplete_Structure.css"; /** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */ @@ -124,7 +129,7 @@ export interface ItemCardsOptions { /** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 · * 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */ onClear?: (kind: CardKind) => Promise; - /** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 · + /** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 · * [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */ labor?: () => void; } @@ -327,35 +332,57 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { }); paintPills(readStateRaw("m02-card-pills") === "true"); sheets[0].shell.tools.prepend(pills); - // 일위대가 카드 — 보기만(고침 = [일위대가 수정] 상세) · 부품은 처음 열 때 받음 + // 일위대가 카드(43-4) — 상세의 단가산출 로직 표(2행) + 자재목록 그대로(`Labor_Detail` 카드 갈래 · + // 순서 · 로직 추가 · 다이어그램 없음) · 고침은 카드 [저장] · Ctrl+S · 부품은 처음 열 때 받음 const laborHost = el("div", { className: "m02-struct__labor" }); const laborCard = opts.labor - ? makeCard("labor", PART_WORD.labor, laborHost, () => Promise.resolve()) + ? makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor()) : null; - let laborView: LaborViewHandle | null = null; - /** 일위대가 카드 — 그 열 문서 · calc 를 받아 그림 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */ + let laborEd: LaborDetailHandle | null = null; + let laborVer = ""; + /** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */ const paintLabor = async (at: string, mine: number): Promise => { if (!laborCard) return; + laborEd?.destroy(); + laborEd = null; + const layer = opts.source().layer; + let mod: typeof import("./M02_MasterTemplete_Labor_Detail"); + let got: Awaited>; try { - // 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 금액 넷 · 40-2) — 자세한 것은 [수정] 상세 - laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(undefined, { - compact: true, - }); + [mod, got] = await Promise.all([ + import("./M02_MasterTemplete_Labor_Detail"), + readTemplate(layer, "labor", at, null), + ]); } catch (error) { if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload); return; } if (mine !== seq) return; - const view = laborView; - laborHost.replaceChildren(view.root); - await view.show(opts.source().layer, at); - if (mine !== seq) return; - const d = view.doc(); + laborVer = got.판 ?? ""; + const d = (got.문서 ?? {}) as { 일위대가번?: string; 담은로직?: unknown[]; 자재?: unknown[] }; laborCard.no.textContent = - d?.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor); - if (blank(laborCard, !!d && !d.담은로직.length && !d.자재.length)) - laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.())); + d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor); + if (blank(laborCard, !d.담은로직?.length && !d.자재?.length)) + return void laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.())); + laborEd = mod.mountLaborDetail(laborHost, got.문서, { + layer, + column: at, + readOnly: !opts.editable(), + onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()), + goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음 + card: true, + }); }; + /** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */ + async function saveLabor(): Promise { + const at = column; + if (!laborEd || !at) return; + const layer = opts.source().layer; + const areaSaved = await laborEd.saveArea(); + if (areaSaved) laborVer = (await readTemplate(layer, "labor", at, null)).판 ?? ""; + laborVer = (await saveTemplate(layer, "labor", at, null, laborVer, laborEd.getDoc())).판; + if (areaSaved) window.setTimeout(reload, 0); // 면적산출 카드도 새 연결로 + } const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets]; const defaults: Record = { structure: TEXT.edit, @@ -375,8 +402,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { const drawEdit = drawingEditable(opts.editable(), opts.source()); const sheetEdit = !!opts.sheetEdit?.can(); for (const c of cards) { - const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit; + const can = + c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit; c.saver.button.hidden = c.help.hidden = !can; + if (c.kind === "labor") c.help.hidden = true; // 일위대가 카드엔 단축키 상자 없음 c.edit.hidden = !opts.editable(); c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김 } @@ -533,7 +562,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { } const source = opts.source(); // 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌: - // 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1) + // 몸(일위대가 상세 카드 갈래)은 따로 읽어 그림(금액 「…」 이 기다림 표시) · 넣으면 덮였다 지워져 빈 카드(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), @@ -582,7 +611,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { destroy: () => { seq += 1; drop(); - laborView?.destroy(); + laborEd?.destroy(); root.remove(); }, }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 220c81a2a..3732937a4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -229,105 +229,6 @@ } } -/* 한 줄 표(카드용 · 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; - } -} - -/* 더 좁으면(카드 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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index b72958ff9..5f76ad6da 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -54,6 +54,9 @@ export interface LaborDetailOptions { onChanged: (dirty: boolean) => void; /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */ goArea: GoArea | null; + /** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 올리기 · 내리기 · 로직 추가 · + * 정보(단위 · 본뜬) · 다이어그램 없음 · 빼기 · 자재 추가 · 줄 · 수량 모달은 상세와 같음 */ + card?: boolean; } export interface LaborDetailHandle { @@ -103,7 +106,7 @@ export function mountLaborDetail( const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); const drawDg = (): void => { - if (!dgBox.open) return; + if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 const data = buildLaborDiagram({ rows: cur.담은로직, logics: [...logics.values()], @@ -129,7 +132,9 @@ export function mountLaborDetail( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells() }, // 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠) - edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow }, + edit + ? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow } + : { open: openRow }, ), ); matHost.replaceChildren( @@ -384,13 +389,19 @@ export function mountLaborDetail( className: "m01-logic__editor m01-combo m02-labor__edit", attrs: { "data-labor": opt.column }, children: [ - el("section", { - className: "m01lab__box m01-logic__section", - children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })], - }), - section(tc("Logics_Title"), addLogic, logicHost), + ...(opt.card + ? [] + : [ + el("section", { + className: "m01lab__box m01-logic__section", + children: [ + el("div", { className: "m01lab__info", children: [unit.root, origin.root] }), + ], + }), + ]), + section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), section(tc("Labor_Mats"), addMat, matHost), - dgBox, + ...(opt.card ? [] : [dgBox]), ], }), ); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 5189b76b1..a0c5500a1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -170,7 +170,8 @@ export interface LogicTableData { export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { const wait = !data.calc && !data.fail; const { move, remove } = on ?? {}; - const tools = !!(move && remove); + // 빼기만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만 + const tools = !!(move || remove); const lineOf = (key: string): LaborCalcLine | undefined => data.calc?.줄.find((l) => l.로직 === key); const trs = data.rows.map((row, i) => { @@ -222,14 +223,15 @@ 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 + ...(tools ? [ el("td", { className: "m02-labrow__move", children: [ - small(tc("Up"), () => move(i, -1)), - small(tc("Down"), () => move(i, 1)), - small(tc("Remove"), () => remove(i)), + ...(move + ? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))] + : []), + ...(remove ? [small(tc("Remove"), () => remove(i))] : []), ], }), ] @@ -361,115 +363,3 @@ 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") } : {}) }, - }); - // 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계) - const heads = [ - tc("Labor_Item"), - tc("Labor_QtyCol"), - tc("Labor_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_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts index 517e9efcc..95a6aae82 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_View.ts @@ -1,12 +1,11 @@ /* ============================================================================= * M02_MasterTemplete_Labor_View.ts * 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영). - * 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`). + * 면적산출 상세 오른쪽 패널(sub3)이 씀(구조물 조합 카드는 43-4 부터 `Labor_Detail` 카드 갈래). 표 모양은 상세와 한 벌(`Labor_Rows`). * · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만) * · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로 * 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결) * (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」) - * · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1) * · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음 * ========================================================================== */ @@ -21,7 +20,7 @@ import { type LaborDoc, } from "./M02_MasterTemplete_Labor_Api"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; -import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import "./M02_MasterTemplete_Labor.css"; @@ -47,15 +46,7 @@ export interface LaborViewHandle { destroy: () => void; } -/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */ -export interface LaborViewOptions { - compact?: boolean; -} - -export function createLaborView( - onRow?: (row: LaborViewRow) => void, - opts: LaborViewOptions = {}, -): LaborViewHandle { +export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle { const root = el("div", { className: "m02-labview" }); const logics = new Map(); let doc: LaborDoc | null = null; @@ -93,10 +84,6 @@ export function createLaborView( 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_card_shell.py b/resources/tester/test_m02_card_shell.py index aae597a1d..1b7c067a6 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -61,3 +61,22 @@ def test_실패는_카드_안_글과_다시_불러오기(): assert "const retry = (): void => void paintLabor(row, seq);" in picker assert "inputs.set(null, why(error), retry);" in picker assert "const stops = [labor.loading(), inputs.loading()];" in picker + + +def test_일위대가_카드는_상세_카드_갈래(): + """43-4 — 일위대가 카드 몸 = Labor_Detail 카드 갈래(단가산출 로직 2행 표 + 자재목록 · 순서 · + 로직 추가 · 다이어그램 없음 · 빼기 그대로) · 카드 [저장] = 면적 먼저 → 일위대가 · + 한 줄 표 지움.""" + cards = _text("M02_MasterTemplete_ItemCards.ts") + assert "mod.mountLaborDetail(laborHost, got.문서, {" in cards and "card: true," in cards + assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards + save = cards[cards.index("async function saveLabor()") :] + assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"') + assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards + assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀 + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로 + assert "opt.card ? null : addLogic" in detail and "...(opt.card ? [] : [dgBox])," in detail + rows = _text("M02_MasterTemplete_Labor_Rows.ts") + assert "const tools = !!(move || remove);" in rows and "compactTable" not in rows + assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1] diff --git a/resources/tester/ui_checks/43-4_일위대가_카드.js b/resources/tester/ui_checks/43-4_일위대가_카드.js new file mode 100644 index 000000000..bc992ab31 --- /dev/null +++ b/resources/tester/ui_checks/43-4_일위대가_카드.js @@ -0,0 +1,96 @@ +/* 43-4 — 구조물 조합 일위대가 카드 = 일위대가 상세 카드 갈래(단가산출 로직 2행 표 + 자재목록) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(일위대가 담은 로직 있는 열)이 있음 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안 — 길면 ORCA 가 끊음) + * 기대 수치: + * - sections = ["단가산출 로직", "자재목록"] · diagram = 0 · info = 0(단위 · 본뜬 칸 없음) + * - addLogic = 0 · up = down = 0 · remove = 로직 줄 수 · addMat = 1 · rows = 담은 로직 수(item1 = 14) + * - money2 = true(금액 칸이 두 줄 — 위 기준 · 아래 반영) + * - scroll.overflowX = "auto" · scroll.wide = 참(item1 표 ≈ 870px > 카드 ≈ 310px · 표 감싸개 안 가로 스크롤) + * - rowModal = 「넣을 값 — …」 · qtyModal = 모달 뜸(수량 · 43-1) · 둘 다 닫기 뒤 dirty = false + * - 빼기 하나 → dirty = true · saveEnabled = true · 다른 항목(item3) → 버림 물음 「확인」 → item1 다시(되돌림) + * → rowsAfter = rows · dirtyAfter = false + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const vis = (b) => b && !b.hidden && b.offsetParent !== null; + const pick = async () => { + document.querySelector('[data-node="i|item1"]').click(); + return until(() => { + const ed = document.querySelector(".m02-struct__labor .m02-labor__edit"); + return ed && ed.querySelector("tbody tr[data-labor-logic]") ? ed : null; + }); + }; + const ed = await pick(); + if (!ed) return JSON.stringify({ error: "카드 몸 없음" }); + const card = ed.closest(".m02-struct > *"); + const btn = (t) => + [...card.querySelectorAll("button")].filter((b) => vis(b) && b.textContent === t); + const save = [...card.querySelectorAll(".ui-collapsible__title button")].find( + (b) => b.textContent === "저장", + ); + const rows = () => ed.querySelectorAll("tbody tr[data-labor-logic]").length; + const money = ed.querySelector("tbody tr[data-labor-logic] td:nth-child(5)"); + const out = { + sections: [...ed.querySelectorAll(".m01-logic__section-head h3")].map((h) => h.textContent), + diagram: ed.querySelectorAll(".m02-labor__dg").length, + info: ed.querySelectorAll("[data-labor-field]").length, + addLogic: btn("로직 추가").length, + up: btn("올리기").length, + down: btn("내리기").length, + remove: btn("빼기").length, + addMat: btn("자재 추가").length, + rows: rows(), + money2: !!money && money.children.length >= 2, + // 표마다 공용 감싸개(ui-general-block__table-wrap)가 가로 스크롤 — 카드보다 넓으면 그 안에서 + scroll: (() => { + const wrap = ed.querySelector("table")?.parentElement; + return wrap + ? { + overflowX: getComputedStyle(wrap).overflowX, + wide: wrap.scrollWidth > wrap.clientWidth, + sw: wrap.scrollWidth, + cw: wrap.clientWidth, + } + : null; + })(), + }; + // 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달 — 닫기만(값 안 바꿈) + const modal = async (open) => { + open(); + const m = await until(() => document.querySelector(".ui-modal"), 2000); + const t = m?.querySelector("h3")?.textContent ?? null; + [...(m?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "닫기")?.click(); + await until(() => !document.querySelector(".ui-modal"), 1500); + return t; + }; + const first = () => ed.querySelector("tbody tr[data-labor-logic]"); + out.rowModal = await modal(() => first().querySelector(".m02-labrow__name").click()); + out.qtyModal = await modal(() => + ( + first().querySelector("[data-labor-qty-open]") ?? first().querySelector(".m02-labrow__qty") + ).click(), + ); + out.dirtyAfterModals = !save.disabled; + // 빼기 하나 → 고침 표시 · 다른 항목으로 갔다 버림 확인 → item1 다시(서버 판으로 되돌림) + btn("빼기").pop().click(); + await w(300); + out.dirty = card.classList.contains("is-dirty") || !save.disabled; + out.saveEnabled = !save.disabled; + document.querySelector('[data-node="i|item3"]').click(); + const ask = await until(() => document.querySelector(".ui-confirm"), 2000); + [...(ask?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "확인")?.click(); + await w(600); + const again = await pick(); + out.rowsAfter = again ? again.querySelectorAll("tbody tr[data-labor-logic]").length : null; + out.dirtyAfter = !save.disabled; + return JSON.stringify(out); +})(); From a21788db30bfe9c9d1be843d7f6917e84e898034 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:34:01 +0900 Subject: [PATCH 07/12] auto: 2026-10-05 00:34 (ESD_LAPTOP) --- docs/raw/M02_구조물조합개편/사용자요구_1005.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1005.md b/docs/raw/M02_구조물조합개편/사용자요구_1005.md index d872c1c82..bea9b003e 100644 --- a/docs/raw/M02_구조물조합개편/사용자요구_1005.md +++ b/docs/raw/M02_구조물조합개편/사용자요구_1005.md @@ -8,3 +8,7 @@ 4. 구조물조합에서 일위대가 미리보기에서 일위대가 상세화면의 단가산출 로직(순서와 로직추가는 제외)과 자재목록을 그대로 보여주게 하되 기능은 유지(수량산출 연결창, 옵션지정 모달창)하고 수평스크롤을 가능하게 하기 5. 일위대가의 재료도 2열구조여야 할것같은데.... 6. 도면정보, 일위대가, 면적산출을 접근 후 제목의 돌아가기 버튼은 알약이 아니라 일반 버튼으로 + +## 추가 (10-5 새벽 · 원문 그대로) + +추가 항목이 하나더 있어. 구조물 조합에서의 미리보고 3개 컨테이너에 새로만들기와 복사 생성로직이 필요없겠네. 구조물 조합의 새항목 생성을 하면 당연히 새로운 항목들이 자동으로 생성되잖아. 그후 가져오기를 하면 당연히 값을 가져와서 덮어쓰면 되겠어. 그리고 일위대가와 면적산출에서 가져오기를 하면 도면은 개별로 작성되는게 맞지만 일위대가와 면적산출은 연결되어 있는정보니 같이 가져오는게 맞겠어. 알약연결 포함해서. 이 내용도 추가해줘. From ef24de8b63dfd6e0851f3547e90c2675ba7c0e2f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:35:03 +0900 Subject: [PATCH 08/12] =?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=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=ED=95=98=EB=82=98=20=E2=80=94=20=EC=A7=80=EA=B8=88?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=20=C2=B7=20=EC=86=90=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=20=C2=B7=20=EC=97=B0=EA=B2=B0=20=ED=92=80=EA=B8=B0=20+=20?= =?UTF-8?q?=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20=EC=8B=9C=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A8=EB=8B=AC=20=EC=95=88=EC=97=90=20=EB=B0=94?= =?UTF-8?q?=EB=A1=9C(=EC=B9=B8=20=EB=88=84=EB=A6=84=20=3D=20=EC=9D=B4?= =?UTF-8?q?=EC=9D=8C)=20=C2=B7=20=EB=84=A3=EC=9D=84=20=EA=B0=92=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=EC=97=90=EC=84=9C=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EC=B9=B8=20=EB=BA=8C(43-1=20=C2=B7=2043-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Labor_QtyModal.ts(로직 줄 수량산출 칸 · 자재 줄 같음 · [확인] = 손 · 고른 칸 · 풀기 · [닫기] = 그대로) · 두 단계 모달([엑셀에서 고르기]) 없앰 - AreaCellPick: 시트 부분을 mountAreaSheet 로 뗌(읽기만 · 실제 값 · 이은 칸 색 · 함께 잇기 · 바꾸기 물음 그대로) · 모달 pickAreaCell 지움 - Labor_Detail: openQty · openMat → openQtyFor · linkCell · Labor_Input: 수량 칸 · openQtyModal 지움(입력값 · 메모만) - 화면 판정 시나리오 resources/tester/ui_checks/43-1_수량산출_모달.js · 시험 test_m02_area_panel 부품 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_AreaCellPick.ts | 185 ++++++++---------- .../M02_MasterTemplete_Labor.css | 18 ++ .../M02_MasterTemplete_Labor_Detail.ts | 131 +++++-------- .../M02_MasterTemplete_Labor_Input.ts | 138 +------------ .../M02_MasterTemplete_Labor_QtyModal.ts | 170 ++++++++++++++++ .../M02_MasterTemplete_Labor_Text.ts | 12 +- resources/tester/test_m02_area_panel.py | 11 +- .../tester/ui_checks/43-1_수량산출_모달.js | 134 +++++++++++++ 8 files changed, 474 insertions(+), 325 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts create mode 100644 resources/tester/ui_checks/43-1_수량산출_모달.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts index 712cdba99..d4f5be81f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -1,15 +1,13 @@ /* ============================================================================= * M02_MasterTemplete_AreaCellPick.ts - * 엑셀 칸 고르기 모달(PLAN 40-3) — 그 열 면적산출 문서를 읽기만 시트로 띄우고 칸을 누르면 그 주소를 돌려줌. - * 일위대가 상세(sub2)가 「수량 → 엑셀에서 고르기」 에 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 이어 - * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). - * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 - * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) - * · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로) + * 면적산출 칸 고르기 시트(PLAN 40-3 · 43-1) — 그 열 면적산출 문서를 읽기만 시트로 붙이고 칸을 누르면 그 주소를 알림. + * 일위대가 수량산출 모달(sub2 `Labor_QtyModal`)이 모달 안에 바로 씀 → 돌려받은 칸을 `linkAreaCell` 로 면적 문서에 + * 이어 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). + * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 실제 값(식 칸 = 푼 값) + * · 칸 누름 · Enter = 그 칸 · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true`) · [취소](그대로) * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; -import { openModal } from "@ui/ui_template_modal"; +import { el } from "@ui/ui_template_elements"; 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"; @@ -19,118 +17,99 @@ 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 { +/** 고른 칸 — `{칸: "B6"}` = 그 칸(다른 알약과 함께) · `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ +export interface AreaCellPick { + 칸: string; + 바꾸기?: boolean; +} + +export interface AreaSheetOptions { /** 그 열 면적산출 문서(저장 안 한 것 포함) — 없으면 `layer` · `column` 저장본을 읽음 */ doc?: Workbook | null; layer: Layer; /** 집계표 열 id */ column: string; - /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 지금 이은 칸을 보임 */ + /** 알약 키(로직 「이름|단위」 · 자재 「자재|MT키」) — 같은 칸 물음에서 자기는 뺌 */ pill: string; - /** 모달 제목에 쓸 줄 이름 */ - label: string; + /** 칸을 고름(물음까지 끝난 뒤) */ + onPick: (pick: AreaCellPick) => void; + /** 시트를 다 읽음(그 문서) · 못 읽으면 null */ + onLoad?: (book: Workbook | null) => void; } -/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) · - * `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ -export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null; +export interface AreaSheetHandle { + destroy: () => void; +} -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 => { +/** 읽기만 면적산출 시트를 `host` 에 붙임 — 칸을 눌러 뗌 · Enter = 그 칸(처음 고름 A1 · 화살표 옮김은 안 고름) */ +export function mountAreaSheet(host: HTMLElement, opts: AreaSheetOptions): AreaSheetHandle { + let handle: SpreadsheetHandle | null = null; + let done = false; + let picked = "A1"; + let moved = false; // 누른 사이 고름이 바뀜 — 머리 · 굴림 막대를 눌렀다 뗀 것은 안 고름 + let book: Workbook | null = null; + host.replaceChildren(el("p", { className: "m02-cellpick__now", text: TEXT.loading })); + 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; + opts.onPick(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at }); + }; + void (async () => { + try { + const [{ createSpreadsheet }, got] = 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; + book = got; + host.replaceChildren(); + handle = createSpreadsheet(host, structuredClone(got), { + readOnly: true, + tabs: false, + onSelect: (s) => { + picked = s.칸; + moved = true; + }, + }); + const grid = host.querySelector(".ss-grid-host"); + grid?.addEventListener("mousedown", () => (moved = false), true); + grid?.addEventListener("mouseup", (e) => { + if ((e as MouseEvent).button === 0 && moved) void choose(); + }); + host.addEventListener("keydown", (e) => e.key === "Enter" && void choose()); + opts.onLoad?.(got); + } catch (error) { + if (done) return; + host.replaceChildren( + el("p", { + className: "m02-cellpick__now", + text: fill(TEXT.failed, error instanceof Error ? error.message : ""), + }), + ); + opts.onLoad?.(null); + } + })(); + return { + destroy: () => { 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, - ); - 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 }, got] = 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; - 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(got), { - 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) void 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_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 220c81a2a..9d9844672 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -351,3 +351,21 @@ border-color: var(--color-primary); background: var(--color-mist-violet); } + +/* 수량산출 모달(43-1) — 위 지금 수량 · 손 입력 · [연결 풀기] / 아래 면적산출 시트(읽기만) */ +.m02-labqty { + width: min(960px, 94vw); +} + +.m02-labqty__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-12); + margin-bottom: var(--spacing-4); +} + +.m02-labqty__now { + font-weight: 600; + font-variant-numeric: tabular-nums; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index b72958ff9..02b57263e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -19,7 +19,7 @@ import { type ItemLogic, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; -import { linkAreaCell, pickAreaCell } from "./M02_MasterTemplete_AreaCellPick"; +import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, @@ -28,6 +28,7 @@ import { normalizeLabor, type LaborCalc, type LaborDoc, + type LaborQty, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { @@ -35,9 +36,10 @@ import { diagramTitle, renderComboDiagram, } from "./M02_MasterTemplete_Labor_Diagram"; -import { openLaborInput, openQtyModal, type GoArea } from "./M02_MasterTemplete_Labor_Input"; +import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; +import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; -import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows"; +import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; @@ -174,37 +176,22 @@ export function mountLaborDetail( } else recalc(); }; - /** 이은 칸 글 「B6 = 12.5 ㎡」 — 알약이 칸에 안 이어졌으면 null */ - const linkedText = (pill: string | null | undefined, unit: string): string | null => { - const at = pill ? links.get(pill) : undefined; - if (!at) return null; - 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"); + /** 칸 잇기 · 풀기(`cell` null) — 화면 문서만(저장은 [저장]) · 됐으면 참 */ + const linkCell = (pill: string, cell: string | null, replace = false): boolean => { + if (!area) { + showToast(tc("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.칸, + cell, { ...(info?.이름 ? { 이름: info.이름 } : {}), ...(info?.단위 ? { 단위: info.단위 } : {}), }, - got.바꾸기 === true, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 고르기 모달 안) + replace, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 시트 안) ); if (!next) { showToast(tc("Labor_BadCell"), "error"); @@ -217,6 +204,37 @@ export function mountLaborDetail( return true; }; + /** 수량산출 모달(43-1) — 로직 줄 · 자재 줄 같음 · 손 · 칸 잇기 · 풀기로 `target.수량` 을 바꿈 */ + const openQtyFor = ( + title: string, + target: { 수량: LaborQty }, + line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, + unit: string, + ): void => { + const pill = line?.알약 ?? null; + openQtyModal({ + title, + now: qtyShort(line, unit, cells()), + linked: !!pill && links.has(pill), + hand: target.수량?.손 ?? null, + unit, + readOnly: !edit, + sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, + goArea: opt.goArea, + done: (r) => { + if (r.kind === "keep") return; + if (r.kind === "hand") target.수량 = { 손: r.손 }; + else if (r.kind === "follow") target.수량 = null; + else { + const cell = r.kind === "cell" ? r.pick.칸 : null; + if (!pill || !linkCell(pill, cell, r.kind === "cell" && r.pick.바꾸기 === true)) return; + target.수량 = null; // 칸을 따름 + } + changed(true); + }, + }); + }; + /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); @@ -247,85 +265,38 @@ export function mountLaborDetail( function openRow(i: number): void { const row: LaborRow = cur.담은로직[i]; const logic = logics.get(row.로직) ?? null; - const line = lineOf(row.로직); openLaborInput({ title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), logic, values: row.입력값, memo: row.메모, - seat: { - qty: row.수량, - linked: linkedText(line?.알약, line?.분모 ?? ""), - unit: line?.분모 ?? "", - pick: () => pickRow(i), - }, readOnly: !edit, - goArea: opt.goArea, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; - const qtyOnly = JSON.stringify(values) === JSON.stringify(row.입력값); + const same = JSON.stringify(values) === JSON.stringify(row.입력값); row.입력값 = values; row.메모 = next.메모; - row.수량 = next.수량; - changed(qtyOnly); + changed(same); // 입력값이 그대로면(메모만) 서버 안 부름 }, }); } - /** 그 줄 알약을 칸에 이음 — 이으면 손 수량은 지움(칸을 따름) */ - 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( + openQtyFor( `${row.로직} ${logics.get(row.로직)?.이름 ?? ""}`.trim(), - { - qty: row.수량, - linked: linkedText(line?.알약, line?.분모 ?? ""), - unit: line?.분모 ?? "", - pick: () => pickRow(i), - }, - !edit, - opt.goArea, - (qty) => { - row.수량 = qty; - changed(true); - }, + row, + line, + line?.분모 ?? "", ); } + /** 자재 줄 누름 — 수량산출 모달 */ function openMat(i: number): void { const mat = cur.자재[i]; - const line = matOf(mat.코드); - openQtyModal( - `${mat.코드} ${mat.이름 ?? ""}`.trim(), - { - 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, - (qty) => { - mat.수량 = qty; - changed(true); - }, - ); + openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? ""); } const addLogic = createButton({ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index 2b0b7c445..019b3541a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -1,14 +1,13 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Input.ts - * 일위대가 입력 모달(39-3) — 담은 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모 · 수량 - * (면적산출 이은 칸 · 값 보임 / 손 입력 / 「면적산출에서 잇기」). 자재 줄은 수량만(`openQtyModal`). + * 일위대가 입력 모달(39-3) — 단가산출 로직 줄을 누르면 그 로직 입력 전부(빈 칸 = 자동값) · 메모. + * 수량은 이 모달에 없음(43-2) — 수량산출 칸 누름 = `Labor_QtyModal`(면적산출 시트를 모달 안에). * [확인] = 줄 값만 바꿈(화면 캐시) · 저장은 상세 [저장]. 값 칸 규칙 = `M02_Labor_Values`(한 벌). * ========================================================================== */ import { createButton, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { ItemInput, ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; -import type { LaborQty } from "./M02_MasterTemplete_Labor_Api"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { createValueControl } from "./M02_Labor_Values"; import { keepKnown, valueHint, type LaborValues } from "./M02_Labor_Values_Model"; @@ -20,102 +19,6 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */ export type GoArea = () => Promise; -/** 옮겨 갔을 때만 모달을 닫음 — 취소면 모달 · 넣던 값 그대로 */ -const leaveTo = - (go: GoArea, close: () => void): (() => void) => - () => - void go().then((moved) => moved && close()); - -export interface QtySeat { - /** 지금 수량 */ - qty: LaborQty; - /** 면적산출 이은 칸 글(「B6 = 12.5 ㎡」) — 없으면 null */ - linked: string | null; - /** 손 입력 단위 표기(분모 · 자재 단위) */ - unit: string; - /** [엑셀에서 고르기](40-1) — 그 열 면적산출 칸을 골라 이음 · 이었으면 참(모달 닫음) · 없으면 단추 없음 */ - pick?: (() => Promise) | null; -} - -interface QtyPart { - root: HTMLElement; - /** 확인 때 값 — 손 입력이 수가 아니면 undefined(막음) */ - read: () => LaborQty | undefined; -} - -/** 수량 칸 — 「면적산출 칸」 / 「손 입력」 · 손이 칸보다 앞섬 */ -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; - input.disabled = readOnly; - return input; - }; - const cell = radio("cell"); - const hand = radio("hand"); - const number = el("input", { - className: "ui-input m02-inputval__control", - attrs: { type: "number", step: "any", "data-labor-qty": "hand" }, - }) as HTMLInputElement; - number.readOnly = readOnly; - if (seat.qty) number.value = String(seat.qty.손); - (seat.qty ? hand : cell).checked = true; - number.disabled = !hand.checked; - for (const r of [cell, hand]) - r.addEventListener("change", () => { - number.disabled = !hand.checked; - if (hand.checked) number.focus(); - }); - 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", - children: [input, el("span", { text: label }), ...rest], - }); - const root = el("section", { - className: "m02-labin__qty", - children: [ - el("h4", { className: "m02-inputval__logic-name", text: tc("In_Qty") }), - line( - cell, - tc("In_QtyCell"), - el("small", { - 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 })), - ], - }); - return { - root, - read: () => { - if (!hand.checked) return null; - const v = Number(number.value); - return number.value.trim() !== "" && Number.isFinite(v) ? { 손: v } : undefined; - }, - }; -} - function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { return el("div", { className: "m02-labin__foot", @@ -131,20 +34,16 @@ function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLEleme export interface LaborInputResult { 입력값: LaborValues; 메모: string | null; - 수량: LaborQty; } export interface LaborInputOptions { /** 「GF000219 메쌓기」 */ title: string; - /** 로직 입력 목록(없으면 = 못 읽음 · 넣을 값 없이 수량만) */ + /** 로직 입력 목록(없으면 = 못 읽음 · 메모만) */ logic: ItemLogic | null; values: LaborValues; memo: string | null; - seat: QtySeat; readOnly: boolean; - /** 「면적산출에서 잇기」 — 면적산출 상세로 감(옮겨 가면 참 · 「저장 안 한 고침」 물음에서 취소면 거짓 · 없으면 단추 없음) */ - goArea: GoArea | null; done: (next: LaborInputResult) => void; } @@ -192,12 +91,9 @@ 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), close); const ok = (): void => { if (bad.size) return showToast(tc("In_Bad"), "error"); - const 수량 = qty.read(); - if (수량 === undefined) return showToast(tc("In_QtyNeed"), "error"); - opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null, 수량 }); + opt.done({ 입력값: { ...draft }, 메모: memo.value.trim() || null }); close(); }; body.append( @@ -222,7 +118,6 @@ export function openLaborInput(opt: LaborInputOptions): void { memo, ], }), - qty.root, ], }), footer(opt.readOnly, ok, close), @@ -230,28 +125,3 @@ export function openLaborInput(opt: LaborInputOptions): void { }, }); } - -/** 자재 줄 수량 모달 */ -export function openQtyModal( - title: string, - seat: QtySeat, - readOnly: boolean, - goArea: GoArea | null, - done: (qty: LaborQty) => void, -): void { - openModal({ - title, - closeLabel: tc("In_Close"), - dialogClass: "m02-modal m02-labin", - mount: (body, 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"); - done(수량); - close(); - }; - body.append(qty.root, footer(readOnly, ok, close)); - }, - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts new file mode 100644 index 000000000..3e466130f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts @@ -0,0 +1,170 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_QtyModal.ts + * 일위대가 수량산출 모달(43-1) — 수량산출 칸 누름(로직 줄 · 자재 줄 같음) = 모달 하나. + * · 위 = 지금 수량(손 · 칸 · —) · 손 입력 칸 · [연결 풀기] + * · 아래 = 그 열 면적산출 시트를 모달 안에 바로(읽기만 · 실제 값 · 이은 칸 색 · `mountAreaSheet`) · + * 칸 누름 = 그 칸을 고름(함께 잇기 · 바꾸기 물음 그대로) + * · [확인] = 손 값이 있으면 손 · 아니면 고른 칸으로 잇기 · 풀기 · [닫기] = 그대로 + * 잇기 · 풀기는 화면 문서만(저장은 일위대가 [저장] 때 면적 문서와 함께 · `Labor_Detail`). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { mountAreaSheet, type AreaCellPick } from "./M02_MasterTemplete_AreaCellPick"; +import type { GoArea } from "./M02_MasterTemplete_Labor_Input"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "./M02_Labor_Values.css"; +import "./M02_MasterTemplete_Labor.css"; + +/** 모달 답 — 손 · 칸 따름(손 지움) · 고른 칸으로 잇기 · 연결 풀기 · 그대로 */ +export type QtyResult = + | { kind: "hand"; 손: number } + | { kind: "follow" } + | { kind: "cell"; pick: AreaCellPick } + | { kind: "unlink" } + | { kind: "keep" }; + +export interface QtyModalOptions { + /** 「GF000219 돌쌓기 메쌓기(인력)」 */ + title: string; + /** 지금 수량 글(「손 5 ㎥」 · 「B6 = 12.5」 · 「—」) */ + now: string; + /** 지금 칸에 이어져 있나 — [연결 풀기] */ + linked: boolean; + /** 지금 손 값(없으면 null) */ + hand: number | null; + /** 손 입력 단위(분모 · 자재 단위) */ + unit: string; + readOnly: boolean; + /** 그 열 면적산출 — `pill` 이 없으면(계산 전) 시트 대신 안내 */ + sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null }; + /** [면적산출 상세로] — 없으면 단추 없음 */ + goArea: GoArea | null; + done: (result: QtyResult) => void; +} + +export function openQtyModal(opt: QtyModalOptions): void { + let pending: QtyResult = { kind: "keep" }; + let sheet: { destroy: () => void } | null = null; + openModal({ + title: `${tc("Labor_QtyCol")} — ${opt.title}`, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labin m02-labqty", + beforeClose: () => (sheet?.destroy(), true), + mount: (body, closeNow) => { + const finish = (): void => { + sheet?.destroy(); + closeNow(); + }; + const now = el("span", { + className: "m02-labqty__now", + text: opt.now, + attrs: { "data-labor-qty-now": "" }, + }); + const state = el("small", { + className: "m02-labin__muted", + attrs: { "data-labor-qty-state": "" }, + }); + const hand = el("input", { + className: "ui-input m02-inputval__control", + attrs: { + type: "number", + step: "any", + "data-labor-qty": "hand", + "aria-label": tc("In_QtyHand"), + }, + }) as HTMLInputElement; + hand.readOnly = opt.readOnly; + if (opt.hand !== null) hand.value = String(opt.hand); + const unlink = + opt.linked && !opt.readOnly + ? createButton({ + label: tc("Labor_Unlink"), + variant: "ghost", + onClick: () => { + pending = { kind: "unlink" }; + hand.value = ""; + state.textContent = tc("Labor_UnlinkPending"); + }, + }) + : null; + const go = opt.goArea + ? createButton({ + label: tc("In_QtyGoArea"), + variant: "ghost", + onClick: () => + void opt.goArea?.().then((moved) => { + if (moved) finish(); + }), + }) + : null; + const host = el("div", { + className: "m02-cellpick__sheet", + attrs: { "data-labor-qty-sheet": "" }, + }); + if (opt.sheet.pill) { + sheet = mountAreaSheet(host, { + doc: opt.sheet.doc, + layer: opt.sheet.layer, + column: opt.sheet.column, + pill: opt.sheet.pill, + onPick: (pick) => { + if (opt.readOnly) return; + pending = { kind: "cell", pick }; + hand.value = ""; // 칸을 고르면 손 값은 지움(칸을 따름) + state.textContent = tc("Labor_CellPending").replace("{value}", pick.칸); + }, + }); + } else + host.replaceChildren( + el("p", { className: "m02-labin__muted", text: tc("Labor_PickWait") }), + ); + hand.addEventListener("input", () => { + if (hand.value.trim() !== "") state.textContent = ""; + }); + const ok = (): void => { + const raw = hand.value.trim(); + if (raw !== "") { + const v = Number(raw); + if (!Number.isFinite(v)) return showToast(tc("In_QtyNeed"), "error"); + opt.done({ kind: "hand", 손: v }); + } else if (pending.kind !== "keep") opt.done(pending); + else if (opt.hand !== null) opt.done({ kind: "follow" }); // 손 값을 지움 = 칸을 따름 + finish(); + }; + body.append( + el("div", { + className: "m02-labqty__head", + children: [ + el("span", { className: "m02-inputval__name", text: tc("Labor_QtyNow") }), + now, + el("label", { + className: "m02-labin__qty-line", + children: [ + el("span", { text: tc("In_QtyHand") }), + hand, + el("small", { text: opt.unit }), + ], + }), + ...(unlink ? [unlink] : []), + ...(go ? [go] : []), + ], + }), + state, + el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), + host, + el("div", { + className: "m02-labin__foot", + children: opt.readOnly + ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: finish })] + : [ + createButton({ label: tc("In_Close"), variant: "ghost", onClick: finish }), + createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + ], + }), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index cc1b30de5..deae683f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -60,10 +60,17 @@ const TEXT = { Labor_Item: ["항목", "Item"], 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_QtyNow: ["지금 수량", "Now"], + Labor_Unlink: ["연결 풀기", "Unlink"], + Labor_UnlinkPending: ["연결 풀기 — [확인]을 누르면 풂", "Unlink on OK"], + Labor_CellPending: ["고른 칸 {value} — [확인]을 누르면 이음", "Cell {value} — link on OK"], + Labor_SheetHint: [ + "면적산출 시트 — 칸을 누르면 그 칸을 고름 · 손 입력이 있으면 손이 앞섬", + "Area sheet — click a cell to pick it · manual value wins", + ], Labor_Base: ["기준", "Base"], Labor_Applied: ["반영", "Applied"], Labor_BasePrice: ["기준 단가", "Base price"], @@ -81,12 +88,9 @@ const TEXT = { In_Title: ["넣을 값 — {value}", "Inputs — {value}"], In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"], 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"], In_QtyGoArea: ["면적산출에서 잇기", "Link in area sheet"], In_QtyNeed: ["손 입력 수량을 수로 적음", "Enter a number"], In_Bad: ["범위 밖 값이 있음 — 고친 뒤 확인", "Out-of-range values — fix first"], diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index e53925a02..3448993d1 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -94,12 +94,15 @@ def test_카드_알약_취향은_계정_받은_뒤_올림(): def test_엑셀_칸_고르기_모달_부품(): - """40-3 ① — 읽기만 시트 · 칸 누름 = 주소 · 풀기 = {칸: null} · 그만둠 = null · 저장 안 함.""" + """40-3 ① · 43-1 — 읽기만 시트(수량산출 모달 안에 바로 · `mountAreaSheet`) · 칸 누름 = 주소 · + 풀기 · 손 · 확인 · 닫기는 일위대가 수량산출 모달(`Labor_QtyModal`) · 저장 안 함.""" 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 - assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + assert "export function mountAreaSheet(" in pick and "readOnly: true," in pick + assert 'opts.onPick(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + qty = _text("M02_MasterTemplete_Labor_QtyModal.ts") + assert "sheet = mountAreaSheet(host, {" in qty and 'pending = { kind: "unlink" };' in qty + assert "엑셀에서 고르기" not in qty # 두 단계 모달 없앰(43-1) 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 diff --git a/resources/tester/ui_checks/43-1_수량산출_모달.js b/resources/tester/ui_checks/43-1_수량산출_모달.js new file mode 100644 index 000000000..ffab377bb --- /dev/null +++ b/resources/tester/ui_checks/43-1_수량산출_모달.js @@ -0,0 +1,134 @@ +/* 43-1 · 43-2 일위대가 수량산출 모달 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌. + * + * 시작 화면: M02 마스터 템플릿(시스템 관리자) · 좌측 일위대가 줄 「SU-000003」 이 보이는 상태 · + * 그 열 면적산출(SA-000003) B6 = 12.5(검증 전 넣고 끝나면 마스터 사본으로 되씀) · 저장은 안 함(끝에 다시 열기 → 버림). + * 기대 수치: + * qtyModal.sheetCells > 0 · qtyModal.twoStepButton = false(「엑셀에서 고르기」 없음) · qtyModal.hasHand = true + * hand.applied = round(기준 × 5) · hand.qty = "손 5 ㎡" + * cell.qty = "B6 = 12.5" · cell.applied = round(기준 × 12.5)(배 1) + * follow.qty = "B6 = 12.5"(손 지움 = 칸 따름 · 연결은 남음) · unlink.qty = "—" + * matRow.opens = true(자재 줄이 있으면 같은 모달) · inputModal.hasQty = false(43-2 · 입력값 · 메모만) + * discarded = true(다시 열기 → 확인 → 처음 수량 글과 같음) + */ +(async () => { + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const btn = (root, text) => $$("button", root).find((b) => b.innerText.trim() === text); + const num = (t) => Number(String(t).replace(/[^0-9.-]/g, "")); + const LOGIC = "GF000219"; + const row = () => $(`[data-labor-logic="${LOGIC}"]`); + const qtyText = () => + row().querySelector(".m02-labrow__qty .m02-labrow__applied").innerText.trim(); + const money = () => + $$(".m02-labrow__money", row()).map((c) => [ + num(c.querySelector(".m02-labrow__base").innerText), + c.querySelector(".m02-labrow__applied").innerText.trim(), + ]); + const out = {}; + const side = $$(".m02-side__item").find((b) => b.innerText.startsWith("SU-000003")); + if (!side) return JSON.stringify({ error: "SU-000003 줄 없음" }); + side.click(); + await W(5000); + const base = money()[3][0]; // 합계 기준 + out.initial = qtyText(); + const openQty = async () => { + row().querySelector("[data-labor-qty-open]").click(); + await W(2500); + return $(".m02-labqty"); + }; + // ① 모달 모양 + 손 5 + let m = await openQty(); + out.qtyModal = { + open: !!m, + sheetCells: $$("[data-labor-qty-sheet] .aislo-cell", m).length, + twoStepButton: !!btn(m, "엑셀에서 고르기"), + hasHand: !!$("[data-labor-qty]", m), + now: $("[data-labor-qty-now]", m)?.innerText, + }; + const hand = $("[data-labor-qty]", m); + hand.value = "5"; + hand.dispatchEvent(new Event("input")); + btn(m, "확인").click(); + await W(600); + out.hand = { qty: qtyText(), applied: num(money()[3][1]), expect: Math.round(base * 5) }; + out.hand.ok = Math.abs(out.hand.applied - out.hand.expect) <= 5; // 기준은 화면 반올림 값 + // ② 칸 B6 고름 → 확인 + m = await openQty(); + const c = $('[data-labor-qty-sheet] [data-addr="B6"]', m); + const r = c.getBoundingClientRect(); + const o = { + bubbles: true, + cancelable: true, + clientX: r.x + 10, + clientY: r.y + 8, + button: 0, + pointerId: 1, + pointerType: "mouse", + }; + c.dispatchEvent(new PointerEvent("pointerdown", o)); + c.dispatchEvent(new MouseEvent("mousedown", o)); + await W(80); + c.dispatchEvent(new PointerEvent("pointerup", o)); + c.dispatchEvent(new MouseEvent("mouseup", o)); + c.dispatchEvent(new MouseEvent("click", o)); + await W(600); + const share = btn(document, "함께 잇기"); // 그 칸에 다른 알약이 있으면 물음 + if (share) { + share.click(); + await W(300); + } + out.cell = { + state: $("[data-labor-qty-state]", m)?.innerText, + handCleared: $("[data-labor-qty]", m).value === "", + }; + btn(m, "확인").click(); + await W(800); + Object.assign(out.cell, { + qty: qtyText(), + applied: num(money()[3][1]), + expect: Math.round(base * 12.5), + }); + // ③ 손 넣었다 지우고 확인 = 칸 따름 · ④ 연결 풀기 + m = await openQty(); + $("[data-labor-qty]", m).value = ""; + btn(m, "확인").click(); + await W(500); + out.follow = { qty: qtyText() }; + m = await openQty(); + const unlink = btn(m, "연결 풀기"); + out.unlink = { button: !!unlink }; + if (unlink) { + unlink.click(); + btn(m, "확인").click(); + await W(500); + out.unlink.qty = qtyText(); + } else btn(m, "닫기").click(); + // ⑤ 자재 줄 · ⑥ 입력 모달(43-2) + const mat = $("[data-labor-mat]"); + if (mat) { + mat.click(); + await W(2500); + out.matRow = { opens: !!$(".m02-labqty") }; + btn($(".m02-labqty"), "닫기")?.click(); + await W(300); + } else out.matRow = { opens: "자재 없음" }; + row().querySelector(".m02-labrow__name").click(); + await W(400); + const im = $(".m02-labin:not(.m02-labqty)"); + out.inputModal = { + open: !!im, + hasQty: !!$("[data-labor-qty]", im) || !!btn(im, "엑셀에서 고르기"), + hasMemo: !!$("[data-labor-memo]", im), + }; + btn(im, "취소")?.click(); + await W(300); + // ⑦ 버림 + btn($(".m02-main__head"), "다시 열기").click(); + await W(600); + btn($(".ui-confirm"), "확인")?.click(); + await W(4500); + out.afterDiscard = qtyText(); + out.discarded = out.afterDiscard === out.initial; + return JSON.stringify(out); +})(); From 46600a230b3291833f4f65e338287c7105ad956a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:35:07 +0900 Subject: [PATCH 09/12] auto: 2026-10-05 00:35 (ESD_LAPTOP) --- docs/raw/M02_구조물조합개편/사용자요구_1005.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/M02_구조물조합개편/사용자요구_1005.md b/docs/raw/M02_구조물조합개편/사용자요구_1005.md index d872c1c82..bea9b003e 100644 --- a/docs/raw/M02_구조물조합개편/사용자요구_1005.md +++ b/docs/raw/M02_구조물조합개편/사용자요구_1005.md @@ -8,3 +8,7 @@ 4. 구조물조합에서 일위대가 미리보기에서 일위대가 상세화면의 단가산출 로직(순서와 로직추가는 제외)과 자재목록을 그대로 보여주게 하되 기능은 유지(수량산출 연결창, 옵션지정 모달창)하고 수평스크롤을 가능하게 하기 5. 일위대가의 재료도 2열구조여야 할것같은데.... 6. 도면정보, 일위대가, 면적산출을 접근 후 제목의 돌아가기 버튼은 알약이 아니라 일반 버튼으로 + +## 추가 (10-5 새벽 · 원문 그대로) + +추가 항목이 하나더 있어. 구조물 조합에서의 미리보고 3개 컨테이너에 새로만들기와 복사 생성로직이 필요없겠네. 구조물 조합의 새항목 생성을 하면 당연히 새로운 항목들이 자동으로 생성되잖아. 그후 가져오기를 하면 당연히 값을 가져와서 덮어쓰면 되겠어. 그리고 일위대가와 면적산출에서 가져오기를 하면 도면은 개별로 작성되는게 맞지만 일위대가와 면적산출은 연결되어 있는정보니 같이 가져오는게 맞겠어. 알약연결 포함해서. 이 내용도 추가해줘. From 4915f8522711af1c839579d780b99c13ecda288c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:36:47 +0900 Subject: [PATCH 10/12] =?UTF-8?q?test(M02):=20UI=5FMain=20=EA=B8=80=20?= =?UTF-8?q?=EC=B0=BE=EB=8A=94=20=EC=8B=9C=ED=97=98=20=EA=B8=B0=EB=8C=80=20?= =?UTF-8?q?=EA=B8=80=EC=9D=84=2041-3=20=EC=BD=94=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4=20=E2=80=94=20onSelect=20=EC=9D=98=20toDrawi?= =?UTF-8?q?ng.select=20=C2=B7=20busy=20=EC=9D=98=20toDrawing.waiting=20(43?= =?UTF-8?q?)?= 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_area_panel.py | 5 ++++- resources/tester/test_m02_card_sheet_edit.py | 2 +- resources/tester/test_m02_drawing_card.py | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index e53925a02..14aecfcaf 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -23,7 +23,10 @@ def test_면적_길은_일위대가_패널(): area = body.split('if (kind === "area") {')[1].split("\n }\n")[0] assert "apanel = buildAreaPanel({" in area and "host.append(apanel.root);" in area assert "picked: () => picked," in area and "pills: () => links.항목," in area - assert "apanel?.refresh();" in body and "onSelect: (s) => (picked = s.칸)," in body + assert ( + "apanel?.refresh();" in body + and "onSelect: (s) => ((picked = s.칸), toDrawing.select(s))," in body + ) assert "buildComboBar" not in main # 조합 줄 · [조합 고치기] 지움 assert body.index('if (at.layer === "project") return;') < body.index("buildAreaPanel") assert "apanel?.destroy();\n apanel = null;" in main # drop diff --git a/resources/tester/test_m02_card_sheet_edit.py b/resources/tester/test_m02_card_sheet_edit.py index 5f74cbd3b..42caf4113 100644 --- a/resources/tester/test_m02_card_sheet_edit.py +++ b/resources/tester/test_m02_card_sheet_edit.py @@ -69,7 +69,7 @@ def test_저장_안_한_카드_고침_물음(): assert "cards.dirty()" in show and "showConfirmDialog(TEXT.discardCards)" in show assert "opts.keepColumn?.(from)" in show main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") - assert "const busy = (): boolean => dirty || !!panel?.dirty();" in main + assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main assert main.count("busy()") >= 3 # 떠나기 · 새로고침 막기 · 나무 고침(settle) assert main.index("await panel.saveCards();") < main.index("await panel.reload();") assert "keepColumn: (colId) => sheet?.selectColumn(colId)" in main diff --git a/resources/tester/test_m02_drawing_card.py b/resources/tester/test_m02_drawing_card.py index 5bffa5871..7db5d6688 100644 --- a/resources/tester/test_m02_drawing_card.py +++ b/resources/tester/test_m02_drawing_card.py @@ -51,5 +51,5 @@ def test_저장_안_한_카드_고침은_물음(): assert "dirty: anyDirty," in cards assert "dirty: () => cards.dirty()," in _text("M02_MasterTemplete_Structure.ts") main = _text("M02_MasterTemplete_UI_Main.ts") - assert "const busy = (): boolean => dirty || !!panel?.dirty();" in main + assert "const busy = (): boolean => dirty || !!panel?.dirty() || toDrawing.waiting();" in main assert main.count("!busy()") >= 2 # 떠날 때 물음 · 창 닫기 막음 From 9d42cb065bce33a6853de39aaa94d1e29160efac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:36:54 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B8=20(43-9a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copy 길 pair=true — 같은 원본 열의 면적산출(시트 · 알약 연결) + 일위대가(담은로직 · 입력값 · 수량 · 자재) 함께 덮어씀 · 도면 그대로 - 면적 먼저(일위대가 계산값이 새 칸 값 읽음) · 원본 면적 없음 · 실무문서 = 일위대가만 - 권한 · 남의 층 감사 기록은 기존 _root 그대로 - 화면 함수 copyLaborAreaFrom(열, 원본, from) — 카드 잇기는 43-9b - 새 항목 칸 셋 자동 생성은 기존 집계표 저장 길 확인 · 시험 test_m02_pair_copy Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../M02_MasterTemplete_Api_Fetch.ts | 11 +++ .../M02_MasterTemplete_Router_Items.py | 13 ++- .../M02_MasterTemplete_Store.py | 19 ++++ resources/tester/test_m02_pair_copy.py | 92 +++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 resources/tester/test_m02_pair_copy.py diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 03b7a9d51..02125837d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -224,16 +224,27 @@ export const copyPartFrom = async ( column: string, source: string, from: { layer: Layer | "practice"; owner: string | null } | null, + pair = false, ): Promise => { const q = new URLSearchParams({ from: source }); if (from) q.set("from_layer", from.layer); if (from?.owner) q.set("from_owner", from.owner); + if (pair) q.set("pair", "true"); const part = kind === "structure" ? "structures" : kind; await call(owned(activeLayer, `/layers/${activeLayer}/${part}/${enc(column)}/copy?${q}`), { method: "POST", }); }; +/** 일위대가 · 면적산출 함께 가져오기(43-9a · 카드 [가져오기]) — 원본 열의 면적산출(시트 · 알약 연결)과 + * 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 · + * 원본에 면적산출이 없으면(실무문서) 일위대가만 · 인자는 `copyPartFrom` 과 같음 */ +export const copyLaborAreaFrom = ( + column: string, + source: string, + from: { layer: Layer | "practice"; owner: string | null } | null, +): Promise => copyPartFrom("labor", column, source, from, true); + /** 항목 하나 비우기(40-4 · 카드 [삭제]) — 새 열과 같은 빈 문서 · 번호 · 열 그대로 · 지금 층 */ export const clearPart = ( kind: "structure" | "labor" | "area", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 3b709f945..0a0ad7976 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -363,15 +363,22 @@ def post_copy( owner: str | None = None, from_layer: Literal["system", "company", "personal", "practice"] | None = None, from_owner: str | None = None, + pair: bool = False, session: Session = Depends(verify_session), ): """항목 하나 복사해 가져오기(덮어씀 · 번호 · 열 그대로) — 원본 = 같은 층(`from_layer` 없음) · - 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만).""" + 다른 층(원본 읽기 권한) · 실무문서 조합(`practice` · 일위대가만 · 시스템 관리자만) · + `pair` = 일위대가 · 면적산출 함께(43-9a · 답 `{area?, labor}` · 실무문서는 일위대가만).""" folder = _root(session, layer, owner, write=True) part = "structure" if kind == "structures" else kind if from_layer == "practice": - if part != "labor" or not access.is_system_admin(session): + if part not in (("labor", "area") if pair else ("labor",)) or not access.is_system_admin( + session + ): raise HTTPException(status_code=403, detail="실무문서는 시스템 관리자가 일위대가로만") - return _call(folder, _copy_practice, column, source) + got = _call(folder, _copy_practice, column, source) + return {"labor": got} if pair else got origin = _root(session, from_layer, from_owner) if from_layer else None + if pair and part != "structure": + return _call(folder, store.copy_pair, column, source, origin) return _call(folder, store.copy_from, part, column, source, origin) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 6fcb2f702..95a05a85a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -397,6 +397,25 @@ def copy_from( return info +def copy_pair( + column: str, + source: str, + source_root: Path | None = None, + labor_body: dict[str, Any] | None = None, +) -> dict[str, Any]: + """일위대가 ↔ 면적산출 함께 가져오기(43-9a) — 같은 원본 열의 면적산출(시트 · 알약 `연결`)과 + 일위대가(담은로직 · 입력값 · 수량 · 자재)로 둘 다 덮어씀 · 도면은 안 건드림 · + 원본에 면적산출이 없으면(실무문서 `labor_body` 포함) 일위대가만 · 면적 먼저(일위대가 계산값이 + 새 면적 칸 값을 읽음 · 면적 계산이 막히면 일위대가도 안 씀).""" + with at(source_root or root()): + has_area = labor_body is None and _path("area", source).is_file() + out: dict[str, Any] = {} + if has_area: + out["area"] = copy_from("area", column, source, source_root) + out["labor"] = copy_from("labor", column, source, source_root, labor_body) + return out + + def prune_removed_structures() -> list[str]: """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 — 번호는 비워 둠(다른 열 번호를 안 당김) · 옛 `inputs/` 는 안 건드림.""" diff --git a/resources/tester/test_m02_pair_copy.py b/resources/tester/test_m02_pair_copy.py new file mode 100644 index 000000000..de344497f --- /dev/null +++ b/resources/tester/test_m02_pair_copy.py @@ -0,0 +1,92 @@ +"""일위대가 ↔ 면적산출 함께 가져오기(PLAN 43-9a) · 새 항목 = 칸 셋 자동 생성. + +시스템 층 = 견본 표(임시 폴더) · 개인 층 = 임시 storage (`test_m02_labor_copy` 의 world 를 씀). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from resources.tester.test_m02_labor_copy import ( + TEN, + URL, + _as, + world, # noqa: F401 — 고정물 +) + + +def _doc(folder: Path, kind: str, cid: str) -> dict: + return json.loads((folder / kind / f"{cid}.json").read_bytes()) + + +def _link_rv(system: Path) -> None: + """원본 rv — 일위대가 수량은 알약 연결 · 면적산출 B6 = 5 에 그 알약.""" + with store.at(system): + got = store.read("labor", "rv") + rows = [{"로직": TEN, "입력값": {}, "수량": None}] + store.write("labor", "rv", got["판"], {**got["문서"], "담은로직": rows}) + pill = labor.pills(store.read("labor", "rv")["문서"])[0]["키"] + area = store.read("area", "rv") + sheet = area["문서"]["시트"][0] + sheet["칸"] = {"B6": {"값": "5"}} + sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}] + store.write("area", "rv", area["판"], area["문서"]) + + +def test_새_항목_칸_셋_빈_문서_자동_같은_뒷자리(world): # noqa: F811 + personal = world["personal"] + numbers = [_doc(personal, k, "a")[key] for k, key in store._KEY.items()] + assert [n[:2] for n in numbers] == ["SD", "SU", "SA"] + assert len({n[2:] for n in numbers}) == 1 + assert _doc(personal, "labor", "a")["담은로직"] == [] + + +def test_일위대가_면적산출_함께_덮어씀_도면은_그대로(world): # noqa: F811 + system, personal = world["system"], world["personal"] + _link_rv(system) + before = {k: _doc(personal, k, "a") for k in ("structure", "labor", "area")} + for kind in ("labor", "area"): # 어느 카드에서 불러도 같음 + url = URL.format("personal", kind, "a") + "?from=rv&from_layer=system&pair=true" + got = _as(world).post(url) + assert got.status_code == 200 and set(got.json()) == {"area", "labor"} + area, doc = _doc(personal, "area", "a"), _doc(personal, "labor", "a") + assert area["시트"][0]["연결"][0]["칸"] == "B6" and area["면적번"] == before["area"]["면적번"] + assert (doc["담은로직"][0]["로직"], doc["일위대가번"]) == (TEN, before["labor"]["일위대가번"]) + line = doc["계산값"]["줄"][0] + assert (line["수량"], line["수량출처"]) == (5, "칸") # 새 면적 칸 값으로 셈 + assert _doc(personal, "structure", "a") == before["structure"] + + +def test_pair_없으면_예전처럼_한_칸만(world): # noqa: F811 + system, personal = world["system"], world["personal"] + _link_rv(system) + url = URL.format("personal", "labor", "a") + "?from=rv&from_layer=system" + assert _as(world).post(url).status_code == 200 + assert "연결" not in _doc(personal, "area", "a")["시트"][0] + + +def test_실무문서는_일위대가만_면적_그대로(world): # noqa: F811 + system = world["system"] + _link_rv(system) + area = _doc(system, "area", "rv") + for kind in ("labor", "area"): + url = URL.format("system", kind, "rv") + "?from=UA000008&from_layer=practice&pair=true" + assert _as(world).post(url).status_code == 403 # 시스템 관리자만 + got = _as(world, "SYSTEM_ADMIN").post(url) + assert got.status_code == 200 and set(got.json()) == {"labor"} + assert len(_doc(system, "labor", "rv")["담은로직"]) == 14 + assert _doc(system, "area", "rv") == area + + +def test_원본에_면적산출_없으면_일위대가만(world): # noqa: F811 + system, personal = world["system"], world["personal"] + (system / "area" / "rv.json").unlink() + area = _doc(personal, "area", "a") + url = URL.format("personal", "area", "a") + "?from=rv&from_layer=system&pair=true" + got = _as(world).post(url) + assert got.status_code == 200 and set(got.json()) == {"labor"} + assert _doc(personal, "area", "a") == area + assert _doc(personal, "labor", "a")["담은로직"][0]["로직"] == TEN From e1f7b5f62dd27a83791831a224bc24fa3611704c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:51:27 +0900 Subject: [PATCH 12/12] =?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=EB=B9=88=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20[=EC=83=88=EB=A1=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EA=B8=B0]=20=C2=B7=20[=EB=B3=B5=EC=82=AC=20=EC=83=9D=EC=84=B1]?= =?UTF-8?q?=20=EC=97=86=EC=95=B0=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=C2=B7=20=EB=A9=B4=EC=A0=81=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=3D=20=ED=95=A8=EA=BB=98?= =?UTF-8?q?=20=EB=8D=AE=EC=96=B4=EC=94=80(43-9b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 빈 카드도 보통 카드 — 빈 몸 + 가져오기 · 수정 · 삭제(새 항목 칸 셋은 서버가 자동 생성) - 일위대가 · 면적산출 카드 가져오기 · 집계표 줄 조회 후 선택 = copyLaborAreaFrom(알약 연결 포함) · 도면은 그 칸만 - 가져오기 창 안내 한 줄 「일위대가 · 면적산출 함께 가져옴」(pickFrom note) - 화면 검증 시나리오 ui_checks/43-9b_빈카드_함께가져오기.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_ItemCards.ts | 71 ++-------- .../M02_MasterTemplete_ItemPick.ts | 10 +- .../M02_MasterTemplete_Structure.css | 16 --- .../M02_MasterTemplete_Structure.ts | 19 ++- .../ui_checks/43-9b_빈카드_함께가져오기.js | 125 ++++++++++++++++++ 5 files changed, 155 insertions(+), 86 deletions(-) create mode 100644 resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts index 08bcefb1f..45cb5ede0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemCards.ts @@ -79,9 +79,6 @@ const TEXT = { clearAsk: "「{name}」 의 {value} 을(를) 비울까? — 번호 · 열은 그대로 · 되돌릴 수 없음", clearFailed: "비우지 못함 — {value}", takeTip: "다른 항목의 {value} 을(를) 골라 이 칸에 덮어씀 — 번호 · 열은 그대로", - blank: "비어 있음 — 새로 만들거나 다른 항목에서 복사해 옴", - makeNew: "새로 만들기", - makeCopy: "복사 생성", }; /** 엑셀 카드 단축키 — 스프레드시트 미리보기(`spreadsheet_keys.ts`)가 받는 것만 */ @@ -98,11 +95,6 @@ const SHEET_KEYS: [string, string][] = [ ]; const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -/** 빈 통합문서 — 값 · 식이 든 칸도 알약 연결도 하나도 없음 */ -const emptyBook = (book: Workbook): boolean => - (book.시트 ?? []).every( - (s) => !s.연결?.length && Object.values(s.칸 ?? {}).every((c) => (c.값 ?? "") === "" && !c.식), - ); const fill = (text: string, value: string): string => text.replace("{value}", value); /** 스프레드시트는 면적산출 카드를 열 때만 받음 */ const loadSpreadsheet = () => import("../A00_Common/spreadsheet/spreadsheet"); @@ -124,13 +116,13 @@ export interface ItemCardsOptions { onDirty?: (dirty: boolean) => void; /** 면적산출 카드를 미리보기에서 고침(31-3) — 없으면 보기만 */ sheetEdit?: SheetEditOptions; - /** 「복사해 가져오기」 · 빈 카드 [복사 생성](39-5) — 다른 항목의 이 칸을 골라 덮어씀 · 없으면 단추 없음 */ + /** 「가져오기」(39-5 · 43-9b) — 다른 항목의 이 칸을 골라 덮어씀 · 빈 카드도 같음 · 없으면 단추 없음 */ onImport?: (kind: CardKind) => void; /** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 · - * 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */ + * 끝나면 카드를 다시 읽음(빈 카드 = 빈 몸 + 같은 단추) */ onClear?: (kind: CardKind) => Promise; /** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 · - * [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */ + * [일위대가 수정] = 이것(일위대가 상세) */ labor?: () => void; } @@ -184,9 +176,9 @@ interface Card { saver: SaveControl; help: HTMLElement; edit: HTMLButtonElement; - /** 「복사해 가져오기」 — 찬 카드만 */ + /** 「가져오기」 — 빈 카드도 */ take: HTMLButtonElement; - /** [삭제] — 찬 카드만 · 쓸 수 있을 때만 */ + /** [삭제] — 쓸 수 있을 때만 */ del: HTMLButtonElement; no: HTMLElement; name: HTMLElement; @@ -199,8 +191,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { let docVersion: string | null = null; let preview: DrawingCardHandle | null = null; let seq = 0; - /** 빈 카드에서 [새로 만들기] 를 누른 칸 — 빈 문서라도 미리보기 편집을 띄움(열이 바뀌면 비움) */ - const started = new Set(); const numberOf = (kind: CardKind, id: string): string => opts.numberOf?.(kind, id) ?? ""; const anyDirty = (): boolean => cards.some((c) => c.saver.dirty()); @@ -252,44 +242,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { 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; - }; - /** 빈 카드 몸 — [새로 만들기] = 그 자리에서 빈 미리보기 편집 · [복사 생성] = 가져오기 창 */ - const startBox = (c: Card, paint: () => void): HTMLElement => { - const fresh = createButton({ - label: TEXT.makeNew, - variant: "ghost", - size: "sm", - onClick: () => { - started.add(c.kind); - paint(); - }, - }); - const copy = createButton({ - label: TEXT.makeCopy, - variant: "ghost", - size: "sm", - onClick: () => opts.onImport?.(c.kind), - }); - copy.hidden = !opts.onImport; - return el("div", { - className: "m02-struct__start", - children: [ - el("p", { className: "m02-struct__empty", text: TEXT.blank }), - el("div", { className: "m02-struct__start-row", children: [fresh, copy] }), - ], - }); - }; - const previewHost = el("div", { className: "m02-struct__preview" }); const info = makeCard("structure", TEXT.drawingInfo, previewHost, () => preview ? preview.write() : Promise.resolve(), @@ -340,7 +295,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { : null; let laborEd: LaborDetailHandle | null = null; let laborVer = ""; - /** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */ + /** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임(빈 일위대가도 빈 몸) */ const paintLabor = async (at: string, mine: number): Promise => { if (!laborCard) return; laborEd?.destroy(); @@ -359,11 +314,9 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { } if (mine !== seq) return; laborVer = got.판 ?? ""; - const d = (got.문서 ?? {}) as { 일위대가번?: string; 담은로직?: unknown[]; 자재?: unknown[] }; + const d = (got.문서 ?? {}) as { 일위대가번?: string }; laborCard.no.textContent = d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor); - if (blank(laborCard, !d.담은로직?.length && !d.자재?.length)) - return void laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.())); laborEd = mod.mountLaborDetail(laborHost, got.문서, { layer, column: at, @@ -407,7 +360,8 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { c.saver.button.hidden = c.help.hidden = !can; if (c.kind === "labor") c.help.hidden = true; // 일위대가 카드엔 단축키 상자 없음 c.edit.hidden = !opts.editable(); - c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김 + c.take.hidden = !opts.editable() || !opts.onImport; + c.del.hidden = !opts.editable() || !opts.onClear; } pills.hidden = !sheetEdit; }; @@ -476,8 +430,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { * 그 사이 열이 바뀌면 버림 */ const paintSheet = async (c: SheetCard, mine: number): Promise => { if (!c.doc) return; - if (blank(c, emptyBook(c.doc))) - return void c.host.replaceChildren(startBox(c, () => void paintSheet(c, seq))); c.host.replaceChildren(); try { const { createSpreadsheet } = await loadSpreadsheet(); @@ -502,12 +454,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { paintDrawing(); }; - /** 도면 미리보기 — 빈 도면이면 두 단추(39-5) */ + /** 도면 미리보기(빈 도면도 빈 몸) */ const paintDrawing = (): void => { if (!doc) return; previewHost.replaceChildren(); - if (blank(info, !doc.도면?.entities?.length)) - return void previewHost.append(startBox(info, paintDrawing)); // 미리보기 — 고칠 수 있으면 메뉴 없이 단축키로 고치고 카드 [저장](31-4 · `DrawingCard`) preview = mountDrawingCard(previewHost, { doc, @@ -543,7 +493,6 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle { } root.hidden = false; paintPills(readStateRaw("m02-card-pills") === "true"); // 계정 취향을 늦게 받았어도 열 때마다 따름 - if (col.id !== column) started.clear(); column = col.id; label = col.label; for (const c of cards) c.name.textContent = label; diff --git a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts index 30fbaea13..69d7b1ef4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ItemPick.ts @@ -225,9 +225,13 @@ export function buildTwoWay(opt: TwoWayOptions): HTMLElement { }); } -/** 창으로 고름 — 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ +/** 창으로 고름 — 위 안내 한 줄(`note` · 있으면) · 줄 누름 → `ask` 확인(있으면) → 고른 것 · 닫으면 null */ export function pickFrom( - opt: Omit & { title: string; ask?: (picked: Picked) => string }, + opt: Omit & { + title: string; + note?: string; + ask?: (picked: Picked) => string; + }, ): Promise { return new Promise((resolve) => { let done = false; @@ -250,6 +254,7 @@ export function pickFrom( close(); })(), }); + if (opt.note) body.append(el("p", { className: "m02-itempick__note", text: opt.note })); body.append(picker.root); }, }); @@ -260,6 +265,7 @@ export function pickFrom( export async function pickHere( opt: Omit & { title: string; + note?: string; ask?: (picked: Picked) => string; }, ): Promise { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index aabaaa5e8..674d1bd2d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -205,22 +205,6 @@ color: var(--color-text-secondary); } -/* 빈 카드(39-5) — 안내 한 줄 + [새로 만들기] [복사 생성] */ -.m02-struct__start { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-8); - padding-bottom: var(--spacing-16); -} - -.m02-struct__start-row { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); - padding: 0 var(--spacing-16); -} - /* 조회 후 선택 창 — 찾기 칸 + 목록(번호 · 이름) */ .m02-pick__list { display: flex; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index b51a094e2..0bfca0ffe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -18,6 +18,7 @@ import { showConfirmDialog, showToast } from "@ui/ui_template_elements"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { clearPart, + copyLaborAreaFrom, copyPartFrom, fetchLaborPills, fetchStructureNumbers, @@ -50,6 +51,8 @@ const TEXT = { overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", + pair: "일위대가 · 면적산출", + pairNote: "일위대가 · 면적산출 함께 가져옴 — 알약 연결 포함 · 도면은 그대로", copyFailed: "복사 실패 — {value}", noPills: "이 열 일위대가에 단가산출 로직 · 자재가 없어 알약 없음 — 일위대가 상세에서 담음", pillsFailed: "일위대가 알약을 못 받음 — {value}", @@ -263,8 +266,10 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }; /** 다른 항목의 도면 · 면적산출(…)을 골라 내용만 복사(39-5) — 탭 = 읽을 수 있는 곳 · 덮어쓰기 확인 · - * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「복사해 가져오기」가 씀 */ + * 번호 · 열은 그대로 · 보고 있던 열이면 다시 읽음 · 집계표 줄 「조회 후 선택」 · 카드 「가져오기」가 씀 · + * 일위대가 · 면적산출은 함께(43-9b · 알약 연결이 이어져 있음 · 실무문서면 서버가 일위대가만) · 도면은 그 칸만 */ const pickCopy = async (kind: CardKind, to: StructureColumn | undefined): Promise => { + const pair = kind !== "structure"; const word = PART_WORD[kind]; const toNo = to && numbers[to.id]?.[PART_FIELD[kind]]; if (!to || !toNo) return void showToast(TEXT.saveFirst, "error"); @@ -276,22 +281,22 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane const picked = await pickHere({ title: `${fill(TEXT.pickCopy, word)} — ${target}`, kind, + note: pair ? TEXT.pairNote : undefined, skip: (from, row) => same(from) && row.열 === to.id, ask: (p) => TEXT.overwrite .replace("{from}", `${p.from.label} ${pickedName(p)}`) - .replace("{what}", word) + .replace("{what}", pair ? TEXT.pair : word) .replace("{to}", target), }); if (!picked) return; + const copy = (source: string, from: Parameters[3]) => + pair ? copyLaborAreaFrom(to.id, source, from) : copyPartFrom(kind, to.id, source, from); try { // 같은 곳 = 다른 열 · 다른 층 · 사람 = 원본 층 · 실무문서 = 조합 키 → 일위대가(39-1b) - if ("combo" in picked) - await copyPartFrom(kind, to.id, picked.combo.키, { layer: "practice", owner: null }); + if ("combo" in picked) await copy(picked.combo.키, { layer: "practice", owner: null }); else - await copyPartFrom( - kind, - to.id, + await copy( picked.row.열, same(picked.from) ? null : { layer: picked.from.layer, owner: picked.from.owner }, ); diff --git a/resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js b/resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js new file mode 100644 index 000000000..2b809ce7b --- /dev/null +++ b/resources/tester/ui_checks/43-9b_빈카드_함께가져오기.js @@ -0,0 +1,125 @@ +/* 43-9b — 구조물 조합 카드 셋: 빈 카드 [새로 만들기] · [복사 생성] 없음 · 일위대가 [가져오기] = 일위대가 · 면적산출 함께 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(SU-000001 · 담은 로직 있음 · 면적산출 있음) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글 · 20초 안) + * 쓰는 것: 마스터 층에 새 항목 하나(「검증43 9b」) + 그 일위대가 · 면적산출 — 끝나면 resources/master_template 사본으로 되씀 + * 기대 수치: + * - made = 새 열 id · twoButtons = 0(카드 안 「새로 만들기」 · 「복사 생성」) · tools = 카드 셋 모두 가져오기 · 수정 · 삭제 + * - note.labor = 「일위대가 · 면적산출 함께 가져옴 …」 · note.area = 같은 글 · note.drawing = null + * - after.logic = src.logic(14) · after.links = src.links(item1 알약 연결 3) · after.areaSame = true(시트 통째 = 원본) + * - drawing.before = drawing.after = 0(도면 그대로) · card.rows = src.logic(카드 다시 읽음) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 6000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const vis = (b) => b && !b.hidden && b.offsetParent !== null; + const btns = (root, t) => + [...root.querySelectorAll("button")].filter((b) => vis(b) && b.innerText.trim() === t); + const api = (kind, col) => + fetch(`/api/m02/layers/system/templates/${kind}/${col}`, { credentials: "include" }).then((r) => + r.json(), + ); + const cells = (book) => + (book.시트 ?? []).flatMap((s) => + Object.entries(s.칸 ?? {}).filter(([, c]) => (c.값 ?? "") !== "" || c.식), + ); + const links = (book) => (book.시트 ?? []).reduce((n, s) => n + (s.연결?.length ?? 0), 0); + const sheets = (book) => JSON.stringify(book.시트 ?? []); + const items = () => + [...document.querySelectorAll('[data-node^="i|"]')].map((n) => n.dataset.node); + const cards = () => [...document.querySelectorAll(".m02-struct > *")]; + const out = {}; + + // ① 새 항목 — 제목 줄 「새 항목」 → [새로 만들기] → 종류 · 이름 → 확인 + const before = items(); + btns(document, "새 항목")[0].click(); + const two = await until(() => [...document.querySelectorAll(".m02-itempick__two")].find(vis)); + two.querySelector(".m02-itempick__buttons button").click(); + await w(500); + const ins = [...document.querySelectorAll("input")].filter(vis); + const set = (i, v) => { + i.value = v; + i.dispatchEvent(new Event("input", { bubbles: true })); + i.dispatchEvent(new Event("change", { bubbles: true })); + }; + set(ins[0], "검증43"); + set(ins[1], `9b-${Date.now() % 100000}`); // 같은 이름이면 안 만들어짐 + btns(document, "확인").pop().click(); + const node = await until(() => items().find((n) => !before.includes(n))); + if (!node) return JSON.stringify({ error: "새 항목 안 생김" }); + const col = node.slice(2); + out.made = col; + await until(() => cards()[1]?.querySelector(".m02-labor__edit"), 8000); + out.twoButtons = cards() + .flatMap((c) => [...c.querySelectorAll("button")]) + .filter((b) => ["새로 만들기", "복사 생성"].includes(b.innerText.trim())).length; + out.tools = cards().map((c) => + ["가져오기", "수정", "삭제"].every((t) => btns(c, t).length === 1), + ); + + // ② 가져오기 창 안내 — 도면(없음) · 면적산출(있음) 은 열어 보고 닫음 + const note = async (i) => { + btns(cards()[i], "가져오기")[0].click(); + const d = await until(() => document.querySelector(".m02-itempick__dialog")); + await w(300); + const text = d.querySelector(".m02-itempick__note")?.innerText ?? null; + return { d, text }; + }; + out.note = {}; + for (const [i, k] of [ + [0, "drawing"], + [2, "area"], + ]) { + const { d, text } = await note(i); + out.note[k] = text; + btns(d, "닫기")[0].click(); + await until(() => !document.querySelector(".m02-itempick__dialog")); + } + + // ③ 일위대가 카드 가져오기 ← item1(같은 마스터 층) + const [srcLabor, srcArea, dstDrawing] = await Promise.all([ + api("labor", "item1"), + api("area", "item1"), + api("structure", col), + ]); + out.src = { + logic: srcLabor.문서.담은로직?.length ?? 0, + links: links(srcArea.문서), + areaCells: cells(srcArea.문서).length, + }; + out.drawing = { before: dstDrawing.문서.도면?.entities?.length ?? 0 }; + const { d, text } = await note(1); + out.note.labor = text; + const row = await until(() => + [...d.querySelectorAll("tr")].find((r) => r.innerText.startsWith("SU-000001")), + ); + row.click(); + await w(400); + btns(document, "확인").pop().click(); + const rows = await until(() => { + const ed = cards()[1]?.querySelector(".m02-labor__edit"); + const n = ed?.querySelectorAll("tbody tr[data-labor-logic]").length ?? 0; + return n ? n : null; + }, 8000); + const [labor, area, drawing] = await Promise.all([ + api("labor", col), + api("area", col), + api("structure", col), + ]); + out.after = { + logic: labor.문서.담은로직?.length ?? 0, + links: links(area.문서), + areaCells: cells(area.문서).length, + areaSame: sheets(area.문서) === sheets(srcArea.문서), + }; + out.drawing.after = drawing.문서.도면?.entities?.length ?? 0; + out.card = { rows }; + return JSON.stringify(out); +})();