Files
Aislo/B07_DesignDetail/B07_DesignDetail_UI_PlanManage.ts
T
eomsangdonandClaude Opus 5.5 6df477a522 feat(B07): 평면도 관리 모달 종류 드롭다운 상세 · 장 한 블록 · 끌어 놓기 종류 순서(140-2)
- 맨 위 [종류 ▼] 상세 폼 · × 대신 폼 안 [지우기]
- 평면도 장 = 종류마다 한 줄 [≡] 이름 [1][2] · 장 단추 누르면 미리보기 + 드롭다운 따라감
- 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 PUT plan-kinds-order(reorder_kinds) · 번호 바뀐 장 확정 풂

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-09 14:51:11 +09:00

256 lines
10 KiB
TypeScript

/* =============================================================================
* B07_DesignDetail_UI_PlanManage.ts
* [평면도 관리] 모달(PLAN 130-1) — 좌측 패널이 좁아 종류 · 대피소 자리 · 장 보기를 한 창에 모음.
*
* 왼쪽 = [종류 ▼] 상세 폼(추가 · 업데이트 · 지우기) · 평면도 장(종류마다 한 줄 [≡] 이름 [1][2] ·
* 줄 끌어 놓기 = 종류 순서 · 놓는 즉시 저장 140-2) · 대피소 · 사토장 자리
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기]
* 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고,
* 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createCadHost } from "@ui/cad_host/cad_host";
import {
fetchDesignDrawing,
fetchDesignDrawingList,
fetchPlanKinds,
reorderPlanKinds,
type CadDrawing,
type DesignDrawingItem,
type PlanKindsState,
} from "./B07_DesignDetail_Api_Fetch";
import {
groupPlanSheets,
kindForm,
marksForm,
run,
type KindForm,
type PlanKindsControls,
} from "./B07_DesignDetail_UI_PlanKinds";
export interface PlanManageControls {
projectId: string;
/** 모달을 닫음 — changed = 종류 · 자리 · 확정을 고쳤나 · openId = [CAD 에서 열기]로 고른 장. */
onClosed: (changed: boolean, openId?: string) => void;
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
if (text) node.textContent = text;
return node;
}
function button(className: string, text: string, onClick: () => void): HTMLButtonElement {
const node = el("button", className, text);
node.type = "button";
node.addEventListener("click", onClick);
return node;
}
export function openPlanManager(controls: PlanManageControls): void {
const { projectId } = controls;
let changed = false;
let closed = false;
/** 미리보기 중인 장 — 늦게 온 응답이 다른 장을 덮지 않게 이것과 맞춰 봄. */
let picked: string | undefined;
/** 드롭다운 · 줄 강조의 종류 — 장 단추를 누르면 그 장의 종류로 따라감. */
let pickedKind = "";
let form: KindForm | undefined;
let sheets: DesignDrawingItem[] = [];
const preview = createCadHost<CadDrawing>({ title: "평면도 미리보기" });
const side = el("div", "b07-plan-manage__side");
const head = el("div", "b07-plan-manage__head");
const view = el("div", "b07-plan-manage__view");
view.append(head, preview.element);
// 종류 · 자리 폼은 좌측에 있던 것 그대로 — 고친 뒤 모달 안만 다시 세움(화면은 닫을 때).
const edits: PlanKindsControls = {
projectId,
onChanged: () => {
changed = true;
void refresh();
},
};
const kindSheets = (kindId: string) =>
groupPlanSheets(sheets.filter((item) => item.plan_kind === kindId))[0]?.sheets ?? [];
/** 장 단추 → 미리보기 + 드롭다운이 그 종류로. */
const pickSheet = (drawing: DesignDrawingItem, label: string): void => {
pickedKind = drawing.plan_kind ?? "";
form?.pick(pickedKind);
void show(drawing, label);
};
/** 「평면도 장」 한 블록 — 종류마다 한 줄 [≡] 이름 [1][2] · 줄 끌어 놓기 = 종류 순서(놓는 즉시). */
const sheetBlock = (state: PlanKindsState): HTMLElement => {
const list = el("div", "b07-plan-tools__block b07-plan-manage__sheets");
list.append(el("strong", "", "평면도 장"));
const ids = state.kinds.map((kind) => kind.id);
let dragId: string | null = null;
for (const kind of state.kinds) {
const row = el("div", "b07-plan-kind__head b07-plan-manage__row");
row.dataset.planKind = kind.id;
row.draggable = true;
const grip = el("span", "b07-plan-manage__grip", "≡");
grip.title = "끌어 놓아 종류 순서 바꾸기";
const group = kindSheets(kind.id);
// 한 장이면 이름이 곧 장 단추 · 여러 장이면 이름 + 작은 장 단추
const name =
group.length === 1
? button("b07-plan-manage__sheet b07-plan-kind__name", kind.name, () =>
pickSheet(group[0].drawing, group[0].label),
)
: el("span", "b07-plan-kind__name", kind.name);
if (group.length === 1) {
name.dataset.drawingId = group[0].drawing.id;
name.dataset.confirmed = String(group[0].drawing.confirmed);
}
row.append(grip, name);
if (group.length > 1)
group.forEach(({ drawing, label }, index) => {
const item = button("b07-plan-manage__sheet", String(index + 1), () =>
pickSheet(drawing, label),
);
item.title = label;
item.dataset.drawingId = drawing.id;
item.dataset.confirmed = String(drawing.confirmed);
row.append(item);
});
// 끌기 꼴 = 공용 탭 줄(`cad_host_tabs.ts`) — 세로라 위 · 아래 반으로 앞 · 뒤
row.addEventListener("dragstart", (event) => {
dragId = kind.id;
event.dataTransfer?.setData("text/plain", kind.id);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
row.addEventListener("dragend", () => (dragId = null));
row.addEventListener("dragover", (event) => {
if (!dragId) return;
event.preventDefault();
const box = row.getBoundingClientRect();
row.dataset.drop = event.clientY > box.top + box.height / 2 ? "after" : "before";
});
row.addEventListener("dragleave", () => delete row.dataset.drop);
row.addEventListener("drop", (event) => {
event.preventDefault();
const after = row.dataset.drop === "after";
delete row.dataset.drop;
if (!dragId || dragId === kind.id) return;
const order = ids.filter((id) => id !== dragId);
order.splice(order.indexOf(kind.id) + (after ? 1 : 0), 0, dragId);
dragId = null;
if (order.join() !== ids.join()) void run(() => reorderPlanKinds(projectId, order), edits);
});
list.append(row);
}
return list;
};
const markActive = (): void => {
side
.querySelectorAll<HTMLElement>(".b07-plan-manage__sheet")
.forEach((item) => (item.dataset.active = String(item.dataset.drawingId === picked)));
side
.querySelectorAll<HTMLElement>(".b07-plan-manage__row")
.forEach((row) => (row.dataset.active = String(row.dataset.planKind === pickedKind)));
};
/** 장 하나를 미리보기에 — 보기 전용 · 확정 풀기는 메인 [수정](140-1). */
const show = async (drawing: DesignDrawingItem, label: string): Promise<void> => {
picked = drawing.id;
markActive();
head.replaceChildren(el("strong", "", label));
preview.beginLoading();
try {
const response = await fetchDesignDrawing(projectId, drawing.id);
if (closed || picked !== drawing.id) return;
const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전");
badge.dataset.confirmed = String(response.confirmed);
head.append(badge);
head.append(button("b07-plan-tools__button", "CAD 에서 열기", () => finish(drawing.id)));
preview.load(
response.drawing,
null,
false,
{},
{
recoveryScope: "b07-plan-preview",
readOnly: true,
viewOnly: true,
hostTitle: "평면도 미리보기",
ribbonCollapsed: true,
panelsCollapsed: true,
bare: true,
},
);
} catch (error) {
if (closed || picked !== drawing.id) return;
preview.fail(error instanceof Error ? error.message : "평면도를 불러오지 못했습니다.");
}
};
/** 종류 · 장 목록을 서버에서 다시 받아 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄. */
const refresh = async (): Promise<void> => {
try {
const [state, list] = await Promise.all([
fetchPlanKinds(projectId),
fetchDesignDrawingList(projectId),
]);
if (closed) return;
sheets = list.drawings.filter((item) => item.kind === "plan");
const all = groupPlanSheets(sheets).flatMap((group) => group.sheets);
const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0];
if (keep) pickedKind = keep.drawing.plan_kind ?? "";
// 드롭다운으로 고른 종류 → 그 종류 첫 장 미리보기 + 줄 강조
form = kindForm(state, edits, pickedKind, (kindId) => {
pickedKind = kindId;
const first = kindSheets(kindId)[0];
if (first) void show(first.drawing, first.label);
else markActive();
});
side.replaceChildren(form.root, sheetBlock(state), marksForm(state, edits));
if (keep) void show(keep.drawing, keep.label);
else {
picked = undefined;
head.replaceChildren(el("span", "b07-plan-kind__name", "평면도 장이 없습니다."));
}
} catch (error) {
showToast(error instanceof Error ? error.message : "평면도 목록을 받지 못했습니다.", "error");
}
};
let closeModal = (): void => undefined;
/** 한 번만 — 미리보기 듣기를 멈추고 페이지에 알림. */
const finish = (openId?: string): void => {
if (closed) return;
closed = true;
preview.destroy();
closeModal();
controls.onClosed(changed, openId);
};
openModal({
title: "평면도 관리",
closeLabel: "닫기",
dialogClass: "b07-plan-manage",
beforeClose: () => {
finish();
return true;
},
mount: (body, close) => {
closeModal = close;
const grid = el("div", "b07-plan-manage__grid");
grid.append(side, view);
body.append(grid);
void refresh();
},
});
}