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>
This commit is contained in:
2026-11-01 21:37:10 +09:00
co-authored by Claude Opus 5.5
parent a0c2db507f
commit ace439ad04
12 changed files with 221 additions and 162 deletions
@@ -13,8 +13,8 @@ import { buildHopyoLedger, LEDGER_HINT, LEDGER_TITLE } from "./M02_MasterTemplet
import { buildHopyoWizard } from "./M02_MasterTemplete_Hopyo_Wizard";
export interface HopyoSectionOptions {
/** 이 층 쓰기 권한 — 별칭 장부 고침 */
canWrite: boolean;
/** 이 층 쓰기 권한 — 별칭 장부 고침(구조물 조합 층이 제자리에서 바뀌어 열 때 읽음 · 720-4) */
canWrite: () => boolean;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 화면을 띄움 */
@@ -34,7 +34,6 @@ const WIZARD = "불러오기";
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const title = L("M02_HopyoImport");
const scope = pageScope();
const start = createButton({
label: "불러오기",
variant: "filled",
@@ -63,7 +62,9 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
if (open?.key === key && open.view.isConnected) return;
if (!(await opt.confirmLeave())) return;
const view =
key === WIZARD ? buildHopyoWizard() : buildHopyoLedger({ scope, canWrite: opt.canWrite });
key === WIZARD
? buildHopyoWizard()
: buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() });
opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view);
opt.onOpen();
open = { key, view };
@@ -130,9 +130,10 @@ export function newItemButton(run: () => void, label = L("M02_TreeNewItem")): HT
return button;
}
/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) */
/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) ·
* `isAdmin` = 지금 구조물 조합 층 고칠 권한(층을 제자리에서 바꾸므로 함수 · 720-4) */
export function createMain(
isAdmin: boolean,
isAdmin: () => boolean,
onSaved?: () => void,
onOpened?: () => void,
extras: MainExtras = {},
@@ -185,7 +186,7 @@ export function createMain(
// 「새 항목」 · 표 접기(제목)는 「구조물 조합」일 때만
const showAside = (): void => {
const combo = isCombo(sel);
if (extras.newItem) extras.newItem.hidden = !combo;
if (extras.newItem) extras.newItem.hidden = !combo || !sel || !writable(sel);
title.classList.toggle("m02-main__title--fold", combo);
title.title = combo ? L("M02_TableFold") : "";
if (!combo) {
@@ -251,7 +252,7 @@ export function createMain(
host.replaceChildren(createEmpty({ text }));
};
/** 고를 것마다 고칠 권한 — 사전조합 층은 고름에 실림(134-3) · 그 밖 페이지 층 */
const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);
const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin());
const bar = (): void => {
reload.hidden = !sel;
save.hidden = !sel || !writable(sel);
@@ -282,7 +283,7 @@ export function createMain(
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드 */
function buildPanel(layer: Layer): StructurePanelHandle {
const made = createStructurePanel({
isAdmin,
isAdmin: isAdmin(),
confirmLeave,
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
onNumbers: () => sheet?.recalc(), // 카드 [저장] 뒤 번호 · 일위대가 합계 줄을 새로(86-3)
+134 -103
View File
@@ -4,7 +4,8 @@
* 우측 편집 칸(표·도면 부품 · 「구조물 조합」이면 나무는 좌측 패널 · PLAN 29-2)
*
* 한 화면에 층 셋(마스터 · 회사 · 개인 · PLAN 27-1) — 고른 층 = 진입 해시 `?open=&root=&owner=&company=`
* (`M02_MasterTemplete_Layer.ts`) · 층 단추 · 「사람 고르기」를 누르면 해시를 바꿔 이 화면을 다시 세움.
* (`M02_MasterTemplete_Layer.ts`) · 구조물 조합 층 단추 · 사람 나무를 누르면 제자리에서 그 층으로 갈아 끼움
* (720-4 · 해시는 replaceState · 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로).
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `accessFor`).
* ========================================================================== */
@@ -20,7 +21,7 @@ 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 { navigateTo, type MountedPage } from "../A00_Common/router";
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 {
@@ -33,7 +34,7 @@ import {
ownOwner,
redirectOldOptions,
viewCompany,
type OpenTarget,
type ManageLayer,
type Root,
} from "./M02_MasterTemplete_Layer";
import {
@@ -67,100 +68,141 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
if (redirectOldOptions(user)) return { covers: () => false, show: () => {}, dispose: () => {} };
const entry = entryFromHash(user); // 134-4 — 해시에 층 없음 = 개인(회사 없으면 마스터)
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;
// 고른 층의 나무 — 고르기 모달과 같은 폴더 나무 · 좌측 「구조물 조합」 줄 아래
// 구조물 조합 층(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 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}`) }];
});
// 회사에 올리기 요청 · 승인(706-1) — 요청 = 개인 층 나무 덧메뉴 · 승인 = 회사 층 위 띠 · 결과 띠
const 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()]),
});
const tree = createTreePanel({
layer,
owner,
copyTo,
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);
},
});
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 }),
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 = (open: OpenTarget, add: HTMLElement | null, reselect: () => void): HTMLElement =>
const layerBar = (add: HTMLElement, reselect: () => void): HTMLElement =>
createLayerBar({
user,
active: { root: entry.root, owner },
company,
go: (next, nextOwner) => void go(next, nextOwner, open),
...(add ? { add } : {}),
go: (next, nextOwner) => void go(next, nextOwner),
add,
reselect,
});
// 남의 회사 「개인」 — 고른 사람 줄 아래에 그 사람 나무(사람 줄 → 라이브러리 · 29-6)
const peopleTree = (open: OpenTarget, panel: HTMLElement): HTMLElement =>
const peopleTree = (panel: HTMLElement): HTMLElement =>
people && owner && company
? createPeopleTree({
company,
active: owner,
panel,
go: (next) => void go("personal", next, open),
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()]);
@@ -169,29 +211,27 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
if (shown && (await main.settle()) !== false) await main.openTable(layer, null, true);
};
const onItem = (): void =>
tree.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem);
main.root.isConnected ? void afterItem() : document.removeEventListener(ITEM_BUILT, onItem);
document.addEventListener(ITEM_BUILT, onItem);
const main = createMain(
isAdmin,
() => isAdmin,
() => void Promise.all([side.refresh(), tree.refresh()]),
() => side.paint(),
{
top: el("div", { children: [...(readOnlyBar ? [readOnlyBar.root] : []), share.top] }),
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만 · 상세 칸에 [새로 만들기] [복사 생성](39-5)
...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {}),
top: topHost,
// 「새 항목」(30-5) — 제목 줄 오른쪽 끝 · 쓸 수 있는 층만(편집 칸이 가림) · [새로 만들기] [복사 생성](39-5)
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),
}
: {}),
// 표 머리 이름 칸 고침 = 나무 이름 바꾸기(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> => {
@@ -214,12 +254,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
);
};
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
const home: Selection = {
layer,
projectId: null,
kind: "table",
name: STRUCTURE_TABLE,
};
const home = (): Selection => ({ layer, projectId: null, kind: "table", name: STRUCTURE_TABLE });
// 일위대가 사전조합 층(134-3) — 페이지 층(해시 root)과 따로 · 마스터 · 볼 회사 · 내 개인(사람 나무 없음) ·
// 못 가는 층(회사 없음) = 마스터 · 고른 층 = 주소 `pre`(페이지를 다시 세우지 않음 · replaceState)
const preScope = (want: Root): PrecomboScope => {
@@ -273,16 +308,13 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))),
});
const side = buildSide({
layer,
isAdmin,
onOpen: (sel) => void main.open(sel ?? home),
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: [
layerBar("구조물조합", tree.add, () => void main.openTable(layer)),
peopleTree("구조물조합", tree.root),
],
tree: [treeHost],
precombo: {
scope: () => pre,
bar: preBar,
@@ -307,12 +339,11 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
);
root.innerHTML = "";
root.append(layout);
void main.open(home);
void main.open(home());
// 진입 해시 `open` — 옵션 · 도면 컨테이너는 마스터에만 ·
// 옵션 화면이 뒤에 열리면 편집 칸 차례(`seq`)가 늦게 온 집계표를 버림
if (entry.open !== "구조물조합") side.reveal(entry.open);
mountSettingsDrawer(user ?? {}, "m02", entry.company); // ☰ 시스템 설정 바로가기(334)
void tree.refresh();
// 이 화면을 떠나면 층 길을 시스템 · 자기 층으로 되돌림(다른 화면의 항목 고르기가 남의 층을 안 부르게)
return { covers: () => false, show: () => {}, dispose: () => setActiveLayer("system") };
}
@@ -4,6 +4,7 @@
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서.
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 도면 양식 · 실무문서 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
@@ -34,7 +35,7 @@ import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { createEmpty } from "@ui/ui_template_empty";
@@ -54,10 +55,10 @@ const STUB_DRAWING = {
};
export interface SideOptions {
/** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */
layer: Layer;
/** 이 층을 고칠 수 있나 */
isAdmin: boolean;
/** 시스템 관리자 — 도면 양식(시스템 층에만 있음) · 실무문서 칸을 늘 보임(720-4) */
isSystem: boolean;
/** 지금 구조물 조합 층을 고칠 수 있나(층이 제자리에서 바뀜) — 일위대가 불러오기 장부 */
canWrite: () => boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
@@ -97,7 +98,7 @@ const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
export function buildSide(opt: SideOptions): SideHandle {
const layer = opt.layer;
const layer: Layer = "system"; // 도면 양식 목록 · 고름의 층
let items: TemplateInfo[] = [];
let seq = 0;
@@ -112,7 +113,7 @@ export function buildSide(opt: SideOptions): SideHandle {
/** 새로 · 본떠 만들기 · 지우기 — 도면 양식 컨테이너 안(구조물 집계 양식은 집계표 하나라 없음) · 관리자만 */
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const actionRow = (kind: Kind): HTMLElement[] => {
if (!canEdit(layer, opt.isAdmin)) return [];
if (!opt.isSystem) return [];
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const copy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
@@ -160,23 +161,21 @@ export function buildSide(opt: SideOptions): SideHandle {
? void Promise.all([precombo.refresh(), sangeun.refresh()])
: document.removeEventListener(HOPYO_BUILT, onBuilt);
document.addEventListener(HOPYO_BUILT, onBuilt);
// 도면 양식은 시스템 층(마스터 템플릿)에만
const drawing =
layer === "system"
? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))
: null;
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
const practice =
layer === "system" && opt.isAdmin
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: paint,
})
: null;
// 도면 양식은 시스템 층(마스터 템플릿)에만 — 시스템 관리자면 늘(720-4)
const drawing = opt.isSystem
? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))
: null;
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 시스템 관리자만
const practice = opt.isSystem
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: paint,
})
: null;
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: canEdit(layer, opt.isAdmin),
canWrite: opt.canWrite,
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (practice?.clear(), paint()),
@@ -197,10 +196,10 @@ export function buildSide(opt: SideOptions): SideHandle {
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
/** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐 */
/** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐(도면 양식 = 시스템 층뿐 · 구조물 조합 표 = 어느 층이든) */
const isPicked = (kind: Kind, name: string): boolean => {
const cur = opt.getOpen();
return !!cur && cur.layer === layer && cur.kind === kind && cur.name === name;
return !!cur && cur.kind === kind && cur.name === name;
};
const isOpen = (info: TemplateInfo): boolean => isPicked(info.종류, info.이름);
@@ -260,7 +259,7 @@ export function buildSide(opt: SideOptions): SideHandle {
const mine = ++seq;
paintButtons();
try {
items = await listTemplates(layer, null);
items = opt.isSystem ? await listTemplates(layer, null) : []; // 도면 양식 목록 — 시스템 관리자만
} catch (error) {
items = [];
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
+21 -1
View File
@@ -62,7 +62,7 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기():
assert "setPre(next), void main.open(precomboList(pre))" in page
assert "void main.open(sangeunList(sk))" in page.split("const skBar")[1].split("reselect:")[0]
# 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([])
assert page.count(" add,\n") == 2
assert page.count(" add,\n") == 3 # 구조물 조합 · 일위대가 · 단가산출
assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib
assert "add.hidden = !at.canWrite;" in lib and "opt.bar(add)" in lib
assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄
@@ -74,3 +74,23 @@ def test_화면_층_단추_최상위_폴더_이름_바꾸기():
assert "이름: name }" in lib and "saveTemplate(at.layer, kind, row.이름, null" in lib
main = _text("M02_MasterTemplete_UI_Main.ts")
assert "title.textContent = at.label = named ? `${at.name} ${named}` : at.name;" in main
def test_구조물_조합_층_제자리_바꿈():
"""720-4 — 해시 이동 없음 · replaceState · 나무 · 층 줄 · 띠를 감싼 칸에 갈아 끼움 · 권한 = 함수 ·
도면 양식 · 실무문서 = 시스템 관리자면 늘 · 불러오기 장부 = 열 때 층 · 권한."""
page = _text("M02_MasterTemplete_UI_Page.ts")
assert "navigateTo" not in page
switch = page.split("const switchStructure = async")[1].split("const go =")[0]
assert "history.replaceState(" in switch and "build();" in switch
assert "await main.openTable(layer, null, true);" in switch
assert "if (mine !== turn) return;" in switch # 늦게 온 옛 고름 버림
build = page.split("const build = (): void => {")[1].split("const settleLayer")[0]
assert "topHost.replaceChildren(" in build and "treeHost.replaceChildren(" in build
assert "tree: [treeHost]," in page and "top: topHost," in page
assert "() => isAdmin," in page and 'isSystem: user?.role === "SYSTEM_ADMIN",' in page
assert "main.root.isConnected ? void afterItem()" in page # 나무를 갈아도 듣기 그대로
side = _text("M02_MasterTemplete_UI_Side.ts")
assert "const drawing = opt.isSystem" in side and "const practice = opt.isSystem" in side
hopyo = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert "buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo
+1 -1
View File
@@ -106,7 +106,7 @@ def test_나무는_좌측_패널_찾기_칸_없음():
assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "createTreePanel({" in page and "tree.select(column)" in page
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
assert "layerBar(tree.add, () => void main.openTable(layer))," 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 # 상세 제목
+3 -1
View File
@@ -87,7 +87,9 @@ def test_머리_고침은_쓸_수_있는_층만_나무로():
assert "readOnly || !extras.onHeadRename" in main
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert "tree.editFromTable(headRenameEdit(col, level, name), col.id)" in page
assert page.index("...(canEdit(layer, isAdmin)\n ? {\n onHeadRename") > 0
assert (
"onHeadRename: (col: StructureColumn" in page
) # 720-4 — 가림은 편집 칸 readOnly · 나무 canEdit
tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert "if (opt.canEdit && (await ready())) await edit(body, colId);" in tree
+4 -2
View File
@@ -342,7 +342,7 @@ def test_좌측_판_자리와_권한():
order = ("precomboSec,", "summary,", "createDivider(),", "section(hopyo.title, hopyo.body),")
assert [host.index(k) for k in order] == sorted(host.index(k) for k in order)
assert host.index("section(hopyo.title, hopyo.body),") < host.index("drawing ?")
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "canWrite: opt.canWrite," in side # 구조물 조합 층 쓰기 권한(720-4 함수)
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
# 718-1 — 제목 = 화면 글 키 · [불러오기] = 구조물 조합 층 단추 꼴 → 본 칸 마법사 · 옛 파일 목록 없음
@@ -351,7 +351,9 @@ def test_좌측_판_자리와_권한():
assert 'label: "불러오기"' in start and 'start.classList.add("m02-layers__btn");' in start
assert "buildHopyoWizard()" in hopyo_side and "listHopyo" not in hopyo_side
assert "uploadHopyo" not in hopyo_side and "openHopyoSheet" not in hopyo_side
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
assert (
"buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() })" in hopyo_side
) # [별칭 장부]
wizard = _text("M02_MasterTemplete_Hopyo_Wizard.ts")
assert 'accept: ".xls,.xlsx"' in wizard and "input.hidden = true;" in wizard
assert "accessFor(layer, null, user).canWrite" in wizard # 결과 층 = 쓸 수 있는 층만
+13 -12
View File
@@ -181,7 +181,10 @@ def test_층_단추_셋_남의_회사_사람_나무():
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
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]
# 720-4 — 다른 층 = 저장 안 한 고침 물음 뒤 제자리 바꿈(해시 이동 · 페이지 다시 세움 없음)
go = page.split("const go =")[1].split("};")[0]
assert "await main.confirmLeave()" in go and "await switchStructure(next, nextOwner);" in go
assert "navigateTo" not in page
assert 'dispose: () => setActiveLayer("system")' in page
assert "side.reveal(entry.open)" in page
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
@@ -192,9 +195,9 @@ def test_층_단추_셋_남의_회사_사람_나무():
assert "manages(company, user);" in roots.split("export const seesOthers")[1] # 맡은 회사(39-6)
assert "const readOnly = !people && !accessFor(" in roots # 사람 나무 = 고침
assert 'L("M02_PeopleEmpty")' in roots
assert 'const people = entry.root === "personal" && seesOthers(company, user);' in page
assert 'const many = want === "personal" && seesOthers(company, user);' in page
assert '(await fetchOwners("personal", company).catch(() => []))[0]?.owner' in page
assert 'peopleTree("구조물조합", tree.root)' in page
assert "peopleTree(tree.root)" in page
assert 'peopleTree("일위대가"' not in page # 일위대가 사람 나무 벌은 39-2 에서 지움
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert (
@@ -223,8 +226,8 @@ def test_층_길_접두어와_고정_풀림():
in labor
)
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert "const layer = opt.layer;" in side
assert 'layer === "system"' in side # 도면 양식은 시스템 층에만
assert 'const layer: Layer = "system";' in side # 도면 양식은 시스템 층에만
assert "const drawing = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert 'sel.layer !== "system"' not in main
assert 'return layer === "project" || isAdmin;' in main
@@ -236,7 +239,7 @@ def test_읽기만_회사용_개인용으로_복사_막대():
assert (
"readOnlyBar?.setColumn(column)" in page
) # 열 고름 → 막대 · 구조물 라이브러리가 함께 받음
assert "accessFor(layer, owner, user).needCompany" in page # 회사 없음 = 마스터로
assert "if (accessFor(lay, who, user).needCompany)" in page # 회사 없음 = 마스터로
assert "buildNeedCompany()" in _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert 'title: L("M02_Title")' in page # 한 화면 한 제목
layer = _text(LAYER)
@@ -290,7 +293,7 @@ def test_켜진_층_단추는_목록으로_복사_끌기():
roots = _text(M02 / "M02_MasterTemplete_TreePanel_Roots.ts")
assert "if (!current) opt.go(root, owner);\n else opt.reselect?.();" in roots
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer))' in page
assert "layerBar(tree.add, () => void main.openTable(layer))" in page
# 39-6 — 쓸 수 있는 층 전부(볼 회사 주인까지 · 지금 층 뺌) · 대상 폴더 고르는 창
assert 'const toOwner = one === "company" ? companyOwner(company, user) : null;' in page
assert "(to === layer && toOwner === here)) return [];" in page
@@ -316,12 +319,10 @@ def test_구조물_조합_제목_줄_새_항목():
r"\s*\.\.\.\(extras\.newItem \? \[extras\.newItem\] : \[\]\),?\s*\]",
main,
)
assert "if (extras.newItem) extras.newItem.hidden = !combo;" in main
# 720-4 — 쓸 수 있는 층만 = 편집 칸이 고른 것 권한으로 가림(층이 제자리에서 바뀜)
assert "extras.newItem.hidden = !combo || !sel || !writable(sel);" in main
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert (
"...(canEdit(layer, isAdmin) ? { newItem: newItemButton(() => void openNew()) } : {})"
in page
)
assert "newItem: newItemButton(() => void openNew())," in page
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
assert 'return at?.kind === "folder" ? at.path : [];' in panel # 고른 폴더(39-5 folder())
assert "const newItem = (): void => void addItem(folder());" in panel
+2 -2
View File
@@ -51,9 +51,9 @@ def test_통째_지우기_시스템_관리자만_판_쓰는_곳(world, master_co
def test_화면_시스템_관리자_마스터만_물음_두_번_읽기만():
side = (M02 / "M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
assert 'layer === "system" && opt.isAdmin' in side
assert "const practice = opt.isSystem" in side # 720-4 — 시스템 관리자면 늘
order = side.split('className: "m02-side__lists ui-sidebar-panel",')[1].split("});")[0]
assert order.index("extras.도면") < order.index("practice") # 도면 양식 아래
assert order.index("drawing ?") < order.index("practice ?") # 도면 양식 아래
view = (M02 / "M02_MasterTemplete_Practice.ts").read_text("utf-8")
assert view.count("showConfirmDialog(") == 2
assert "dropPractice(" in view and "createInputField" not in view # 고칠 칸 없음
+7 -5
View File
@@ -72,15 +72,17 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre
assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음
assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음
bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split("const side = buildSide(")[0]
bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split(
"const side = buildSide("
)[0]
# 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표
go = bar.split("go: (next) =>")[1].split("reselect:")[0]
assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0]
assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
assert page.count('layerBar("') == 1
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
# 구조물 조합 층 단추 한 벌(720-4 제자리 바꿈)
assert page.count("layerBar(") == 1
assert "layerBar(tree.add, () => void main.openTable(layer))," in page
assert "precombo: {\n scope: () => pre,\n bar: preBar," in page # 706-1 more 덧붙음
@@ -92,7 +94,7 @@ def test_고름에_owner_권한_서고_길에_명시_owner():
assert "listTemplates(at.layer, null, at.owner)" in lib_side
main = _text("M02_MasterTemplete_UI_Main.ts")
assert (
"const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin);"
"const writable = (at: Selection): boolean => at.canWrite ?? canEdit(at.layer, isAdmin());"
in main
)
assert "readTemplate(next.layer, next.kind, next.name, next.projectId, next.owner)" in main
+1 -1
View File
@@ -180,7 +180,7 @@ def test_면적_오른쪽_일위대가_패널은_공용_틀_손잡이():
def test_첫_화면과_닫은_뒤는_구조물집계표():
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert re.search(r'kind: "table",\s*name: STRUCTURE_TABLE', page)
assert "main.open(sel ?? home)" in page and "void main.open(home);" in page
assert "main.open(sel ?? home())" in page and "void main.open(home());" in page
def test_좌측_고름_표시는_열린_것_한_줄뿐():