/* ============================================================================= * B08_Quantity_UI_Milestone_State.ts * 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길 + 좌측 패널 카드(공사지 · 기준점). * * 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 + * 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음. * 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createCheckbox } from "@ui/ui_template_checkbox"; import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { DIRS, ROADS, type MilestoneData, type Road } from "./B08_Quantity_Milestone_Calc"; export interface SiteInfo { length_m: number | null; mid_default_m: number | null; start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; } interface Payload { data: MilestoneData; site: SiteInfo; images: Record; } /** 8방 이름 */ export const DIR_TEXT: Record = { N: "북", NE: "북동", E: "동", SE: "남동", S: "남", SW: "남서", W: "서", NW: "북서", }; export const EMPTY: MilestoneData = { site: { mid_station_m: null, mid_road: "비포장" }, base: { enabled: false, name: "", address: "", dir: null, legs: [] }, routes: [], }; const changes = new EventTarget(); let saved: Payload | null = null; let savedFor: string | null = null; const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; /** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */ export async function loadMilestone(projectId: string, force = false): Promise { if (!saved || savedFor !== projectId || force) { saved = await request(`/projects/${projectId}/quantity/milestone`); savedFor = projectId; changes.dispatchEvent(new Event("change")); } return saved; } export const siteInfo = (): SiteInfo | null => saved?.site ?? null; export const routeImages = (): Record => saved?.images ?? {}; export const hasMilestoneDraft = (projectId: string): boolean => readState("b08-milestone", projectId) !== null; /** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */ export function liveMilestone(projectId: string): MilestoneData { return clone(readState("b08-milestone", projectId) ?? saved?.data ?? EMPTY); } /** 고친 한 벌을 캐시에 · 화면에 알림 */ export function putMilestone(projectId: string, data: MilestoneData): void { writeState("b08-milestone", data, projectId); changes.dispatchEvent(new Event("change")); } /** host 가 붙어 있는 동안 바뀔 때마다 fn — 떨어진 뒤 첫 알림에서 스스로 뗌 */ export function onMilestoneChange(host: HTMLElement, fn: () => void): void { const listener = (): void => { if (host.isConnected) fn(); else changes.removeEventListener("change", listener); }; changes.addEventListener("change", listener); } /** [저장] — 캐시가 있을 때만 PUT · 서버가 표를 다시 셈 */ export async function saveMilestone(projectId: string): Promise { const draft = readState("b08-milestone", projectId); if (!draft) return; saved = await request(`/projects/${projectId}/quantity/milestone`, { method: "PUT", body: JSON.stringify(draft), }); savedFor = projectId; clearState("b08-milestone", projectId); changes.dispatchEvent(new Event("change")); } const imageUrl = (projectId: string, routeId: string): string => `${API_BASE_URL}/projects/${projectId}/quantity/milestone/image?route=${encodeURIComponent(routeId)}`; async function call(input: string, init: RequestInit = {}): Promise { const response = await fetch(input, { credentials: "include", ...init }); if (!response.ok) { const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); } return response; } /** 경로 그림 — 올리기(교체) · 지우기는 즉시 서버 · 목록 고쳐 알림 */ export async function uploadImage(projectId: string, routeId: string, file: File): Promise { const form = new FormData(); form.append("file", file); const body = (await ( await call(imageUrl(projectId, routeId), { method: "POST", body: form }) ).json()) as { name: string }; if (saved) saved.images = { ...saved.images, [routeId]: body.name }; changes.dispatchEvent(new Event("change")); } export async function deleteImage(projectId: string, routeId: string): Promise { await call(imageUrl(projectId, routeId), { method: "DELETE" }); if (saved) { const rest = { ...saved.images }; delete rest[routeId]; saved.images = rest; } changes.dispatchEvent(new Event("change")); } /** 그림 → 화면 주소(blob · 쿠키 포함) */ export async function imageBlobUrl(projectId: string, routeId: string): Promise { return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob()); } export const roadOptions = ROADS.map((r) => ({ value: r, text: r })); export const dirOptions = [ { value: "", text: "방위 —" }, ...DIRS.map((d) => ({ value: d, text: DIR_TEXT[d] })), ]; const km = (m: number | null | undefined): string => m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 }); /** 좌측 패널 「거리이정표」 카드 몸 — 공사지(시점 · 중점) · 기준점(있음 · 이름 · 주소 · 방위) */ export function milestoneCardBody(projectId: string | null): HTMLElement[] { const mid = createInputField({ label: "중점 측점 (m)", type: "number", compact: true, min: 0 }); const midRoad = createSelectField({ label: "시점 → 중점 도로", options: roadOptions, compact: true, }); const siteHint = el("p", { className: "b08-options__hint" }); const has = createCheckbox("기준점 있음"); const name = createInputField({ label: "기준점 이름", compact: true }); const address = createInputField({ label: "기준점 주소", compact: true }); const dir = createSelectField({ label: "공사지에서 본 방위", options: dirOptions, compact: true, }); mid.input.dataset.b08Milestone = "mid_station_m"; has.input.dataset.b08Milestone = "base_enabled"; const baseFields = el("div", { children: [name.root, address.root, dir.root] }); const show = (): void => { if (!projectId) return; const data = liveMilestone(projectId); const site = siteInfo(); mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : ""; if (document.activeElement !== mid.input) mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m); midRoad.select.value = data.site.mid_road; siteHint.textContent = `시점 = 노선 첫 점(측점 0) · 노선 ${km(site?.length_m)} km · ` + `중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`; has.input.checked = data.base.enabled; baseFields.hidden = !data.base.enabled; if (document.activeElement !== name.input) name.input.value = data.base.name; if (document.activeElement !== address.input) address.input.value = data.base.address; dir.select.value = data.base.dir ?? ""; }; const edit = (fn: (data: MilestoneData) => void): void => { if (!projectId) return; const data = liveMilestone(projectId); fn(data); putMilestone(projectId, data); }; mid.input.addEventListener("input", () => edit((d) => { const value = Number(mid.input.value); d.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null; }), ); midRoad.select.addEventListener("change", () => edit((d) => (d.site.mid_road = midRoad.select.value as Road)), ); has.input.addEventListener("change", () => edit((d) => { d.base.enabled = has.input.checked; if (d.base.enabled && !d.base.legs.length) d.base.legs = [{ road: "1차포장", km: null }]; }), ); name.input.addEventListener("input", () => edit((d) => (d.base.name = name.input.value))); address.input.addEventListener("input", () => edit((d) => (d.base.address = address.input.value)), ); dir.select.addEventListener("change", () => edit((d) => (d.base.dir = (dir.select.value || null) as MilestoneData["base"]["dir"])), ); const host = el("div", { children: [mid.root, midRoad.root, siteHint, has.root, baseFields], }); show(); onMilestoneChange(host, show); // 첫 알림(읽기 끝)은 패널이 붙은 뒤 if (projectId) void loadMilestone(projectId).catch(() => undefined); return [host]; }