From 28e535f4905e998b79db53dd135ce32d2171b851 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:47:49 +0900 Subject: [PATCH 1/9] =?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=EA=B5=AC=EB=B6=84=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EA=B5=AC=EB=B6=84=20=EA=B1=B0=EB=A6=84?= =?UTF-8?q?=EC=97=90=20=EB=8B=A8=EA=B0=80=EC=8A=B9=EA=B2=A9=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20=EC=A4=84=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20(51-12=20=ED=99=94=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구분 · 상세구분 고르기 칸 = 로직 구분 + 서고 줄(승격 · 조합) 구분 · 같은 값은 한 칸 - 구분을 고르면 서고 줄도 같은 값으로 걸러짐(이전 = 서고 줄 숨김) - 서고 줄 보조 글에 구분 · 상세구분 · 사전조합 상세 [추가] 창도 같은 창 - 확인 시나리오 resources/tester/ui_checks/51-12_고르기_구분.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Pick.ts | 38 ++++++-- .../tester/ui_checks/51-12_고르기_구분.js | 97 +++++++++++++++++++ 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 resources/tester/ui_checks/51-12_고르기_구분.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index ebf4db18c..c896cb70a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -233,6 +233,9 @@ export interface LibraryRow { 이름: string; 제목?: string | null; 단위?: string | null; + /** 단가승격 = 원 로직 것 · 사전조합 = 사용자가 고름(51-12) */ + 구분?: string | null; + 상세구분?: string | null; } /** 서고 목록 — 그 층(주인) 단가승격 `promote` · 사전조합 `precombo` 문서 */ export const fetchLibrary = async ( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 445b48d45..02331349f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -82,9 +82,12 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra (!detail.select.value || x.상세구분 === detail.select.value) && (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), ); - const libFound = sub.select.value - ? [] - : libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)); + const libFound = libRows.filter( + (x) => + (!sub.select.value || x.row.구분 === sub.select.value) && + (!detail.select.value || x.row.상세구분 === detail.select.value) && + (!q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)), + ); const total = all.length + libRows.length; const shown = found.length + libFound.length; finder.hint.textContent = loading @@ -110,7 +113,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra key: x.row.이름, tag: tc(x.kind), name: x.row.제목 || x.row.이름, - sub: x.row.이름, + sub: `${x.row.이름} · ${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim(), unit: x.row.단위 ?? "", onPick: () => { close(); @@ -142,6 +145,7 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); } loading = false; + drawSubs(); find(); }; const drawLayers = (): void => @@ -158,10 +162,28 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra }), ), ); - sub.select.addEventListener("change", () => { - const d = subs.find((s) => s.name === sub.select.value)?.details ?? []; - detail.setOptions(opts(d), ""); + /** 구분 → 상세구분 — 로직 구분에 서고 줄(승격 · 조합) 것을 더함 · 같은 값은 한 칸 */ + const subMap = (): Map> => { + const m = new Map(subs.map((s) => [s.name, new Set(s.details)])); + for (const { row } of libRows) { + if (!row.구분) continue; + if (!m.has(row.구분)) m.set(row.구분, new Set()); + if (row.상세구분) m.get(row.구분)!.add(row.상세구분); + } + return m; + }; + const drawDetails = (keep: string): void => { + const d = [...(subMap().get(sub.select.value) ?? [])]; + detail.setOptions(opts(d), d.includes(keep) ? keep : ""); detail.select.disabled = d.length === 0; + }; + const drawSubs = (): void => { + const keep = detail.select.value; + sub.setOptions(opts([...subMap().keys()]), sub.select.value); + drawDetails(keep); + }; + sub.select.addEventListener("change", () => { + drawDetails(""); find(); }); detail.select.addEventListener("change", find); @@ -202,8 +224,8 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra .then(([s, l]) => { subs.push(...s); all = l; + drawSubs(); find(); - sub.setOptions(opts(s.map((x) => x.name)), ""); }) .catch((error) => showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), diff --git a/resources/tester/ui_checks/51-12_고르기_구분.js b/resources/tester/ui_checks/51-12_고르기_구분.js new file mode 100644 index 000000000..9361d5031 --- /dev/null +++ b/resources/tester/ui_checks/51-12_고르기_구분.js @@ -0,0 +1,97 @@ +/* 51-12 — 로직 고르기 창 구분 · 상세구분 거름에 단가승격 · 사전조합 줄도 들어감 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · item1 있음 + * 부르는 법: orca eval --expression "$(tr ' +' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: item1 → 일위대가 [수정] → [로직 추가] → 서고 줄마다 구분 · 상세구분으로 걸러 봄 → 창 닫음(저장 안 함) + * 기대 수치: + * - libRows ≥ 2(SP-000001 · SC-000001 포함) + * - lib[키].sub = 한 구분(빈 값 아님) · lib[키].detail = 한 상세구분 · lib[키].subText 에 둘이 다 보임 + * - lib[키].withLogic = true(같은 구분 칸에 단가산출 줄도 함께 — 같은 값은 한 칸) + * - subDup = 0(구분 칸 겹침 없음) + */ +(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 out = {}; + 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]"); + [...host.querySelectorAll(".m01-logic__section-head button")] + .find((b) => b.innerText.trim() === "로직 추가") + .click(); + const dlg = await until(() => document.querySelector(".m01-logic__pick")); + const rows = () => [...dlg.querySelectorAll(".ui-find__row")]; + const isLib = (r) => r.querySelector(".ui-find__tag")?.innerText.trim() !== "단가산출"; + await until(() => rows().some(isLib) && rows().some((r) => !isLib(r)), 20000); + await w(500); + const [subSel, detailSel] = dlg.querySelectorAll(".ui-find__filters select"); + const set = async (sel, v) => { + sel.value = v; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await w(120); + }; + const libKeys = rows() + .filter(isLib) + .map((r) => r.dataset.pick); + out.libRows = libKeys.length; + const subVals = [...subSel.options].map((o) => o.value).filter(Boolean); + out.subDup = subVals.length - new Set(subVals).size; + const lib = {}; + for (const k of libKeys) + lib[k] = { + subText: rows() + .find((r) => r.dataset.pick === k) + ?.querySelector(".ui-find__sub")?.innerText, + sub: [], + detail: [], + withLogic: false, + }; + for (const s of subVals) { + await set(subSel, s); + const shown = rows(); + const hasLogic = shown.some((r) => !isLib(r)); + for (const r of shown.filter(isLib)) { + if (!lib[r.dataset.pick]) continue; + lib[r.dataset.pick].sub.push(s); + lib[r.dataset.pick].withLogic ||= hasLogic; + } + for (const d of [...detailSel.options].map((o) => o.value).filter(Boolean)) { + await set(detailSel, d); + for (const r of rows().filter(isLib)) lib[r.dataset.pick]?.detail.push(d); + } + await set(detailSel, ""); + } + await set(subSel, ""); + out.lib = lib; + [...dlg.querySelectorAll("button")].find((b) => b.innerText.trim() === "닫기")?.click(); + out.closed = !document.querySelector(".m01-logic__pick"); + document.querySelector('[data-node="i|item1"]')?.click(); + await w(800); + const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((x) => + /확인|버림|떠나기/.test(x.innerText), + ); + ask?.click(); + return JSON.stringify(out); +})(); From 0793543803d568f90d6bd05444def0110f8ff2d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:49:53 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix(M02):=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=EC=84=9C=EA=B3=A0=20=EC=A4=84=20=EB=B3=B4=EC=A1=B0?= =?UTF-8?q?=20=EA=B8=80=20=E2=80=94=20=EA=B5=AC=EB=B6=84=20=EC=97=86?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EB=81=9D=20=E3=80=8C=C2=B7=E3=80=8D=20?= =?UTF-8?q?=EC=95=88=20=EB=B6=99=EC=9E=84=20(51-12=20=ED=99=94=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 02331349f..ebf61477e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -113,7 +113,9 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra key: x.row.이름, tag: tc(x.kind), name: x.row.제목 || x.row.이름, - sub: `${x.row.이름} · ${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim(), + sub: [x.row.이름, `${x.row.구분 ?? ""} ${x.row.상세구분 ?? ""}`.trim()] + .filter(Boolean) + .join(" · "), unit: x.row.단위 ?? "", onPick: () => { close(); From 86d4f3e88260568555bd7a06cb1d047f3ee71632 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:55:11 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix(M02):=2051-13=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EC=A4=84=20=ED=95=98=EC=9C=84=20=ED=96=89?= =?UTF-8?q?=20=E2=80=94=20=EC=84=B8=EB=B6=80=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=ED=95=9C=20=EC=B8=B5(=EC=B0=B8=EC=A1=B0=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=A4=84)=20=EB=85=B8=EB=AC=B4?= =?UTF-8?q?=EB=B9=84=20=C2=B7=20=EC=9E=AC=EB=A3=8C=EB=B9=84=20=C2=B7=20?= =?UTF-8?q?=EA=B2=BD=EB=B9=84=20=3D=20=EC=95=88=20=EC=A4=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=C3=97=20=EC=95=88=20=EC=A4=84=20=EC=88=98=EB=9F=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 하위 행 이름이 참조 키만 읽어 로직 안 줄(51-11)에서 표가 깨짐 → 참조 키 없으면 로직 키 - calc 답 「안」 타입에 로직 줄 자리 추가 - 확인 시나리오 resources/tester/ui_checks/51-13_일위조합_하위행.js (calc 답 바꿔 끼움 · 서고 저장 없음) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor_Calc.ts | 6 +- .../M02_MasterTemplete_Labor_Rows.ts | 19 ++- .../tester/ui_checks/51-13_일위조합_하위행.js | 132 ++++++++++++++++++ 3 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 resources/tester/ui_checks/51-13_일위조합_하위행.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index 0e8ee2a49..54e3b3e2e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -36,11 +36,13 @@ export interface CalcLine { 결과?: unknown; /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ 줄?: LogicLine[]; - /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④) */ + /** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ · + * 참조 줄 = `참조` · 로직 줄(51-11) = `로직`) */ 이름?: string | null; 원로직?: string | null; 안?: { - 참조: LaborRef; + 참조?: LaborRef; + 로직?: string; 수량: number; 이름?: string | null; 단위?: string | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 94b8ed902..5d108131b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -229,17 +229,22 @@ export interface LogicTableData { } /** 고른 줄 하위 행(51-2 · 같은 표 · 같은 칸) — 단가산출 · 단가승격 = 그 로직 재료 · 노무 · 경비 줄 · - * 사전조합 = 안 줄 · 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) · + * 일위조합 = 세부 항목 한 층(51-13 · 참조 줄 · 로직 줄 · 안을 다시 펼치지 않음 · 금액 = 안 줄 기준 × 안 줄 수량) · + * 금액 칸 = 위 기준(위 줄 기준과 같은 바탕) / 아래 반영(× 위 줄 수량 ÷ 배) · * 하위 합 = 위 줄 금액 · 줄이 없으면 빈 한 줄(「…」 · 까닭 · 「—」) */ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean): HTMLElement[] { const times = line?.수량 === null || line?.수량 === undefined ? null : line.수량 / (line.배 || 1); const items = line?.안 - ? line.안.map((x) => ({ - name: `${x.참조.키} ${x.이름 ?? ""}${x.까닭 ? ` · ${x.까닭}` : ""}`.trim(), - qty: x.수량, - unit: x.단위 ?? "", - cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null, - })) + ? line.안.map((x) => { + const key = x.참조?.키 ?? x.로직 ?? ""; + const label = x.이름 && x.이름 !== key ? `${key} ${x.이름}` : key; // 로직 줄 이름 없으면 키만 + return { + name: `${label}${x.까닭 ? ` · ${x.까닭}` : ""}`, + qty: x.수량, + unit: x.단위 ?? "", + cost: x.기준 ? Object.fromEntries(COSTS.map((c) => [c, x.기준![c] * x.수량])) : null, + }; + }) : (line?.줄 ?? []).map((x) => ({ name: x.출처 ? `${x.이름} (${x.출처.replace(/[A-Z]{2}\d{6}\./g, "")})` : x.이름, qty: x.수량, diff --git a/resources/tester/ui_checks/51-13_일위조합_하위행.js b/resources/tester/ui_checks/51-13_일위조합_하위행.js new file mode 100644 index 000000000..c40dc1b7b --- /dev/null +++ b/resources/tester/ui_checks/51-13_일위조합_하위행.js @@ -0,0 +1,132 @@ +/* 51-13 — 일위대가 표 구분 「일위조합」 줄 누름 = 하위 행 = 그 조합 세부 항목 한 층(참조 줄 · 로직 줄 51-11) — 카드 · 상세 두 곳 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item3(단가승격 SP-000001 · 일위조합 SC-000001 줄이 있음) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — item3 calc 답만 바꿔 끼움(fetch · 끝에 되돌림): SC 줄 「안」 = [SP-000001 × 2 · 첫 단가산출 로직 줄 × 1.5] + * (안 줄 기준 = 같은 답의 그 줄 기준 · SC 기준 = 안 기준 × 수량 합 · SC 수량 손 3 · 반영 = 기준 × 3 · 서고 저장 없음) + * 기대 수치(카드 · 상세 같음): + * - sc.subs = sc.inner(2) · sc.cells = [head] · sc.next = true + * - sc.base ≈ sc.subBase(위 줄 기준 = 하위 합 · 반올림 차 ≤ 2) · sc.applied ≈ sc.subApplied ≈ sc.base × 3 + * - sc.names[0] 「SP-000001 …」 · sc.names[1] 「<로직 키>」(로직 줄 · 깨지지 않음) + * - sc.qty = ["2", "1.5"](안 줄 수량) · sp.subs ≥ 1(단가승격 하위 행 그대로) + */ +(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 SC = "참조|system||SC-000001"; + const SP = "참조|system||SP-000001"; + const KEYS = ["노무비", "재료비", "경비", "계"]; + const real = window.fetch; + let inner = 0; + window.fetch = async (input, init) => { + const res = await real(input, init); + const url = typeof input === "string" ? input : input.url; + if (!/\/labor\/item3\/calc/.test(url) || !res.ok) return res; + const j = await res.clone().json(); + const sc = j.줄.find((l) => l.참조?.키 === "SC-000001"); + const sp = j.줄.find((l) => l.참조?.키 === "SP-000001"); + const lg = j.줄.find((l) => !l.참조 && l.기준); + if (sc && sp?.기준 && lg) { + sc.안 = [ + { 참조: sp.참조, 수량: 2, 이름: sp.이름, 단위: sp.분모, 기준: sp.기준, 까닭: null }, + { 로직: lg.로직, 수량: 1.5, 이름: lg.로직, 단위: null, 기준: lg.기준, 까닭: null }, + ]; + sc.기준 = Object.fromEntries(KEYS.map((k) => [k, sp.기준[k] * 2 + lg.기준[k] * 1.5])); + Object.assign(sc, { 수량: 3, 수량출처: "손" }); // 위 줄 수량 3 — 반영 = 기준 × 3 + sc.반영 = Object.fromEntries(KEYS.map((k) => [k, sc.기준[k] * 3])); + inner = sc.안.length; + } + return new Response(JSON.stringify(j), { status: 200, headers: res.headers }); + }; + const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0; + const total = (tr, part) => { + const tds = tr.querySelectorAll(".m02-labrow__money"); + return n(tds[tds.length - 1].querySelector(part).innerText); + }; + const settled = (get) => + until(() => { + const t = get(); + return t && !/…/.test(t.querySelector("tbody").innerText) ? t : null; + }, 10000); + const open = async (get, key) => { + get().querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__name`).click(); + await w(300); + const tb = get(); + const tr = tb.querySelector(`tr[data-labor-logic="${key}"]`); + const subs = [...tb.querySelectorAll("tbody tr[data-labor-sub]")]; + let at = tr.nextElementSibling; + if (at?.hasAttribute("data-labor-opt")) at = at.nextElementSibling; + return { tb, tr, subs, next: at === subs[0] }; + }; + const measure = async (get) => { + const t = await settled(get); + const head = t.querySelectorAll("thead th").length; + const sp = await open(get, SP); + const { tr, subs, next } = await open(get, SC); + const out = { + head, + sp: { subs: sp.subs.length }, + sc: { + inner, + subs: subs.length, + next, + cells: [ + ...new Set(subs.map((s) => [...s.children].reduce((a, c) => a + (c.colSpan || 1), 0))), + ], + base: total(tr, ".m02-labrow__base"), + subBase: Math.round(subs.reduce((a, s) => a + total(s, ".m02-labrow__base"), 0)), + applied: total(tr, ".m02-labrow__applied"), + subApplied: Math.round(subs.reduce((a, s) => a + total(s, ".m02-labrow__applied"), 0)), + names: subs.map((s) => s.querySelector(".m02-labrow__subname").innerText), + qty: subs.map((s) => s.querySelector(".m02-labrow__qty").innerText), + units: subs.map((s) => s.querySelector(".m02-labrow__unit").innerText), + }, + }; + await open(get, SC); // 같은 줄 다시 = 닫힘 + return out; + }; + try { + const node = await until(() => document.querySelector('[data-node="i|item3"]')); + if (!node) return JSON.stringify({ error: "item3 없음 — 폴더를 펼쳐 둘 것" }); + document.querySelector('[data-node="i|item1"]')?.click(); // 다시 읽게 다른 항목 거침 + await w(1500); + node.click(); + const card = await until(() => + [...document.querySelectorAll(".m02-struct__card")].find((c) => + c.querySelector(`table.m02-labrow tbody tr[data-labor-logic="${SC}"]`), + ), + ); + if (!card) return JSON.stringify({ error: "일위조합 줄 카드 없음" }); + await w(1500); + const out = { card: await measure(() => card.querySelector("table.m02-labrow")) }; + const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find( + (b) => b.innerText.trim() === "수정", + ); + if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" }); + edit.click(); + const detail = () => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => !t.closest(".m02-struct__card") && t.querySelector(`tr[data-labor-logic="${SC}"]`), + ); + if (!(await until(detail))) return JSON.stringify({ ...out, error: "상세 표 없음" }); + await w(2000); + out.detail = await measure(detail); + document.querySelector('[data-node="i|item3"]')?.click(); + await w(800); + [...document.querySelectorAll(".ui-modal button, dialog button")] + .find((b) => /확인|버림|떠나기/.test(b.innerText)) + ?.click(); + await w(1200); + out.back = !!document.querySelector(".m02-struct__card table.m02-labrow"); + return JSON.stringify(out); + } finally { + window.fetch = real; + } +})(); From f6bcfa52ce3b541d3e3c9e609244dc35390093a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:55:15 +0900 Subject: [PATCH 4/9] =?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 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- docs/raw/사용자요구/2026-10-05_수정요구.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 14f1cf7f7..292d015d7 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -112,3 +112,7 @@ 단가산출과 일위대가 사전조합에서 테이블의 양식도 M01에 공통테이블 양식이 있을것같고 버튼들도 M01에 템플릿이 있을거야. 확인해봐 그리고 사전조합의 경우에 검색은 별도의 창이 아니라 구조물 조합의 일위대가 테이블의 조건 추가와 같은 기능이어야. 일위대가 사전조합에서 나오는 모당창의 형식 뿐만아니라 내용도 구조물조합의 일위대가의 상세 화면에서 로직 추가와 같아야된다고 선택되는 항목도 + +어차피 신규로 키값을 만들지 않았음? 그럼 당연히 해당 연결정보와 구분, 상세구분을 만들어야하지 않음? + +다음으로 구조물 조합에서 일위대가의 상세페이지에서 테이블 행 선택에서 단가승격이야 단가산출과 같은 항목이라 문제가 없는데 일위대가 사전조합의 경우에는 전체를 다 표현하는데 문제가 있어. 일위조합인 경우에는 조합의 세부항목을 기준으로 노무비, 재료비, 경비를 값을 가져와서 사용하면 가능하지 않을까? From a3368f206dec5a0657408fb104299be8dc3fc8f6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:56:05 +0900 Subject: [PATCH 5/9] =?UTF-8?q?chore(plan):=2010-5=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=C2=B7=20=EC=9D=BC=EC=9C=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=ED=95=98=EC=9C=84=20=ED=96=89=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 14f1cf7f7..292d015d7 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -112,3 +112,7 @@ 단가산출과 일위대가 사전조합에서 테이블의 양식도 M01에 공통테이블 양식이 있을것같고 버튼들도 M01에 템플릿이 있을거야. 확인해봐 그리고 사전조합의 경우에 검색은 별도의 창이 아니라 구조물 조합의 일위대가 테이블의 조건 추가와 같은 기능이어야. 일위대가 사전조합에서 나오는 모당창의 형식 뿐만아니라 내용도 구조물조합의 일위대가의 상세 화면에서 로직 추가와 같아야된다고 선택되는 항목도 + +어차피 신규로 키값을 만들지 않았음? 그럼 당연히 해당 연결정보와 구분, 상세구분을 만들어야하지 않음? + +다음으로 구조물 조합에서 일위대가의 상세페이지에서 테이블 행 선택에서 단가승격이야 단가산출과 같은 항목이라 문제가 없는데 일위대가 사전조합의 경우에는 전체를 다 표현하는데 문제가 있어. 일위조합인 경우에는 조합의 세부항목을 기준으로 노무비, 재료비, 경비를 값을 가져와서 사용하면 가능하지 않을까? From a522c1979bb182b319b9cf88ff86e6c80ac6cab3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:58:11 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore(M02):=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=20=E2=80=94=20=EB=A7=88=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=20=EC=84=9C=EA=B3=A0=20=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20?= =?UTF-8?q?SC-000001(=EA=B5=AC=EB=B6=84=20=EA=B3=A0=EB=A6=84)?= 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 --- .../master_template/precombo/SC-000001.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/resources/master_template/precombo/SC-000001.json b/resources/master_template/precombo/SC-000001.json index c24f868cc..cb35b6b86 100644 --- a/resources/master_template/precombo/SC-000001.json +++ b/resources/master_template/precombo/SC-000001.json @@ -3,6 +3,23 @@ "키": "SC-000001", "이름": "테스트 사전조합", "단위": null, - "담은로직": [], + "담은로직": [ + { + "로직": "GF000297", + "입력값": {}, + "수량": { + "손": 1 + }, + "메모": null + }, + { + "로직": "GF000289", + "입력값": {}, + "수량": { + "손": 1 + }, + "메모": null + } + ], "본뜬": null } From 524c89d85c7371eb724b10830a04b914f929e2e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 20:58:39 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore(plan):=2010-5=20=EC=88=9C=EC=84=9C=20?= =?UTF-8?q?=EC=B9=B8=202=ED=96=89=20=C2=B7=20=EC=8A=B9=EA=B2=A9=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=C2=B7=20=EC=A1=B0=ED=95=A9=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9A=94=EA=B5=AC=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 292d015d7..bebbfa64f 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -116,3 +116,6 @@ 어차피 신규로 키값을 만들지 않았음? 그럼 당연히 해당 연결정보와 구분, 상세구분을 만들어야하지 않음? 다음으로 구조물 조합에서 일위대가의 상세페이지에서 테이블 행 선택에서 단가승격이야 단가산출과 같은 항목이라 문제가 없는데 일위대가 사전조합의 경우에는 전체를 다 표현하는데 문제가 있어. 일위조합인 경우에는 조합의 세부항목을 기준으로 노무비, 재료비, 경비를 값을 가져와서 사용하면 가능하지 않을까? + +추가로 구조물 조합의 일위대가 상세페이지의 로직 테이블의 순서 열에서 존재하는 버튼을 2행 구조로 변경 +단가 승격에도 옵션 버튼이있어야할것같고. 일위조합의 경우에는 바로 옵션으로 지정할수 없으니 수정버튼(모달)도 잇어할것 같은데? From 47cbbd17bdd4eaa6e49e0106579b5e6143809230 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 21:05:31 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat(M02):=2051-14=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=88=9C=EC=84=9C=20=EC=B9=B8=202?= =?UTF-8?q?=ED=96=89=20=E2=80=94=20=EC=9C=84=20[=EC=9C=84=EB=A1=9C]=20[?= =?UTF-8?q?=EC=95=84=EB=9E=98=EB=A1=9C]=20/=20=EC=95=84=EB=9E=98=20[?= =?UTF-8?q?=EC=98=B5=EC=85=98]=C2=B7[=EC=88=98=EC=A0=95]=20[=EC=82=AD?= =?UTF-8?q?=EC=A0=9C]=20=C2=B7=20=EB=8B=A8=EA=B0=80=EC=8A=B9=EA=B2=A9=20[?= =?UTF-8?q?=EC=98=B5=EC=85=98]=20=C2=B7=20=EC=9D=BC=EC=9C=84=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20[=EC=88=98=EC=A0=95]=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EB=AA=A8=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 순서 칸 = 단추 묶음 두 줄(공용 ui-btn-group 간격) · 카드 갈래는 아래 줄만 - 단가승격 줄 [옵션] = 승격 문서 넣을 값 모달 · [저장] = 서고 저장 길 · 머리 한 줄 알림(쓰는 모든 구조물에 따라감) - 일위조합 줄 [수정] = 사전조합 상세 모달(서고 모드 표 · 옵션 · 수량 · 순서 · 삭제 · 추가) · [저장] = 서고 원본 · 취소 = 버림 물음 - 자기 층 서고만 고침(위 층 = 읽기만) · 닫으면 구조물 일위대가 다시 셈 - 확인 시나리오 resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../M02_MasterTemplete_Labor.css | 13 ++ .../M02_MasterTemplete_Labor_Detail.ts | 19 ++- .../M02_MasterTemplete_Labor_Input.ts | 24 ++- .../M02_MasterTemplete_Labor_RefEdit.ts | 134 +++++++++++++++ .../M02_MasterTemplete_Labor_Rows.ts | 37 +++- .../M02_MasterTemplete_Labor_Text.ts | 8 + resources/tester/test_m02_card_shell.py | 23 ++- .../ui_checks/51-14_순서칸_2행_옵션수정.js | 159 ++++++++++++++++++ 8 files changed, 403 insertions(+), 14 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts create mode 100644 resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 7f212df44..bf59b0c4f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -90,6 +90,14 @@ white-space: nowrap; } +/* 순서 칸 2행(51-14) — 위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제] · 줄 사이도 단추 묶음 간격 */ +.m02-labrow__tools { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: var(--btn-group-gap); +} + /* 옵션 값 줄(44-6) — 이름 ~ 합계에 걸친 한 줄 말줄임(max-width 0 = 표 폭을 안 늘림) · 위 줄과 한 덩이 */ .m02-labrow .m02-labrow__optrow td { padding-top: 0; @@ -179,6 +187,11 @@ color: var(--color-primary); } +/* 참조 줄 모달(51-14) — 일위조합 [수정] = 사전조합 상세 */ +.m02-labref { + width: min(72rem, calc(100vw - 32px)); +} + /* 입력 모달 */ .m02-labin { width: min(40rem, calc(100vw - 32px)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d0bbc892b..f30ad063d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -3,7 +3,7 @@ * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 그 줄 바로 아래 하위 행(51-2 · `Labor_Rows`) · - * 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · + * 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · 참조 줄 [옵션] · [수정] = 서고 원본 모달(51-14 · `Labor_RefEdit`) · * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 @@ -47,6 +47,7 @@ import { } from "./M02_MasterTemplete_Labor_Diagram"; import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; +import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -174,12 +175,13 @@ export function mountLaborDetail( ? { open: pick, option: openRow, + ref: openRef, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow, alias: setAlias, } - : { open: pick, option: openRow }, + : { open: pick, option: openRow, ref: openRef }, ), ); const draw = (): void => { @@ -347,6 +349,19 @@ export function mountLaborDetail( }); } + /** 참조 줄 [옵션] · [수정](51-14) — 서고 원본 모달 · 자기 층 서고만 고침(위 층 = 읽기만 · 서버도 막음) · + * 닫으면 다시 셈(원본이 바뀌었을 수 있음) */ + function openRef(i: number): void { + const ref = cur.담은로직[i].참조; + if (!ref) return; + void openRefEdit({ + ref, + title: `${ref.키} ${lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? ""}`.trim(), + readOnly: !edit || ref.층 !== opt.layer, + closed: () => alive && recalc(), + }); + } + /** 수량산출 칸 누름 — 로직 줄 */ function openQty(i: number): void { const row = cur.담은로직[i]; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts index 019b3541a..f60fe5c0d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Input.ts @@ -19,14 +19,19 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** 면적산출 상세로 감 — 옮겨 갔으면 참(물음에서 취소 = 거짓) */ export type GoArea = () => Promise; -function footer(readOnly: boolean, ok: () => void, close: () => void): HTMLElement { +function footer( + readOnly: boolean, + ok: () => void, + close: () => void, + okLabel = tc("In_Ok"), +): HTMLElement { return el("div", { className: "m02-labin__foot", children: readOnly ? [createButton({ label: tc("In_Close"), variant: "ghost", onClick: close })] : [ createButton({ label: tc("In_Cancel"), variant: "ghost", onClick: close }), - createButton({ label: tc("In_Ok"), variant: "filled", onClick: ok }), + createButton({ label: okLabel, variant: "filled", onClick: ok }), ], }); } @@ -45,6 +50,10 @@ export interface LaborInputOptions { memo: string | null; readOnly: boolean; done: (next: LaborInputResult) => void; + /** 머리 한 줄 알림(51-14 · 서고 원본 고침) */ + notice?: string; + /** 확인 단추 글(기본 「확인」) */ + okLabel?: string; } /** 담은 로직 줄 입력 모달 */ @@ -100,6 +109,15 @@ export function openLaborInput(opt: LaborInputOptions): void { el("div", { className: "m02-inputval", children: [ + ...(opt.notice + ? [ + el("p", { + className: "m02-labin__muted", + text: opt.notice, + attrs: { "data-labor-notice": "" }, + }), + ] + : []), ...(lost.length ? [ el("p", { @@ -120,7 +138,7 @@ export function openLaborInput(opt: LaborInputOptions): void { }), ], }), - footer(opt.readOnly, ok, close), + footer(opt.readOnly, ok, close, opt.okLabel), ); }, }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts new file mode 100644 index 000000000..4ecb9e78f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RefEdit.ts @@ -0,0 +1,134 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_RefEdit.ts + * 일위대가 참조 줄 모달(51-14) — 서고 원본(50-3)을 그 자리에서 고침 · [저장] = 서고 저장 길(`saveTemplate`) · + * 그 문서를 쓰는 모든 구조물에 따라감(머리 한 줄 알림) · 그 층을 못 고치면 읽기만. + * · 단가승격 [옵션] = 승격 문서 로직 한 줄의 넣을 값 모달(`Labor_Input`) + * · 일위조합 [수정] = 사전조합 상세(서고 모드 표 · 안 줄 [옵션] · 수량 · 위로 · 아래로 · 삭제 · 추가) + * 닫으면(저장 · 취소 모두) `closed` — 부르는 쪽이 구조물 일위대가 표를 다시 셈. + * ========================================================================== */ + +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { readTemplate, saveTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchLogicInputs, type LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { keepKnown } from "./M02_Labor_Values_Model"; + +export interface RefEditOptions { + ref: LaborRef; + /** 「SP-000001 메쌓기」 */ + title: string; + readOnly: boolean; + closed: () => void; +} + +const fail = (error: unknown, key: "Load_Failed" | "Lib_SaveFailed"): void => + showToast(error instanceof Error ? error.message : tc(key), "error"); + +export async function openRefEdit(opt: RefEditOptions): Promise { + const kind = opt.ref.키.startsWith("SP-") ? "promote" : "precombo"; + const layer = opt.ref.층 as Layer; + let doc: { 담은로직?: LaborRow[] }; + let 판: string; + try { + const got = await readTemplate(layer, kind, opt.ref.키, null); + doc = got.문서 as typeof doc; + 판 = got.판 ?? ""; + } catch (error) { + return fail(error, "Load_Failed"); + } + const save = (body: unknown): Promise => + saveTemplate(layer, kind, opt.ref.키, null, 판, body).then(() => + showToast(tc("Saved"), "success"), + ); + const notice = opt.readOnly ? `${tc("Ref_Shared")} · ${tc("Ref_ReadOnly")}` : tc("Ref_Shared"); + + if (kind === "promote") { + const row = doc.담은로직?.[0]; + if (!row) return fail(null, "Load_Failed"); + const logic = row.로직 + ? ((await fetchLogicInputs([row.로직]).catch(() => null))?.로직[0] ?? null) + : null; + return openLaborInput({ + title: opt.title, + logic, + values: row.입력값, + memo: row.메모, + readOnly: opt.readOnly, + notice, + okLabel: tc("Save"), + done: (next) => { + row.입력값 = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; + row.메모 = next.메모; + void save(doc) + .catch((error) => fail(error, "Lib_SaveFailed")) + .finally(opt.closed); + }, + }); + } + + // 사전조합 — 상세와 같은 부품(서로 부르므로 늦게 받음) + const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + let dirty = false; + let handle: ReturnType | null = null; + const finish = (): void => { + handle?.destroy(); + opt.closed(); + }; + openModal({ + title: opt.title, + closeLabel: tc("In_Close"), + dialogClass: "m02-modal m02-labref", + beforeClose: async () => { + if (dirty && !(await showConfirmDialog(tc("Ref_Discard")))) return false; + finish(); + return true; + }, + mount: (body, close) => { + const host = el("div", { attrs: { "data-labor-ref": opt.ref.키 } }); + handle = mountLaborDetail(host, doc, { + layer, + column: opt.ref.키, + readOnly: opt.readOnly, + onChanged: (d) => (dirty = d && !opt.readOnly), + goArea: null, + mode: "precombo", + }); + const cancel = createButton({ + label: tc(opt.readOnly ? "In_Close" : "In_Cancel"), + variant: "ghost", + onClick: async () => { + if (dirty && !(await showConfirmDialog(tc("Ref_Discard")))) return; + close(); + finish(); + }, + }); + const ok = createButton({ + label: tc("Save"), + variant: "filled", + onClick: () => + void save(handle?.getDoc()).then( + () => { + close(); + finish(); + }, + (error) => fail(error, "Lib_SaveFailed"), + ), + }); + body.append( + el("p", { + className: "m02-labin__muted", + text: notice, + attrs: { "data-labor-notice": "" }, + }), + host, + el("div", { + className: "m02-labin__foot", + children: opt.readOnly ? [cancel] : [cancel, ok], + }), + ); + }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index 5d108131b..bcdd9122b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -6,7 +6,8 @@ * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조). * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). * 금액은 calc 답(없으면 「…」). - * 줄 누름 · 줄 단추(위로 · 아래로 · 옵션 · 삭제 · 묶음 `ui-btn-group`)는 `on` 이 있을 때만(보기 갈래 = 없음) · + * 줄 누름 · 줄 단추는 `on` 이 있을 때만(보기 갈래 = 없음) — 순서 칸 2행(51-14 · 위 [위로] [아래로] / 아래 [옵션] 또는 [수정] · [삭제] · + * 묶음 `ui-btn-group` · 순서 없는 카드 갈래 = 아래 줄만) · 단가승격 줄 = [옵션] · 일위조합 줄 = [수정](`ref` · 서고 원본) · * 상세는 줄 누름 = 고름(`selected` 줄 하이라이트 · 그 줄 바로 아래 하위 행 · 51-2 · 화살표 없음 51-7) · 넣을 값 모달은 [옵션](47-5). * ========================================================================== */ @@ -174,6 +175,8 @@ export interface RowHandlers { open: (i: number) => void; /** [옵션] 단추(47-5 · 넣을 값 모달) — 없으면 단추 없음 */ option?: (i: number) => void; + /** 참조 줄 단추(51-14) — 단가승격 [옵션] · 일위조합 [수정] = 서고 원본 모달 · 없으면 단추 없음 */ + ref?: (i: number) => void; /** 수량산출 칸 누름(손 입력 · 엑셀에서 고르기) — 없으면 줄 누름과 같음 */ qty?: (i: number) => void; move?: (i: number, d: number) => void; @@ -290,9 +293,11 @@ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean) /** 담은 로직 표 — 보기 갈래는 `on` 없이 */ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { const wait = !data.calc && !data.fail; - const { move, remove, option } = on ?? {}; + const { move, remove, option, ref } = on ?? {}; // 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만 - const tools = !!(move || remove || option); + const tools = !!(move || remove || option || ref); + const group = (children: HTMLElement[]): HTMLElement => + el("div", { className: "ui-btn-group", children }); const lineOf = (key: string): LaborCalcLine | undefined => data.calc?.줄.find((l) => lineId(l) === key); const trs = data.rows.flatMap((row, i) => { @@ -362,13 +367,31 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement className: "m02-labrow__move", children: [ el("div", { - className: "ui-btn-group", + className: "m02-labrow__tools", children: [ ...(move - ? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))] + ? [ + group([ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + ]), + ] : []), - ...(option && !row.참조 ? [small(tc("Labor_Option"), () => option(i))] : []), - ...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []), + group([ + ...(row.참조 + ? ref + ? [ + small( + tc(row.참조.키.startsWith("SP-") ? "Labor_Option" : "Labor_Edit"), + () => ref(i), + ), + ] + : [] + : option + ? [small(tc("Labor_Option"), () => option(i))] + : []), + ...(remove ? [small(tc("Remove"), () => remove(i), "danger")] : []), + ]), ], }), ], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index c7be730db..6b5ea157e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -35,6 +35,14 @@ const TEXT = { Down: ["아래로", "Down"], Remove: ["삭제", "Delete"], Labor_Option: ["옵션", "Options"], + Labor_Edit: ["수정", "Edit"], + Ref_Shared: [ + "서고 원본을 고침 — 이 문서를 쓰는 모든 구조물에 따라감", + "Edits the library original — every structure using it follows", + ], + Ref_ReadOnly: ["이 층은 고칠 수 없음 — 읽기만", "Read-only layer"], + Lib_SaveFailed: ["저장 못 함", "Save failed"], + Ref_Discard: ["저장 안 한 고침을 버리고 닫음?", "Discard unsaved edits?"], Labor_Source: ["원문", "Source"], Labor_SourceHint: [ "단가산출 로직 줄을 고르면 그 원문이 보임", diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 8882439d8..6b03b170e 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -86,7 +86,9 @@ def test_일위대가_카드는_상세_카드_갈래(): assert "...(opt.card || lib ? [] : [dgBox])," in detail assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로 rows = _text("M02_MasterTemplete_Labor_Rows.ts") - assert "const tools = !!(move || remove || option);" in rows and "compactTable" not in rows + assert ( + "const tools = !!(move || remove || option || ref);" in rows and "compactTable" not in rows + ) assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1] @@ -127,7 +129,9 @@ def test_일위대가_줄_고름과_옵션_단추(): 단추 위로 · 아래로 · 옵션 · 삭제(빨강) · 묶음 간격 = 공용 `ui-btn-group`.""" detail = _text("M02_MasterTemplete_Labor_Detail.ts") assert "open: pick," in detail and "option: openRow," in detail - assert "{ open: pick, option: openRow }" in detail # 읽기만도 고름 · [옵션] = 보기 모달 + assert ( + "{ open: pick, option: openRow, ref: openRef }" in detail + ) # 읽기만도 고름 · [옵션] = 보기 모달 assert "selected: picked" in detail rows = _text("M02_MasterTemplete_Labor_Rows.ts") up, down = rows.index('small(tc("Up")'), rows.index('small(tc("Down")') @@ -141,3 +145,18 @@ def test_일위대가_줄_고름과_옵션_단추(): assert t in text css = (ROOT / "ui_template" / "ui_template_general_layout.css").read_text(encoding="utf-8") assert ".ui-btn-group {" in css and "gap: var(--btn-group-gap);" in css + + +def test_순서칸_2행_참조줄_옵션_수정(): + """51-14 — 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 단가승격 [옵션] · 일위조합 [수정] = + 서고 원본 모달(저장 = 서고 저장 길 · 자기 층만 고침) · 닫으면 다시 셈.""" + rows = _text("M02_MasterTemplete_Labor_Rows.ts") + assert 'className: "m02-labrow__tools"' in rows + assert '"SP-") ? "Labor_Option" : "Labor_Edit"' in rows + detail = _text("M02_MasterTemplete_Labor_Detail.ts") + assert "readOnly: !edit || ref.층 !== opt.layer," in detail + assert "closed: () => alive && recalc()," in detail + ref = _text("M02_MasterTemplete_Labor_RefEdit.ts") + assert "saveTemplate(layer, kind, opt.ref.키, null, 판, body)" in ref + assert 'mode: "precombo",' in ref and "showConfirmDialog" in ref + assert 'Labor_Edit: ["수정"' in _text("M02_MasterTemplete_Labor_Text.ts") diff --git a/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js b/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js new file mode 100644 index 000000000..19f94dc3a --- /dev/null +++ b/resources/tester/ui_checks/51-14_순서칸_2행_옵션수정.js @@ -0,0 +1,159 @@ +/* 51-14 — 일위대가 표 순서 칸 2행(위 [위로] [아래로] / 아래 [옵션]·[수정] [삭제]) · 단가승격 [옵션] · 일위조합 [수정] 모달 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item3(단가승격 SP-000001 · 일위조합 SC-000001 줄이 있음) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 모달은 모두 [취소]로 닫음 · PUT 수 = 0 확인(서고 저장 안 함) + * 기대 수치: + * - card.sp / card.sc: groups 1 · labels [옵션, 삭제] / [수정, 삭제] + * - detail.sp / detail.sc: groups 2 · top [위로, 아래로] · bottom [옵션, 삭제] / [수정, 삭제] · danger = 삭제 + * - promote: notice 있음 · foot [취소, 저장] · closed = true + * - combo: notice 있음 · add [로직 추가] · foot [취소, 저장] — 모달 안 [로직 추가]로 단가산출 한 줄 넣음(화면만) · + * added.groups 2 · top [위로, 아래로] · bottom [옵션, 삭제] · [취소] = 버림 물음(asked) → 확인 · closed = true · recalc ≥ 1 + * - puts = 0 + */ +(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 SC = "참조|system||SC-000001"; + const SP = "참조|system||SP-000001"; + const real = window.fetch; + let puts = 0; + let calcs = 0; + window.fetch = (input, init) => { + const url = typeof input === "string" ? input : input.url; + if ((init?.method ?? "GET").toUpperCase() === "PUT") puts++; + if (/\/labor\/item3\/calc/.test(url)) calcs++; + return real(input, init); + }; + const txt = (b) => b.innerText.trim(); + const tools = (tb, key) => { + const cell = tb.querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__move`); + const groups = [...cell.querySelectorAll(".m02-labrow__tools > .ui-btn-group")]; + const btn = (g) => [...g.querySelectorAll("button")]; + const last = groups.at(-1) ? btn(groups.at(-1)).at(-1) : null; + return { + groups: groups.length, + top: groups.length > 1 ? btn(groups[0]).map(txt) : [], + bottom: groups.length ? btn(groups.at(-1)).map(txt) : [], + danger: last ? last.className : "", + _btn: (label) => groups.flatMap(btn).find((b) => txt(b) === label), + }; + }; + const clean = (t) => { + delete t._btn; + return t; + }; + const modal = () => [...document.querySelectorAll(".ui-modal")].at(-1) ?? null; + const foot = (m) => [...m.querySelectorAll(".m02-labin__foot button")].map(txt); + const cancel = async (m) => { + [...m.querySelectorAll(".m02-labin__foot button")].find((b) => txt(b) === "취소").click(); + await w(600); + return !document.body.contains(m); + }; + try { + const node = await until(() => document.querySelector('[data-node="i|item3"]')); + if (!node) return JSON.stringify({ error: "item3 없음 — 폴더를 펼쳐 둘 것" }); + document.querySelector('[data-node="i|item1"]')?.click(); // 다시 읽게 다른 항목 거침 + await w(1500); + node.click(); + const card = await until(() => + [...document.querySelectorAll(".m02-struct__card")].find((c) => + c.querySelector(`table.m02-labrow tbody tr[data-labor-logic="${SC}"]`), + ), + ); + if (!card) return JSON.stringify({ error: "일위조합 줄 카드 없음" }); + await w(1500); + const ct = card.querySelector("table.m02-labrow"); + const out = { card: { sp: clean(tools(ct, SP)), sc: clean(tools(ct, SC)) } }; + const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find( + (b) => txt(b) === "수정", + ); + if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" }); + edit.click(); + const detail = () => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => + !t.closest(".m02-struct__card, .ui-modal") && + t.querySelector(`tr[data-labor-logic="${SC}"]`), + ); + if (!(await until(detail))) return JSON.stringify({ ...out, error: "상세 표 없음" }); + await w(2000); + const sp = tools(detail(), SP); + const sc = tools(detail(), SC); + out.detail = { sp: clean({ ...sp }), sc: clean({ ...sc }) }; + + // 단가승격 [옵션] — 넣을 값 모달 · 알림 한 줄 · [취소] + sp._btn("옵션").click(); + const pm = await until(() => modal()?.querySelector(".m02-inputval") && modal()); + out.promote = pm + ? { + title: pm.querySelector(".ui-modal__top h3").innerText, + notice: !!pm.querySelector("[data-labor-notice]"), + inputs: pm.querySelectorAll("[data-labor-input]").length, + foot: foot(pm), + closed: await cancel(pm), + } + : { error: "승격 모달 없음" }; + + // 일위조합 [수정] — 사전조합 상세 모달 · [취소] · 닫으면 다시 셈 + tools(detail(), SC)._btn("수정").click(); + const cm = await until(() => document.querySelector(".m02-labref")); + if (!cm) return JSON.stringify({ ...out, error: "일위조합 모달 없음", puts }); + await w(1000); + out.combo = { + title: cm.querySelector(".ui-modal__top h3").innerText, + notice: !!cm.querySelector("[data-labor-notice]"), + rows0: cm.querySelectorAll("table.m02-labrow tbody tr[data-labor-logic]").length, + add: [...cm.querySelectorAll(".m01-logic__section-head button")].map(txt), + foot: foot(cm), + }; + // 모달 안 [로직 추가] → 단가산출 첫 줄(화면 캐시만) + [...cm.querySelectorAll(".m01-logic__section-head button")] + .find((b) => txt(b) === "로직 추가") + .click(); + const pick = await until( + () => + [...document.querySelectorAll(".m01-logic__pick .ui-find__row")].find( + (r) => r.querySelector(".ui-find__tag")?.innerText.trim() === "단가산출", + ), + 30000, // 로직 목록 1천여 줄 + ); + if (!pick) return JSON.stringify({ ...out, error: "고르기 줄 없음", puts }); + pick.click(); + const added = await until(() => + cm.querySelector("table.m02-labrow tbody tr[data-labor-logic]"), + ); + if (added) { + const key = added.getAttribute("data-labor-logic"); + out.combo.added = clean(tools(cm.querySelector("table.m02-labrow"), key)); + } + const before = calcs; + [...cm.querySelectorAll(".m02-labin__foot button")].find((b) => txt(b) === "취소").click(); + const ask = await until(() => document.querySelector(".ui-confirm")); + out.combo.asked = !!ask; + [...(ask?.querySelectorAll("button") ?? [])].find((b) => txt(b) === "확인")?.click(); + await w(600); + out.combo.closed = !document.body.contains(cm); + await w(1200); + out.combo.recalc = calcs - before; + out.puts = puts; + + document.querySelector('[data-node="i|item3"]')?.click(); + await w(800); + [...document.querySelectorAll(".ui-modal button, dialog button")] + .find((b) => /확인|버림|떠나기/.test(b.innerText)) + ?.click(); + await w(1200); + out.back = !!document.querySelector(".m02-struct__card table.m02-labrow"); + return JSON.stringify(out); + } finally { + window.fetch = real; + } +})(); From b7b827a831a3378db3632609215965fcb91f548b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 21:05:43 +0900 Subject: [PATCH 9/9] =?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 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 292d015d7..bebbfa64f 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -116,3 +116,6 @@ 어차피 신규로 키값을 만들지 않았음? 그럼 당연히 해당 연결정보와 구분, 상세구분을 만들어야하지 않음? 다음으로 구조물 조합에서 일위대가의 상세페이지에서 테이블 행 선택에서 단가승격이야 단가산출과 같은 항목이라 문제가 없는데 일위대가 사전조합의 경우에는 전체를 다 표현하는데 문제가 있어. 일위조합인 경우에는 조합의 세부항목을 기준으로 노무비, 재료비, 경비를 값을 가져와서 사용하면 가능하지 않을까? + +추가로 구조물 조합의 일위대가 상세페이지의 로직 테이블의 순서 열에서 존재하는 버튼을 2행 구조로 변경 +단가 승격에도 옵션 버튼이있어야할것같고. 일위조합의 경우에는 바로 옵션으로 지정할수 없으니 수정버튼(모달)도 잇어할것 같은데?