/* ============================================================================= * 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; /** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */ userName?: string; onOpen: (sel: Selection | null) => void; getOpen: () => Selection | null; } export interface SideHandle { root: HTMLElement; refresh: () => Promise; } 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[1]; interface Source { label: string; body: ApplyFrom; } /** 가져올 곳 — 서버 `sources` 는 층마다 묶음 · 개인·프로젝트는 사람·프로젝트 아래 양식 목록 */ function sourceItems(raw: unknown): Source[] { const group = (raw ?? {}) as Record[] | undefined>; const layerName: Record = { 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[] = []; 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 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", () => { 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" }); 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 { 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()); } 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 => { 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 => { 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): Promise => { 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: 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"), () => saveOpenAs("personal")); projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company")); void refresh(); return { root, refresh }; }