Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
T
eomsangdonandClaude Sonnet 5 ef5a7e9771 fix(M02): 좌측 패널 공용 틀 M01 과 맞춤
- ui_template_overlay.css 에 ui-sidebar-panel · ui-sidebar-card · ui-sidebar-list ·
  ui-sidebar-row 공용 클래스 추가
- M01·M02 좌측 코드가 공용 클래스를 씀 — 카드 여백·둥글기·바탕 · 목록 간격 ·
  줄 선택 표시 한 벌
- M02 좌측 전용 CSS 중복 규칙 제거(없는 토큰 --radius-md 로 둥글기 안 먹던 것도 해소)
- M02 표/도면/구조물 카드 처음 접힘으로 M01 과 맞춤 · 새로·본떠·지우기 단추 줄 카드 안으로

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
2026-09-27 18:24:34 +09:00

258 lines
8.7 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면) · 새로 · 본떠 · 지우기. 서버 길은 계약 `6_계약.md`.
* ========================================================================== */
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import {
deleteTemplate,
listTemplates,
readTemplate,
saveTemplate,
StaleError,
type Kind,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
/** 서버 종류 추가 전까지 `Kind` 에 없음 — 목록 응답에 있을 때만 그림 */
const STRUCTURE = "structure" as Kind;
const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
/** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */
function stubDoc(kind: Kind, name: string): unknown {
return kind === "table"
? {
양식: name,
종류: "표",
판: 1,
층: ["종류", "공법", "규격"],
열: [{ id: "sta", 머리: ["측점", null, null], 단위: null, 꼴: "글", 고정: true }],
줄: [],
합계줄: [],
보기: {},
}
: { format: 6, entities: [] };
}
export interface SideOptions {
isAdmin: boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
}
export interface SideHandle {
root: HTMLElement;
refresh: () => Promise<void>;
/** 좌측 고름 표시만 옮김 — 화면은 열지 않음 */
select: (kind: Kind, name: string) => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
const badName = (name: string): boolean =>
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
const failed = (error: unknown): void =>
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
export function buildSide(opt: SideOptions): SideHandle {
const layer = "system" as const;
let items: TemplateInfo[] = [];
let seq = 0;
let picked: { kind: Kind; name: string } | null = null;
const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement =>
el("section", {
className: "m02-side__group ui-collapsible ui-sidebar-section ui-sidebar-card is-collapsed",
children: [el("h3", { className: "ui-collapsible__title", text: title }), body, ...more],
});
const bodies = new Map<Kind, HTMLElement>(
[...KINDS.map((k) => k.kind), STRUCTURE].map((k) => [
k,
el("div", { className: "m02-side__items ui-sidebar-list" }),
]),
);
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
section(L("M02_KindTable"), bodies.get("table")!),
section(
L("M02_KindDrawing"),
bodies.get("drawing")!,
section(L("M02_KindStructure"), bodies.get(STRUCTURE)!),
),
],
});
attachCollapsible(listHost);
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 ui-sidebar-card",
children: [btnNew, btnCopy, btnDel],
});
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost, editRow] });
const isOpen = (info: TemplateInfo): boolean => {
if (picked) return picked.kind === info.종류 && picked.name === info.이름;
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 {
for (const [kind, body] of bodies) {
const mine = items.filter((i) => i.종류 === kind);
body.replaceChildren(
...(mine.length
? mine.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`,
text: info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => void select(info));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
}
}
function paintButtons(): void {
const editable = canEdit(layer, opt.isAdmin);
btnNew.disabled = !editable;
btnCopy.disabled = !editable || !currentInfo();
btnDel.disabled = !editable || !currentInfo();
}
async function select(info: TemplateInfo): Promise<void> {
if (!isOpen(info) && !(await opt.confirmLeave())) return;
picked = null;
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
paintList();
paintButtons();
}
async function refresh(): Promise<void> {
const mine = ++seq;
paintButtons();
try {
items = await listTemplates(layer, null);
} 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");
if (badName(text)) return showToast(L("M02_BadName"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(kind.select.value as Kind, text);
close();
} catch (error) {
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
else failed(error);
} finally {
ok.disabled = false;
}
});
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) await 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);
}
});
void refresh();
return {
root,
refresh,
select: (kind, name) => {
picked = { kind, name };
paintList();
},
};
}