Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-09-30 17:39:54 +09:00
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);
}
+10 -5
View File
@@ -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
+8 -5
View File
@@ -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 없이(화면 다시 안 그림)
+39 -6
View File
@@ -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
+1
View File
@@ -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: [
"항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함",
+35 -1
View File
@@ -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;
+18 -2
View File
@@ -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 }),
],
}),