From 96f2313bafc7403e0a96c7c34e722b9fceb74c67 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:31:55 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore(sync):=20=EA=B3=B5=EC=9C=A0=20docs=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9A=94=EA=B5=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/사용자요구/2026-10-05_수정요구.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index c8dbf6477..a4f06f28f 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -88,3 +88,17 @@ 8. 구조물 조합의 일위대가 상세화면에서의 로직테이블은 일위대가 목록표임. 여기서 행을 눌렀을때 하단에 상세 내역이 보였으면 함. 상세내역은 M01페이지의 단가산출 로직을 보면 시험계산 컨테이너 내부에 기준단위에 대한 테이블 리스트가 있음. 이걸 B02 구조물 조합의 일위대가 조합에 맞게 넣어주면 됨. 행을 선택했을때만 보여주면 되고 다른 행을 선택하면 닫히면 됨. 9. 구조물 조합의 일위대가 상세화면에 단가산출 로직 테이블의 이름 좌측 열을 추가하고 구분을 추가함. 구분은 출처로 단가산출인지, 일위대가 조합인지, 단가산출을 승격한 정보인지 표기 항목은 4음절로 표기, 일단 상세히 반영 가능성에 대해 상세히 검토후 계획서에 반영 + +## 오른쪽 패널 높이 (10-5 · 원문 그대로) + +하나씩 수정 내용에 대해 요청할께. M01 단가산출 로직 상세와 M02의 구조물 조합의 일위대가와 면적산출에는 우측 패널이 존재함. 이 우측 패널의 높이는 화면에 맞춰서 높이를 결정하게 했으면 함. 스크롤 동작시 움직임. + +추가로 너가 오해한게 있는데 구조물 조합의 일위대가 상세페이지에서 단가산출 로직 행을 선택했을때 나오는 상세정보를 해당행이 아래에 별도의 행으로 구분하여 접었다 폈다하면서 내용을 보줬으면 해. 별도의 공간에 M01처럼 보여달라는게 아니라 테이블에 맞춰서 정보를 넣어달라는 의미였어. + +## 서고 · 고르기 창 (10-5 · 원문 그대로) + +1. 로직고르기 모달창에서 찾기 버튼은 필요없어 보임. 다른 찾기 기능을 보면 없어도 될것같아. 그리고 단가 승격과 사전 조합을 별도의 버튼으로 활성화하는데 이건 그냥 구분정보를 보여줘서 선택을 유도해도 되지 않을까? +2. 일위대가 조합 컨테이너에는 새로, 본떠 만들기, 지우기 버튼은 필요없고 단가산출 승격 이라는 항목과 일위대가 사전조합이라는 항목 두게만 있으면 될것같음. 그리고 해당 항목을 누르면 상세에 테이블형태로 리스트 들을 보여주고 거기에 생성, 복사, 삭제 버튼이 있으면 됨. +3. 사전 조합도 동일함 + +단위를 선택하게 하는 항목들 중 선택 후 나오는 단위 표기 버튼이 너무 작음 저장같은 버튼과 같은 크기로 변경 From 36a9461f5771c500c2cc26c92c618fe18115de7c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:32:14 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore(sync):=20=EA=B3=B5=EC=9C=A0=20docs=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EB=8B=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/사용자요구/2026-10-05_수정요구.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index c8dbf6477..50ae3392e 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -88,3 +88,19 @@ 8. 구조물 조합의 일위대가 상세화면에서의 로직테이블은 일위대가 목록표임. 여기서 행을 눌렀을때 하단에 상세 내역이 보였으면 함. 상세내역은 M01페이지의 단가산출 로직을 보면 시험계산 컨테이너 내부에 기준단위에 대한 테이블 리스트가 있음. 이걸 B02 구조물 조합의 일위대가 조합에 맞게 넣어주면 됨. 행을 선택했을때만 보여주면 되고 다른 행을 선택하면 닫히면 됨. 9. 구조물 조합의 일위대가 상세화면에 단가산출 로직 테이블의 이름 좌측 열을 추가하고 구분을 추가함. 구분은 출처로 단가산출인지, 일위대가 조합인지, 단가산출을 승격한 정보인지 표기 항목은 4음절로 표기, 일단 상세히 반영 가능성에 대해 상세히 검토후 계획서에 반영 + +## 오른쪽 패널 높이 (10-5 · 원문 그대로) + +하나씩 수정 내용에 대해 요청할께. M01 단가산출 로직 상세와 M02의 구조물 조합의 일위대가와 면적산출에는 우측 패널이 존재함. 이 우측 패널의 높이는 화면에 맞춰서 높이를 결정하게 했으면 함. 스크롤 동작시 움직임. + +추가로 너가 오해한게 있는데 구조물 조합의 일위대가 상세페이지에서 단가산출 로직 행을 선택했을때 나오는 상세정보를 해당행이 아래에 별도의 행으로 구분하여 접었다 폈다하면서 내용을 보줬으면 해. 별도의 공간에 M01처럼 보여달라는게 아니라 테이블에 맞춰서 정보를 넣어달라는 의미였어. + +## 서고 · 고르기 창 (10-5 · 원문 그대로) + +1. 로직고르기 모달창에서 찾기 버튼은 필요없어 보임. 다른 찾기 기능을 보면 없어도 될것같아. 그리고 단가 승격과 사전 조합을 별도의 버튼으로 활성화하는데 이건 그냥 구분정보를 보여줘서 선택을 유도해도 되지 않을까? +2. 일위대가 조합 컨테이너에는 새로, 본떠 만들기, 지우기 버튼은 필요없고 단가산출 승격 이라는 항목과 일위대가 사전조합이라는 항목 두게만 있으면 될것같음. 그리고 해당 항목을 누르면 상세에 테이블형태로 리스트 들을 보여주고 거기에 생성, 복사, 삭제 버튼이 있으면 됨. +3. 사전 조합도 동일함 + +단위를 선택하게 하는 항목들 중 선택 후 나오는 단위 표기 버튼이 너무 작음 저장같은 버튼과 같은 크기로 변경 + +사전조합에 상세에서 보여지는 단가산출 로직의 선택 기능이 없음 From 9aeefd44dcd80740308c5eb1aa7157e4077626bc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:35:01 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(M02):=20=EB=A1=9C=EC=A7=81=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20=ED=95=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=E2=80=94=20=EC=B0=BE=EA=B8=B0=20=EB=8B=A8=EC=B6=94=20=C2=B7?= =?UTF-8?q?=20=ED=83=AD=20=EC=97=86=EC=95=B0=20=C2=B7=20=EA=B5=AC=EB=B6=84?= =?UTF-8?q?=20=EC=B9=B8=20=C2=B7=20=EC=B9=98=EB=8A=94=20=EB=8C=80=EB=A1=9C?= =?UTF-8?q?=20=EA=B1=B8=EB=9F=AC=EC=A7=90=20(51-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_Labor_Pick.ts | 203 +++++++++--------- .../M02_MasterTemplete_Labor_Text.ts | 6 - .../tester/ui_checks/51-3_고르기_한목록.js | 171 +++++++++++++++ 3 files changed, 269 insertions(+), 111 deletions(-) create mode 100644 resources/tester/ui_checks/51-3_고르기_한목록.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index d6a92347d..c8cac3e90 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Pick.ts - * [로직 더하기] 창 — 구분 → 상세구분 → 찾기로 사용자가 로직을 하나씩 고름(자동 추천 없음) · - * 탭 단가산출 · 단가승격 · 사전조합(50-3 ④) — 서고 탭은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) + - * 그 층 목록 · 고르면 참조 {층 · 주인 · 키} · 프로젝트 · 카드엔 서고 탭 없음 + * [로직 더하기] 창 — 한 목록에 구분(단가산출 · 단가승격 · 일위조합) 칸 · 치는 대로 걸러짐(찾기 단추 없음) · + * 단가승격 · 일위조합 줄은 층 단추(위 층만 · 짝 `M02_Labor_Ref.readable`) 층의 서고 · 고르면 참조 {층 · 주인 · 키} · + * 프로젝트 · 카드엔 서고 줄 없음 * ========================================================================== */ import { @@ -65,9 +65,39 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra detail.select.disabled = true; const searchField = createInputField({ type: "search", placeholder: tc("Pick_Search") }); const list = el("div", { className: "m01-logic__pick-list" }); - const hint = el("p", { className: "m01-logic__muted", text: tc("Pick_Hint") }); + const hint = el("p", { className: "m01-logic__muted" }); const close = (): void => backdrop.remove(); + /* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */ + const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); + let scopes: Scope[] = []; + let scope: Scope | null = null; + let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = []; + let loading = false; + let seq = 0; + const pickRow = ( + key: string, + kind: string, + title: string, + sub2: string, + unit: string, + ): HTMLElement => + el("button", { + className: "m01-logic__pick-row m01-logic__pick-row--button", + attrs: { type: "button", "data-pick": key }, + children: [ + el("span", { className: "m01-logic__muted", attrs: { "data-pick-kind": "" }, text: kind }), + el("div", { + className: "m01-logic__stack", + children: [ + el("strong", { text: title }), + el("span", { className: "m01-logic__muted", text: sub2 }), + ], + }), + el("span", { text: unit }), + ], + }); + const find = (): void => { const q = searchField.input.value.trim().toLowerCase(); const found = all.filter( @@ -76,96 +106,68 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (!detail.select.value || x.상세구분 === detail.select.value) && (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), ); - hint.textContent = found.length ? `${found.length} / ${all.length}` : tc("Pick_None"); + const libFound = sub.select.value + ? [] + : libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)); + const total = all.length + libRows.length; + const shown = found.length + libFound.length; + hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None"); list.replaceChildren( ...found.slice(0, 100).map((x) => { - const row = el("button", { - className: "m01-logic__pick-row m01-logic__pick-row--button", - attrs: { type: "button", "data-pick": x.키 }, - children: [ - el("div", { - className: "m01-logic__stack", - children: [ - el("strong", { text: x.이름 }), - el("span", { - className: "m01-logic__muted", - text: `${x.키} · ${x.구분} ${x.상세구분}`.trim(), - }), - ], - }), - el("span", { text: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}` }), - ], - }); + const row = pickRow( + x.키, + tc("Kind_Logic"), + x.이름, + `${x.키} · ${x.구분} ${x.상세구분}`.trim(), + `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, + ); row.addEventListener("click", () => { close(); done(x); }); return row; }), - ); - }; - sub.select.addEventListener("change", () => { - const d = subs.find((s) => s.name === sub.select.value)?.details ?? []; - detail.setOptions(opts(d), ""); - detail.select.disabled = d.length === 0; - }); - - /* 서고 탭(50-3 ④) — 탭 · 층 단추 · 목록 */ - const filters = el("div", { - className: "m01-logic__filters", - children: [ - sub.root, - detail.root, - searchField.root, - createButton({ label: tc("Pick_Find"), variant: "filled", onClick: find }), - ], - }); - const tabs = el("div", { className: "ui-btn-group", attrs: { "data-pick-tabs": "" } }); - const layerBar = el("div", { className: "ui-btn-group", attrs: { "data-pick-layers": "" } }); - let tab: "logic" | "promote" | "precombo" = "logic"; - let scopes: Scope[] = []; - let scope: Scope | null = null; - let seq = 0; - const libList = async (): Promise => { - if (!lib || !scope || tab === "logic") return; - const mine = ++seq; - const at = scope; - list.replaceChildren(); - hint.textContent = tc("Loading"); - let rows: LibraryRow[] = []; - try { - rows = await fetchLibrary(at, tab); - } catch (error) { - if (mine === seq) - hint.textContent = error instanceof Error ? error.message : tc("Load_Failed"); - return; - } - if (mine !== seq) return; - hint.textContent = rows.length ? `${rows.length}` : tc("Pick_None"); - list.replaceChildren( - ...rows.map((x) => { - const row = el("button", { - className: "m01-logic__pick-row m01-logic__pick-row--button", - attrs: { type: "button", "data-pick": x.이름 }, - children: [ - el("div", { - className: "m01-logic__stack", - children: [ - el("strong", { text: x.제목 || x.이름 }), - el("span", { className: "m01-logic__muted", text: x.이름 }), - ], - }), - el("span", { text: x.단위 ?? "" }), - ], - }); + ...libFound.map((x) => { + const at = scope as Scope; + const row = pickRow( + x.row.이름, + tc(x.kind), + x.row.제목 || x.row.이름, + x.row.이름, + x.row.단위 ?? "", + ); row.addEventListener("click", () => { close(); - lib.pick({ ...at, 키: x.이름 }, x.제목 || x.이름); + lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름); }); return row; }), ); }; + const loadLib = async (): Promise => { + if (!lib || !scope) return; + const mine = ++seq; + const at = scope; + loading = true; + find(); + try { + const [promote, combo] = await Promise.all([ + fetchLibrary(at, "promote"), + fetchLibrary(at, "precombo"), + ]); + if (mine !== seq) return; + libRows = [ + ...promote.map((row) => ({ kind: "Kind_Promote" as const, row })), + ...combo.map((row) => ({ kind: "Kind_Combo" as const, row })), + ]; + } catch (error) { + if (mine !== seq) return; + libRows = []; + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + } + loading = false; + find(); + }; const drawLayers = (): void => layerBar.replaceChildren( ...scopes.map((x) => @@ -175,41 +177,33 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra onClick: () => { scope = x; drawLayers(); - void libList(); + void loadLib(); }, }), ), ); - const drawTabs = (): void => { - tabs.replaceChildren( - ...(["logic", "promote", "precombo"] as const).map((k) => - createButton({ - label: tc( - k === "logic" ? "Kind_Logic" : k === "promote" ? "Kind_Promote" : "Lib_Precombo", - ), - variant: k === tab ? "filled" : "ghost", - onClick: () => { - tab = k; - drawTabs(); - filters.style.display = k === "logic" ? "" : "none"; // 공용 꼴 display 가 hidden 을 이김 - layerBar.style.display = k === "logic" ? "none" : ""; - if (k === "logic") { - list.replaceChildren(); - hint.textContent = tc("Pick_Hint"); - } else void libList(); - }, - }), - ), - ); - }; + sub.select.addEventListener("change", () => { + const d = subs.find((s) => s.name === sub.select.value)?.details ?? []; + detail.setOptions(opts(d), ""); + detail.select.disabled = d.length === 0; + find(); + }); + detail.select.addEventListener("change", find); + searchField.input.addEventListener("input", find); + + const filters = el("div", { + className: "m01-logic__filters", + children: [sub.root, detail.root, searchField.root], + }); layerBar.style.display = "none"; if (lib) void upper(lib.layer, lib.owner).then((got) => { scopes = got; scope = got[got.length - 1] ?? null; // 기본 = 가장 가까운 층 if (!got.length) return; - drawTabs(); + layerBar.style.display = ""; drawLayers(); + void loadLib(); }); const dialog = el("div", { @@ -223,7 +217,6 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), - tabs, filters, layerBar, hint, @@ -234,13 +227,13 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); document.body.append(backdrop); - searchField.input.addEventListener("keydown", (ev) => ev.key === "Enter" && find()); searchField.input.focus(); void Promise.all([fetchLogicSubs(), fetchLogics()]) .then(([s, l]) => { subs.push(...s); all = l; + find(); sub.setOptions(opts(s.map((x) => x.name)), ""); }) .catch((error) => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 48ed9576c..b7c8341b5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -45,11 +45,6 @@ const TEXT = { Open: ["열기", "Open"], Pick_Title: ["로직 고르기", "Pick a logic"], Pick_Search: ["이름·키로 찾기", "Search by name or key"], - Pick_Find: ["찾기", "Find"], - Pick_Hint: [ - "구분 → 상세구분을 고르고 [찾기]를 누름 — 하나씩 직접 고름", - "Choose category, detail, then Find — pick one by one", - ], Pick_None: ["찾은 로직 없음", "No logics found"], Pick_Close: ["닫기", "Close"], Pick_Blocked: ["막힘", "Blocked"], @@ -92,7 +87,6 @@ const TEXT = { Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"], Labor_LineBase: ["기준", "Base"], Labor_FromLogic: ["원 로직", "Source logic"], - Lib_Precombo: ["사전조합", "Pre-combination"], Lib_System: ["마스터", "Master"], Lib_Company: ["회사", "Company"], Lib_Personal: ["개인", "Personal"], diff --git a/resources/tester/ui_checks/51-3_고르기_한목록.js b/resources/tester/ui_checks/51-3_고르기_한목록.js new file mode 100644 index 000000000..d1bf582bc --- /dev/null +++ b/resources/tester/ui_checks/51-3_고르기_한목록.js @@ -0,0 +1,171 @@ +/* 51-3 — 일위대가 상세 [로직 추가] 고르기 창 한 목록(찾기 단추 · 탭 없음 · 구분 칸 · 치는 대로 걸러짐) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · item1 있음 + * 부르는 법: orca eval --expression "$(tr ' +' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 공유 마스터에 서고 문서를 안 씀 — 이 탭 fetch 만 바꿔 끼움(층 목록 = 가짜 SP-990001 · SC-990001 더함 · 끝나면 되돌림) + * 하는 일: item1 → 일위대가 [수정] → [로직 추가] → 목록 점검 → "SP-990001" 침 → 고름 → 표 구분 · 삭제(저장 안 함) + * 기대 수치: + * - tabs = 0 · findBtn = 0 · kinds 에 "단가산출" · "단가승격" · "일위조합" 셋 다 · layers = ["마스터"] + * - typed = ["SP-990001"] · typedKinds = ["단가승격"] + * - added.kinds = ["단가승격"] · added.keys = ["SP-990001"] · removed = true · askedLeave = false · back = true + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0; + const SP = { 층: "system", 주인: null, 키: "SP-990001" }; + const SC = { 층: "system", 주인: null, 키: "SC-990001" }; + const rid = (r) => `참조|${r.층}|${r.주인 ?? ""}|${r.키}`; + const real = window.fetch; + let first = null; + window.fetch = async (url, init) => { + const u = String(url); + if (/\/layers\/system\/templates$/.test(u)) { + const got = await (await real(url, init)).json(); + got.양식.push( + { 종류: "promote", 이름: SP.키, 제목: "시험 승격", 단위: "㎡" }, + { 종류: "precombo", 이름: SC.키, 제목: "시험 조합", 단위: "식" }, + ); + return new Response(JSON.stringify(got), { status: 200 }); + } + if (/\/labor\/[^/]+\/calc/.test(u) && init?.body) { + const body = JSON.parse(init.body); + const refs = (body.doc?.담은로직 ?? []).filter((r) => r.참조); + if (!refs.length) return real(url, init); + body.doc.담은로직 = body.doc.담은로직.filter((r) => !r.참조); + const got = await (await real(url, { ...init, body: JSON.stringify(body) })).json(); + first = got.줄[0]; + for (const r of refs) { + const base = { ...first.기준 }; + const line = { + 참조: r.참조, + 이름: r.참조.키 === SP.키 ? "시험 승격" : "시험 조합", + 알약: rid(r.참조), + 분모: "㎡", + 배: 1, + 기준: base, + 반영: null, + 수량: null, + 수량출처: "없음", + 까닭: null, + }; + if (r.참조.키 === SP.키) Object.assign(line, { 원로직: first.로직, 줄: first.줄 }); + else + line.안 = [{ 참조: SP, 수량: 1, 이름: "시험 승격", 단위: "㎡", 기준: base, 까닭: null }]; + got.줄.push(line); + } + return new Response(JSON.stringify(got), { status: 200 }); + } + return real(url, init); + }; + const out = {}; + try { + const node = await until(() => document.querySelector('[data-node="i|item1"]')); + if (!node) return JSON.stringify({ error: "item1 없음" }); + node.click(); + const card = await until(() => + [...document.querySelectorAll(".m02-struct__card")].find((c) => + c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"), + ), + ); + if (!card) return JSON.stringify({ error: "일위대가 카드 없음" }); + [...card.querySelectorAll(".m02-struct__card-tools button")] + .find((b) => b.innerText.trim() === "수정") + ?.click(); + const table = () => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => !t.closest(".m02-struct__card") && t.querySelector("tr[data-labor-logic]"), + ); + await until(() => table() && !/…/.test(table().querySelector("tbody").innerText), 12000); + const host = table().closest("[data-labor]"); + const before = table().querySelectorAll("tbody tr[data-labor-logic]").length; + [...host.querySelectorAll(".m01-logic__section-head button")] + .find((b) => b.innerText.trim() === "로직 추가") + .click(); + const dlg = await until(() => document.querySelector(".m01-logic__pick")); + await until( + () => + dlg.querySelector(`[data-pick="${SP.키}"]`) && + [...dlg.querySelectorAll("[data-pick-kind]")].some( + (k) => k.innerText.trim() === "단가산출", + ), + ); + const kinds = () => + [...dlg.querySelectorAll("[data-pick-kind]")].map((k) => k.innerText.trim()); + out.tabs = dlg.querySelectorAll("[data-pick-tabs]").length; + out.findBtn = [...dlg.querySelectorAll("button")].filter( + (b) => b.innerText.trim() === "찾기", + ).length; + out.kinds = [...new Set(kinds())]; + out.total = dlg.querySelectorAll("[data-pick]").length; + out.layers = [...dlg.querySelectorAll("[data-pick-layers] button")].map((b) => + b.innerText.trim(), + ); + const search = dlg.querySelector('input[type="search"]'); + search.value = "SP-990001"; + search.dispatchEvent(new Event("input", { bubbles: true })); + await w(200); + out.typed = [...dlg.querySelectorAll("[data-pick]")].map((b) => b.dataset.pick); + out.typedKinds = kinds(); + dlg.querySelector(`[data-pick="${SP.키}"]`).click(); + await until( + () => + table()?.querySelector(`tr[data-labor-logic="${rid(SP)}"]`) && + !/…/.test(table().querySelector("tbody").innerText), + 12000, + ); + const rows = [...table().querySelectorAll("tbody tr[data-labor-logic]")]; + out.added = { + kinds: rows.slice(-1).map((r) => r.querySelector(".m02-labrow__kind").innerText.trim()), + keys: rows.slice(-1).map((r) => r.querySelector(".m02-labrow__key").innerText.trim()), + optBtn: rows + .slice(-1) + .flatMap((r) => + [...r.querySelectorAll("button")].filter((b) => b.innerText.trim() === "옵션"), + ).length, + }; + const open = async (ref) => { + table() + .querySelector(`tr[data-labor-logic="${rid(ref)}"] .m02-labrow__name`) + .click(); + await w(300); + return host.querySelector("[data-labor-linedet]"); + }; + let det = await open(SP); + const grid = det.querySelector("table.m01-logic__grid"); + out.promote = { + from: det.querySelector("p")?.innerText.trim(), + rows: grid?.querySelectorAll("tbody tr").length ?? 0, + sum: [...(grid?.querySelectorAll("tbody tr") ?? [])].reduce( + (a, r) => a + n(r.lastElementChild.innerText), + 0, + ), + firstBase: first?.기준?.계 ?? null, + }; + for (const ref of [SP]) { + const tr = table().querySelector(`tr[data-labor-logic="${rid(ref)}"]`); + [...tr.querySelectorAll("button")].find((b) => b.innerText.trim() === "삭제").click(); + await w(600); + } + out.removed = table().querySelectorAll("tbody tr[data-labor-logic]").length === before; + } finally { + window.fetch = real; + } + document.querySelector('[data-node="i|item1"]')?.click(); + await w(800); + const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((b) => + /확인|버림|떠나기/.test(b.innerText), + ); + out.askedLeave = !!ask; /* 저장 안 한 고침이 남았으면 물음 — 지운 뒤라 없어야 함 */ + ask?.click(); + await w(1200); + out.back = !!document.querySelector(".m02-struct__card table.m02-labrow"); + return JSON.stringify(out); +})(); From 9d9955631f3445dfe0cb50bbf75ef8d870b288da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:39:07 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EC=84=9C=EA=B3=A0=20=E2=80=94?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=ED=95=AD=EB=AA=A9=20=EB=91=98=20=C2=B7?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EB=AA=A9=EB=A1=9D=ED=91=9C(51-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 컨테이너 = 층 단추 + 「단가산출 승격」 · 「일위대가 사전조합」 항목만 · 새로 · 본떠 · 지우기 단추 없앰 - 항목 누름 = 상세 칸 목록표(고름 · 키 · 이름 · 단위 · 기준 금액 · 고친 날) · 줄 누름 = 그 문서 상세 - 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 까닭 알림) - 층 목록 서고 줄에 1 단위 기준 계(`기준금액`) 실음 · 시험 한 개 · 화면 시나리오 51-4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_Labor_Ref.py | 21 +- .../M02_MasterTemplete_Labor_Text.ts | 10 + .../M02_MasterTemplete_Library_List.ts | 229 +++++++++++++++++ .../M02_MasterTemplete_Router_Layers.py | 4 +- .../M02_MasterTemplete_UI_Main.ts | 13 + .../M02_MasterTemplete_UI_Side.ts | 8 +- .../M02_MasterTemplete_UI_Side_Precombo.ts | 240 +++--------------- .../M02_MasterTemplete_UI_Style.css | 20 +- resources/tester/test_m02_labor_ref.py | 15 ++ .../tester/ui_checks/51-4_서고_목록표.js | 153 +++++++++++ 10 files changed, 487 insertions(+), 226 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Library_List.ts create mode 100644 resources/tester/ui_checks/51-4_서고_목록표.js diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 486b55002..6e3c9d0e0 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -219,13 +219,28 @@ def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]: raise _bad("새 키를 못 잡음 — 다시 누름", 409) -def summary(folder: Path, kind: str, key: str) -> dict[str, Any]: - """층 목록 한 줄에 붙일 것 — `{제목, 단위}`.""" +def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> dict[str, Any]: + """층 목록 한 줄에 붙일 것 — `{제목, 단위}` · `expand` 면 1 단위 기준 계(`기준금액` · 51-4).""" try: doc = json.loads((Path(folder) / kind / f"{key}.json").read_bytes()) except (OSError, ValueError): return {} - return {"제목": doc.get("이름"), "단위": doc.get("단위")} + out = {"제목": doc.get("이름"), "단위": doc.get("단위")} + if expand is not None: + layer, owner = where(folder) + base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {} + out["기준금액"] = float(base["계"]) if "계" in base else None + return out + + +def summaries(folder: Path, rows: list[dict[str, Any]]) -> None: + """층 목록의 서고 줄마다 `summary`(값 포함) — 한 번 셈 캐시를 함께 씀.""" + from M01_MasterData import M01_MasterData_Store as m01 + + expand = Expand(m01.loaded()[1]) + for row in rows: + if row["종류"] in layers.LIBRARY_KINDS: + row.update(summary(folder, row["종류"], row["이름"], expand)) # ── 쓰는 곳 · 지우기 ────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 48ed9576c..46ea3b85c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -119,6 +119,16 @@ const TEXT = { In_Cancel: ["취소", "Cancel"], In_Close: ["닫기", "Close"], Lib_Name: ["이름", "Name"], + // 서고 목록표(51-4) + Lib_Promote: ["단가산출 승격", "Promoted logic"], + Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"], + Lib_Create: ["생성", "Create"], + Lib_Copy: ["복사", "Copy"], + Lib_Delete: ["삭제", "Delete"], + Lib_Key: ["키", "Key"], + Lib_Unit: ["단위", "Unit"], + Lib_Base: ["기준 금액", "Base amount"], + Lib_Date: ["고친 날", "Modified"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts new file mode 100644 index 000000000..0b15d996f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -0,0 +1,229 @@ +/* ============================================================================= + * M02_MasterTemplete_Library_List.ts + * 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸. + * 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. + * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { + deleteLayerTemplate, + deleteTemplate, + getActiveOwner, + listTemplates, + newLibrary, + readTemplate, + saveTemplate, + type Layer, + type LibraryBody, + type TemplateInfo, +} from "./M02_MasterTemplete_Api_Fetch"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo"; + +/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */ +type Row = TemplateInfo & { 제목?: string | null; 단위?: string | null; 기준금액?: number | null }; + +export interface LibraryListOptions { + layer: Layer; + kind: PrecomboKind; + canEdit: boolean; + /** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */ + open: (key: string, label: string) => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const money = (v: number | null | undefined): string => + v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); +export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름); + +/** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */ +export function mountLibraryList( + host: HTMLElement, + tools: HTMLElement, + opt: LibraryListOptions, +): { getDoc: () => null; destroy: () => void } { + let rows: Row[] = []; + let picked: string | null = null; + const body = el("tbody"); + const btnNew = createButton({ label: tc("Lib_Create"), variant: "filled" }); + const btnCopy = createButton({ label: tc("Lib_Copy"), variant: "ghost" }); + const btnDel = createButton({ label: tc("Lib_Delete"), variant: "danger" }); + const paintButtons = (): void => { + btnCopy.disabled = btnDel.disabled = !rows.some((r) => r.이름 === picked); + }; + + const draw = (): void => { + body.replaceChildren( + ...(rows.length + ? rows.map((row) => { + const radio = el("input", { + attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 }, + }) as HTMLInputElement; + radio.checked = row.이름 === picked; + const pick = el("td", { className: "m02-lib__pick", children: [radio] }); + pick.addEventListener("click", (event) => { + event.stopPropagation(); // 고름 칸은 열지 않음 + picked = row.이름; + radio.checked = true; + paintButtons(); + }); + const tr = el("tr", { + className: "m02-lib__row", + attrs: { "data-lib-key": row.이름 }, + children: [ + pick, + ...[row.이름, row.제목 ?? "", row.단위 ?? "", money(row.기준금액), row.수정일].map( + (v) => el("td", { text: v, attrs: { title: v } }), + ), + ], + }); + tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row))); + return tr; + }) + : [ + el("tr", { + children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "6" } })], + }), + ]), + ); + paintButtons(); + }; + + const load = async (): Promise => { + try { + rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[]; + } catch (error) { + rows = []; + showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); + } + draw(); + }; + + const nameDialog = (title: string, start: string, onOk: (name: string) => Promise) => { + const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); + name.input.value = start; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal", + mount: (box, close) => { + const ok = createButton({ label: L("M02_Create"), variant: "filled" }); + ok.addEventListener("click", async () => { + const text = name.input.value.trim(); + if (!text) return showToast(L("M02_NameNeeded"), "error"); + ok.disabled = true; // 연달아 눌러도 한 번만 + try { + await onOk(text); + close(); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } finally { + ok.disabled = false; + } + }); + name.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) ok.click(); + }); + box.append(name.root, ok); + }, + }); + }; + + /** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */ + const made = (key: string, label: string): void => { + showToast(L("M02_Created"), "success"); + opt.open(key, label); + }; + const create = async (body: LibraryBody, label: string): Promise => + made((await newLibrary(opt.layer, opt.kind, body)).이름, label); + + /** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것 · 사전조합 = 이름만 · 빈 묶음 */ + btnNew.addEventListener("click", () => { + if (opt.kind === "promote") + return openLogicPicker((x) => { + const doc = { + 이름: x.이름, + 단위: x.결과단위 || null, + 담은로직: [{ 로직: x.키, 입력값: {} }], + }; + void create({ 문서: doc }, x.이름).catch((error) => + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"), + ); + }); + nameDialog(L("M02_NewTitle"), "", (name) => + create({ 문서: { 이름: name, 단위: null, 담은로직: [] } }, name), + ); + }); + + /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ + btnCopy.addEventListener("click", () => { + const from = rows.find((r) => r.이름 === picked); + if (!from) return; + nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { + const got = await newLibrary(opt.layer, opt.kind, { + 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, + }); + const doc = await readTemplate(opt.layer, opt.kind, got.이름, null); + await saveTemplate(opt.layer, opt.kind, got.이름, null, got.판, { + ...(doc.문서 as object), + 이름: name, + }); + made(got.이름, name); + }); + }); + + btnDel.addEventListener("click", async () => { + await load(); // 판을 최신으로 + const info = rows.find((r) => r.이름 === picked); + if (!info) return; + const label = libraryLabel(info); + if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return; + try { + if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판); + else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판); + showToast(L("M02_Deleted"), "success"); + picked = null; + await load(); + } catch (error) { + // 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로 + showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error"); + } + }); + + const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const; + host.replaceChildren( + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + attrs: { "data-lib-list": opt.kind }, + children: [ + el("table", { + className: "ui-general-block__table m02-lib__table", + children: [ + el("thead", { + children: [ + el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }), + ], + }), + body, + ], + }), + ], + }), + ); + if (opt.canEdit) + tools.replaceChildren( + el("span", { className: "m02-main__tools", children: [btnNew, btnCopy, btnDel] }), + ); + void load(); + return { getDoc: () => null, destroy: () => undefined }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index e44597522..303a250ae 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -279,8 +279,8 @@ async def list_layer( row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] - if row["종류"] in layers.LIBRARY_KINDS: # 서고(50-3) — 보이는 이름 · 단위 - row.update(refs.summary(folder, row["종류"], row["이름"])) + # 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4) + await asyncio.to_thread(refs.summaries, folder, rows) return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 82d6282bb..7fe44d24a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -454,6 +454,19 @@ export function createMain( return empty(""); } title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a + if (isPrecombo(next.kind) && !next.name) { + // 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세 + save.hidden = true; + const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List"); + if (mine !== seq) return; + editor = mountLibraryList(host, slot, { + layer: next.layer, + kind: next.kind, + canEdit: canEdit(next.layer, isAdmin), + open: (name, label) => void open({ ...next, name, label }), + }); + return; + } const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6) try { const got = await readTemplate(next.layer, next.kind, next.name, next.projectId); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 0fa16606e..7ceefa0f5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -137,12 +137,10 @@ export function buildSide(opt: SideOptions): SideHandle { // 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인) const precombo = buildPrecomboSection({ layer, - canEdit: canEdit(layer, opt.isAdmin), bar: opt.precomboBar ?? [], confirmLeave: opt.confirmLeave, onOpen: opt.onOpen, - isOpen: (kind, name) => isPicked(kind, name), - refresh: () => refresh(), + openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null), }); const precomboSec = section(PRECOMBO_TITLE, precombo.body); // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) @@ -221,7 +219,7 @@ export function buildSide(opt: SideOptions): SideHandle { * (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */ function paint(): void { paintList(); - precombo.paint(items); + precombo.paint(); paintButtons(); if (opt.getOpen()) options.clear(); if (opt.getOpen()) practice?.clear(); @@ -245,7 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - precombo.paint(items); + precombo.paint(); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 8773635dd..7c198a6df 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,240 +1,68 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · - * 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) · - * 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기. - * 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음). - * 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭). + * 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · + * 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만. + * 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) · + * 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드). * ========================================================================== */ -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { openModal } from "@ui/ui_template_modal"; -import { - deleteLayerTemplate, - deleteTemplate, - getActiveOwner, - newLibrary, - readTemplate, - saveTemplate, - type Kind, - type Layer, - type LibraryBody, - type TemplateInfo, -} from "./M02_MasterTemplete_Api_Fetch"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { el } from "@ui/ui_template_elements"; +import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; -/** 종류 · 화면 이름(= 문서 `종류` 글 · 키 SP- · SC- 는 서버가 줌) */ -const PARTS: { kind: PrecomboKind; label: string }[] = [ - { kind: "promote", label: "단가승격" }, - { kind: "precombo", label: "사전조합" }, -]; +const PARTS = [ + { kind: "promote", label: "Lib_Promote" }, + { kind: "precombo", label: "Lib_PrecomboAll" }, +] as const; export const PRECOMBO_TITLE = "일위대가 조합"; export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "promote" || kind === "precombo"; -/** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */ -type Info = TemplateInfo & { 제목?: string }; -export const precomboLabel = (info: Info): string => - info.제목 ? `${info.이름} ${info.제목}` : info.이름; - export interface PrecomboOptions { layer: Layer; - canEdit: boolean; /** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */ bar: HTMLElement[]; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; - isOpen: (kind: Kind, name: string) => boolean; - /** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */ - refresh: () => Promise; + /** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */ + openKind: () => Kind | null; } export interface PrecomboHandle { body: HTMLElement; - /** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */ - paint: (items: TemplateInfo[]) => void; + paint: () => void; } -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -const failed = (error: unknown): void => - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - let items: Info[] = []; - const lists = new Map(); - const buttons = new Map(); - const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind); - const current = (kind: PrecomboKind): Info | undefined => - mine(kind).find((i) => opt.isOpen(kind, i.이름)); - - const select = async (info: Info): Promise => { - if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return; - opt.onOpen({ - layer: opt.layer, - projectId: null, - kind: info.종류, - name: info.이름, - label: precomboLabel(info), + const items = PARTS.map((part) => { + const b = el("button", { + className: "m02-side__item ui-sidebar-row", + text: tc(part.label), + attrs: { type: "button", "data-precombo": part.kind }, }); - }; - - /** 새로 — 서버가 그 층 다음 키로 씀 → 엶 */ - const create = async (kind: PrecomboKind, body: LibraryBody): Promise => { - const key = (await newLibrary(opt.layer, kind, body)).이름; - showToast(L("M02_Created"), "success"); - await opt.refresh(); - const info = mine(kind).find((i) => i.이름 === key); - if (info) await select(info); - }; - - const nameDialog = (title: string, start: string, onOk: (name: string) => Promise) => { - const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); - name.input.value = start; - openModal({ - title, - closeLabel: L("M02_Close"), - dialogClass: "m02-modal", - mount: (body, close) => { - const ok = createButton({ label: L("M02_Create"), variant: "filled" }); - ok.addEventListener("click", async () => { - const text = name.input.value.trim(); - if (!text) return showToast(L("M02_NameNeeded"), "error"); - ok.disabled = true; // 연달아 눌러도 한 번만 - try { - await onOk(text); - close(); - } catch (error) { - failed(error); - } finally { - ok.disabled = false; - } - }); - name.input.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !event.isComposing) ok.click(); - }); - body.append(name.root, ok); - }, - }); - }; - - /** 단가승격 새로 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것(입력값은 상세에서 고정) */ - const newPromote = (): void => - openLogicPicker((x) => { - void opt.confirmLeave().then((go) => - !go - ? undefined - : create("promote", { - 문서: { - 이름: x.이름, - 단위: x.결과단위 || null, - 담은로직: [{ 로직: x.키, 입력값: {} }], - }, - }).catch(failed), - ); - }); - /** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */ - const newPrecombo = (): void => - nameDialog(L("M02_NewTitle"), "", async (name) => { + // 이름 "" = 목록표(UI_Main) + b.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; - await create("precombo", { 문서: { 이름: name, 단위: null, 담은로직: [] } }); - }); - - /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ - const copy = (kind: PrecomboKind): void => { - const from = current(kind); - if (!from) return; - nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { - if (!(await opt.confirmLeave())) return; - const made = await newLibrary(opt.layer, kind, { - 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, + opt.onOpen({ + layer: opt.layer, + projectId: null, + kind: part.kind, + name: "", + label: tc(part.label), }); - const got = await readTemplate(opt.layer, kind, made.이름, null); - await saveTemplate(opt.layer, kind, made.이름, null, made.판, { - ...(got.문서 as object), - 이름: name, - }); - showToast(L("M02_Created"), "success"); - await opt.refresh(); - const info = mine(kind).find((i) => i.이름 === made.이름); - if (info) await select(info); }); - }; - - const remove = async (kind: PrecomboKind): Promise => { - await opt.refresh(); // 저장 직후여도 최신 판으로 - const info = current(kind); - if (!info) return; - const name = precomboLabel(info); - if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return; - try { - if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판); - else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판); - showToast(L("M02_Deleted"), "success"); - opt.onOpen(null); - await opt.refresh(); - } catch (error) { - // 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로 - showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error"); - } - }; - - const block = (part: (typeof PARTS)[number]): HTMLElement => { - const list = el("div", { className: "m02-side__items ui-sidebar-list" }); - lists.set(part.kind, list); - const row: HTMLElement[] = []; - if (opt.canEdit) { - const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" }); - const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" }); - const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" }); - buttons.set(part.kind, { copy: btnCopy, del }); - btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo); - btnCopy.addEventListener("click", () => copy(part.kind)); - del.addEventListener("click", () => void remove(part.kind)); - row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] })); - } - return el("div", { - className: "m02-side__sub", - attrs: { "data-precombo": part.kind }, - children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list], - }); - }; - + return b; + }); const body = el("div", { className: "m02-side__items ui-sidebar-list", - children: [...opt.bar, ...PARTS.map(block)], + children: [...opt.bar, ...items], }); - - function paint(all: TemplateInfo[]): void { - items = all.filter((i) => isPrecombo(i.종류)); - for (const part of PARTS) { - const found = mine(part.kind); - lists.get(part.kind)?.replaceChildren( - ...(found.length - ? found.map((info) => { - const b = el("button", { - className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`, - text: precomboLabel(info), - attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 }, - }); - b.addEventListener("click", () => void select(info)); - return b; - }) - : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), - ); - const b = buttons.get(part.kind); - if (b) b.copy.disabled = b.del.disabled = !current(part.kind); - } - } - + const paint = (): void => { + const on = opt.openKind(); + items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on)); + }; return { body, paint }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index c232dcff0..09567f32b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -56,18 +56,18 @@ gap: var(--spacing-4); } -/* 일위대가 조합 서고(50-3) — 하위 단가승격 · 사전조합 */ -.m02-side__sub { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - margin-top: var(--spacing-8); +/* 일위대가 조합 서고 목록표(51-4) — 줄 누름 = 상세 · 첫 칸 = 고름 */ +.m02-lib__row { + cursor: pointer; } -.m02-side__subtitle { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); +.m02-lib__row:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); +} + +.m02-lib__table .m02-lib__pick { + width: 2.5rem; + text-align: center; } .m02-main { diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 176e4c6af..d3b75fe12 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -323,3 +323,18 @@ def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path): _promote(world["system"]) got = layers.seed_project(tmp_path / "p1") assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"]) + + +def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): + sp = _promote(world["system"]) + sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋") + rows = [ + {"종류": "promote", "이름": sp}, + {"종류": "precombo", "이름": sc}, + {"종류": "labor", "이름": "rv"}, + ] + refs.summaries(world["system"], rows) + one = float(Decimal(str(_base(TEN)["계"])) / 10) + assert rows[0]["기준금액"] == pytest.approx(one) + assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3)) + assert "기준금액" not in rows[2] diff --git a/resources/tester/ui_checks/51-4_서고_목록표.js b/resources/tester/ui_checks/51-4_서고_목록표.js new file mode 100644 index 000000000..767aa03a9 --- /dev/null +++ b/resources/tester/ui_checks/51-4_서고_목록표.js @@ -0,0 +1,153 @@ +/* 51-4 — M02 좌측 「일위대가 조합」 = 층 단추 + 항목 둘 · 항목 누름 = 상세 목록표 · 머리 [생성] · [복사] · [삭제] + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 서고 목록 · 문서 읽기 · 지우기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) · 새로 창은 열고 닫기만 + * 기대 수치: + * - side.items = ["단가산출 승격", "일위대가 사전조합"] · side.otherButtons = 0(새로 · 본떠 · 지우기 없음) + * - promote.heads = ["", "키", "이름", "단위", "기준 금액", "고친 날"] · rows = 2 · base = ["1,234.5", ""] + * - promote.tools = ["생성", "복사", "삭제"] · disabledBefore = [false, true, true] · disabledAfter = [false, false, false] + * - promote.active = true(좌측 항목 고름) · picker = true(생성 = 로직 고르기 창) + * - blocked = 쓰는 곳 까닭 알림 · rowsAfterBlocked = 2 + * - open = { title: "SP-999901 시험 승격", name: "시험 승격" }(줄 누름 = 그 문서 상세) + * - precombo.rows = 1 · dialog = true(생성 = 이름 창) + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 4000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await wait(100); + } + return null; + }; + const rows = [ + { + 종류: "promote", + 이름: "SP-999901", + 판: "a", + 수정일: "2026-10-05T10:00:00", + 제목: "시험 승격", + 단위: "㎡", + 기준금액: 1234.5, + }, + { + 종류: "promote", + 이름: "SP-999902", + 판: "b", + 수정일: "2026-10-05T11:00:00", + 제목: "값 없음", + 단위: null, + 기준금액: null, + }, + { + 종류: "precombo", + 이름: "SC-999901", + 판: "c", + 수정일: "2026-10-05T12:00:00", + 제목: "시험 묶음", + 단위: "개소", + 기준금액: 10, + }, + ]; + const doc = { + 종류: "단가승격", + 키: "SP-999901", + 이름: "시험 승격", + 단위: "㎡", + 담은로직: [{ 로직: "L-0001", 입력값: {} }], + }; + const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const real = window.fetch; + window.fetch = async (input, init = {}) => { + const url = String(input?.url ?? input); + const method = (init.method ?? "GET").toUpperCase(); + if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) { + const got = await (await real(input, init)).json(); + return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] }); + } + if (method === "GET" && url.includes("/templates/promote/SP-999901")) + return json({ 종류: "promote", 이름: "SP-999901", 판: "a", 문서: doc }); + if (method === "DELETE" && /SP-9999/.test(url)) + return json({ detail: "쓰는 곳이 있어 못 지움 「SP-999901」 — 일위대가 시험" }, 409); + return real(input, init); + }; + try { + const sec = [...document.querySelectorAll(".m02-side__lists > section")].find( + (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합", + ); + const items = [...sec.querySelectorAll("[data-precombo]")]; + const side = { + items: items.map((b) => b.textContent), + otherButtons: [ + ...sec.querySelectorAll(".ui-collapsible__body button, .m02-side__items button"), + ].filter((b) => !b.matches("[data-precombo], .m02-layers__btn")).length, + }; + const tools = () => [...document.querySelectorAll(".m02-main__slot .m02-main__tools button")]; + const list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`); + // 단가산출 승격 + items[0].click(); + await until(() => list("promote")?.querySelectorAll("[data-lib-key]").length); + const L = list("promote"); + const promote = { + title: document.querySelector(".m02-main__title")?.textContent, + active: items[0].classList.contains("is-active"), + heads: [...L.querySelectorAll("th")].map((x) => x.textContent), + rows: L.querySelectorAll("[data-lib-key]").length, + base: [...L.querySelectorAll("[data-lib-key]")].map((tr) => tr.children[4].textContent), + tools: tools().map((b) => b.textContent), + disabledBefore: tools().map((b) => b.disabled), + }; + L.querySelector('[data-lib-key="SP-999901"] .m02-lib__pick').click(); + await wait(100); + promote.disabledAfter = tools().map((b) => b.disabled); + promote.stillList = !!list("promote"); // 고름 칸 누름은 안 엶 + // [생성] = 로직 고르기 창 → 닫기 + tools()[0].click(); + await wait(400); + const pick = document.querySelector(".m01-logic__pick"); + promote.picker = !!pick; + pick?.closest(".m01-logic__backdrop")?.remove(); + // [삭제] — 확인 창 [확인] → 쓰는 곳 409 까닭 알림 + tools()[2].click(); + const okBtn = await until(() => + document.querySelector( + ".ui-confirm__actions .ui-button--danger, .ui-confirm__actions button:last-child", + ), + ); + okBtn?.click(); + const toast = await until(() => + [...document.querySelectorAll(".ui-toast")] + .map((x) => x.textContent) + .find((t) => t?.includes("쓰는 곳")), + ); + const blocked = toast ?? null; + const rowsAfterBlocked = list("promote")?.querySelectorAll("[data-lib-key]").length ?? 0; + // 줄 누름 = 그 문서 상세 + list("promote").querySelector('[data-lib-key="SP-999901"] td:nth-child(3)').click(); + const nameInput = await until(() => document.querySelector('[data-labor-field="이름"] input')); + const open = { + title: document.querySelector(".m02-main__title")?.textContent, + name: nameInput?.value ?? null, + sideActive: items[0].classList.contains("is-active"), + }; + // 일위대가 사전조합 + items[1].click(); + await until(() => list("precombo")?.querySelectorAll("[data-lib-key]").length); + const precombo = { + rows: list("precombo")?.querySelectorAll("[data-lib-key]").length ?? 0, + active: items[1].classList.contains("is-active") && !items[0].classList.contains("is-active"), + }; + tools()[0].click(); + await wait(300); + const modal = document.querySelector(".m02-modal"); + precombo.dialog = !!modal; + modal?.querySelector(".ui-modal__top button")?.click(); + await wait(200); + precombo.dialogClosed = !document.querySelector(".m02-modal"); + return JSON.stringify({ side, promote, blocked, rowsAfterBlocked, open, precombo }); + } finally { + window.fetch = real; + } +})(); From 16bacad5c4f3b2c2ebb4b4122ae821dead7cf381 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:39:13 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore(sync):=20=EA=B3=B5=EC=9C=A0=20docs=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9A=94=EA=B5=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=8B=B4=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/사용자요구/2026-10-05_수정요구.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index a4f06f28f..50ae3392e 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -102,3 +102,5 @@ 3. 사전 조합도 동일함 단위를 선택하게 하는 항목들 중 선택 후 나오는 단위 표기 버튼이 너무 작음 저장같은 버튼과 같은 크기로 변경 + +사전조합에 상세에서 보여지는 단가산출 로직의 선택 기능이 없음 From a1673aeb10f02e2a28c06823fb31dbfcb02c4646 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:42:39 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat(M02):=20=EC=84=9C=EA=B3=A0=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EC=A1=B0=ED=95=A9=20=EC=83=81=EC=84=B8=20=EC=A4=84=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0=20=E2=80=94=20[=EC=B6=94=EA=B0=80]=20=C2=B7?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=20=EC=86=90=20=EC=9E=85=EB=A0=A5=20=C2=B7?= =?UTF-8?q?=20=EC=9E=90=EA=B8=B0=20=EC=B0=B8=EC=A1=B0=20422(51-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사전조합 상세 머리 [추가] = 고르기 창 서고 줄만(단가승격 · 사전조합 · 위 층만 · 구분 거름 없음) - 새 줄 수량 기본 손 1 · 수량 창은 손 입력만(면적산출 시트 없음) · 위로 · 아래로 · 삭제 · [저장] - 서버: 사전조합이 자기 자신을 담으면 422 「순환 — 자기 자신」 · 단가승격 상세는 한 줄 고정 그대로 - 시험 한 개 · 화면 시나리오 51-6(가짜 응답 · 서고 쓰기 없음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_Labor_Ref.py | 2 + .../M02_MasterTemplete_Labor_Detail.ts | 20 +- .../M02_MasterTemplete_Labor_Pick.ts | 5 +- .../M02_MasterTemplete_Labor_QtyModal.ts | 11 +- .../M02_MasterTemplete_Labor_Text.ts | 1 + resources/tester/test_m02_labor_ref.py | 19 ++ .../tester/ui_checks/51-6_사전조합_줄넣기.js | 208 ++++++++++++++++++ 7 files changed, 258 insertions(+), 8 deletions(-) create mode 100644 resources/tester/ui_checks/51-6_사전조합_줄넣기.js diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 6e3c9d0e0..12ecac51d 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -183,6 +183,8 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: ) me = {"층": at_[0], "주인": at_[1], "키": key} for one in lines: + if ref_id(one["참조"]) == ref_id(me): + raise _bad(f"순환 — 자기 자신을 담음 「{key}」", 422) _no_loop(one["참조"], [me, one["참조"]]) return lines diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 53c8f16de..aec4101b0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -9,7 +9,7 @@ * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · - * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) + * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [추가] · 수량 손 입력(기본 1) · 순서 · 삭제 * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; @@ -380,6 +380,7 @@ export function mountLaborDetail( readOnly: !edit, sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, goArea: opt.goArea, + ...(lib ? { handOnly: true } : {}), done: (r) => { if (r.kind === "keep") return; if (r.kind === "hand") target.수량 = { 손: r.손 }; @@ -482,10 +483,17 @@ export function mountLaborDetail( const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); - cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) + cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); changed(); }, }; + /** 사전조합 [추가] — 서고 줄만 고르기(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422) */ + const addRef = createButton({ + label: tc("Lib_AddRow"), + variant: "filled", + onClick: () => openLogicPicker(() => undefined, { ...library, only: true }), + }); const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", @@ -580,8 +588,12 @@ export function mountLaborDetail( ], }), ]), - // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 고르기는 고르기 창 탭(50-3 ④) - section(tc("Logics_Title"), opt.card || lib ? null : addLogic, logicHost), + // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = [추가] 서고 줄만 고르기(51-6) + section( + tc("Logics_Title"), + opt.card || promote ? null : lib ? addRef : addLogic, + logicHost, + ), ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), ...(opt.card || lib ? [] : [dgBox]), ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index c8cac3e90..5477edeb0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -37,6 +37,8 @@ export interface LibraryPick { layer: Layer; owner: string | null; pick: (ref: LaborRef, name: string) => void; + /** 서고 줄만(사전조합 상세 [추가] · 51-6) — 단가산출 로직 · 구분 거름 없음 */ + only?: boolean; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -193,7 +195,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const filters = el("div", { className: "m01-logic__filters", - children: [sub.root, detail.root, searchField.root], + children: [...(lib?.only ? [] : [sub.root, detail.root]), searchField.root], }); layerBar.style.display = "none"; if (lib) @@ -229,6 +231,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra document.body.append(backdrop); searchField.input.focus(); + if (lib?.only) return; void Promise.all([fetchLogicSubs(), fetchLogics()]) .then(([s, l]) => { subs.push(...s); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts index 3e466130f..8bd58dc80 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_QtyModal.ts @@ -42,6 +42,8 @@ export interface QtyModalOptions { sheet: { doc: Workbook | null; layer: Layer; column: string; pill: string | null }; /** [면적산출 상세로] — 없으면 단추 없음 */ goArea: GoArea | null; + /** 손 입력만(서고 사전조합 줄 · 51-6) — 면적산출 시트 없음 */ + handOnly?: boolean; done: (result: QtyResult) => void; } @@ -104,7 +106,9 @@ export function openQtyModal(opt: QtyModalOptions): void { className: "m02-cellpick__sheet", attrs: { "data-labor-qty-sheet": "" }, }); - if (opt.sheet.pill) { + if (opt.handOnly) { + // 시트 없음 + } else if (opt.sheet.pill) { sheet = mountAreaSheet(host, { doc: opt.sheet.doc, layer: opt.sheet.layer, @@ -153,8 +157,9 @@ export function openQtyModal(opt: QtyModalOptions): void { ], }), state, - el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), - host, + ...(opt.handOnly + ? [] + : [el("p", { className: "m02-cellpick__hint", text: tc("Labor_SheetHint") }), host]), el("div", { className: "m02-labin__foot", children: opt.readOnly diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 3f47ac612..21272f3c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -123,6 +123,7 @@ const TEXT = { Lib_Unit: ["단위", "Unit"], Lib_Base: ["기준 금액", "Base amount"], Lib_Date: ["고친 날", "Modified"], + Lib_AddRow: ["추가", "Add"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index d3b75fe12..913a9877c 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -338,3 +338,22 @@ def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): assert rows[0]["기준금액"] == pytest.approx(one) assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3)) assert "기준금액" not in rows[2] + + +def test_사전조합이_자기_자신을_담으면_422_수량_기본_1(world): + sp = _promote(world["system"]) + a = _combo(world["system"], [{"참조": {**SYS, "키": sp}}], "가") + with store.at(world["system"]): + have = store.read("precombo", a) + assert have["문서"]["담은로직"][0]["수량"] == {"손": 1} + with pytest.raises(store.StoreError) as e: + store.write( + "precombo", + a, + have["판"], + { + **have["문서"], + "담은로직": [*have["문서"]["담은로직"], {"참조": {**SYS, "키": a}}], + }, + ) + assert e.value.status == 422 and "자기 자신" in e.value.detail diff --git a/resources/tester/ui_checks/51-6_사전조합_줄넣기.js b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js new file mode 100644 index 000000000..213b08cc8 --- /dev/null +++ b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js @@ -0,0 +1,208 @@ +/* 51-6 — 서고 사전조합 상세(일위대가 상세 서고 모드) 줄 넣는 길 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 서고 목록 · 문서 읽기 · [저장] PUT 은 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) · calc 만 서버 + * 기대 수치: + * - promote = { add: false, rows: 1, move: 1 }(단가승격 상세는 한 줄 고정 · [옵션]만 · 위로 · 아래로 · 삭제 없음) + * - precombo.add = true · picker = { filters: 0, kinds: ["단가승격", "단가승격", "일위조합"], layers: 1 } + * - added = 2 · again = 「이미 담은」 알림 + * - moved = ["SP-999902", "SP-999901"](위로) · qty = { start: "1"(새 줄 기본), sheet: false, after: { 손: 3 } } · addedQty = { 손: 3 } + * - removed = 1 · selfSave = 「순환」 알림(PUT 422) · putBody 에 자기 키 · left = 버림 확인 뒤 목록 + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 4000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await wait(100); + } + return null; + }; + const SYS = { 층: "system", 주인: null }; + const rows = [ + { + 종류: "promote", + 이름: "SP-999901", + 판: "a", + 수정일: "2026-10-05T10:00:00", + 제목: "시험 승격", + 단위: "㎡", + 기준금액: 100, + }, + { + 종류: "promote", + 이름: "SP-999902", + 판: "b", + 수정일: "2026-10-05T11:00:00", + 제목: "시험 승격 둘", + 단위: "㎡", + 기준금액: 200, + }, + { + 종류: "precombo", + 이름: "SC-999901", + 판: "c", + 수정일: "2026-10-05T12:00:00", + 제목: "시험 묶음", + 단위: "개소", + 기준금액: 100, + }, + ]; + const docs = { + "promote/SP-999901": { + 종류: "단가승격", + 키: "SP-999901", + 이름: "시험 승격", + 단위: "㎡", + 담은로직: [{ 로직: "GC000421", 입력값: {} }], + }, + "precombo/SC-999901": { + 종류: "사전조합", + 키: "SC-999901", + 이름: "시험 묶음", + 단위: "개소", + 담은로직: [{ 참조: { ...SYS, 키: "SP-999901" }, 수량: { 손: 1 } }], + }, + }; + const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const real = window.fetch; + let putBody = null; + window.fetch = async (input, init = {}) => { + const url = String(input?.url ?? input); + const method = (init.method ?? "GET").toUpperCase(); + if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) { + const got = await (await real(input, init)).json(); + return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] }); + } + const hit = Object.keys(docs).find((k) => url.includes(`/templates/${k}`)); + if (hit && method === "GET") + return json({ 종류: hit.split("/")[0], 이름: docs[hit].키, 판: "a", 문서: docs[hit] }); + if (hit && method === "PUT") { + putBody = JSON.parse(init.body); + const self = JSON.stringify(putBody).includes('"키":"SC-999901"}'); + return self + ? json({ detail: "순환 — 자기 자신을 담음 「SC-999901」" }, 422) + : json({ 이름: "SC-999901", 판: "z" }); + } + if (method !== "GET" && !url.includes("/calc")) + return json({ detail: "시험 — 쓰기 막음" }, 400); + return real(input, init); + }; + const sec = [...document.querySelectorAll(".m02-side__lists > section")].find( + (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합", + ); + const items = [...sec.querySelectorAll("[data-precombo]")]; + const btn = (root, text) => + [...root.querySelectorAll("button")].find((b) => b.textContent === text); + const edit = () => document.querySelector(".m02-main__host [data-labor]"); + const lines = () => [...(edit()?.querySelectorAll("[data-labor-logic]") ?? [])]; + const keys = () => lines().map((tr) => tr.querySelector(".m02-labrow__key")?.textContent); + const toast = (re) => + until(() => + [...document.querySelectorAll(".ui-toast")] + .map((x) => x.textContent) + .find((t) => re.test(t ?? "")), + ); + const openDoc = async (i, key) => { + items[i].click(); + const row = await until(() => + document.querySelector(`[data-lib-key="${key}"] td:nth-child(3)`), + ); + row.click(); + await until(() => lines().length); + await wait(300); + }; + const pickerRow = async (key) => { + btn(edit(), "추가").click(); + const row = await until(() => document.querySelector(`.m01-logic__pick [data-pick="${key}"]`)); + return row; + }; + try { + // 단가승격 상세 — 한 줄 고정 + await openDoc(0, "SP-999901"); + const promote = { + add: !!btn(edit(), "추가"), + rows: lines().length, + move: edit().querySelectorAll(".m02-labrow__move button").length, + }; + // 사전조합 상세 + await openDoc(1, "SC-999901"); + const precombo = { add: !!btn(edit(), "추가"), rows: lines().length }; + const first = await pickerRow("SP-999902"); + const pick = document.querySelector(".m01-logic__pick"); + const picker = { + filters: pick.querySelectorAll(".m01-logic__filters select").length, + kinds: [...pick.querySelectorAll("[data-pick-kind]")].map((x) => x.textContent), + layers: pick.querySelectorAll("[data-pick-layers] button").length, + }; + first.click(); + await until(() => lines().length === 2); + await wait(300); + const added = lines().length; + // 같은 참조 두 번 — 막힘 + (await pickerRow("SP-999901")).click(); + const again = await toast(/이미 담은/); + // 위로 — 둘째 줄을 첫째로 + btn(lines()[1], "위로").click(); + await wait(300); + const moved = keys(); + // 수량 손 입력 — 시트 없음 · 3 + lines()[0].querySelector("[data-labor-qty-open]").click(); + const hand = await until(() => document.querySelector('.m02-labqty [data-labor-qty="hand"]')); + const qty = { + start: hand?.value, + sheet: !!document.querySelector(".m02-labqty [data-labor-qty-sheet]"), + }; + hand.value = "3"; + hand.dispatchEvent(new Event("input")); + btn(document.querySelector(".m02-labqty"), "확인").click(); + await wait(400); + // 저장 본문으로 수량 확인(가짜 PUT) + const save = [...document.querySelectorAll(".m02-main__head button")].find( + (b) => b.textContent === "저장", + ); + save.click(); + await until(() => putBody); + const sent = putBody.문서.담은로직; + const addedQty = sent.find((x) => x.참조.키 === "SP-999902")?.수량 ?? null; + qty.after = sent[0]?.수량 ?? null; + // 삭제 — 줄 하나 뺌 + btn(lines()[1], "삭제").click(); + await wait(300); + const removed = lines().length; + // 자기 자신 담고 [저장] → 서버 422 알림 + putBody = null; + (await pickerRow("SC-999901")).click(); + await until(() => lines().length === 2); + save.click(); + const selfSave = await toast(/순환/); + const selfSent = JSON.stringify(putBody ?? {}).includes("SC-999901"); + // 떠남 — 버림 확인 → 목록 + items[1].click(); + const ok = await until(() => + document.querySelector( + ".ui-confirm__actions .ui-button--danger, .ui-confirm__actions button:last-child", + ), + ); + ok?.click(); + const left = !!(await until(() => document.querySelector('[data-lib-list="precombo"]'))); + return JSON.stringify({ + promote, + precombo, + picker, + added, + addedQty, + again, + moved, + qty, + removed, + selfSave, + selfSent, + left, + }); + } finally { + window.fetch = real; + } +})();