refactor(M02): 좌측 패널을 시스템 양식 목록만으로 — 층 · 프로젝트 · 프로젝트 단추 다섯 뺌 · 제목 줄 headerSlot 연결
작도 영역 칸은 제목 줄 가운데로. 그림 칸 위쪽 246px. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxvYb5ufV1kWdBvZbpDfu6
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_UI_Side.ts
|
||||
* 마스터 템플릿 좌측 패널 — 층 고르기 · 프로젝트 고르기 · 양식 목록(표/도면) · 새로 · 본떠 · 지우기
|
||||
* · 프로젝트 층 단추 다섯. 서버 길은 계약 `6_계약.md`.
|
||||
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면) · 새로 · 본떠 · 지우기. 서버 길은 계약 `6_계약.md`.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -14,37 +13,21 @@ import {
|
||||
} 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,
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
fetchSources,
|
||||
listTemplates,
|
||||
readTemplate,
|
||||
resetProject,
|
||||
saveProjectAs,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
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"
|
||||
@@ -61,29 +44,8 @@ function stubDoc(kind: Kind, name: string): unknown {
|
||||
: { 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;
|
||||
/** 회사 관리자(회사 층 지우기 · 회사 공식으로 저장) */
|
||||
isCompanyAdmin?: boolean;
|
||||
/** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */
|
||||
userName?: string;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
@@ -103,98 +65,11 @@ const why = (error: unknown): string => (error instanceof Error ? error.message
|
||||
const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
|
||||
type ApplyFrom = Parameters<typeof applyToProject>[1];
|
||||
interface Source {
|
||||
label: string;
|
||||
body: ApplyFrom;
|
||||
}
|
||||
|
||||
/** 가져올 곳 — 서버 `sources` 는 층마다 묶음 · 개인·프로젝트는 사람·프로젝트 아래 양식 목록 */
|
||||
function sourceItems(raw: unknown): Source[] {
|
||||
const group = (raw ?? {}) as Record<string, Record<string, unknown>[] | undefined>;
|
||||
const layerName: Record<string, string> = {
|
||||
system: L("M02_LayerSystem"),
|
||||
company: L("M02_LayerCompany"),
|
||||
personal: L("M02_LayerPersonal"),
|
||||
project: L("M02_LayerProject"),
|
||||
};
|
||||
const out: Source[] = [];
|
||||
for (const from of ["system", "company", "personal", "project"] as const) {
|
||||
for (const entry of group[from] ?? []) {
|
||||
const templates = (Array.isArray(entry.양식) ? entry.양식 : [entry]) as TemplateInfo[];
|
||||
const owner = entry.name ? ` · ${String(entry.name)}` : "";
|
||||
for (const t of templates) {
|
||||
const body: ApplyFrom = { from, 종류: t.종류, 이름: t.이름 };
|
||||
if (entry.user_id != null) body.user_id = String(entry.user_id);
|
||||
if (entry.project_id != null) body.project_id = String(entry.project_id);
|
||||
out.push({ label: `${layerName[from]}${owner} · ${t.이름}`, body });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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";
|
||||
// 마지막에 고른 프로젝트는 localStorage — 다시 들어와도 유지
|
||||
const lastProject = (): string => {
|
||||
try {
|
||||
return window.localStorage.getItem(PROJECT_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
let projectId = lastProject();
|
||||
let projects: ProjectItem[] = [];
|
||||
const layer = "system" as const;
|
||||
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", async () => {
|
||||
if (id !== layer && !(await opt.confirmLeave())) return;
|
||||
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 remember = (id: string): void => {
|
||||
try {
|
||||
window.localStorage.setItem(PROJECT_KEY, id);
|
||||
} catch {
|
||||
// 저장소가 막혀도 화면은 동작
|
||||
}
|
||||
};
|
||||
// 없으면 내가 만든 프로젝트 중 최근 것 · 그래도 없으면 빈 칸
|
||||
const defaultProject = (): string => {
|
||||
const mine = projects
|
||||
.filter((p) => opt.userName && p.owner_name === opt.userName)
|
||||
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""));
|
||||
return mine[0] ? String(mine[0].id) : "";
|
||||
};
|
||||
|
||||
const projectField = createSelectField({ label: L("M02_Project"), options: [] });
|
||||
projectField.select.addEventListener("change", async () => {
|
||||
if (!(await opt.confirmLeave())) {
|
||||
projectField.select.value = projectId;
|
||||
return;
|
||||
}
|
||||
projectId = projectField.select.value;
|
||||
remember(projectId);
|
||||
opt.onOpen(null);
|
||||
void refresh();
|
||||
});
|
||||
|
||||
const listHost = el("div", { className: "m02-side__lists" });
|
||||
|
||||
const btnNew = createButton({ label: L("M02_New"), variant: "filled" });
|
||||
@@ -202,14 +77,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
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 root = el("div", { className: "m02-side", children: [listHost, editRow] });
|
||||
|
||||
const isOpen = (info: TemplateInfo): boolean => {
|
||||
const cur = opt.getOpen();
|
||||
@@ -247,23 +115,15 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
|
||||
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);
|
||||
const canDelete =
|
||||
editable || layer === "personal" || (layer === "company" && !!opt.isCompanyAdmin);
|
||||
editRow.hidden = isProject();
|
||||
btnNew.hidden = btnCopy.hidden = layer !== "system";
|
||||
const editable = canEdit(layer, opt.isAdmin);
|
||||
btnNew.disabled = !editable;
|
||||
btnCopy.disabled = !editable || !currentInfo();
|
||||
btnDel.disabled = !canDelete || !currentInfo();
|
||||
projectRow.hidden = !isProject();
|
||||
projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId));
|
||||
btnDel.disabled = !editable || !currentInfo();
|
||||
}
|
||||
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 });
|
||||
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
|
||||
paintList();
|
||||
paintButtons();
|
||||
}
|
||||
@@ -271,24 +131,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
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 = defaultProject();
|
||||
projectField.setOptions([
|
||||
{ value: "", text: L(projects.length ? "M02_PickProject" : "M02_NoProjects") },
|
||||
...projects.map((p) => ({ value: String(p.id), text: p.name ?? String(p.id) })),
|
||||
]);
|
||||
projectField.select.value = projectId;
|
||||
remember(projectId);
|
||||
}
|
||||
try {
|
||||
items = isProject() && !projectId ? [] : await listTemplates(layer, pid());
|
||||
items = await listTemplates(layer, null);
|
||||
} catch (error) {
|
||||
items = [];
|
||||
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
||||
@@ -364,8 +208,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (!info) return;
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
|
||||
try {
|
||||
if (layer === "system") await deleteTemplate(info.종류, info.이름, info.판);
|
||||
else await deleteLayerTemplate(layer, info.종류, info.이름, info.판);
|
||||
await deleteTemplate(info.종류, info.이름, info.판);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.onOpen(null);
|
||||
await refresh();
|
||||
@@ -374,86 +217,6 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
}
|
||||
});
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */
|
||||
const act = async (run: () => Promise<unknown>): Promise<void> => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
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 = async (to: "personal" | "company"): Promise<void> => {
|
||||
const cur = opt.getOpen();
|
||||
if (!cur) return void showToast(L("M02_PickTemplate"), "error");
|
||||
try {
|
||||
const rows = await listTemplates(to, null);
|
||||
const same = rows.some((r) => r.종류 === cur.kind && r.이름 === cur.name);
|
||||
if (
|
||||
same &&
|
||||
!(await showConfirmDialog(L("M02_OverwriteConfirm").replace("{value}", cur.name)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
return failed(error);
|
||||
}
|
||||
try {
|
||||
await saveProjectAs(projectId, to, cur.kind, cur.name);
|
||||
showToast(L("M02_Done"), "success"); // 열던 양식은 그대로 둠
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
};
|
||||
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: Source[] = [];
|
||||
try {
|
||||
sources = sourceItems(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 b = el("button", {
|
||||
className: "m02-side__item",
|
||||
text: src.label,
|
||||
attrs: { type: "button" },
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
close();
|
||||
void act(() => applyToProject(projectId, src.body));
|
||||
});
|
||||
body.append(b);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
projectButton(L("M02_ProjectSavePersonal"), () => void saveOpenAs("personal"));
|
||||
projectButton(L("M02_ProjectSaveCompany"), () => void saveOpenAs("company"));
|
||||
|
||||
void refresh();
|
||||
return { root, refresh };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user