Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Place.ts
T
eomsangdonandClaude Sonnet 5.5 b2ad4f7e40 feat(B08): 262 상자 창 제목 이름만 · 소재지 지도 시점 표지 고정
- 거래처 · 기준점 창 제목 = 이름만 · 시점 창 = 「시점 · 중점」
- 소재지 지도에 시점 표지(고정 · 끌기 없음) · 소재지 없으면 시점 중심

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 16:33:24 +09:00

153 lines
5.9 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 { liveMilestone, startPoint } 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";
// 262-2 시점 = 거리이정표가 쓰는 시점(끌어 옮긴 덮개 · 없으면 노선 시점)
const start = (): [number, number] | null => startPoint(liveMilestone(projectId));
const map = editMap(projectId, {
hint: "지도 누름 = 그 자리를 소재지로 · 표지 끌어 옮김",
onClick: (lon, lat) => void moveTo(lon, lat),
onMove: (_key, lon, lat) => void moveTo(lon, lat),
center: start,
});
const draw = (): void => {
where.textContent = `소재지 — ${place.address || "주소 찾기 · 지도에서 고르세요"}`;
const s = start();
map.set(
[
...(s
? [{ key: "start", name: "시점", lon: s[0], lat: s[1], movable: false, fixed: true }]
: []),
...(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 }),
};
}