From c8686ee3ad4376acfcdcd3585cf8b5da4eac0e27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 13:35:38 +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=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=A2=8C=EC=B8=A1=20=E3=80=8C?= =?UTF-8?q?=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20=EC=A1=B0=ED=95=A9?= =?UTF-8?q?=E3=80=8D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=C2=B7=20?= =?UTF-8?q?=EB=BF=8C=EB=A6=AC=20=EC=85=8B=20=EB=82=98=EB=AC=B4=20=C2=B7=20?= =?UTF-8?q?=EC=A1=B0=ED=95=A9=20=ED=8E=B8=EC=A7=91=20=C2=B7=20M01=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=BA=8C(PLAN=2027-2b?= =?UTF-8?q?=20=C2=B7=2027-2c=20=ED=99=94=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 「일위대가 조합」(구조물 조합 위) → 메인 칸 왼쪽 뿌리 셋 + 조합 나무(폴더 [구분, 상세구분] 깊이 2 · TreePanel 공용) · 오른쪽 목록 · 상세(옛 M01 조합 화면 옮김 · Labor_Edit) - 나무 오른쪽 클릭: 폴더 더하기(2 단까지) · 조합 더하기(그 폴더 채운 빈 조합) · 이름 · 옮기기 · 지우기 · 조합 이름 바꾸기 서버 길 더함 - 해시 open=일위대가 로 진입 · 다른 뿌리를 누르면 그 뿌리 일위대가로 - 읽기만 뿌리는 저장 · 지우기 · 로직 더하기 없음 - TreePanel 에 maxDepth · item(부르는 말 · 새 항목 화면) 선택지 더함(구조물 라이브러리는 그대로) - 조합 고르기(면적산출 옆 목록 · 일위대가 고르기)가 이 층이 쓸 수 있는 세 층 조합 · 알약은 /api/m02/combos/key/{키}/inputs - M01 좌측 · 페이지에서 일위대가 조합 컨테이너 뺌 - 시험 test_m02_labor_tree(새) · test_m02_combo_layers 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 14 - M01_MasterData/M01_MasterData_UI_Page.ts | 34 +- M01_MasterData/M01_MasterData_UI_Side.ts | 24 +- .../M02_MasterTemplete_ComboPanel.ts | 9 +- .../M02_MasterTemplete_Combo_Store.py | 23 +- .../M02_MasterTemplete_Labor.ts | 156 ++++++++++ .../M02_MasterTemplete_Labor_Api.ts | 32 ++ .../M02_MasterTemplete_Labor_Edit.ts | 290 ++++++++---------- .../M02_MasterTemplete_Labor_Text.ts | 4 + .../M02_MasterTemplete_Labor_Tree.ts | 45 +++ .../M02_MasterTemplete_Router_Combos.py | 19 +- .../M02_MasterTemplete_Structure.ts | 23 +- .../M02_MasterTemplete_TreePanel.ts | 19 +- .../M02_MasterTemplete_UI_Page.ts | 35 ++- .../M02_MasterTemplete_UI_Side.ts | 23 +- resources/tester/test_m02_combo_layers.py | 14 + resources/tester/test_m02_labor_tree.py | 76 +++++ 17 files changed, 572 insertions(+), 268 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor.ts rename M01_MasterData/M01_MasterData_UI_Combo.ts => M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts (56%) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts create mode 100644 resources/tester/test_m02_labor_tree.py diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index f9105aff..c2eaeb33 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -176,20 +176,6 @@ export const fetchPick = ( q: string, ): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); -/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ -export interface ComboQuantity { - 키: string; - 이름: string; - 단위: string; - 로직: string[]; - 배?: number; -} - -export const fetchComboInputs = ( - combo: string, -): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> => - get(`/combo/${encodeURIComponent(combo)}/inputs`, {}); - /** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */ export const searchMaterials = async ( q: string, diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index 00882927..05e519d7 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Page.ts - * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직·일위대가 조합) + * 마스터 요소 화면 — 좌측 도킹 패널(컨테이너: 인력·재료·기계·소요량·계수·환율·요율·단가산출 로직) + * · 일위대가 조합은 M02 마스터 템플릿(PLAN 27-2b) * / 우측 요소 표 · 표 목록 · 로직 편집 * * 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 — @@ -38,7 +39,6 @@ import { loadTable } from "./M01_MasterData_UI_Logic_Note"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { renderTables } from "./M01_MasterData_UI_Tables"; import type { LogicHandle } from "./M01_MasterData_UI_LogicLab"; -import type { ComboHandle } from "./M01_MasterData_UI_Combo"; import "./M01_MasterData_UI_Style.css"; const TABLE_GROUPS = ["소요량", "계수"]; @@ -59,7 +59,6 @@ function buildPage(): HTMLElement { let query = ""; let dispose = (): void => {}; let logicMounted = false; - let comboHandle: ComboHandle | null = null; /* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */ const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") }); @@ -75,7 +74,6 @@ function buildPage(): HTMLElement { children: [title, searchField.root, summary, drop, save], }); const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); - const comboHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } }); const elementView = el("div", { className: "m01-master__elements", children: [ @@ -98,9 +96,8 @@ function buildPage(): HTMLElement { onDraftChange(refreshBar); refreshBar(); - const showMode = (mode: "elements" | "logic" | "combo"): void => { + const showMode = (mode: "elements" | "logic"): void => { logicHost.hidden = mode !== "logic"; - comboHost.hidden = mode !== "combo"; elementView.hidden = mode !== "elements"; }; @@ -206,27 +203,6 @@ function buildPage(): HTMLElement { }); }; - /** 「일위대가 조합」 — 목록을 새로 받아 그림 · 로직 줄을 누르면 단가산출 로직 탭으로 */ - const openComboTab = (): void => { - dispose(); - dispose = (): void => {}; - current = null; - showMode("combo"); - if (comboHandle) { - void comboHandle.show(); - return; - } - void import("./M01_MasterData_UI_Combo").then((m) => { - comboHandle = m.mountM01Combo( - comboHost, - side, - (key) => openLogicTab(key), - () => comboHost.hidden && openComboTab(), - ); - void comboHandle.show(); - }); - }; - /** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「단가산출 로직으로 돌아가기」를 둠 */ const jump = async (target: MasterJump): Promise => { let group = target.kind === "element" ? target.group : ""; @@ -267,7 +243,7 @@ function buildPage(): HTMLElement { setMasterJump((target) => void jump(target)); /* --- 좌측: 컨테이너 --- */ - const side = buildSide(openFile, () => openLogicTab(), openComboTab); + const side = buildSide(openFile, () => openLogicTab()); const layout = el("div", { className: "ui-workflow-layout m01-master" }); const main = el("main", { @@ -275,7 +251,7 @@ function buildPage(): HTMLElement { children: [ el("div", { className: "m01-master__panel", - children: [elementView, logicHost, comboHost], + children: [elementView, logicHost], }), ], }); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 39288ea1..1ac821ea 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_Side.ts * 왼쪽 패널 — 공용 접기 컨테이너(`ui-collapsible ui-sidebar-section`) 아홉 개 - * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직 · 일위대가 조합 + * 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 단가산출 로직(일위대가 조합은 M02 · PLAN 27-2b) * 펼치면 하위 목록(거름 · 파일) · 환율/요율은 누르면 바로 표 · 로직은 안에 로직 목록 * ========================================================================== */ @@ -10,7 +10,6 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { t as L } from "@ui/ui_template_locale"; import { fetchFiles, fetchSubsAll, type FileInfo, type SubInfo } from "./M01_MasterData_Api_Fetch"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { tc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Text"; import { renderTree, type MakeRow } from "./M01_MasterData_UI_Tree"; /** 컨테이너마다 기억하는 화면 상태 — 찾기 글 · 쪽 · 소요량·계수 표의 용도 대상 거름 (거름은 `m01.filter.*`) */ @@ -86,8 +85,6 @@ export interface SideHandle { root: HTMLElement; /** 단가산출 로직 컨테이너 안(로직 목록이 들어갈 자리) */ logicHost: HTMLElement; - /** 일위대가 조합 컨테이너 안(구분 · 상세구분 거름이 들어갈 자리) */ - comboHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; /** 그룹의 파일 목록(이미 받은 것) */ @@ -145,13 +142,8 @@ const LEAF: Group[] = ["환율", "요율"]; /** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */ const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"]; const LOGIC_ID = "로직|"; -const COMBO_ID = "조합|"; -export function buildSide( - onOpen: (pick: Pick) => void, - onLogic: () => void, - onCombo: () => void = () => {}, -): SideHandle { +export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle { const lists = new Map(); const bodies = new Map(); const buttons = new Map(); @@ -452,17 +444,9 @@ ${first.file}` onLogic(); }); - const comboHost = el("div", { className: "m01-side__items ui-sidebar-list" }); - const combo = section(tc("Title"), comboHost); - combo.querySelector(".ui-collapsible__title")?.addEventListener("click", () => { - if (!combo.classList.contains("is-collapsed")) return; - setActive(buttons.has(COMBO_ID) ? COMBO_ID : null); - onCombo(); - }); - const root = el("div", { className: "m01-side ui-sidebar-panel", - children: [...groups, logic, combo], + children: [...groups, logic], }); attachCollapsible(root); void Promise.all(GROUPS.map(refresh)).catch((error) => @@ -472,5 +456,5 @@ ${first.file}` onPicked = fn; }; const files = (group: string): FileInfo[] => lists.get(group as Group) ?? []; - return { root, logicHost, comboHost, refresh, files, setActive, setOnPicked, filter }; + return { root, logicHost, refresh, files, setActive, setOnPicked, filter }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts index 4d2d418b..77b43e1d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts @@ -4,7 +4,8 @@ * 공용 폭 손잡이(`ui_template_resizer` · 폭 기억 · 최소 폭) · 공용 접기 손잡이(`ui_template_overlay`). * · 「이 열의 조합」 — 면적 = 일위대가 조합 줄 + [조합 고치기](`M02_MasterTemplete_Combo.ts`) · * 재료 = 담은 자재 표(`M02_MasterTemplete_Materials.ts`) — 넣는 쪽이 `setMine` 으로 갈아 끼움 - * · 조합 목록 — 면적 = 일위대가 조합(UA) · 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 · + * · 조합 목록 — 면적 = 일위대가 조합(이 층이 쓸 수 있는 마스터 UA · 회사 UC · 개인 UP · 27-2c) · + * 재료 = 자재 조합(UM) · 찾기 · 이 열 조합 강조 · * 하나 골라 [이 열에 쓰기] = 집계표 그 열 키만 바꿈(서버 `use-combo` · 조합은 그대로) * ========================================================================== */ @@ -19,8 +20,8 @@ import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state"; -import { fetchCombos } from "./M02_MasterTemplete_Labor_Api"; -import { listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; +import { getActiveLayer, listMaterialCombos, setColumnCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; import "./M02_MasterTemplete_ComboPanel.css"; @@ -177,7 +178,7 @@ export function buildComboPanel(opts: ComboPanelOptions): ComboPanel { const [base, got] = await Promise.all([ readColumnBase(opts.column), area - ? fetchCombos().then((cs) => + ? fetchUsableCombos(getActiveLayer()).then((cs) => cs.map((c) => ({ 키: c.키, 이름: c.이름, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py index acd75edf..042c76e5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Store.py @@ -216,14 +216,17 @@ def _clean(row: Any, key: str, was: dict | None = None) -> dict[str, Any]: return one -def _check(doc: dict, one: dict) -> None: - """이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422.""" +def _name_errors(doc: dict, one: dict) -> list[str]: name = one["이름"] errors = [f"이름이 너무 김 — {mcb.MAX_이름}자까지"] if len(name) > mcb.MAX_이름 else [] if any(r.get("키") != one["키"] and str(r.get("이름", "")).strip() == name for r in doc["줄"]): errors.append(f"「{name}」 이름의 조합이 이미 있음 — 다른 이름으로") - errors += mcb.check_row(m01.loaded()[1], one) - if errors: + return errors + + +def _check(doc: dict, one: dict) -> None: + """이름 길이 · 겹침 · 담은 로직(없는 키 · 두 번 · 빈 조합) — 막히면 422.""" + if errors := _name_errors(doc, one) + mcb.check_row(m01.loaded()[1], one): raise store.StoreError(422, {"errors": errors}) @@ -316,7 +319,7 @@ def _fresh(name: Any) -> str: def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: - """나무 고침 한 번 → (새 표, 지운 조합 키). 조합 더하기 · 고치기는 `create` · `edit` 몫.""" + """나무 고침 한 번 → (새 표, 지운 조합 키) · 조합 더하기 · 담은 로직은 `create` · `edit` 몫.""" op, key = body.get("op"), body.get("키") folders, rows = folders_of(doc), [dict(r) for r in doc.get("줄") or []] gone: list[str] = [] @@ -330,6 +333,8 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: if made in folders: raise store.StoreError(409, f"이미 있는 폴더 「{' › '.join(made)}」") folders.append(made) + elif op == "rename" and key: + _find({"줄": rows}, str(key))["이름"] = _fresh(body.get("이름")) elif op == "move" and key: _set_path(_find({"줄": rows}, str(key)), target) elif op == "delete" and key: @@ -360,14 +365,18 @@ def _apply(doc: dict, body: dict) -> tuple[dict, list[str]]: def edit_tree(body: dict) -> dict[str, Any]: - """폴더 더하기 · 이름 바꾸기 · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · 조합) - → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409.""" + """폴더 더하기 · 이름 바꾸기(폴더 · 조합) · 옮기기(폴더 · 조합) · 지우기(폴더 = 안 조합까지 · + 조합) → 새 목록 · 쓰이는 조합이 들면 409 · 판 다르면 409 · 조합 이름 겹침 422.""" doc, have = _load() if have != (body.get("판") or ""): raise store.StoreError(409, {"stale": [FILE], "판": have}) new, gone = _apply(doc, body) _refuse_used(gone) if heads()[-1] != MASTER: + if body.get("op") == "rename" and body.get("키"): + one = _find(new, str(body["키"])) + if errors := _name_errors(new, one): + raise store.StoreError(422, {"errors": errors}) _save(new, have) return {**listing(), "지운조합": gone} was = {str(r["키"]): r for r in doc["줄"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts new file mode 100644 index 00000000..decf8f54 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor.ts + * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 누르면 메인 칸에 일위대가 라이브러리: + * 왼쪽 뿌리 셋(마스터 · 회사 · 개인 · `TreePanel_Roots`) 아래 조합 나무(폴더 [구분, 상세구분] 깊이 2 · + * 구조물 라이브러리와 같은 `TreePanel`) · 오른쪽 조합 목록 · 상세(`Labor_Edit`). + * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). + * ========================================================================== */ + +import { el, showConfirmDialog } from "@ui/ui_template_elements"; +import { ApiError } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { StaleError } from "./M02_MasterTemplete_Api_Fetch"; +import { + editComboTree, + fetchComboList, + type ComboList, + type ComboTreeEdit, + type LaborScope, +} from "./M02_MasterTemplete_Labor_Api"; +import { createLaborEditor } from "./M02_MasterTemplete_Labor_Edit"; +import { noteOf, toView } from "./M02_MasterTemplete_Labor_Tree"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; + +/** 나무 부품이 아는 막힘으로 — 판 다름 = `StaleError("HTTP 409")` · 나머지는 서버 까닭 글 */ +function treeError(error: unknown): Error { + if (!(error instanceof ApiError)) + return error instanceof Error ? error : new Error(String(error)); + const d = error.detail as { errors?: string[]; stale?: unknown } | string | null; + const text = typeof d === "string" ? d : d?.errors?.join(" · ") || `HTTP ${String(error.status)}`; + return error.status === 409 ? new StaleError(text) : new Error(text); +} + +export interface LaborLibraryOptions { + scope: LaborScope; + canEdit: boolean; + /** 뿌리 셋 — 고른 뿌리 아래에 이 나무(`panel`)를 붙인 것 */ + roots: (panel: HTMLElement) => HTMLElement; +} + +export interface LaborLibrary { + root: HTMLElement; + refresh: () => Promise; + /** 메인 칸 「저장 안 한 고침」 손잡이 */ + bind: (setDirty: (dirty: boolean) => void) => void; +} + +export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { + const { scope } = opt; + let setDirty: (dirty: boolean) => void = () => {}; + let keys = new Set(); // 지금 목록의 조합 키 — 열린 조합이 지워졌나 + const took = (list: ComboList): ComboList => { + keys = new Set(list.줄.map((c) => c.키)); + editor.setList(list.줄); + return list; + }; + const editor = createLaborEditor({ + scope, + canEdit: opt.canEdit, + onDirty: (dirty) => setDirty(dirty), + onSaved: async (key) => { + await tree.refresh(); + tree.select(key); + }, + }); + const tree = createTreePanel({ + layer: scope.layer, + owner: scope.owner, + label: tc("Title"), + canEdit: opt.canEdit, + maxDepth: 2, + item: { add: tc("AddCombo"), name: tc("ComboName"), onAdd: (path) => editor.startNew(path) }, + api: { + fetch: async () => { + return toView(took(await fetchComboList(scope))); + }, + edit: async (_layer, edit, 판) => { + const body = { ...edit, 판 } as ComboTreeEdit; + const got = await editComboTree(body, scope).catch((error: unknown) => { + throw treeError(error); + }); + return toView(took(got), noteOf(edit, got.지운조합)); + }, + }, + onPick: (item) => void editor.open(item.key), + // 나무를 고치기 전 — 저장 안 한 조합 고침은 버릴지 물음(버리면 목록으로) + settle: async () => { + if (!editor.isDirty()) return "clean"; + if (!(await showConfirmDialog(tc("BackAsk")))) return false; + editor.showList(); + return "changed"; + }, + // 나무가 바뀜 — 열린 조합은 새 판으로 다시 엶(이름 · 폴더가 바뀌었을 수 있음) · 지워졌으면 목록 + onChanged: async () => { + const open = editor.current(); + if (!open) return; + if (keys.has(open)) await editor.open(open); + else editor.showList(); + }, + }); + const aside = el("aside", { + className: "m02-shell__tree", + children: [ + el("h3", { className: "m02-shell__title", text: tc("Library") }), + opt.roots(tree.root), + ], + }); + return { + root: el("div", { className: "m02-shell m02-labor", children: [aside, editor.root] }), + refresh: () => tree.refresh(), + bind: (fn) => (setDirty = fn), + }; +} + +export interface LaborSectionOptions { + /** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */ + show: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 저장 안 한 고침을 버릴지 확인 — 취소면 false */ + confirmLeave: () => Promise; + /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ + onOpen: () => void; + make: () => LaborLibrary; +} + +export interface LaborSection { + title: string; + body: HTMLElement; + open: () => Promise; + /** 다른 화면을 열었음 — 고름 표시만 지움 */ + clear: () => void; +} + +export function buildLaborSection(opt: LaborSectionOptions): LaborSection { + const row = el("button", { + className: "m02-side__item ui-sidebar-row", + text: tc("Library"), + attrs: { type: "button", "data-labor-row": "" }, + }); + const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] }); + let library: LaborLibrary | null = null; + let opened = false; + const mark = (on: boolean): void => { + opened = on; + row.classList.toggle("is-active", on); + }; + + async function open(): Promise { + if (opened || !(await opt.confirmLeave())) return; + library ??= opt.make(); + library.bind(opt.show(tc("Library"), library.root)); + opt.onOpen(); + mark(true); + await library.refresh(); + } + row.addEventListener("click", () => void open()); + return { title: tc("Title"), body, open, clear: () => mark(false) }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 93da186e..fd3cb672 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -134,6 +134,38 @@ export const fetchCombos = (scope: LaborScope = SYSTEM_SCOPE): Promise => request("/combos/all"); +/** 조합 고르기(PLAN 27-2c) — 이 층 항목이 쓸 수 있는 조합: 마스터 · 회사(회사 · 개인 층) · 개인(개인 층) · + * 차례 = 마스터 → 회사 → 개인 · `layer` = 그 조합의 층 */ +export async function fetchUsableCombos( + layer: Layer, +): Promise<(ComboSummary & { layer: LaborScope["layer"] })[]> { + const usable: LaborScope["layer"][] = + layer === "personal" || layer === "project" + ? ["system", "company", "personal"] + : layer === "company" + ? ["system", "company"] + : ["system"]; + const all = await fetchAllCombos(); + return all + .filter((group) => usable.includes(group.layer)) + .flatMap((group) => group.줄.map((c) => ({ ...c, layer: group.layer }))); +} + +/** 조합 알약 물량 하나 — 키 = 「이름|단위」 · 담은 로직 결과단위 분모(같은 이름 · 단위는 합침) · `배` = 원/10㎡ 의 10 */ +export interface ComboQuantity { + 키: string; + 이름: string; + 단위: string; + 로직: string[]; + 배?: number; +} + +/** 조합 알약(어느 층이든 · 옛 M01 `/combo/{키}/inputs`) */ +export const fetchComboInputs = ( + key: string, +): Promise<{ 키: string; 이름: string; 물량: ComboQuantity[]; 조건: unknown[] }> => + request(`/combos/key/${enc(key)}/inputs`); + /** 키 하나 — 어느 층이든(그 층 읽기 권한) · `version` = 그 층 조합 표 판 */ export const fetchCombo = (key: string): Promise => request & LaborScope & { 판: string }>( diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts similarity index 56% rename from M01_MasterData/M01_MasterData_UI_Combo.ts rename to M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts index 2a41d4fc..c5178784 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Edit.ts @@ -1,8 +1,8 @@ /* ============================================================================= - * M01_MasterData_UI_Combo.ts - * 일위대가 조합 화면 — 왼쪽 컨테이너(구분 · 상세구분 거름) / 오른쪽 목록 · 상세 - * 상세 = 이름·구분·단위·비고 + 담은 로직 표(차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기 - * 저장·지우기는 서버 조합 마스터(`일위대가조합.json` · 키 UA) — 길은 `Combo_Api` 한 곳 + * M02_MasterTemplete_Labor_Edit.ts + * 일위대가 라이브러리 오른쪽 — 조합 목록(찾기) / 상세(이름 · 구분 · 상세구분 · 단위 · 비고 + 담은 로직 표 + * (차례 · 키 · 이름 · 결과 단위 · 메모 · 순서) + 미리 보기). 옛 M01 조합 화면을 옮겨 씀(PLAN 27-2b). + * 저장 · 지우기 = 고른 층 조합 표(마스터 UA · 회사 UC · 개인 UP · `Labor_Api`) · 읽기만 층은 단추 없음. * ========================================================================== */ import { @@ -12,30 +12,29 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api"; +import { ApiError, type LogicSummary } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { deleteCombo, editCombo, fetchCombo, - fetchCombos, + fetchLibLogics, newCombo, type Combo, type ComboSummary, -} from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Api"; -import { openLogicPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Pick"; -import { buildPreview } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Preview"; -import { tc } from "../M02_MasterTemplete/M02_MasterTemplete_Labor_Text"; -import type { SideHandle } from "./M01_MasterData_UI_Side"; -import "./M01_MasterData_UI_Logic_Style.css"; -import "./M01_MasterData_UI_LogicLab_Style.css"; -import "../M02_MasterTemplete/M02_MasterTemplete_Labor.css"; + type LaborScope, +} from "./M02_MasterTemplete_Labor_Api"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { buildPreview } from "./M02_MasterTemplete_Labor_Preview"; +import { tc } from "./M02_MasterTemplete_Labor_Text"; +import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; +import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; +import "./M02_MasterTemplete_Labor.css"; -const FILTER_ID = "조합|"; -const blankCombo = (): Combo => ({ +const blankCombo = (path: string[] = []): Combo => ({ 키: "", 이름: "", - 구분: null, - 상세구분: null, + 구분: path[0] ?? null, + 상세구분: path[1] ?? null, 단위: null, 담은로직: [], 출처: "자체", @@ -44,135 +43,75 @@ const blankCombo = (): Combo => ({ 비고: null, }); -/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */ -function reason(error: unknown): string { +/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 · 쓰는 곳 */ +export function reason(error: unknown): string { if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { const d = error.detail as { errors?: string[]; stale?: string[] }; // 서버 검사 글 머리(「파일.json · 키 」)는 떼고 보임 — 새로 만들 때 키는 아직 없는 번호라 헷갈림 if (d.errors) - return d.errors.map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} )?/, "")).join(" / "); + return d.errors + .map((x) => x.replace(/^\S+\.json · (U[AM]\d{6} |U[CP]\d+-\d{6} )?/, "")) + .join(" / "); if (d.stale) return tc("Stale"); } return error instanceof Error ? error.message : tc("Load_Failed"); } -export interface ComboHandle { - /** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */ - show: () => Promise; +export interface LaborEditOptions { + scope: LaborScope; + canEdit: boolean; + /** 저장 · 지움 뒤 — 나무 · 목록을 다시 받고 그 조합(`key`)을 고름 */ + onSaved: (key: string | null) => Promise; + /** 저장 안 한 고침이 있나 — 페이지가 떠날 때 물음 */ + onDirty: (dirty: boolean) => void; } -export function mountM01Combo( - host: HTMLElement, - side: SideHandle, - onOpenLogic: (key: string) => void, - /** 왼쪽 거름 · 찾기를 만지면 — 다른 화면이 떠 있으면 이 화면으로 */ - activate: () => void = () => {}, -): ComboHandle { +export interface LaborEditor { + root: HTMLElement; + /** 나무가 받은 목록 — 상세가 안 열려 있으면 목록을 다시 그림 */ + setList: (items: ComboSummary[]) => void; + open: (key: string) => Promise; + /** 나무 「조합 더하기」 — 그 폴더(구분 · 상세구분)를 채운 빈 조합 */ + startNew: (path: string[]) => void; + showList: () => void; + isDirty: () => boolean; + /** 열린 조합 키 — 목록이면 null · 새 조합이면 빈 글 */ + current: () => string | null; +} + +export function createLaborEditor(opt: LaborEditOptions): LaborEditor { + const edit = opt.canEdit; let items: ComboSummary[] = []; let logics = new Map(); - let pick = { sub: "", detail: "" }; /** 화면을 바꾼 횟수 — 늦게 온 답은 이 값이 달라졌으면 버림 */ let nav = 0; - /** 열어 둔 상세 — 다른 화면에 갔다 와도 그대로 돌아오게 기억 */ - /** `saved` = 연 때의 모양 — 다시 그려도 「저장 안 한 고침」 판정이 안 풀리게 */ + /** `saved` = 연 때의 모양 — 「저장 안 한 고침」 판정 */ let current: { combo: Combo; version: string; saved: string } | null = null; - let logicsReady: Promise = Promise.resolve(); let q = ""; - const filterHost = el("div"); + const view = el("div", { className: "m01-logic__editor m01-combo m02-labor__edit" }); const find = createInputField({ type: "search", placeholder: tc("Search") }); find.input.addEventListener("input", () => { q = find.input.value.trim().toLowerCase(); - activate(); if (!current) drawList(); }); - side.comboHost.replaceChildren(find.root, filterHost); - const view = el("div", { className: "m01-logic__editor m01-combo" }); - host.replaceChildren(view); - /** 목록만 받음 — 로직 이름표(1300여 개)는 따로 받아 목록을 붙잡지 않음 */ - const load = async (redraw = true): Promise => { - const my = nav; - try { - items = await fetchCombos(); - } catch (error) { - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); - } - drawFilter(); - if (redraw && my === nav) drawList(); - }; - - const loadLogics = (): void => { - logicsReady = fetchLogics() - .then((all) => { - logics = new Map(all.map((x) => [x.키, x])); - }) - .catch((error) => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), - ); - }; - - const show = async (): Promise => { - if (!logics.size) loadLogics(); - if (!items.length && !current) { - view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") })); - } - await load(false); - if (!current) drawList(); - await logicsReady; - if (current) draw(current.combo, current.version, current.saved); - }; - - /* --- 왼쪽 거름 --- */ - const drawFilter = (): void => { - const subs = [...new Set(items.map((x) => x.구분).filter((v): v is string => !!v))].map( - (name) => ({ - name, - book: null, - details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter( - (v): v is string => !!v, - ), - }), + const logicsReady = fetchLibLogics() + .then((all) => { + logics = new Map(all.map((x) => [x.키, x])); + }) + .catch((error) => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"), ); - filterHost.replaceChildren( - side.filter({ - id: FILTER_ID, - store: "m01.filter.조합", - subs, - total: items.length, - subLabel: tc("Sub"), - detailLabel: tc("Detail"), - onPick: (sub, detail) => { - pick = { sub, detail }; - activate(); - drawList(); - }, - }), - ); - }; + + const dirty = (): boolean => !!current && JSON.stringify(current.combo) !== current.saved; + const markDirty = (): void => opt.onDirty(dirty()); /* --- 목록 --- */ - const toolbar = (...more: HTMLElement[]): HTMLElement => - el("div", { - className: "m01-logic__section-head", - children: [ - createButton({ - label: tc("New"), - variant: "filled", - onClick: () => draw(blankCombo(), ""), - }), - ...more, - ], - }); - const drawList = (): void => { nav++; current = null; - const shown = items.filter( - (x) => - (!pick.sub || x.구분 === pick.sub) && - (!pick.detail || x.상세구분 === pick.detail) && - (!q || `${x.키} ${x.이름}`.toLowerCase().includes(q)), - ); + opt.onDirty(false); + const shown = items.filter((x) => !q || `${x.키} ${x.이름}`.toLowerCase().includes(q)); const heads = [ tc("Head_Key"), tc("Head_Name"), @@ -200,7 +139,15 @@ export function mountM01Combo( return tr; }); view.replaceChildren( - toolbar(), + el("div", { + className: "m01-logic__section-head", + children: [ + find.root, + ...(edit + ? [createButton({ label: tc("New"), variant: "filled", onClick: () => startNew([]) })] + : [el("span", { className: "m01-logic__muted", text: tc("ReadOnly") })]), + ], + }), shown.length ? el("div", { className: "m01-master__grid-wrap", @@ -232,19 +179,24 @@ export function mountM01Combo( } }; + const startNew = (path: string[]): void => draw(blankCombo(path), ""); + /* --- 상세 --- */ const draw = (combo: Combo, version: string, saved = JSON.stringify(combo)): void => { nav++; current = { combo, version, saved }; + opt.onDirty(dirty()); const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { const f = createInputField({ type: "text", label }); f.input.value = combo[name] ?? ""; + f.input.readOnly = !edit; f.input.setAttribute("data-combo-field", name); if (name === "이름") f.input.maxLength = 60; // 서버 한도(`MAX_이름`)와 같음 f.input.addEventListener("input", () => { const v = f.input.value; if (name === "이름") combo.이름 = v; else combo[name] = v || null; + markDirty(); }); return f.root; }; @@ -256,53 +208,39 @@ export function mountM01Combo( if (j < 0 || j >= combo.담은로직.length) return; [combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]]; drawTable(); + markDirty(); }; const trs = combo.담은로직.map((row, i) => { const info = logics.get(row.로직); const memo = createInputField({ type: "text" }); memo.input.value = row.메모 ?? ""; - memo.input.addEventListener("input", () => (row.메모 = memo.input.value || null)); - const name = el("button", { - className: "ui-btn ui-btn--ghost ui-btn--sm", - text: info?.이름 ?? row.로직, - attrs: { type: "button", title: tc("Open") }, - }); - name.addEventListener("click", async () => { - if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("LeaveAsk")))) return; - onOpenLogic(row.로직); + memo.input.readOnly = !edit; + memo.input.addEventListener("input", () => { + row.메모 = memo.input.value || null; + markDirty(); }); + const small = (label: string, run: () => void): HTMLElement => + createButton({ label, variant: "ghost", size: "sm", onClick: run }); const btns = el("div", { className: "m01-logic__chips", - children: [ - createButton({ - label: tc("Up"), - variant: "ghost", - size: "sm", - onClick: () => move(i, -1), - }), - createButton({ - label: tc("Down"), - variant: "ghost", - size: "sm", - onClick: () => move(i, 1), - }), - createButton({ - label: tc("Remove"), - variant: "ghost", - size: "sm", - onClick: () => { - combo.담은로직.splice(i, 1); - drawTable(); - }, - }), - ], + children: edit + ? [ + small(tc("Up"), () => move(i, -1)), + small(tc("Down"), () => move(i, 1)), + small(tc("Remove"), () => { + combo.담은로직.splice(i, 1); + drawTable(); + markDirty(); + }), + ] + : [], }); return el("tr", { attrs: { "data-combo-logic": row.로직 }, children: [ el("td", { text: String(i + 1) }), el("td", { text: row.로직 }), - el("td", { children: [name] }), + el("td", { text: info?.이름 ?? row.로직 }), el("td", { text: info?.결과단위 ?? "" }), el("td", { children: [memo.root] }), el("td", { children: [btns] }), @@ -340,6 +278,7 @@ export function mountM01Combo( if (!logics.has(x.키)) logics.set(x.키, x); combo.담은로직.push({ 로직: x.키, 메모: null }); drawTable(); + markDirty(); }), }); const save = createButton({ @@ -347,19 +286,20 @@ export function mountM01Combo( variant: "filled", onClick: async () => { if (!combo.이름.trim()) return showToast(tc("NeedName"), "error"); - const my = nav; + save.disabled = true; // 연달아 눌러도 한 번만 try { - const key = combo.키 ? combo.키 : (await newCombo(combo)).key; - if (combo.키) await editCombo(combo.키, version, combo); + let key = combo.키; + if (key) await editCombo(key, version, combo, opt.scope); + else key = (await newCombo(combo, opt.scope)).key; showToast(tc("Saved"), "success"); - await load(false); - if (my !== nav) { - if (current?.combo === combo) current = null; // 저장 사이에 나감 — 옛 판본을 붙들지 않음 - return; - } + current = null; // 저장함 — 다시 여는 동안 「고침 있음」 으로 안 물음 + opt.onDirty(false); + await opt.onSaved(key); await open(key); } catch (error) { showToast(reason(error), "error"); + } finally { + save.disabled = false; } }, }); @@ -373,9 +313,11 @@ export function mountM01Combo( ) return; try { - await deleteCombo(combo.키, version); + await deleteCombo(combo.키, version, opt.scope); showToast(tc("Deleted"), "success"); - await load(); + current = null; + await opt.onSaved(null); + drawList(); } catch (error) { showToast(reason(error), "error"); } @@ -385,7 +327,7 @@ export function mountM01Combo( label: "←", variant: "ghost", onClick: async () => { - if (JSON.stringify(combo) !== saved && !(await showConfirmDialog(tc("BackAsk")))) return; + if (dirty() && !(await showConfirmDialog(tc("BackAsk")))) return; drawList(); }, }); @@ -396,7 +338,11 @@ export function mountM01Combo( view.replaceChildren( el("div", { className: "m01-logic__section-head", - children: [back, el("h3", { text: combo.키 || tc("New") }), remove, save], + children: [ + back, + el("h3", { text: combo.키 || tc("New") }), + ...(edit ? [remove, save] : []), + ], }), el("section", { className: "m01lab__box m01-logic__section", @@ -419,7 +365,7 @@ export function mountM01Combo( children: [ el("div", { className: "m01-logic__section-head", - children: [el("h3", { text: tc("Logics_Title") }), add], + children: [el("h3", { text: tc("Logics_Title") }), ...(edit ? [add] : [])], }), table, ], @@ -429,5 +375,17 @@ export function mountM01Combo( drawTable(); }; - return { show }; + view.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Loading") })); + return { + root: view, + setList: (next) => { + items = next; + if (!current) drawList(); + }, + open, + startNew, + showList: drawList, + isDirty: dirty, + current: () => current?.combo.키 ?? null, + }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 8d6b1a1d..d1fd7e12 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -7,6 +7,10 @@ import { currentLanguageIndex } from "@ui/ui_template_locale"; const TEXT = { Title: ["일위대가 조합", "Unit-price combination"], + Library: ["일위대가 라이브러리", "Unit-price library"], + AddCombo: ["조합 더하기", "Add combination"], + ComboName: ["조합 이름", "Combination name"], + ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"], Sub: ["구분", "Category"], Detail: ["상세구분", "Detail"], All: ["전체", "All"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts new file mode 100644 index 00000000..d18017ca --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Tree.ts + * 일위대가 라이브러리 나무 자료(화면 없음) — 조합 목록 → 나무 부품(`TreePanel`) 모양 · 나무 고침 뒤 알림. + * 조합 폴더 = [구분] · [구분, 상세구분](깊이 2 · PLAN 27-2a) · 항목 = 조합(열 = 키). + * ========================================================================== */ + +import type { TreeEdit, TreeNote, TreeView } from "./M02_MasterTemplete_Api_Fetch"; +import type { ComboList, ComboSummary } from "./M02_MasterTemplete_Labor_Api"; + +/** 조합의 폴더 — [구분] · [구분, 상세구분] · 구분 없으면 맨 위 */ +export const pathOf = (c: ComboSummary): string[] => + c.구분 ? (c.상세구분 ? [c.구분, c.상세구분] : [c.구분]) : []; + +/** 조합 목록 → 나무 부품 모양(항목 = 조합 · 열 = 키) */ +export const toView = (list: ComboList, note?: TreeNote): TreeView => ({ + 판: list.판, + 폴더: list.폴더, + items: list.줄.map((c) => ({ + key: c.키, + 열: c.키, + 경로: [...pathOf(c), c.이름], + 이름: c.이름, + 단위: c.단위, + })), + ...(note ? { 알림: note } : {}), +}); + +/** 나무 고침 뒤 알림 — 새 · 바뀐 폴더를 펼치고 고친 조합은 고른 채 */ +export function noteOf(edit: TreeEdit, gone: string[]): TreeNote { + const at = edit.경로 ?? []; + const last = at[at.length - 1] ?? ""; + if (edit.키 && edit.op !== "delete") { + const where = edit.op === "move" ? (edit.새경로 ?? []) : at; + return { op: edit.op, 열: edit.키, 경로: [...where, edit.이름 ?? ""], 지운항목: gone }; + } + const path = + edit.op === "add-folder" + ? [...at, edit.이름 ?? ""] + : edit.op === "rename" + ? [...at.slice(0, -1), edit.이름 ?? ""] + : edit.op === "move" + ? [...(edit.새경로 ?? []), last] + : undefined; + return { op: edit.op, ...(path ? { 경로: path } : {}), 지운항목: gone }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index f6873a78..05970da7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -62,16 +62,27 @@ def get_all(session: Session = Depends(verify_session)) -> list[dict]: return out -@router.get("/combos/key/{key}") -def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict: - """키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`.""" +def _where(key: str) -> tuple[str, str | None]: got = combos.where(key) if got is None: raise HTTPException(status_code=404, detail=f"없는 조합 「{key}」") - layer, owner = got + return got + + +@router.get("/combos/key/{key}") +def get_by_key(key: str, session: Session = Depends(verify_session)) -> dict: + """키 하나(어느 층이든 · 그 층 읽기 권한) — `{layer, owner, 판, combo, 줄, …}`.""" + layer, owner = _where(key) return {"layer": layer, "owner": owner, **_call(_root(session, layer, owner), combos.one, key)} +@router.get("/combos/key/{key}/inputs") +def get_inputs_by_key(key: str, session: Session = Depends(verify_session)) -> dict: + """조합 알약(어느 층이든) — 구조물 조합 면적산출 연결이 부름.""" + layer, owner = _where(key) + return _call(_root(session, layer, owner), combos.pills, key) + + @router.get("/layers/{layer}/combos") def get_combos(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): return _call(_root(session, layer, owner), combos.listing) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 3c75064e..baf34db0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -23,8 +23,8 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; -import { fetchComboInputs, searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; -import { type ComboSummary, fetchCombos } from "./M02_MasterTemplete_Labor_Api"; +import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import { fetchComboInputs, fetchUsableCombos } from "./M02_MasterTemplete_Labor_Api"; import { copyArea, copyMaterial, @@ -86,13 +86,9 @@ const fill = (text: string, value: string): string => text.replace("{value}", va /** `{열 id: {도번, 면적번, 재료번}}` — 집계표 번호 줄(없는 열은 키 없음 = 「없음」) */ export type ColumnNumbers = Record; -/** 일위대가 목록 — 처음 한 번만 받음(못 받으면 다음에 다시) */ -let combos: Promise | null = null; -const loadCombos = (): Promise => - (combos ??= fetchCombos().catch((error: unknown) => { - combos = null; - throw error; - })); +/** 일위대가 목록 — 이 층이 쓸 수 있는 세 층 조합(27-2c) · 고를 때마다 받음(층을 바꿔 다시 들어와도 맞게) */ +const loadCombos = () => fetchUsableCombos(getActiveLayer()); +const LAYER_WORD = { system: "마스터", company: "회사", personal: "개인" } as const; interface Choice { value: string; @@ -309,7 +305,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane }; const pickPrice = async (colId: string, columns: StructureColumn[]): Promise => { - let list: ComboSummary[]; + let list: Awaited>; try { list = await loadCombos(); } catch (error) { @@ -321,7 +317,12 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane value: c.키, key: c.키, // 이름에 단위가 이미 붙은 조합이 있음 — 겹치면 뺌 - label: [c.이름, c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, c.구분] + label: [ + c.이름, + c.단위 && !c.이름.includes(`(${c.단위})`) && `(${c.단위})`, + c.구분, + `· ${LAYER_WORD[c.layer]}`, + ] .filter(Boolean) .join(" "), })); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index f6f02951..291297f8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -56,6 +56,10 @@ export interface TreePanelOptions { }; /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ canEdit: boolean; + /** 폴더 깊이 한도 — 닿은 폴더엔 「폴더 더하기」 없음(일위대가 라이브러리 = 2) */ + maxDepth?: number; + /** 항목 부르는 말 · 「항목 더하기」 대신 부르는 쪽이 새 항목 화면을 엶(일위대가 조합) */ + item?: { add: string; name: string; onAdd?: (path: string[]) => void }; /** 항목을 누름 — 표에서 그 열을 고름 */ onPick: (item: TreeItem) => void; /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ @@ -106,8 +110,13 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { return [ ...(target.kind === "folder" ? [ - { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, - { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, + ...(opt.maxDepth !== undefined && target.path.length >= opt.maxDepth + ? [] + : [{ label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }]), + { + label: opt.item?.add ?? L("M02_TreeAddItem"), + run: () => void addItem(target.path), + }, ] : []), ...(isTop @@ -272,6 +281,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { async function addItem(parent: string[]): Promise { if (!(await ready())) return; + if (opt.item?.onAdd) return opt.item.onAdd(parent); const got = await askText(L("M02_TreeAddItem"), [ { key: "name", label: L("M02_TreeItemName") }, { key: "unit", label: L("M02_TreeUnit") }, @@ -291,7 +301,10 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const got = await askText(L("M02_TreeRename"), [ { key: "name", - label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"), + label: + target.kind === "item" + ? (opt.item?.name ?? L("M02_TreeItemName")) + : L("M02_TreeFolderName"), value: nameOf(target), }, ]); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index a86599a4..cdb0e2d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -23,8 +23,10 @@ import { entryQuery, layerOfRoot, ownOwner, + type OpenTarget, type Root, } from "./M02_MasterTemplete_Layer"; +import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; import { createLibraryRoots } from "./M02_MasterTemplete_TreePanel_Roots"; @@ -55,11 +57,26 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - const go = async (next: Root, nextOwner: string | null): Promise => { + const go = async ( + next: Root, + nextOwner: string | null, + open: OpenTarget = "구조물조합", + ): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; - navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery({ ...entry, root: next, owner: nextOwner })); + navigateTo( + ROUTES.M02_MASTER_TEMPLATE, + entryQuery({ ...entry, open, root: next, owner: nextOwner }), + ); }; + // 일위대가 라이브러리(PLAN 27-2b) — 같은 뿌리 셋 · 다른 뿌리를 누르면 그 뿌리의 일위대가로 다시 엶 + const laborRoots = (panel: HTMLElement): HTMLElement => + createLibraryRoots({ + user, + active: { root: entry.root, owner }, + panel, + go: (next, nextOwner) => void go(next, nextOwner, "일위대가"), + }); const roots = createLibraryRoots({ user, active: { root: entry.root, owner }, @@ -97,6 +114,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise + buildLaborSection({ + show: main.showView, + confirmLeave: main.confirmLeave, + onOpen, + make: () => + buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, roots: laborRoots }), + }), }); const layout = el("div", { className: "ui-workflow-layout m02-master" }); @@ -116,9 +141,9 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise false, show: () => {}, dispose: () => setActiveLayer("system") }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 2be9f784..77d675d9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -39,6 +39,7 @@ import { type StructureColumn, } from "./M02_MasterTemplete_Structure"; import { buildMatsetSection } from "./M02_MasterTemplete_Matset"; +import type { LaborSection } from "./M02_MasterTemplete_Labor"; import { buildOptionsSection } from "./M02_MasterTemplete_Options"; import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main"; @@ -73,6 +74,8 @@ export interface SideOptions { confirmLeave: () => Promise; /** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ + labor?: (hooks: { onOpen: () => void }) => LaborSection; } export interface SideHandle { @@ -81,7 +84,7 @@ export interface SideHandle { /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ paint: () => void; /** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침(옵션은 열기까지) · 시스템 층에만 있음 */ - reveal: (target: "옵션" | "도면") => void; + reveal: (target: "옵션" | "도면" | "일위대가") => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -131,7 +134,7 @@ export function buildSide(opt: SideOptions): SideHandle { confirmLeave: opt.confirmLeave, show: opt.showView, close: () => opt.onOpen(null), - onOpen: () => (options.clear(), paint()), + onOpen: () => (options.clear(), labor?.clear(), paint()), }); const matsetSection = section(matset.title, matset.body, ...matset.actions); // 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게 @@ -143,8 +146,9 @@ export function buildSide(opt: SideOptions): SideHandle { isAdmin: canEdit(layer, opt.isAdmin), confirmLeave: opt.confirmLeave, show: opt.showView, - onOpen: () => (matset.clear(), paint()), + onOpen: () => (matset.clear(), labor?.clear(), paint()), }); + const labor = opt.labor?.({ onOpen: () => (matset.clear(), options.clear(), paint()) }); // 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음) const summary = section( L("M02_KindTable"), @@ -164,7 +168,11 @@ export function buildSide(opt: SideOptions): SideHandle { : null; const listHost = el("div", { className: "m02-side__lists ui-sidebar-panel", - children: [summary, ...(extras ? [extras.옵션, extras.도면] : [])], + children: [ + ...(labor ? [section(labor.title, labor.body)] : []), + summary, + ...(extras ? [extras.옵션, extras.도면] : []), + ], }); attachCollapsible(listHost); @@ -240,6 +248,7 @@ export function buildSide(opt: SideOptions): SideHandle { paintButtons(); if (opt.getOpen()) matset.clear(); if (opt.getOpen()) options.clear(); + if (opt.getOpen()) labor?.clear(); } // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) @@ -363,7 +372,11 @@ export function buildSide(opt: SideOptions): SideHandle { } } - const reveal = (target: "옵션" | "도면"): void => { + const reveal = (target: "옵션" | "도면" | "일위대가"): void => { + if (target === "일위대가") { + labor?.body.closest("section")?.classList.remove("is-collapsed"); + return void labor?.open(); + } extras?.[target].classList.remove("is-collapsed"); if (target === "옵션") options.body.querySelector("button")?.click(); }; diff --git a/resources/tester/test_m02_combo_layers.py b/resources/tester/test_m02_combo_layers.py index 6a71f801..d6fecefd 100644 --- a/resources/tester/test_m02_combo_layers.py +++ b/resources/tester/test_m02_combo_layers.py @@ -156,6 +156,15 @@ def test_나무_폴더_깊이_2_이름_옮기기_지우기(world): row = next(r for r in got["줄"] if r["키"] == key) assert (row["구분"], row["상세구분"]) == ("찰쌓기", None) _tree(me, "personal", 409, op="add-folder", 경로=[], 이름="석축", 판="낡은판") + _new(me, "personal", _combo("다른 것")) + got = _tree(me, "personal", op="rename", 키=key, 이름="찰쌓기 한 벌") + assert next(r for r in got["줄"] if r["키"] == key)["이름"] == "찰쌓기 한 벌" + assert ( + "이미 있음" + in _tree(me, "personal", 422, op="rename", 키=key, 이름="다른 것")["detail"]["errors"][0] + ) + other = next(r["키"] for r in _list(me, "personal")["줄"] if r["이름"] == "다른 것") + _tree(me, "personal", op="delete", 키=other) got = _tree(me, "personal", op="delete", 경로=["찰쌓기"]) assert got["지운조합"] == [key] and got["줄"] == [] and got["폴더"] == [["석축"]] @@ -198,6 +207,10 @@ def master_copy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def test_마스터_나무는_M01_저장_길_빈_폴더는_머리_틀_검사_통과(world, master_copy): admin = _as(world, "SYSTEM_ADMIN") before = _list(admin, "system") + first = before["줄"][0]["키"] + got = _tree(admin, "system", op="rename", 키=first, 이름="이름 바꾼 견본") + assert next(r for r in got["줄"] if r["키"] == first)["이름"] == "이름 바꾼 견본" + before = _list(admin, "system") assert ["실무 일위대가"] in before["폴더"] _tree(admin, "system", op="add-folder", 경로=[], 이름="빈 폴더") got = _tree(admin, "system", op="rename", 경로=["실무 일위대가"], 이름="실무") @@ -272,6 +285,7 @@ def test_면적_알약이_개인_조합을_읽음(world): up = _new(me, "personal", _combo("찰쌓기", (A, B)))["키"] got = me.get(f"{URL.format('personal')}/{up}/inputs").json() assert [p["키"] for p in got["물량"]] == ["돌쌓기 찰쌓기|㎡"] + assert me.get(f"/api/m02/combos/key/{up}/inputs").json() == got tree = me.post( "/api/m02/layers/personal/tree", json={"op": "add-item", "경로": [], "이름": "찰쌓기 옹벽", "단위": "m", "판": _판(me)}, diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py new file mode 100644 index 00000000..f766acf5 --- /dev/null +++ b/resources/tester/test_m02_labor_tree.py @@ -0,0 +1,76 @@ +"""일위대가 라이브러리 나무 자료(PLAN 27-2b · `Labor_Tree.ts`) — Node 로 TS 를 돌려 판정. + +못박는 것 + - 조합 → 나무 항목: 경로 = [구분, 상세구분, 이름](빈 칸 건너뜀) · 열 = 키. + - 나무 고침 알림: 새 · 바뀐 · 옮긴 폴더 경로 · 조합 고침은 그 조합(열)을 고른 채 · + 지우기는 고름 없음. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = r""" +const fs = require("fs"); +const ts = require(process.argv[1] + "/config/node_modules/typescript"); +const file = "/M02_MasterTemplete/M02_MasterTemplete_Labor_Tree.ts"; +const src = fs.readFileSync(process.argv[1] + file, "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; +const row = (키, 이름, 구분, 상세구분) => ({ 키, 이름, 구분, 상세구분, 단위: "m" }); +const list = { + 판: "v1", + 폴더: [["석축"], ["석축", "찰쌓기"], ["빈"]], + 줄: [row("UP1-000001", "가", "석축", "찰쌓기"), row("UP1-000002", "나", "석축", null), + row("UP1-000003", "다", null, "무시")], +}; +const view = m.toView(list); +const notes = [ + { op: "add-folder", 경로: ["석축"], 이름: "메쌓기" }, + { op: "rename", 경로: ["석축", "찰쌓기"], 이름: "찰" }, + { op: "move", 경로: ["석축", "찰쌓기"], 새경로: [] }, + { op: "move", 경로: ["석축"], 키: "UP1-000002", 새경로: ["빈"] }, + { op: "rename", 경로: ["석축"], 키: "UP1-000002", 이름: "나나" }, + { op: "delete", 경로: ["석축"], 키: "UP1-000002" }, + { op: "delete", 경로: ["빈"] }, +].map((e) => m.noteOf(e, e.op === "delete" ? ["UP1-000002"] : [])); +console.log(JSON.stringify({ view, notes })); +""" + + +@pytest.fixture(scope="module") +def got(): + if shutil.which("node") is None: + pytest.skip("node 가 없음") + proc = subprocess.run( + ["node", "-e", SCRIPT, str(ROOT)], capture_output=True, text=True, encoding="utf-8" + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_조합이_나무_항목으로(got): + items = got["view"]["items"] + assert [i["경로"] for i in items] == [["석축", "찰쌓기", "가"], ["석축", "나"], ["다"]] + assert [i["열"] for i in items] == ["UP1-000001", "UP1-000002", "UP1-000003"] + assert got["view"]["폴더"] == [["석축"], ["석축", "찰쌓기"], ["빈"]] + assert "알림" not in got["view"] + + +def test_나무_고침_알림(got): + add, rename, move, move_item, rename_item, delete_item, delete_folder = got["notes"] + assert add["경로"] == ["석축", "메쌓기"] and "열" not in add + assert rename["경로"] == ["석축", "찰"] + assert move["경로"] == ["찰쌓기"] + assert move_item["열"] == "UP1-000002" and move_item["경로"][:-1] == ["빈"] + assert rename_item["경로"] == ["석축", "나나"] + assert "열" not in delete_item and delete_item["지운항목"] == ["UP1-000002"] + assert "경로" not in delete_folder