Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Modal.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

331 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B08_Quantity_UI_Milestone_Modal.ts
* 거리이정표 다이어그램 모달(PLAN 244-4 · 244-13 · 사용자 147번 5 · 8) — 페이지 이동 없이 창에서 보고 고침.
*
* 등록 모달(244-13) = 공사지 · 기준점 [+] · 거래처 · 기준점 상자 누름 같은 한 벌(`openPlaceModal`) — 더 큰 창 ·
* 2열(분류(기준위치 · 거래처) · 항목(분류 따라 고르기 목록) · 거래처 이름 · 목적지) + 소재지(주소 찾기 ·
* 다음(카카오) 지도에서 고르기 · `…_UI_Milestone_Place`) · 추가 = [등록] · 상자 = [고침] · [삭제]
* 분류 기준위치 = 기준점(하나 · 있으면 고르기 막음 · 다이어그램 [기준점 +] 없앰) · 목적지 = 시점 고정
* 같은 거래처 이름 = 그 경로에 표 줄만 · 방위 = 소재지 좌표로 자동 자리
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 조작 = 캐시 · [저장] 때 서버
* ========================================================================== */
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 { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
import type { Dest, MilestoneRoute, MilestoneRow, 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");
}
/** 항목 ▾ · 목적지 ▾ 한 줄 — 목록 밖 옛 항목 글도 보임 */
function rowField(r: MilestoneRow, kind: "route" | "base") {
const item = createSelectField({ label: "항목", compact: true, options: [] });
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const setKind = (k: "route" | "base"): void => {
const list = [...ITEMS[k]];
if (r.item && !list.includes(r.item)) list.unshift(r.item);
const keep = list.includes(item.select.value) ? item.select.value : r.item || list[0];
item.setOptions(
list.map((t) => ({ value: t, text: t })),
keep,
);
dest.root.style.visibility = k === "base" ? "hidden" : ""; // 기준점 → 시점 고정
};
setKind(kind);
dest.select.value = r.dest;
item.select.dataset.b08MilestoneAdd = "item";
return { item, dest, note: r.note, setKind };
}
export type PlaceTarget = { add: true; viaBase: boolean } | { 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
? `항목 추가 — ${target.viaBase ? "기준점 거침" : "공사지로 바로"}`
: 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");
const initRows: MilestoneRow[] = route?.rows.length
? route.rows
: [{ item: "", dest: "start", note: "" }];
const rows = initRows.map((r) => rowField(r, kind));
const [first] = rows;
const more = el("div");
const addRow = (field: ReturnType<typeof rowField>): void => {
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(field), 1);
row.remove();
},
});
const row = line(field.item.root, field.dest.root, drop);
more.append(row);
};
rows.slice(1).forEach(addRow);
const plusRow = createButton({
label: "+ 줄",
size: "sm",
variant: "ghost",
onClick: () => {
const field = rowField({ item: "", dest: "start", note: "" }, kind);
rows.push(field);
addRow(field);
},
});
const via = createCheckbox("기준점 거침");
via.input.checked = route?.via_base ?? true;
via.root.hidden = !route || !data.base.enabled;
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" ? "기준점 이름" : "거래처 이름";
rows.forEach((r) => r.setKind(k));
more.hidden = plusRow.hidden = k === "base";
};
sort.select.addEventListener("change", () => setKind(sort.select.value as "route" | "base"));
setKind(kind);
const rowValues = (): MilestoneRow[] =>
rows.map((r) => ({
item: r.item.select.value,
dest: r.dest.select.value as Dest,
note: r.note,
}));
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,
via_base: via.input.checked,
rows: rowValues(),
});
putMilestone(projectId, fresh);
if (moved(where) || r.via_base !== route.via_base) segment = r.id;
} else {
if (!text) return name.setError("거래처 이름을 넣으세요.");
const same = fresh.routes.find((r) => r.name.trim() === text);
if (same) {
same.rows.push(...rowValues());
if (where.lon != null) Object.assign(same, where);
putMilestone(projectId, fresh);
}
const viaBase = target.add && target.viaBase;
const id =
same?.id ??
addRoute?.({ name: text, via_base: viaBase, dir: null, rows: rowValues(), ...where });
if (id && where.lon != null) segment = id;
}
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],
}),
more,
plusRow,
via.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(false, false, data.site.mid_road),
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"),
);
},
});
}