Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1

충돌 셋(UI_Side · UI_Style · locale_m2)은 양쪽을 모두 살려 풂 — 옵션 보이기 · 항목 고르기 문구 · 층 화면 문구를 한 벌로, 도면 양식 줄은 시스템 층에서만.
This commit is contained in:
2026-09-29 12:28:14 +09:00
14 changed files with 391 additions and 48 deletions
@@ -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,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<unknown> =>
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<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";
@@ -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<string, StructureNumbers> = {};
@@ -151,7 +155,9 @@ export function buildSide(opt: SideOptions): SideHandle {
children: [
summary,
section(options.title, options.body),
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
...(layer === "system"
? [section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))]
: []),
],
});
attachCollapsible(listHost);
@@ -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);
}