- 좌측 패널 「공사지에서 본 방위」 칸 · 8방 고르기 지움 · 옛 저장 값 방위는 무시 - 기준점 모달에 「주소를 고르면 실제 방위로 놓임」 안내 한 줄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
341 lines
13 KiB
TypeScript
341 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* 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 } 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): void {
|
||
const base = liveMilestone(projectId).base;
|
||
openModal({
|
||
title: `기준점 — ${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, "1차포장");
|
||
const save = createButton({
|
||
label: "고침",
|
||
variant: "filled",
|
||
onClick: () => {
|
||
const fresh: MilestoneData = liveMilestone(projectId);
|
||
Object.assign(fresh.base, placeOf(address, fresh.base), {
|
||
name: name.input.value.trim(),
|
||
legs: legs.value(),
|
||
});
|
||
putMilestone(projectId, fresh);
|
||
close();
|
||
},
|
||
});
|
||
actions.append(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) => {
|
||
const text = (label: string, value: string): HTMLElement =>
|
||
el("p", { className: "b08-options__hint", text: `${label} — ${value}` });
|
||
const map = buildAddressMap();
|
||
const info = el("div", { children: [text("주소", "불러오는 중…")] });
|
||
body.append(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"),
|
||
);
|
||
},
|
||
});
|
||
}
|