diff --git a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts index 2883c6835..ed0cd9dd0 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts @@ -106,7 +106,6 @@ export function createUnitPick(opts: UnitPickOptions): HTMLElement { const open = createButton({ label: opts.value || NO_UNIT, variant: "ghost", - size: "sm", disabled: opts.readOnly, onClick: () => toggle(), }); diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 486b55002..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 @@ -219,13 +221,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_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 d6a92347d..5477edeb0 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 { @@ -37,6 +37,8 @@ export interface LibraryPick { layer: Layer; owner: string | null; pick: (ref: LaborRef, name: string) => void; + /** 서고 줄만(사전조합 상세 [추가] · 51-6) — 단가산출 로직 · 구분 거름 없음 */ + only?: boolean; } /** 고를 수 있는 층 — 위 층만(마스터 → 마스터 · 회사 → 그 회사 · 마스터 · 개인 → 그 개인 · 그 회사 · 마스터) */ @@ -65,9 +67,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 +108,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 +179,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: [...(lib?.only ? [] : [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 +219,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 +229,14 @@ 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(); + if (lib?.only) return; 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_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 48ed9576c..21272f3c5 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"], @@ -119,6 +113,17 @@ 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"], + 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 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..913a9877c 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -323,3 +323,37 @@ 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] + + +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-1_오른쪽패널_높이.js b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js new file mode 100644 index 000000000..ba3fabdd3 --- /dev/null +++ b/resources/tester/ui_checks/51-1_오른쪽패널_높이.js @@ -0,0 +1,54 @@ +/* 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 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const sp = [...document.querySelectorAll(".ui-side-panels")].find( + (s) => !s.classList.contains("is-collapsed") && s.getClientRects().length, + ); + if (!sp) return JSON.stringify({ error: "펴진 패널 없음" }); + const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0; + const scroller = (() => { + for (let n = sp.parentElement; n; n = n.parentElement) { + const y = getComputedStyle(n).overflowY; + if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n; + } + return document.scrollingElement; + })(); + const max = scroller.scrollHeight - scroller.clientHeight; + const sample = async (name, y) => { + scroller.scrollTop = y; + window.dispatchEvent(new Event("scroll")); // 뒤에 가린 탭은 스크롤 알림이 안 와서 직접 + await w(150); + const r = sp.getBoundingClientRect(); + const want = Math.round( + innerHeight - Math.max(head, sp.parentElement.getBoundingClientRect().top), + ); + const body = sp.querySelector(".ui-side-panels__panel:not([hidden]) .ui-side-panels__body"); + return { + name, + top: Math.round(r.top), + bottom: Math.round(r.bottom), + h: Math.round(r.height), + want, + inView: r.top >= head - 2 && r.bottom <= innerHeight + 2, + bodyScrolls: body ? body.scrollHeight > body.clientHeight : null, + }; + }; + const was = scroller.scrollTop; + const rows = [ + await sample("처음", 0), + await sample("중간", Math.round(max / 2)), + await sample("끝", max), + ]; + scroller.scrollTop = was; + return JSON.stringify({ + innerHeight, + head: Math.round(head), + scrollMax: Math.round(max), + rows, + ok: rows.every((r) => Math.abs(r.h - r.want) <= 2 && r.inView), + }); +})(); 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); +})(); 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; + } +})(); diff --git a/resources/tester/ui_checks/51-5_단위단추_크기.js b/resources/tester/ui_checks/51-5_단위단추_크기.js new file mode 100644 index 000000000..9fd87f7d3 --- /dev/null +++ b/resources/tester/ui_checks/51-5_단위단추_크기.js @@ -0,0 +1,35 @@ +/* 51-5 — 단위 고르기 단추(공용 M02_DF_UnitPick) 높이 · 글자 = [저장] 단추와 같음 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: item1 → 일위대가 [수정] 상세 → 단위 단추 · [저장] 단추 높이 · 글자 크기 잼 + * 기대: ok = true (|unit.h − save.h| ≤ 1 · 글자 같음) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + document.querySelector('[data-node="i|item1"]')?.click(); + await w(2500); + const card = [...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(); + await w(3000); + const read = (b) => + b && { + h: Math.round(b.getBoundingClientRect().height * 10) / 10, + font: getComputedStyle(b).fontSize, + }; + const unit = read(document.querySelector('[data-labor-field="단위"] .ui-btn')); + const save = read( + [...document.querySelectorAll("button")].find( + (b) => b.innerText.trim() === "저장" && b.offsetParent, + ), + ); + return JSON.stringify({ + unit, + save, + ok: !!unit && !!save && Math.abs(unit.h - save.h) <= 1 && unit.font === save.font, + }); +})(); 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; + } +})(); diff --git a/ui_template/ui_template_side_panels.ts b/ui_template/ui_template_side_panels.ts index 8ba93babb..2041a5c04 100644 --- a/ui_template/ui_template_side_panels.ts +++ b/ui_template/ui_template_side_panels.ts @@ -180,7 +180,7 @@ function scroller(node: HTMLElement): HTMLElement | null { } /** - * 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 칸 · 본문을 굴려도 그 자리(몸만 따로 스크롤) + * 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 창 높이 − 머리띠(칸이 아래에 있으면 칸 위부터) · 본문을 굴려도 그 자리(몸만 따로 스크롤) * CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼 */ function followView(node: HTMLElement): void { @@ -191,13 +191,16 @@ function followView(node: HTMLElement): void { if (!root.isConnected) return; const box = scroller(root)?.getBoundingClientRect(); const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0; - const top = Math.max(head, box?.top ?? 0) + 8; - const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight) - 8; + const view = Math.max(head, box?.top ?? 0); + const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight); const row = root.parentElement?.getBoundingClientRect(); if (!row) return; - const height = Math.max(240, bottom - top); + // 칸이 아직 화면 아래에 있으면 칸 위에 맞춤 · 올라가 붙으면 보이는 칸 전체 높이 + const top = Math.max(view, row.top); + // 칸 바닥(+ 쪽 아래 여백)을 넘지 않음 · 너무 줄면 최소 높이로 두고 칸 바닥에 맞춰 올라감 + const height = Math.max(240, Math.min(bottom, row.bottom + 48) - top); root.style.height = `${height}px`; - const shift = Math.min(Math.max(0, top - row.top), Math.max(0, row.height - height)); + const shift = Math.min(Math.max(0, view - row.top), Math.max(0, row.height + 48 - height)); root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : ""; }; const stop = (): void => {