Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
T
eomsangdonandClaude Sonnet 5.5 4737756502 fix(b08): 거리이정표 km 칸 끝수 정리 — 3자리 반올림 · 끝 0 지움 · 안 고치면 저장 m 그대로 (261-1)
- 시점 창 · 구간 창 · 다이어그램 선 위 km 칸 · 자동 값 글 = 같은 도우미(fmtKm)
- 안 고친 칸은 반올림 값으로 덮지 않음
- 시험 test_261_1 추가 · test_255_1 기대값만 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 15:17:40 +09:00

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 || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음
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"),
);
},
});
}