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:
2026-09-30 17:37:46 +09:00
co-authored by Claude Opus 5.5
parent a01cf96769
commit cd2ee5664d
15 changed files with 342 additions and 239 deletions
+23 -19
View File
@@ -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) };
}
+21 -2
View File
@@ -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);
}