feat(M02): 좌측 층 단추 셋 · 나무를 좌측 패널로 · 탐색기 모양 나무 · 사람 고르기 · 해시 company (PLAN 29-2)
- 「일위대가 라이브러리」 · 「구조물 조합」 줄 아래 층 단추 셋(마스터 · 회사 · 개인 · 권한 없으면 「읽기」) → 고른 층의 나무를 좌측 패널에 · 상세는 표 · 편집만 - 찾기 칸은 상세 위쪽(구조물 조합일 때 · 일위대가 화면 맨 위) - 회사 → 사람 펼침 없앰 · 「사람 고르기」 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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[]>,
|
||||
): HTMLElement {
|
||||
const kids = el("div", { className: "ui-tree__kids" });
|
||||
kids.hidden = !open;
|
||||
let loaded = false;
|
||||
const fill = async (): Promise<void> => {
|
||||
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<HTMLElement[]> => {
|
||||
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],
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user