- 거래처 · 기준점 창 제목 = 이름만 · 시점 창 = 「시점 · 중점」 - 소재지 지도에 시점 표지(고정 · 끌기 없음) · 소재지 없으면 시점 중심 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
331 lines
14 KiB
TypeScript
331 lines
14 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 · 공사지 · 기준점 [+] 없앰)
|
|
* 분류 기준위치 = 기준점(254 · 여럿 · 새 `bases` 원소 · 항목 · 목적지 없음 · 등록 = 상자만 · 연결 점으로 이음) ·
|
|
* 방위 = 소재지 좌표로 자동 자리 · 기준점 [삭제] = 그 기준점에 이은 거래처 · 기준점은 안 이음으로
|
|
* 구간 km · 도로 구분 · 길찾기 = 연결선 모달(`…_UI_Milestone_LegModal`) · [등록] · 소재지 옮김 뒤 그 구간 한 번
|
|
* 시점(공사지) 상자 = 중점 측점 km(저장 m · 255-1) · 아래 카카오 지도(시점 · 중점 표지)
|
|
* 조작 = 캐시 · [저장] 때 서버
|
|
* ========================================================================== */
|
|
|
|
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 {
|
|
roadOf,
|
|
type Dest,
|
|
type MilestoneRoute,
|
|
type MilestoneRow,
|
|
type Road,
|
|
} from "./B08_Quantity_Milestone_Calc";
|
|
import { setLeg } from "./B08_Quantity_Milestone_Layout";
|
|
import { request } from "./B08_Quantity_UI_Hydro";
|
|
import { editMap, type MapPin } from "./B08_Quantity_UI_Milestone_KakaoMap";
|
|
import { placePicker } from "./B08_Quantity_UI_Milestone_Place";
|
|
import {
|
|
capturePlace,
|
|
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 });
|
|
|
|
/** 길찾기 결과 한 줄 → 알림(까닭 줄 = 경고) */
|
|
export function toastKm(text: string): void {
|
|
if (text) showToast(`길찾기 — ${text}`, text.includes(" — ") ? "warning" : "success");
|
|
}
|
|
|
|
/** 칸 · 글에 보이는 km — 3자리(1 m) 반올림 · 끝 0 지움(셈 값은 안 바꿈 · 261-1) */
|
|
export const fmtKm = (km: number): string => String(Math.round(km * 1000) / 1000);
|
|
|
|
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)
|
|
// 255-3 자리도 비움 — 기준점 이름이 분류 옆 첫 줄(두 줄로 안 꺾임)
|
|
item.root.style.display = dest.root.style.display = k === "base" ? "none" : "";
|
|
};
|
|
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 = 거래처 · 기준점 id */
|
|
export function openPlaceModal(
|
|
projectId: string,
|
|
target: PlaceTarget,
|
|
addRoute?: (init: Partial<MilestoneRoute>) => string,
|
|
): void {
|
|
const data = liveMilestone(projectId);
|
|
const base = target.add ? undefined : data.bases.find((b) => b.id === target.id);
|
|
const editBase = !!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
|
|
? "요소 추가 — 기준점 · 거래처"
|
|
: base
|
|
? base.name || "기준점"
|
|
: route!.name || "거래처"; // 262-1 「—」 앞 글자 없음
|
|
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;
|
|
sort.select.dataset.b08MilestoneAdd = "sort";
|
|
const name = createInputField({ label: "거래처 이름", compact: true });
|
|
name.input.value = base ? 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 = 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; // [등록] · 소재지 옮김 뒤 그 구간 길찾기 한 번
|
|
let placed: string | undefined; // 256-1 등록 · 좌표 바뀜 = 장소 그림 캡처(뒤에서)
|
|
if (kind === "base" && base) {
|
|
const b = fresh.bases.find((x) => x.id === base.id);
|
|
if (!b) return close();
|
|
Object.assign(b, where, { name: text, dir: null });
|
|
putMilestone(projectId, fresh);
|
|
if (moved(where)) segment = placed = b.id;
|
|
} else if (kind === "base") {
|
|
// 254 새 기준점 = 상자만 · 선은 연결 점 ● 로 이음(이을 때 길찾기)
|
|
placed = `b${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
|
|
fresh.bases.push({
|
|
id: placed,
|
|
name: text,
|
|
dir: null,
|
|
legs: [{ road: null, km: null }],
|
|
to: "site",
|
|
linked: false,
|
|
...where,
|
|
});
|
|
putMilestone(projectId, fresh);
|
|
} 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 = placed = r.id;
|
|
} else {
|
|
if (!text) return name.setError("거래처 이름을 넣으세요.");
|
|
// 155 등록 = 상자만 · 선은 다이어그램 연결 점 ● 로 이음(이을 때 길찾기)
|
|
placed = addRoute?.({
|
|
name: text,
|
|
to: "site",
|
|
linked: false,
|
|
dir: null,
|
|
rows: rowValues(),
|
|
...where,
|
|
});
|
|
}
|
|
close();
|
|
if (segment) void findSegmentKm(projectId, segment).then(toastKm);
|
|
if (placed && where.lon != null && where.lat != null)
|
|
void capturePlace(projectId, placed, {
|
|
name: text || (kind === "base" ? "기준점" : "거래처"),
|
|
lon: where.lon,
|
|
lat: where.lat,
|
|
});
|
|
},
|
|
});
|
|
ok.dataset.b08MilestoneInfo = "save";
|
|
const remove = createButton({
|
|
label: "삭제",
|
|
variant: "danger",
|
|
onClick: async () => {
|
|
const label = base ? base.name || "기준점" : route?.name || "거래처";
|
|
if (!(await showConfirmDialog(`「${label}」 항목을 지울까요? — [저장] 때 반영`, "삭제")))
|
|
return;
|
|
const fresh = liveMilestone(projectId);
|
|
if (base) {
|
|
fresh.bases = fresh.bases.filter((b) => b.id !== base.id);
|
|
// 254 그 기준점에 이은 상자 = 안 이음(옛 구간 km = 다른 길 · 연결 점으로 다시 이음)
|
|
for (const o of [...fresh.bases, ...fresh.routes])
|
|
if (o.to === base.id)
|
|
Object.assign(o, { to: "site", linked: false, legs: [{ road: null, km: null }] });
|
|
} 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],
|
|
}),
|
|
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;
|
|
}
|
|
|
|
/** 시점(공사지) 상자 — 중점 측점(km · 저장은 m) · 시점 → 중점 도로 · 아래 카카오 지도(시점 · 중점 표지) */
|
|
export function openSiteInfo(projectId: string): void {
|
|
openModal({
|
|
title: "시점 · 중점",
|
|
closeLabel: "닫기",
|
|
dialogClass: "b08-milestone__modal",
|
|
mount: (body, close, actions) => {
|
|
// 중점 측점 · 시점 → 중점 도로(좌측 패널 카드에서 옮김 · 244-6) · 보이고 받는 것만 km(255-1)
|
|
const data = liveMilestone(projectId);
|
|
const site = siteInfo();
|
|
const mid = createInputField({
|
|
label: "중점 측점 (km)",
|
|
type: "number",
|
|
min: 0,
|
|
compact: true,
|
|
});
|
|
mid.input.step = "0.001";
|
|
mid.input.placeholder = site?.mid_default_m != null ? fmtKm(site.mid_default_m / 1000) : "";
|
|
mid.input.value =
|
|
data.site.mid_station_m == null ? "" : fmtKm(data.site.mid_station_m / 1000);
|
|
const shown = mid.input.value; // 안 고치면 저장 값(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 text = mid.input.value;
|
|
const value = Number(text);
|
|
const km = text.trim() && Number.isFinite(value) && value >= 0 ? value : null;
|
|
const fresh = liveMilestone(projectId);
|
|
putMilestone(
|
|
projectId,
|
|
setLeg(fresh, "start", 0, {
|
|
road: road.select.value as Road,
|
|
...(text === shown ? {} : { km }),
|
|
}),
|
|
);
|
|
close();
|
|
},
|
|
});
|
|
save.dataset.b08MilestoneSite = "save";
|
|
actions.append(save);
|
|
const map = editMap(projectId, { hint: "", onMove: () => undefined });
|
|
const pins = (s: {
|
|
start_lonlat: [number, number] | null;
|
|
mid_lonlat: [number, number] | null;
|
|
}): MapPin[] =>
|
|
(
|
|
[
|
|
["start", "시점", s.start_lonlat],
|
|
["mid", "중점", s.mid_lonlat],
|
|
] as const
|
|
).flatMap(([key, name, p]) =>
|
|
p ? [{ key, name, lon: p[0], lat: p[1], movable: false }] : [],
|
|
);
|
|
if (site) map.set(pins(site), []);
|
|
body.append(line(mid.root, road.root), map.root);
|
|
// 지금 중점 측점(캐시)의 중점 좌표 — 오면 표지 다시
|
|
const query = data.site.mid_station_m == null ? "" : `?mid_m=${data.site.mid_station_m}`;
|
|
request<SiteAddress>(`/projects/${projectId}/quantity/milestone/site-address${query}`).then(
|
|
(s) => map.set(pins(s), []),
|
|
(error: unknown) => showToast(`공사지 좌표를 못 읽었습니다 — ${String(error)}`, "error"),
|
|
);
|
|
},
|
|
});
|
|
}
|