diff --git a/A00_Common/main.ts b/A00_Common/main.ts index 3ffa2037..804a64dd 100644 --- a/A00_Common/main.ts +++ b/A00_Common/main.ts @@ -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); // 취소 — 머문 채 주소만 되돌림 } }); }); diff --git a/A00_Common/router.ts b/A00_Common/router.ts index ae56148f..b856bba0 100644 --- a/A00_Common/router.ts +++ b/A00_Common/router.ts @@ -78,15 +78,20 @@ export async function isAuthenticated(): Promise { 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): void { + const target = `#/${route}${query ? `?${new URLSearchParams(query)}` : ""}`; if (window.location.hash === target) { // 동일 해시면 hashchange가 안 뜨므로 수동 트리거 window.dispatchEvent(new HashChangeEvent("hashchange")); diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 18ac31c6..b526078f 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -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), ); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 2fc61e8a..95d6bd83 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -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 = (path: string, init: RequestInit = {}): Promise => + call(`${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 => /** 새로 — 도번 자동 · 빈 도면 (+ 없으면 빈 면적산출 · 재료산출 통합문서) · 이미 있으면 StaleError */ export const createStructure = (column: string): Promise => - 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> => - call("/structures/numbers"); + scoped("/structures/numbers"); /** 구조물집계표조합 한 줄 — 구조물 열 하나(키 `SS` + 6자리 · 한 번 준 키는 안 바뀜) */ export interface StructureCombo extends StructureNumbers { @@ -161,7 +170,7 @@ export interface StructureCombos { } /** 조합 표 — 서버가 집계표 [저장] 때 맞춤(읽기만) */ -export const fetchStructureCombos = (): Promise => call("/structure-combos"); +export const fetchStructureCombos = (): Promise => scoped("/structure-combos"); /** 조합 갈라 내기 결과 — `새로만듦` false = 같은 담은 로직의 조합이 이미 있어 그 키를 가리킴 */ export interface BranchedCombo { @@ -182,14 +191,14 @@ export const branchCombo = ( 판?: string; }, ): Promise => - 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 => - 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 => - 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 => - 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; -}> => call("/material-combos"); +}> => scoped("/material-combos"); /** 새로 · 본떠 만들기(`본뜬키`) — 키는 서버가 표 「다음」 번호로 줌 */ export const createMaterialCombo = ( 판: string, 줄: Omit, ): Promise<{ 키: string; 판: string; 줄: MaterialCombo }> => - call("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) }); + scoped("/material-combos", { method: "POST", body: JSON.stringify({ 판, 줄 }) }); /** 고치기 — 이름 · 비고 · 담은자재(본뜬키 · 소유는 서버가 있던 것으로) */ export const editMaterialCombo = ( @@ -244,11 +253,11 @@ 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 => - call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" }); + scoped(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" }); /* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */ export interface OptionKind { @@ -275,19 +284,31 @@ export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): /** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */ export const copyStructure = async (column: string, from: string): Promise => { - 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 => { - 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 => { - 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 => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts index 1eb5e2b8..ffddd5d1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo_Api.ts @@ -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 { 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) => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Layer.ts b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts new file mode 100644 index 00000000..7725b25b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Layer.ts @@ -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; + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index 3220cab2..45059bc7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -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; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 8e0ceeb1..a7a83fb7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -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 { 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 () => { diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 0f24cc71..eca6beac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -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 { 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 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), diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index f765c1f1..349b9884 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -25,6 +25,7 @@ import { saveTemplate, StaleError, type Kind, + type Layer, type StructureNumbers, type TemplateInfo, } from "./M02_MasterTemplete_Api_Fetch"; @@ -60,6 +61,9 @@ const STUB_DRAWING = { }; export interface SideOptions { + /** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */ + layer: Layer; + /** 이 층을 고칠 수 있나 */ isAdmin: boolean; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; @@ -88,7 +92,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 = {}; @@ -150,8 +154,12 @@ export function buildSide(opt: SideOptions): SideHandle { className: "m02-side__lists ui-sidebar-panel", children: [ summary, - section(options.title, options.body), - section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), + ...(layer === "system" + ? [ + section(options.title, options.body), + section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")), + ] + : []), ], }); attachCollapsible(listHost); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 57c1705e..b98577c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -193,3 +193,25 @@ color: var(--color-text-muted); font-size: 11px; } + +/* 층 화면 — 회사용 읽기만 안내 막대 · 회사 없음 안내 */ +.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); +} diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py new file mode 100644 index 00000000..1e628924 --- /dev/null +++ b/resources/tester/test_m02_layer_screen.py @@ -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" 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 diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 7604667f..fad34b65 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -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 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 2a247992..a13da8fc 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -79,4 +79,22 @@ export const ui_locales_m2 = { M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"], M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], + 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;