From 4a1fb2128b90077d30583074c1f603fb499869a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 30 Sep 2026 18:24:40 +0900 Subject: [PATCH] =?UTF-8?q?feat(M02):=20=E3=80=8C=EC=82=AC=EB=9E=8C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=E3=80=8D=20=EC=97=86=EC=95=B0=20?= =?UTF-8?q?=C2=B7=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EB=8A=94=20=EA=B3=A0=EB=A5=B8=20=ED=9A=8C=EC=82=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=9A=8C=EC=82=AC=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=EC=B2=98=EB=9F=BC=20=E2=80=94=20=E3=80=8C=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=E3=80=8D=20=3D=20=EA=B7=B8=20=ED=9A=8C=EC=82=AC=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=20=EB=82=98=EB=AC=B4=20(PLAN=2029-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 남의 회사 「개인」 단추(읽기) = 사람 줄 → 고른 사람 라이브러리(구조물 조합 · 일위대가 두 나무) · 안 골랐으면 첫 사람 · 사람 없으면 마스터 - 시스템 관리자 자기 개인 층은 자기 회사를 고를 때만 · 회사 관리자 화면 그대로 - 시험 맞춤 Co-Authored-By: Claude Opus 5.5 --- .../M02_MasterTemplete_Labor.ts | 7 +- .../M02_MasterTemplete_TreePanel.css | 14 +-- .../M02_MasterTemplete_TreePanel_Roots.ts | 96 ++++++++++--------- .../M02_MasterTemplete_UI_Page.ts | 30 +++++- resources/tester/test_m02_combo_screen.py | 2 +- resources/tester/test_m02_labor_tree.py | 2 +- resources/tester/test_m02_layer_screen.py | 21 ++-- ui_template/ui_template_locale_m2.ts | 1 - 8 files changed, 103 insertions(+), 70 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts index cc22a464..d27f5c85 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.ts @@ -131,6 +131,8 @@ export interface LaborSectionOptions { make: (reveal: () => Promise) => LaborLibrary; /** 층 단추(PLAN 29-2) — `add` = 나무 맨 위 더하기 「+」를 단추 옆에 */ bar?: (add: HTMLElement) => HTMLElement; + /** 나무를 감쌈 — 남의 회사 「개인」이면 사람 나무 안에(PLAN 29-6) */ + wrap?: (tree: HTMLElement) => HTMLElement; } export interface LaborSection { @@ -160,7 +162,10 @@ export function buildLaborSection(opt: LaborSectionOptions): LaborSection { void library.refresh(); const body = el("div", { className: "m02-side__items ui-sidebar-list", - children: [...(opt.bar ? [opt.bar(library.add)] : []), library.tree], + children: [ + ...(opt.bar ? [opt.bar(library.add)] : []), + opt.wrap ? opt.wrap(library.tree) : library.tree, + ], }); return { title: tc("Title"), body, open, clear: () => mark(false) }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css index 9450e2f2..cc6f3b9c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -86,17 +86,9 @@ display: none; } -.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; +/* 남의 회사 「개인」 사람 나무 — 고른 사람 줄 아래 그 사람 라이브러리(PLAN 29-6) */ +.m02-people__panel { + padding-left: var(--spacing-16); } .m02-tree__hint { diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts index 20b03214..4926c608 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Roots.ts @@ -4,14 +4,16 @@ * 「구조물 조합」 아래 한 줄 · 나무 종류와 무관한 공용 부품. 고른 층의 나무는 부르는 쪽이 이 줄 아래에 붙임. * 권한 없는 층 = 단추 글 끝에 「읽기」(권한 = `accessFor` · 서버 `M02_Layer_Access` 와 짝) * 회사 = 볼 회사(`viewCompany` — 시스템 관리자는 해시 `company` · `aislo.adminCompany` · 자기 회사) - * 「사람 고르기」(같은 줄 select · 시스템 관리자만) = 고른 회사 사람의 개인 층 · 고르면 해시 `owner` + * 시스템 관리자가 남의 회사를 보면 그 회사 관리자처럼(PLAN 29-6) — 「개인」 = 그 회사 사람 나무 + * (`createPeopleTree` · 사람 줄 → 그 사람 라이브러리 · 읽기) · 자기 개인 층은 자기 회사를 고를 때만 * 누르면 `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 } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, type LayerOwner } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildNeedCompany, @@ -26,7 +28,7 @@ export interface LayerBarOptions { active: { root: Root; owner: string | null }; /** 볼 회사 id — 없으면 회사 · 개인 단추 없음 */ company: string | null; - /** 다른 층을 누름 — `owner` null = 자기 층 */ + /** 다른 층을 누름 — `owner` null = 자기 층(남의 회사 「개인」 = 부르는 쪽이 첫 사람으로) */ go: (root: Root, owner: string | null) => void; /** 단추 옆 「+」(고른 층 나무 맨 위 폴더 · 항목 더하기 · PLAN 29-6) */ add?: HTMLElement; @@ -36,10 +38,15 @@ export interface LayerBarOptions { export const companyOwner = (company: string | null, user: SessionUser | null): string | null => company && company !== ownOwner("company", user) ? company : null; +/** 시스템 관리자가 남의 회사를 봄 — 「개인」 단추 = 그 회사 사람 나무(읽기) */ +export const seesOthers = (company: string | null, user: SessionUser | null): boolean => + user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user); + export function createLayerBar(opt: LayerBarOptions): HTMLElement { const { user, active } = opt; const isSystem = user?.role === "SYSTEM_ADMIN"; const ownPersonal = ownOwner("personal", user); + const others = seesOthers(opt.company, user); if (!opt.company && !isSystem) { return el("div", { className: "m02-layers", @@ -47,8 +54,8 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { }); } - function button(root: Root, owner: string | null): HTMLButtonElement { - const readOnly = !accessFor(layerOfRoot(root), owner, user).canWrite; + function button(root: Root, owner: string | null, people = false): HTMLButtonElement { + const readOnly = people || !accessFor(layerOfRoot(root), owner, user).canWrite; const name = L( root === "master" ? "M02_PickerSystem" @@ -56,7 +63,7 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { ? "M02_PickerCompany" : "M02_PickerPersonal", ); - const current = root === active.root && (root !== "personal" || !active.owner); + const current = root === active.root && (root !== "personal" || people || !active.owner); const b = el("button", { className: `m02-layers__btn${current ? " is-active" : ""}`, text: readOnly ? `${name} · ${L("M02_RootReadOnly")}` : name, @@ -71,50 +78,53 @@ export function createLayerBar(opt: LayerBarOptions): HTMLElement { const buttons = [ button("master", null), ...(opt.company ? [button("company", companyOwner(opt.company, user))] : []), - ...(ownPersonal ? [button("personal", null)] : []), + ...(others ? [button("personal", null, true)] : ownPersonal ? [button("personal", null)] : []), ]; const row = el("div", { className: "m02-layers__row", children: [...buttons, ...(opt.add ? [opt.add] : [])], }); - const bar = el("div", { className: "m02-layers", children: [row] }); - if (isSystem && opt.company) row.append(buildPeople(opt)); // 같은 줄 · 시스템 관리자만 - return bar; + return el("div", { className: "m02-layers", children: [row] }); } -/** 「사람 고르기」 — 그 회사 사람(본인 뺌 · 층 폴더 있는 사람)의 개인 층 · 지금 고른 남의 층은 목록에 없어도 보임 */ -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; +export interface PeopleTreeOptions { + /** 볼 회사 id */ + company: string; + /** 지금 고른 사람의 개인 층 주인 `{C}/{U}` */ + active: string; + /** 고른 사람 줄 아래에 붙일 그 사람 라이브러리 나무 */ + panel: HTMLElement; + /** 다른 사람을 누름 */ + go: (owner: string) => void; +} + +/** 그 회사 사람 나무 — 사람 줄(층 폴더 있는 사람) → 고른 사람 줄 아래 그 사람 라이브러리(읽기) · + * 지금 고른 사람은 목록에 없어도 줄을 세움 */ +export function createPeopleTree(opt: PeopleTreeOptions): HTMLElement { + const box = el("div", { className: "m02-people" }); + const paint = (list: LayerOwner[]): void => { + const rows = list.some((one) => one.owner === opt.active) + ? list + : [...list, { owner: opt.active, name: opt.active }]; + const wrap = el("div", { className: "m02-people__panel", children: [opt.panel] }); + box.replaceChildren( + ...rows.flatMap((one) => { + const current = one.owner === opt.active; + const node = { id: one.owner, label: one.name, count: null, folder: true }; + const row = createTreeRow(node, current ? "▾" : "▸", 0, () => { + if (!current) return opt.go(one.owner); + wrap.hidden = !wrap.hidden; + const caret = row.querySelector(".ui-tree__caret"); + if (caret) caret.textContent = wrap.hidden ? "▸" : "▾"; + }); + row.classList.toggle("is-active", current); + return current ? [row, wrap] : [row]; + }), + ); }; - select.append(option("", "—"), ...(picked ? [option(picked, picked)] : [])); - select.disabled = true; + paint([]); 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], - }); + .then(paint) + .catch(() => undefined); // 목록을 못 받아도 고른 사람 나무는 보임 + return box; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index fbd44fc6..55984ca8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -15,7 +15,7 @@ import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { navigateTo, type MountedPage } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; +import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; import { accessFor, buildReadOnlyBar, @@ -30,7 +30,12 @@ import { import { buildLaborLibrary, buildLaborSection } from "./M02_MasterTemplete_Labor"; import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; -import { companyOwner, createLayerBar } from "./M02_MasterTemplete_TreePanel_Roots"; +import { + companyOwner, + createLayerBar, + createPeopleTree, + seesOthers, +} 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"; @@ -43,6 +48,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise []))[0]?.owner ?? null; + entry.owner = owner; + if (!owner) [entry.root, layer] = ["master", "system"]; + } if (accessFor(layer, owner, user).needCompany) { [entry.root, layer, owner] = ["master", "system", null]; } @@ -85,6 +98,16 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise void go(next, nextOwner, open), add, }); + // 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6) + const peopleTree = (open: OpenTarget, panel: HTMLElement): HTMLElement => + people && owner && company + ? createPeopleTree({ + company, + active: owner, + panel, + go: (next) => void go("personal", next, open), + }) + : panel; const main = createMain( isAdmin, () => void Promise.all([side.refresh(), tree.refresh()]), @@ -116,7 +139,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise buildLaborSection({ show: main.showView, @@ -126,6 +149,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise layerBar("일위대가", add), + wrap: (panel) => peopleTree("일위대가", panel), make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }), }), }); diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py index fbd2312f..69b2c416 100644 --- a/resources/tester/test_m02_combo_screen.py +++ b/resources/tester/test_m02_combo_screen.py @@ -96,7 +96,7 @@ def test_나무는_좌측_패널_찾기_칸은_상세_위쪽(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert "createTreePanel({" in page and "tree.select(column)" in page assert ( - 'tree: [layerBar("구조물조합", tree.add), tree.root],' in page + 'tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],' in page and "search: tree.search," in page ) main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") diff --git a/resources/tester/test_m02_labor_tree.py b/resources/tester/test_m02_labor_tree.py index c348a04b..9aec6dff 100644 --- a/resources/tester/test_m02_labor_tree.py +++ b/resources/tester/test_m02_labor_tree.py @@ -84,7 +84,7 @@ def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤(): assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길 assert ( 'bar: (add) => layerBar("일위대가", add),' in page - and 'tree: [layerBar("구조물조합", tree.add), tree.root],' in page + and 'tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],' in page ) labor = page.split("buildLaborSection({")[1].split("make:")[0] assert 'mark("일위대가")' in labor # 여는 순간 해시 open diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index 2b00eddd..938be71b 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -116,9 +116,10 @@ def test_볼_회사는_시스템_관리자만_고름(): assert json.loads(result.stdout) == ["3", "1", "5", "3", "3", "1"] -def test_층_단추_셋_사람_고르기(): - """27-1b · 29-2 — 층 단추 셋 · 다른 층 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) · - 떠나면 층 길을 시스템으로 되돌림 · 회사 → 사람 펼침 없음 · 「사람 고르기」 = 같은 줄 select · +def test_층_단추_셋_남의_회사_사람_나무(): + """27-1b · 29-2 · 29-6 — 층 단추 셋 · 다른 층 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) · + 떠나면 층 길을 시스템으로 되돌림 · 「사람 고르기」 없음 · 시스템 관리자가 남의 회사를 보면 + 「개인」 = 그 회사 사람 나무(사람 줄 → 라이브러리 · 읽기 · 안 골랐으면 첫 사람) · 나무 부품은 owner · 자료 길을 받음.""" page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert "createLayerBar({" in page and "setActiveLayer(layer, owner)" in page @@ -128,12 +129,14 @@ def test_층_단추_셋_사람_고르기(): assert "side.reveal(entry.open)" in page roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts") assert 'fetchOwners("company")' not in roots # 회사 목록 펼침 없앰 - assert 'fetchOwners("personal", opt.company)' in roots - assert ( - "if (isSystem && opt.company) row.append(buildPeople(opt)); // 같은 줄 · 시스템 관리자만" - in roots - ) - assert 'if (select.value) opt.go("personal", select.value);' in roots + assert "select" not in roots # 사람 고르기 없앰 + assert 'fetchOwners("personal", opt.company)' in roots # 사람 나무 + assert 'button("personal", null, true)' in roots # 남의 회사 「개인」 = 읽기 + assert 'user?.role === "SYSTEM_ADMIN" && !!companyOwner(company, user)' in roots + assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page + assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page + assert 'peopleTree("구조물조합", tree.root)' in page + assert 'wrap: (panel) => peopleTree("일위대가", panel)' in page 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 1a2b7df3..047ce548 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -103,7 +103,6 @@ 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 — name, key, folder, unit (all words)",