feat(M02): 「사람 고르기」 없앰 · 시스템 관리자는 고른 회사를 회사 관리자처럼 — 「개인」 = 그 회사 사람 나무 (PLAN 29-6)
- 남의 회사 「개인」 단추(읽기) = 사람 줄 → 고른 사람 라이브러리(구조물 조합 · 일위대가 두 나무) · 안 골랐으면 첫 사람 · 사람 없으면 마스터 - 시스템 관리자 자기 개인 층은 자기 회사를 고를 때만 · 회사 관리자 화면 그대로 - 시험 맞춤 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user