- 좌측 「구조물 도면」을 「표 양식」·「도면 양식」과 같은 층 컨테이너로 - 구조물집계표 열마다 빈 구조물 도면을 서버가 미리 만들어 둠(도번 자동 · 도각 없는 빈 CAD 문서 · 산출근거 빈 줄 셋) — 좌측 줄에 항상 도번이 붙음 - 새 열을 더해 집계표를 저장하면 그 열의 빈 구조물 도면도 서버가 같이 만듦 (`M02_MasterTemplete_Store.write` → `backfill_missing_structures`) - 좌측 구조물 도면 줄을 누르면 그 CAD 화면이 곧장 열림(열 머리글 · 도번을 제목에) - 지금 있는 49개 열의 구조물 도면을 미리 만들어 커밋(`resources/master_template/structure/`) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K7JS47SacjPxZ718QsZKyr
309 lines
11 KiB
TypeScript
309 lines
11 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,
|
|
fetchStructureNumbers,
|
|
listTemplates,
|
|
readTemplate,
|
|
saveTemplate,
|
|
StaleError,
|
|
type Kind,
|
|
type TemplateInfo,
|
|
} from "./M02_MasterTemplete_Api_Fetch";
|
|
import { STRUCTURE_TABLE, columnHead, type StructureColumn } from "./M02_MasterTemplete_Structure";
|
|
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
|
|
|
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 columns: StructureColumn[] = [];
|
|
let numbers: Record<string, string> = {};
|
|
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, el("div", { className: "m02-side__items ui-sidebar-list" })]),
|
|
);
|
|
const structureBody = 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"), structureBody),
|
|
],
|
|
});
|
|
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] });
|
|
|
|
/** 고름 표시 — `picked`(방금 누름) 이 있으면 그것 · 없으면 지금 열린 것과 비교 */
|
|
const isPicked = (kind: Kind, name: string): boolean => {
|
|
if (picked) return picked.kind === kind && picked.name === name;
|
|
const cur = opt.getOpen();
|
|
return !!cur && cur.layer === layer && cur.kind === kind && cur.name === name;
|
|
};
|
|
|
|
const isOpen = (info: TemplateInfo): boolean => isPicked(info.종류, 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 paintStructure(): void {
|
|
structureBody.replaceChildren(
|
|
...(columns.length
|
|
? columns.map((col) => {
|
|
const num = numbers[col.id] ?? L("M02_StructureNone");
|
|
const b = el("button", {
|
|
className: `m02-side__item ui-sidebar-row${isPicked("structure", col.id) ? " is-active" : ""}`,
|
|
text: `${columnHead(col)} · ${num}`,
|
|
attrs: { type: "button" },
|
|
});
|
|
b.addEventListener("click", () => void selectColumn(col));
|
|
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 selectColumn(col: StructureColumn): Promise<void> {
|
|
if (!isPicked("structure", col.id) && !(await opt.confirmLeave())) return;
|
|
picked = null;
|
|
opt.onOpen({
|
|
layer,
|
|
projectId: null,
|
|
kind: "structure",
|
|
name: col.id,
|
|
label: columnHead(col),
|
|
});
|
|
paintStructure();
|
|
paintButtons();
|
|
}
|
|
|
|
async function refreshStructure(): Promise<void> {
|
|
try {
|
|
const [table, nums] = await Promise.all([
|
|
readTemplate(layer, "table", STRUCTURE_TABLE, null),
|
|
fetchStructureNumbers(),
|
|
]);
|
|
columns = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[];
|
|
numbers = nums;
|
|
} catch (error) {
|
|
columns = [];
|
|
numbers = {};
|
|
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
|
|
}
|
|
paintStructure();
|
|
}
|
|
|
|
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();
|
|
await refreshStructure();
|
|
}
|
|
|
|
/* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */
|
|
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();
|
|
paintStructure();
|
|
},
|
|
};
|
|
}
|