feat(M02): 「사람 고르기」 없앰 · 시스템 관리자는 고른 회사를 회사 관리자처럼 — 「개인」 = 그 회사 사람 나무 (PLAN 29-6)

- 남의 회사 「개인」 단추(읽기) = 사람 줄 → 고른 사람 라이브러리(구조물 조합 · 일위대가 두 나무) · 안 골랐으면 첫 사람 · 사람 없으면 마스터
- 시스템 관리자 자기 개인 층은 자기 회사를 고를 때만 · 회사 관리자 화면 그대로
- 시험 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 18:24:40 +09:00
co-authored by Claude Opus 5.5
parent f685b4e3b3
commit 4a1fb2128b
8 changed files with 103 additions and 70 deletions
@@ -131,6 +131,8 @@ export interface LaborSectionOptions {
make: (reveal: () => Promise<void>) => 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) };
}
@@ -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 {
@@ -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;
}
@@ -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<Mounte
// 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 · 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내)
let owner = entry.owner ?? (entry.root === "company" ? companyOwner(company, user) : null);
if (owner === ownOwner(layer, user)) owner = null;
// 시스템 관리자가 남의 회사 「개인」 — 그 회사 관리자처럼 사람 나무(PLAN 29-6) · 사람을 안 골랐으면 첫 사람 ·
// 사람이 없으면 마스터
const people = entry.root === "personal" && seesOthers(company, user);
if (people && !owner) {
owner = (await fetchOwners("personal", company).catch(() => []))[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<Mounte
go: (next, nextOwner) => 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<Mounte
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
tree: [layerBar("구조물조합", tree.add), tree.root],
tree: [layerBar("구조물조합", tree.add), peopleTree("구조물조합", tree.root)],
labor: ({ onOpen }) =>
buildLaborSection({
show: main.showView,
@@ -126,6 +149,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
mark("일위대가");
},
bar: (add) => layerBar("일위대가", add),
wrap: (panel) => peopleTree("일위대가", panel),
make: (reveal) => buildLaborLibrary({ scope: { layer, owner }, canEdit: isAdmin, reveal }),
}),
});
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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
+12 -9
View File
@@ -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
-1
View File
@@ -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)",