Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts
T
eomsangdonandClaude Opus 5.5 ace439ad04 feat(720-4): 구조물 조합 층 단추 = 제자리 바꿈(해시로 페이지 통째 다시 세움 없음) · 도면 양식 · 실무문서 칸 = 시스템 관리자면 늘
- 페이지: 층 고름(resolve) · 층 맞춤(settleLayer) · 띠 · 나무 · 층 줄 갈아 끼움(build) 한 길 · 주소 replaceState · 새 층 구조물 표
- 편집 칸 권한 = 함수(UI_Main isAdmin) · 「새 항목」 = 고른 것 권한으로 가림 · 불러오기 장부 = 열 때 층 · 권한(Hopyo_Side)
- 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로
- 낡은 시험(층 단추 해시 이동 · 사람 나무 · 도면 칸 층 묶음 · 새 항목 · 머리 고침 · 불러오기 권한 · 실무문서 차례 · home) 새 꼴로 — test_m02_practice 도면 차례 글은 이미 낡아 실패하던 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:37:10 +09:00

350 lines
16 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_UI_Page.ts
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록 · 층 단추 · 고른 층의 나무) /
* 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 나무는 좌측 패널 · PLAN 29-2)
*
* 한 화면에 층 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 층 = 진입 해시 `?open=&root=&owner=&company=`
* (`M02_MasterTemplete_Layer.ts`) · 구조물 조합 층 단추 · 사람 나무를 누르면 제자리에서 그 층으로 갈아 끼움
* (720-4 · 해시는 replaceState · 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로).
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `accessFor`).
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
// 부품 상자 겉(553-5) — 페이지 CSS 앞에 실어 페이지 곁값이 이김
import "@ui/ui_template_banner.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_table.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_toolbar.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 { mountSettingsDrawer } from "../A00_Common/m_settings_nav";
import type { MountedPage } from "../A00_Common/router";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { fetchOwners, setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
import {
accessFor,
buildReadOnlyBar,
defaultRoot,
entryFromHash,
entryQuery,
layerOfRoot,
ownOwner,
redirectOldOptions,
viewCompany,
type ManageLayer,
type Root,
} from "./M02_MasterTemplete_Layer";
import {
headRenameEdit,
STRUCTURE_TABLE,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { createShare } from "./M02_MasterTemplete_ShareRequest";
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 } from "./M02_MasterTemplete_UI_Side";
import { ITEM_BUILT } from "./M02_MasterTemplete_Hopyo_ToItem";
import { precomboList, type PrecomboScope } from "./M02_MasterTemplete_UI_Side_Precombo";
import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { buildTwoWay, pickedName } from "./M02_MasterTemplete_ItemPick";
import "./M02_MasterTemplete_UI_Style.css";
const NEW_NOTE = "새 항목이 들어갈 자리 — {value} · 나무에서 폴더를 고르면 그 안";
const NEW_ASK =
"{from} 「{name}」 을(를) 복사해 「{to}」 에 새 항목으로 만듦 — 새 번호 · 원래 이름";
export async function renderM02MasterTemplate(root: HTMLElement): Promise<MountedPage> {
const user = await fetchSessionUser().catch(() => null);
// 구조물 옵션 정의는 M01 로 옮김(716-1) — 옛 주소는 M01 그 칸으로
if (redirectOldOptions(user)) return { covers: () => false, show: () => {}, dispose: () => {} };
const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터)
const company = viewCompany(entry, user); // 볼 회사(시스템 관리자는 고른 회사)
// 구조물 조합 층(720-4) — 층 단추 · 사람 나무 · 공유 승인 띠가 바꿈 · 페이지는 그대로 두고 제자리에서 갈아 끼움
// (`switchStructure` · 해시는 replaceState) · 단가산출 · 일위대가 층 · 왼쪽 칸 펼침은 그대로
let layer: ManageLayer = "system";
let owner: string | null = null;
let people = false;
let isAdmin = false; // 이 층을 고칠 수 있나(하위 부품의 「관리자」)
let readOnlyBar: ReturnType<typeof buildReadOnlyBar> | null = null;
let share: ReturnType<typeof createShare>;
let tree: ReturnType<typeof createTreePanel>;
let turn = 0;
/** 고른 층 → 실제 층 — 회사 층 주인 = 볼 회사 · 자기 층 주인을 적어도 자기 층 ·
* 시스템 관리자가 남의 회사 「개인」 = 사람 나무(29-6 · 안 골랐으면 첫 사람 · 사람이 없으면 마스터) ·
* 회사 없는 사람의 회사 · 개인 = 마스터(단추 자리에 안내) */
const resolve = async (want: Root, wantOwner: string | null) => {
let [at, lay] = [want, layerOfRoot(want)];
let who = wantOwner ?? (want === "company" ? companyOwner(company, user) : null);
if (who === ownOwner(lay, user)) who = null;
const many = want === "personal" && seesOthers(company, user);
if (many && !who) {
who = (await fetchOwners("personal", company).catch(() => []))[0]?.owner ?? null;
if (!who) [at, lay] = ["master", "system"];
}
if (accessFor(lay, who, user).needCompany) [at, lay, who] = ["master", "system", null];
return { root: at, layer: lay, owner: who, people: many && at === "personal" };
};
// 「〇〇로 복사…」(30-2 · 39-6) — 쓸 수 있는 층 전부(마스터 · 볼 회사 · 내 개인 · 지금 보는 층은 뺌) ·
// 대상 폴더는 고르는 창
const copyTargets = () => {
const here = owner === ownOwner(layer, user) ? null : owner;
return (["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 build = (): void => {
// 자기 회사용을 읽기만 볼 때 — 안내 + [개인용 복사]
readOnlyBar = layer === "company" && !owner && !isAdmin ? buildReadOnlyBar(user) : null;
// 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠
share = createShare({
user,
company,
layer,
owner,
showView: (label, node) =>
void main.confirmLeave().then((ok) => ok && main.showView(label, node)),
openPrecombo: (r) =>
void main.open({
layer: "personal",
owner: r.주인,
canWrite: false,
projectId: null,
kind: "precombo",
name: r.키,
label: r.이름,
}),
openItem: (r) => void go("personal", r.주인),
changed: () => void Promise.all([side.refresh(), tree.refresh()]),
});
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
tree = createTreePanel({
layer,
owner,
copyTo: copyTargets(),
more: share.itemMenu,
canEdit: canEdit(layer, isAdmin),
onPick: (item) => void main.openTable(layer, item.열),
settle: () => main.settle(),
// 나무가 바뀜(새 항목 · 복사 생성 · 붙여넣기 · 지우기 · 이름 · 옮기기) — 집계표
onChanged: async (colId) => {
await main.openTable(layer, colId, true);
},
});
topHost.replaceChildren(...(readOnlyBar ? [readOnlyBar.root] : []), share.top);
treeHost.replaceChildren(
layerBar(tree.add, () => void main.openTable(layer)),
peopleTree(tree.root),
);
void tree.refresh();
};
/** 층을 정해 페이지 층 길 · 권한을 맞춤 */
const settleLayer = (got: Awaited<ReturnType<typeof resolve>>): void => {
[entry.root, entry.owner, layer, owner, people] = [
got.root,
got.owner,
got.layer,
got.owner,
got.people,
];
setActiveLayer(layer, owner);
isAdmin = accessFor(layer, owner, user).canWrite;
};
/** 구조물 조합 층 바꿈(720-4) — 제자리 · 주소 replaceState · 새 층 구조물 표 */
const switchStructure = async (want: Root, wantOwner: string | null): Promise<void> => {
const mine = ++turn;
const got = await resolve(want, wantOwner);
if (mine !== turn) return;
settleLayer(got);
const query = new URLSearchParams(entryQuery({ ...entry, open: "구조물조합" }));
history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
build();
await main.openTable(layer, null, true);
};
const go = async (next: Root, nextOwner: string | null): Promise<void> => {
// 저장 안 한 고침은 먼저 물음(취소 = 층 그대로)
if (!(await main.confirmLeave())) return;
await switchStructure(next, nextOwner);
};
// 층 단추 셋(PLAN 29-2) — 「구조물 조합」 줄 아래 한 벌
// 단추 옆 「+」 = 그 나무 맨 위 더하기(뿌리 줄을 갈음 · 29-6)
// 켜진 단추를 다시 누르면 그 층 목록 화면(30-4 · 집계표)
const layerBar = (add: HTMLElement, reselect: () => void): HTMLElement =>
createLayerBar({
user,
active: { root: entry.root, owner },
company,
go: (next, nextOwner) => void go(next, nextOwner),
add,
reselect,
});
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
const peopleTree = (panel: HTMLElement): HTMLElement =>
people && owner && company
? createPeopleTree({
company,
active: owner,
panel,
go: (next) => void go("personal", next),
})
: panel;
const topHost = el("div");
const treeHost = el("div");
// 704-4 [구조물 조합으로] 만든 항목 → 나무 · 옆 목록 · 열린 표(행렬)를 다시(페이지가 내려가면 듣기 끝)
const afterItem = async (): Promise<void> => {
await Promise.all([side.refresh(), tree.refresh()]);
const now = main.current();
const shown = now?.kind === "table" && now.name === STRUCTURE_TABLE;
if (shown && (await main.settle()) !== false) await main.openTable(layer, null, true);
};
const onItem = (): void =>
main.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem);
document.addEventListener(ITEM_BUILT, onItem);
const main = createMain(
() => isAdmin,
() => void Promise.all([side.refresh(), tree.refresh()]),
() => side.paint(),
{
top: topHost,
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만(편집 칸이 가림) · [새로 만들기] [복사 생성](39-5)
newItem: newItemButton(() => void openNew()),
onColumn: (column) => {
readOnlyBar?.setColumn(column);
tree.select(column);
},
// 표 머리 이름 칸 고침 = 나무 이름 바꾸기(31-1) · 쓸 수 있는 층만(나무가 가림)
onHeadRename: (col: StructureColumn, level: number, name: string) =>
void tree.editFromTable(headRenameEdit(col, level, name), col.id),
},
);
settleLayer(await resolve(entry.root, entry.owner ?? null));
build();
/** 새 구조물 조합(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 });
// 일위대가 사전조합 층(134-3) — 페이지 층(해시 root)과 따로 · 마스터 · 볼 회사 · 내 개인(사람 나무 없음) ·
// 못 가는 층(회사 없음) = 마스터 · 고른 층 = 주소 `pre`(페이지를 다시 세우지 않음 · replaceState)
const preScope = (want: Root): PrecomboScope => {
const at =
(want === "company" && !company) || (want === "personal" && !ownOwner("personal", user))
? "master"
: want;
const to = layerOfRoot(at);
const who = at === "company" ? companyOwner(company, user) : null;
return { root: at, layer: to, owner: who, canWrite: accessFor(to, who, user).canWrite };
};
let pre = preScope(entry.pre ?? defaultRoot(user));
const setPre = (want: Root): void => {
pre = preScope(want);
entry.pre = pre.root;
const query = new URLSearchParams(entryQuery(entry));
history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
void side.refreshPrecombo();
};
// 층 단추 옆 「+」(720-2) = 서고 칸이 만든 맨 위 메뉴
const preBar = (add: HTMLElement): HTMLElement =>
createLayerBar({
user,
active: { root: pre.root, owner: pre.owner },
company,
add,
// 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표(720-1) · 켜진 단추 다시 누름 = 목록표
go: (next) =>
void main
.confirmLeave()
.then((ok) => ok && (setPre(next), void main.open(precomboList(pre)))),
reselect: () =>
void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))),
});
// 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음
let sk = preScope("personal");
const skBar = (add: HTMLElement): HTMLElement =>
createLayerBar({
user,
active: { root: sk.root, owner: sk.owner },
company,
add,
go: (next) =>
void main
.confirmLeave()
.then(
(ok) =>
ok &&
((sk = preScope(next)), void side.refreshSangeun(), void main.open(sangeunList(sk))),
),
reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))),
});
const side = buildSide({
isSystem: user?.role === "SYSTEM_ADMIN",
canWrite: () => canEdit(layer, isAdmin),
onOpen: (sel) => void main.open(sel ?? home()),
getOpen: main.current,
confirmLeave: main.confirmLeave,
showView: main.showView,
tree: [treeHost],
precombo: {
scope: () => pre,
bar: preBar,
more: (key, name) => share.precomboMenu(pre.root === "personal" && !pre.owner, key, name),
},
sangeun: { scope: () => sk, bar: skBar },
});
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);
mountSettingsDrawer(user ?? {}, "m02", entry.company); // ☰ 시스템 설정 바로가기(334)
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
}