Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
/* =============================================================================
|
|
* 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<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");
|
|
|
|
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[] = [];
|
|
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<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());
|
|
} 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 () => {
|
|
await refresh(); // 저장 직후여도 최신 판으로
|
|
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: 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 };
|
|
}
|