Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts
eomsangdonandClaude Opus 5.5 a64e3ca398 feat(717-2a): M02 왼쪽 맨 위 「단가산출」 칸 — 층 단추 따로(기본 개인) · 폴더 나무 · 목록표 · 상세 자리 · 대시보드 줄
- 새 UI_Side_Sangeun.ts — 서고 칸(716-4) kind sangeun · 목록표 고름 sangeunList
- 새 Sangeun_View.ts — 이름 · 단위 · 바탕 · 덮개 · 더한 줄 · 뺀 줄 · 열린 입력 요약 + 상세 자리표(717-2b 가 갈음)
- UI_Main 종류 sangeun 갈래 · 목록표 조건에 sangeun · Library_List 열 표(단가산출 = 바탕 · 고친 줄 · 쓰는 곳) · sangeunBlank(단위 식 · 바탕 없음)
- UI_Page 단가산출 층 상태 sk · UI_Side 맨 위 칸 · reveal 「단가산출」 · Layer open 「단가산출」 · Api Kind sangeun
- m_settings_nav 대시보드 마스터 템플릿 묶음 「단가산출」 줄(?open=단가산출) · locale_m2 키
- 낡은 글 대조 시험 넷 고침(197_1 · b01_settings_cards · m02_layer_screen · m02_precombo_scope) — 대시보드 줄 하나 늘고 OPENS · reveal · 목록 열 표 · 칸 차례가 바뀜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:00:03 +09:00

373 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_MasterTemplete_UI_Side.ts
* 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` ·
* 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 ·
* `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기) › 실무문서.
* 구조물 옵션 정의는 M01 로 옮김(716-1).
* 서버 길은 계약 `6_계약.md`.
* 구조물 도면 · 일위대가 · 면적산출 상세는 좌측 줄이 아니라 항목 카드 [수정]으로(PLAN 45-2).
* 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로.
* 구조물 라이브러리는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임).
* ========================================================================== */
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,
listTemplates,
readTemplate,
saveTemplate,
StaleError,
type Kind,
type Layer,
type TemplateInfo,
} from "./M02_MasterTemplete_Api_Fetch";
import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure";
import { buildPracticeSection } from "./M02_MasterTemplete_Practice";
import { HOPYO_BUILT } from "./M02_MasterTemplete_Hopyo_Check";
import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
import { buildPrecomboSection, type PrecomboOptions } from "./M02_MasterTemplete_UI_Side_Precombo";
import { buildSangeunSection, type SangeunOptions } from "./M02_MasterTemplete_UI_Side_Sangeun";
import { createEmpty } from "@ui/ui_template_empty";
import { createDivider } from "@ui/ui_template_divider";
const KINDS: { kind: Kind; label: () => string }[] = [
{ kind: "table", label: () => L("M02_KindTable") },
{ kind: "drawing", label: () => L("M02_KindDrawing") },
];
/** 새 도면 양식의 첫 문서 — 빈 웹캐드 문서 */
const STUB_DRAWING = {
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 {
/** 이 화면의 층 — 도면 양식 목록은 시스템 층에만 있음 */
layer: Layer;
/** 이 층을 고칠 수 있나 */
isAdmin: boolean;
onOpen: (sel: Selection | null) => void;
getOpen: () => Selection | null;
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
confirmLeave: () => Promise<boolean>;
/** 메인 칸에 양식 밖 화면(자재 조합 상세)을 띄움 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
/** 「구조물 조합」 줄 아래 — 층 단추 · 고른 층의 구조물 라이브러리 나무(PLAN 29-2 · 페이지가 만듦) */
tree?: HTMLElement[];
/** 「일위대가」 층(134-3 · 페이지 층과 따로 · 층 단추는 페이지가 만듦) */
precombo: Pick<PrecomboOptions, "scope" | "bar" | "more">;
/** 「단가산출」 층(717-2a · 일위대가 층과 따로 · 층 단추는 페이지가 만듦) */
sangeun: Pick<SangeunOptions, "scope" | "bar">;
}
export interface SideHandle {
root: HTMLElement;
refresh: () => Promise<void>;
/** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */
paint: () => void;
/** 진입 해시 `open`(PLAN 27-1d) — 그 컨테이너를 펼침 · 도면은 시스템 층에만 · 옵션은 M01 로 옮겨 아무 일 없음 */
reveal: (target: "옵션" | "도면" | "일위대가조합" | "단가산출") => void;
/** 사전조합 층이 바뀜(134-3) — 그 컨테이너만 다시 그림 */
refreshPrecombo: () => Promise<void>;
/** 단가산출 층이 바뀜(717-2a) — 그 컨테이너만 다시 그림 */
refreshSangeun: () => Promise<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 = opt.layer;
let items: TemplateInfo[] = [];
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 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] })];
};
/** 컨테이너 제목을 펼치면 그 화면을 엶 — 같은 이름 줄 단추를 갈음(PLAN 29-6) */
const openOnExpand = (sec: HTMLElement, run: () => void): HTMLElement => {
sec.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
if (sec.classList.contains("is-collapsed")) run();
});
return sec;
};
// 구조물 집계 양식 — 층 단추 · 나무 · 처음엔 닫힘(집계표는 가운데에 그대로 열려 있음) ·
// 같은 이름 줄 단추 없음(제목이 그 이름 · 29-6)
const summary = section(
L("M02_KindTable"),
el("div", { className: "m02-side__items ui-sidebar-list", children: opt.tree ?? [] }),
);
openOnExpand(summary, async () => {
if (!isPicked("table", STRUCTURE_TABLE) && (await opt.confirmLeave())) opt.onOpen(null);
});
// 일위대가 서고(50-3 · 134-3) — 단가산출 다음 칸 · 층은 페이지 층과 따로
const precombo = buildPrecomboSection({
...opt.precombo,
confirmLeave: opt.confirmLeave,
onOpen: opt.onOpen,
getOpen: opt.getOpen,
});
const precomboSec = section(L("M02_PrecomboTitle"), precombo.body);
// 단가산출 서고(717-2a) — 맨 위 칸 · 층은 일위대가 층과 따로
const sangeun = buildSangeunSection({
...opt.sangeun,
confirmLeave: opt.confirmLeave,
onOpen: opt.onOpen,
getOpen: opt.getOpen,
});
const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body);
// 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝
const onBuilt = (): void =>
precombo.body.isConnected
? void Promise.all([precombo.refresh(), sangeun.refresh()])
: document.removeEventListener(HOPYO_BUILT, onBuilt);
document.addEventListener(HOPYO_BUILT, onBuilt);
// 도면 양식은 시스템 층(마스터 템플릿)에만
const drawing =
layer === "system"
? section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing"))
: null;
// 실무문서(옛 마스터 일위대가 조합 · PLAN 39-2) — 구조물 조합 밖 · 도면 양식 아래 · 마스터 층 · 시스템 관리자만
const practice =
layer === "system" && opt.isAdmin
? buildPracticeSection({
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: paint,
})
: null;
// 일위대가 불러오기(324-4 · 제목은 718-1) · 층 = 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐
const hopyo = buildHopyoSection({
canWrite: canEdit(layer, opt.isAdmin),
confirmLeave: opt.confirmLeave,
show: opt.showView,
onOpen: () => (practice?.clear(), paint()),
});
const listHost = el("div", {
className: "m02-side__lists ui-sidebar-panel",
children: [
sangeunSec,
precomboSec,
summary,
createDivider(),
section(hopyo.title, hopyo.body),
...(drawing ? [drawing] : []),
...(practice ? [section(practice.title, practice.body)] : []),
],
});
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 && (kind !== "table" || i.이름 === STRUCTURE_TABLE),
);
body.replaceChildren(
...(mine.length
? mine.map((info) => {
const b = el("button", {
className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`,
// 구조물집계표 = 화면 이름 「구조물 조합」(26-7a)
text: info.이름 === STRUCTURE_TABLE ? L("M02_KindTable") : info.이름,
attrs: { type: "button", title: info.수정일 },
});
b.addEventListener("click", () => void select(info));
return b;
})
: [createEmpty({ text: L("M02_NoTemplates"), small: true })]),
);
}
}
/** 본떠 만들기 · 지우기 — 그 컨테이너에서 고른(열린) 양식이 있을 때만 켜짐 */
function paintButtons(): void {
const cur = currentInfo();
for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind;
}
/** 고름 표시는 여기서만 — 목록 · 실무문서 줄을 함께 그려야 두 줄이 동시에 안 남음
* (양식이 열려 있으면 실무문서 고름은 지움) */
function paint(): void {
paintList();
sangeun.paint();
precombo.paint();
hopyo.paint();
paintButtons();
if (opt.getOpen()) practice?.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 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([precombo.refresh(), sangeun.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, "", STUB_DRAWING);
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);
await saveTemplate("system", from.종류, name, null, "", got.문서);
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);
}
}
const reveal = (target: "옵션" | "도면" | "일위대가조합" | "단가산출"): void => {
const sec =
target === "단가산출"
? sangeunSec
: target === "일위대가조합"
? precomboSec
: target === "도면"
? drawing
: null;
sec?.classList.remove("is-collapsed");
};
void refresh();
return {
root,
refresh,
paint,
reveal,
refreshPrecombo: precombo.refresh,
refreshSangeun: sangeun.refresh,
};
}