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

This commit is contained in:
2026-09-25 09:41:40 +09:00
14 changed files with 971 additions and 22 deletions
+1
View File
@@ -27,6 +27,7 @@ const FOOTERLESS_ROUTES: readonly RoutePath[] = [
ROUTES.B10_PAYMENT,
ROUTES.B11_STATUS,
ROUTES.M01_MASTER_DATA,
ROUTES.M02_MASTER_TEMPLATE,
];
function bootstrap(): void {
+2
View File
@@ -61,6 +61,8 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../B11_Status/B11_Status_UI_Loading")).renderB11Loading,
[ROUTES.M01_MASTER_DATA]: async () =>
(await import("../M01_MasterData/M01_MasterData_UI_Page")).renderM01MasterData,
[ROUTES.M02_MASTER_TEMPLATE]: async () =>
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
};
/** 로그인 여부 (토큰 존재 확인) */
+5
View File
@@ -139,6 +139,11 @@ function buildSystemSettingsPanel(): HTMLElement {
variant: "ghost",
onClick: () => navigateTo(ROUTES.M01_MASTER_DATA),
}),
createButton({
label: L("B01_Dashboard_MasterTemplate"),
variant: "ghost",
onClick: () => navigateTo(ROUTES.M02_MASTER_TEMPLATE),
}),
);
return wrap;
}
@@ -9,6 +9,7 @@
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
searchElements,
type ElementBrief,
@@ -207,29 +208,13 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
function openDialog(title: string, mount: (body: HTMLElement) => void): void {
const body = el("div", { className: "m01lab__modal-body" });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: "m01-logic__pick m01lab__modal",
attrs: { role: "dialog", "aria-label": title },
children: [
el("div", {
className: "m01lab__modal-top",
children: [
el("h3", { text: title }),
createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }),
],
}),
body,
],
openModal({
title,
closeLabel: tl("Modal_Close"),
mount,
dialogClass: "m01-logic__pick m01lab__modal",
backdropClass: "m01-logic__backdrop",
});
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
mount(body);
document.body.append(backdrop);
dialog.tabIndex = -1;
dialog.focus();
}
const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__muted", text });
@@ -0,0 +1,105 @@
/* =============================================================================
* M02_MasterTemplete_Api_Fetch.ts
* 마스터 템플릿 서버 호출 — 계약 `6_계약.md` 서버 길 · 시스템 층은 시스템 관리자만
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 층 이름 — 서버와 같은 글 */
export type Layer = "system" | "company" | "personal" | "project";
/** 서버 종류 — 화면의 「표 양식」 = table · 「도면 양식」 = drawing */
export type Kind = "table" | "drawing";
export interface TemplateInfo {
종류: Kind;
이름: string;
판: string;
수정일: string;
}
export interface TemplateDoc {
종류: Kind;
이름: string;
판: string;
문서: unknown;
}
/** 서버가 판 불일치로 막았을 때 */
export class StaleError extends Error {}
async function call<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}/m02${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (response.status === 409) throw new StaleError(String(body.detail ?? "409"));
if (!response.ok) {
const detail = body.detail;
throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`);
}
return body;
}
const enc = encodeURIComponent;
const q = (projectId: string | null): string => (projectId ? `?project_id=${enc(projectId)}` : "");
const head = (layer: Layer): string =>
layer === "system" ? "/templates" : `/layers/${layer}/templates`;
const item = (layer: Layer, kind: Kind, name: string, projectId: string | null): string =>
`${head(layer)}/${kind}/${enc(name)}${layer === "system" ? "" : q(projectId)}`;
export const listTemplates = (layer: Layer, projectId: string | null): Promise<TemplateInfo[]> =>
call(layer === "system" ? head(layer) : `${head(layer)}${q(projectId)}`);
export const readTemplate = (
layer: Layer,
kind: Kind,
name: string,
projectId: string | null,
): Promise<TemplateDoc> => call(item(layer, kind, name, projectId));
export const saveTemplate = (
layer: Layer,
kind: Kind,
name: string,
projectId: string | null,
판: string,
문서: unknown,
): Promise<TemplateInfo> =>
call(item(layer, kind, name, projectId), { method: "PUT", body: JSON.stringify({ 판, 문서 }) });
/** 시스템 층만 지움 */
export const deleteTemplate = (kind: Kind, name: string, 판: string): Promise<{ ok: boolean }> =>
call(`/templates/${kind}/${enc(name)}?판=${enc(판)}`, { method: "DELETE" });
/* --- 프로젝트 층 단추 다섯 --- */
const project = (id: string, tail: string): string => `/projects/${enc(id)}/templates/${tail}`;
const post = (path: string, body: object = {}): Promise<unknown> =>
call(path, { method: "POST", body: JSON.stringify(body) });
export const resetProject = (id: string): Promise<unknown> => post(project(id, "reset"));
export const applyToProject = (
id: string,
from: { from: Layer; user_id?: string; project_id?: string; 종류?: Kind; 이름?: string },
): Promise<unknown> => post(project(id, "apply"), from);
export const saveProjectAs = (
id: string,
to: "personal" | "company",
종류: Kind,
이름: string,
): Promise<unknown> => post(project(id, "save-as"), { to, 종류, 이름 });
export interface SourceItem {
from: Layer;
label?: string;
user_id?: string;
project_id?: string;
종류?: Kind;
이름?: string;
[key: string]: unknown;
}
export const fetchSources = (id: string): Promise<unknown> => call(`/projects/${enc(id)}/sources`);
@@ -0,0 +1,172 @@
/* =============================================================================
* M02_MasterTemplete_UI_Main.ts
* 마스터 템플릿 메인 칸 — 양식 한 개를 읽어 표·도면 편집 부품에 붙임(계약 `6_계약.md` 화면 부품).
* 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import {
readTemplate,
saveTemplate,
StaleError,
type Kind,
type Layer,
} from "./M02_MasterTemplete_Api_Fetch";
export interface Selection {
layer: Layer;
projectId: string | null;
kind: Kind;
name: string;
}
interface EditorHandle {
getDoc: () => unknown;
destroy: () => void;
}
type Mods = Record<string, () => Promise<unknown>>;
// 표 부품(sub3) · 도면 부품(sub2) — 파일 이름은 각 창이 정함 → 폴더를 훑어 내보낸 이름으로 찾음
const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/*.ts");
const DRAWING_MODS: Mods = import.meta.glob("./M02_MasterTemplete_Drawing*.ts");
async function findExport<T>(mods: Mods, name: string): Promise<T | null> {
for (const load of Object.values(mods)) {
try {
const mod = (await load()) as Record<string, unknown>;
if (typeof mod[name] === "function") return mod[name] as T;
} catch {
// 못 읽는 파일은 건너뜀 — 페이지는 뜸
}
}
return null;
}
export interface MainHandle {
root: HTMLElement;
open: (sel: Selection | null) => Promise<void>;
current: () => Selection | null;
}
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
export function canEdit(layer: Layer, isAdmin: boolean): boolean {
return layer === "project" || layer === "personal" || (layer === "system" && isAdmin);
}
export function createMain(isAdmin: boolean): MainHandle {
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
const save = createButton({ label: L("M02_Save"), variant: "filled" });
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
const host = el("div", { className: "m02-main__host" });
const root = el("div", {
className: "m02-main",
children: [
el("div", { className: "m02-main__head", children: [title, reload, save] }),
notice,
host,
],
});
let sel: Selection | null = null;
let version = "";
let loaded: unknown = null;
let editor: EditorHandle | null = null;
let seq = 0;
const showNotice = (nodes: (HTMLElement | string)[]): void => {
notice.replaceChildren(...nodes);
notice.hidden = nodes.length === 0;
};
const empty = (text: string): void => {
host.replaceChildren(el("p", { className: "m02-main__empty", text }));
};
const bar = (): void => {
reload.hidden = !sel;
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
};
const drop = (): void => {
editor?.destroy();
editor = null;
};
async function mount(doc: unknown, at: Selection): Promise<void> {
const readOnly = !canEdit(at.layer, isAdmin);
host.replaceChildren();
if (at.kind === "table") {
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
SHEET_MODS,
"createSheet",
);
if (create) {
editor = create(host, doc, { mode: at.layer === "project" ? "project" : "master" });
return;
}
return empty(L("M02_NoSheet"));
}
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
DRAWING_MODS,
"mountDrawingTemplate",
);
if (mountDrawing) {
editor = mountDrawing(host, doc, { onSave: () => void doSave(), readOnly });
return;
}
empty(L("M02_NoDrawing"));
}
async function open(next: Selection | null): Promise<void> {
const mine = ++seq;
drop();
showNotice([]);
sel = next;
bar();
if (!next) {
title.textContent = L("M02_PickTemplate");
return empty("");
}
title.textContent = next.name;
try {
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
if (mine !== seq) return;
version = got.판;
loaded = got.문서;
await mount(got.문서, next);
} catch (error) {
if (mine !== seq) return;
empty("");
const why = error instanceof Error ? error.message : "";
showToast(L("M02_LoadFailed").replace("{value}", why), "error");
}
}
async function doSave(): Promise<void> {
if (!sel) return;
const at = sel;
try {
const doc = editor ? editor.getDoc() : loaded;
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
version = info.판;
loaded = doc;
showNotice([]);
showToast(L("M02_Saved"), "success");
} catch (error) {
if (error instanceof StaleError) {
const again = createButton({ label: L("M02_Reload"), variant: "ghost" });
again.addEventListener("click", () => void open(at));
showNotice([L("M02_Stale"), again]);
return;
}
const why = error instanceof Error ? error.message : "";
showToast(L("M02_SaveFailed").replace("{value}", why), "error");
}
}
save.addEventListener("click", () => void doSave());
reload.addEventListener("click", () => sel && void open(sel));
bar();
empty("");
return { root, open, current: () => sel };
}
@@ -0,0 +1,43 @@
/* =============================================================================
* M02_MasterTemplete_UI_Page.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 { createMain } from "./M02_MasterTemplete_UI_Main";
import { buildSide } from "./M02_MasterTemplete_UI_Side";
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 main = createMain(isAdmin);
const side = buildSide({
isAdmin,
onOpen: (sel) => void main.open(sel),
getOpen: main.current,
});
const layout = el("div", { className: "ui-workflow-layout m02-master" });
const overlays = createWorkflowOverlays({
title: L("M02_Title"),
optionsContent: el("div", { className: "m02-master__side", children: [side.root] }),
showProjectName: false,
onOptionsOpenChange: (isOpen) => layout.classList.toggle("is-options-open", isOpen),
});
layout.append(
el("div", {
className: "ui-workflow-layout__body",
children: [el("main", { className: "ui-workflow-layout__main", children: [main.root] })],
}),
overlays.root,
);
root.innerHTML = "";
root.append(layout);
}
@@ -0,0 +1,372 @@
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 층 고르기 · 프로젝트 고르기 · 양식 목록(표/도면) · 새로 · 본떠 · 지우기
* · 프로젝트 층 단추 다섯. 서버 길은 계약 `6_계약.md`.
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { fetchUserProjects, type ProjectItem } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import {
applyToProject,
deleteTemplate,
fetchSources,
listTemplates,
readTemplate,
resetProject,
saveProjectAs,
saveTemplate,
type Kind,
type Layer,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
const LAYERS: { layer: Layer; label: () => string }[] = [
{ layer: "system", label: () => L("M02_LayerSystem") },
{ layer: "company", label: () => L("M02_LayerCompany") },
{ layer: "personal", label: () => L("M02_LayerPersonal") },
{ layer: "project", label: () => L("M02_LayerProject") },
];
const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
const LAYER_KEY = "m02:layer";
const PROJECT_KEY = "m02:project";
/** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */
function stubDoc(kind: Kind, name: string): unknown {
return kind === "table"
? {
양식: name,
종류: "표",
판: 1,
층: ["종류", "공법", "규격"],
열: [{ id: "sta", 머리: ["측점", null, null], 단위: null, 꼴: "글", 고정: true }],
줄: [],
합계줄: [],
보기: {},
}
: { format: 6, entities: [] };
}
const store = {
get: (key: string): string => {
try {
return window.sessionStorage.getItem(key) ?? "";
} catch {
return "";
}
},
set: (key: string, value: string): void => {
try {
window.sessionStorage.setItem(key, value);
} catch {
// 저장소가 막혀도 화면은 동작
}
},
};
export interface SideOptions {
isAdmin: boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
}
export interface SideHandle {
root: HTMLElement;
refresh: () => Promise<void>;
}
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
/** 가져올 곳 목록 — 모양이 배열이든 묶음 객체든 한 줄씩 펴서 받음(길이 채워지면 맞춤) */
function flatten(raw: unknown): Record<string, unknown>[] {
if (Array.isArray(raw)) {
return raw.filter((x): x is Record<string, unknown> => !!x && typeof x === "object");
}
if (raw && typeof raw === "object") {
return Object.entries(raw).flatMap(([from, v]) =>
flatten(v).map((item) => ({ from: item.from ?? from, ...item })),
);
}
return [];
}
export function buildSide(opt: SideOptions): SideHandle {
const savedLayer = store.get(LAYER_KEY) as Layer;
let layer: Layer = LAYERS.some((l) => l.layer === savedLayer) ? savedLayer : "project";
if (layer === "system" && !opt.isAdmin) layer = "project";
let projectId = store.get(PROJECT_KEY);
let projects: ProjectItem[] = [];
let items: TemplateInfo[] = [];
let seq = 0;
const layerButtons = LAYERS.map(({ layer: id, label }) => {
const button = createButton({ label: label(), variant: "ghost" });
button.disabled = id === "system" && !opt.isAdmin;
button.addEventListener("click", () => {
layer = id;
store.set(LAYER_KEY, id);
opt.onOpen(null);
void refresh();
});
return { id, button };
});
const layerRow = el("div", {
className: "m02-side__layers",
children: layerButtons.map((b) => b.button),
});
const projectField = createSelectField({ label: L("M02_Project"), options: [] });
projectField.select.addEventListener("change", () => {
projectId = projectField.select.value;
store.set(PROJECT_KEY, projectId);
opt.onOpen(null);
void refresh();
});
const listHost = el("div", { className: "m02-side__lists" });
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
const btnCopy = createButton({ label: L("M02_Copy"), variant: "ghost" });
const btnDel = createButton({ label: L("M02_Delete"), variant: "danger" });
const editRow = el("div", { className: "m02-side__row", children: [btnNew, btnCopy, btnDel] });
const projectRow = el("div", { className: "m02-side__row" });
const root = el("div", {
className: "m02-side",
children: [layerRow, projectField.root, listHost, editRow, projectRow],
});
const isProject = (): boolean => layer === "project";
const pid = (): string | null => (isProject() && projectId ? projectId : null);
const isOpen = (info: TemplateInfo): boolean => {
const cur = opt.getOpen();
return !!cur && cur.layer === layer && cur.kind === info.종류 && cur.name === info.이름;
};
const currentInfo = (): TemplateInfo | undefined => {
const cur = opt.getOpen();
return cur ? items.find((i) => i.종류 === cur.kind && i.이름 === cur.name) : undefined;
};
function paintList(): void {
listHost.replaceChildren(
...KINDS.map(({ kind, label }) => {
const mine = items.filter((i) => i.종류 === kind);
return el("div", {
className: "m02-side__group",
children: [
el("h4", { text: label() }),
...(mine.length
? mine.map((info) => {
const b = el("button", {
className: `m02-side__item${isOpen(info) ? " is-active" : ""}`,
text: info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => select(info));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
],
});
}),
);
}
function paintButtons(): void {
layerButtons.forEach(({ id, button }) => button.classList.toggle("is-active", id === layer));
projectField.root.hidden = !isProject();
const editable = layer === "system" && canEdit(layer, opt.isAdmin);
editRow.hidden = layer !== "system";
btnNew.disabled = !editable;
btnCopy.disabled = btnDel.disabled = !editable || !currentInfo();
projectRow.hidden = !isProject();
projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId));
}
function select(info: TemplateInfo): void {
opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 });
paintList();
paintButtons();
}
async function refresh(): Promise<void> {
const mine = ++seq;
paintButtons();
if (isProject()) {
if (!projects.length) {
try {
projects = await fetchUserProjects();
} catch (error) {
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
}
if (!projects.some((p) => String(p.id) === projectId)) {
projectId = projects[0] ? String(projects[0].id) : "";
}
projectField.setOptions(
projects.length
? projects.map((p) => ({ value: String(p.id), text: p.name ?? String(p.id) }))
: [{ value: "", text: L("M02_NoProjects") }],
);
projectField.select.value = projectId;
store.set(PROJECT_KEY, projectId);
}
try {
items = isProject() && !projectId ? [] : await listTemplates(layer, pid());
} catch (error) {
items = [];
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
if (mine !== seq) return;
paintList();
paintButtons();
}
/* --- 시스템 층 새로 · 본떠 · 지우기 --- */
const nameDialog = (
title: string,
withKind: boolean,
onOk: (kind: Kind, name: string) => Promise<void>,
): void => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
const kind = createSelectField({
label: L("M02_Kind"),
options: KINDS.map((k) => ({ value: k.kind, text: k.label() })),
});
openModal({
title,
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
const ok = createButton({ label: L("M02_Create"), variant: "filled" });
ok.addEventListener("click", async () => {
const text = name.input.value.trim();
if (!text) return showToast(L("M02_NameNeeded"), "error");
try {
await onOk(kind.select.value as Kind, text);
close();
} catch (error) {
failed(error);
}
});
body.append(...(withKind ? [kind.root] : []), name.root, ok);
name.input.focus();
},
});
};
const openCreated = async (kind: Kind, name: string): Promise<void> => {
showToast(L("M02_Created"), "success");
await refresh();
const info = items.find((i) => i.종류 === kind && i.이름 === name);
if (info) select(info);
};
btnNew.addEventListener("click", () =>
nameDialog(L("M02_NewTitle"), true, async (kind, name) => {
await saveTemplate("system", kind, name, null, "", stubDoc(kind, name));
await openCreated(kind, name);
}),
);
btnCopy.addEventListener("click", () => {
const from = currentInfo();
if (!from) return;
nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => {
const got = await readTemplate("system", from.종류, from.이름, null);
await saveTemplate("system", from.종류, name, null, "", got.문서);
await openCreated(from.종류, name);
});
});
btnDel.addEventListener("click", async () => {
const info = currentInfo();
if (!info) return;
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
try {
await deleteTemplate(info.종류, info.이름, info.판);
showToast(L("M02_Deleted"), "success");
opt.onOpen(null);
await refresh();
} catch (error) {
failed(error);
}
});
/* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */
const act = async (run: () => Promise<unknown>): Promise<void> => {
try {
await run();
showToast(L("M02_Done"), "success");
opt.onOpen(null);
await refresh();
} catch (error) {
failed(error);
}
};
const projectButton = (label: string, onClick: () => void): void => {
const b = createButton({ label, variant: "ghost" });
b.addEventListener("click", onClick);
projectRow.append(b);
};
const saveOpenAs = (to: "personal" | "company"): void => {
const cur = opt.getOpen();
if (!cur) return void showToast(L("M02_PickTemplate"), "error");
void act(() => saveProjectAs(projectId, to, cur.kind, cur.name));
};
projectButton(L("M02_ProjectReset"), async () => {
if (await showConfirmDialog(L("M02_ResetConfirm"))) void act(() => resetProject(projectId));
});
projectButton(L("M02_ProjectApplyCompany"), async () => {
if (await showConfirmDialog(L("M02_ApplyConfirm"))) {
void act(() => applyToProject(projectId, { from: "company" }));
}
});
projectButton(L("M02_ProjectImport"), async () => {
let sources: Record<string, unknown>[] = [];
try {
sources = flatten(await fetchSources(projectId));
} catch (error) {
return failed(error);
}
openModal({
title: L("M02_ImportTitle"),
closeLabel: L("M02_Close"),
dialogClass: "m02-modal",
mount: (body, close) => {
if (!sources.length) {
body.append(el("p", { className: "m02-side__empty", text: L("M02_ImportNone") }));
}
for (const src of sources) {
const text = String(src.label ?? src.이름 ?? src.from ?? JSON.stringify(src));
const b = el("button", { className: "m02-side__item", text, attrs: { type: "button" } });
b.addEventListener("click", () => {
close();
const { label: _label, ...rest } = src;
void act(() => applyToProject(projectId, rest as never));
});
body.append(b);
}
},
});
});
projectButton(L("M02_ProjectSavePersonal"), () => saveOpenAs("personal"));
projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company"));
void refresh();
return { root, refresh };
}
@@ -0,0 +1,114 @@
/* M02 마스터 템플릿 — 좌측 패널 + 메인. 색·간격은 공용 토큰. */
.m02-side {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
padding-top: var(--spacing-8);
}
.m02-side__layers {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--spacing-4);
}
.m02-side__layers .ui-btn.is-active {
border-color: var(--color-accent);
color: var(--color-accent);
font-weight: var(--font-weight-bold);
}
.m02-side__group {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-side__group h4 {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-side__item {
width: 100%;
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-radius: var(--radius-md);
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-side__item:hover {
background: var(--color-paper);
}
.m02-side__item.is-active {
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
font-weight: var(--font-weight-bold);
}
.m02-side__empty {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-side__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
.m02-main {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
padding: var(--spacing-16) var(--spacing-24);
}
.m02-main__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-12);
}
.m02-main__title {
flex: 1 1 auto;
margin: 0;
color: var(--color-text);
font-size: var(--text-body);
}
.m02-main__notice {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-canvas));
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m02-main__host {
min-height: 240px;
}
.m02-main__empty {
margin: 0;
padding: var(--spacing-24);
color: var(--color-text-muted);
text-align: center;
}
.m02-modal__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
+3
View File
@@ -118,6 +118,8 @@ export const ROUTES = {
B11_LOADING: "b11-loading",
// 시스템 관리자 전용 — 마스터 요소(파일 첫 층 JSON) 보기·고치기.
M01_MASTER_DATA: "m01-master-data",
// 마스터 템플릿 — 표 양식 · 도면 양식(시스템 · 회사 · 개인 · 프로젝트 층). 화면은 로그인만 확인.
M02_MASTER_TEMPLATE: "m02-master-template",
} as const;
export type RouteKey = keyof typeof ROUTES;
@@ -141,6 +143,7 @@ export const PROTECTED_ROUTES: readonly RoutePath[] = [
ROUTES.B11_STATUS,
ROUTES.B11_LOADING,
ROUTES.M01_MASTER_DATA,
ROUTES.M02_MASTER_TEMPLATE,
];
/* -----------------------------------------------------------------------------
+2
View File
@@ -25,6 +25,7 @@ import { ui_locales_b1 } from "./ui_template_locale_b1";
import { ui_locales_b2 } from "./ui_template_locale_b2";
import { ui_locales_b3 } from "./ui_template_locale_b3";
import { ui_locales_m1 } from "./ui_template_locale_m1";
import { ui_locales_m2 } from "./ui_template_locale_m2";
/** 지원 언어 인덱스: 0 = 한국어, 1 = 영어 */
export const LANGUAGES = ["ko", "en"] as const;
@@ -60,6 +61,7 @@ export const ui_locales = {
...ui_locales_b2,
...ui_locales_b3,
...ui_locales_m1,
...ui_locales_m2,
} as const;
export type LocaleKey = keyof typeof ui_locales;
+64
View File
@@ -0,0 +1,64 @@
/* =============================================================================
* ui_template_locale_m2.ts
* M02 마스터 템플릿 화면 사전 — 신규 문구는 최하단에 추가.
* ========================================================================== */
export const ui_locales_m2 = {
B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"],
M02_Title: ["마스터 템플릿", "Master Templates"],
M02_Layer: ["층", "Layer"],
M02_LayerSystem: ["시스템", "System"],
M02_LayerCompany: ["회사", "Company"],
M02_LayerPersonal: ["개인", "Personal"],
M02_LayerProject: ["프로젝트", "Project"],
M02_Project: ["프로젝트", "Project"],
M02_PickProject: ["프로젝트를 고를 것", "Pick a project"],
M02_NoProjects: ["프로젝트 없음", "No projects"],
M02_KindTable: ["표 양식", "Table templates"],
M02_KindDrawing: ["도면 양식", "Drawing templates"],
M02_NoTemplates: ["양식 없음", "None"],
M02_New: ["새로", "New"],
M02_Copy: ["본떠 만들기", "Copy as new"],
M02_Delete: ["지우기", "Delete"],
M02_NewTitle: ["새 양식", "New template"],
M02_CopyTitle: ["본떠 만들기", "Copy as new"],
M02_Kind: ["종류", "Kind"],
M02_Name: ["이름", "Name"],
M02_Create: ["만들기", "Create"],
M02_Close: ["닫기", "Close"],
M02_NameNeeded: ["이름을 적을 것", "Enter a name"],
M02_DeleteConfirm: ["「{value}」 양식을 지울까?", "Delete template “{value}”?"],
M02_Deleted: ["지웠음", "Deleted"],
M02_Created: ["만들었음", "Created"],
M02_Saved: ["저장했음", "Saved"],
M02_Save: ["저장", "Save"],
M02_Reload: ["다시 열기", "Reopen"],
M02_PickTemplate: ["왼쪽에서 양식을 고를 것", "Pick a template on the left"],
M02_ReadOnlyLayer: ["이 층은 여기서 고치지 못함", "This layer is read-only here"],
M02_LoadFailed: ["양식을 못 읽음 — {value}", "Failed to load — {value}"],
M02_SaveFailed: ["저장 못 함 — {value}", "Save failed — {value}"],
M02_Stale: [
"그 사이 양식이 바뀜 — 다시 열어 새 판으로 고칠 것",
"The template changed meanwhile — reopen to edit the latest",
],
M02_NoSheet: ["표 편집 부품이 아직 없음", "Table editor not available yet"],
M02_NoDrawing: ["도면 편집 부품이 아직 없음", "Drawing editor not available yet"],
M02_ProjectReset: ["초기화", "Reset"],
M02_ProjectApplyCompany: ["회사 양식 적용", "Apply company templates"],
M02_ProjectImport: ["양식 가져오기", "Import template"],
M02_ProjectSavePersonal: ["내 양식으로 저장", "Save as mine"],
M02_ProjectSaveCompany: ["회사 공식으로 저장", "Save as company official"],
M02_ResetConfirm: [
"프로젝트 양식을 처음 복사한 것으로 되돌릴까?",
"Reset project templates to the initial copy?",
],
M02_ApplyConfirm: [
"회사 양식을 프로젝트 양식에 덮어쓸까?",
"Overwrite project templates with the company's?",
],
M02_ImportTitle: ["양식 가져오기", "Import template"],
M02_ImportNone: ["가져올 양식 없음", "Nothing to import"],
M02_Done: ["했음", "Done"],
M02_ActionFailed: ["안 됨 — {value}", "Failed — {value}"],
} as const;
+35
View File
@@ -0,0 +1,35 @@
.ui-modal__backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
}
.ui-modal {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
width: min(720px, 92vw);
max-height: 80vh;
padding: var(--spacing-16);
border-radius: var(--radius-xl);
background: var(--color-surface-raised);
font-size: var(--text-body-sm);
overflow: auto;
}
.ui-modal__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.ui-modal__body {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
+46
View File
@@ -0,0 +1,46 @@
/* =============================================================================
* ui_template_modal.ts
* 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움.
* M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임.
* ========================================================================== */
import "./ui_template_modal.css";
import { createButton, el } from "./ui_template_elements";
export interface ModalOptions {
title: string;
closeLabel: string;
mount: (body: HTMLElement, close: () => void) => void;
dialogClass?: string;
backdropClass?: string;
}
export function openModal(opts: ModalOptions): { close: () => void } {
const body = el("div", { className: "ui-modal__body" });
const close = (): void => backdrop.remove();
const dialog = el("div", {
className: `ui-modal ${opts.dialogClass ?? ""}`.trim(),
attrs: { role: "dialog", "aria-label": opts.title },
children: [
el("div", {
className: "ui-modal__top",
children: [
el("h3", { text: opts.title }),
createButton({ label: opts.closeLabel, variant: "ghost", onClick: close }),
],
}),
body,
],
});
const backdrop = el("div", {
className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(),
children: [dialog],
});
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
opts.mount(body, close);
document.body.append(backdrop);
dialog.tabIndex = -1;
dialog.focus();
return { close };
}