diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index b9974542c..441a2a223 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) · 순서 · 삭제 + * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [로직 추가](같은 고르기 창 · 서고 줄만) · 수량 손 입력(기본 1) · 순서 · 삭제 * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; @@ -378,22 +378,20 @@ export function mountLaborDetail( 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", onClick: () => - openLogicPicker((x) => { - if (cur.담은로직.some((r) => r.로직 === x.키)) - return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); - cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); - void loadLogics().then(() => changed()); - }, library), + openLogicPicker( + (x) => { + if (cur.담은로직.some((r) => r.로직 === x.키)) + return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); + cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); + void loadLogics().then(() => changed()); + // 사전조합 = 같은 창 · 서고 줄만(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422) + }, + lib ? { ...library, only: true } : library, + ), }); const addMat = createButton({ label: tc("Labor_AddMat"), @@ -478,12 +476,8 @@ export function mountLaborDetail( ], }), ]), - // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = [추가] 서고 줄만 고르기(51-6) - section( - tc("Logics_Title"), - opt.card || promote ? null : lib ? addRef : addLogic, - logicHost, - ), + // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = 같은 [로직 추가] · 서고 줄만(51-10) + section(tc("Logics_Title"), opt.card || promote ? null : 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 5477edeb0..44756fbb0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -5,13 +5,8 @@ * 프로젝트 · 카드엔 서고 줄 없음 * ========================================================================== */ -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { createFindList } from "@ui/ui_template_find_list"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -65,9 +60,6 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") }); const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); 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" }); const close = (): void => backdrop.remove(); /* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */ @@ -77,31 +69,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra 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 }), - ], - }); + /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ + const finder = createFindList({ + placeholder: tc("Pick_Search"), + filters: lib?.only ? [] : [sub.root, detail.root], + extra: [layerBar], + }); const find = (): void => { - const q = searchField.input.value.trim().toLowerCase(); + const q = finder.search.value.trim().toLowerCase(); const found = all.filter( (x) => (!sub.select.value || x.구분 === sub.select.value) && @@ -113,38 +89,38 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra : 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 = pickRow( - x.키, - tc("Kind_Logic"), - x.이름, - `${x.키} · ${x.구분} ${x.상세구분}`.trim(), - `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, - ); - row.addEventListener("click", () => { + finder.hint.textContent = loading + ? tc("Loading") + : shown + ? `${shown} / ${total}` + : tc("Pick_None"); + finder.setRows([ + ...found.slice(0, 100).map((x) => ({ + key: x.키, + tag: tc("Kind_Logic"), + name: x.이름, + sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(), + unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, + onPick: () => { close(); done(x); - }); - return row; - }), + }, + })), ...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.row.이름 }, x.row.제목 || x.row.이름); - }); - return row; + return { + key: x.row.이름, + tag: tc(x.kind), + name: x.row.제목 || x.row.이름, + sub: x.row.이름, + unit: x.row.단위 ?? "", + onPick: () => { + close(); + lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름); + }, + }; }), - ); + ]); }; const loadLib = async (): Promise => { if (!lib || !scope) return; @@ -191,12 +167,8 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra find(); }); detail.select.addEventListener("change", find); - searchField.input.addEventListener("input", find); + finder.search.addEventListener("input", find); - const filters = el("div", { - className: "m01-logic__filters", - children: [...(lib?.only ? [] : [sub.root, detail.root]), searchField.root], - }); layerBar.style.display = "none"; if (lib) void upper(lib.layer, lib.owner).then((got) => { @@ -219,17 +191,14 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), - filters, - layerBar, - hint, - list, + finder.root, ], }); const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); document.body.append(backdrop); - searchField.input.focus(); + finder.search.focus(); if (lib?.only) return; void Promise.all([fetchLogicSubs(), fetchLogics()]) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index cff2e9cda..c7be730db 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -124,7 +124,6 @@ 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/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts index 0b15d996f..3a183094e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -3,6 +3,7 @@ * 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸. * 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). + * 꼴 = M01 단가산출 로직 목록 — 찾기 줄(`buildHeadFilters`) · 쪽(`buildPager`) · 표(`m01-master__grid`) 를 M01 에서 import. * ========================================================================== */ import { @@ -14,6 +15,9 @@ import { } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; +import { buildPager } from "../M01_MasterData/M01_MasterData_UI_Cells"; +import { buildHeadFilters } from "../M01_MasterData/M01_MasterData_UI_Head"; +import "../M01_MasterData/M01_MasterData_UI_Style.css"; import { deleteLayerTemplate, deleteTemplate, @@ -41,6 +45,7 @@ export interface LibraryListOptions { open: (key: string, label: string) => void; } +const SIZE = 30; 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 }); @@ -54,7 +59,10 @@ export function mountLibraryList( ): { getDoc: () => null; destroy: () => void } { let rows: Row[] = []; let picked: string | null = null; + let page = 1; + const searchField = createInputField({ type: "search", placeholder: tc("Search") }); const body = el("tbody"); + let pager = buildPager(0, SIZE, 1, () => {}); 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" }); @@ -63,9 +71,18 @@ export function mountLibraryList( }; const draw = (): void => { + const q = searchField.input.value.trim().toLowerCase(); + const shown = rows.filter((r) => !q || libraryLabel(r).toLowerCase().includes(q)); + page = Math.min(page, Math.max(1, Math.ceil(shown.length / SIZE))); + const next = buildPager(shown.length, SIZE, page, (p) => { + page = p; + draw(); + }); + pager.replaceWith(next); + pager = next; body.replaceChildren( - ...(rows.length - ? rows.map((row) => { + ...(shown.length + ? shown.slice((page - 1) * SIZE, page * SIZE).map((row) => { const radio = el("input", { attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 }, }) as HTMLInputElement; @@ -78,7 +95,7 @@ export function mountLibraryList( paintButtons(); }); const tr = el("tr", { - className: "m02-lib__row", + className: "m01-master__table-row", attrs: { "data-lib-key": row.이름 }, children: [ pick, @@ -200,14 +217,19 @@ export function mountLibraryList( } }); + searchField.input.addEventListener("input", () => { + page = 1; + draw(); + }); const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const; host.replaceChildren( + buildHeadFilters([searchField.root, pager]), el("div", { - className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + className: "m01-master__grid-wrap", attrs: { "data-lib-list": opt.kind }, children: [ el("table", { - className: "ui-general-block__table m02-lib__table", + className: "m01-master__grid", children: [ el("thead", { children: [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 09567f32b..5fe85ec07 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -56,16 +56,8 @@ gap: var(--spacing-4); } -/* 일위대가 조합 서고 목록표(51-4) — 줄 누름 = 상세 · 첫 칸 = 고름 */ -.m02-lib__row { - cursor: pointer; -} - -.m02-lib__row:hover { - background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); -} - -.m02-lib__table .m02-lib__pick { +/* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */ +.m01-master__grid .m02-lib__pick { width: 2.5rem; text-align: center; } @@ -123,6 +115,7 @@ /* 제목 줄 오른쪽 끝 도구(일위대가 「새 조합」 · PLAN 30-5) */ .m02-main__tools { display: inline-flex; + gap: var(--spacing-8); /* M01 머리 줄(`m01-head`)과 같은 틈 */ margin-left: auto; } diff --git a/resources/master_template/labor/item3.json b/resources/master_template/labor/item3.json index b512db52a..964bcab3f 100644 --- a/resources/master_template/labor/item3.json +++ b/resources/master_template/labor/item3.json @@ -11,21 +11,44 @@ "담은로직": [ { "로직": "GF000219", + "별칭": null, "메모": "메쌓기 — 돌쌓기 인력", "입력값": {}, "수량": null }, { "로직": "GF000119", + "별칭": null, "메모": "노체포설 — 굴착기 0.7㎥", "입력값": {}, "수량": null }, { "로직": "GF000137", + "별칭": null, "메모": "운반 — 중기운반 트레일러(20ton)", "입력값": {}, "수량": null + }, + { + "참조": { + "층": "system", + "주인": null, + "키": "SP-000001" + }, + "별칭": null, + "메모": null, + "수량": null + }, + { + "참조": { + "층": "system", + "주인": null, + "키": "SC-000001" + }, + "별칭": null, + "메모": null, + "수량": null } ], "자재": [], @@ -43,6 +66,28 @@ "경비": 0, "계": 79279.668 }, + "줄": [ + { + "이름": "석공", + "단위": "인", + "수량": 0.195, + "단가": 268908, + "금액": 52437.06, + "비목": { + "노무비": 52437.06 + } + }, + { + "이름": "보통인부", + "단위": "인", + "수량": 0.156, + "단가": 172068, + "금액": 26842.608, + "비목": { + "노무비": 26842.608 + } + } + ], "까닭": null, "수량": null, "수량출처": "없음", @@ -59,6 +104,20 @@ "경비": 1012.3273551844981, "계": 4522.956900158043 }, + "줄": [ + { + "이름": "굴착기(무한궤도) 0.7㎥", + "단위": "시간", + "수량": 0.04122861265718408, + "단가": 109704.31961333334, + "금액": 4522.956900158043, + "비목": { + "노무비": 2433.321308321308, + "재료비": 1077.3082366522367, + "경비": 1012.3273551844981 + } + } + ], "까닭": null, "수량": null, "수량출처": "없음", @@ -75,18 +134,110 @@ "경비": 13280.479259259258, "계": 83943.98719017902 }, + "줄": [ + { + "이름": "트럭 트랙터 및 평판트레일러 20ton", + "단위": "시간", + "수량": 0.7670370370370371, + "단가": 109439.28798333333, + "금액": 83943.98719017902, + "비목": { + "노무비": 38181.82530864197, + "재료비": 32481.682622277778, + "경비": 13280.479259259258 + } + }, + { + "이름": "트럭 10.5ton", + "단위": "시간", + "수량": 0, + "단가": 97740.38995333333, + "금액": 0, + "비목": { + "노무비": 0, + "재료비": 0, + "경비": 0 + } + } + ], "까닭": null, "수량": null, "수량출처": "없음", "반영": null + }, + { + "참조": { + "층": "system", + "주인": null, + "키": "SP-000001" + }, + "이름": "돌쌓기 메쌓기(인력)", + "알약": "참조|system||SP-000001", + "분모": "원/㎡", + "배": 1, + "기준": { + "노무비": 79279.668, + "재료비": 0, + "경비": 0, + "계": 79279.668 + }, + "까닭": null, + "원로직": "GF000219", + "줄": [ + { + "이름": "석공", + "단위": "인", + "수량": 0.195, + "단가": 268908, + "금액": 52437.06, + "비목": { + "노무비": 52437.06 + } + }, + { + "이름": "보통인부", + "단위": "인", + "수량": 0.156, + "단가": 172068, + "금액": 26842.608, + "비목": { + "노무비": 26842.608 + } + } + ], + "수량": null, + "수량출처": "없음", + "반영": null + }, + { + "참조": { + "층": "system", + "주인": null, + "키": "SC-000001" + }, + "이름": "테스트 사전조합", + "알약": "참조|system||SC-000001", + "분모": null, + "배": 1, + "기준": { + "노무비": 0, + "재료비": 0, + "경비": 0, + "계": 0 + }, + "까닭": null, + "안": [], + "수량": null, + "수량출처": "없음", + "반영": null } ], "자재": [], "기준합": { - "노무비": 119894.81461696328, + "노무비": 199174.48261696327, "재료비": 33558.99085893002, "경비": 14292.806614443756, - "계": 167746.61209033706 + "계": 247026.28009033707 }, "반영합": { "노무비": 0, diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 3ffaa76b1..178871a91 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -80,9 +80,9 @@ def test_일위대가_카드는_상세_카드_갈래(): detail = _text("M02_MasterTemplete_Labor_Detail.ts") assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로 # 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음 - assert ( - "opt.card || promote ? null : lib ? addRef : addLogic" - ) # 51-6 사전조합 = 줄 고르기 in detail + # 51-10 사전조합 = 같은 [로직 추가] · 서고 줄만 + assert "opt.card || promote ? null : addLogic" in detail + assert "lib ? { ...library, only: true } : library" in detail assert "...(opt.card || lib ? [] : [dgBox])," in detail assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로 rows = _text("M02_MasterTemplete_Labor_Rows.ts") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 323a0f461..f2771090c 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -77,7 +77,10 @@ def test_sub2_가_부르는_이름과_모양(): r"interface StructureNumbers \{\s*도번: string;\s*면적번: string;\s*/\*\* 일위대가[^*]*\*/\s*일위대가번: string;\s*\}", body, ) - assert re.search(r'export type Kind = [^;]*"area" \| "labor";', body) # 재료산출 없앰 + assert re.search(r'export type Kind = [^;]*"area" \| "labor"', body) # 재료산출 없앰 + assert '"material"' not in re.search(r"export type Kind = [^;]*;", body).group( + 0 + ) # 50-3 서고 종류 더함 def test_집계표만_컨테이너를_붙임(): diff --git a/resources/tester/ui_checks/51-10_서고_표양식.js b/resources/tester/ui_checks/51-10_서고_표양식.js new file mode 100644 index 000000000..0b472b6ef --- /dev/null +++ b/resources/tester/ui_checks/51-10_서고_표양식.js @@ -0,0 +1,149 @@ +/* 51-10 — 서고 목록표(단가산출 승격 · 일위대가 사전조합) = M01 단가산출 로직 목록 표 양식 · 단추 꼴 · + * 사전조합 상세 [로직 추가] = 일위대가 상세 [로직 추가] 와 같은 고르기 창(서고 줄만) + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 서고 목록 · 문서 읽기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) + * 기대 수치: + * - table.wrap = "m01-master__grid-wrap" · table.table = "m01-master__grid" · table.row = "m01-master__table-row" + * - table.filters = true(찾기 칸 + 쪽 `m01-master__pager`) · table.sameCell = true(M01 표 칸과 padding · 글자 · 밑줄 같음) + * - table.search = 1(「값 없음」 치면 한 줄) + * - buttons.variants = ["filled", "ghost", "danger"](M01 [신규 로직] · [로직 복사/생성] · [로직 지우기] 와 같음) · buttons.redDelete = true + * - precombo.add = "로직 추가" · picker.label = "로직 고르기" · picker.kinds ⊂ {단가승격, 일위조합} · picker.logicRows = 0 + */ +(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-05", + 제목: "시험 승격", + 단위: "㎡", + 기준금액: 1234.5, + }, + { + 종류: "promote", + 이름: "SP-999902", + 판: "b", + 수정일: "2026-10-05", + 제목: "값 없음", + 단위: null, + 기준금액: null, + }, + { + 종류: "precombo", + 이름: "SC-999901", + 판: "c", + 수정일: "2026-10-05", + 제목: "시험 묶음", + 단위: "개소", + 기준금액: 10, + }, + ]; + const doc = { + 종류: "사전조합", + 키: "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; + 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/precombo/SC-999901")) + return json({ 종류: "precombo", 이름: "SC-999901", 판: "c", 문서: doc }); + 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 list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`); + const tools = () => [...document.querySelectorAll(".m02-main__slot .m02-main__tools button")]; + const box = (n) => { + const s = getComputedStyle(n); + return [s.paddingTop, s.paddingLeft, s.fontSize, s.borderBottomWidth].join("|"); + }; + try { + items[0].click(); + await until(() => list("promote")?.querySelector(`[data-lib-key="SP-999902"]`)); + const wrap = list("promote"); + const td = wrap.querySelector("[data-lib-key] td:nth-child(3)"); + // M01 목록 표와 같은 클래스로 짠 견본 칸 — 같은 CSS 면 같은 칸 꼴 + const ref = document.createElement("div"); + ref.className = "m01-master__grid-wrap"; + ref.innerHTML = + '
시험 승격
'; + wrap.parentElement.append(ref); + const table = { + wrap: wrap.className, + table: wrap.querySelector("table").className, + row: wrap.querySelector("[data-lib-key]").className, + filters: + !!wrap.parentElement.querySelector(".m01-head__filters input[type=search]") && + !!wrap.parentElement.querySelector(".m01-head__filters .m01-master__pager"), + sameCell: box(td) === box(ref.querySelector("td")), + cell: box(td), + }; + ref.remove(); + const search = wrap.parentElement.querySelector(".m01-head__filters input[type=search]"); + search.value = "값 없음"; + search.dispatchEvent(new Event("input")); + await wait(100); + table.search = list("promote").querySelectorAll("[data-lib-key]").length; + search.value = ""; + search.dispatchEvent(new Event("input")); + const v = (b) => ["filled", "ghost", "danger"].find((x) => b.className.includes(`--${x}`)); + const buttons = { + labels: tools().map((b) => b.textContent), + variants: tools().map(v), + redDelete: /rgb\((1\d\d|2\d\d), (\d|[1-9]\d), (\d|[1-9]\d)\)/.test( + getComputedStyle(tools()[2]).backgroundColor + getComputedStyle(tools()[2]).color, + ), + deleteColor: getComputedStyle(tools()[2]).backgroundColor, + }; + // 사전조합 상세 [로직 추가] + items[1].click(); + const row = await until(() => + document.querySelector('[data-lib-key="SC-999901"] td:nth-child(3)'), + ); + row.click(); + const edit = await until(() => document.querySelector(".m02-main__host [data-labor]")); + await wait(300); + const add = [...edit.querySelectorAll("button")].find((b) => b.textContent === "로직 추가"); + const precombo = { add: add?.textContent ?? null }; + add?.click(); + const pick = await until(() => document.querySelector(".m01-logic__pick")); + await until(() => pick?.querySelectorAll("[data-pick]").length, 3000); + const kinds = [...(pick?.querySelectorAll("[data-pick-kind]") ?? [])].map((x) => x.textContent); + const picker = { + label: pick?.getAttribute("aria-label") ?? null, + kinds: [...new Set(kinds)], + logicRows: kinds.filter((k) => k === "단가산출").length, + rows: kinds.length, + }; + pick?.closest(".m01-logic__backdrop")?.remove(); + return JSON.stringify({ table, buttons, precombo, picker }); + } finally { + window.fetch = real; + } +})(); diff --git a/resources/tester/ui_checks/51-1_오른쪽패널_높이.js b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js index ba3fabdd3..1a447c13b 100644 --- a/resources/tester/ui_checks/51-1_오른쪽패널_높이.js +++ b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js @@ -1,7 +1,7 @@ /* 51-1 — 오른쪽 패널(공용 ui_template_side_panels) 높이 = innerHeight − 머리띠 · 스크롤 처음/중간/끝 화면 안 * 시작 화면: 패널이 이미 열린 상세 화면 하나(M02 일위대가 상세 · M02 면적산출 · M01 로직 상세 중 하나) · 화면마다 한 번씩 부름 * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) - * 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 처음·중간·끝 모두 true + * 기대: ok = true · h = want(innerHeight − max(머리띠, 칸 위)) ±2 · 칸이 올라가 붙은 뒤는 innerHeight − 머리띠 · inView = 그대로·처음·중간·끝 모두 true */ (async () => { const w = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -38,7 +38,16 @@ }; }; const was = scroller.scrollTop; + // 스크롤 알림 없이 지금 높이 — 내용이 늦게 차도 스스로 다시 쟀는지(알림을 쏘면 가려짐 · 가린 탭은 먼저 orca screenshot 으로 한 장 그림) + const r0 = sp.getBoundingClientRect(); + const still = { + name: "그대로", + h: Math.round(r0.height), + want: Math.round(innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top)), + inView: r0.top >= head - 2 && r0.bottom <= innerHeight + 2, + }; const rows = [ + still, await sample("처음", 0), await sample("중간", Math.round(max / 2)), await sample("끝", max), diff --git a/resources/tester/ui_checks/51-6_사전조합_줄넣기.js b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js index 213b08cc8..689cf8434 100644 --- a/resources/tester/ui_checks/51-6_사전조합_줄넣기.js +++ b/resources/tester/ui_checks/51-6_사전조합_줄넣기.js @@ -115,7 +115,7 @@ await wait(300); }; const pickerRow = async (key) => { - btn(edit(), "추가").click(); + btn(edit(), "로직 추가").click(); const row = await until(() => document.querySelector(`.m01-logic__pick [data-pick="${key}"]`)); return row; }; @@ -123,13 +123,13 @@ // 단가승격 상세 — 한 줄 고정 await openDoc(0, "SP-999901"); const promote = { - add: !!btn(edit(), "추가"), + 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 precombo = { add: !!btn(edit(), "로직 추가"), rows: lines().length }; const first = await pickerRow("SP-999902"); const pick = document.querySelector(".m01-logic__pick"); const picker = { diff --git a/resources/tester/ui_checks/51-9_고르기_찾기양식.js b/resources/tester/ui_checks/51-9_고르기_찾기양식.js new file mode 100644 index 000000000..5a0c5c23b --- /dev/null +++ b/resources/tester/ui_checks/51-9_고르기_찾기양식.js @@ -0,0 +1,90 @@ +/* 51-9 — [로직 추가] 고르기 창이 공용 찾기 양식(ui_template_find_list)을 씀 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · item1 있음 + * 부르는 법: orca eval --expression "$(tr ' +' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: item1 → 일위대가 [수정] → [로직 추가] → 칸 배치 · 줄 맞춤 · ↓ Enter 점검 → 창 닫음(저장 안 함) + * 기대 수치: + * - template = true · filters = 2 · sameLine = true(구분 · 상세구분 top 같음 · left 다름) + * - nameAlign = "left" · nameLeftGap ≤ 꼬리표 폭 + 16 · rowHeight ≤ 40 · oneLine = true + * - active0 = 0 · activeAfterDown = 1 · enterPicked = true(Enter 로 창이 닫힘) · undone = 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 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]"); + const before = table().querySelectorAll("tbody tr[data-labor-logic]").length; + const openPick = async () => { + [...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.querySelectorAll(".ui-find__row").length > 1, 20000); + return dlg; + }; + const dlg = await openPick(); + out.template = !!dlg.querySelector(".ui-find .ui-find__filters") && !!dlg.querySelector(".ui-find__list"); + const sels = [...dlg.querySelectorAll(".ui-find__filters > *")]; + out.filters = sels.length; + const [a, b] = sels.map((x) => x.getBoundingClientRect()); + out.sameLine = Math.abs(a.top - b.top) < 2 && b.left > a.right - 1; + const row = dlg.querySelector(".ui-find__row"); + const name = row.querySelector(".ui-find__name"); + const tag = row.querySelector(".ui-find__tag"); + out.nameAlign = getComputedStyle(name).textAlign; + out.rowAlign = getComputedStyle(row).textAlign; + out.nameLeftGap = Math.round(name.getBoundingClientRect().left - row.getBoundingClientRect().left); + out.tagWidth = Math.round(tag.getBoundingClientRect().width); + out.rowHeight = Math.round(row.getBoundingClientRect().height); + out.oneLine = getComputedStyle(row).whiteSpace === "nowrap"; + const rows = () => [...dlg.querySelectorAll(".ui-find__row")]; + const active = () => rows().findIndex((r) => r.classList.contains("is-active")); + out.active0 = active(); + const search = dlg.querySelector('input[type="search"]'); + search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + out.activeAfterDown = active(); + out.rows = rows().length; + /* Enter = 고름 — 창이 닫히고 표에 붙은 줄은 바로 지움 */ + const pickedKey = rows()[active()].dataset.pick; + search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + out.enterPicked = !!(await until(() => !document.querySelector(".m01-logic__pick") || null)); + out.pickedKey = pickedKey; + await until(() => table().querySelectorAll("tbody tr[data-labor-logic]").length > before, 8000); + const added = [...table().querySelectorAll("tbody tr[data-labor-logic]")].slice(before); + for (const tr of added) { + [...tr.querySelectorAll("button")].find((x) => x.innerText.trim() === "삭제")?.click(); + await w(600); + } + out.undone = table().querySelectorAll("tbody tr[data-labor-logic]").length === before; + 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); +})(); diff --git a/ui_template/ui_template_find_list.css b/ui_template/ui_template_find_list.css new file mode 100644 index 000000000..04dbaf7e0 --- /dev/null +++ b/ui_template/ui_template_find_list.css @@ -0,0 +1,73 @@ +/* 공용 찾기 목록(ui_template_find_list.ts) — 거름 2열 · 결과 한 줄 · 이름 왼쪽 맞춤 */ +.ui-find { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.ui-find__filters { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} + +.ui-find__hint { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-find__list { + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; +} + +.ui-find__row { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-bottom: 1px solid var(--color-border); + background: none; + color: var(--color-text-body); + font: inherit; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.ui-find__row.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.ui-find__tag, +.ui-find__sub, +.ui-find__unit { + flex: none; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-find__tag { + min-width: 4.5em; +} + +.ui-find__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.ui-find__sub { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/ui_template/ui_template_find_list.ts b/ui_template/ui_template_find_list.ts new file mode 100644 index 000000000..f4fcab62f --- /dev/null +++ b/ui_template/ui_template_find_list.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * ui_template_find_list.ts + * 공용 찾기 목록 — 걸러내기 고르기 칸(2열 격자) · 찾기 칸 · 알림 줄 · 결과 목록(이름 왼쪽 맞춤 · 한 줄). + * 키보드 — 찾기 칸에서 ↑ ↓ 로 옮기고 Enter 로 고름. 창 겉틀은 부르는 쪽 몫. + * + * const find = createFindList({ placeholder, filters: [sub.root, detail.root] }); + * find.setRows([{ key, tag, name, sub, unit, onPick }]); + * ========================================================================== */ + +import "./ui_template_find_list.css"; +import { createInputField, el } from "./ui_template_elements"; + +export interface FindRow { + /** 줄 표지(`data-pick`) */ + key: string; + name: string; + /** 앞 흐린 꼬리표(종류 등) */ + tag?: string; + /** 이름 뒤 흐린 보조 글(키 · 구분 등) */ + sub?: string; + /** 끝 흐린 글(단위 등) */ + unit?: string; + onPick: () => void; +} + +export interface FindList { + root: HTMLElement; + search: HTMLInputElement; + hint: HTMLElement; + setRows: (rows: FindRow[]) => void; +} + +export function createFindList(opts: { + placeholder: string; + filters?: HTMLElement[]; + /** 찾기 칸과 알림 줄 사이(층 단추 등) */ + extra?: HTMLElement[]; +}): FindList { + const field = createInputField({ type: "search", placeholder: opts.placeholder }); + const hint = el("p", { className: "ui-find__hint" }); + const list = el("div", { className: "ui-find__list", attrs: { role: "listbox" } }); + let rows: FindRow[] = []; + let at = -1; + + const mark = (i: number): void => { + list.children[at]?.classList.remove("is-active"); + at = i; + const row = list.children[at]; + row?.classList.add("is-active"); + row?.scrollIntoView({ block: "nearest" }); + }; + field.input.addEventListener("keydown", (ev) => { + if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { + ev.preventDefault(); + if (!rows.length) return; + const step = ev.key === "ArrowDown" ? 1 : -1; + mark((at + step + rows.length) % rows.length); + } else if (ev.key === "Enter" && rows[at]) { + ev.preventDefault(); + rows[at].onPick(); + } + }); + + const setRows = (next: FindRow[]): void => { + rows = next; + at = -1; + list.replaceChildren( + ...rows.map((r, i) => { + const row = el("button", { + className: "ui-find__row", + attrs: { type: "button", role: "option", "data-pick": r.key, title: r.name }, + children: [ + ...(r.tag + ? [ + el("span", { + className: "ui-find__tag", + attrs: { "data-pick-kind": "" }, + text: r.tag, + }), + ] + : []), + el("span", { className: "ui-find__name", text: r.name }), + el("span", { className: "ui-find__sub", text: r.sub ?? "" }), + el("span", { className: "ui-find__unit", text: r.unit ?? "" }), + ], + }); + row.addEventListener("click", r.onPick); + row.addEventListener("mousemove", () => at !== i && mark(i)); + return row; + }), + ); + if (rows.length) mark(0); + }; + + const filters = opts.filters?.length + ? [el("div", { className: "ui-find__filters", children: opts.filters })] + : []; + const root = el("div", { + className: "ui-find", + children: [...filters, field.root, ...(opts.extra ?? []), hint, list], + }); + return { root, search: field.input, hint, setRows }; +} diff --git a/ui_template/ui_template_side_panels.ts b/ui_template/ui_template_side_panels.ts index 2041a5c04..93aa4c17c 100644 --- a/ui_template/ui_template_side_panels.ts +++ b/ui_template/ui_template_side_panels.ts @@ -185,10 +185,18 @@ function scroller(node: HTMLElement): HTMLElement | null { */ function followView(node: HTMLElement): void { const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침 + // 다시 붙음 · 칸 내용이 늦게 차 칸 크기가 바뀜(스크롤 없이) → 다시 잼 + const sized = new ResizeObserver(() => place()); + let watched: Element | null = null; const place = (): void => { const root = ref.deref(); if (!root) return stop(); if (!root.isConnected) return; + if (root.parentElement && root.parentElement !== watched) { + if (watched) sized.unobserve(watched); + watched = root.parentElement; + sized.observe(watched); + } const box = scroller(root)?.getBoundingClientRect(); const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0; const view = Math.max(head, box?.top ?? 0); @@ -206,8 +214,10 @@ function followView(node: HTMLElement): void { const stop = (): void => { window.removeEventListener("scroll", place, true); window.removeEventListener("resize", place); + sized.disconnect(); }; window.addEventListener("scroll", place, true); window.addEventListener("resize", place); + sized.observe(node); setTimeout(place); // 붙은 뒤 한 번 }