Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
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);
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
저장본 것을 다시 끼움(열 차례 = 저장본 · 보인 고침은 받음).
|
||||
② 표 부품 `lockHead` — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(단위 · 조회 후 선택은
|
||||
그대로).
|
||||
③ 좌측 패널에 나무 없음 · 페이지가 나무를 편집 칸 왼쪽(`aside`)에 · 「구조물 조합」일 때만 보임.
|
||||
③ 나무는 좌측 패널 「구조물 조합」 줄 아래(층 단추 + 나무 · PLAN 29-2) · 상세 위쪽 찾기 칸은
|
||||
「구조물 조합」일 때만.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -88,19 +89,23 @@ def test_참조표는_열_머리를_못_고침():
|
||||
assert "getDoc: () => comboMerge(loaded, shown.getDoc())" in main
|
||||
|
||||
|
||||
def test_나무는_편집_칸_왼쪽_좌측_패널엔_없음():
|
||||
def test_나무는_좌측_패널_찾기_칸은_상세_위쪽():
|
||||
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
|
||||
assert "createTreePanel" not in side and "M02_TreeTitle" not in side
|
||||
assert "tree?: HTMLElement[];" in side and "...(opt.tree ?? [])," in side
|
||||
assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "createTreePanel({" in page and "aside," in page and "tree.select(column)" in page
|
||||
assert "createTreePanel({" in page and "tree.select(column)" in page
|
||||
assert 'tree: [layerBar("구조물조합"), tree.root],' in page and "search: tree.search," in page
|
||||
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
|
||||
assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main
|
||||
assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목
|
||||
assert main.count("showAside();") == 3 # 열 때 · 양식 밖 화면 · 처음
|
||||
assert "if (extras.search) extras.search.hidden = !isCombo(sel);" in main
|
||||
locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts")
|
||||
assert 'M02_KindTable: ["구조물 조합"' in locale
|
||||
assert ".m02-shell__tree[hidden]" in _text(M02 / "M02_MasterTemplete_UI_Style.css")
|
||||
assert ".m02-shell" not in _text(
|
||||
M02 / "M02_MasterTemplete_UI_Style.css"
|
||||
) # 상세 왼쪽 나무 자리 없앰
|
||||
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
'error instanceof StaleError && error.message === "HTTP 409"' in tree
|
||||
|
||||
@@ -77,12 +77,15 @@ def test_나무_고침_알림(got):
|
||||
|
||||
|
||||
def test_뿌리를_바꿔도_일위대가_화면_유지_해시도_맞춤():
|
||||
"""브레인 검증 흠(27-2b) — 뿌리 줄은 두 벌(구조물 · 일위대가) · 어느 줄을 누르든 지금 화면 ·
|
||||
좌측 「일위대가 라이브러리」를 열면 해시 `open=일위대가`(다시 그리지 않고 주소만)."""
|
||||
"""브레인 검증 흠(27-2b) · 29-2 — 층 단추 두 벌(일위대가 라이브러리 · 구조물 조합 줄 아래) ·
|
||||
일위대가 단추로 층을 바꾸면 일위대가 화면 그대로 · 좌측 「일위대가 라이브러리」를 열면 해시
|
||||
`open=일위대가`(다시 그리지 않고 주소만)."""
|
||||
page = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts").read_text("utf-8")
|
||||
assert 'open: OpenTarget = view === "일위대가" ? "일위대가" : "구조물조합"' in page
|
||||
assert page.count("go: (next, nextOwner) => void go(next, nextOwner),") == 2 # 두 벌 같은 길
|
||||
assert '"일위대가")' not in page.split("const laborRoots")[1].split("const roots")[0]
|
||||
assert "go: (next, nextOwner) => void go(next, nextOwner, open)," in page # 두 벌 같은 길
|
||||
assert (
|
||||
'bar: layerBar("일위대가"),' in page
|
||||
and 'tree: [layerBar("구조물조합"), tree.root],' in page
|
||||
)
|
||||
labor = page.split("buildLaborSection({")[1].split("make:")[0]
|
||||
assert 'mark("일위대가")' in labor # 여는 순간 해시 open
|
||||
assert "history.replaceState" in page # hashchange 없이(화면 다시 안 그림)
|
||||
|
||||
@@ -87,17 +87,50 @@ def test_주소_인자를_라우트에서_뗌():
|
||||
assert 'open: "구조물조합", root: "personal"' in picker and "layer=personal" not in picker
|
||||
|
||||
|
||||
def test_한_화면_뿌리_셋():
|
||||
"""27-1b · c — 뿌리 셋 한 화면 · 다른 뿌리 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) ·
|
||||
떠나면 층 길을 시스템으로 되돌림 · 목록은 펼칠 때 · 나무 부품은 owner · 자료 길을 받음."""
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_볼_회사는_시스템_관리자만_고름():
|
||||
"""29-2 — 시스템 관리자 = 해시 company · sessionStorage aislo.adminCompany · 자기 회사 차례 ·
|
||||
그 밖 = 자기 회사(해시 company 무시) · 모양 틀린 값은 버림."""
|
||||
found = re.search(r"^export function viewCompany[\s\S]*?^}", _text(LAYER), re.M)
|
||||
assert found, "viewCompany 를 못 찾음"
|
||||
script = (
|
||||
"interface SessionUser { role?: string; company_id?: number | null }\n"
|
||||
"interface Entry { company: string | null }\n"
|
||||
"let saved: string | null = null;\n"
|
||||
"(globalThis as any).sessionStorage = { getItem: () => saved };\n"
|
||||
+ found.group(0).replace("export ", "")
|
||||
+ "\nconst sys = { role: 'SYSTEM_ADMIN', company_id: 1 };"
|
||||
+ "\nconst admin = { role: 'ADMIN', company_id: 3 };"
|
||||
+ "\nconst out = [viewCompany({ company: '3' }, sys), viewCompany({ company: null }, sys)];"
|
||||
+ "\nsaved = '5';"
|
||||
+ "\nout.push(viewCompany({ company: null }, sys), viewCompany({ company: '3' }, sys));"
|
||||
+ "\nout.push(viewCompany({ company: '9' }, admin), viewCompany({ company: 'x' }, sys));"
|
||||
+ "\nconsole.log(JSON.stringify(out));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["3", "1", "5", "3", "3", "1"]
|
||||
|
||||
|
||||
def test_층_단추_셋_사람_고르기():
|
||||
"""27-1b · 29-2 — 층 단추 셋 · 다른 층 = 해시로 다시 세움(저장 안 한 고침은 먼저 물음) ·
|
||||
떠나면 층 길을 시스템으로 되돌림 · 회사 → 사람 펼침 없음 · 「사람 고르기」 = 같은 줄 select ·
|
||||
나무 부품은 owner · 자료 길을 받음."""
|
||||
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
|
||||
assert "createLibraryRoots(" in page and "setActiveLayer(layer, owner)" in page
|
||||
assert "createLayerBar({" in page and "setActiveLayer(layer, owner)" in page
|
||||
assert "const company = viewCompany(entry, user);" in page
|
||||
assert "await main.confirmLeave()" in page.split("const go =")[1].split("navigateTo(")[0]
|
||||
assert 'dispose: () => setActiveLayer("system")' in page
|
||||
assert "side.reveal(entry.open)" in page
|
||||
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
|
||||
assert 'fetchOwners("company")' in roots and 'fetchOwners("personal", company)' in roots
|
||||
assert "if (!kids.hidden && !loaded) void fill();" in roots # 펼칠 때 불러옴
|
||||
assert 'fetchOwners("company")' not in roots # 회사 목록 펼침 없앰
|
||||
assert 'fetchOwners("personal", opt.company)' in roots
|
||||
assert "if (companyAdmin && opt.company) row.append(buildPeople(opt)); // 같은 줄" in roots
|
||||
assert 'if (select.value) opt.go("personal", select.value);' in roots
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert (
|
||||
"api.fetch(opt.layer, owner)" in panel
|
||||
|
||||
@@ -103,6 +103,7 @@ 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 item"],
|
||||
M02_TreeEmptyEdit: [
|
||||
"항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",
|
||||
|
||||
@@ -1,10 +1,44 @@
|
||||
/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) */
|
||||
/* 공용 나무 보기 — 줄 모양은 공용 ui-sidebar-row(ui_template_overlay.css) ·
|
||||
* 탐색기 모양(들여쓰기 안내선 · 접기 화살 · 폴더/항목 아이콘 · 폴더 굵게 · 고른 줄 배경) */
|
||||
.ui-tree__kids {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* 들여쓰기 안내선 — 부모 줄 화살 한가운데에서 자식 끝까지(줄 들임 = spacing-8 + 깊이 × spacing-16) */
|
||||
.ui-tree__kids::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(var(--spacing-8) + var(--tree-depth, 0) * var(--spacing-16) + var(--spacing-12) / 2);
|
||||
border-left: 1px solid var(--color-border);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-tree__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-tree__icon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
margin-right: var(--spacing-4);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-tree__row.is-branch .ui-tree__label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-tree__row.is-active .ui-tree__icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.ui-tree__branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -23,8 +23,17 @@ export interface TreeNode {
|
||||
tip?: string;
|
||||
/** 오른쪽 클릭 메뉴 항목 — 부를 때마다 새로 받음 · 빈 목록 · 없음 = 메뉴 없음(기본 줄) */
|
||||
menu?: () => TreeMenuItem[];
|
||||
/** 자식이 없어도 폴더(빈 폴더) — 폴더 아이콘 · 굵은 글(기본 줄) */
|
||||
folder?: boolean;
|
||||
}
|
||||
|
||||
/** 기본 줄 아이콘 — 폴더 · 항목(글꼴색을 따름) */
|
||||
const ICON = {
|
||||
folder:
|
||||
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
|
||||
};
|
||||
|
||||
export interface TreeMenuItem {
|
||||
label: string;
|
||||
run: () => void;
|
||||
@@ -92,6 +101,7 @@ export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLEle
|
||||
className: "ui-tree__kids",
|
||||
children: renderTree(node.children, make, depth + 1),
|
||||
});
|
||||
kids.style.setProperty("--tree-depth", String(depth)); // 들여쓰기 안내선 자리(CSS)
|
||||
kids.hidden = !node.open;
|
||||
const row = make(node, kids.hidden ? "▸" : "▾", depth, () => {
|
||||
kids.hidden = !kids.hidden;
|
||||
@@ -103,20 +113,26 @@ export function renderTree(nodes: TreeNode[], make: MakeRow, depth = 0): HTMLEle
|
||||
});
|
||||
}
|
||||
|
||||
/** 기본 줄 — 화살표 · 글 · 줄 수 · 깊이 들임. 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
|
||||
/** 기본 줄 — 화살표 · 폴더/항목 아이콘 · 글(폴더는 굵게) · 줄 수 · 깊이 들임.
|
||||
* 잎 고름 표시(`is-active`)는 부르는 쪽이 켬. */
|
||||
export function createTreeRow(
|
||||
node: TreeNode,
|
||||
caret: string,
|
||||
depth: number,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement {
|
||||
const branch = !!node.children?.length || !!node.folder;
|
||||
const icon = el("span", { className: "ui-tree__icon" });
|
||||
icon.innerHTML = branch ? ICON.folder : ICON.leaf; // 고정 SVG 글만
|
||||
const button = el("button", {
|
||||
className: "ui-tree__row ui-sidebar-row",
|
||||
className: `ui-tree__row ui-sidebar-row${branch ? " is-branch" : ""}`,
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "ui-tree__label",
|
||||
children: [
|
||||
el("span", { className: "ui-tree__caret", text: caret }),
|
||||
icon,
|
||||
el("span", { text: node.label }),
|
||||
],
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user