- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + 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
178 lines
6.5 KiB
TypeScript
178 lines
6.5 KiB
TypeScript
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
|
import { searchAddress, type AddressCandidate } from "./B01_Dashboard_Api_Fetch";
|
|
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
|
|
import { L } from "./B01_Dashboard_UI_Common";
|
|
|
|
/**
|
|
* 회사 주소 입력칸 — 검색해서 고르는 방식 (2026-09-13 사용자 지시).
|
|
*
|
|
* 손으로 정확히 치지 않아도 되게, 일부만 넣어 후보를 받고 고른다.
|
|
* 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 바로 그린다.
|
|
* 저장은 지금대로 한 줄 — 「기본주소 + 상세주소」. 우편번호는 화면 확인용.
|
|
*
|
|
* 줄 배치는 라벨·입력·버튼을 한 줄에 세운다(B06 횡단 설정과 같은 꼴) — ui-field 가
|
|
* 기본이 세로라 그대로 두면 오류 슬롯 높이만큼 버튼이 밀려 수평이 어긋난다.
|
|
* 그래서 오류 슬롯은 접고, 잘못 넣은 것은 토스트로 알린다.
|
|
*/
|
|
|
|
export interface AddressFieldHandle {
|
|
root: HTMLElement;
|
|
/** 저장할 한 줄 주소. 비면 null. */
|
|
value: () => string | null;
|
|
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
|
|
point: () => { lat: number; lon: number } | null;
|
|
}
|
|
|
|
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
|
|
function candidateLine(item: AddressCandidate): string {
|
|
const main = item.category === "parcel" ? item.parcel : item.road;
|
|
return main || item.road || item.parcel;
|
|
}
|
|
|
|
function candidateHint(item: AddressCandidate): string {
|
|
const other = item.category === "parcel" ? item.road : item.parcel;
|
|
return [item.building, other, item.zipcode].filter(Boolean).join(" · ");
|
|
}
|
|
|
|
/** createButton 은 글자를 span 에 담는다 — textContent 로 덮으면 그 껍질이 사라진다. */
|
|
function setButtonLabel(button: HTMLButtonElement, label: string): void {
|
|
const slot = button.querySelector(".ui-btn__label");
|
|
if (slot) slot.textContent = label;
|
|
else button.textContent = label;
|
|
}
|
|
|
|
function row(...parts: HTMLElement[]): HTMLElement {
|
|
const line = document.createElement("div");
|
|
line.className = "b01-dashboard__address-row";
|
|
line.append(...parts);
|
|
return line;
|
|
}
|
|
|
|
export function buildAddressField(initial: string | null): AddressFieldHandle {
|
|
const root = document.createElement("div");
|
|
root.className = "b01-dashboard__address";
|
|
|
|
// 1) 검색 줄 — 일부만 넣어도 후보가 나온다.
|
|
const query = createInputField({
|
|
label: L("B01_Dashboard_Field_Address"),
|
|
placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)",
|
|
});
|
|
const findBtn = createButton({
|
|
label: "주소 찾기",
|
|
variant: "ghost",
|
|
onClick: () => void runSearch(),
|
|
});
|
|
const manualBtn = createButton({
|
|
label: "직접 입력",
|
|
variant: "ghost",
|
|
onClick: function onB01_Address_Manual_Click() {
|
|
manual = !manual;
|
|
setButtonLabel(manualBtn, manual ? "검색으로" : "직접 입력");
|
|
base.input.readOnly = !manual;
|
|
query.root.hidden = manual;
|
|
findBtn.hidden = manual;
|
|
mapBtn.hidden = !manual;
|
|
if (manual) base.input.focus();
|
|
},
|
|
});
|
|
const searchRow = row(query.root, findBtn, manualBtn);
|
|
|
|
// 2) 후보 목록 — 고르면 사라진다.
|
|
const list = document.createElement("div");
|
|
list.className = "b01-dashboard__address-list";
|
|
list.hidden = true;
|
|
|
|
// 3) 고른 주소 + 상세주소.
|
|
const base = createInputField({ label: "기본주소", value: initial ?? "" });
|
|
base.input.readOnly = true;
|
|
const zip = document.createElement("span");
|
|
zip.className = "b01-dashboard__address-zip";
|
|
zip.hidden = true;
|
|
const detail = createInputField({ label: "상세주소", placeholder: "동·층·호" });
|
|
|
|
// 4) 지도 — 고른 즉시 그린다. 직접 입력일 때만 버튼으로 확인한다.
|
|
const map = buildAddressMap();
|
|
const mapBtn = createButton({
|
|
label: "지도 확인",
|
|
variant: "ghost",
|
|
onClick: async function onB01_Address_Map_Click() {
|
|
picked = await map.show(base.input.value.trim());
|
|
},
|
|
});
|
|
mapBtn.hidden = true;
|
|
|
|
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
|
|
|
|
let manual = false;
|
|
let picked: { lat: number; lon: number } | null = null;
|
|
|
|
function pick(item: AddressCandidate): void {
|
|
base.input.value = candidateLine(item);
|
|
zip.textContent = item.zipcode ? `우편번호 ${item.zipcode}` : "";
|
|
zip.hidden = !item.zipcode;
|
|
list.hidden = true;
|
|
list.innerHTML = "";
|
|
picked = { lat: item.lat, lon: item.lon };
|
|
map.showPoint(item.lat, item.lon);
|
|
detail.input.focus();
|
|
}
|
|
|
|
async function runSearch(): Promise<void> {
|
|
const text = query.input.value.trim();
|
|
if (text.length < 2) {
|
|
showToast("주소를 두 글자 이상 넣으십시오.", "error");
|
|
return;
|
|
}
|
|
findBtn.disabled = true;
|
|
setButtonLabel(findBtn, "찾는 중…");
|
|
try {
|
|
const items = await searchAddress(text);
|
|
list.innerHTML = "";
|
|
list.hidden = false;
|
|
if (items.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "b01-dashboard__modal-text";
|
|
empty.textContent = "찾은 주소가 없습니다. 다른 낱말로 넣거나 「직접 입력」을 쓰십시오.";
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
for (const item of items) {
|
|
const option = document.createElement("button");
|
|
option.type = "button";
|
|
option.className = "b01-dashboard__address-item";
|
|
const line = document.createElement("strong");
|
|
line.textContent = candidateLine(item);
|
|
const hint = document.createElement("small");
|
|
hint.textContent = candidateHint(item);
|
|
option.append(line, hint);
|
|
option.addEventListener("click", () => pick(item));
|
|
list.append(option);
|
|
}
|
|
} catch {
|
|
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하십시오.", "error");
|
|
} finally {
|
|
findBtn.disabled = false;
|
|
setButtonLabel(findBtn, "주소 찾기");
|
|
}
|
|
}
|
|
|
|
// 검색칸에서 Enter — 모달이 닫히지 않게 막고 찾기만 한다.
|
|
query.input.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter") return;
|
|
event.preventDefault();
|
|
void runSearch();
|
|
});
|
|
|
|
// 이미 있는 주소는 열자마자 지도로 보여 준다.
|
|
if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
|
|
|
|
return {
|
|
root,
|
|
value: () => {
|
|
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
|
|
return merged || null;
|
|
},
|
|
point: () => picked,
|
|
};
|
|
}
|