Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
T
eomsangdonandClaude Opus 5.5 2d5d357bf3 feat(b08): 거리이정표 [요소 추가] · 선 없는 새 거래처 · 연결선 수기 입력 (250-4)
- 공사지 · 기준점 [+] 없앰 → [자동 정렬] 옆 [요소 추가]
- 등록 = 상자만(linked false) · 연결 점으로 이음 · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝)
- 연결선 모달 [수기 입력] = 카카오 길찾기 연결 끊음(manual)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:24:53 +09:00

304 lines
13 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침.
*
* 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 ·
* 2열(분류(기준위치 · 거래처) · 항목(손 입력 + 분류 따라 제안 · 244-14) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [수정] · [삭제]
* 상자 하나 = 항목 하나(149 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달
* 연결(기준점 거침 · 시점으로 바로) = 다이어그램 연결 점 ● 끌기(154 · 155 · 등록 = 상자만 · 선 없음)
* 열기 = 다이어그램 [요소 추가](155 · 공사지 · 기준점 [+] 없앰)
* 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 조작 = 캐시 · [저장] 때 서버
* ========================================================================== */
import "../B01_Dashboard/B01_Dashboard_UI_Style.css";
import {
createButton,
createInputField,
createSelectField,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
import {
roadOf,
type Dest,
type MilestoneRoute,
type MilestoneRow,
type Road,
} from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import { placePicker } from "./B08_Quantity_UI_Milestone_Place";
import {
findSegmentKm,
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const DEST_OPTIONS = [
{ value: "start", text: "시점" },
{ value: "mid", text: "중점" },
];
/** 분류 → 항목 고르기 목록(거래처 = 견본 「거리이정표」 시트 다섯) */
export const ITEMS: Record<"route" | "base", string[]> = {
route: ["장비(20톤)", "장비(8톤)", "혼합골재", "전석운반", "철근"],
base: ["기준점"],
};
const line = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "b08-milestone__line-edit", children });
const head = (text: string): HTMLElement => el("h4", { text });
/** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */
export function toastKm(text: string): void {
if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success");
}
let listSeq = 0;
/** 항목(손 입력 + 제안 목록 · 244-14) · 목적지 ▾ 한 줄 — 목록 밖 글도 받음 · 분류 바꾸면 제안만 바뀜 */
function rowField(r: MilestoneRow, kind: "route" | "base") {
const item = createInputField({ label: "항목", compact: true });
const list = el("datalist", { attrs: { id: `b08-milestone-items-${++listSeq}` } });
item.input.setAttribute("list", list.id);
item.input.value = r.item;
item.root.append(list);
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const setKind = (k: "route" | "base"): void => {
list.replaceChildren(...ITEMS[k].map((t) => el("option", { attrs: { value: t } })));
item.input.placeholder = `예 ${ITEMS[k][0]}`;
// 기준점 = 거쳐 가는 자리 — 항목 · 목적지 없음(149)
item.root.style.visibility = dest.root.style.visibility = k === "base" ? "hidden" : "";
};
setKind(kind);
dest.select.value = r.dest;
item.input.dataset.b08MilestoneAdd = "item";
return { item, dest, note: r.note, setKind };
}
export type PlaceTarget = { add: true } | { add: false; id: string };
/** 등록 모달 — 추가([+]) · 고침(거래처 · 기준점 상자) 한 벌 · id "base" = 기준점 */
export function openPlaceModal(
projectId: string,
target: PlaceTarget,
addRoute?: (init: Partial<MilestoneRoute>) => string,
): void {
const data = liveMilestone(projectId);
const editBase = !target.add && target.id === "base";
const route = !target.add && !editBase ? data.routes.find((r) => r.id === target.id) : undefined;
if (!target.add && !editBase && !route) return;
const title = target.add
? "요소 추가 — 기준점 · 거래처"
: editBase
? `기준점 — ${data.base.name || "기준점"}`
: `항목 정보 — ${route!.name || "거래처"}`;
openModal({
title,
closeLabel: "닫기",
dialogClass: "b08-milestone__modal",
mount: (body, close, actions) => {
let kind: "route" | "base" = editBase ? "base" : "route";
const sort = createSelectField({
label: "분류",
compact: true,
options: [
{ value: "base", text: "기준위치" },
{ value: "route", text: "거래처" },
],
});
sort.select.value = kind;
sort.select.disabled = !target.add;
// 기준점 = 하나 — 이미 있으면 [+]에서 기준위치 못 고름(기준점 상자에서 고침)
const baseOption = sort.select.querySelector<HTMLOptionElement>('option[value="base"]');
if (target.add && data.base.enabled && baseOption) {
baseOption.disabled = true;
baseOption.textContent = "기준위치(있음 — 기준점 상자에서 수정)";
}
sort.select.dataset.b08MilestoneAdd = "sort";
const name = createInputField({ label: "거래처 이름", compact: true });
name.input.value = editBase ? data.base.name : (route?.name ?? "");
name.input.dataset.b08MilestoneAdd = "company";
const nameLabel = name.root.querySelector(".ui-field__label");
// 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠
const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind);
const old = editBase ? data.base : route;
const place = placePicker(
projectId,
{ address: old?.address ?? "", lon: old?.lon ?? null, lat: old?.lat ?? null },
() => name.input.value.trim() || (kind === "base" ? "기준점" : "거래처"),
);
const setKind = (k: "route" | "base"): void => {
kind = k;
if (nameLabel) nameLabel.textContent = k === "base" ? "기준점 이름" : "거래처 이름";
first.setKind(k);
};
sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base"));
setKind(kind);
const rowValues = (): MilestoneRow[] => [
{
item: first.item.input.value.trim(),
dest: first.dest.select.value as Dest,
note: first.note,
},
...(route?.rows.slice(1) ?? []),
];
const moved = (p: { lon: number | null; lat: number | null }): boolean =>
p.lon != null && (p.lon !== old?.lon || p.lat !== old?.lat);
const ok = createButton({
label: target.add ? "등록" : "수정",
variant: "filled",
onClick: () => {
const text = name.input.value.trim();
const where = place.value();
const fresh = liveMilestone(projectId);
let segment: string | undefined; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번
if (kind === "base") {
Object.assign(fresh.base, where, { enabled: true, name: text, dir: null });
if (!fresh.base.legs.length) fresh.base.legs = [{ road: null, km: null }];
putMilestone(projectId, fresh);
if (moved(where)) segment = "base";
} else if (route) {
const r = fresh.routes.find((x) => x.id === route.id);
if (!r) return close();
Object.assign(r, where, {
name: text || r.name,
rows: rowValues(),
});
putMilestone(projectId, fresh);
if (moved(where)) segment = r.id;
} else {
if (!text) return name.setError("거래처 이름을 넣으세요.");
// 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기)
const id = addRoute?.({
name: text,
via_base: true,
linked: false,
dir: null,
rows: rowValues(),
...where,
});
}
close();
if (segment) void findSegmentKm(projectId, segment).then(toastKm);
},
});
ok.dataset.b08MilestoneInfo = "save";
const remove = createButton({
label: "삭제",
variant: "danger",
onClick: async () => {
const label = editBase ? data.base.name || "기준점" : route?.name || "거래처";
if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제")))
return;
const fresh = liveMilestone(projectId);
if (editBase) fresh.base = { enabled: false, name: "", address: "", dir: null, legs: [] };
else fresh.routes = fresh.routes.filter((r) => r.id !== route!.id); // 경로 탭도 같이
putMilestone(projectId, fresh);
close();
},
});
remove.dataset.b08MilestoneInfo = "delete";
remove.hidden = target.add;
actions.append(remove, ok);
body.append(
el("div", {
className: "b08-milestone__grid",
children: [sort.root, first.item.root, name.root, first.dest.root],
}),
head("소재지 — 주소 찾기 · 지도에서 고르기(방위는 이 자리로 자동)"),
place.root,
);
},
});
}
export 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("mid"),
compact: true,
});
road.select.value = roadOf(data.site.mid_road, "mid");
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"),
);
},
});
}