Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
T
eomsangdonandClaude Opus 5.5 0816595e2e feat(b08): 거리이정표 경로 지도 = 카카오 지도 JS 위 길 선 · 지도 캡처 (244-9)
- 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(vertexes = 경도 · 위도 번갈아) · 저장 안 함 · km 만 저장 그대로
- 새 길 GET …/quantity/milestone/map-key — 카카오 지도 JavaScript 키(.env · 공개용) · 없으면 null
- 거래처 모달 · 경로 하위 탭에 카카오 지도 — 길 선 · 거래처 · 기준점 · 시점 표지 · 경로 전체 맞춤
- [지도 캡처] = 지금 탭 캡처 → 지도 칸만 자름(CropTarget · 없으면 자리 수치) → 그 경로 그림 · 트랙 바로 멈춤
- JS 키 없음 · SDK 못 받음 = 안내 한 줄 · 사진 올리기 · 다음 지도 새 창 길은 그대로
- 새 시험 test_244_9 · test_244_7 응답 꼴 고침(path 더함)

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

460 lines
18 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 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
* [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 ·
* 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」)
* 경로 지도(244-9) = 거래처 모달 아래 카카오 지도(길 선 · 표지 · [지도 캡처] → 그 경로 그림)
* ========================================================================== */
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 { routeMapView } from "./B08_Quantity_UI_Milestone_KakaoMap";
import {
findRouteKm,
legRoadOptions,
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} 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[]): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement; auto: number | null }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({ label: "도로 구분", options: legRoadOptions, 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 auto = leg.auto_km ?? null;
if (auto != null) {
km.input.placeholder = String(auto); // 빈칸 = 자동 값 · 고치면 그 값 우선
km.input.title = `자동 값 ${auto} km`;
}
const ref = { road: road.select, km: km.input, auto };
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: null, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km, auto }) => {
const n = Number(km.value);
return {
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : null,
km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null,
auto_km: auto,
};
}),
};
}
/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */
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 });
/** [길찾기] 한 번 — 구간마다 결과 한 줄(알림) · 실패 줄이 있으면 경고 */
async function routeKm(projectId: string, routeId: string): Promise<string[]> {
const lines = await findRouteKm(projectId, routeId);
if (lines.length)
showToast(
`길찾기 — ${lines.join(" / ")}`,
lines.some((l) => l.includes(" — ")) ? "warning" : "success",
);
return lines;
}
/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
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);
let id = same?.id;
if (same) {
same.rows.push(row);
if (place.lon != null) Object.assign(same, place);
putMilestone(projectId, data);
} else id = addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place });
close();
if (id && place.lon != null) void routeKm(projectId, id); // [등록] 뒤 한 번
},
});
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);
let legs = legsEditor(route.legs);
const status = el("p", { className: "b08-options__hint" });
status.dataset.b08MilestoneInfo = "status";
const mapView = routeMapView(projectId, routeId, () =>
showToast("지도 캡처 = 이 거래처 경로 그림", "success"),
);
/** 모달 값 → 캐시(고침 · 길찾기 앞) */
const apply = (): boolean => {
const fresh = liveMilestone(projectId);
const r = fresh.routes.find((x) => x.id === routeId);
if (!r) return false;
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);
return true;
};
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
apply();
close();
},
});
// [길찾기] = 지금 모달 값을 캐시에 넣고 구간마다 카카오 km(서버) → 구간 칸 다시 그림
const find = createButton({
label: "길찾기",
variant: "ghost",
onClick: async () => {
if (!apply()) return close();
find.disabled = true;
status.textContent = "길찾기 중…";
const lines = await routeKm(projectId, routeId).finally(() => (find.disabled = false));
status.textContent = lines.join(" / ");
const r = liveMilestone(projectId).routes.find((x) => x.id === routeId);
if (!r) return;
const next = legsEditor(r.legs);
legs.root.replaceWith(next.root);
legs = next;
mapView.draw(); // 길 선(그때그때 · 저장 안 함)
},
});
find.dataset.b08MilestoneInfo = "find";
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, find, 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,
status,
head("주소 · 지도"),
address.root,
head("경로 지도 — 카카오"),
mapView.root,
);
},
});
}
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
export function openBaseInfo(projectId: string, create = false): void {
const base = liveMilestone(projectId).base;
openModal({
title: create ? "기준점 추가" : `기준점 — ${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.length ? base.legs : [{ road: null, km: null }]);
const save = createButton({
label: create ? "등록" : "고침",
variant: "filled",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
Object.assign(fresh.base, placeOf(address, fresh.base), {
enabled: true,
name: name.input.value.trim(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
// 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로)
const off = createButton({
label: "기준점 없앰",
variant: "danger",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
fresh.base.enabled = false;
putMilestone(projectId, fresh);
close();
},
});
off.hidden = create;
save.dataset.b08MilestoneBase = "save";
off.dataset.b08MilestoneBase = "off";
actions.append(off, save);
body.append(
name.root,
head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"),
legs.root,
head("주소 · 지도"),
el("p", {
className: "b08-options__hint",
text: "주소를 고르면 다이어그램에 실제 방위로 놓임 — 안 고르면 공사지 서쪽(왼쪽) 기본 자리",
}),
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, close, actions) => {
const text = (label: string, value: string): HTMLElement =>
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
// 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6)
const data = liveMilestone(projectId);
const site = siteInfo();
const km = (m: number | null | undefined): string =>
m == null ? "-" : (m / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 });
const mid = createInputField({
label: "중점 측점 (m)",
type: "number",
min: 0,
compact: true,
});
mid.input.placeholder = site?.mid_default_m != null ? String(site.mid_default_m) : "";
mid.input.value = data.site.mid_station_m == null ? "" : String(data.site.mid_station_m);
mid.input.dataset.b08Milestone = "mid_station_m";
const road = createSelectField({
label: "시점 → 중점 도로",
options: roadOptions,
compact: true,
});
road.select.value = data.site.mid_road;
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
const value = Number(mid.input.value);
fresh.site.mid_station_m = mid.input.value.trim() && value >= 0 ? value : null;
fresh.site.mid_road = road.select.value as Road;
putMilestone(projectId, fresh);
close();
},
});
save.dataset.b08MilestoneSite = "save";
actions.append(save);
const map = buildAddressMap();
const info = el("div", { children: [text("주소", "불러오는 중…")] });
body.append(
line(mid.root, road.root),
text(
"시점 = 노선 첫 점(측점 0)",
`노선 ${km(site?.length_m)} km · 중점 기본 = 노선 ÷ 2(${km(site?.mid_default_m)} km) · 빈칸 = 기본`,
),
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"),
);
},
});
}