/* ============================================================================= * B08_Quantity_UI_Milestone_Modal.ts * 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침. * * [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] = * 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리 * 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제] * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) * 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, createSelectField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { buildAddressField, type AddressFieldHandle, } from "../B01_Dashboard/B01_Dashboard_UI_AddressField"; import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; import { ROADS, type Dest, type Leg, type MilestoneData, type MilestoneRoute, type MilestoneRow, type Road, } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State"; const DEST_OPTIONS = [ { value: "start", text: "시점" }, { value: "mid", text: "중점" }, ]; const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); /** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */ function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } { const list = el("div"); const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = []; const add = (leg: Leg): void => { const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true }); const km = createInputField({ label: "km", type: "number", min: 0, compact: true }); road.select.value = leg.road; km.input.value = leg.km == null ? "" : String(leg.km); const ref = { road: road.select, km: km.input }; rows.push(ref); const drop = createButton({ label: "×", size: "sm", variant: "ghost", onClick: () => { rows.splice(rows.indexOf(ref), 1); row.remove(); }, }); const row = line(road.root, km.root, drop); row.dataset.b08MilestoneLeg = ""; list.append(row); }; initial.forEach(add); const more = createButton({ label: "+ 구간", size: "sm", variant: "ghost", onClick: () => add({ road: fresh, km: null }), }); return { root: el("div", { children: [list, more] }), value: () => rows.map(({ road, km }) => { const n = Number(km.value); return { road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh, km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null, }; }), }; } /** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */ function rowsEditor(initial: MilestoneRow[]): { root: HTMLElement; value: () => MilestoneRow[]; } { const list = el("div"); const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = []; const add = (r: MilestoneRow): void => { const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true }); item.input.value = r.item; dest.select.value = r.dest; const ref = { item: item.input, dest: dest.select, note: r.note }; rows.push(ref); const drop = createButton({ label: "×", size: "sm", variant: "ghost", onClick: () => { rows.splice(rows.indexOf(ref), 1); row.remove(); }, }); const row = line(item.root, dest.root, drop); list.append(row); }; initial.forEach(add); const more = createButton({ label: "+ 줄", size: "sm", variant: "ghost", onClick: () => add({ item: "", dest: "start", note: "" }), }); return { root: el("div", { children: [list, more] }), value: () => rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })), }; } /** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */ function placeOf( field: AddressFieldHandle, old: { address: string; lon?: number | null; lat?: number | null }, ): { address: string; lon: number | null; lat: number | null } { const address = field.value() ?? ""; const point = field.point(); if (point) return { address, lon: point.lon, lat: point.lat }; return address === old.address ? { address, lon: old.lon ?? null, lat: old.lat ?? null } : { address, lon: null, lat: null }; } const head = (text: string): HTMLElement => el("h4", { text }); /** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */ export function openAddItem( projectId: string, viaBase: boolean, addRoute: (init: Partial) => string, ): void { openModal({ title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로", closeLabel: "닫기", mount: (body, close, actions) => { const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" }); const company = createInputField({ label: "업체 이름", compact: true }); const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); const address = buildAddressField(null); item.input.dataset.b08MilestoneAdd = "item"; company.input.dataset.b08MilestoneAdd = "company"; const ok = createButton({ label: "등록", variant: "filled", onClick: () => { const name = company.input.value.trim(); if (!name) return company.setError("업체 이름을 넣으세요."); const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" }; const place = placeOf(address, { address: "" }); const data = liveMilestone(projectId); const same = data.routes.find((r) => r.name.trim() === name); if (same) { same.rows.push(row); if (place.lon != null) Object.assign(same, place); putMilestone(projectId, data); } else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place }); close(); }, }); actions.append(ok); body.append( item.root, company.root, dest.root, head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"), address.root, ); }, }); } /** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */ export function openRouteInfo(projectId: string, routeId: string): void { const data = liveMilestone(projectId); const route = data.routes.find((r) => r.id === routeId); if (!route) return; openModal({ title: `항목 정보 — ${route.name || "업체"}`, closeLabel: "닫기", mount: (body, close, actions) => { const name = createInputField({ label: "업체 이름", compact: true }); name.input.value = route.name; name.input.dataset.b08MilestoneInfo = "name"; const via = createCheckbox("기준점 거침"); via.input.checked = route.via_base; via.root.hidden = !data.base.enabled; const rows = rowsEditor(route.rows); const address = buildAddressField(route.address || null); const legs = legsEditor(route.legs, "2차포장"); const save = createButton({ label: "고침", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); const r = fresh.routes.find((x) => x.id === routeId); if (!r) return close(); Object.assign(r, placeOf(address, r), { name: name.input.value.trim() || r.name, via_base: via.input.checked, rows: rows.value(), legs: legs.value(), }); putMilestone(projectId, fresh); close(); }, }); const remove = createButton({ label: "삭제", variant: "danger", onClick: async () => { if ( !(await showConfirmDialog( `「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`, "삭제", )) ) return; const fresh = liveMilestone(projectId); fresh.routes = fresh.routes.filter((r) => r.id !== routeId); putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐 close(); }, }); save.dataset.b08MilestoneInfo = "save"; remove.dataset.b08MilestoneInfo = "delete"; actions.append(remove, save); body.append( name.root, via.root, head("표 줄 — 항목 · 목적지"), rows.root, head( `구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`, ), legs.root, head("주소 · 지도"), address.root, ); }, }); } /** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */ export function openBaseInfo(projectId: string): void { const base = liveMilestone(projectId).base; openModal({ title: `기준점 — ${base.name || "기준점"}`, closeLabel: "닫기", mount: (body, close, actions) => { const name = createInputField({ label: "기준점 이름", compact: true }); name.input.value = base.name; const address = buildAddressField(base.address || null); const legs = legsEditor(base.legs, "1차포장"); const save = createButton({ label: "고침", variant: "filled", onClick: () => { const fresh: MilestoneData = liveMilestone(projectId); Object.assign(fresh.base, placeOf(address, fresh.base), { name: name.input.value.trim(), legs: legs.value(), }); putMilestone(projectId, fresh); close(); }, }); actions.append(save); body.append( name.root, head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"), legs.root, head("주소 · 지도"), address.root, ); }, }); } interface SiteAddress { region: string; start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; start_address: string | null; mid_address: string | null; } /** 시점(공사지) 상자 — 프로젝트 등록 주소 · VWorld 주소 · 아래 지도(시점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ title: "공사지 — 시점", closeLabel: "닫기", mount: (body) => { const text = (label: string, value: string): HTMLElement => el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); const map = buildAddressMap(); const info = el("div", { children: [text("주소", "불러오는 중…")] }); body.append(info, map.root); request(`/projects/${projectId}/quantity/milestone/site-address`).then( (s) => { const ll = (p: [number, number] | null): string => p ? ` (${p[1].toFixed(6)}, ${p[0].toFixed(6)})` : ""; info.replaceChildren( text("프로젝트 등록 주소(사업지역)", s.region || "없음"), text("시점 브이월드 주소", (s.start_address ?? "못 찾음") + ll(s.start_lonlat)), text("중점 브이월드 주소", (s.mid_address ?? "못 찾음") + ll(s.mid_lonlat)), ); if (s.start_lonlat) map.showPoint(s.start_lonlat[1], s.start_lonlat[0]); else if (s.region) void map.show(s.region); }, (error: unknown) => showToast(`공사지 주소를 못 읽었습니다 — ${String(error)}`, "error"), ); }, }); }