- 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」 - 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤 - 카카오 지도 높이 어디서나 480px - 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
308 lines
13 KiB
TypeScript
308 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 · [+ 줄] 없음 · 같은 거래처라도 항목마다 따로 등록) · 경로 값은 연결선 모달
|
|
* 분류 기준위치 = 기준점(하나 · 항목 · 목적지 없음 · 있으면 고르기 막음) · 방위 = 소재지 좌표로 자동 자리
|
|
* 구간 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 {
|
|
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; 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");
|
|
// 상자 하나 = 항목 하나(149) — 옛 자료의 둘째 줄부터는 손대지 않고 그대로 둠
|
|
const first = rowField(route?.rows[0] ?? { item: "", dest: "start", note: "" }, kind);
|
|
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" ? "기준점 이름" : "거래처 이름";
|
|
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,
|
|
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 viaBase = target.add && target.viaBase;
|
|
const 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],
|
|
}),
|
|
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("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"),
|
|
);
|
|
},
|
|
});
|
|
}
|