Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/** 로그인 여부 (토큰 존재 확인) */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user