From cd2ee5664d45f9915365958734bf99c476fc6718 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 17:37:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=EC=A2=8C=EC=B8=A1=20=EC=B8=B5=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=94=20=EC=85=8B=20=C2=B7=20=EB=82=98=EB=AC=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=ED=83=90=EC=83=89=EA=B8=B0=20=EB=AA=A8=EC=96=91=20?= =?UTF-8?q?=EB=82=98=EB=AC=B4=20=C2=B7=20=EC=82=AC=EB=9E=8C=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=ED=95=B4=EC=8B=9C=20company=20(P?= =?UTF-8?q?LAN=2029-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 층 단추 셋(마스터 · 회사 · 개인 · 권한 없으면 「읽기」) → 고른 층의 나무를 좌측 패널에 · 상세는 표 · 편집만 - 찾기 칸은 상세 위쪽(구조물 조합일 때 · 일위대가 화면 맨 위) - 회사 → 사람 펼침 없앰 · 「사람 고르기」 select(같은 줄 · 회사 관리자 · 시스템 관리자) → 해시 owner - 시스템 관리자 볼 회사 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사(viewCompany) - 공용 나무(ui_template_tree) — 들여쓰기 안내선 · 폴더 · 항목 아이콘 · 폴더 굵게(M01 · 고르기 모달도 같이) - 시험 — combo_screen · labor_tree · layer_screen 새 배치 · viewCompany Node 시험 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor.ts | 42 ++-- .../M02_MasterTemplete_Layer.ts | 23 ++- .../M02_MasterTemplete_TreePanel.css | 55 +++++ .../M02_MasterTemplete_TreePanel.ts | 11 +- .../M02_MasterTemplete_TreePanel_Roots.ts | 193 ++++++++---------- .../M02_MasterTemplete_UI_Main.ts | 13 +- .../M02_MasterTemplete_UI_Page.ts | 61 +++--- .../M02_MasterTemplete_UI_Side.ts | 3 + .../M02_MasterTemplete_UI_Style.css | 50 +---- resources/tester/test_m02_combo_screen.py | 15 +- resources/tester/test_m02_labor_tree.py | 13 +- resources/tester/test_m02_layer_screen.py | 45 +++- ui_template/ui_template_locale_m2.ts | 1 + ui_template/ui_template_tree.css | 36 +++- ui_template/ui_template_tree.ts | 20 +- 15 files changed, 342 insertions(+), 239 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts index b83df40b..a4804095 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -1,8 +1,8 @@ /* ============================================================================= * M02_MasterTemplete_Labor.ts - * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 누르면 메인 칸에 일위대가 라이브러리: - * 왼쪽 뿌리 셋(마스터 · 회사 · 개인 · `TreePanel_Roots`) 아래 조합 나무(폴더 [구분, 상세구분] 깊이 2 · - * 구조물 라이브러리와 같은 `TreePanel`) · 오른쪽 조합 목록 · 상세(`Labor_Edit`). + * 좌측 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 「일위대가 라이브러리」 줄 아래 층 단추 + * (`TreePanel_Roots`) · 조합 나무(폴더 [구분, 상세구분] 깊이 2 · 구조물 라이브러리와 같은 `TreePanel` · + * 좌측 패널 안 · PLAN 29-2) · 줄 · 나무 항목을 누르면 메인 칸에 찾기 칸 + 조합 목록 · 상세(`Labor_Edit`). * 나무 고침 = `POST /api/m02/layers/{층}/combos/tree` · 「조합 더하기」 = 그 폴더를 채운 빈 조합(저장해야 생김). * ========================================================================== */ @@ -33,12 +33,15 @@ function treeError(error: unknown): Error { export interface LaborLibraryOptions { scope: LaborScope; canEdit: boolean; - /** 뿌리 셋 — 고른 뿌리 아래에 이 나무(`panel`)를 붙인 것 */ - roots: (panel: HTMLElement) => HTMLElement; + /** 나무 항목을 누름 — 메인 칸에 일위대가 화면을 먼저 띄움 */ + reveal: () => Promise; } export interface LaborLibrary { + /** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */ root: HTMLElement; + /** 좌측 패널 나무 */ + tree: HTMLElement; refresh: () => Promise; /** 메인 칸 「저장 안 한 고침」 손잡이 */ bind: (setDirty: (dirty: boolean) => void) => void; @@ -65,7 +68,6 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { 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) }, @@ -81,7 +83,8 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { return toView(took(got), noteOf(edit, got.지운조합)); }, }, - onPick: (item) => { + onPick: async (item) => { + await opt.reveal(); tree.select(item.key); void editor.open(item.key); }, @@ -100,15 +103,9 @@ export function buildLaborLibrary(opt: LaborLibraryOptions): LaborLibrary { 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] }), + root: el("div", { className: "m02-labor", children: [tree.search, editor.root] }), + tree: tree.root, refresh: () => tree.refresh(), bind: (fn) => (setDirty = fn), }; @@ -121,7 +118,10 @@ export interface LaborSectionOptions { confirmLeave: () => Promise; /** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */ onOpen: () => void; - make: () => LaborLibrary; + /** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */ + make: (reveal: () => Promise) => LaborLibrary; + /** 줄 아래 층 단추(PLAN 29-2) */ + bar?: HTMLElement; } export interface LaborSection { @@ -138,8 +138,6 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection { 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; @@ -148,12 +146,18 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection { 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(); } + // 나무는 좌측 패널에 늘 있음(PLAN 29-2) — 처음부터 만들고 읽음 + const library = opt.make(open); + void library.refresh(); + const body = el("div", { + className: "m02-side__items ui-sidebar-list", + children: [row, ...(opt.bar ? [opt.bar] : []), library.tree], + }); row.addEventListener("click", () => void open()); return { title: tc("Title"), body, open, clear: () => mark(false) }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts index bb640778..742f8f20 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Layer.ts * M02 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 진입 해시 - * `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…` + * `#/m02-master-template?open=구조물조합|일위대가|옵션|도면&root=master|company|personal&owner=…&company=…` * (옛 `?layer=` 도 root 로 읽음) · 고칠 권한(`accessFor` — 서버 `M02_Layer_Access` 와 짝 · 27-1c) · * 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대. * ========================================================================== */ @@ -23,6 +23,8 @@ export interface Entry { /** 남의 층 주인(회사 `{C}` · 개인 `{C}/{U}`) — 자기 층 = null */ owner: string | null; open: OpenTarget; + /** 시스템 관리자가 보는 회사(PLAN 29 · 없으면 sessionStorage `aislo.adminCompany` · 자기 회사) */ + company: string | null; } const OPENS: OpenTarget[] = ["구조물조합", "일위대가", "옵션", "도면"]; @@ -35,7 +37,8 @@ export function entryFromHash(): Entry { const raw = query.get("root") ?? query.get("layer"); const root: Root = raw === "company" || raw === "personal" ? raw : "master"; const open = OPENS.find((one) => one === query.get("open")) ?? "구조물조합"; - return { root, owner: root === "master" ? null : query.get("owner") || null, open }; + const owner = root === "master" ? null : query.get("owner") || null; + return { root, owner, open, company: query.get("company") || null }; } /** 진입 해시 인자 — `navigateTo(ROUTES.M02_MASTER_TEMPLATE, entryQuery(…))` */ @@ -43,8 +46,24 @@ export const entryQuery = (entry: Entry): Record => ({ open: entry.open, root: entry.root, ...(entry.owner ? { owner: entry.owner } : {}), + ...(entry.company ? { company: entry.company } : {}), }); +/** 볼 회사 — 시스템 관리자 = 해시 `company` · sessionStorage `aislo.adminCompany`(대시보드가 넣음) · + * 자기 회사 차례 · 그 밖 = 자기 회사 · 없으면 null */ +export function viewCompany(entry: Entry, user: SessionUser | null): string | null { + const own = user?.company_id ? String(user.company_id) : null; + if (user?.role !== "SYSTEM_ADMIN") return own; + let saved: string | null = null; + try { + saved = sessionStorage.getItem("aislo.adminCompany"); + } catch { + saved = null; + } + const picked = entry.company ?? saved; + return picked && /^\d+$/.test(picked) ? picked : own; +} + /** 자기 층 주인 글 — 회사 없으면 null */ export const ownOwner = (layer: ManageLayer, user: SessionUser | null): string | null => !user?.company_id || layer === "system" diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css index 47603a99..ef5d9317 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -22,6 +22,61 @@ overflow-wrap: anywhere; } +/* 찾기 칸 — 상세 위쪽(PLAN 29-2) */ +.m02-tree__search { + max-width: 24rem; +} + +/* 층 단추 셋 · 사람 고르기 — 좌측 패널 줄 아래 한 줄(PLAN 29-2) */ +.m02-layers { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-4) 0; +} + +.m02-layers__row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.m02-layers__btn { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-buttons); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: var(--text-body-sm); + cursor: pointer; +} + +.m02-layers__btn:hover { + background: var(--color-mist-violet); +} + +.m02-layers__btn.is-active { + border-color: var(--color-accent); + background: var(--color-mist-violet); + color: var(--color-accent); + font-weight: 600; +} + +.m02-layers__pick { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m02-layers__people { + max-width: 9rem; + font: inherit; +} + .m02-tree__hint { margin: 0; padding: var(--spacing-8); diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 291297f8..032b80b9 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel.ts * 관리 화면(마스터 · 회사 · 개인 공용) 「구조물 라이브러리」 한 층의 나무 — 폴더 → … → 항목(깊이 자유) · 찾기. - * 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 뿌리 셋 배치는 `_Roots` · 자료 길은 `api` 로 바꿔 끼움. + * 층 + `owner`(남의 층 · PLAN 27-1c) 한 벌을 그림 — 층 단추는 `_Roots` · 찾기 칸은 부르는 쪽이 상세 위쪽에 · 자료 길은 `api`. * 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목. * 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 — * `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`. @@ -69,7 +69,10 @@ export interface TreePanelOptions { } export interface TreePanelHandle { + /** 나무(좌측 패널에 붙임) */ root: HTMLElement; + /** 찾기 칸 — 부르는 쪽이 상세 위쪽에 붙임(PLAN 29-2) */ + search: HTMLElement; refresh: () => Promise; /** 표에서 고른 열을 나무에 표시(null = 지움) */ select: (colId: string | null) => void; @@ -102,7 +105,8 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true }); const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } }); - const root = el("div", { className: "m02-tree", children: [search.root, body] }); + const root = el("div", { className: "m02-tree", children: [body] }); + search.root.classList.add("m02-tree__search"); /* --- 그리기 --- */ const menuOf = (target: Target): TreeMenuItem[] => { @@ -156,6 +160,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { { label: folder.path.length ? folder.path[folder.path.length - 1] : layerLabel, count: countItems(folder), + folder: true, children: kids, open: searching || open.has(id), run: () => void (open.has(id) ? open.delete(id) : open.add(id)), @@ -396,5 +401,5 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { } }); - return { root, refresh, select }; + return { root, search: search.root, refresh, select }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index 4d78b1eb..57eeb3f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -1,19 +1,17 @@ /* ============================================================================= * M02_MasterTemplete_TreePanel_Roots.ts - * 라이브러리 한 화면의 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1b · c) — 나무 종류와 무관한 공용 부품 - * (구조물 라이브러리 · 일위대가 라이브러리가 함께 씀). 고른 뿌리(`active`) 아래에 부르는 쪽 나무(`panel`)를 붙임. - * 시스템 관리자 「회사」 → 회사 목록 → (회사 공용 · 그 회사 사람) - * 회사 관리자 「개인」 → (내 것 · 같은 회사 사람) - * 그 밖 마스터 · 회사 · 개인 한 줄씩 - * 목록은 펼칠 때 불러옴(`fetchOwners`) · 다른 뿌리를 누르면 `go`(부르는 쪽이 해시로 다시 엶) · - * 읽기만 뿌리는 줄 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝). + * 라이브러리 층 단추 셋(마스터 · 회사 · 개인 · PLAN 29-2) — 좌측 패널 「일위대가 라이브러리」 · + * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. + * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) + * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) + * 「사람 고르기」(같은 줄 select · 회사 관리자 · 시스템 관리자) = 그 회사 사람의 개인 층 · 고르면 해시 `owner` + * 누르면 `go`(부르는 쪽이 해시로 다시 엶). * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { createTreeRow } from "@ui/ui_template_tree"; import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchOwners, type LayerOwner } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildNeedCompany, @@ -22,120 +20,97 @@ import { type Root, } from "./M02_MasterTemplete_Layer"; -export interface RootsOptions { +export interface LayerBarOptions { user: SessionUser | null; - /** 지금 고른 뿌리 — `owner` null = 자기 층 */ + /** 지금 고른 층 — `owner` null = 자기 층 */ active: { root: Root; owner: string | null }; - /** 고른 뿌리 아래에 붙일 나무 */ - panel: HTMLElement; - /** 다른 뿌리를 누름 — `owner` null = 자기 층 */ + /** 볼 회사 id — 없으면 회사 · 개인 단추 없음 */ + company: string | null; + /** 다른 층을 누름 — `owner` null = 자기 층 */ go: (root: Root, owner: string | null) => void; } -const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text }); -const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +/** 회사 단추의 주인 — 볼 회사가 자기 회사면 null(자기 층) */ +export const companyOwner = (company: string | null, user: SessionUser | null): string | null => + company && company !== ownOwner("company", user) ? company : null; -export function createLibraryRoots(opt: RootsOptions): HTMLElement { +export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; const isSystem = user?.role === "SYSTEM_ADMIN"; const companyAdmin = isSystem || user?.role === "ADMIN" || !!user?.is_master; - const ownCompany = ownOwner("company", user); - const activeCompany = active.owner?.split("/")[0] ?? ownCompany; - - /** 층 하나 — 누르면 그 뿌리로 · 고른 뿌리면 아래에 나무(누르면 접기/펴기) */ - function leaf(label: string, root: Root, owner: string | null, depth: number): HTMLElement[] { - const current = root === active.root && owner === active.owner; - const readOnly = !accessFor(layerOfRoot(root), owner, user).canWrite; - const text = readOnly ? `${label} · ${L("M02_RootReadOnly")}` : label; - const wrap = el("div", { className: "m02-roots__panel", children: current ? [opt.panel] : [] }); - wrap.style.paddingLeft = `calc(${depth + 1} * var(--spacing-16))`; - const row = createTreeRow( - { id: `${root}|${owner}`, label: text, count: null }, - "", - depth, - () => { - if (!current) return opt.go(root, owner); - wrap.hidden = !wrap.hidden; - }, - ); - row.classList.toggle("is-active", current); - return current ? [row, wrap] : [row]; - } - - /** 묶음 — 펼칠 때 한 번 불러옴(`load`) · `open` = 고른 뿌리가 안에 있어 처음부터 펼침 */ - function group( - label: string, - depth: number, - open: boolean, - load: () => Promise, - ): HTMLElement { - const kids = el("div", { className: "ui-tree__kids" }); - kids.hidden = !open; - let loaded = false; - const fill = async (): Promise => { - loaded = true; - kids.replaceChildren(hint(L("M02_PickerLoading"))); - try { - const nodes = await load(); - kids.replaceChildren(...(nodes.length ? nodes : [hint(L("M02_RootNone"))])); - } catch (error) { - loaded = false; - kids.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error)))); - } - }; - const row = createTreeRow({ id: label, label, count: null }, open ? "▾" : "▸", depth, () => { - kids.hidden = !kids.hidden; - const caret = row.querySelector(".ui-tree__caret"); - if (caret) caret.textContent = kids.hidden ? "▸" : "▾"; - if (!kids.hidden && !loaded) void fill(); + const ownPersonal = ownOwner("personal", user); + if (!opt.company && !isSystem) { + return el("div", { + className: "m02-layers", + children: [button("master", null), buildNeedCompany()], }); - if (open) void fill(); - return el("div", { className: "ui-tree__branch", children: [row, kids] }); } - /** 목록에 없는 고른 층(해시로 바로 들어옴 · 탈퇴한 사람 등)도 줄을 세움 — 나무를 붙일 자리 */ - const withActive = (list: LayerOwner[], owner: string | null, fits: boolean): LayerOwner[] => - owner && fits && !list.some((one) => one.owner === owner) - ? [...list, { owner, name: owner }] - : list; - - /** 그 회사 사람들의 개인 층(본인 뺌) */ - const people = async (company: string | null, depth: number): Promise => { - const fits = active.root === "personal" && activeCompany === (company ?? ownCompany); - return withActive(await fetchOwners("personal", company), active.owner, fits).flatMap((one) => - leaf(one.name, "personal", one.owner, depth), + function button(root: Root, owner: string | null): HTMLButtonElement { + const readOnly = !accessFor(layerOfRoot(root), owner, user).canWrite; + const name = L( + root === "master" + ? "M02_PickerSystem" + : root === "company" + ? "M02_PickerCompany" + : "M02_PickerPersonal", ); - }; - - const master = leaf(L("M02_PickerSystem"), "master", null, 0); - if (!ownCompany && !isSystem) { - return el("div", { className: "m02-roots", children: [...master, buildNeedCompany()] }); + const current = root === active.root && (root !== "personal" || !active.owner); + const b = el("button", { + className: `m02-layers__btn${current ? " is-active" : ""}`, + text: readOnly ? `${name} · ${L("M02_RootReadOnly")}` : name, + attrs: { type: "button", "data-root": root, "aria-pressed": String(current) }, + }); + b.addEventListener("click", () => { + if (!current) opt.go(root, owner); + }); + return b; } - // 시스템 관리자 — 회사 목록 → 회사 공용 · 그 회사 사람 - const companies = (): HTMLElement => - group(L("M02_PickerCompany"), 0, active.root === "company" || !!active.owner, async () => - withActive(await fetchOwners("company"), activeCompany, !!active.owner).map((one) => { - const mine = one.owner === ownCompany; - const here = !!active.owner || active.root === "company"; - return group(one.name, 1, here && activeCompany === one.owner, async () => [ - ...leaf(L("M02_RootShared"), "company", mine ? null : one.owner, 2), - ...(await people(one.owner, 2)), - ]); - }), - ); + const buttons = [ + button("master", null), + ...(opt.company ? [button("company", companyOwner(opt.company, user))] : []), + ...(ownPersonal ? [button("personal", null)] : []), + ]; + const row = el("div", { className: "m02-layers__row", children: buttons }); + const bar = el("div", { className: "m02-layers", children: [row] }); + if (companyAdmin && opt.company) row.append(buildPeople(opt)); // 같은 줄 + return bar; +} - const company = isSystem ? [companies()] : leaf(L("M02_PickerCompany"), "company", null, 0); - // 회사 관리자 — 개인 → 내 것 · 같은 회사 사람 · 시스템 관리자는 남의 개인을 회사 아래에서 봄 - const personal = !ownCompany - ? [] - : companyAdmin && !isSystem - ? [ - group(L("M02_PickerPersonal"), 0, active.root === "personal", async () => [ - ...leaf(L("M02_RootMine"), "personal", null, 1), - ...(await people(null, 1)), - ]), - ] - : leaf(L("M02_PickerPersonal"), "personal", null, 0); - return el("div", { className: "m02-roots", children: [...master, ...company, ...personal] }); +/** 「사람 고르기」 — 그 회사 사람(본인 뺌 · 층 폴더 있는 사람)의 개인 층 · 지금 고른 남의 층은 목록에 없어도 보임 */ +function buildPeople(opt: LayerBarOptions): HTMLElement { + const { active } = opt; + const picked = active.root === "personal" ? active.owner : null; + const select = el("select", { + className: "m02-layers__people", + attrs: { "aria-label": L("M02_PeoplePick") }, + }) as HTMLSelectElement; + const option = (value: string, text: string): HTMLOptionElement => { + const one = el("option", { text, attrs: { value } }) as HTMLOptionElement; + one.selected = value === (picked ?? ""); + return one; + }; + select.append(option("", "—"), ...(picked ? [option(picked, picked)] : [])); + select.disabled = true; + void fetchOwners("personal", opt.company) + .then((list) => { + const rows = + picked && !list.some((one) => one.owner === picked) + ? [...list, { owner: picked, name: picked }] + : list; + select.replaceChildren(option("", "—"), ...rows.map((one) => option(one.owner, one.name))); + select.disabled = rows.length === 0; + select.title = rows.length ? "" : L("M02_RootNone"); + }) + .catch((error: unknown) => { + select.title = error instanceof Error ? error.message : ""; + }); + select.addEventListener("change", () => { + if (select.value) opt.go("personal", select.value); + }); + return el("label", { + className: "m02-layers__pick", + children: [el("span", { text: L("M02_PeoplePick") }), select], + }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 1f192869..82baccef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -106,10 +106,10 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean { } /** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 · - * `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(구조물 라이브러리 · PLAN 26-7c) */ + * `search` = 「구조물 조합」을 열 때만 상세 위쪽에 보이는 나무 찾기 칸(나무는 좌측 패널 · PLAN 29-2) */ export interface MainExtras { top?: HTMLElement; - aside?: HTMLElement; + search?: HTMLElement; onColumn?: (column: string | null) => void; } @@ -134,19 +134,18 @@ export function createMain( children: [ ...(extras.top ? [extras.top] : []), el("div", { className: "m02-main__head", children: [title, back, slot, reload, save] }), + ...(extras.search ? [extras.search] : []), notice, host, extra, ], }); - // 왼쪽 나무(주) · 오른쪽 편집 칸 — 나무는 「구조물 조합」일 때만(`showAside`) - const root = extras.aside - ? el("div", { className: "m02-shell", children: [extras.aside, column] }) - : column; + const root = column; const isCombo = (at: Selection | null): boolean => !!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"; + // 찾기 칸은 「구조물 조합」일 때만 const showAside = (): void => { - if (extras.aside) extras.aside.hidden = !isCombo(sel); + if (extras.search) extras.search.hidden = !isCombo(sel); }; let sel: Selection | null = null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index ec5f7c37..490814e4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -1,11 +1,11 @@ /* ============================================================================= * M02_MasterTemplete_UI_Page.ts - * 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) · - * 「구조물 조합」을 열면 편집 칸 왼쪽에 구조물 라이브러리(뿌리 셋 · 주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c) + * 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록 · 층 단추 · 고른 층의 나무) / + * 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 위쪽에 나무 찾기 칸 · PLAN 29-2) * - * 한 화면에 뿌리 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 뿌리 = 진입 해시 `?open=&root=&owner=` - * (`M02_MasterTemplete_Layer.ts`) · 다른 뿌리를 누르면 해시를 바꿔 이 화면을 다시 세움. - * 화면은 로그인만 확인 — 고치기는 뿌리별 권한일 때만 켬(서버도 막음 · `accessFor`). + * 한 화면에 층 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 층 = 진입 해시 `?open=&root=&owner=&company=` + * (`M02_MasterTemplete_Layer.ts`) · 층 단추 · 「사람 고르기」를 누르면 해시를 바꿔 이 화면을 다시 세움. + * 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `accessFor`). * ========================================================================== */ import "@ui/ui_template_workflow_layout.css"; @@ -23,13 +23,14 @@ import { entryQuery, layerOfRoot, ownOwner, + viewCompany, 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"; +import { companyOwner, createLayerBar } from "./M02_MasterTemplete_TreePanel_Roots"; import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; import "./M02_MasterTemplete_UI_Style.css"; @@ -37,27 +38,28 @@ import "./M02_MasterTemplete_UI_Style.css"; export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const entry = entryFromHash(); + const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사) let layer = layerOfRoot(entry.root); - // 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 뿌리 = 마스터(뿌리 자리에 안내) - let owner = entry.owner && entry.owner !== ownOwner(layer, user) ? entry.owner : null; + // 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내) + let owner = entry.owner ?? (entry.root === "company" ? companyOwner(company, user) : null); + if (owner === ownOwner(layer, user)) owner = null; if (accessFor(layer, owner, user).needCompany) { [entry.root, layer, owner] = ["master", "system", null]; } setActiveLayer(layer, owner); - const isAdmin = accessFor(layer, owner, user).canWrite; // 이 뿌리를 고칠 수 있나(하위 부품의 「관리자」) + const isAdmin = accessFor(layer, owner, user).canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」) // 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사] const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null; - // 고른 뿌리의 나무 — 고르기 모달과 같은 폴더 나무 · 「구조물 조합」 편집 칸 왼쪽 + // 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래 const tree = createTreePanel({ layer, owner, - label: L("M02_KindTable"), canEdit: canEdit(layer, isAdmin), onPick: (item) => void main.openTable(layer, item.열), settle: () => main.settle(), onChanged: (colId) => main.openTable(layer, colId, true), }); - // 일위대가 화면이 떠 있나 — 뿌리를 바꿔도(어느 뿌리 줄을 누르든) 일위대가로 다시 엶 · + // 일위대가 화면이 떠 있나 — 층을 바꿔도 일위대가로 다시 엶 · // 해시 `open` 도 맞춤(새로 고침 · 카드 진입과 같게) · 옵션 · 도면은 옛 규칙(구조물 조합으로) let view: OpenTarget = entry.open; const mark = (open: OpenTarget): void => { @@ -65,11 +67,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise => { + const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise => { // 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서 if (!(await main.confirmLeave())) return; navigateTo( @@ -77,24 +75,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise - createLibraryRoots({ + // 층 단추 셋(PLAN 29-2) — 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 한 벌씩 + const layerBar = (open: OpenTarget): HTMLElement => + createLayerBar({ user, active: { root: entry.root, owner }, - panel, - go: (next, nextOwner) => void go(next, nextOwner), + company, + go: (next, nextOwner) => void go(next, nextOwner, open), }); - const roots = createLibraryRoots({ - user, - active: { root: entry.root, owner }, - panel: tree.root, - go: (next, nextOwner) => void go(next, nextOwner), - }); - const aside = el("aside", { - className: "m02-shell__tree", - children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), roots], - }); const main = createMain( isAdmin, () => void Promise.all([side.refresh(), tree.refresh()]), @@ -105,7 +93,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise { readOnlyBar?.setColumn(column); tree.select(column); @@ -126,6 +114,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise buildLaborSection({ show: main.showView, @@ -134,8 +123,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise - buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, roots: laborRoots }), + bar: layerBar("일위대가"), + make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }), }), }); @@ -156,7 +145,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise (dirty: boolean) => void; /** 「일위대가 조합」 컨테이너(구조물 조합 위 · PLAN 27-2b) — 페이지가 만듦(뿌리 셋이 페이지 것) */ labor?: (hooks: { onOpen: () => void }) => LaborSection; + /** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */ + tree?: HTMLElement[]; } export interface SideHandle { @@ -153,6 +155,7 @@ export function buildSide(opt: SideOptions): SideHandle { const summary = section( L("M02_KindTable"), bodies.get("table")!, + ...(opt.tree ?? []), ...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])), matsetSection, ); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 3551963a..2931748f 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -216,52 +216,14 @@ font-size: 11px; } -/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 구조물 라이브러리(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */ -.m02-shell { +/* 나무는 좌측 패널(PLAN 29-2) — 일위대가 화면 = 찾기 칸 위 · 조합 목록 · 상세 아래 */ +.m02-labor { display: flex; - align-items: stretch; - min-width: 0; -} - -.m02-shell > .m02-main { - flex: 1 1 0; -} - -.m02-shell__tree { - display: flex; - flex: 0 0 16rem; flex-direction: column; - box-sizing: border-box; - max-height: calc(100vh - var(--spacing-64)); - padding: var(--spacing-16) var(--spacing-12); - border-right: 1px solid var(--color-border); + gap: var(--spacing-8); min-width: 0; - overflow: auto; } -.m02-shell__tree[hidden] { - display: none; -} - -.m02-shell__title { - margin: 0; - color: var(--color-text); - font-size: var(--text-body); -} - -.m02-shell__tree .m02-tree__body { - max-height: none; -} - -@media (max-width: 720px) { - .m02-shell { - flex-direction: column; - } - - .m02-shell__tree { - flex-basis: auto; - max-height: 40vh; - border-right: 0; - border-bottom: 1px solid var(--color-border); - } -} +.m02-main__head + .m02-tree__search { + margin-bottom: var(--spacing-8); +} \ No newline at end of file diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py index b9ee90e0..4535349f 100644 --- a/resources/tester/test_m02_combo_screen.py +++ b/resources/tester/test_m02_combo_screen.py @@ -5,7 +5,8 @@ 저장본 것을 다시 끼움(열 차례 = 저장본 · 보인 고침은 받음). ② 표 부품 `lockHead` — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(단위 · 조회 후 선택은 그대로). -③ 좌측 패널에 나무 없음 · 페이지가 나무를 편집 칸 왼쪽(`aside`)에 · 「구조물 조합」일 때만 보임. +③ 나무는 좌측 패널 「구조물 조합」 줄 아래(층 단추 + 나무 · PLAN 29-2) · 상세 위쪽 찾기 칸은 + 「구조물 조합」일 때만. """ from __future__ import annotations @@ -88,19 +89,23 @@ def test_참조표는_열_머리를_못_고침(): assert "getDoc: () => comboMerge(loaded, shown.getDoc())" in main -def test_나무는_편집_칸_왼쪽_좌측_패널엔_없음(): +def test_나무는_좌측_패널_찾기_칸은_상세_위쪽(): side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") - assert "createTreePanel" not in side and "M02_TreeTitle" not in side + assert "tree?: HTMLElement[];" in side and "...(opt.tree ?? [])," in side assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름 page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert "createTreePanel({" in page and "aside," in page and "tree.select(column)" in page + assert "createTreePanel({" in page and "tree.select(column)" in page + assert 'tree: [layerBar("구조물조합"), tree.root],' in page and "search: tree.search," in page main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목 assert main.count("showAside();") == 3 # 열 때 · 양식 밖 화면 · 처음 + assert "if (extras.search) extras.search.hidden = !isCombo(sel);" in main locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") assert 'M02_KindTable: ["구조물 조합"' in locale - assert ".m02-shell__tree[hidden]" in _text(M02 / "M02_MasterTemplete_UI_Style.css") + assert ".m02-shell" not in _text( + M02 / "M02_MasterTemplete_UI_Style.css" + ) # 상세 왼쪽 나무 자리 없앰 tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( 'error instanceof StaleError && error.message === "HTTP 409"' in tree diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index 2b643578..e1282239 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -77,12 +77,15 @@ def test_나무_고침_알림(got): def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): - """브레인 검증 흠(27-2b) — 뿌리 줄은 두 벌(구조물 · 일위대가) · 어느 줄을 누르든 지금 화면 · - 좌측 「일위대가 라이브러리」를 열면 해시 `open=일위대가`(다시 그리지 않고 주소만).""" + """브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) · + 일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시 + `open=일위대가`(다시 그리지 않고 주소만).""" page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8") - assert 'open: OpenTarget = view === "일위대가" ? "일위대가" : "구조물조합"' in page - assert page.count("go: (next, nextOwner) => void go(next, nextOwner),") == 2 # 두 벌 같은 길 - assert '"일위대가")' not in page.split("const laborRoots")[1].split("const roots")[0] + assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길 + assert ( + 'bar: layerBar("일위대가"),' in page + and 'tree: [layerBar("구조물조합"), tree.root],' in page + ) labor = page.split("buildLaborSection({")[1].split("make:")[0] assert 'mark("일위대가")' in labor # 여는 순간 해시 open assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림) diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 29c7f92c..f33898fe 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -87,17 +87,50 @@ def test_주소_인자를_라우트에서_뗌(): assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker -def test_한_화면_뿌리_셋(): - """27-1b · c — 뿌리 셋 한 화면 · 다른 뿌리 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) · - 떠나면 층 길을 시스템으로 되돌림 · 목록은 펼칠 때 · 나무 부품은 owner · 자료 길을 받음.""" +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_볼_회사는_시스템_관리자만_고름(): + """29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 · + 그 밖 = 자기 회사(해시 company 무시) · 모양 틀린 값은 버림.""" + found = re.search(r"^export function viewCompany[\s\S]*?^}", _text(LAYER), re.M) + assert found, "viewCompany 를 못 찾음" + script = ( + "interface SessionUser { role?: string; company_id?: number | null }\n" + "interface Entry { company: string | null }\n" + "let saved: string | null = null;\n" + "(globalThis as any).sessionStorage = { getItem: () => saved };\n" + + found.group(0).replace("export ", "") + + "\nconst sys = { role: 'SYSTEM_ADMIN', company_id: 1 };" + + "\nconst admin = { role: 'ADMIN', company_id: 3 };" + + "\nconst out = [viewCompany({ company: '3' }, sys), viewCompany({ company: null }, sys)];" + + "\nsaved = '5';" + + "\nout.push(viewCompany({ company: null }, sys), viewCompany({ company: '3' }, sys));" + + "\nout.push(viewCompany({ company: '9' }, admin), viewCompany({ company: 'x' }, sys));" + + "\nconsole.log(JSON.stringify(out));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == ["3", "1", "5", "3", "3", "1"] + + +def test_층_단추_셋_사람_고르기(): + """27-1b · 29-2 — 층 단추 셋 · 다른 층 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) · + 떠나면 층 길을 시스템으로 되돌림 · 회사 → 사람 펼침 없음 · 「사람 고르기」 = 같은 줄 select · + 나무 부품은 owner · 자료 길을 받음.""" page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") - assert "createLibraryRoots(" in page and "setActiveLayer(layer, owner)" in page + assert "createLayerBar({" in page and "setActiveLayer(layer, owner)" in page + assert "const company = viewCompany(entry, user);" in page assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0] assert 'dispose: () => setActiveLayer("system")' in page assert "side.reveal(entry.open)" in page roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") - assert 'fetchOwners("company")' in roots and 'fetchOwners("personal", company)' in roots - assert "if (!kids.hidden && !loaded) void fill();" in roots # 펼칠 때 불러옴 + assert 'fetchOwners("company")' not in roots # 회사 목록 펼침 없앰 + assert 'fetchOwners("personal", opt.company)' in roots + assert "if (companyAdmin && opt.company) row.append(buildPeople(opt)); // 같은 줄" in roots + assert 'if (select.value) opt.go("personal", select.value);' in roots panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts") assert ( "api.fetch(opt.layer, owner)" in panel diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index dbac9a6b..57809b43 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -103,6 +103,7 @@ export const ui_locales_m2 = { M02_RootShared: ["회사 공용", "Company shared"], M02_RootReadOnly: ["읽기", "read-only"], M02_RootNone: ["볼 수 있는 층이 없음", "Nothing to show"], + M02_PeoplePick: ["사람 고르기", "Pick a person"], M02_TreeSearch: ["항목 찾기", "Find item"], M02_TreeEmptyEdit: [ "항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함", diff --git a/ui_template/ui_template_tree.css b/ui_template/ui_template_tree.css index 18c80691..23f1bec7 100644 --- a/ui_template/ui_template_tree.css +++ b/ui_template/ui_template_tree.css @@ -1,10 +1,44 @@ -/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */ +/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) · + * 탐색기 모양(들여쓰기 안내선 · 접기 화살 · 폴더/항목 아이콘 · 폴더 굵게 · 고른 줄 배경) */ .ui-tree__kids { + position: relative; display: flex; flex-direction: column; gap: 2px; } +/* 들여쓰기 안내선 — 부모 줄 화살 한가운데에서 자식 끝까지(줄 들임 = spacing-8 + 깊이 × spacing-16) */ +.ui-tree__kids::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--spacing-8) + var(--tree-depth, 0) * var(--spacing-16) + var(--spacing-12) / 2); + border-left: 1px solid var(--color-border); + pointer-events: none; +} + +.ui-tree__label { + display: inline-flex; + align-items: center; + min-width: 0; +} + +.ui-tree__icon { + display: inline-flex; + flex: none; + margin-right: var(--spacing-4); + color: var(--color-text-secondary); +} + +.ui-tree__row.is-branch .ui-tree__label { + font-weight: 600; +} + +.ui-tree__row.is-active .ui-tree__icon { + color: inherit; +} + .ui-tree__branch { display: flex; flex-direction: column; diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts index 42eb0c19..d19f6d53 100644 --- a/ui_template/ui_template_tree.ts +++ b/ui_template/ui_template_tree.ts @@ -23,8 +23,17 @@ export interface TreeNode { tip?: string; /** 오른쪽 클릭 메뉴 항목 — 부를 때마다 새로 받음 · 빈 목록 · 없음 = 메뉴 없음(기본 줄) */ menu?: () => TreeMenuItem[]; + /** 자식이 없어도 폴더(빈 폴더) — 폴더 아이콘 · 굵은 글(기본 줄) */ + folder?: boolean; } +/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */ +const ICON = { + folder: + '', + leaf: '', +}; + export interface TreeMenuItem { label: string; run: () => void; @@ -92,6 +101,7 @@ export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLEle className: "ui-tree__kids", children: renderTree(node.children, make, depth + 1), }); + kids.style.setProperty("--tree-depth", String(depth)); // 들여쓰기 안내선 자리(CSS) kids.hidden = !node.open; const row = make(node, kids.hidden ? "▸" : "▾", depth, () => { kids.hidden = !kids.hidden; @@ -103,20 +113,26 @@ export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLEle }); } -/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */ +/** 기본 줄 — 화살표 · 폴더/항목 아이콘 · 글(폴더는 굵게) · 줄 수 · 깊이 들임. + * 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */ export function createTreeRow( node: TreeNode, caret: string, depth: number, onClick: () => void, ): HTMLButtonElement { + const branch = !!node.children?.length || !!node.folder; + const icon = el("span", { className: "ui-tree__icon" }); + icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만 const button = el("button", { - className: "ui-tree__row ui-sidebar-row", + className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`, attrs: { type: "button" }, children: [ el("span", { + className: "ui-tree__label", children: [ el("span", { className: "ui-tree__caret", text: caret }), + icon, el("span", { text: node.label }), ], }),