From b1a5f38a1655bca483b02bbdee9bc52842985718 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 18:42:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=2050-3=E2=91=A2=20=EC=9D=BC=EC=9C=84?= =?UTF-8?q?=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9=20=EC=84=9C=EA=B3=A0=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80=20=E2=80=94=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=8A=B9=EA=B2=A9=20=C2=B7=20=EC=82=AC=EC=A0=84?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=84=9C=EA=B3=A0=20=EB=AA=A8=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