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,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<void>;
|
||||
}
|
||||
|
||||
export interface LaborLibrary {
|
||||
/** 메인 칸 — 찾기 칸 + 조합 목록 · 상세 */
|
||||
root: HTMLElement;
|
||||
/** 좌측 패널 나무 */
|
||||
tree: HTMLElement;
|
||||
refresh: () => Promise<void>;
|
||||
/** 메인 칸 「저장 안 한 고침」 손잡이 */
|
||||
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<boolean>;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
make: () => LaborLibrary;
|
||||
/** 라이브러리 — `reveal` = 메인 칸에 일위대가 화면을 띄움(나무 항목을 누를 때) */
|
||||
make: (reveal: () => Promise<void>) => 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<void> {
|
||||
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) };
|
||||
}
|
||||
|
||||
@@ -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<string, string> => ({
|
||||
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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>;
|
||||
/** 표에서 고른 열을 나무에 표시(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 };
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MountedPage> {
|
||||
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<Mounte
|
||||
const query = new URLSearchParams(entryQuery({ ...entry, open }));
|
||||
history.replaceState(history.state, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
|
||||
};
|
||||
const go = async (
|
||||
next: Root,
|
||||
nextOwner: string | null,
|
||||
open: OpenTarget = view === "일위대가" ? "일위대가" : "구조물조합",
|
||||
): Promise<void> => {
|
||||
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
||||
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
||||
if (!(await main.confirmLeave())) return;
|
||||
navigateTo(
|
||||
@@ -77,24 +75,14 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
|
||||
);
|
||||
};
|
||||
// 일위대가 라이브러리(PLAN 27-2b) — 같은 뿌리 셋 · 다른 뿌리를 누르면 그 뿌리의 일위대가로 다시 엶
|
||||
const laborRoots = (panel: HTMLElement): HTMLElement =>
|
||||
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<Mounte
|
||||
},
|
||||
{
|
||||
top: readOnlyBar?.root,
|
||||
aside,
|
||||
search: tree.search,
|
||||
onColumn: (column) => {
|
||||
readOnlyBar?.setColumn(column);
|
||||
tree.select(column);
|
||||
@@ -126,6 +114,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
getOpen: main.current,
|
||||
confirmLeave: main.confirmLeave,
|
||||
showView: main.showView,
|
||||
tree: [layerBar("구조물조합"), tree.root],
|
||||
labor: ({ onOpen }) =>
|
||||
buildLaborSection({
|
||||
show: main.showView,
|
||||
@@ -134,8 +123,8 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
onOpen();
|
||||
mark("일위대가");
|
||||
},
|
||||
make: () =>
|
||||
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<Mounte
|
||||
root.innerHTML = "";
|
||||
root.append(layout);
|
||||
void main.open(home);
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 뿌리 ·
|
||||
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 세 층 ·
|
||||
// 옵션 · 일위대가 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
||||
if (entry.open !== "구조물조합") side.reveal(entry.open);
|
||||
void tree.refresh();
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface SideOptions {
|
||||
showView: (label: string, node: HTMLElement) => (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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user