From b1a5f38a1655bca483b02bbdee9bc52842985718 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:42:50 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(M02):=2050-3=E2=91=A2=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EC=84=9C?= =?UTF-8?q?=EA=B3=A0=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=E2=80=94=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A8=EA=B0=80=EC=8A=B9=EA=B2=A9=20=C2=B7=20?= =?UTF-8?q?=EC=82=AC=EC=A0=84=EC=A1=B0=ED=95=A9=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=EC=84=B8=20=EC=84=9C=EA=B3=A0=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 구조물 조합 아래 「일위대가 조합」 컨테이너 · 층 단추(진입 open=일위대가조합) · 새로 · 복사 · 지우기 - 단가승격 새로 = 로직 고르기 → SP- 키 저장 · 사전조합 새로 = 이름만 → SC- 키 - 상세 = 일위대가 상세 서고 모드(이름 칸 · 로직 표 · 원문만 · 자재 · 면적 · 다이어그램 없음) - 낡은 글 찾기 시험 셋을 지금 코드에 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_Api_Fetch.ts | 5 +- .../M02_MasterTemplete_Labor_Detail.ts | 57 ++++- .../M02_MasterTemplete_Labor_Text.ts | 1 + .../M02_MasterTemplete_Layer.ts | 4 +- .../M02_MasterTemplete_UI_Main.ts | 20 +- .../M02_MasterTemplete_UI_Page.ts | 6 +- .../M02_MasterTemplete_UI_Side.ts | 26 +- .../M02_MasterTemplete_UI_Side_Precombo.ts | 240 ++++++++++++++++++ .../M02_MasterTemplete_UI_Style.css | 14 + resources/tester/test_m02_card_shell.py | 4 +- resources/tester/test_m02_labor_tree.py | 6 +- resources/tester/test_m02_layer_screen.py | 2 +- 12 files changed, 350 insertions(+), 35 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index e0569ac5c..a56806e1f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -9,8 +9,9 @@ import { API_BASE_URL } from "@config/config_frontend"; export type Layer = "system" | "company" | "personal" | "project"; /** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing · 「구조물 도면」 = structure · * 「면적산출」 = area(구조물집계표 열마다 한 통합문서) · - * 「일위대가」 = labor(열마다 문서 · 39장) */ -export type Kind = "table" | "drawing" | "structure" | "area" | "labor"; + * 「일위대가」 = labor(열마다 문서 · 39장) · + * 「단가승격」 = promote · 「사전조합」 = precombo(일위대가 조합 서고 · 50-3 · 키가 이름) */ +export type Kind = "table" | "drawing" | "structure" | "area" | "labor" | "promote" | "precombo"; export interface TemplateInfo { 종류: Kind; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 0e34d21ee..c3069f8b5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -7,6 +7,8 @@ * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 + * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · + * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; @@ -64,6 +66,8 @@ export interface LaborDetailOptions { /** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 · * 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */ card?: boolean; + /** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */ + mode?: "promote" | "precombo"; } export interface LaborDetailHandle { @@ -82,6 +86,8 @@ export function mountLaborDetail( const cur = normalizeLabor(doc, opt.column); const saved = JSON.stringify(cur); const edit = !opt.readOnly; + const lib = opt.mode ?? null; + const promote = lib === "promote"; const logics = new Map(); let calc: LaborCalc | null = null; let fail: string | null = null; @@ -135,7 +141,7 @@ export function mountLaborDetail( const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); const drawDg = (): void => { - if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 + if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음 const data = buildLaborDiagram({ rows: cur.담은로직, logics: [...logics.values()], @@ -161,7 +167,7 @@ export function mountLaborDetail( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) - edit + edit && !promote ? { open: pick, option: openRow, @@ -415,6 +421,16 @@ export function mountLaborDetail( ], }); + // 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름) + const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); + nameField.root.dataset.laborField = "이름"; + nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); + nameField.input.readOnly = !edit; + nameField.input.addEventListener("input", () => { + (cur as { 이름?: string }).이름 = nameField.input.value.trim(); + opt.onChanged(dirty()); + }); + const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( @@ -429,13 +445,17 @@ export function mountLaborDetail( el("section", { className: "m01lab__box m01-logic__section", children: [ - el("div", { className: "m01lab__info", children: [unit.root, origin.root] }), + el("div", { + className: "m01lab__info", + children: [...(lib ? [nameField.root] : []), unit.root, origin.root], + }), ], }), ]), - section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost), - section(tc("Labor_Mats"), opt.card ? null : addMat, matHost), - ...(opt.card ? [] : [dgBox]), + // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 고르기는 고르기 창 탭(50-3 ④) + section(tc("Logics_Title"), opt.card || lib ? null : addLogic, logicHost), + ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), + ...(opt.card || lib ? [] : [dgBox]), ], }), ), @@ -444,19 +464,28 @@ export function mountLaborDetail( // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 void Promise.all([ loadLogics(), - readTemplate(opt.layer, "area", opt.column, null).then( - (got) => { - area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; - links = areaLinks(got.문서); - }, - () => undefined, - ), + lib // 서고 모드엔 면적산출 없음 + ? undefined + : readTemplate(opt.layer, "area", opt.column, null).then( + (got) => { + area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; + links = areaLinks(got.문서); + }, + () => undefined, + ), ]).then(() => { if (alive) recalc(); }); return { - getDoc: () => cur, + // 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음 + getDoc: () => { + if (!lib) return cur; + const out: Record = { ...cur, 종류: promote ? "단가승격" : "사전조합" }; + delete out.열; + delete out.자재; + return out as unknown as LaborDoc; + }, saveArea: async () => { if (!area || !areaDirty) return false; const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index db14ee6bc..16f8cb5c2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -105,6 +105,7 @@ const TEXT = { In_Ok: ["확인", "OK"], In_Cancel: ["취소", "Cancel"], In_Close: ["닫기", "Close"], + Lib_Name: ["이름", "Name"], } as const satisfies Record; export type ComboTextKey = keyof typeof TEXT; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index 47fd72a35..43a7b6b86 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -16,7 +16,7 @@ import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_A export type ManageLayer = "system" | "company" | "personal"; /** 해시 · 화면의 뿌리 이름 — 서버 층 이름은 `layerOfRoot` */ export type Root = "master" | "company" | "personal"; -export type OpenTarget = "구조물조합" | "옵션" | "도면"; +export type OpenTarget = "구조물조합" | "옵션" | "도면" | "일위대가조합"; export interface Entry { root: Root; @@ -27,7 +27,7 @@ export interface Entry { company: string | null; } -const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면"]; +const OPENS: OpenTarget[] = ["구조물조합", "옵션", "도면", "일위대가조합"]; export const layerOfRoot = (root: Root): ManageLayer => (root === "master" ? "system" : root); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 37867bab2..6ebe70b4c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -31,6 +31,7 @@ import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel"; import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing"; import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp"; import { helpParts } from "./M02_MasterTemplete_CardShell"; +import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; export interface Selection { @@ -378,19 +379,24 @@ export function createMain( editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy }; return; } - if (at.kind === "labor") { - // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 + if (at.kind === "labor" || isPrecombo(at.kind)) { + // 일위대가 상세(39-3) — 구조물 열마다 하나 · 고침은 이 화면 [저장]으로 · + // 단가승격 · 사전조합(50-3) = 같은 상세 모드(도면 · 면적 · 자재 없음 · 이름 = 키) const { mountLaborDetail } = await import("./M02_MasterTemplete_Labor_Detail"); + const mode = isPrecombo(at.kind) ? at.kind : undefined; const labor = mountLaborDetail(host, doc, { layer: at.layer, column: at.name, readOnly, onChanged: (d) => (dirty = d && !readOnly), - goArea: async () => { - if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로 - void open({ ...at, kind: "area" }); - return true; - }, + goArea: mode + ? null + : async () => { + if (!(await confirmLeave())) return false; // 취소 — 입력 모달 그대로 + void open({ ...at, kind: "area" }); + return true; + }, + ...(mode ? { mode } : {}), }); // [저장] = 엑셀에서 고른 칸 연결(면적 문서) 먼저 · 그다음 일위대가(서버가 새 칸 값으로 계산값) editor = { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index f3a44130f..7eebb2002 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -105,13 +105,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void): HTMLElement => + const layerBar = (open: OpenTarget, add: HTMLElement | null, reselect: () => void): HTMLElement => createLayerBar({ user, active: { root: entry.root, owner }, company, go: (next, nextOwner) => void go(next, nextOwner, open), - add, + ...(add ? { add } : {}), reselect, }); // 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6) @@ -184,6 +184,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void main.openTable(layer)), peopleTree("구조물조합", tree.root), ], + // 일위대가 조합(50-3) — 같은 층 단추 · 누르면 그 층 화면에서 이 컨테이너를 펼침 + precomboBar: [layerBar("일위대가조합", null, () => undefined)], }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 12561432e..0fa16606e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「도면 양식」(새로 · 본떠 · 지우기). + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(층 단추 · 나무) · 「일위대가 조합」(층 단추 · 단가승격 · 사전조합 · + * `UI_Side_Precombo` · 50-3) · 「도면 양식」(새로 · 본떠 · 지우기). * 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2). * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. @@ -31,6 +32,7 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { buildOptionsSection } from "./StructOptions/M02_SO_Options"; import { buildPracticeSection } from "./M02_MasterTemplete_Practice"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; +import { buildPrecomboSection, PRECOMBO_TITLE } from "./M02_MasterTemplete_UI_Side_Precombo"; const KINDS: { kind: Kind; label: () => string }[] = [ { kind: "table", label: () => L("M02_KindTable") }, @@ -58,6 +60,8 @@ export interface SideOptions { showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ tree?: HTMLElement[]; + /** 「일위대가 조합」 줄 아래 층 단추(50-3 · 페이지가 만듦) */ + precomboBar?: HTMLElement[]; } export interface SideHandle { @@ -66,7 +70,7 @@ export interface SideHandle { /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 옵션 · 도면은 시스템 층에만 */ - reveal: (target: "옵션" | "도면") => void; + reveal: (target: "옵션" | "도면" | "일위대가조합") => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -130,6 +134,17 @@ export function buildSide(opt: SideOptions): SideHandle { openOnExpand(summary, async () => { if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null); }); + // 일위대가 조합 서고(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(), + }); + const precomboSec = section(PRECOMBO_TITLE, precombo.body); // 구조물 옵션 정의 · 도면 양식은 시스템 층(마스터 템플릿)에만 · 정의 고쳐 씀은 시스템 관리자만 · 다른 역할은 읽기만(PLAN 48-2) const extras = layer === "system" @@ -151,6 +166,7 @@ export function buildSide(opt: SideOptions): SideHandle { className: "m02-side__lists ui-sidebar-panel", children: [ summary, + precomboSec, ...(extras ? [extras.옵션, extras.도면].filter((s): s is HTMLElement => !!s) : []), ...(practice ? [section(practice.title, practice.body)] : []), ], @@ -205,6 +221,7 @@ export function buildSide(opt: SideOptions): SideHandle { * (양식이 열려 있으면 옵션 · 일위대가 조합 고름은 지움) */ function paint(): void { paintList(); + precombo.paint(items); paintButtons(); if (opt.getOpen()) options.clear(); if (opt.getOpen()) practice?.clear(); @@ -228,6 +245,7 @@ export function buildSide(opt: SideOptions): SideHandle { if (mine !== seq) return; paintList(); paintButtons(); + precombo.paint(items); } /* --- 시스템 층 새로 · 본떠 · 지우기(도면 양식만 — 구조물 도면은 집계표 열이 정함) --- */ @@ -307,8 +325,8 @@ export function buildSide(opt: SideOptions): SideHandle { } } - const reveal = (target: "옵션" | "도면"): void => { - const sec = extras?.[target]; + const reveal = (target: "옵션" | "도면" | "일위대가조합"): void => { + const sec = target === "일위대가조합" ? precomboSec : extras?.[target]; if (!sec) return; // 옵션 보이기는 시스템 관리자만 sec.classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts new file mode 100644 index 000000000..67c0f220f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -0,0 +1,240 @@ +/* ============================================================================= + * M02_MasterTemplete_UI_Side_Precombo.ts + * 좌측 「일위대가 조합」 컨테이너(50-3 ③) — 구조물 조합 아래 · 층 단추(페이지가 만듦) · + * 하위 둘 「단가승격」(promote · 단가산출 로직 하나를 입력값 고정해 일위대가 꼴로) · + * 「사전조합」(precombo · 승격 + 다른 사전조합 묶음) — 목록 · 새로 · 복사 · 지우기. + * 상세는 편집 칸(UI_Main 종류 promote · precombo = 일위대가 상세 모드 · 도면 · 면적 · 자재 없음). + * 파일 이름 = 키(SP- · SC-) · 화면 이름 = 문서 `이름`(목록 `제목`) · 쓰는 곳 있으면 서버가 지우기를 막음(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, + readTemplate, + saveTemplate, + type Kind, + type Layer, + type TemplateInfo, +} from "./M02_MasterTemplete_Api_Fetch"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +export type PrecomboKind = Extract; + +/** 종류 · 키 머리 · 화면 이름(= 문서 `종류` 글) */ +const PARTS: { kind: PrecomboKind; prefix: string; label: string }[] = [ + { kind: "promote", prefix: "SP", label: "단가승격" }, + { kind: "precombo", prefix: "SC", label: "사전조합" }, +]; +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.이름; + +/** 그 층 다음 키 — 지금 목록의 가장 큰 수 + 1(서버가 같은 키면 409 로 막음) */ +const nextKey = (prefix: string, items: Info[]): string => { + const top = items.reduce((max, i) => { + const m = new RegExp(`^${prefix}-(\\d+)$`).exec(i.이름); + return m ? Math.max(max, Number(m[1])) : max; + }, 0); + return `${prefix}-${String(top + 1).padStart(6, "0")}`; +}; + +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; +} + +export interface PrecomboHandle { + body: HTMLElement; + /** 좌측이 받은 층 목록 중 승격 · 사전조합만 그림 */ + paint: (items: TemplateInfo[]) => 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 create = async (kind: PrecomboKind, doc: Record): Promise => { + const key = nextKey(PARTS.find((p) => p.kind === kind)!.prefix, mine(kind)); + await saveTemplate(opt.layer, kind, key, null, "", doc); + 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) => { + if (!(await opt.confirmLeave())) return; + await create("precombo", { 종류: "사전조합", 이름: name, 단위: null, 담은로직: [] }); + }); + + /** 복사 = 같은 층 새 키 · 이름 「(2)」 · 본뜬 = 원래 키 */ + 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 got = await readTemplate(opt.layer, kind, from.이름, null); + const doc: Record = { ...(got.문서 as object), 이름: name }; + doc.본뜬 = { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }; + delete doc.판; + await create(kind, doc); + }); + }; + + 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], + }); + }; + + const body = el("div", { + className: "m02-side__items ui-sidebar-list", + children: [...opt.bar, ...PARTS.map(block)], + }); + + 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); + } + } + + return { body, paint }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 6791fc2a9..c232dcff0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -56,6 +56,20 @@ gap: var(--spacing-4); } +/* 일위대가 조합 서고(50-3) — 하위 단가승격 · 사전조합 */ +.m02-side__sub { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-top: var(--spacing-8); +} + +.m02-side__subtitle { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + .m02-main { display: flex; flex-direction: column; diff --git a/resources/tester/test_m02_card_shell.py b/resources/tester/test_m02_card_shell.py index 0cd36812d..57faaba79 100644 --- a/resources/tester/test_m02_card_shell.py +++ b/resources/tester/test_m02_card_shell.py @@ -79,7 +79,9 @@ def test_일위대가_카드는_상세_카드_갈래(): assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀 detail = _text("M02_MasterTemplete_Labor_Detail.ts") assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로 - assert "opt.card ? null : addLogic" in detail and "...(opt.card ? [] : [dgBox])," in detail + # 서고 모드(50-3 · lib)도 더하기 · 다이어그램 없음 + assert "opt.card || lib ? null : addLogic" in detail + assert "...(opt.card || lib ? [] : [dgBox])," in detail assert "opt.card ? null : addMat" in detail # 49-6 — 카드엔 [자재 추가] 없음 · 상세는 그대로 rows = _text("M02_MasterTemplete_Labor_Rows.ts") assert "const tools = !!(move || remove || option);" in rows and "compactTable" not in rows diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 4675902fc..ff2f7bb97 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -46,7 +46,7 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음(): assert "buildAllItems" not in page and "onAll" not in page and "ALL_TITLE" not in page assert "buildAllItems" not in _text("M02_MasterTemplete_ItemList.ts") api = _text("M02_MasterTemplete_Api_Fetch.ts") - assert '| "labor";' in api and "일위대가번: string;" in api + assert '| "labor" |' in api and "일위대가번: string;" in api locale = (ROOT / "ui_template" / "ui_template_locale_m2.ts").read_text("utf-8") for gone in ("M02_ItemsAll", "M02_KindLabor", "M02_KindStructure", "M02_KindArea"): assert gone not in locale, gone @@ -57,7 +57,9 @@ def test_좌측_하위_컨테이너와_전체_항목_길_없음(): def test_층_단추는_구조물_조합_한_벌(): page = _text("M02_MasterTemplete_UI_Page.ts") - assert page.count('layerBar("') == 1 # 구조물 조합 줄 아래 하나 + # 구조물 조합 줄 아래 하나 + 일위대가 조합 서고(50-3) 하나 + assert page.count('layerBar("') == 2 + assert 'layerBar("일위대가조합", null, () => undefined)' in page assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 3892125c6..963adb174 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -87,7 +87,7 @@ def test_주소_인자를_라우트에서_뗌(): # 진입 해시 `?open=&root=&owner=`(PLAN 27-1d) · 옛 `?layer=` 도 root 로 assert 'query.get("root") ?? query.get("layer")' in layer assert 'raw === "company" || raw === "personal" ? raw : "master"' in layer - assert '["구조물조합", "옵션", "도면"]' in layer # 일위대가 = 구조물 조합 안(44-7) + assert '["구조물조합", "옵션", "도면", "일위대가조합"]' in layer # 일위대가 조합 서고 = 50-3 picker = _text(ROOT / "ui_template" / "ui_template_item_picker.ts") assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker From 027ad3da9833ea3d6922db5c918dbdd106b7dd1e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:45:01 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(M02):=2050-3=E2=91=A2=20=EC=84=9C?= =?UTF-8?q?=EA=B3=A0=20=EC=83=88=EB=A1=9C=20=C2=B7=20=EB=B3=B5=EC=82=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=20=ED=82=A4=20=EA=B8=B8=EB=A1=9C?= =?UTF-8?q?=20=E2=80=94=20POST=20/layers/{=EC=B8=B5}/library/{=EC=A2=85?= =?UTF-8?q?=EB=A5=98}=20=C2=B7=20=EC=B0=B8=EC=A1=B0=20=EC=A4=84=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새로 = 서버가 그 층 다음 SP · SC 키 · 복사 = 본뜬 원본 → 고른 이름으로 고쳐 씀 - 일위대가 문서 줄의 참조 {층, 주인, 키} 를 화면 캐시에서 안 버림 · 로직 입력 받기에서 참조 줄 건너뜀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../M02_MasterTemplete_Api_Fetch.ts | 13 +++++ .../M02_MasterTemplete_Labor_Api.ts | 3 + .../M02_MasterTemplete_Labor_Detail.ts | 2 +- .../M02_MasterTemplete_UI_Side_Precombo.ts | 56 +++++++++---------- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index a56806e1f..a76619d33 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -126,6 +126,19 @@ export const deleteLayerTemplate = ( method: "DELETE", }); +/** 서고 새로 · 복사(50-3) — 새로 = `문서` · 복사 = `본뜬` {층, 주인, 키} · 키는 서버가 줌(답 `이름`) */ +export type LibraryBody = + { 문서: Record } | { 본뜬: { 층: Layer; 주인: string | null; 키: string } }; +export const newLibrary = ( + layer: Layer, + kind: "promote" | "precombo", + body: LibraryBody, +): Promise => + call(owned(layer, `/layers/${layer}/library/${kind}`), { + method: "POST", + body: JSON.stringify(body), + }); + /* --- 구조물 도면(종류 structure · 이름 = 구조물집계표 열 id) --- * 읽기 · 저장 · 지우기는 위 readTemplate · saveTemplate · deleteTemplate 에 "structure" */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 5e7b50172..21cfd6835 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -126,6 +126,8 @@ export interface LaborRow { 메모: string | null; 입력값: Record; 수량: LaborQty; + /** 서고 참조 줄(50-3 · 사전조합 · 로직 없음) — 단가승격 · 사전조합 {층, 주인, 키} */ + 참조?: { 층: string; 주인: string | null; 키: string }; } export interface LaborMat { 코드: string; @@ -159,6 +161,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 메모: r.메모 ?? null, 입력값: r.입력값 ?? {}, 수량: r.수량 ?? null, + ...(r.참조 ? { 참조: r.참조 } : {}), })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index c3069f8b5..8176a4bed 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -283,7 +283,7 @@ export function mountLaborDetail( /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { - const want = cur.담은로직.map((r) => r.로직).filter((k) => !logics.has(k)); + const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); // 참조 줄 = 로직 없음 if (!want.length) return; try { const got = await fetchLogicInputs(want); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts index 67c0f220f..8773635dd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side_Precombo.ts @@ -20,10 +20,12 @@ 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"; @@ -31,10 +33,10 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main"; export type PrecomboKind = Extract; -/** 종류 · 키 머리 · 화면 이름(= 문서 `종류` 글) */ -const PARTS: { kind: PrecomboKind; prefix: string; label: string }[] = [ - { kind: "promote", prefix: "SP", label: "단가승격" }, - { kind: "precombo", prefix: "SC", label: "사전조합" }, +/** 종류 · 화면 이름(= 문서 `종류` 글 · 키 SP- · SC- 는 서버가 줌) */ +const PARTS: { kind: PrecomboKind; label: string }[] = [ + { kind: "promote", label: "단가승격" }, + { kind: "precombo", label: "사전조합" }, ]; export const PRECOMBO_TITLE = "일위대가 조합"; export const isPrecombo = (kind: Kind): kind is PrecomboKind => @@ -45,15 +47,6 @@ type Info = TemplateInfo & { 제목?: string }; export const precomboLabel = (info: Info): string => info.제목 ? `${info.이름} ${info.제목}` : info.이름; -/** 그 층 다음 키 — 지금 목록의 가장 큰 수 + 1(서버가 같은 키면 409 로 막음) */ -const nextKey = (prefix: string, items: Info[]): string => { - const top = items.reduce((max, i) => { - const m = new RegExp(`^${prefix}-(\\d+)$`).exec(i.이름); - return m ? Math.max(max, Number(m[1])) : max; - }, 0); - return `${prefix}-${String(top + 1).padStart(6, "0")}`; -}; - export interface PrecomboOptions { layer: Layer; canEdit: boolean; @@ -95,10 +88,9 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { }); }; - /** 새로 · 복사 — 다음 키로 저장 후 엶 */ - const create = async (kind: PrecomboKind, doc: Record): Promise => { - const key = nextKey(PARTS.find((p) => p.kind === kind)!.prefix, mine(kind)); - await saveTemplate(opt.layer, kind, key, null, "", doc); + /** 새로 — 서버가 그 층 다음 키로 씀 → 엶 */ + 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); @@ -142,10 +134,11 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { !go ? undefined : create("promote", { - 종류: "단가승격", - 이름: x.이름, - 단위: x.결과단위 || null, - 담은로직: [{ 로직: x.키, 입력값: {} }], + 문서: { + 이름: x.이름, + 단위: x.결과단위 || null, + 담은로직: [{ 로직: x.키, 입력값: {} }], + }, }).catch(failed), ); }); @@ -153,20 +146,27 @@ export function buildPrecomboSection(opt: PrecomboOptions): PrecomboHandle { const newPrecombo = (): void => nameDialog(L("M02_NewTitle"), "", async (name) => { if (!(await opt.confirmLeave())) return; - await create("precombo", { 종류: "사전조합", 이름: name, 단위: null, 담은로직: [] }); + await create("precombo", { 문서: { 이름: name, 단위: null, 담은로직: [] } }); }); - /** 복사 = 같은 층 새 키 · 이름 「(2)」 · 본뜬 = 원래 키 */ + /** 복사 = 같은 층 새 키(서버 · 본뜬 = 원래 키) → 고른 이름으로 고쳐 씀 */ 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 got = await readTemplate(opt.layer, kind, from.이름, null); - const doc: Record = { ...(got.문서 as object), 이름: name }; - doc.본뜬 = { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }; - delete doc.판; - await create(kind, doc); + const made = await newLibrary(opt.layer, kind, { + 본뜬: { 층: opt.layer, 주인: getActiveOwner(), 키: from.이름 }, + }); + 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); }); }; From 6430b65036f711738fa56d4f5383e962fcaba2ce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:07:38 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(M02):=2050-3=E2=91=A2=20=EC=84=9C?= =?UTF-8?q?=EA=B3=A0=20=ED=99=94=EB=A9=B4=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=E2=80=94=20=EC=A2=8C=EC=B8=A1=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=EC=B0=A8=EB=A1=80=20=C2=B7=20=EC=B8=B5=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=C2=B7=20=EC=83=88=EB=A1=9C=20=EC=B0=BD=20?= =?UTF-8?q?=C2=B7=20=EC=83=81=EC=84=B8=20=EC=84=9C=EA=B3=A0=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../ui_checks/50-3_일위대가조합_서고.js | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 resources/tester/ui_checks/50-3_일위대가조합_서고.js diff --git a/resources/tester/ui_checks/50-3_일위대가조합_서고.js b/resources/tester/ui_checks/50-3_일위대가조합_서고.js new file mode 100644 index 000000000..2e8c23c96 --- /dev/null +++ b/resources/tester/ui_checks/50-3_일위대가조합_서고.js @@ -0,0 +1,97 @@ +/* 50-3 ③ — M02 좌측 「일위대가 조합」 서고 컨테이너 · 상세 서고 모드 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=일위대가조합&root=master) · 시스템 관리자 + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) + * 쓰기 없음 — 새로 창은 열고 닫기만 · 상세 모드는 떼어 둔 칸에 가짜 문서로 붙여 봄(서버 calc 만 부름) + * 기대 수치: + * - side.order = ["구조물 조합", "일위대가 조합", …] · side.open = true(진입 open=일위대가조합 이 펼침) + * - side.layerButtons ≥ 1 · side.subs = ["promote", "precombo"] · side.newButtons = 2(관리자) + * - picker.opened = true(단가승격 [새로] = 로직 고르기 창) · precomboDialog = { opened: true, closed: true } + * - detail.promote = { name: true, mats: false, diagram: false, add: false, rows: 1, source: true } + * - detail.precombo = { name: true, mats: false, diagram: false, add: false, rows: 0 } + */ +(async () => { + const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + const sections = [...document.querySelectorAll(".m02-side__lists > section")]; + const title = (s) => s.querySelector(".ui-collapsible__title")?.textContent?.trim() ?? ""; + const lib = sections.find((s) => title(s) === "일위대가 조합"); + const side = { + order: sections.map(title), + open: !!lib && !lib.classList.contains("is-collapsed"), + layerButtons: lib?.querySelectorAll(".m02-layers__btn").length ?? 0, + subs: [...(lib?.querySelectorAll("[data-precombo]") ?? [])].map((x) => x.dataset.precombo), + newButtons: [...(lib?.querySelectorAll(".m02-side__row") ?? [])].length, + rows: lib?.querySelectorAll("[data-precombo-key]").length ?? 0, + }; + const btn = (kind, i) => + lib?.querySelector(`[data-precombo="${kind}"] .m02-side__row`)?.querySelectorAll("button")[i]; + // 단가승격 [새로] = 로직 고르기 창 → 닫기 + btn("promote", 0)?.click(); + await wait(300); + const pick = document.querySelector(".m01-logic__pick"); + const picker = { opened: !!pick }; + pick?.closest(".m01-logic__backdrop")?.remove(); + // 사전조합 [새로] = 이름 창 → 닫기(Escape) + btn("precombo", 0)?.click(); + await wait(300); + const modal = document.querySelector(".m02-modal"); + const precomboDialog = { opened: !!modal }; + modal?.querySelector(".ui-modal__top button")?.click(); // [닫기] + await wait(200); + + // 상세 서고 모드 — 떼어 둔 칸(화면 밖)에 가짜 문서 + // 모듈 주소 = 페이지가 받은 UI_Side 주소에서 파일 이름만 바꿈(vite 뿌리 밖 `/@fs/…`) + const sideUrl = performance + .getEntriesByType("resource") + .map((e) => e.name) + .find((n) => n.includes("M02_MasterTemplete_UI_Side.ts")); + const { mountLaborDetail } = await import( + sideUrl.replace(/M02_MasterTemplete_UI_Side\.ts.*$/, "M02_MasterTemplete_Labor_Detail.ts") + ); + const probe = async (mode, doc) => { + const host = document.createElement("div"); + host.style.cssText = "position:fixed;left:-9999px;top:0;width:900px"; + document.body.append(host); + const h = mountLaborDetail(host, doc, { + layer: "system", + column: doc.키, + readOnly: false, + onChanged: () => {}, + goArea: null, + mode, + }); + await wait(400); + const out = { + name: !!host.querySelector('[data-labor-field="이름"] input'), + nameValue: host.querySelector('[data-labor-field="이름"] input')?.value ?? null, + mats: [...host.querySelectorAll("h3")].some((x) => x.textContent === "자재목록"), + diagram: !!host.querySelector(".m02-labor__dg"), + add: [...host.querySelectorAll("button")].some((x) => x.textContent === "로직 추가"), + rows: host.querySelectorAll("[data-labor-logic]").length, + source: + !!host.querySelector("[data-labor-source]") || !!host.querySelector(".ui-side-panels"), + kind: h.getDoc().종류, + keepsColumn: "열" in h.getDoc() || "자재" in h.getDoc(), + }; + h.destroy(); + host.remove(); + return out; + }; + const detail = { + promote: await probe("promote", { + 종류: "단가승격", + 키: "SP-999999", + 이름: "시험 승격", + 단위: null, + 담은로직: [{ 로직: "L-0001", 입력값: {} }], + }), + precombo: await probe("precombo", { + 종류: "사전조합", + 키: "SC-999999", + 이름: "시험 사전조합", + 단위: null, + 담은로직: [], + }), + }; + precomboDialog.closed = !document.querySelector(".m02-modal"); + return JSON.stringify({ side, picker, precomboDialog, detail }); +})();