Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
T
eomsangdonandClaude Opus 5.5 5f2e6e18b6 feat(b08): 거리이정표 [+] 모달 · 주소 검색 재활용 · 방위 자동 · 시점 모달 · 팬 줌 · 경로 탭 = 길찾기 그림 (244-4)
- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리
- 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간)
- 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan
- 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰
- 서버 site-address(좌표 → 주소 · VWorld getAddress)

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

337 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침.
*
* [+] 항목 추가 = 항목 · 업체 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
* 경로(하위 탭) 생김 · 같은 업체 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
* 업체 상자 = 그 항목 정보(업체 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점)
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
import { createCheckbox } from "@ui/ui_template_checkbox";
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import {
buildAddressField,
type AddressFieldHandle,
} from "../B01_Dashboard/B01_Dashboard_UI_AddressField";
import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
import {
ROADS,
type Dest,
type Leg,
type MilestoneData,
type MilestoneRoute,
type MilestoneRow,
type Road,
} from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import { liveMilestone, putMilestone, roadOptions } from "./B08_Quantity_UI_Milestone_State";
const DEST_OPTIONS = [
{ value: "start", text: "시점" },
{ value: "mid", text: "중점" },
];
const line = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "b08-milestone__line-edit", children });
/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */
function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true });
const km = createInputField({ label: "km", type: "number", min: 0, compact: true });
road.select.value = leg.road;
km.input.value = leg.km == null ? "" : String(leg.km);
const ref = { road: road.select, km: km.input };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = line(road.root, km.root, drop);
row.dataset.b08MilestoneLeg = "";
list.append(row);
};
initial.forEach(add);
const more = createButton({
label: "+ 구간",
size: "sm",
variant: "ghost",
onClick: () => add({ road: fresh, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km }) => {
const n = Number(km.value);
return {
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh,
km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null,
};
}),
};
}
/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */
function rowsEditor(initial: MilestoneRow[]): {
root: HTMLElement;
value: () => MilestoneRow[];
} {
const list = el("div");
const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = [];
const add = (r: MilestoneRow): void => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true });
item.input.value = r.item;
dest.select.value = r.dest;
const ref = { item: item.input, dest: dest.select, note: r.note };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = line(item.root, dest.root, drop);
list.append(row);
};
initial.forEach(add);
const more = createButton({
label: "+ 줄",
size: "sm",
variant: "ghost",
onClick: () => add({ item: "", dest: "start", note: "" }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })),
};
}
/** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */
function placeOf(
field: AddressFieldHandle,
old: { address: string; lon?: number | null; lat?: number | null },
): { address: string; lon: number | null; lat: number | null } {
const address = field.value() ?? "";
const point = field.point();
if (point) return { address, lon: point.lon, lat: point.lat };
return address === old.address
? { address, lon: old.lon ?? null, lat: old.lat ?? null }
: { address, lon: null, lat: null };
}
const head = (text: string): HTMLElement => el("h4", { text });
/** [+] — 항목 · 업체 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
export function openAddItem(
projectId: string,
viaBase: boolean,
addRoute: (init: Partial<MilestoneRoute>) => string,
): void {
openModal({
title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
closeLabel: "닫기",
mount: (body, close, actions) => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const company = createInputField({ label: "업체 이름", compact: true });
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const address = buildAddressField(null);
item.input.dataset.b08MilestoneAdd = "item";
company.input.dataset.b08MilestoneAdd = "company";
const ok = createButton({
label: "등록",
variant: "filled",
onClick: () => {
const name = company.input.value.trim();
if (!name) return company.setError("업체 이름을 넣으세요.");
const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
const place = placeOf(address, { address: "" });
const data = liveMilestone(projectId);
const same = data.routes.find((r) => r.name.trim() === name);
if (same) {
same.rows.push(row);
if (place.lon != null) Object.assign(same, place);
putMilestone(projectId, data);
} else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place });
close();
},
});
actions.append(ok);
body.append(
item.root,
company.root,
dest.root,
head("업체 위치 — 주소 검색(방위는 이 자리로 자동)"),
address.root,
);
},
});
}
/** 업체 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */
export function openRouteInfo(projectId: string, routeId: string): void {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === routeId);
if (!route) return;
openModal({
title: `항목 정보 — ${route.name || "업체"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "업체 이름", compact: true });
name.input.value = route.name;
name.input.dataset.b08MilestoneInfo = "name";
const via = createCheckbox("기준점 거침");
via.input.checked = route.via_base;
via.root.hidden = !data.base.enabled;
const rows = rowsEditor(route.rows);
const address = buildAddressField(route.address || null);
const legs = legsEditor(route.legs, "2차포장");
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
const r = fresh.routes.find((x) => x.id === routeId);
if (!r) return close();
Object.assign(r, placeOf(address, r), {
name: name.input.value.trim() || r.name,
via_base: via.input.checked,
rows: rows.value(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
const remove = createButton({
label: "삭제",
variant: "danger",
onClick: async () => {
if (
!(await showConfirmDialog(
`「${route.name || "업체"}」 항목을 지울까요? — [저장] 때 반영`,
"삭제",
))
)
return;
const fresh = liveMilestone(projectId);
fresh.routes = fresh.routes.filter((r) => r.id !== routeId);
putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐
close();
},
});
save.dataset.b08MilestoneInfo = "save";
remove.dataset.b08MilestoneInfo = "delete";
actions.append(remove, save);
body.append(
name.root,
via.root,
head("표 줄 — 항목 · 목적지"),
rows.root,
head(
`구간 — ${route.name || "업체"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`,
),
legs.root,
head("주소 · 지도"),
address.root,
);
},
});
}
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
export function openBaseInfo(projectId: string): void {
const base = liveMilestone(projectId).base;
openModal({
title: `기준점 — ${base.name || "기준점"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "기준점 이름", compact: true });
name.input.value = base.name;
const address = buildAddressField(base.address || null);
const legs = legsEditor(base.legs, "1차포장");
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
Object.assign(fresh.base, placeOf(address, fresh.base), {
name: name.input.value.trim(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
actions.append(save);
body.append(
name.root,
head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"),
legs.root,
head("주소 · 지도"),
address.root,
);
},
});
}
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) => {
const text = (label: string, value: string): HTMLElement =>
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
const map = buildAddressMap();
const info = el("div", { children: [text("주소", "불러오는 중…")] });
body.append(info, map.root);
request<SiteAddress>(`/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"),
);
},
});
}