Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts
T
eomsangdonandClaude Opus 5.5 4b70627682 feat(b08): 거리이정표 표 폭 = 다이어그램 폭 · 거래처 상자 항목 글 · 좌측 카드 없앰 · 팬 고침 · 거래처 글 (244-6)
- 틀 너비 = 표 너비 · 다이어그램은 그 너비로 늘음
- 거래처 상자 = 항목(윗줄) + 거래처 이름
- 좌측 패널 거리이정표 카드 없앰 — 중점 측점 · 도로는 공사지 모달 · 기준점은 기준점 모달([기준점 +] · 기준점 없앰)
- 팬 고침: 원배율에서 공용 팬 범위가 0 이던 것 — 범위 줌 · 빈 곳 왼쪽 끌기 더함(공용 panStart 선택 칸 · 기본 그대로)
- 화면 글 「업체」 → 「거래처」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:46:37 +09:00

406 lines
15 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 · 사용자 130번) — 페이지 이동 없이 창에서 보고 고침.
*
* [+] 항목 추가 = 항목 · 거래처 이름 · 목적지 + 아래 주소 검색(후보 고름 → 지도 표지) → [등록] =
* 경로(하위 탭) 생김 · 같은 거래처 이름이면 그 경로에 표 줄만 · 방위 = 고른 주소 좌표로 자동 자리
* 거래처 상자 = 그 항목 정보(거래처 · 표 줄 · 주소 · 지도 · 구간 km · 도로 구분) · [고침] · [삭제]
* 시점(공사지) 상자 = 프로젝트 등록 주소 · VWorld 주소(좌표 → 주소 · 서버) · 아래 지도
* 기준점 상자 = 기준점 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점)
* 주소 검색 · 지도 = B01 회사 주소 칸 재활용(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 {
buildAddressField,
type AddressFieldHandle,
} from "../B01_Dashboard/B01_Dashboard_UI_AddressField";
import { buildAddressMap } from "../B01_Dashboard/B01_Dashboard_UI_MapPreview";
import {
ROADS,
type Dest,
type Leg,
type MilestoneData,
type MilestoneRoute,
type MilestoneRow,
type Road,
} from "./B08_Quantity_Milestone_Calc";
import { request } from "./B08_Quantity_UI_Hydro";
import {
liveMilestone,
putMilestone,
roadOptions,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
const DEST_OPTIONS = [
{ value: "start", text: "시점" },
{ value: "mid", text: "중점" },
];
const line = (...children: HTMLElement[]): HTMLElement =>
el("div", { className: "b08-milestone__line-edit", children });
/** 구간 줄 — 도로 구분 ▾ · km · [×] · [+ 구간] */
function legsEditor(initial: Leg[], fresh: Road): { root: HTMLElement; value: () => Leg[] } {
const list = el("div");
const rows: Array<{ road: HTMLSelectElement; km: HTMLInputElement }> = [];
const add = (leg: Leg): void => {
const road = createSelectField({ label: "도로 구분", options: roadOptions, compact: true });
const km = createInputField({ label: "km", type: "number", min: 0, compact: true });
road.select.value = leg.road;
km.input.value = leg.km == null ? "" : String(leg.km);
const ref = { road: road.select, km: km.input };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = line(road.root, km.root, drop);
row.dataset.b08MilestoneLeg = "";
list.append(row);
};
initial.forEach(add);
const more = createButton({
label: "+ 구간",
size: "sm",
variant: "ghost",
onClick: () => add({ road: fresh, km: null }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map(({ road, km }) => {
const n = Number(km.value);
return {
road: (ROADS as readonly string[]).includes(road.value) ? (road.value as Road) : fresh,
km: km.value.trim() && Number.isFinite(n) && n >= 0 ? n : null,
};
}),
};
}
/** 표 줄 — 항목 · 목적지 ▾ · [×] · [+ 줄](비고는 그대로) */
function rowsEditor(initial: MilestoneRow[]): {
root: HTMLElement;
value: () => MilestoneRow[];
} {
const list = el("div");
const rows: Array<{ item: HTMLInputElement; dest: HTMLSelectElement; note: string }> = [];
const add = (r: MilestoneRow): void => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const dest = createSelectField({ label: "목적지", options: DEST_OPTIONS, compact: true });
item.input.value = r.item;
dest.select.value = r.dest;
const ref = { item: item.input, dest: dest.select, note: r.note };
rows.push(ref);
const drop = createButton({
label: "×",
size: "sm",
variant: "ghost",
onClick: () => {
rows.splice(rows.indexOf(ref), 1);
row.remove();
},
});
const row = line(item.root, dest.root, drop);
list.append(row);
};
initial.forEach(add);
const more = createButton({
label: "+ 줄",
size: "sm",
variant: "ghost",
onClick: () => add({ item: "", dest: "start", note: "" }),
});
return {
root: el("div", { children: [list, more] }),
value: () =>
rows.map((r) => ({ item: r.item.value.trim(), dest: r.dest.value as Dest, note: r.note })),
};
}
/** 주소 칸 값 → 주소 글 · 좌표(못 찾았고 글이 그대로면 옛 좌표 · 바뀌었으면 비움) */
function placeOf(
field: AddressFieldHandle,
old: { address: string; lon?: number | null; lat?: number | null },
): { address: string; lon: number | null; lat: number | null } {
const address = field.value() ?? "";
const point = field.point();
if (point) return { address, lon: point.lon, lat: point.lat };
return address === old.address
? { address, lon: old.lon ?? null, lat: old.lat ?? null }
: { address, lon: null, lat: null };
}
const head = (text: string): HTMLElement => el("h4", { text });
/** [+] — 항목 · 거래처 이름 · 목적지 + 주소 검색 · 지도 → [등록] */
export function openAddItem(
projectId: string,
viaBase: boolean,
addRoute: (init: Partial<MilestoneRoute>) => string,
): void {
openModal({
title: viaBase ? "항목 추가 — 기준점 거침" : "항목 추가 — 공사지로 바로",
closeLabel: "닫기",
mount: (body, close, actions) => {
const item = createInputField({ label: "항목", compact: true, placeholder: "예 장비(20톤)" });
const company = createInputField({ label: "거래처 이름", compact: true });
const dest = createSelectField({ label: "목적지", compact: true, options: DEST_OPTIONS });
const address = buildAddressField(null);
item.input.dataset.b08MilestoneAdd = "item";
company.input.dataset.b08MilestoneAdd = "company";
const ok = createButton({
label: "등록",
variant: "filled",
onClick: () => {
const name = company.input.value.trim();
if (!name) return company.setError("거래처 이름을 넣으세요.");
const row = { item: item.input.value.trim(), dest: dest.select.value as Dest, note: "" };
const place = placeOf(address, { address: "" });
const data = liveMilestone(projectId);
const same = data.routes.find((r) => r.name.trim() === name);
if (same) {
same.rows.push(row);
if (place.lon != null) Object.assign(same, place);
putMilestone(projectId, data);
} else addRoute({ name, via_base: viaBase, dir: null, rows: [row], ...place });
close();
},
});
actions.append(ok);
body.append(
item.root,
company.root,
dest.root,
head("거래처 위치 — 주소 검색(방위는 이 자리로 자동)"),
address.root,
);
},
});
}
/** 거래처 상자 — 그 항목 정보 · 고침 · 삭제(탭 이동 아님) */
export function openRouteInfo(projectId: string, routeId: string): void {
const data = liveMilestone(projectId);
const route = data.routes.find((r) => r.id === routeId);
if (!route) return;
openModal({
title: `항목 정보 — ${route.name || "거래처"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "거래처 이름", compact: true });
name.input.value = route.name;
name.input.dataset.b08MilestoneInfo = "name";
const via = createCheckbox("기준점 거침");
via.input.checked = route.via_base;
via.root.hidden = !data.base.enabled;
const rows = rowsEditor(route.rows);
const address = buildAddressField(route.address || null);
const legs = legsEditor(route.legs, "2차포장");
const save = createButton({
label: "고침",
variant: "filled",
onClick: () => {
const fresh = liveMilestone(projectId);
const r = fresh.routes.find((x) => x.id === routeId);
if (!r) return close();
Object.assign(r, placeOf(address, r), {
name: name.input.value.trim() || r.name,
via_base: via.input.checked,
rows: rows.value(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
const remove = createButton({
label: "삭제",
variant: "danger",
onClick: async () => {
if (
!(await showConfirmDialog(
`「${route.name || "거래처"}」 항목을 지울까요? — [저장] 때 반영`,
"삭제",
))
)
return;
const fresh = liveMilestone(projectId);
fresh.routes = fresh.routes.filter((r) => r.id !== routeId);
putMilestone(projectId, fresh); // 경로 하위 탭도 같이 없어짐
close();
},
});
save.dataset.b08MilestoneInfo = "save";
remove.dataset.b08MilestoneInfo = "delete";
actions.append(remove, save);
body.append(
name.root,
via.root,
head("표 줄 — 항목 · 목적지"),
rows.root,
head(
`구간 — ${route.name || "거래처"} → ${data.base.enabled && route.via_base ? data.base.name || "기준점" : "시점"} (km · 도로 구분)`,
),
legs.root,
head("주소 · 지도"),
address.root,
);
},
});
}
/** 기준점 상자 — 이름 · 주소 · 지도 · 공통 구간(기준점 → 시점) */
export function openBaseInfo(projectId: string, create = false): void {
const base = liveMilestone(projectId).base;
openModal({
title: create ? "기준점 추가" : `기준점 — ${base.name || "기준점"}`,
closeLabel: "닫기",
mount: (body, close, actions) => {
const name = createInputField({ label: "기준점 이름", compact: true });
name.input.value = base.name;
const address = buildAddressField(base.address || null);
const legs = legsEditor(
base.legs.length ? base.legs : [{ road: "1차포장", km: null }],
"1차포장",
);
const save = createButton({
label: create ? "등록" : "고침",
variant: "filled",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
Object.assign(fresh.base, placeOf(address, fresh.base), {
enabled: true,
name: name.input.value.trim(),
legs: legs.value(),
});
putMilestone(projectId, fresh);
close();
},
});
// 기준점 없앰 = 꺼 둠(값은 남김 · 거래처는 시점으로 바로)
const off = createButton({
label: "기준점 없앰",
variant: "danger",
onClick: () => {
const fresh: MilestoneData = liveMilestone(projectId);
fresh.base.enabled = false;
putMilestone(projectId, fresh);
close();
},
});
off.hidden = create;
save.dataset.b08MilestoneBase = "save";
off.dataset.b08MilestoneBase = "off";
actions.append(off, save);
body.append(
name.root,
head("공통 구간 — 기준점 → 시점 (km · 도로 구분)"),
legs.root,
head("주소 · 지도"),
el("p", {
className: "b08-options__hint",
text: "주소를 고르면 다이어그램에 실제 방위로 놓임 — 안 고르면 공사지 서쪽(왼쪽) 기본 자리",
}),
address.root,
);
},
});
}
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,
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"),
);
},
});
}