/* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. * 체크박스는 공용(`ui_template_checkbox`) · 측점 입력은 B05 구조물 폼과 같은 측점 NO + 거리 두 칸. * ========================================================================== */ import { createCheckbox } from "@ui/ui_template_checkbox"; import { showToast } from "@ui/ui_template_elements"; import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; import { addPlanKind, deletePlanKind, fetchPlanKinds, savePlanMarks, updatePlanKind, type DesignDrawingItem, type PlanKindsState, type PlanMark, } from "./B07_DesignDetail_Api_Fetch"; /** 종단이 없어 측점 간격을 못 받았을 때 — 서버 `infer_station_interval` 기본값과 같음. */ const DEFAULT_STATION_INTERVAL_M = 20; export interface PlanKindsControls { projectId: string; /** 종류 · 대피소 자리를 고친 뒤 — 목록과 도면을 다시 받는다. */ onChanged: () => void; } function el( tag: K, className: string, text = "", ): HTMLElementTagNameMap[K] { const node = document.createElement(tag); node.className = className; if (text) node.textContent = text; return node; } /** 실패는 토스트로 알리고 삼킨다 — 성공했을 때만 화면을 다시 세운다. */ async function run(action: () => Promise, controls: PlanKindsControls): Promise { try { await action(); controls.onChanged(); } catch (error) { showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error"); } } /** 종류 — 공통 항목. 종류를 고르면 든 조각이 체크로 보이고 [업데이트] · 「새 종류」면 [추가]. */ function kindForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "종류")); const pick = el("select", "b07-plan-tools__kind"); pick.append( ...state.kinds.map((kind) => new Option(kind.name, kind.id)), new Option("새 종류", ""), ); const name = el("input", "b07-plan-tools__name"); name.type = "text"; name.placeholder = "종류 이름"; name.maxLength = 40; const boxes = state.pieces.map((piece) => createCheckbox(piece.name, { value: piece.id })); const pieceList = el("div", "b07-plan-tools__pieces"); pieceList.append(...boxes.map((box) => box.root)); const action = el("button", "b07-plan-tools__button"); action.type = "button"; const show = (): void => { const kind = state.kinds.find((item) => item.id === pick.value); name.value = kind?.name ?? ""; boxes.forEach(({ input }) => (input.checked = kind?.pieces.includes(input.value) ?? false)); action.textContent = kind ? "업데이트" : "추가"; }; pick.addEventListener("change", show); show(); action.addEventListener("click", () => { const pieces = boxes.filter((box) => box.input.checked).map((box) => box.input.value); const kindId = pick.value; const label = name.value.trim(); void run( () => kindId ? updatePlanKind(controls.projectId, kindId, label, pieces) : addPlanKind(controls.projectId, label, pieces), controls, ); }); form.append(pick, name, pieceList, action); return form; } function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElement { const form = el("div", "b07-plan-tools__block"); form.append(el("strong", "", "대피소 · 사토장 자리")); form.append( el( "p", "b07-plan-tools__hint", "구조물로 놓은 대피소 · 사토장은 저절로 실립니다. 더 놓을 측점(NO + 거리)만 적으세요.", ), ); const interval = state.station_interval_m || DEFAULT_STATION_INTERVAL_M; const rows = el("div", "b07-plan-tools__marks"); const readers = new Map PlanMark | null>(); const addRow = (mark: PlanMark): void => { const row = el("div", "b07-plan-tools__mark"); const station = stationFields("측점", () => interval); station.write(mark.chainage_m, interval); const refuge = createCheckbox("대피소", { checked: mark.refuge }); const spoil = createCheckbox("사토장", { checked: mark.spoil }); const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = "이 자리 빼기"; remove.addEventListener("click", () => { readers.delete(row); row.remove(); }); row.append(station.wrap, refuge.root, spoil.root, remove); readers.set(row, () => { const chainage = station.read(interval, true); return chainage === null ? null : { chainage_m: chainage, refuge: refuge.input.checked, spoil: spoil.input.checked }; }); rows.append(row); }; state.marks.forEach(addRow); const more = el("button", "b07-plan-tools__button", "자리 추가"); more.type = "button"; more.addEventListener("click", () => addRow({ chainage_m: 0, refuge: true, spoil: false })); const save = el("button", "b07-plan-tools__button", "업데이트"); save.type = "button"; save.addEventListener("click", () => { const marks = [...readers.values()].map((read) => read()); if (marks.some((mark) => mark === null)) { showToast("측점 NO + 거리를 바르게 적어 주세요.", "error"); return; } void run(() => savePlanMarks(controls.projectId, marks as PlanMark[]), controls); }); const actions = el("div", "b07-plan-tools__actions"); actions.append(more, save); form.append(rows, actions); return form; } /** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, controls?: PlanKindsControls, ): HTMLElement { const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); section.dataset.kind = "plan"; section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`)); const kinds = new Map(); for (const item of items) { const key = item.plan_kind ?? item.id; kinds.set(key, [...(kinds.get(key) ?? []), item]); } for (const [kindId, sheets] of kinds) { const name = sheets[0].plan_kind_name ?? sheets[0].label; const block = el("div", "b07-plan-kind"); block.dataset.planKind = kindId; // 한 장이면 [도면 단추][×] 한 줄 · 여러 장이면 [이름][×] 아래 장 단추. const head = el("div", "b07-plan-kind__head"); const buttons = sheets.map((drawing, index) => makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name), ); head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]); if (controls) { const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = `「${name}」 종류 지우기`; remove.addEventListener("click", () => { if (!window.confirm(`「${name}」 종류를 지웁니다. 다시 쓰려면 「새 종류」로 추가합니다.`)) return; void run(() => deletePlanKind(controls.projectId, kindId), controls); }); head.append(remove); } block.append(head, ...(sheets.length > 1 ? buttons : [])); section.append(block); } if (controls) { // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. const tools = el("details", "b07-plan-tools"); tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리")); tools.addEventListener("toggle", () => { if (!tools.open || tools.dataset.loaded) return; tools.dataset.loaded = "true"; void fetchPlanKinds(controls.projectId) .then((state) => tools.append(kindForm(state, controls), marksForm(state, controls))) .catch((error: unknown) => { delete tools.dataset.loaded; showToast( error instanceof Error ? error.message : "종류 목록을 받지 못했습니다.", "error", ); }); }); section.append(tools); } return section; }