Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts
eomsangdonandClaude Sonnet 5.5 db1bb9aa87 feat(M02): 구조물집계표 넷째 카드 「선택 입력」 — 로직마다 입력 목록 · 자리 고르기(면적 · 재료 · 선택 · 안 씀) · 알약 줄에 뜸 (PLAN 25-2c)
- 카드 = 로직 이름 · 결과단위 묶음 아래 입력 줄(이름 · 설명 · 단위 · 고르기/범위 · 자동값 흐리게) + 자리 상자 · 처음은 모두 안 씀
- 저장 = 집계표 [저장]에 이어 PUT input-links(자동저장 없음) · 읽기만 층은 상자 잠금
- 면적 · 재료로 고른 입력은 그 시트 알약 줄에 「로직 이름 · 입력 이름」 · 자재 모드에서도 [목록에서 빼기] 막음(고정)
- 알약 줄 시험을 합계 줄 아래 자리에 맞춤(25-0 뒤 어긋난 것)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
2026-09-29 23:18:13 +09:00

523 lines
20 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,
pillsFor,
STRUCTURE_TABLE,
type StructureColumn,
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
import { inputPills } from "./M02_MasterTemplete_Inputs";
import { buildComboBar } from "./M02_MasterTemplete_Combo";
import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel";
import {
buildMaterialPanel,
loadMaterialState,
toPills,
type MaterialPanel,
type MaterialState,
} from "./M02_MasterTemplete_Materials";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links";
import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch";
import type { MaterialItem } from "./M02_MasterTemplete_Api_Fetch";
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;
selectColumn: (colId: string) => void;
}
type Mods = Record<string, () => Promise<unknown>>;
/** 열 id 로 여는 문서의 번호 칸 */
const NUMBER_FIELD: Partial<Record<Kind, string>> = {
structure: "도번",
area: "면적번",
material: "재료번",
};
// 표 부품(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>;
/** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */
showView: (label: string, node: HTMLElement) => (dirty: boolean) => void;
}
/** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */
export function canEdit(layer: Layer, isAdmin: boolean): boolean {
return layer === "project" || isAdmin;
}
/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */
export interface MainExtras {
top?: HTMLElement;
onColumn?: (column: string | null) => void;
}
/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) */
export function createMain(
isAdmin: boolean,
onSaved?: () => void,
onOpened?: () => void,
extras: MainExtras = {},
): 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 back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" });
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: [
...(extras.top ? [extras.top] : []),
el("div", { className: "m02-main__head", children: [title, back, 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 sheetHandle: SpreadsheetHandle | null = null;
let matPanel: MaterialPanel | null = null;
let matState: MaterialState | null = null;
let cpanel: ComboPanel | 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")))) {
// 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로
host
.querySelector<HTMLElement>(".ss-input, .ui-sheet, iframe")
?.focus({ preventScroll: true });
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);
back.hidden = !sel || !(sel.kind in NUMBER_FIELD);
};
const drop = (): void => {
editor?.destroy();
editor = null;
sheet = null;
sheetHandle = null;
matPanel = null;
matState = null;
cpanel?.destroy();
cpanel = null;
panel?.destroy();
panel = null;
extra.replaceChildren();
slot.replaceChildren();
};
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
function buildPanel(layer: Layer): StructurePanelHandle {
const made = createStructurePanel({
isAdmin,
confirmLeave,
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
onEdit: (column, label, kind) => {
void open({ layer, projectId: null, kind, name: column, label });
},
});
extra.replaceChildren(made.root);
return made;
}
/** 면적산출 · 재료산출 스프레드시트 — 위 알약 막대(sub7 `links`) · 면적 = 그 열 일위대가 입력값 ·
* 재료 = 자재DB 찾기(`pillsFor`) · 시스템 층은 면적 = 조합 줄 + [조합 고치기] ·
* 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판
* (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */
async function mountSheet(
doc: unknown,
kind: "area" | "material",
at: Selection,
readOnly: boolean,
pillItems?: MaterialItem[],
): Promise<void> {
const mine = seq;
try {
const master = at.layer !== "project" && kind === "material";
if (master && !matState) matState = await loadMaterialState(at.name);
const [{ createSpreadsheet }, pills, own] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
master ? null : pillsFor(kind, at.name),
master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력
]);
if (mine !== seq) return;
if (pills?.notice) showNotice([pills.notice]);
const links: LinkOptions =
pills?.links ??
({
항목: [...toPills(pillItems ?? matState?.combo?.담은자재 ?? []), ...own],
모드: "자재",
자재찾기: searchMaterials,
// 알약 막대에서 더하기 · 빼기 — 옆 판 표를 같은 목록으로
onItemsChange: (list) =>
matPanel?.setItems(list.flatMap((i) => (i.자재 ? [{ ...i.자재 }] : []))),
} satisfies LinkOptions);
const handle: SpreadsheetHandle = createSpreadsheet(
host,
doc as Parameters<typeof createSpreadsheet>[1],
{
readOnly,
tabs: false, // 시트 하나 · 탭 줄 없음
onChange: () => {
dirty = !readOnly;
matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신
},
links,
},
);
editor = handle;
sheetHandle = handle;
if (at.layer === "project") return;
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
cpanel ??= buildComboPanel({
kind,
column: at.name,
isAdmin,
onUsed: () => remount(kind, at, readOnly, true),
});
host.append(cpanel.root);
if (kind === "area") {
const bar = await buildComboBar({
column: at.name,
label: at.label ?? at.name,
isAdmin,
beforeEdit: async () => true,
onApplied: async () => {
await remount(kind, at, readOnly, false);
await cpanel?.refresh();
},
});
if (mine === seq) cpanel?.setMine(bar);
return;
}
if (matPanel || !matState) return;
matPanel = buildMaterialPanel({
column: at.name,
label: at.label ?? at.name,
isAdmin,
state: matState,
onApplied: () => void cpanel?.refresh(),
links: () => sheetHandle?.getDoc().시트.flatMap((s) => s.연결 ?? []) ?? [],
// 옆 판 표에서 고침 — 알약 막대만 이 목록으로 다시 만듦(저장 안 한 시트 고침은 그대로)
onChange: (items) => {
const kept = sheetHandle?.getDoc();
if (!kept) return;
editor?.destroy();
editor = null;
void mountSheet(kept, kind, at, readOnly, items);
},
goTo: (code) =>
host
.querySelector<HTMLElement>(`.ss-link.is-linked[data-key="${CSS.escape(code)}"]`)
?.click(),
unlink: (codes) => {
const book = sheetHandle?.getDoc();
if (!book) return;
for (const s of book.시트) {
const rest = (s.연결 ?? []).filter((l) => !codes.includes(l.자재?.코드 ?? l.키));
if (rest.length) s.연결 = rest;
else delete s.연결;
}
sheetHandle?.setDoc(book);
dirty = !readOnly;
},
});
cpanel.setMine(matPanel.root);
} catch {
empty(L("M02_NoSpreadsheet"));
}
}
/** 시트만 다시 붙임(알약을 새로 받음) — 저장 안 한 시트 고침은 그대로 옮김 ·
* `fresh` = 이 열 조합이 바뀜(재료는 자재 조합 · 담은 자재 표를 새로 받음) */
async function remount(
kind: "area" | "material",
at: Selection,
readOnly: boolean,
fresh: boolean,
): Promise<void> {
const kept = sheetHandle?.getDoc();
if (sel !== at || !kept) return;
editor?.destroy();
editor = null;
if (fresh && kind === "material") {
matPanel = null;
matState = null;
}
await mountSheet(kept, kind, at, readOnly);
}
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 !== "project" && at.name === STRUCTURE_TABLE ? buildPanel(at.layer) : null;
if (struct) await struct.reload();
panel = struct;
// ⚠ sheet 는 바깥(다시 열기 등으로 drop() 이 null 로 되돌리는) 값 — 표 부품이
// 헐린 뒤에도 안 떼인 이벤트가 이 줄을 부르면 sheet?.getDoc() 가 undefined 라
// 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD
// 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다.
const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? [];
editor = create(host, doc, {
mode: at.layer === "project" ? "project" : "master",
readOnly,
onChange: () => (dirty = !readOnly),
...(struct
? {
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "",
onSelect: (s: { colId: string | null }) => {
struct.show(s.colId, columns());
extras.onColumn?.(s.colId);
},
...(readOnly
? {}
: {
onPick: (s: { row: string; colId: string | null }) =>
struct.pick(s.row, s.colId, columns()),
}),
}
: {}),
});
if (struct) sheet = editor as SheetEditorHandle;
return;
}
if (at.kind === "area" || at.kind === "material") {
// 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
return mountSheet(doc, at.kind, at, readOnly);
}
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,
originMarker: at.kind === "structure",
noArea: at.kind === "structure",
});
return;
}
empty(L("M02_NoDrawing"));
}
async function open(next: Selection | null): Promise<void> {
const mine = ++seq;
dirty = false;
drop();
showNotice([]);
sel = next;
onOpened?.();
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 로 열리니 머리 맨 앞에 번호(「SD01010100 돌기슭막이 …」)
const field = NUMBER_FIELD[next.kind];
const number = field && (got.문서 as Record<string, unknown> | null)?.[field];
if (number) title.textContent = `${String(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 === "area" || at.kind === "material") && 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;
// 구조물집계표 — 서버가 새 열 파일 · 번호를 만듦 · 번호를 다시 받아 도면 줄 · 카드를 새로
// (안 하면 새 열은 다시 열 때까지 「도면 없음」 · 번호 빈칸)
if (panel && sheet) {
await panel.reload();
sheet.recalc();
panel.refresh(sheet.getDoc()?.열 ?? []);
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
}
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");
}
}
function showView(label: string, node: HTMLElement): (d: boolean) => void {
++seq;
dirty = false;
drop();
showNotice([]);
sel = null;
onOpened?.(); // 좌측 양식 · 열 줄 고름 지움
bar();
title.textContent = label;
host.replaceChildren(node);
return (d) => (dirty = d);
}
save.addEventListener("click", () => void doSave());
back.addEventListener("click", async () => {
const column = sel?.name;
if (!column || !(await confirmLeave())) return;
await open({
layer: sel?.layer ?? "system",
projectId: null,
kind: "table",
name: STRUCTURE_TABLE,
});
if (sel?.kind === "table") sheet?.selectColumn(column); // 그 사이 다른 것을 열었으면 안 고름
});
reload.addEventListener("click", async () => {
if (sel && (await confirmLeave())) void open(sel);
});
bar();
empty("");
return { root, open, current: () => sel, confirmLeave, showView };
}