feat(M02): 좌측 새로 · 본떠 만들기 · 지우기를 표 양식 · 도면 양식 컨테이너 안으로 · 본떠 만들기 흠 고침

- 맨 아래 단추 줄 없앰 · 표 양식 · 도면 양식 컨테이너마다 [새로] [본떠 만들기] [지우기] · 새로 = 그 종류로 이름만 · 본떠 · 지우기 = 그 컨테이너에서 고른 양식이 있을 때만 켜짐 · 구조물 도면 · 면적산출 · 재료산출 컨테이너는 단추 없음 · 시스템 관리자만
- 본떠 만들기 흠: 표 복사본의 머리 이름(양식)이 원본 이름으로 남던 것을 새 이름으로
- 이름 규칙: 80자 넘는 이름이 서버 500 이던 것 · 앞 밑줄 이름이 새 프로젝트 복사를 깨던 것을 서버 · 화면 둘 다 막음
- 시험: 복사 독립 · 새 프로젝트 복사 · 이름 규칙 · 컨테이너 안 단추

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vX53nJGC8ucBRFJns2n7B
This commit is contained in:
2026-09-28 17:08:53 +09:00
co-authored by Claude Sonnet 5
parent c4735e4669
commit 6198fad176
5 changed files with 90 additions and 42 deletions
@@ -9,7 +9,6 @@
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
@@ -87,7 +86,10 @@ export interface SideHandle {
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
const badName = (name: string): boolean =>
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
/[\\/:*?"<>|\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 =>
@@ -113,25 +115,30 @@ export function buildSide(opt: SideOptions): SideHandle {
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" });
const copy = createButton({ label: L("M02_Copy"), variant: "ghost" });
const del = createButton({ label: L("M02_Delete"), variant: "danger" });
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 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_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])),
],
});
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 root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] });
/** 고름 표시 — `picked`(방금 누름) 이 있으면 그것 · 없으면 지금 열린 것과 비교 */
const isPicked = (kind: Kind, name: string): boolean => {
@@ -186,11 +193,10 @@ export function buildSide(opt: SideOptions): SideHandle {
const paintColumns = (): void =>
COLUMN_KINDS.forEach((k, i) => paintRows(columnBodies[i], k.kind, k.key));
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
const editable = canEdit(layer, opt.isAdmin);
btnNew.disabled = !editable;
btnCopy.disabled = !editable || !currentInfo();
btnDel.disabled = !editable || !currentInfo();
const cur = currentInfo();
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
async function select(info: TemplateInfo): Promise<void> {
@@ -243,16 +249,8 @@ export function buildSide(opt: SideOptions): SideHandle {
}
/* --- 시스템 층 새로 · 본떠 · 지우기(표 · 도면만 — 구조물 도면은 집계표 열이 정함) --- */
const nameDialog = (
title: string,
withKind: boolean,
onOk: (kind: Kind, name: string) => Promise<void>,
): void => {
const nameDialog = (title: string, onOk: (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"),
@@ -265,7 +263,7 @@ export function buildSide(opt: SideOptions): SideHandle {
if (badName(text)) return showToast(L("M02_BadName"), "error");
ok.disabled = true; // 연달아 눌러도 한 번만
try {
await onOk(kind.select.value as Kind, text);
await onOk(text);
close();
} catch (error) {
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
@@ -274,7 +272,7 @@ export function buildSide(opt: SideOptions): SideHandle {
ok.disabled = false;
}
});
body.append(...(withKind ? [kind.root] : []), name.root, ok);
body.append(name.root, ok);
name.input.focus();
},
});
@@ -287,22 +285,25 @@ export function buildSide(opt: SideOptions): SideHandle {
if (info) await select(info);
};
btnNew.addEventListener("click", () =>
nameDialog(L("M02_NewTitle"), true, async (kind, name) => {
/** 이 컨테이너 종류의 새 양식 — 종류 고르기 없이 이름만 */
function newTemplate(kind: Kind): void {
nameDialog(L("M02_NewTitle"), async (name) => {
await saveTemplate("system", kind, name, null, "", stubDoc(kind, name));
await openCreated(kind, name);
}),
);
btnCopy.addEventListener("click", () => {
});
}
function copyTemplate(): void {
const from = currentInfo();
if (!from) return;
nameDialog(L("M02_CopyTitle"), false, async (_kind, name) => {
nameDialog(L("M02_CopyTitle"), async (name) => {
const got = await readTemplate("system", from.종류, from.이름, null);
await saveTemplate("system", from.종류, name, null, "", got.문서);
// 표 문서 머리 이름(`양식`)도 새 이름으로 — 안 바꾸면 복사본이 원본 이름을 달고 있음
const doc = from.종류 === "table" ? { ...(got.문서 as object), 양식: name } : got.문서;
await saveTemplate("system", from.종류, name, null, "", doc);
await openCreated(from.종류, name);
});
});
btnDel.addEventListener("click", async () => {
}
async function deleteCurrent(): Promise<void> {
await refresh(); // 저장 직후여도 최신 판으로
const info = currentInfo();
if (!info) return;
@@ -315,7 +316,7 @@ export function buildSide(opt: SideOptions): SideHandle {
} catch (error) {
failed(error);
}
});
}
void refresh();
return {