feat(M02): 구조물 관리 화면 — 층 인자(개인 · 회사) · 층별 권한 · 대시보드 카드

- 주소 `?layer=personal|company`(없으면 시스템) — 라우터가 인자를 떼고 `currentQuery` 로 읽음 · 층 고정 풂
- 층 길 접두어 `/layers/{층}` — 구조물집계표 · 조합 · 복사 호출이 화면 층을 따름(시스템은 옛 길 그대로)
- 제목 셋(마스터 템플릿 · 구조물 관리(회사) · 구조물 관리(개인)) · 돌아가기 층 무관 · 도면 양식은 시스템 층만
- 편집 권한 — 시스템 SYSTEM_ADMIN · 회사 ADMIN 또는 is_master · 개인 본인 · 읽기만이면 단추 숨김 + [개인용으로 복사]
- 회사 없음 → 「회사부터 등록」 안내 · 대시보드 카드 「구조물 관리」(USER 개인용 · ADMIN · SYSTEM_ADMIN 회사용 + 개인용)
- 시험 test_m02_layer_screen.py 5 · test_m02_structure_panel.py 갱신

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA
This commit is contained in:
2026-09-29 12:09:31 +09:00
co-authored by Claude Sonnet 5.5
parent b7449f857c
commit 154d693599
14 changed files with 391 additions and 48 deletions
+3 -1
View File
@@ -52,8 +52,10 @@ function bootstrap(): void {
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = "";
let shownHash = "";
const renderAll = (): void => {
shown = currentRoute();
shownHash = window.location.hash;
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
@@ -68,7 +70,7 @@ function bootstrap(): void {
setLeaveGuard(null);
renderAll();
} else {
history.replaceState(null, "", `#/${shown}`); // 취소 — 머문 채 주소만 되돌림
history.replaceState(null, "", shownHash); // 취소 — 머문 채 주소만 되돌림
}
});
});
+10 -5
View File
@@ -78,15 +78,20 @@ export async function isAuthenticated(): Promise<boolean> {
return fetchSession();
}
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home) */
/** 현재 해시에서 라우트 경로 추출 (#/a01-home → a01-home · `?이름=값` 뒤는 뗌) */
export function currentRoute(): RoutePath {
const hash = window.location.hash.replace(/^#\/?/, "").trim();
const hash = window.location.hash.replace(/^#\/?/, "").split("?")[0].trim();
return (hash || DEFAULT_ROUTE) as RoutePath;
}
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) */
export function navigateTo(route: RoutePath): void {
const target = `#/${route}`;
/** 현재 해시의 `?이름=값` 인자 (#/m02-master-template?layer=personal → layer=personal) */
export function currentQuery(): URLSearchParams {
return new URLSearchParams(window.location.hash.split("?")[1] ?? "");
}
/** 지정 라우트로 이동 (해시 변경 → hashchange 이벤트로 렌더 트리거) · `query` = `?이름=값` 인자 */
export function navigateTo(route: RoutePath, query?: Record<string, string>): void {
const target = `#/${route}${query ? `?${new URLSearchParams(query)}` : ""}`;
if (window.location.hash === target) {
// 동일 해시면 hashchange가 안 뜨므로 수동 트리거
window.dispatchEvent(new HashChangeEvent("hashchange"));
+20
View File
@@ -148,6 +148,23 @@ function buildSystemSettingsPanel(): HTMLElement {
return wrap;
}
/** 구조물 관리 카드 — 개인용은 모두 · 회사용은 회사 관리자와 시스템 관리자(21-0) */
function buildStructurePanel(state: DashboardState): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__actions";
const go = (layer: "company" | "personal", label: string): HTMLElement =>
createButton({
label,
variant: "ghost",
onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE, { layer }),
});
if (state.user.role === "ADMIN" || state.user.role === "SYSTEM_ADMIN") {
wrap.append(go("company", L("B01_Dashboard_StructureCompany")));
}
wrap.append(go("personal", L("B01_Dashboard_StructurePersonal")));
return wrap;
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
@@ -169,6 +186,7 @@ function buildPage(state: DashboardState): HTMLElement {
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
]),
buildTempUploadSection(),
section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)),
section(L("B01_Dashboard_Users"), userTable(state.allUsers, state.user), true, [
createButton({ label: "+", onClick: () => openAddMemberModal() }),
]),
@@ -183,6 +201,7 @@ function buildPage(state: DashboardState): HTMLElement {
createButton({ label: "+", onClick: () => navigateTo(ROUTES.B02_PROJ_REGISTER) }),
]),
buildTempUploadSection(),
section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)),
section(L("B01_Dashboard_Members"), memberTable(state.members, state.user), true, [
createButton({ label: "+", onClick: () => openAddMemberModal() }),
]),
@@ -197,6 +216,7 @@ function buildPage(state: DashboardState): HTMLElement {
// 임시 보관함 — 프로젝트를 만들기 전에 자료를 올려 두는 곳이라 프로젝트 목록
// 바로 아래에 둔다(2026-08-08 사용자 지시).
buildTempUploadSection(),
section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state)),
section(L("B01_Dashboard_Company"), buildCompanyPanel(state), true),
);
}
@@ -53,6 +53,15 @@ function reason(detail: unknown, status: number): string {
return lines.filter(Boolean).join(" · ") || `HTTP ${status}`;
}
/** 지금 화면의 층 — 시스템 = 옛 길 그대로 · 회사 · 개인 = `/layers/{층}` 아래(구조물집계표 · 조합 길 모두) */
let activeLayer: Layer = "system";
export const setActiveLayer = (layer: Layer): void => {
activeLayer = layer;
};
export const getActiveLayer = (): Layer => activeLayer;
const scoped = <T>(path: string, init: RequestInit = {}): Promise<T> =>
call<T>(`${activeLayer === "system" ? "" : `/layers/${activeLayer}`}${path}`, init);
const enc = encodeURIComponent;
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
const head = (layer: Layer): string =>
@@ -122,7 +131,7 @@ export const listStructures = async (): Promise<TemplateInfo[]> =>
/** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */
export const createStructure = (column: string): Promise<TemplateDoc> =>
call(`/structures/${enc(column)}`, { method: "POST" });
scoped(`/structures/${enc(column)}`, { method: "POST" });
/** 한 열의 번호 — 도번 `SD` · 면적번 `SA` · 재료번 `SM` + 같은 8자리
* [종류][분류1][분류2][분류3](없는 쪽은 빈 글) */
@@ -134,7 +143,7 @@ export interface StructureNumbers {
/** `{열 id: {도번, 면적번, 재료번}}` — 구조물 열만(글 열 · 없는 열은 키 없음 = 「없음」) */
export const fetchStructureNumbers = (): Promise<Record<string, StructureNumbers>> =>
call("/structures/numbers");
scoped("/structures/numbers");
/** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */
export interface StructureCombo extends StructureNumbers {
@@ -161,7 +170,7 @@ export interface StructureCombos {
}
/** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */
export const fetchStructureCombos = (): Promise<StructureCombos> => call("/structure-combos");
export const fetchStructureCombos = (): Promise<StructureCombos> => scoped("/structure-combos");
/** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */
export interface BranchedCombo {
@@ -182,14 +191,14 @@ export const branchCombo = (
판?: string;
},
): Promise<BranchedCombo> =>
call(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
scoped(`/structures/${enc(column)}/combo`, { method: "POST", body: JSON.stringify(body) });
/** 있는 조합을 이 열에 씀 — 집계표 그 열 일위대가 · 자재조합 키만 바꿈(조합은 그대로 · `새로만듦` false) */
export const setColumnCombo = (
column: string,
body: { 종류: "일위대가" | "자재조합"; 키: string; 판?: string },
): Promise<BranchedCombo> =>
call(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
scoped(`/structures/${enc(column)}/use-combo`, { method: "POST", body: JSON.stringify(body) });
/** 자재 조합(키 `UM`) 담은 자재 한 줄 */
export interface MaterialItem {
@@ -210,7 +219,7 @@ export interface MaterialCombo {
}
export const fetchMaterialCombo = (key: string): Promise<MaterialCombo> =>
call(`/material-combos/${enc(key)}`);
scoped(`/material-combos/${enc(key)}`);
/** 이 열의 자재 조합을 고친 담은 자재로 갈라 냄 — 규칙은 `branchCombo` 와 같음 ·
* `본뜬키` 빈 글 = 이 열의 첫 자재 조합 */
@@ -218,7 +227,7 @@ export const branchMaterialCombo = (
column: string,
body: { 본뜬키?: string; 담은자재: MaterialItem[]; 이름: string; 판?: string },
): Promise<BranchedCombo> =>
call(`/structures/${enc(column)}/material-combo`, {
scoped(`/structures/${enc(column)}/material-combo`, {
method: "POST",
body: JSON.stringify(body),
});
@@ -229,14 +238,14 @@ export const listMaterialCombos = (): Promise<{
판: string;
줄: MaterialCombo[];
쓰는곳: Record<string, string[]>;
}> => call("/material-combos");
}> => scoped("/material-combos");
/** 새로 · 본떠 만들기(`본뜬키`) — 키는 서버가 표 「다음」 번호로 줌 */
export const createMaterialCombo = (
판: string,
줄: Omit<MaterialCombo, "키">,
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
call("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) });
scoped("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) });
/** 고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 서버가 있던 것으로) */
export const editMaterialCombo = (
@@ -244,27 +253,39 @@ export const editMaterialCombo = (
판: string,
줄: MaterialCombo,
): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> =>
call(`/material-combos/${enc(키)}`, { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
scoped(`/material-combos/${enc(키)}`, { method: "PUT", body: JSON.stringify({ 판, 줄 }) });
/** 지우기 — 구조물 열이 쓰면 서버가 막고 그 열 이름들을 까닭으로 줌 */
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
scoped(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
export const copyStructure = async (column: string, from: string): Promise<void> => {
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
await scoped(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 다른 열(`from`) 면적산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 면적번 · 열은 그대로 */
export const copyArea = async (column: string, from: string): Promise<void> => {
await call(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
await scoped(`/area/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 다른 열(`from`) 재료산출 내용(알약 연결 포함)을 받는 열(`column`)로 가져옴 — 재료번 · 열은 그대로 */
export const copyMaterial = async (column: string, from: string): Promise<void> => {
await call(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
await scoped(`/material/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
};
/** 회사 · 개인 층의 집계표 항목(열 하나 + 도면 · 면적 · 재료 파일 + 자재조합 줄)을 내 개인 층으로 복사 —
* 새 SS 키로 만들고 `본뜬` 표시 · `fromOwner` = 회사 층 회사 · 개인 층 「회사/사용자」 */
export const copyItemToPersonal = (body: {
fromLayer: Layer;
fromOwner: string | null;
key: string;
}): Promise<{ key: string }> =>
call("/layers/personal/items/copy", {
method: "POST",
body: JSON.stringify({ from_layer: body.fromLayer, from_owner: body.fromOwner, key: body.key }),
});
/* --- 프로젝트 층 단추 다섯 --- */
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
const post = (path: string, body: object = {}): Promise<unknown> =>
@@ -4,7 +4,7 @@
* 서버 까닭 글. 갈라 내기 길(`branchCombo` · `branchMaterialCombo` · `fetchMaterialCombo`)은 `M02_MasterTemplete_Api_Fetch.ts`.
* ========================================================================== */
import { readTemplate } from "./M02_MasterTemplete_Api_Fetch";
import { getActiveLayer, readTemplate } from "./M02_MasterTemplete_Api_Fetch";
/** 구조물집계표 이름 — `M02_MasterTemplete_Structure.STRUCTURE_TABLE` 과 같은 글 */
const STRUCTURE_TABLE = "구조물집계표";
@@ -23,7 +23,9 @@ export const reasonOf = (error: unknown): string => (error instanceof Error ? er
/** 집계표(시스템 층)에서 이 열의 일위대가 · 자재조합 키와 같이 쓰는 다른 열 수 */
export async function readColumnBase(column: string): Promise<ColumnBase> {
type Col = { id: string; 일위대가?: string | null; 자재조합?: string | null };
const table = await readTemplate("system", "table", STRUCTURE_TABLE, null).catch(() => null);
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
() => null,
);
const cols = ((table?.문서 as { 열?: Col[] } | null)?.열 ?? []) as Col[];
const mine = cols.find((c) => c.id === column);
const same = (key: string | null | undefined, pick: (c: Col) => string | null | undefined) =>
@@ -0,0 +1,94 @@
/* =============================================================================
* M02_MasterTemplete_Layer.ts
* M02 화면의 층 — 주소 인자(`#/m02-master-template?layer=personal|company` · 없으면 system) ·
* 층별 제목 · 고칠 권한(시스템 = SYSTEM_ADMIN · 회사 = role ADMIN 또는 is_master · 개인 = 본인) ·
* 회사 없음 안내 · 회사용 읽기만일 때 [개인용으로 복사] 막대.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { currentQuery, navigateTo } from "../A00_Common/router";
import type { SessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { copyItemToPersonal, fetchStructureCombos } from "./M02_MasterTemplete_Api_Fetch";
export type ManageLayer = "system" | "company" | "personal";
/** 주소 인자에서 층 — 모르는 값 · 없음 = 시스템 */
export function layerFromHash(): ManageLayer {
const value = currentQuery().get("layer");
return value === "company" || value === "personal" ? value : "system";
}
export const titleOf = (layer: ManageLayer): string =>
layer === "company"
? L("M02_TitleCompany")
: layer === "personal"
? L("M02_TitlePersonal")
: L("M02_Title");
export interface LayerAccess {
/** 이 층을 고칠 수 있나 — 서버도 같은 규칙으로 막음 */
canWrite: boolean;
/** 회사 · 개인 층인데 회사가 없음 — 「회사부터 등록」 안내 */
needCompany: boolean;
}
export function accessFor(layer: ManageLayer, user: SessionUser | null): LayerAccess {
if (layer === "system") return { canWrite: user?.role === "SYSTEM_ADMIN", needCompany: false };
if (!user?.company_id) return { canWrite: false, needCompany: true };
const companyAdmin = user.role === "ADMIN" || !!user.is_master;
return { canWrite: layer === "personal" || companyAdmin, needCompany: false };
}
/** 회사 없음 — 개인 · 회사 층은 회사 폴더 아래라 회사부터 */
export function buildNeedCompany(): HTMLElement {
const go = createButton({ label: L("M02_NeedCompanyGo"), variant: "filled" });
go.addEventListener("click", () => navigateTo(ROUTES.B01_ACCOUNT));
return el("div", {
className: "m02-layer-need",
children: [el("p", { text: L("M02_NeedCompany") }), go],
});
}
export interface ReadOnlyBar {
root: HTMLElement;
/** 집계표에서 고른 열 id — 없으면 null */
setColumn: (column: string | null) => void;
}
/** 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사](고른 열 하나를 내 개인 층으로) */
export function buildReadOnlyBar(user: SessionUser | null): ReadOnlyBar {
let column: string | null = null;
const copy = createButton({ label: L("M02_CopyToPersonal"), variant: "filled", size: "sm" });
copy.disabled = true;
copy.addEventListener("click", async () => {
if (!column) return showToast(L("M02_CopyPickFirst"), "error");
copy.disabled = true;
try {
const key = (await fetchStructureCombos()).줄.find((row) => row.열 === column)?.키;
if (!key) throw new Error(L("M02_CopyPickFirst"));
await copyItemToPersonal({
fromLayer: "company",
fromOwner: user?.company_id == null ? null : String(user.company_id),
key,
});
showToast(L("M02_CopyDone"), "success");
} catch (error) {
const why = error instanceof Error ? error.message : "";
showToast(L("M02_CopyFailed").replace("{value}", why), "error");
} finally {
copy.disabled = !column;
}
});
return {
root: el("div", {
className: "m02-layer-bar",
children: [el("span", { text: L("M02_ReadOnlyCompany") }), copy],
}),
setColumn: (id) => {
column = id;
copy.disabled = !id;
},
};
}
@@ -32,6 +32,7 @@ import {
copyMaterial,
copyStructure,
fetchStructureNumbers,
getActiveLayer,
readTemplate,
type StructureDoc,
type StructureNumbers,
@@ -208,7 +209,9 @@ export async function pillsFor(
): Promise<{ links: LinkOptions; notice?: string }> {
if (kind === "material") return { links: { 항목: [], 모드: "자재", 자재찾기: searchMaterials } };
const none = (notice: string) => ({ links: { 항목: [], 모드: "입력" as const }, notice });
const table = await readTemplate("system", "table", STRUCTURE_TABLE, null).catch(() => null);
const table = await readTemplate(getActiveLayer(), "table", STRUCTURE_TABLE, null).catch(
() => null,
);
const cols = (table?.문서 as { 열?: { id: string; 일위대가?: string | null }[] } | null)?.열;
const combo = cols?.find((c) => c.id === column)?.일위대가;
if (!combo) return none(TEXT.noPrice);
@@ -376,7 +379,7 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
preview = mountDrawingTemplate(previewHost, doc.도면 as unknown as DrawingTemplateDoc, {
readOnly: true,
name: `preview:${column ?? ""}`,
layer: "system",
layer: getActiveLayer(),
panelsCollapsed: true, // 특성 · 도면층 패널은 접힌 채 — › 를 누르면 펼침
preview: true, // 왼쪽 끌기 = 화면 옮기기 · 두 번 누름 = 전체 맞춤
originMarker: true, // 좌표 0,0 기준점 십자
@@ -409,8 +412,8 @@ export function createStructurePanel(opts: StructurePanelOptions): StructurePane
c.host.replaceChildren();
}
const [drawing, ...books] = await Promise.allSettled([
readTemplate("system", "structure", col.id, null),
...sheets.map((c) => readTemplate("system", c.kind, col.id, null)),
readTemplate(getActiveLayer(), "structure", col.id, null),
...sheets.map((c) => readTemplate(getActiveLayer(), c.kind, col.id, null)),
]);
if (mine !== seq) return;
if (drawing.status === "fulfilled") doc = drawing.value.문서 as StructureDoc;
@@ -92,9 +92,15 @@ export interface MainHandle {
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
}
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
/** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
export function canEdit(layer: Layer, isAdmin: boolean): boolean {
return layer === "project" || layer === "personal" || (layer === "system" && isAdmin);
return layer === "project" || isAdmin;
}
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */
export interface MainExtras {
top?: HTMLElement;
onColumn?: (column: string | null) => void;
}
/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) */
@@ -102,6 +108,7 @@ export function createMain(
isAdmin: boolean,
onSaved?: () => void,
onOpened?: () => void,
extras: MainExtras = {},
): MainHandle {
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
@@ -115,6 +122,7 @@ export function createMain(
const root = el("div", {
className: "m02-main",
children: [
...(extras.top ? [extras.top] : []),
el("div", { className: "m02-main__head", children: [title, back, slot, reload, save] }),
notice,
host,
@@ -131,7 +139,7 @@ export function createMain(
let sheetHandle: SpreadsheetHandle | null = null;
let matPanel: MaterialPanel | null = null;
let matState: MaterialState | null = null;
let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(시스템 층)
let cpanel: ComboPanel | null = null; // 면적산출 · 재료산출 오른쪽 조합 패널(프로젝트 층 아님)
let seq = 0;
let dirty = false;
@@ -166,7 +174,7 @@ export function createMain(
const bar = (): void => {
reload.hidden = !sel;
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
back.hidden = !sel || sel.layer !== "system" || !(sel.kind in NUMBER_FIELD);
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
};
const drop = (): void => {
@@ -185,13 +193,13 @@ export function createMain(
};
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
function buildPanel(): StructurePanelHandle {
function buildPanel(layer: Layer): StructurePanelHandle {
const made = createStructurePanel({
isAdmin,
confirmLeave,
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
onEdit: (column, label, kind) => {
void open({ layer: "system", projectId: null, kind, name: column, label });
void open({ layer, projectId: null, kind, name: column, label });
},
});
extra.replaceChildren(made.root);
@@ -211,7 +219,7 @@ export function createMain(
): Promise<void> {
const mine = seq;
try {
const master = at.layer === "system" && kind === "material";
const master = at.layer !== "project" && kind === "material";
if (master && !matState) matState = await loadMaterialState(at.name);
const [{ createSpreadsheet }, pills] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
@@ -244,7 +252,7 @@ export function createMain(
);
editor = handle;
sheetHandle = handle;
if (at.layer !== "system") return;
if (at.layer === "project") return;
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
cpanel ??= buildComboPanel({
kind,
@@ -333,9 +341,10 @@ export function createMain(
"createSheet",
);
if (!create) return empty(L("M02_NoSheet"));
// 구조물집계표(시스템 층)만 — 도면 · 면적산출 · 재료산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
// 구조물집계표(프로젝트 층 아님)만 — 도면 · 면적산출 · 재료산출 줄에 번호 · 칸을 고르면 아래 컨테이너 ·
// 고칠 수 있으면 일위대가 · 도면 · 면적산출 · 재료산출 칸은 조회 후 선택
const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null;
const struct =
at.layer !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null;
if (struct) await struct.reload();
panel = struct;
// ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이
@@ -351,7 +360,10 @@ export function createMain(
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "",
onSelect: (s: { colId: string | null }) => struct.show(s.colId, columns()),
onSelect: (s: { colId: string | null }) => {
struct.show(s.colId, columns());
extras.onColumn?.(s.colId);
},
...(readOnly
? {}
: {
@@ -488,7 +500,12 @@ export function createMain(
back.addEventListener("click", async () => {
const column = sel?.name;
if (!column || !(await confirmLeave())) return;
await open({ layer: "system", projectId: null, kind: "table", name: STRUCTURE_TABLE });
await open({
layer: sel?.layer ?? "system",
projectId: null,
kind: "table",
name: STRUCTURE_TABLE,
});
if (sel?.kind === "table") sheet?.selectColumn(column); // 그 사이 다른 것을 열었으면 안 고름
});
reload.addEventListener("click", async () => {
@@ -1,15 +1,23 @@
/* =============================================================================
* M02_MasterTemplete_UI_Page.ts
* 마스터 템플릿 화면 — 좌측 도킹 패널(시스템 양식 목록) / 우측 편집 칸(표·도면 부품)
* 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품)
*
* 화면은 로그인만 확인 — 시스템 층 고치기는 시스템 관리자일 때만 켬(서버도 막음).
* 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌.
* 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`).
* ========================================================================== */
import "@ui/ui_template_workflow_layout.css";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch";
import {
accessFor,
buildNeedCompany,
buildReadOnlyBar,
layerFromHash,
titleOf,
} from "./M02_MasterTemplete_Layer";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { createMain, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
@@ -17,20 +25,31 @@ import "./M02_MasterTemplete_UI_Style.css";
export async function renderM02MasterTemplate(root: HTMLElement): Promise<void> {
const user = await fetchSessionUser().catch(() => null);
const isAdmin = user?.role === "SYSTEM_ADMIN";
const layer = layerFromHash();
setActiveLayer(layer);
const access = accessFor(layer, user);
if (access.needCompany) {
root.replaceChildren(buildNeedCompany());
return;
}
const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻)
// 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사]
const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null;
const main = createMain(
isAdmin,
() => void side.refresh(),
() => side.paint(),
{ top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) },
);
// 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음
const home: Selection = {
layer: "system",
layer,
projectId: null,
kind: "table",
name: STRUCTURE_TABLE,
};
const side = buildSide({
layer,
isAdmin,
onOpen: (sel) => void main.open(sel ?? home),
getOpen: main.current,
@@ -40,7 +59,7 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<void>
const layout = el("div", { className: "ui-workflow-layout m02-master" });
const overlays = createWorkflowOverlays({
title: L("M02_Title"),
title: titleOf(layer),
optionsContent: el("div", { className: "m02-master__side", children: [side.root] }),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
@@ -25,6 +25,7 @@ import {
saveTemplate,
StaleError,
type Kind,
type Layer,
type StructureNumbers,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
@@ -59,6 +60,9 @@ const STUB_DRAWING = {
};
export interface SideOptions {
/** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */
layer: Layer;
/** 이 층을 고칠 수 있나 */
isAdmin: boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
@@ -87,7 +91,7 @@ const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
export function buildSide(opt: SideOptions): SideHandle {
const layer = "system" as const;
const layer = opt.layer;
let items: TemplateInfo[] = [];
let columns: StructureColumn[] = [];
let numbers: Record<string, StructureNumbers> = {};
@@ -142,7 +146,9 @@ export function buildSide(opt: SideOptions): SideHandle {
className: "m02-side__lists ui-sidebar-panel",
children: [
summary,
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
...(layer === "system"
? [section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))]
: []),
],
});
attachCollapsible(listHost);
@@ -123,3 +123,25 @@
color: var(--color-text);
font-size: var(--text-body);
}
/* 층 화면 — 회사용 읽기만 안내 막대 · 회사 없음 안내 */
.m02-layer-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-raised);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-layer-need {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-24);
color: var(--color-text);
}
+111
View File
@@ -0,0 +1,111 @@
"""M02 층 화면(`?layer=personal|company`) 붙는 자리 시험 — 화면 조작은 ORCA.
· 권한 규칙(`accessFor`)을 Node 로 바로 돌림 — 시스템 SYSTEM_ADMIN ·
회사 ADMIN/is_master · 개인 본인 · 회사 없음
· 주소 인자를 라우트에서 떼는지 · 대시보드 카드 역할별 단추 · 층 길 접두어
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
M02 = ROOT / "M02_MasterTemplete"
LAYER = M02 / "M02_MasterTemplete_Layer.ts"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_층별_고칠_권한():
found = re.search(r"^export function accessFor[\s\S]*?^}", _text(LAYER), re.M)
assert found, "accessFor 를 못 찾음"
script = (
'type ManageLayer = "system" | "company" | "personal";\n'
"interface SessionUser { role?: string; company_id?: number | null; is_master?: boolean }\n"
"interface LayerAccess { canWrite: boolean; needCompany: boolean }\n"
+ found.group(0).replace("export ", "")
+ "\nconst cases = JSON.parse(process.argv[1]);"
+ "\nconsole.log(JSON.stringify(cases.map((c) => accessFor(c[0], c[1]))));"
)
sysadmin = {"role": "SYSTEM_ADMIN", "company_id": None}
admin = {"role": "ADMIN", "company_id": 3}
master = {"role": "USER", "company_id": 3, "is_master": True}
user = {"role": "USER", "company_id": 3}
orphan = {"role": "USER", "company_id": None}
cases = [
["system", sysadmin], ["system", admin], ["system", None],
["company", admin], ["company", master], ["company", user], ["company", orphan],
["personal", user], ["personal", admin], ["personal", orphan], ["personal", sysadmin],
] # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script, json.dumps(cases)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
ok = {"canWrite": True, "needCompany": False}
ro = {"canWrite": False, "needCompany": False}
no_company = {"canWrite": False, "needCompany": True}
assert json.loads(result.stdout) == [
ok, ro, ro, # 시스템 = SYSTEM_ADMIN 만
ok, ok, ro, no_company, # 회사 = ADMIN · is_master · 일반은 읽기만 · 회사 없음 안내
ok, ok, no_company, no_company, # 개인 = 본인 · 회사 없으면 안내
] # fmt: skip
def test_주소_인자를_라우트에서_뗌():
router = _text(ROOT / "A00_Common" / "router.ts")
assert '.split("?")[0]' in router
assert "export function currentQuery()" in router
assert "query?: Record<string, string>" in router
layer = _text(LAYER)
assert 'value === "company" || value === "personal" ? value : "system"' in layer
def test_대시보드_카드_역할별_단추():
page = _text(ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Page.ts")
assert (
page.count('section(L("B01_Dashboard_StructureManage"), buildStructurePanel(state))') == 3
)
body = page.split("function buildStructurePanel")[1].split("function buildPage")[0]
# 개인용은 모두 · 회사용은 ADMIN · SYSTEM_ADMIN 만
assert 'state.user.role === "ADMIN" || state.user.role === "SYSTEM_ADMIN"' in body
assert body.index('go("company"') < body.index('go("personal"')
assert "ROUTES.M02_MASTER_TEMPLATE, { layer }" in body
config = _text(ROOT / "config" / "config_frontend.ts")
assert re.search(r"PROTECTED_ROUTES[\s\S]*?ROUTES\.M02_MASTER_TEMPLATE,[\s\S]*?\];", config)
def test_층_길_접두어와_고정_풀림():
api = _text(M02 / "M02_MasterTemplete_Api_Fetch.ts")
assert 'activeLayer === "system" ? "" : `/layers/${activeLayer}`' in api
for path in ("/structures/numbers", "/structure-combos", "/material-combos"):
assert re.search(rf'scoped\(\s*[`"]{re.escape(path)}', api), path
assert "/layers/personal/items/copy" in api
for name in ("Structure", "Combo_Api"):
assert '"system"' not in _text(M02 / f"M02_MasterTemplete_{name}.ts")
side = _text(M02 / "M02_MasterTemplete_UI_Side.ts")
assert "const layer = opt.layer;" in side
assert 'layer === "system"' in side # 도면 양식은 시스템 층에만
main = _text(M02 / "M02_MasterTemplete_UI_Main.ts")
assert 'sel.layer !== "system"' not in main
assert 'return layer === "project" || isAdmin;' in main
def test_읽기만_회사용_개인용으로_복사_막대():
page = _text(M02 / "M02_MasterTemplete_UI_Page.ts")
assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page
assert "onColumn: (column) => readOnlyBar?.setColumn(column)" in page
assert "access.needCompany" in page and "buildNeedCompany()" in page
assert "titleOf(layer)" in page
layer = _text(LAYER)
assert 'fromLayer: "company"' in layer and "M02_CopyToPersonal" in layer
+5 -2
View File
@@ -81,9 +81,12 @@ def test_sub2_가_부르는_이름과_모양():
def test_집계표만_컨테이너를_붙임():
"""다른 표 양식에는 안 붙음 — 시스템 층 · 이름이 집계표일 때만."""
"""다른 표 양식에는 안 붙음 — 프로젝트 층 아님 · 이름이 집계표일 때만."""
body = _text(MAIN)
assert 'at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null' in body
assert re.search(
r'at\.layer !== "project" && at\.name === STRUCTURE_TABLE\s*\? buildPanel\(at\.layer\)\s*: null',
body,
)
# 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로
assert '{ ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }' in body
+18
View File
@@ -56,4 +56,22 @@ export const ui_locales_m2 = {
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
M02_StructureNone: ["없음", "None"],
M02_Back: ["돌아가기", "Back"],
B01_Dashboard_StructureManage: ["구조물 관리", "Structure management"],
B01_Dashboard_StructureCompany: ["회사용", "Company"],
B01_Dashboard_StructurePersonal: ["개인용", "Personal"],
M02_TitleCompany: ["구조물 관리(회사)", "Structure management (company)"],
M02_TitlePersonal: ["구조물 관리(개인)", "Structure management (personal)"],
M02_ReadOnlyCompany: [
"회사용은 복사해서 씀 — 고치려면 고른 열을 개인용으로 복사",
"Company templates are read-only — copy the selected column to your own to edit",
],
M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"],
M02_CopyPickFirst: ["복사할 열을 집계표에서 먼저 고름", "Pick a column in the table first"],
M02_CopyDone: ["개인용으로 복사함 — 구조물 관리(개인)에서 고침", "Copied — edit it in your own"],
M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"],
M02_NeedCompany: [
"회사부터 등록해야 구조물 집계표를 만들 수 있음",
"Register a company first to manage structure templates",
],
M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"],
} as const;