From b1dbd9c3cb331fb43e2279b91f1827238c683455 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 00:26:53 +0900 Subject: [PATCH 1/7] =?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 2/7] =?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 3/7] =?UTF-8?q?fix(M02):=20=EC=83=81=EC=84=B8=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20[=EB=8F=8C=EC=95=84=EA=B0=80=EA=B8=B0]=20=3D=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=8B=A8=EC=B6=94=20=EB=8C=80=EB=B9=84=20=EC=8B=9C?= =?UTF-8?q?=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 4/7] =?UTF-8?q?test(M02):=20=ED=99=94=EB=A9=B4=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=2043-6=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=20=EC=84=A0=20=EC=83=89=20=E2=80=94=20orca=20eval=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=C2=B7=20=EB=86=93=EA=B8=B0=20=E2=86=92?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20JSON=20=EC=84=A0=20=EC=83=89=20=C2=B7?= =?UTF-8?q?=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 5/7] =?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 6/7] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=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 7/7] 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개 컨테이너에 새로만들기와 복사 생성로직이 필요없겠네. 구조물 조합의 새항목 생성을 하면 당연히 새로운 항목들이 자동으로 생성되잖아. 그후 가져오기를 하면 당연히 값을 가져와서 덮어쓰면 되겠어. 그리고 일위대가와 면적산출에서 가져오기를 하면 도면은 개별로 작성되는게 맞지만 일위대가와 면적산출은 연결되어 있는정보니 같이 가져오는게 맞겠어. 알약연결 포함해서. 이 내용도 추가해줘.