Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168FQuoV7vDh5nhnSowW5cp
300 lines
11 KiB
TypeScript
300 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* M02_MasterTemplete_UI_Main.ts
|
|
* 마스터 템플릿 메인 칸 — 양식 한 개를 읽어 표·도면 편집 부품에 붙임(계약 `6_계약.md` 화면 부품).
|
|
* 부품 파일이 없거나 못 읽어도 페이지는 뜸 — 빈 자리 글.
|
|
* ========================================================================== */
|
|
|
|
import { setLeaveGuard } from "../A00_Common/router";
|
|
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
|
import { t as L } from "@ui/ui_template_locale";
|
|
import {
|
|
readFilled,
|
|
readTemplate,
|
|
saveTemplate,
|
|
StaleError,
|
|
type Kind,
|
|
type Layer,
|
|
type StructureDoc,
|
|
} from "./M02_MasterTemplete_Api_Fetch";
|
|
import {
|
|
createStructurePanel,
|
|
STRUCTURE_TABLE,
|
|
type StructureColumn,
|
|
type StructurePanelHandle,
|
|
} from "./M02_MasterTemplete_Structure";
|
|
export interface Selection {
|
|
layer: Layer;
|
|
projectId: string | null;
|
|
kind: Kind;
|
|
name: string;
|
|
/** 화면 제목에 쓸 글 — 없으면 name 그대로(구조물 도면은 열 머리글) */
|
|
label?: string;
|
|
}
|
|
|
|
interface EditorHandle {
|
|
getDoc: () => unknown | Promise<unknown>;
|
|
destroy: () => void;
|
|
}
|
|
|
|
/** 표 부품 손잡이 — 도면 줄 도번을 다시 그리려고 `recalc` 을 씀 */
|
|
interface SheetEditorHandle extends EditorHandle {
|
|
getDoc: () => { 열: StructureColumn[] };
|
|
recalc: () => void;
|
|
}
|
|
|
|
type Mods = Record<string, () => Promise<unknown>>;
|
|
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
|
|
// 표는 진입 파일만 — 폴더 통째(`*.ts`)면 분수 모듈이 따로 늦게 받는 입구가 되어 번들러 도우미를
|
|
// 달고, 그 도우미가 든 큰 조각(compass)까지 스프레드시트가 끌어옴
|
|
const SHEET_MODS: Mods = import.meta.glob("../ui_template/sheet/ui_template_sheet.ts");
|
|
const DRAWING_MODS: Mods = import.meta.glob("./M02_MasterTemplete_Drawing*.ts");
|
|
|
|
async function findExport<T>(mods: Mods, name: string): Promise<T | null> {
|
|
for (const load of Object.values(mods)) {
|
|
try {
|
|
const mod = (await load()) as Record<string, unknown>;
|
|
if (typeof mod[name] === "function") return mod[name] as T;
|
|
} catch {
|
|
// 못 읽는 파일은 건너뜀 — 페이지는 뜸
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export interface MainHandle {
|
|
root: HTMLElement;
|
|
open: (sel: Selection | null) => Promise<void>;
|
|
current: () => Selection | null;
|
|
/** 저장 안 한 고침이 있으면 버릴지 물음 — 취소면 false */
|
|
confirmLeave: () => Promise<boolean>;
|
|
}
|
|
|
|
/** 이 층에서 고칠 수 있나 — 시스템은 관리자만 · 회사는 여기서 못 고침(공식으로 저장으로만) */
|
|
export function canEdit(layer: Layer, isAdmin: boolean): boolean {
|
|
return layer === "project" || layer === "personal" || (layer === "system" && isAdmin);
|
|
}
|
|
|
|
export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle {
|
|
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
|
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
|
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
|
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
|
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
|
const host = el("div", { className: "m02-main__host" });
|
|
const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너
|
|
const root = el("div", {
|
|
className: "m02-main",
|
|
children: [
|
|
el("div", { className: "m02-main__head", children: [title, slot, reload, save] }),
|
|
notice,
|
|
host,
|
|
extra,
|
|
],
|
|
});
|
|
|
|
let sel: Selection | null = null;
|
|
let version = "";
|
|
let loaded: unknown = null;
|
|
let editor: EditorHandle | null = null;
|
|
let sheet: SheetEditorHandle | null = null;
|
|
let panel: StructurePanelHandle | null = null;
|
|
let seq = 0;
|
|
let dirty = false;
|
|
|
|
const guard = (event: BeforeUnloadEvent): void => {
|
|
if (!root.isConnected) return window.removeEventListener("beforeunload", guard);
|
|
if (!dirty) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
};
|
|
window.addEventListener("beforeunload", guard);
|
|
const confirmLeave = async (): Promise<boolean> => {
|
|
if (!dirty) return true;
|
|
if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false;
|
|
dirty = false;
|
|
return true;
|
|
};
|
|
setLeaveGuard(confirmLeave); // 머리 메뉴 · 뒤로 가기로 떠날 때도 물음
|
|
|
|
const showNotice = (nodes: (HTMLElement | string)[]): void => {
|
|
notice.replaceChildren(...nodes);
|
|
notice.hidden = nodes.length === 0;
|
|
};
|
|
const empty = (text: string): void => {
|
|
host.replaceChildren(el("p", { className: "m02-main__empty", text }));
|
|
};
|
|
const bar = (): void => {
|
|
reload.hidden = !sel;
|
|
save.hidden = !sel || !canEdit(sel.layer, isAdmin);
|
|
};
|
|
|
|
const drop = (): void => {
|
|
editor?.destroy();
|
|
editor = null;
|
|
sheet = null;
|
|
panel?.destroy();
|
|
panel = null;
|
|
extra.replaceChildren();
|
|
slot.replaceChildren();
|
|
};
|
|
|
|
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 미리보기 · [도면 수정] · 상세 산출근거 */
|
|
function buildPanel(): StructurePanelHandle {
|
|
const made = createStructurePanel({
|
|
isAdmin,
|
|
confirmLeave,
|
|
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
|
|
onEdit: (column, label, kind) => {
|
|
void open({ layer: "system", projectId: null, kind, name: column, label });
|
|
},
|
|
});
|
|
extra.replaceChildren(made.root);
|
|
return made;
|
|
}
|
|
|
|
async function mount(doc: unknown, at: Selection): Promise<void> {
|
|
const readOnly = !canEdit(at.layer, isAdmin);
|
|
host.replaceChildren();
|
|
if (at.kind === "table") {
|
|
const create = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
|
SHEET_MODS,
|
|
"createSheet",
|
|
);
|
|
if (!create) return empty(L("M02_NoSheet"));
|
|
// 구조물집계표(시스템 층)만 — 도면 줄에 도번 · 칸을 고르면 아래 컨테이너
|
|
const struct = at.layer === "system" && at.name === STRUCTURE_TABLE ? buildPanel() : null;
|
|
if (struct) await struct.reload();
|
|
panel = struct;
|
|
editor = create(host, doc, {
|
|
mode: at.layer === "project" ? "project" : "master",
|
|
onChange: () => (dirty = !readOnly),
|
|
...(struct
|
|
? {
|
|
drawingLabel: (colId: string) => struct.numbers()[colId] ?? "",
|
|
onSelect: (s: { colId: string | null }) =>
|
|
struct.show(s.colId, sheet?.getDoc().열 ?? []),
|
|
}
|
|
: {}),
|
|
});
|
|
if (struct) sheet = editor as SheetEditorHandle;
|
|
return;
|
|
}
|
|
if (at.kind === "basis") {
|
|
// 산출근거 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을 열 때만
|
|
// `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
|
|
try {
|
|
const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet");
|
|
editor = createSpreadsheet(host, doc as Parameters<typeof createSpreadsheet>[1], {
|
|
readOnly,
|
|
onChange: () => (dirty = !readOnly),
|
|
});
|
|
} catch {
|
|
empty(L("M02_NoSpreadsheet"));
|
|
}
|
|
return;
|
|
}
|
|
const mountDrawing = await findExport<(h: HTMLElement, d: unknown, o: object) => EditorHandle>(
|
|
DRAWING_MODS,
|
|
"mountDrawingTemplate",
|
|
);
|
|
if (mountDrawing) {
|
|
// 구조물 도면은 CAD 문서가 `도면` 칸에 들어 있음 — 도번 · 산출근거는 안 건드림
|
|
const cad = at.kind === "structure" ? (doc as StructureDoc).도면 : doc;
|
|
editor = mountDrawing(host, cad, {
|
|
onSave: () => void doSave(),
|
|
readOnly,
|
|
name: at.name,
|
|
layer: at.layer,
|
|
projectId: at.projectId,
|
|
onChanged: (d: boolean) => (dirty = d && !readOnly),
|
|
headerSlot: slot,
|
|
});
|
|
return;
|
|
}
|
|
empty(L("M02_NoDrawing"));
|
|
}
|
|
|
|
async function open(next: Selection | null): Promise<void> {
|
|
const mine = ++seq;
|
|
dirty = false;
|
|
drop();
|
|
showNotice([]);
|
|
sel = next;
|
|
bar();
|
|
if (!next) {
|
|
title.textContent = L("M02_PickTemplate");
|
|
return empty("");
|
|
}
|
|
title.textContent = next.label ?? next.name;
|
|
try {
|
|
const got = await readTemplate(next.layer, next.kind, next.name, next.projectId);
|
|
if (mine !== seq) return;
|
|
version = got.판 ?? "";
|
|
loaded = got.문서;
|
|
// 프로젝트 층 표 — 작업본 판은 그대로 두고 보이는 문서만 설계값으로 채움(서버가 채움 · 저장 안 함)
|
|
const shown =
|
|
next.layer === "project" && next.kind === "table" && next.projectId
|
|
? await readFilled(next.projectId, next.name).then(
|
|
(f) => f.문서,
|
|
() => got.문서,
|
|
)
|
|
: got.문서;
|
|
if (mine !== seq) return;
|
|
// 구조물 도면 · 산출근거는 열 id 로 열리니 머리에 도번 · 표번을 함께 보임
|
|
if (next.kind === "structure") {
|
|
const number = (got.문서 as StructureDoc)?.도번;
|
|
if (number) title.textContent = `${number} · ${next.label ?? next.name}`;
|
|
} else if (next.kind === "basis") {
|
|
const number = (got.문서 as { 표번?: string })?.표번;
|
|
if (number) title.textContent = `${number} · ${next.label ?? next.name}`;
|
|
}
|
|
await mount(shown, next);
|
|
} catch (error) {
|
|
if (mine !== seq) return;
|
|
empty("");
|
|
const why = error instanceof Error ? error.message : "";
|
|
showToast(L("M02_LoadFailed").replace("{value}", why), "error");
|
|
}
|
|
}
|
|
|
|
async function doSave(): Promise<void> {
|
|
if (!sel) return;
|
|
const at = sel;
|
|
try {
|
|
const edited = editor ? await editor.getDoc() : loaded;
|
|
// 구조물 도면 저장은 `도면` 칸만 갈아 끼움 — 도번 · 산출근거는 읽은 그대로.
|
|
// 산출근거는 스프레드시트 문서로 통째 갈아 끼우되 표번은 계약 밖(엔진이 모름)이라 옮겨 붙임.
|
|
const doc =
|
|
at.kind === "structure" && loaded
|
|
? { ...(loaded as StructureDoc), 도면: edited as StructureDoc["도면"] }
|
|
: at.kind === "basis" && loaded
|
|
? { ...(loaded as Record<string, unknown>), ...(edited as Record<string, unknown>) }
|
|
: edited;
|
|
const info = await saveTemplate(at.layer, at.kind, at.name, at.projectId, version, doc);
|
|
version = info.판;
|
|
loaded = doc;
|
|
dirty = false;
|
|
showNotice([]);
|
|
showToast(L("M02_Saved"), "success");
|
|
onSaved?.(); // 좌측 목록의 판을 새 판으로
|
|
} catch (error) {
|
|
if (error instanceof StaleError) {
|
|
const again = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
|
again.addEventListener("click", () => void open(at));
|
|
showNotice([L("M02_Stale"), again]);
|
|
return;
|
|
}
|
|
const why = error instanceof Error ? error.message : "";
|
|
showToast(L("M02_SaveFailed").replace("{value}", why), "error");
|
|
}
|
|
}
|
|
|
|
save.addEventListener("click", () => void doSave());
|
|
reload.addEventListener("click", async () => {
|
|
if (sel && (await confirmLeave())) void open(sel);
|
|
});
|
|
bar();
|
|
empty("");
|
|
return { root, open, current: () => sel, confirmLeave };
|
|
}
|