Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_UI_Page.ts
|
|
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록 · 층 단추 · 고른 층의 나무) /
|
|
* 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 위쪽에 나무 찾기 칸 · PLAN 29-2)
|
|
*
|
|
* 한 화면에 층 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 층 = 진입 해시 `?open=&root=&owner=&company=`
|
|
* (`M02_MasterTemplete_Layer.ts`) · 층 단추 · 「사람 고르기」를 누르면 해시를 바꿔 이 화면을 다시 세움.
|
|
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `accessFor`).
|
|
* ========================================================================== */
|
|
|
|
import "@ui/ui_template_workflow_layout.css";
|
|
import { ROUTES } from "@config/config_frontend";
|
|
import { el } from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
|
|
import { navigateTo, type MountedPage } from "../A00_Common/router";
|
|
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
|
import { fetchOwners, setActiveLayer, type Kind } from "./M02_MasterTemplete_Api_Fetch";
|
|
import {
|
|
accessFor,
|
|
buildReadOnlyBar,
|
|
entryFromHash,
|
|
entryQuery,
|
|
layerOfRoot,
|
|
ownOwner,
|
|
viewCompany,
|
|
type OpenTarget,
|
|
type Root,
|
|
} from "./M02_MasterTemplete_Layer";
|
|
import {
|
|
headRenameEdit,
|
|
STRUCTURE_TABLE,
|
|
type StructureColumn,
|
|
} from "./M02_MasterTemplete_Structure";
|
|
import { createTreePanel } from "./M02_MasterTemplete_TreePanel";
|
|
import {
|
|
companyOwner,
|
|
createLayerBar,
|
|
createPeopleTree,
|
|
seesOthers,
|
|
} from "./M02_MasterTemplete_TreePanel_Roots";
|
|
import { canEdit, createMain, newItemButton, type Selection } from "./M02_MasterTemplete_UI_Main";
|
|
import { buildSide, type AllKind } from "./M02_MasterTemplete_UI_Side";
|
|
import { buildAllItems } from "./M02_MasterTemplete_ItemList";
|
|
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
|
|
import "./M02_MasterTemplete_UI_Style.css";
|
|
|
|
const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안";
|
|
const NEW_ASK =
|
|
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
|
|
|
|
/** 「전체 항목」 화면 제목 앞마디 — 하위 이름 */
|
|
const ALL_TITLE = {
|
|
structure: "M02_KindStructure",
|
|
labor: "M02_KindLabor",
|
|
area: "M02_KindArea",
|
|
} as const satisfies Record<AllKind, string>;
|
|
|
|
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.root === "company" ? companyOwner(company, user) : null);
|
|
if (owner === ownOwner(layer, user)) owner = null;
|
|
// 시스템 관리자가 남의 회사 「개인」 — 그 회사 관리자처럼 사람 나무(PLAN 29-6) · 사람을 안 골랐으면 첫 사람 ·
|
|
// 사람이 없으면 마스터
|
|
const people = entry.root === "personal" && seesOthers(company, user);
|
|
if (people && !owner) {
|
|
owner = (await fetchOwners("personal", company).catch(() => []))[0]?.owner ?? null;
|
|
entry.owner = owner;
|
|
if (!owner) [entry.root, layer] = ["master", "system"];
|
|
}
|
|
if (accessFor(layer, owner, user).needCompany) {
|
|
[entry.root, layer, owner] = ["master", "system", null];
|
|
}
|
|
setActiveLayer(layer, owner);
|
|
const isAdmin = accessFor(layer, owner, user).canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」)
|
|
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
|
|
const readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
|
|
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
|
|
// 「〇〇로 복사…」(30-2 · 39-6) — 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 보는 층은 뺌) ·
|
|
// 대상 폴더는 고르는 창
|
|
const here = owner === ownOwner(layer, user) ? null : owner;
|
|
const copyTo = (["master", "company", "personal"] as Root[]).flatMap((one) => {
|
|
const to = layerOfRoot(one);
|
|
const toOwner = one === "company" ? companyOwner(company, user) : null;
|
|
if (!accessFor(to, toOwner, user).canWrite || (to === layer && toOwner === here)) return [];
|
|
const key = one === "master" ? "Master" : one === "company" ? "Company" : "Personal";
|
|
return [{ layer: to, owner: toOwner, label: L(`M02_TreeCopyTo${key}`) }];
|
|
});
|
|
const tree = createTreePanel({
|
|
layer,
|
|
owner,
|
|
copyTo,
|
|
canEdit: canEdit(layer, isAdmin),
|
|
onPick: (item) => void main.openTable(layer, item.열),
|
|
settle: () => main.settle(),
|
|
onChanged: (colId) => main.openTable(layer, colId, true),
|
|
});
|
|
const go = async (next: Root, nextOwner: string | null, open: OpenTarget): Promise<void> => {
|
|
// 같은 주소 안 이동은 머리 가드가 안 물음 — 저장 안 한 고침은 여기서
|
|
if (!(await main.confirmLeave())) return;
|
|
navigateTo(
|
|
ROUTES.M02_MASTER_TEMPLATE,
|
|
entryQuery({ ...entry, open, root: next, owner: nextOwner }),
|
|
);
|
|
};
|
|
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌(하위 도면 · 일위대가 · 면적산출도 이 층)
|
|
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
|
|
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
|
|
const layerBar = (open: OpenTarget, add: HTMLElement, reselect: () => void): HTMLElement =>
|
|
createLayerBar({
|
|
user,
|
|
active: { root: entry.root, owner },
|
|
company,
|
|
go: (next, nextOwner) => void go(next, nextOwner, open),
|
|
add,
|
|
reselect,
|
|
});
|
|
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
|
|
const peopleTree = (open: OpenTarget, panel: HTMLElement): HTMLElement =>
|
|
people && owner && company
|
|
? createPeopleTree({
|
|
company,
|
|
active: owner,
|
|
panel,
|
|
go: (next) => void go("personal", next, open),
|
|
})
|
|
: panel;
|
|
const main = createMain(
|
|
isAdmin,
|
|
() => void Promise.all([side.refresh(), tree.refresh()]),
|
|
() => side.paint(),
|
|
{
|
|
top: readOnlyBar?.root,
|
|
search: tree.search,
|
|
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
|
|
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
|
|
onColumn: (column) => {
|
|
readOnlyBar?.setColumn(column);
|
|
tree.select(column);
|
|
},
|
|
// 표 머리 이름 칸 고침 = 나무 이름 바꾸기(31-1) · 쓸 수 있는 층만
|
|
...(canEdit(layer, isAdmin)
|
|
? {
|
|
onHeadRename: (col: StructureColumn, level: number, name: string) =>
|
|
void tree.editFromTable(headRenameEdit(col, level, name), col.id),
|
|
}
|
|
: {}),
|
|
},
|
|
);
|
|
/** 새 구조물 조합(39-5 · 답 2) — 새로 만들기 = 나무 「항목 추가」 창 · 복사 생성 = 탭 + 목록 →
|
|
* 지금 보는 층 · 나무에서 고른 폴더(없으면 맨 위) · 원래 이름(겹치면 「(2)」) · 새 키 · 새 번호 */
|
|
const openNew = async (): Promise<void> => {
|
|
if (!(await main.confirmLeave())) return; // 집계표 · 카드에 저장 안 한 고침 — 버릴지 물음
|
|
const where = (): string => tree.folder().join(" / ") || L("M02_TreeTop");
|
|
main.showView(
|
|
L("M02_TreeNewItem"),
|
|
buildTwoWay({
|
|
note: NEW_NOTE.replace("{value}", where()),
|
|
onNew: () => tree.newItem(),
|
|
picker: { user, company, kind: "item" },
|
|
ask: (picked) =>
|
|
NEW_ASK.replace("{name}", pickedName(picked))
|
|
.replace("{from}", picked.from.label)
|
|
.replace("{to}", where()),
|
|
onCopy: async (picked) => {
|
|
if ("row" in picked) await tree.copyIn(picked.from, picked.row.key, tree.folder());
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
|
|
const home: Selection = {
|
|
layer,
|
|
projectId: null,
|
|
kind: "table",
|
|
name: STRUCTURE_TABLE,
|
|
};
|
|
const side = buildSide({
|
|
layer,
|
|
isAdmin,
|
|
onOpen: (sel) => void main.open(sel ?? home),
|
|
getOpen: main.current,
|
|
confirmLeave: main.confirmLeave,
|
|
showView: main.showView,
|
|
tree: [
|
|
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
|
|
peopleTree("구조물조합", tree.root),
|
|
],
|
|
// 「전체 항목」(39-5) — 메인 칸에 그 하위 목록 · 줄 누름 = 그 항목 상세(좌측 열 줄과 같은 `open`)
|
|
onAll: (kind) =>
|
|
void main.showView(
|
|
`${L(ALL_TITLE[kind])} · ${L("M02_ItemsAll")}`,
|
|
buildAllItems(kind, layer, owner, (row) => {
|
|
void main.open({
|
|
layer,
|
|
projectId: null,
|
|
kind: kind as Kind,
|
|
name: row.열,
|
|
label: row.이름,
|
|
});
|
|
}),
|
|
),
|
|
});
|
|
|
|
const layout = el("div", { className: "ui-workflow-layout m02-master" });
|
|
const overlays = createWorkflowOverlays({
|
|
title: L("M02_Title"),
|
|
optionsContent: el("div", { className: "m02-master__side", children: [side.root] }),
|
|
showProjectName: false,
|
|
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
|
|
});
|
|
layout.append(
|
|
el("div", {
|
|
className: "ui-workflow-layout__body",
|
|
children: [el("main", { className: "ui-workflow-layout__main", children: [main.root] })],
|
|
}),
|
|
overlays.root,
|
|
);
|
|
root.innerHTML = "";
|
|
root.append(layout);
|
|
void main.open(home);
|
|
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 · 일위대가는 구조물 조합 하위(세 층) ·
|
|
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
|
|
if (entry.open !== "구조물조합") side.reveal(entry.open);
|
|
void tree.refresh();
|
|
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
|
|
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
|
|
}
|