Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
eomsangdonandClaude Opus 5.5 9a3645bcaa fix(M02): 좌측 한 곳 다시 그리기에 「자재 조합」 줄도 넣음 — 고름 표시 늘 한 줄
- dev(sub5 990e8e67 「좌측 고름 표시 한 줄만」) 받기 충돌을 두 쪽 다 살려 풂
- 자재 조합 상세를 열 때도 편집 칸이 onOpened 로 좌측 paint 를 부름 · paint 는 양식이 열려 있으면 자재 조합 고름을 지움
- 시험: 자재 조합 상세도 같은 한 곳을 거치는지 확인 줄 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VwJQ5eRrCKf3nH9EFeKvJz
2026-09-28 22:48:44 +09:00

350 lines
14 KiB
TypeScript

/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 — 시스템 양식 목록(표/도면/구조물 도면/면적산출/재료산출) · 새로 · 본떠 · 지우기.
* 서버 길은 계약 `6_계약.md`. 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 —
* 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) ·
* 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 맨 아래 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`.
* ========================================================================== */
import {
createButton,
createInputField,
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 StructureNumbers,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import {
STRUCTURE_TABLE,
columnHead,
isStructureColumn,
structureLabel,
type StructureColumn,
} from "./M02_MasterTemplete_Structure";
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
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") },
];
/** 구조물 열마다 한 줄씩인 목록 셋 — 종류 · 번호 칸 · 제목 */
type ColumnKind = "structure" | "area" | "material";
const COLUMN_KINDS: { kind: ColumnKind; key: keyof StructureNumbers; label: () => string }[] = [
{ kind: "structure", key: "도번", label: () => L("M02_KindStructure") },
{ kind: "area", key: "면적번", label: () => L("M02_KindArea") },
{ kind: "material", key: "재료번", label: () => L("M02_KindMaterial") },
];
/** 새 양식의 첫 문서 — 표는 `3_표양식.md` 4장 틀 · 도면은 빈 웹캐드 문서 */
function stubDoc(kind: Kind, name: string): unknown {
return kind === "table"
? {
양식: name,
종류: "표",
판: 1,
층: ["종류", "공법", "규격"],
열: [{ id: "sta", 머리: ["측점", null, null], 단위: null, 꼴: "글", 고정: true }],
줄: [],
합계줄: [],
보기: {},
}
: {
format: 6,
entities: [],
// 기본 도면층 하나 — 없으면 B07 CAD 가 못 엶("Cannot read properties of
// undefined (reading 'length')", 2026-09-27 실측)
layers: [{ id: "기본", name: "기본", isVisible: true, isLocked: false }],
};
}
export interface SideOptions {
isAdmin: boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
}
export interface SideHandle {
root: HTMLElement;
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
}
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
const badName = (name: string): boolean =>
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) ||
name.startsWith(".") ||
name.startsWith("_") ||
name.length > 80;
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, StructureNumbers> = {};
let seq = 0;
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 columnBodies = COLUMN_KINDS.map(() =>
el("div", { className: "m02-side__items ui-sidebar-list" }),
);
/** 새로 · 본떠 만들기 · 지우기 — 표 · 도면 컨테이너 안에 하나씩(그 종류로 동작) · 관리자만 */
const editButtons = new Map<Kind, { copy: HTMLButtonElement; del: HTMLButtonElement }>();
const actionRow = (kind: Kind): HTMLElement[] => {
if (!canEdit(layer, opt.isAdmin)) return [];
const btnNew = createButton({ label: L("M02_New"), variant: "filled", size: "sm" });
const copy = createButton({ label: L("M02_Copy"), variant: "ghost", size: "sm" });
const del = createButton({ label: L("M02_Delete"), variant: "danger", size: "sm" });
editButtons.set(kind, { copy, del });
btnNew.addEventListener("click", () => newTemplate(kind));
copy.addEventListener("click", copyTemplate);
del.addEventListener("click", () => void deleteCurrent());
return [el("div", { className: "m02-side__row", children: [btnNew, copy, del] })];
};
const matset = buildMatsetSection({
isAdmin: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
close: () => opt.onOpen(null),
onOpen: () => paint(),
});
const matsetSection = section(matset.title, matset.body, ...matset.actions);
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (matsetSection.classList.contains("is-collapsed")) void matset.refresh();
});
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
section(L("M02_KindTable"), bodies.get("table")!, ...actionRow("table")),
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
...COLUMN_KINDS.map((k, i) => section(k.label(), columnBodies[i])),
matsetSection,
],
});
attachCollapsible(listHost);
const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
/** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐 */
const isPicked = (kind: Kind, name: string): boolean => {
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 paintRows(body: HTMLElement, kind: ColumnKind, key: keyof StructureNumbers): void {
body.replaceChildren(
...(columns.length
? columns.map((col) => {
const num = numbers[col.id]?.[key] || L("M02_StructureNone");
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isPicked(kind, col.id) ? " is-active" : ""}`,
text: structureLabel(num, col),
attrs: { type: "button" },
});
b.addEventListener("click", () => void selectColumn(col, kind));
return b;
})
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
);
}
const paintColumns = (): void =>
COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key));
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
const cur = currentInfo();
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
/** 고름 표시는 여기서만 — 목록 · 열 줄 · 자재 조합 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 자재 조합 고름은 지움 · 자재 조합 상세는 열린 양식이 없음) */
function paint(): void {
paintList();
paintColumns();
paintButtons();
if (opt.getOpen()) matset.clear();
}
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
async function select(info: TemplateInfo): Promise<void> {
if (!isOpen(info) && !(await opt.confirmLeave())) return;
opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 });
}
async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise<void> {
if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return;
// 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」)
opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) });
}
async function refreshStructure(): Promise<void> {
try {
const [table, nums] = await Promise.all([
readTemplate(layer, "table", STRUCTURE_TABLE, null),
fetchStructureNumbers(),
]);
const all = ((table.문서 as { 열?: StructureColumn[] }).열 ?? []) as StructureColumn[];
columns = all.filter(isStructureColumn);
numbers = nums;
} catch (error) {
columns = [];
numbers = {};
showToast(L("M02_LoadFailed").replace("{value}", why(error)), "error");
}
paintColumns();
}
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 Promise.all([refreshStructure(), matset.refresh()]);
}
/* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */
const nameDialog = (title: string, onOk: (name: string) => Promise<void>): void => {
const name = createInputField({ label: L("M02_Name"), placeholder: L("M02_Name") });
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(text);
close();
} catch (error) {
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
else failed(error);
} finally {
ok.disabled = false;
}
});
name.input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.isComposing) ok.click();
});
body.append(name.root, ok);
},
});
};
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);
};
/** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */
function newTemplate(kind: Kind): void {
nameDialog(L("M02_NewTitle"), async (name) => {
await saveTemplate("system", kind, name, null, "", stubDoc(kind, name));
await openCreated(kind, name);
});
}
function copyTemplate(): void {
const from = currentInfo();
if (!from) return;
nameDialog(L("M02_CopyTitle"), async (name) => {
const got = await readTemplate("system", from.종류, from.이름, null);
// 표 문서 머리 이름(`양식`)도 새 이름으로 — 안 바꾸면 복사본이 원본 이름을 달고 있음
const doc = from.종류 === "table" ? { ...(got.문서 as object), 양식: name } : got.문서;
await saveTemplate("system", from.종류, name, null, "", doc);
await openCreated(from.종류, name);
});
}
async function deleteCurrent(): Promise<void> {
await refresh(); // 저장 직후여도 최신 판으로
const info = currentInfo();
if (!info) return;
if (info.쓰는곳) {
showToast(
L("M02_InUse").replace("{value}", info.이름).replace("{use}", info.쓰는곳),
"error",
);
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, paint };
}