Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_State.ts
T
eomsangdonandClaude Opus 5.5 4b70627682 feat(b08): 거리이정표 표 폭 = 다이어그램 폭 · 거래처 상자 항목 글 · 좌측 카드 없앰 · 팬 고침 · 거래처 글 (244-6)
- 틀 너비 = 표 너비 · 다이어그램은 그 너비로 늘음
- 거래처 상자 = 항목(윗줄) + 거래처 이름
- 좌측 패널 거리이정표 카드 없앰 — 중점 측점 · 도로는 공사지 모달 · 기준점은 기준점 모달([기준점 +] · 기준점 없앰)
- 팬 고침: 원배율에서 공용 팬 범위가 0 이던 것 — 범위 줌 · 빈 곳 왼쪽 끌기 더함(공용 panStart 선택 칸 · 기본 그대로)
- 화면 글 「업체」 → 「거래처」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:46:37 +09:00

129 lines
5.2 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone_State.ts
* 거리이정표(PLAN 244-1) 데이터 층 — 저장본 · 캐시 · [저장] · 경로 그림 길(좌측 패널 카드는 244-6 에서 없앰 ·
* 공사지 · 기준점은 다이어그램 모달).
*
* 저장본 = GET …/quantity/milestone(서버 정본 · 표는 서버가 셈) · 조작 = 캐시 `b08-milestone` 즉시 +
* 표는 TS 짝(`…_Milestone_Calc`)이 바로 셈 · [저장](좌측 패널 한 줄) = PUT → 캐시 비움 · 자동 저장 없음.
* 그림 = 올리는 즉시 서버(경로 하나 = 그림 하나 · B09 견적서 꼴).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { request } from "./B08_Quantity_UI_Hydro";
import { ROADS, type MilestoneData } 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<string, string>;
}
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 = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
/** 저장본 읽기(프로젝트마다 한 번 · force = 다시) */
export async function loadMilestone(projectId: string, force = false): Promise<Payload> {
if (!saved || savedFor !== projectId || force) {
saved = await request<Payload>(`/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<string, string> => saved?.images ?? {};
export const hasMilestoneDraft = (projectId: string): boolean =>
readState<MilestoneData>("b08-milestone", projectId) !== null;
/** 지금 값 — 캐시 → 저장본 → 빈 꼴(늘 복사본) */
export function liveMilestone(projectId: string): MilestoneData {
return clone(readState<MilestoneData>("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<void> {
const draft = readState<MilestoneData>("b08-milestone", projectId);
if (!draft) return;
saved = await request<Payload>(`/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<Response> {
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<void> {
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<void> {
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<string> {
return URL.createObjectURL(await (await call(imageUrl(projectId, routeId))).blob());
}
export const roadOptions = ROADS.map((r) => ({ value: r, text: r }));