/* ============================================================================= * B08_Quantity_UI_Milestone_Modal.ts * 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침. * * 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 · * 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 · * 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제] * 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달 * 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음) * 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰) * 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도 * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ import "../B01_Dashboard/B01_Dashboard_UI_Style.css"; import { createButton, createInputField, createSelectField, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview"; import { roadOf, type Dest, type MilestoneRoute, type MilestoneRow, type Road, } from "./B08_Quantity_Milestone_Calc"; import { request } from "./B08_Quantity_UI_Hydro"; import { placePicker } from "./B08_Quantity_UI_Milestone_Place"; import { findSegmentKm, liveMilestone, putMilestone, roadOptions, siteInfo, } from "./B08_Quantity_UI_Milestone_State"; const DEST_OPTIONS = [ { value: "start", text: "시점" }, { value: "mid", text: "중점" }, ]; /** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */ export const ITEMS: Record<"route" | "base", string[]> = { route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"], base: ["기준점"], }; const line = (...children: HTMLElement[]): HTMLElement => el("div", { className: "b08-milestone__line-edit", children }); const head = (text: string): HTMLElement => el("h4", { text }); /** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */ export function toastKm(text: string): void { if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success"); } let listSeq = 0; /** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */ function rowField(r: MilestoneRow, kind: "route" | "base") { const item = createInputField({ label: "항목", compact: true }); const list = el("datalist", { attrs: { id: `b08-milestone-items-${++listSeq}` } }); item.input.setAttribute("list", list.id); item.input.value = r.item; item.root.append(list); const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS }); const setKind = (k: "route" | "base"): void => { list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } }))); item.input.placeholder = `예 ${ITEMS[k][0]}`; // 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149) item.root.style.visibility = dest.root.style.visibility = k === "base" ? "hidden" : ""; }; setKind(kind); dest.select.value = r.dest; item.input.dataset.b08MilestoneAdd = "item"; return { item, dest, note: r.note, setKind }; } export type PlaceTarget = { add: true } | { add: false; id: string }; /** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */ export function openPlaceModal( projectId: string, target: PlaceTarget, addRoute?: (init: Partial) => string, ): void { const data = liveMilestone(projectId); const editBase = !target.add && target.id === "base"; const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined; if (!target.add && !editBase && !route) return; const title = target.add ? "요소 추가 — 기준점 · 거래처" : editBase ? `기준점 — ${data.base.name || "기준점"}` : `항목 정보 — ${route!.name || "거래처"}`; openModal({ title, closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { let kind: "route" | "base" = editBase ? "base" : "route"; const sort = createSelectField({ label: "분류", compact: true, options: [ { value: "base", text: "기준위치" }, { value: "route", text: "거래처" }, ], }); sort.select.value = kind; sort.select.disabled = !target.add; // 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침) const baseOption = sort.select.querySelector('option[value="base"]'); if (target.add && data.base.enabled && baseOption) { baseOption.disabled = true; baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)"; } sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); name.input.value = editBase ? data.base.name : (route?.name ?? ""); name.input.dataset.b08MilestoneAdd = "company"; const nameLabel = name.root.querySelector(".ui-field__label"); // 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠 const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind); const old = editBase ? data.base : route; const place = placePicker( projectId, { address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null }, () => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"), ); const setKind = (k: "route" | "base"): void => { kind = k; if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름"; first.setKind(k); }; sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base")); setKind(kind); const rowValues = (): MilestoneRow[] => [ { item: first.item.input.value.trim(), dest: first.dest.select.value as Dest, note: first.note, }, ...(route?.rows.slice(1) ?? []), ]; const moved = (p: { lon: number | null; lat: number | null }): boolean => p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat); const ok = createButton({ label: target.add ? "등록" : "수정", variant: "filled", onClick: () => { const text = name.input.value.trim(); const where = place.value(); const fresh = liveMilestone(projectId); let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번 if (kind === "base") { Object.assign(fresh.base, where, { enabled: true, name: text, dir: null }); if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }]; putMilestone(projectId, fresh); if (moved(where)) segment = "base"; } else if (route) { const r = fresh.routes.find((x) => x.id === route.id); if (!r) return close(); Object.assign(r, where, { name: text || r.name, rows: rowValues(), }); putMilestone(projectId, fresh); if (moved(where)) segment = r.id; } else { if (!text) return name.setError("거래처 이름을 넣으세요."); // 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) const id = addRoute?.({ name: text, via_base: true, linked: false, dir: null, rows: rowValues(), ...where, }); } close(); if (segment) void findSegmentKm(projectId, segment).then(toastKm); }, }); ok.dataset.b08MilestoneInfo = "save"; const remove = createButton({ label: "삭제", variant: "danger", onClick: async () => { const label = editBase ? data.base.name || "기준점" : route?.name || "거래처"; if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] }; else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이 putMilestone(projectId, fresh); close(); }, }); remove.dataset.b08MilestoneInfo = "delete"; remove.hidden = target.add; actions.append(remove, ok); body.append( el("div", { className: "b08-milestone__grid", children: [sort.root, first.item.root, name.root, first.dest.root], }), head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"), place.root, ); }, }); } export 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, close, actions) => { const text = (label: string, value: string): HTMLElement => el("p", { className: "b08-options__hint", text: `${label} — ${value}` }); // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) const data = liveMilestone(projectId); const site = siteInfo(); const km = (m: number | null | undefined): string => m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); const mid = createInputField({ label: "중점 측점 (m)", type: "number", min: 0, compact: true, }); mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); mid.input.dataset.b08Milestone = "mid_station_m"; const road = createSelectField({ label: "시점 → 중점 도로", options: roadOptions("mid"), compact: true, }); road.select.value = roadOf(data.site.mid_road, "mid"); const save = createButton({ label: "수정", variant: "filled", onClick: () => { const fresh = liveMilestone(projectId); const value = Number(mid.input.value); fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; fresh.site.mid_road = road.select.value as Road; putMilestone(projectId, fresh); close(); }, }); save.dataset.b08MilestoneSite = "save"; actions.append(save); const map = buildAddressMap(); const info = el("div", { children: [text("주소", "불러오는 중…")] }); body.append( line(mid.root, road.root), text( "시점 = 노선 첫 점(측점 0)", `노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`, ), 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"), ); }, }); }