- 서버 새 길 POST …/quantity/milestone/directions — 두 좌표 → 카카오 길찾기 → km 소수 1자리 · 키 없음 · 실패 = 까닭 한 줄 · 키 서버만 · 응답 원문 · 경로 좌표 안 돌려줌 - 거래처 모달 [길찾기] · [등록] 뒤 한 번 — 거래처 → 기준점 · 기준점 → 시점(기준점 없으면 거래처 → 시점) 자동 km(auto_km 만 저장) - 사용자 km 우선 · 다르면 호버 「자동 값 ○○ km」 · 도로 구분 안 고름 = 합에 안 듦 · 표 합계 칸 「도로 구분 고르기」 - 표 합 파이썬 · TS 짝 같이 고침 · 새 시험 test_244_7 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
/* =============================================================================
|
||
* B08_Quantity_UI_Milestone_Modal.ts
|
||
* 거리이정표 다이어그램 모달(PLAN 244-4 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침.
|
||
*
|
||
* [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
|
||
* 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
|
||
* 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
|
||
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
|
||
* 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점)
|
||
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(VWorld 대리 · 새 키 없음) · 조작 = 캐시 · [저장] 때 서버
|
||
* [길찾기](244-7) = 거래처 모달 단추 · [등록] 뒤 한 번 — 구간 km 자동(카카오 · 서버) · 사용자 km 우선 ·
|
||
* 자동 값 = km 칸 빈칸 글 · 호버 · 도로 구분 = 사용자 고름(빈 값 = 「도로 구분 고르기」)
|
||
* ========================================================================== */
|
||
|
||
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 {
|
||
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 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;
|
||
},
|
||
});
|
||
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,
|
||
);
|
||
},
|
||
});
|
||
}
|
||
|
||
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
|
||
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"),
|
||
);
|
||
},
|
||
});
|
||
}
|