/* ============================================================================= * 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 · 공사지 · 기준점 [+] 없앰) * 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) · * 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로 * 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번 * 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지) * 조작 = 캐시 · [저장] 때 서버 * ========================================================================== */ 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 { roadOf, type Dest, type MilestoneRoute, type MilestoneRow, type Road, } from "./B08_Quantity_Milestone_Calc"; import { setLeg } from "./B08_Quantity_Milestone_Layout"; import { request } from "./B08_Quantity_UI_Hydro"; import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap"; 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 = 거래처 · 기준점 id */ export function openPlaceModal( projectId: string, target: PlaceTarget, addRoute?: (init: Partial) => string, ): void { const data = liveMilestone(projectId); const base = target.add ? undefined : data.bases.find((b) => b.id === target.id); const editBase = !!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 ? "요소 추가 — 기준점 · 거래처" : base ? `기준점 — ${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; sort.select.dataset.b08MilestoneAdd = "sort"; const name = createInputField({ label: "거래처 이름", compact: true }); name.input.value = base ? 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 = 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" && base) { const b = fresh.bases.find((x) => x.id === base.id); if (!b) return close(); Object.assign(b, where, { name: text, dir: null }); putMilestone(projectId, fresh); if (moved(where)) segment = b.id; } else if (kind === "base") { // 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기) fresh.bases.push({ id: `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`, name: text, dir: null, legs: [{ road: null, km: null }], to: "site", linked: false, ...where, }); putMilestone(projectId, fresh); } 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 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기) addRoute?.({ name: text, to: "site", 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 = base ? base.name || "기준점" : route?.name || "거래처"; if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제"))) return; const fresh = liveMilestone(projectId); if (base) { fresh.bases = fresh.bases.filter((b) => b.id !== base.id); // 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음) for (const o of [...fresh.bases, ...fresh.routes]) if (o.to === base.id) Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] }); } 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; } /** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */ export function openSiteInfo(projectId: string): void { openModal({ title: "공사지 — 시점 · 중점", closeLabel: "닫기", dialogClass: "b08-milestone__modal", mount: (body, close, actions) => { // 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1) const data = liveMilestone(projectId); const site = siteInfo(); const mid = createInputField({ label: "중점 측점 (km)", type: "number", min: 0, compact: true, }); mid.input.step = "0.001"; mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m / 1000) : ""; mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m / 1000); 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 text = mid.input.value; const value = Number(text); const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null; const fresh = liveMilestone(projectId); putMilestone( projectId, setLeg(fresh, "start", 0, { road: road.select.value as Road, km }), ); close(); }, }); save.dataset.b08MilestoneSite = "save"; actions.append(save); const map = editMap(projectId, { hint: "", onMove: () => undefined }); const pins = (s: { start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; }): MapPin[] => ( [ ["start", "시점", s.start_lonlat], ["mid", "중점", s.mid_lonlat], ] as const ).flatMap(([key, name, p]) => p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [], ); if (site) map.set(pins(site), []); body.append(line(mid.root, road.root), map.root); // 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시 const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`; request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( (s) => map.set(pins(s), []), (error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"), ); }, }); }