Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts
T
eomsangdonandClaude Opus 5.5 2afb1af1ca feat(B08): 거리이정표 등록 모달 2열 · 소재지 다음 지도 · 연결선 모달 길찾기 · 도로 구분 갈래 열(244-13)
- 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창
- 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길
- 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기
- 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간
- 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라

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

145 lines
5.5 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone_Place.ts
* 거리이정표 등록 모달 「소재지」 칸(PLAN 244-13 · 사용자 147번 5) — 주소 찾기 · 지도에서 고르기만(직접 입력 없음).
*
* 주소 찾기 = B01 회사 주소와 같은 서버 후보 검색(VWorld 대리 · `searchAddress`) → 후보 고름 = 소재지 · 표지
* 지도 = 다음(카카오) 지도 JS(`…_KakaoMap` editMap) — 지도 누름 = 그 자리 소재지 · 표지 끌어 옮김 = 다시 고름 ·
* 고른 좌표 → 서버 GET …/milestone/reverse(VWorld 주소 한 줄 · 못 찾음 = 위도, 경도 글)
* 값 = 주소 글 · 경도 · 위도(캐시 · [저장] 때 서버)
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { searchAddress, type AddressCandidate } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { request } from "./B08_Quantity_UI_Hydro";
import { editMap } from "./B08_Quantity_UI_Milestone_KakaoMap";
import { siteInfo } from "./B08_Quantity_UI_Milestone_State";
export interface Place {
address: string;
lon: number | null;
lat: number | null;
}
export interface PlaceHandle {
root: HTMLElement;
value: () => Place;
}
/** 지번 후보 = 지번 줄이 시 · 도까지 갖춤(B01 주소 칸과 같은 고름) */
const lineOf = (c: AddressCandidate): string =>
(c.category === "parcel" ? c.parcel : c.road) || c.road || c.parcel;
/** 좌표 → 주소 한 줄(서버 VWorld) · 못 찾음 = 「위도, 경도」 글 */
export async function reverseAddress(projectId: string, lon: number, lat: number): Promise<string> {
const got = await request<{ address: string | null }>(
`/projects/${projectId}/quantity/milestone/reverse?lon=${lon}&lat=${lat}`,
).catch(() => null);
return got?.address || `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
}
/** 소재지 칸 — name() = 지도 표지 이름(거래처 · 기준점 이름) */
export function placePicker(projectId: string, initial: Place, name: () => string): PlaceHandle {
let place: Place = { ...initial };
let seq = 0; // 늦게 온 주소가 새 자리를 덮지 않게
const query = createInputField({
label: "주소 찾기",
compact: true,
placeholder: "도로명 · 지번 · 건물명 일부 (예 병곡리 968-31)",
});
const find = createButton({ label: "주소 찾기", variant: "ghost", onClick: () => void search() });
find.dataset.b08MilestonePlace = "find";
const list = el("div", { className: "b01-dashboard__address-list" });
list.hidden = true;
const where = el("p", { className: "b08-milestone__place" });
where.dataset.b08MilestonePlace = "value";
const map = editMap(projectId, {
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
onClick: (lon, lat) => void moveTo(lon, lat),
onMove: (_key, lon, lat) => void moveTo(lon, lat),
center: () => siteInfo()?.start_lonlat ?? null,
});
const draw = (): void => {
where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`;
map.set(
place.lon != null && place.lat != null
? [
{
key: "place",
name: name() || "소재지",
lon: place.lon,
lat: place.lat,
movable: true,
},
]
: [],
[],
);
};
async function moveTo(lon: number, lat: number): Promise<void> {
const mine = ++seq;
place = { ...place, lon, lat };
draw();
const address = await reverseAddress(projectId, lon, lat);
if (mine !== seq) return;
place.address = address;
draw();
}
async function search(): Promise<void> {
const text = query.input.value.trim();
if (text.length < 2) return showToast("주소를 두 글자 이상 넣으세요.", "error");
find.disabled = true;
try {
const items = await searchAddress(text);
list.hidden = false;
list.replaceChildren(
...(items.length
? items.map((item) => {
const option = el("button", {
className: "b01-dashboard__address-item",
attrs: { type: "button" },
children: [
el("strong", { text: lineOf(item) }),
el("small", { text: [item.building, item.zipcode].filter(Boolean).join(" · ") }),
],
});
option.addEventListener("click", () => {
seq++;
place = { address: lineOf(item), lon: item.lon, lat: item.lat };
list.hidden = true;
draw();
});
return option;
})
: [el("p", { text: "찾은 주소가 없습니다 — 다른 낱말로 찾거나 지도에서 고르세요." })]),
);
} catch {
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하세요.", "error");
} finally {
find.disabled = false;
}
}
// 검색 칸 Enter = 찾기만(모달 안 닫힘)
query.input.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
e.preventDefault();
void search();
});
draw();
return {
root: el("div", {
className: "b08-milestone__place-field",
children: [
el("div", { className: "b08-milestone__line-edit", children: [query.root, find] }),
list,
where,
map.root,
],
}),
value: () => ({ ...place }),
};
}