- 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제] - 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나 - 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위) - 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
225 lines
9.0 KiB
TypeScript
225 lines
9.0 KiB
TypeScript
/* =============================================================================
|
|
* 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<string, string>;
|
|
}
|
|
|
|
/** 8방 이름 */
|
|
export const DIR_TEXT: Record<string, string> = {
|
|
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 = <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 }));
|
|
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];
|
|
}
|