- 페이지: 층 고름(resolve) · 층 맞춤(settleLayer) · 띠 · 나무 · 층 줄 갈아 끼움(build) 한 길 · 주소 replaceState · 새 층 구조물 표 - 편집 칸 권한 = 함수(UI_Main isAdmin) · 「새 항목」 = 고른 것 권한으로 가림 · 불러오기 장부 = 열 때 층 · 권한(Hopyo_Side) - 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로 - 낡은 시험(층 단추 해시 이동 · 사람 나무 · 도면 칸 층 묶음 · 새 항목 · 머리 고침 · 불러오기 권한 · 실무문서 차례 · home) 새 꼴로 — test_m02_practice 도면 차례 글은 이미 낡아 실패하던 것 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
16 KiB
TypeScript
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") };
|
|
}
|