From 9d9955631f3445dfe0cb50bbf75ef8d870b288da Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:39:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EC=84=9C=EA=B3=A0=20=E2=80=94?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=ED=95=AD=EB=AA=A9=20=EB=91=98=20=C2=B7?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EB=AA=A9=EB=A1=9D=ED=91=9C(51-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 컨테이너 = 층 단추 + 「단가산출 승격」 · 「일위대가 사전조합」 항목만 · 새로 · 본떠 · 지우기 단추 없앰 - 항목 누름 = 상세 칸 목록표(고름 · 키 · 이름 · 단위 · 기준 금액 · 고친 날) · 줄 누름 = 그 문서 상세 - 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 까닭 알림) - 층 목록 서고 줄에 1 단위 기준 계(`기준금액`) 실음 · 시험 한 개 · 화면 시나리오 51-4 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M02_MasterTemplete/M02_Labor_Ref.py | 21 +- .../M02_MasterTemplete_Labor_Text.ts | 10 + .../M02_MasterTemplete_Library_List.ts | 229 +++++++++++++++++ .../M02_MasterTemplete_Router_Layers.py | 4 +- .../M02_MasterTemplete_UI_Main.ts | 13 + .../M02_MasterTemplete_UI_Side.ts | 8 +- .../M02_MasterTemplete_UI_Side_Precombo.ts | 240 +++--------------- .../M02_MasterTemplete_UI_Style.css | 20 +- resources/tester/test_m02_labor_ref.py | 15 ++ .../tester/ui_checks/51-4_서고_목록표.js | 153 +++++++++++ 10 files changed, 487 insertions(+), 226 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Library_List.ts create mode 100644 resources/tester/ui_checks/51-4_서고_목록표.js diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 486b55002..6e3c9d0e0 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -219,13 +219,28 @@ def create(kind: str, doc: dict[str, Any]) -> dict[str, Any]: raise _bad("새 키를 못 잡음 — 다시 누름", 409) -def summary(folder: Path, kind: str, key: str) -> dict[str, Any]: - """층 목록 한 줄에 붙일 것 — `{제목, 단위}`.""" +def summary(folder: Path, kind: str, key: str, expand: Expand | None = None) -> dict[str, Any]: + """층 목록 한 줄에 붙일 것 — `{제목, 단위}` · `expand` 면 1 단위 기준 계(`기준금액` · 51-4).""" try: doc = json.loads((Path(folder) / kind / f"{key}.json").read_bytes()) except (OSError, ValueError): return {} - return {"제목": doc.get("이름"), "단위": doc.get("단위")} + out = {"제목": doc.get("이름"), "단위": doc.get("단위")} + if expand is not None: + layer, owner = where(folder) + base = expand({"층": layer, "주인": owner, "키": key}).get("기준") or {} + out["기준금액"] = float(base["계"]) if "계" in base else None + return out + + +def summaries(folder: Path, rows: list[dict[str, Any]]) -> None: + """층 목록의 서고 줄마다 `summary`(값 포함) — 한 번 셈 캐시를 함께 씀.""" + from M01_MasterData import M01_MasterData_Store as m01 + + expand = Expand(m01.loaded()[1]) + for row in rows: + if row["종류"] in layers.LIBRARY_KINDS: + row.update(summary(folder, row["종류"], row["이름"], expand)) # ── 쓰는 곳 · 지우기 ────────────────────────────────────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 48ed9576c..46ea3b85c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -119,6 +119,16 @@ const TEXT = { In_Cancel: ["취소", "Cancel"], In_Close: ["닫기", "Close"], Lib_Name: ["이름", "Name"], + // 서고 목록표(51-4) + Lib_Promote: ["단가산출 승격", "Promoted logic"], + Lib_PrecomboAll: ["일위대가 사전조합", "Pre-combination"], + Lib_Create: ["생성", "Create"], + Lib_Copy: ["복사", "Copy"], + Lib_Delete: ["삭제", "Delete"], + Lib_Key: ["키", "Key"], + Lib_Unit: ["단위", "Unit"], + Lib_Base: ["기준 금액", "Base amount"], + Lib_Date: ["고친 날", "Modified"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts new file mode 100644 index 000000000..0b15d996f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Library_List.ts @@ -0,0 +1,229 @@ +/* ============================================================================= + * M02_MasterTemplete_Library_List.ts + * 일위대가 조합 서고 목록표(51-4) — 좌측 항목(단가산출 승격 · 일위대가 사전조합)을 누르면 상세 칸에 뜸. + * 표 = 고름 · 키 · 이름 · 단위 · 기준 금액(서버 1 단위 계) · 고친 날 · 줄 누름 = 그 문서 상세. + * 머리 [생성] · [복사](고른 줄) · [삭제](고른 줄 · 쓰는 곳 있으면 서버 409 까닭 그대로). + * ========================================================================== */ + +import { + createButton, + createInputField, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; +import { + deleteLayerTemplate, + deleteTemplate, + getActiveOwner, + listTemplates, + newLibrary, + readTemplate, + saveTemplate, + type Layer, + type LibraryBody, + type TemplateInfo, +} from "./M02_MasterTemplete_Api_Fetch"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import type { PrecomboKind } from "./M02_MasterTemplete_UI_Side_Precombo"; + +/** 목록 줄 — 서버가 문서 `이름`(제목) · 단위 · 1 단위 기준 계를 실어 보냄 */ +type Row = TemplateInfo & { 제목?: string | null; 단위?: string | null; 기준금액?: number | null }; + +export interface LibraryListOptions { + layer: Layer; + kind: PrecomboKind; + canEdit: boolean; + /** 줄 누름 · 생성 · 복사 뒤 — 그 문서 상세 */ + open: (key: string, label: string) => void; +} + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const money = (v: number | null | undefined): string => + v == null ? "" : v.toLocaleString("ko-KR", { maximumFractionDigits: 2 }); +export const libraryLabel = (row: Row): string => (row.제목 ? `${row.이름} ${row.제목}` : row.이름); + +/** `tools` = 상세 머리 단추 자리(페이지 slot) · 돌려준 `destroy` 는 안 씀(칸째 비움) */ +export function mountLibraryList( + host: HTMLElement, + tools: HTMLElement, + opt: LibraryListOptions, +): { getDoc: () => null; destroy: () => void } { + let rows: Row[] = []; + let picked: string | null = null; + const body = el("tbody"); + const btnNew = createButton({ label: tc("Lib_Create"), variant: "filled" }); + const btnCopy = createButton({ label: tc("Lib_Copy"), variant: "ghost" }); + const btnDel = createButton({ label: tc("Lib_Delete"), variant: "danger" }); + const paintButtons = (): void => { + btnCopy.disabled = btnDel.disabled = !rows.some((r) => r.이름 === picked); + }; + + const draw = (): void => { + body.replaceChildren( + ...(rows.length + ? rows.map((row) => { + const radio = el("input", { + attrs: { type: "radio", name: `m02-lib-${opt.kind}`, "aria-label": row.이름 }, + }) as HTMLInputElement; + radio.checked = row.이름 === picked; + const pick = el("td", { className: "m02-lib__pick", children: [radio] }); + pick.addEventListener("click", (event) => { + event.stopPropagation(); // 고름 칸은 열지 않음 + picked = row.이름; + radio.checked = true; + paintButtons(); + }); + const tr = el("tr", { + className: "m02-lib__row", + attrs: { "data-lib-key": row.이름 }, + children: [ + pick, + ...[row.이름, row.제목 ?? "", row.단위 ?? "", money(row.기준금액), row.수정일].map( + (v) => el("td", { text: v, attrs: { title: v } }), + ), + ], + }); + tr.addEventListener("click", () => opt.open(row.이름, libraryLabel(row))); + return tr; + }) + : [ + el("tr", { + children: [el("td", { text: L("M02_NoTemplates"), attrs: { colspan: "6" } })], + }), + ]), + ); + paintButtons(); + }; + + const load = async (): Promise => { + try { + rows = (await listTemplates(opt.layer, null)).filter((r) => r.종류 === opt.kind) as Row[]; + } catch (error) { + rows = []; + showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error"); + } + draw(); + }; + + const nameDialog = (title: string, start: string, onOk: (name: string) => Promise) => { + const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); + name.input.value = start; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal", + mount: (box, close) => { + const ok = createButton({ label: L("M02_Create"), variant: "filled" }); + ok.addEventListener("click", async () => { + const text = name.input.value.trim(); + if (!text) return showToast(L("M02_NameNeeded"), "error"); + ok.disabled = true; // 연달아 눌러도 한 번만 + try { + await onOk(text); + close(); + } catch (error) { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } finally { + ok.disabled = false; + } + }); + name.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) ok.click(); + }); + box.append(name.root, ok); + }, + }); + }; + + /** 새로 — 서버가 그 층 다음 키로 씀 → 그 문서 상세 */ + const made = (key: string, label: string): void => { + showToast(L("M02_Created"), "success"); + opt.open(key, label); + }; + const create = async (body: LibraryBody, label: string): Promise => + made((await newLibrary(opt.layer, opt.kind, body)).이름, label); + + /** 단가승격 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것 · 사전조합 = 이름만 · 빈 묶음 */ + btnNew.addEventListener("click", () => { + if (opt.kind === "promote") + return openLogicPicker((x) => { + const doc = { + 이름: x.이름, + 단위: x.결과단위 || null, + 담은로직: [{ 로직: x.키, 입력값: {} }], + }; + void create({ 문서: doc }, x.이름).catch((error) => + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"), + ); + }); + nameDialog(L("M02_NewTitle"), "", (name) => + create({ 문서: { 이름: name, 단위: null, 담은로직: [] } }, name), + ); + }); + + /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ + btnCopy.addEventListener("click", () => { + const from = rows.find((r) => r.이름 === picked); + if (!from) return; + nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { + const got = await newLibrary(opt.layer, opt.kind, { + 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, + }); + const doc = await readTemplate(opt.layer, opt.kind, got.이름, null); + await saveTemplate(opt.layer, opt.kind, got.이름, null, got.판, { + ...(doc.문서 as object), + 이름: name, + }); + made(got.이름, name); + }); + }); + + btnDel.addEventListener("click", async () => { + await load(); // 판을 최신으로 + const info = rows.find((r) => r.이름 === picked); + if (!info) return; + const label = libraryLabel(info); + if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", label)))) return; + try { + if (opt.layer === "system") await deleteTemplate(opt.kind, info.이름, info.판); + else await deleteLayerTemplate(opt.layer, opt.kind, info.이름, info.판); + showToast(L("M02_Deleted"), "success"); + picked = null; + await load(); + } catch (error) { + // 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로 + showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error"); + } + }); + + const heads = ["", "Lib_Key", "Lib_Name", "Lib_Unit", "Lib_Base", "Lib_Date"] as const; + host.replaceChildren( + el("div", { + className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head", + attrs: { "data-lib-list": opt.kind }, + children: [ + el("table", { + className: "ui-general-block__table m02-lib__table", + children: [ + el("thead", { + children: [ + el("tr", { children: heads.map((h) => el("th", { text: h ? tc(h) : "" })) }), + ], + }), + body, + ], + }), + ], + }), + ); + if (opt.canEdit) + tools.replaceChildren( + el("span", { className: "m02-main__tools", children: [btnNew, btnCopy, btnDel] }), + ); + void load(); + return { getDoc: () => null, destroy: () => undefined }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index e44597522..303a250ae 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -279,8 +279,8 @@ async def list_layer( row["출처"] = manifest.get(f"{row['종류']}/{row['이름']}") if (row["종류"], row["이름"]) in store.IN_USE: # 화면이 지우기 확인 창 전에 막음 row["쓰는곳"] = store.IN_USE[(row["종류"], row["이름"])] - if row["종류"] in layers.LIBRARY_KINDS: # 서고(50-3) — 보이는 이름 · 단위 - row.update(refs.summary(folder, row["종류"], row["이름"])) + # 서고(50-3) — 보이는 이름 · 단위 · 기준 금액(51-4) + await asyncio.to_thread(refs.summaries, folder, rows) return {"층": layer, "양식": rows} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 82d6282bb..7fe44d24a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -454,6 +454,19 @@ export function createMain( return empty(""); } title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a + if (isPrecombo(next.kind) && !next.name) { + // 서고 목록표(51-4) — 이름 "" · 저장할 것 없음 · 줄 누름 = 그 문서 상세 + save.hidden = true; + const { mountLibraryList } = await import("./M02_MasterTemplete_Library_List"); + if (mine !== seq) return; + editor = mountLibraryList(host, slot, { + layer: next.layer, + kind: next.kind, + canEdit: canEdit(next.layer, isAdmin), + open: (name, label) => void open({ ...next, name, label }), + }); + return; + } const stop = showLoading(host); // 0.3초 넘으면 상세 칸 안에 「불러오는 중」(PLAN 30-6) try { const got = await readTemplate(next.layer, next.kind, next.name, next.projectId); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 0fa16606e..7ceefa0f5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -137,12 +137,10 @@ export function buildSide(opt: SideOptions): SideHandle { // 일위대가 조합 서고(50-3) — 구조물 조합 바로 아래 · 층마다(마스터 · 회사 · 개인) const precombo = buildPrecomboSection({ layer, - canEdit: canEdit(layer, opt.isAdmin), bar: opt.precomboBar ?? [], confirmLeave: opt.confirmLeave, onOpen: opt.onOpen, - isOpen: (kind, name) => isPicked(kind, name), - refresh: () => refresh(), + openKind: () => (opt.getOpen()?.layer === layer ? (opt.getOpen()?.kind ?? null) : null), }); const precomboSec = section(PRECOMBO_TITLE, precombo.body); // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) @@ -221,7 +219,7 @@ export function buildSide(opt: SideOptions): SideHandle { * (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */ function paint(): void { paintList(); - precombo.paint(items); + precombo.paint(); paintButtons(); if (opt.getOpen()) options.clear(); if (opt.getOpen()) practice?.clear(); @@ -245,7 +243,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); - precombo.paint(items); + precombo.paint(); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 8773635dd..7c198a6df 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -1,240 +1,68 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side_Precombo.ts - * 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · - * 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) · - * 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기. - * 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음). - * 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(409 까닭). + * 좌측 「일위대가 조합」 컨테이너(50-3 ③ · 51-4) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · + * 항목 둘 「단가산출 승격」(promote) · 「일위대가 사전조합」(precombo)만. + * 항목 누름 = 상세 칸에 그 종류 목록표(`Library_List` · 생성 · 복사 · 삭제는 거기) · + * 표 줄 누름 = 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드). * ========================================================================== */ -import { - createButton, - createInputField, - el, - showConfirmDialog, - showToast, -} from "@ui/ui_template_elements"; -import { t as L } from "@ui/ui_template_locale"; -import { openModal } from "@ui/ui_template_modal"; -import { - deleteLayerTemplate, - deleteTemplate, - getActiveOwner, - newLibrary, - readTemplate, - saveTemplate, - type Kind, - type Layer, - type LibraryBody, - type TemplateInfo, -} from "./M02_MasterTemplete_Api_Fetch"; -import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { el } from "@ui/ui_template_elements"; +import type { Kind, Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; -/** 종류 · 화면 이름(= 문서 `종류` 글 · 키 SP- · SC- 는 서버가 줌) */ -const PARTS: { kind: PrecomboKind; label: string }[] = [ - { kind: "promote", label: "단가승격" }, - { kind: "precombo", label: "사전조합" }, -]; +const PARTS = [ + { kind: "promote", label: "Lib_Promote" }, + { kind: "precombo", label: "Lib_PrecomboAll" }, +] as const; export const PRECOMBO_TITLE = "일위대가 조합"; export const isPrecombo = (kind: Kind): kind is PrecomboKind => kind === "promote" || kind === "precombo"; -/** 목록 줄 — 서버가 문서 이름을 `제목` 으로 실어 보냄(없으면 키만) */ -type Info = TemplateInfo & { 제목?: string }; -export const precomboLabel = (info: Info): string => - info.제목 ? `${info.이름} ${info.제목}` : info.이름; - export interface PrecomboOptions { layer: Layer; - canEdit: boolean; /** 층 단추 줄(페이지가 만듦 · 누르면 그 층 화면 + 이 컨테이너 펼침) */ bar: HTMLElement[]; confirmLeave: () => Promise; onOpen: (sel: Selection | null) => void; - isOpen: (kind: Kind, name: string) => boolean; - /** 목록 다시 받음(좌측 전체) — 받은 뒤 `paint` 가 불림 */ - refresh: () => Promise; + /** 이 층에서 지금 열린 종류(목록이든 문서든) — 항목 고름 표시 */ + openKind: () => Kind | null; } export interface PrecomboHandle { body: HTMLElement; - /** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */ - paint: (items: TemplateInfo[]) => void; + paint: () => void; } -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); -const failed = (error: unknown): void => - showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); - export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { - let items: Info[] = []; - const lists = new Map(); - const buttons = new Map(); - const mine = (kind: PrecomboKind): Info[] => items.filter((i) => i.종류 === kind); - const current = (kind: PrecomboKind): Info | undefined => - mine(kind).find((i) => opt.isOpen(kind, i.이름)); - - const select = async (info: Info): Promise => { - if (!opt.isOpen(info.종류, info.이름) && !(await opt.confirmLeave())) return; - opt.onOpen({ - layer: opt.layer, - projectId: null, - kind: info.종류, - name: info.이름, - label: precomboLabel(info), + const items = PARTS.map((part) => { + const b = el("button", { + className: "m02-side__item ui-sidebar-row", + text: tc(part.label), + attrs: { type: "button", "data-precombo": part.kind }, }); - }; - - /** 새로 — 서버가 그 층 다음 키로 씀 → 엶 */ - const create = async (kind: PrecomboKind, body: LibraryBody): Promise => { - const key = (await newLibrary(opt.layer, kind, body)).이름; - showToast(L("M02_Created"), "success"); - await opt.refresh(); - const info = mine(kind).find((i) => i.이름 === key); - if (info) await select(info); - }; - - const nameDialog = (title: string, start: string, onOk: (name: string) => Promise) => { - const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") }); - name.input.value = start; - openModal({ - title, - closeLabel: L("M02_Close"), - dialogClass: "m02-modal", - mount: (body, close) => { - const ok = createButton({ label: L("M02_Create"), variant: "filled" }); - ok.addEventListener("click", async () => { - const text = name.input.value.trim(); - if (!text) return showToast(L("M02_NameNeeded"), "error"); - ok.disabled = true; // 연달아 눌러도 한 번만 - try { - await onOk(text); - close(); - } catch (error) { - failed(error); - } finally { - ok.disabled = false; - } - }); - name.input.addEventListener("keydown", (event) => { - if (event.key === "Enter" && !event.isComposing) ok.click(); - }); - body.append(name.root, ok); - }, - }); - }; - - /** 단가승격 새로 = 단가산출 로직 고르기 → 이름 · 단위는 로직 것(입력값은 상세에서 고정) */ - const newPromote = (): void => - openLogicPicker((x) => { - void opt.confirmLeave().then((go) => - !go - ? undefined - : create("promote", { - 문서: { - 이름: x.이름, - 단위: x.결과단위 || null, - 담은로직: [{ 로직: x.키, 입력값: {} }], - }, - }).catch(failed), - ); - }); - /** 사전조합 새로 = 이름만 · 빈 묶음(승격 · 사전조합은 상세 [로직 추가]로) */ - const newPrecombo = (): void => - nameDialog(L("M02_NewTitle"), "", async (name) => { + // 이름 "" = 목록표(UI_Main) + b.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; - await create("precombo", { 문서: { 이름: name, 단위: null, 담은로직: [] } }); - }); - - /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ - const copy = (kind: PrecomboKind): void => { - const from = current(kind); - if (!from) return; - nameDialog(L("M02_CopyTitle"), `${from.제목 ?? from.이름} (2)`, async (name) => { - if (!(await opt.confirmLeave())) return; - const made = await newLibrary(opt.layer, kind, { - 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, + opt.onOpen({ + layer: opt.layer, + projectId: null, + kind: part.kind, + name: "", + label: tc(part.label), }); - const got = await readTemplate(opt.layer, kind, made.이름, null); - await saveTemplate(opt.layer, kind, made.이름, null, made.판, { - ...(got.문서 as object), - 이름: name, - }); - showToast(L("M02_Created"), "success"); - await opt.refresh(); - const info = mine(kind).find((i) => i.이름 === made.이름); - if (info) await select(info); }); - }; - - const remove = async (kind: PrecomboKind): Promise => { - await opt.refresh(); // 저장 직후여도 최신 판으로 - const info = current(kind); - if (!info) return; - const name = precomboLabel(info); - if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", name)))) return; - try { - if (opt.layer === "system") await deleteTemplate(kind, info.이름, info.판); - else await deleteLayerTemplate(opt.layer, kind, info.이름, info.판); - showToast(L("M02_Deleted"), "success"); - opt.onOpen(null); - await opt.refresh(); - } catch (error) { - // 쓰는 곳이 있으면 서버가 막음 — 까닭(쓰는 곳 이름) 그대로 - showToast(why(error) || L("M02_ActionFailed").replace("{value}", ""), "error"); - } - }; - - const block = (part: (typeof PARTS)[number]): HTMLElement => { - const list = el("div", { className: "m02-side__items ui-sidebar-list" }); - lists.set(part.kind, list); - const row: HTMLElement[] = []; - if (opt.canEdit) { - const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" }); - const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" }); - const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" }); - buttons.set(part.kind, { copy: btnCopy, del }); - btnNew.addEventListener("click", part.kind === "promote" ? newPromote : newPrecombo); - btnCopy.addEventListener("click", () => copy(part.kind)); - del.addEventListener("click", () => void remove(part.kind)); - row.push(el("div", { className: "m02-side__row", children: [btnNew, btnCopy, del] })); - } - return el("div", { - className: "m02-side__sub", - attrs: { "data-precombo": part.kind }, - children: [el("h4", { className: "m02-side__subtitle", text: part.label }), ...row, list], - }); - }; - + return b; + }); const body = el("div", { className: "m02-side__items ui-sidebar-list", - children: [...opt.bar, ...PARTS.map(block)], + children: [...opt.bar, ...items], }); - - function paint(all: TemplateInfo[]): void { - items = all.filter((i) => isPrecombo(i.종류)); - for (const part of PARTS) { - const found = mine(part.kind); - lists.get(part.kind)?.replaceChildren( - ...(found.length - ? found.map((info) => { - const b = el("button", { - className: `m02-side__item ui-sidebar-row${opt.isOpen(part.kind, info.이름) ? " is-active" : ""}`, - text: precomboLabel(info), - attrs: { type: "button", title: info.수정일, "data-precombo-key": info.이름 }, - }); - b.addEventListener("click", () => void select(info)); - return b; - }) - : [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]), - ); - const b = buttons.get(part.kind); - if (b) b.copy.disabled = b.del.disabled = !current(part.kind); - } - } - + const paint = (): void => { + const on = opt.openKind(); + items.forEach((b, i) => b.classList.toggle("is-active", PARTS[i].kind === on)); + }; return { body, paint }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index c232dcff0..09567f32b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -56,18 +56,18 @@ gap: var(--spacing-4); } -/* 일위대가 조합 서고(50-3) — 하위 단가승격 · 사전조합 */ -.m02-side__sub { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - margin-top: var(--spacing-8); +/* 일위대가 조합 서고 목록표(51-4) — 줄 누름 = 상세 · 첫 칸 = 고름 */ +.m02-lib__row { + cursor: pointer; } -.m02-side__subtitle { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); +.m02-lib__row:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.04)); +} + +.m02-lib__table .m02-lib__pick { + width: 2.5rem; + text-align: center; } .m02-main { diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 176e4c6af..d3b75fe12 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -323,3 +323,18 @@ def test_새_프로젝트_씨앗은_서고를_안_가져감(world, tmp_path): _promote(world["system"]) got = layers.seed_project(tmp_path / "p1") assert not any(k.startswith(layers.LIBRARY_KINDS) for k in got["작업본"]) + + +def test_층_목록_서고_줄은_이름_단위_기준금액을_실음(world): + sp = _promote(world["system"]) + sc = _combo(world["system"], [{"참조": {**SYS, "키": sp}, "수량": {"손": 3}}], "셋") + rows = [ + {"종류": "promote", "이름": sp}, + {"종류": "precombo", "이름": sc}, + {"종류": "labor", "이름": "rv"}, + ] + refs.summaries(world["system"], rows) + one = float(Decimal(str(_base(TEN)["계"])) / 10) + assert rows[0]["기준금액"] == pytest.approx(one) + assert (rows[1]["제목"], rows[1]["기준금액"]) == ("셋", pytest.approx(one * 3)) + assert "기준금액" not in rows[2] diff --git a/resources/tester/ui_checks/51-4_서고_목록표.js b/resources/tester/ui_checks/51-4_서고_목록표.js new file mode 100644 index 000000000..767aa03a9 --- /dev/null +++ b/resources/tester/ui_checks/51-4_서고_목록표.js @@ -0,0 +1,153 @@ +/* 51-4 — M02 좌측 「일위대가 조합」 = 층 단추 + 항목 둘 · 항목 누름 = 상세 목록표 · 머리 [생성] · [복사] · [삭제] + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 서고 목록 · 문서 읽기 · 지우기는 가짜 응답(fetch 바꿔 끼움 · 끝에 되돌림) · 새로 창은 열고 닫기만 + * 기대 수치: + * - side.items = ["단가산출 승격", "일위대가 사전조합"] · side.otherButtons = 0(새로 · 본떠 · 지우기 없음) + * - promote.heads = ["", "키", "이름", "단위", "기준 금액", "고친 날"] · rows = 2 · base = ["1,234.5", ""] + * - promote.tools = ["생성", "복사", "삭제"] · disabledBefore = [false, true, true] · disabledAfter = [false, false, false] + * - promote.active = true(좌측 항목 고름) · picker = true(생성 = 로직 고르기 창) + * - blocked = 쓰는 곳 까닭 알림 · rowsAfterBlocked = 2 + * - open = { title: "SP-999901 시험 승격", name: "시험 승격" }(줄 누름 = 그 문서 상세) + * - precombo.rows = 1 · dialog = true(생성 = 이름 창) + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 4000) => { + for (let t = 0; t < ms; t += 100) { + const v = fn(); + if (v) return v; + await wait(100); + } + return null; + }; + const rows = [ + { + 종류: "promote", + 이름: "SP-999901", + 판: "a", + 수정일: "2026-10-05T10:00:00", + 제목: "시험 승격", + 단위: "㎡", + 기준금액: 1234.5, + }, + { + 종류: "promote", + 이름: "SP-999902", + 판: "b", + 수정일: "2026-10-05T11:00:00", + 제목: "값 없음", + 단위: null, + 기준금액: null, + }, + { + 종류: "precombo", + 이름: "SC-999901", + 판: "c", + 수정일: "2026-10-05T12:00:00", + 제목: "시험 묶음", + 단위: "개소", + 기준금액: 10, + }, + ]; + const doc = { + 종류: "단가승격", + 키: "SP-999901", + 이름: "시험 승격", + 단위: "㎡", + 담은로직: [{ 로직: "L-0001", 입력값: {} }], + }; + const json = (body, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const real = window.fetch; + window.fetch = async (input, init = {}) => { + const url = String(input?.url ?? input); + const method = (init.method ?? "GET").toUpperCase(); + if (method === "GET" && /\/m02\/layers\/system\/templates(\?|$)/.test(url)) { + const got = await (await real(input, init)).json(); + return json({ ...got, 양식: [...got.양식.filter((r) => !/^S[PC]-/.test(r.이름)), ...rows] }); + } + if (method === "GET" && url.includes("/templates/promote/SP-999901")) + return json({ 종류: "promote", 이름: "SP-999901", 판: "a", 문서: doc }); + if (method === "DELETE" && /SP-9999/.test(url)) + return json({ detail: "쓰는 곳이 있어 못 지움 「SP-999901」 — 일위대가 시험" }, 409); + return real(input, init); + }; + try { + const sec = [...document.querySelectorAll(".m02-side__lists > section")].find( + (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() === "일위대가 조합", + ); + const items = [...sec.querySelectorAll("[data-precombo]")]; + const side = { + items: items.map((b) => b.textContent), + otherButtons: [ + ...sec.querySelectorAll(".ui-collapsible__body button, .m02-side__items button"), + ].filter((b) => !b.matches("[data-precombo], .m02-layers__btn")).length, + }; + const tools = () => [...document.querySelectorAll(".m02-main__slot .m02-main__tools button")]; + const list = (kind) => document.querySelector(`[data-lib-list="${kind}"]`); + // 단가산출 승격 + items[0].click(); + await until(() => list("promote")?.querySelectorAll("[data-lib-key]").length); + const L = list("promote"); + const promote = { + title: document.querySelector(".m02-main__title")?.textContent, + active: items[0].classList.contains("is-active"), + heads: [...L.querySelectorAll("th")].map((x) => x.textContent), + rows: L.querySelectorAll("[data-lib-key]").length, + base: [...L.querySelectorAll("[data-lib-key]")].map((tr) => tr.children[4].textContent), + tools: tools().map((b) => b.textContent), + disabledBefore: tools().map((b) => b.disabled), + }; + L.querySelector('[data-lib-key="SP-999901"] .m02-lib__pick').click(); + await wait(100); + promote.disabledAfter = tools().map((b) => b.disabled); + promote.stillList = !!list("promote"); // 고름 칸 누름은 안 엶 + // [생성] = 로직 고르기 창 → 닫기 + tools()[0].click(); + await wait(400); + const pick = document.querySelector(".m01-logic__pick"); + promote.picker = !!pick; + pick?.closest(".m01-logic__backdrop")?.remove(); + // [삭제] — 확인 창 [확인] → 쓰는 곳 409 까닭 알림 + tools()[2].click(); + const okBtn = await until(() => + document.querySelector( + ".ui-confirm__actions .ui-button--danger, .ui-confirm__actions button:last-child", + ), + ); + okBtn?.click(); + const toast = await until(() => + [...document.querySelectorAll(".ui-toast")] + .map((x) => x.textContent) + .find((t) => t?.includes("쓰는 곳")), + ); + const blocked = toast ?? null; + const rowsAfterBlocked = list("promote")?.querySelectorAll("[data-lib-key]").length ?? 0; + // 줄 누름 = 그 문서 상세 + list("promote").querySelector('[data-lib-key="SP-999901"] td:nth-child(3)').click(); + const nameInput = await until(() => document.querySelector('[data-labor-field="이름"] input')); + const open = { + title: document.querySelector(".m02-main__title")?.textContent, + name: nameInput?.value ?? null, + sideActive: items[0].classList.contains("is-active"), + }; + // 일위대가 사전조합 + items[1].click(); + await until(() => list("precombo")?.querySelectorAll("[data-lib-key]").length); + const precombo = { + rows: list("precombo")?.querySelectorAll("[data-lib-key]").length ?? 0, + active: items[1].classList.contains("is-active") && !items[0].classList.contains("is-active"), + }; + tools()[0].click(); + await wait(300); + const modal = document.querySelector(".m02-modal"); + precombo.dialog = !!modal; + modal?.querySelector(".ui-modal__top button")?.click(); + await wait(200); + precombo.dialogClosed = !document.querySelector(".m02-modal"); + return JSON.stringify({ side, promote, blocked, rowsAfterBlocked, open, precombo }); + } finally { + window.fetch = real; + } +})();