merge(M02): origin/sub_laptop_6 받음 — UI_Main 충돌 풂(면적 일위대가 패널 살림 · 재료산출 · 조합 패널 길 지움)

- sub3 apanel · 고른 칸 · refresh 살림 · sub6 의 matPanel · matState 지움 · 면적만 남아 죽은 조합 패널(cpanel) · remount 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ
This commit is contained in:
2026-10-04 20:39:09 +09:00
co-authored by Claude Opus 5.5
94 changed files with 393 additions and 4977 deletions
+18 -116
View File
@@ -26,21 +26,10 @@ import {
type StructureColumn,
type StructurePanelHandle,
} from "./M02_MasterTemplete_Structure";
import { inputPills } from "./M02_MasterTemplete_Inputs";
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
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;
@@ -67,7 +56,6 @@ type Mods = Record<string, () => Promise<unknown>>;
const NUMBER_FIELD: Partial<Record<Kind, string>> = {
structure: "도번",
area: "면적번",
material: "재료번",
labor: "일위대가번",
};
// 표 부품(sub3) · 도면 부품(sub2) — 내보낸 이름으로 찾음
@@ -141,7 +129,7 @@ export function createMain(
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: "" } });
@@ -173,7 +161,7 @@ export function createMain(
title.classList.toggle("m02-main__title--fold", combo);
title.title = combo ? L("M02_TableFold") : "";
if (!combo) {
host.hidden = false; // 다른 상세(도면 · 면적 · 재료)는 늘 펼침
host.hidden = false; // 다른 상세(도면 · 면적)는 늘 펼침
title.classList.remove("is-folded");
}
};
@@ -191,14 +179,11 @@ export function createMain(
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 apanel: AreaPanel | null = null; // 면적산출 오른쪽 일위대가 패널(프로젝트 층 아님 · 39-4)
let picked = "A1"; // 면적산출 시트에서 지금 고른 칸 — 칸 지정 모달 [지금 고른 칸으로]
let seq = 0;
let dirty = false;
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 · 재료산출 31-3) */
/** 저장 안 한 고침 — 이 화면 + 카드(도면 31-4 · 면적산출 31-3) */
const busy = (): boolean => dirty || !!panel?.dirty();
const guard = (event: BeforeUnloadEvent): void => {
@@ -240,10 +225,6 @@ export function createMain(
editor = null;
sheet = null;
sheetHandle = null;
matPanel = null;
matState = null;
cpanel?.destroy();
cpanel = null;
apanel?.destroy();
apanel = null;
panel?.destroy();
@@ -252,12 +233,11 @@ export function createMain(
slot.replaceChildren();
};
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 · 재료산출 카드 */
/** 구조물집계표 아래 설계 컨테이너 — 고른 열의 도면 정보 · 면적산출 카드 */
function buildPanel(layer: Layer): StructurePanelHandle {
const made = createStructurePanel({
isAdmin,
confirmLeave,
onDirty: () => (dirty = true), // 「선택 입력」 카드에서 자리를 고름 — 집계표 [저장]으로 함께 저장
keepColumn: (colId) => sheet?.selectColumn(colId), // 카드 고침을 안 버림 — 고른 열 되돌림
// 같은 페이지에서 그 열을 CAD·스프레드시트 전체 편집으로 엶
onEdit: (column, label, kind) => {
@@ -268,38 +248,23 @@ export function createMain(
return made;
}
/** 면적산출 · 재료산출 스프레드시트 — 아래 알약 막대 · 면적 = 그 열 일위대가 알약(`pillsFor`) ·
* 재료 = 자재DB 찾기 · 프로젝트 층 아니면 면적 = 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) ·
* 재료 = 그 열 자재 조합 담은 자재(알약 항목 = 옆 판 표 = 같은 목록) + 옆 판
* (`pillItems` = 옆 판에서 고친 목록으로 알약 막대만 다시 만들 때) */
/** 면적산출 스프레드시트 — 아래 알약 막대 = 그 열 일위대가 알약(`pillsFor`) ·
* 프로젝트 층 아니면 오른쪽 일위대가 패널(담은 로직 · 자재목록 · 칸 지정) */
async function mountSheet(
doc: unknown,
kind: "area" | "material",
kind: "area",
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([
const [{ createSpreadsheet }, pills] = await Promise.all([
import("../A00_Common/spreadsheet/spreadsheet"),
master ? null : pillsFor(kind, at.name),
master ? inputPills(at.name, "재료") : [], // 「선택 입력」 카드가 재료산출로 고른 입력
pillsFor(kind, 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);
if (pills.notice) showNotice([pills.notice]);
const links: LinkOptions = pills.links;
const handle: SpreadsheetHandle = createSpreadsheet(
host,
doc as Parameters<typeof createSpreadsheet>[1],
@@ -308,7 +273,6 @@ export function createMain(
tabs: false, // 시트 하나 · 탭 줄 없음
onChange: () => {
dirty = !readOnly;
matPanel?.refresh(); // 알약을 이음 · 풂 — 옆 판 「이은 칸」 바로 갱신
apanel?.refresh(); // 칸 값 · 연결이 바뀜 — 반영 금액을 바로 다시 곱함
},
onSelect: (s) => (picked = s.칸),
@@ -332,71 +296,11 @@ export function createMain(
host.append(apanel.root);
return;
}
// 오른쪽 조합 패널 — 한 번 만들고 시트를 다시 붙여도 그대로(늘 시트 뒤)
cpanel ??= buildComboPanel({
kind,
column: at.name,
isAdmin,
onUsed: () => remount(kind, at, readOnly, true),
});
host.append(cpanel.root);
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();
@@ -406,8 +310,8 @@ export function createMain(
"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();
@@ -427,7 +331,6 @@ export function createMain(
? {
drawingLabel: (colId: string) => struct.numbers()[colId]?.도번 ?? "",
areaLabel: (colId: string) => struct.numbers()[colId]?.면적번 ?? "",
materialLabel: (colId: string) => struct.numbers()[colId]?.재료번 ?? "",
combo: {
...(readOnly || !extras.onHeadRename
? {}
@@ -472,8 +375,8 @@ export function createMain(
});
return;
}
if (at.kind === "area" || at.kind === "material") {
// 면적산출 · 재료산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
if (at.kind === "area") {
// 면적산출 = 구조물집계표 열마다 한 스프레드시트 — 페이지 번들에 안 섞이게 이 화면을
// 열 때만 `import()` 로 받음(계약 `spreadsheet_types.ts` 머리)
return mountSheet(doc, at.kind, at, readOnly);
}
@@ -529,7 +432,7 @@ export function createMain(
)
: got.문서;
if (mine !== seq) return;
// 구조물 도면 · 면적산출 · 재료산출은 열 id 로 열리니 머리 맨 앞에 번호(「SD01010100 돌기슭막이 …」)
// 구조물 도면 · 면적산출은 열 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}`;
@@ -551,11 +454,11 @@ export function createMain(
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
: at.kind === "area" && 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);
@@ -569,7 +472,6 @@ export function createMain(
await panel.reload();
sheet.recalc();
panel.refresh(sheet.getDoc()?.열 ?? []);
await panel.saveInputs(); // 「선택 입력」 카드의 자리 고름 — 같은 [저장]에서
}
showNotice([]);
showToast(L("M02_Saved"), "success");