- 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제] - 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나 - 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위) - 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/* =============================================================================
|
|
* B08_Quantity_UI_Milestone_Route.ts
|
|
* 거리이정표 경로 하위 탭(PLAN 244-1) — 컨테이너 하나 = 업체 한 곳 · 그림 하나.
|
|
*
|
|
* 위 = 업체 이름(= 하위 탭 이름) · 주소 · 방위(기준점 · 없으면 공사지 기준 8방) · 기준점 거침
|
|
* 구간 표 = 업체 → 기준점(또는 시점) · 기준점 → 시점(공통 · 모든 경로가 같이 씀) · 시점 → 중점(측점 km) ·
|
|
* 구간마다 km(더블클릭 손 입력) · 도로 구분 ▾(2차포장 · 1차포장 · 비포장)
|
|
* 표 줄 = 항목 · 목적지 ▾ · 비고 — [거리이정표] 표에 이 업체 줄로 나감
|
|
* 그림 = 올리는 즉시 서버(교체 · 지우기) · 나머지 조작 = 캐시(`…_UI_Milestone_State`) · [저장] 때 서버
|
|
* ========================================================================== */
|
|
|
|
import { createCheckbox } from "@ui/ui_template_checkbox";
|
|
import {
|
|
createMapContextMenu,
|
|
type MapContextMenu,
|
|
type MapContextMenuItem,
|
|
} from "@ui/ui_template_context_menu";
|
|
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import {
|
|
ROADS,
|
|
midKm,
|
|
type Dest,
|
|
type Dir,
|
|
type MilestoneData,
|
|
type MilestoneRoute,
|
|
} from "./B08_Quantity_Milestone_Calc";
|
|
import {
|
|
deleteImage,
|
|
dirOptions,
|
|
imageBlobUrl,
|
|
liveMilestone,
|
|
putMilestone,
|
|
routeImages,
|
|
siteInfo,
|
|
uploadImage,
|
|
} from "./B08_Quantity_UI_Milestone_State";
|
|
|
|
let menu: MapContextMenu | null = null;
|
|
let lastCell: Element | null = null;
|
|
|
|
/** 표 칸 누름 자리 기억 — 「▾」 메뉴를 그 칸 아래에 엶 */
|
|
export function trackCells(root: HTMLElement): void {
|
|
root.addEventListener("click", (e) => (lastCell = (e.target as Element).closest("td")), true);
|
|
}
|
|
|
|
export function pickAtCell(items: MapContextMenuItem[]): void {
|
|
const box = lastCell?.getBoundingClientRect();
|
|
if (!box) return;
|
|
menu ??= createMapContextMenu("b08-milestone");
|
|
if (!menu.element.isConnected) {
|
|
menu.element.style.position = "fixed";
|
|
document.body.append(menu.element);
|
|
}
|
|
menu.open(box.left, box.bottom, items);
|
|
}
|
|
|
|
/** 고른 줄 강조(공용 표 is-focus · 다시 그리지 않음 — 더블클릭 그대로) */
|
|
function markRow(root: HTMLElement, index: number | null): void {
|
|
root.querySelectorAll("tr.is-focus").forEach((tr) => tr.classList.remove("is-focus"));
|
|
if (index !== null) root.querySelector(`tr[data-i="${index}"]`)?.classList.add("is-focus");
|
|
}
|
|
|
|
export const DEST_TEXT: Record<Dest, string> = { start: "시점", mid: "중점" };
|
|
|
|
/** 그 경로를 고쳐 캐시에 */
|
|
export function editRoute(
|
|
projectId: string,
|
|
routeId: string,
|
|
fn: (data: MilestoneData, route: MilestoneRoute) => void,
|
|
): void {
|
|
const data = liveMilestone(projectId);
|
|
const route = data.routes.find((r) => r.id === routeId);
|
|
if (!route) return;
|
|
fn(data, route);
|
|
putMilestone(projectId, data);
|
|
}
|
|
|
|
const kmOf = (text: string): number | null | undefined => {
|
|
if (!text.trim()) return null;
|
|
const value = Number(text.replace(/,/g, ""));
|
|
return Number.isFinite(value) && value >= 0 ? value : undefined; // undefined = 그대로
|
|
};
|
|
|
|
type LegRef = { group: "own" | "base" | "mid"; i: number };
|
|
|
|
const LEG_COLUMNS: DataColumn[] = [
|
|
{ key: "span", label: "구간", width: 200 },
|
|
{ key: "road", label: "도로 구분", width: 96 },
|
|
{ key: "km", label: "km", kind: "number", decimals: 3, trim: true, width: 80 },
|
|
];
|
|
const ROW_COLUMNS: DataColumn[] = [
|
|
{ key: "item", label: "항목", width: 140 },
|
|
{ key: "dest", label: "목적지", width: 80 },
|
|
{ key: "note", label: "비고", width: 180 },
|
|
];
|
|
|
|
export interface MilestoneView {
|
|
root: HTMLElement;
|
|
refresh: () => void;
|
|
}
|
|
|
|
export function routeView(projectId: string, routeId: string): MilestoneView {
|
|
const edit = (fn: (data: MilestoneData, route: MilestoneRoute) => void): void =>
|
|
editRoute(projectId, routeId, fn);
|
|
|
|
// 위 — 업체 이름 · 주소 · 방위 · 기준점 거침
|
|
const name = createInputField({ label: "업체 이름", compact: true });
|
|
const address = createInputField({ label: "주소", compact: true });
|
|
const dir = createSelectField({ label: "방위", options: dirOptions, compact: true });
|
|
const via = createCheckbox("기준점 거침");
|
|
name.input.dataset.b08Milestone = "route_name";
|
|
name.input.addEventListener("input", () => edit((_, r) => (r.name = name.input.value)));
|
|
address.input.addEventListener("input", () => edit((_, r) => (r.address = address.input.value)));
|
|
dir.select.addEventListener("change", () =>
|
|
edit((_, r) => (r.dir = (dir.select.value || null) as Dir | null)),
|
|
);
|
|
via.input.addEventListener("change", () => edit((_, r) => (r.via_base = via.input.checked)));
|
|
const head = el("div", {
|
|
className: "b08-milestone__head",
|
|
children: [name.root, address.root, dir.root, via.root],
|
|
});
|
|
|
|
// 구간 표
|
|
let legRefs: LegRef[] = [];
|
|
let pickedLeg: number | null = null;
|
|
const legRows = (d: MilestoneData, r: MilestoneRoute): DataRow[] => {
|
|
const useBase = d.base.enabled && r.via_base;
|
|
const baseName = d.base.name || "기준점";
|
|
const who = r.name || "업체";
|
|
const rows: Array<[LegRef, string, string, number | null]> = [
|
|
...r.legs.map((leg, i): [LegRef, string, string, number | null] => [
|
|
{ group: "own", i },
|
|
`${who} → ${useBase ? baseName : "시점"}`,
|
|
leg.road,
|
|
leg.km,
|
|
]),
|
|
...(useBase ? d.base.legs : []).map((leg, i): [LegRef, string, string, number | null] => [
|
|
{ group: "base", i },
|
|
`${baseName} → 시점 (공통)`,
|
|
leg.road,
|
|
leg.km,
|
|
]),
|
|
[
|
|
{ group: "mid", i: 0 },
|
|
"시점 → 중점 (측점)",
|
|
d.site.mid_road,
|
|
midKm(d, siteInfo()?.mid_default_m ?? null),
|
|
],
|
|
];
|
|
legRefs = rows.map(([ref]) => ref);
|
|
return rows.map(([, span, road, km]) => ({ span, road: `${road} ▾`, km }));
|
|
};
|
|
const setLeg = (ref: LegRef, fn: (leg: { road: string; km: number | null }) => void): void =>
|
|
edit((d, r) => {
|
|
if (ref.group === "mid") {
|
|
const leg = { road: d.site.mid_road as string, km: null };
|
|
fn(leg);
|
|
d.site.mid_road = leg.road as MilestoneData["site"]["mid_road"];
|
|
} else fn((ref.group === "own" ? r.legs : d.base.legs)[ref.i]);
|
|
});
|
|
const legs = createDataTable({
|
|
columns: LEG_COLUMNS,
|
|
rows: [],
|
|
caption: "구간",
|
|
empty: "",
|
|
editOnDouble: true,
|
|
editable: (i, key) => key === "km" && legRefs[i]?.group !== "mid",
|
|
onEdit: (i, _key, text) => {
|
|
const km = kmOf(text);
|
|
if (km !== undefined) setLeg(legRefs[i], (leg) => (leg.km = km));
|
|
else refresh();
|
|
},
|
|
onCellClick: (i, key) => {
|
|
if (key !== "road") return;
|
|
pickAtCell(ROADS.map((road) => [road, () => setLeg(legRefs[i], (leg) => (leg.road = road))]));
|
|
},
|
|
onRowClick: (i) => markRow(legs.root, (pickedLeg = i)),
|
|
});
|
|
trackCells(legs.root);
|
|
legs.root.dataset.b08Milestone = "legs";
|
|
const addOwn = createButton({
|
|
label: "+ 업체 구간",
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () => edit((_, r) => r.legs.push({ road: "2차포장", km: null })),
|
|
});
|
|
const addBase = createButton({
|
|
label: "+ 공통 구간",
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () => edit((d) => d.base.legs.push({ road: "1차포장", km: null })),
|
|
});
|
|
const dropLeg = createButton({
|
|
label: "고른 구간 지우기",
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
const ref = pickedLeg === null ? null : legRefs[pickedLeg];
|
|
if (!ref || ref.group === "mid") return showToast("지울 구간 줄을 먼저 고르세요.", "info");
|
|
pickedLeg = null;
|
|
edit((d, r) => (ref.group === "own" ? r.legs : d.base.legs).splice(ref.i, 1));
|
|
},
|
|
});
|
|
|
|
// 표 줄
|
|
let pickedRow: number | null = null;
|
|
const rows = createDataTable({
|
|
columns: ROW_COLUMNS,
|
|
rows: [],
|
|
caption: "표 줄",
|
|
empty: "",
|
|
editOnDouble: true,
|
|
editable: (_i, key) => key === "item" || key === "note",
|
|
onEdit: (i, key, text) =>
|
|
edit((_, r) => {
|
|
if (r.rows[i]) r.rows[i][key as "item" | "note"] = text;
|
|
}),
|
|
onCellClick: (i, key) => {
|
|
if (key !== "dest") return;
|
|
pickAtCell(
|
|
(["start", "mid"] as Dest[]).map((dest) => [
|
|
DEST_TEXT[dest],
|
|
() => edit((_, r) => r.rows[i] && (r.rows[i].dest = dest)),
|
|
]),
|
|
);
|
|
},
|
|
onRowClick: (i) => markRow(rows.root, (pickedRow = i)),
|
|
});
|
|
trackCells(rows.root);
|
|
rows.root.dataset.b08Milestone = "rows";
|
|
const addRow = createButton({
|
|
label: "+ 줄",
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () => edit((_, r) => r.rows.push({ item: "", dest: "start", note: "" })),
|
|
});
|
|
const dropRow = createButton({
|
|
label: "고른 줄 지우기",
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
if (pickedRow === null) return showToast("지울 줄을 먼저 고르세요.", "info");
|
|
const at = pickedRow;
|
|
pickedRow = null;
|
|
edit((_, r) => r.rows.splice(at, 1));
|
|
},
|
|
});
|
|
|
|
// 그림 — 경로 하나 = 그림 하나
|
|
const img = el("img", { className: "b08-milestone__image", attrs: { alt: "경로 그림" } });
|
|
const noImage = el("p", { className: "ui-general-block__empty", text: "올린 그림 없음" });
|
|
let shown: string | null | undefined;
|
|
const loadImage = (force = false): void => {
|
|
const name = routeImages()[routeId] ?? null;
|
|
if (!force && name === shown) return;
|
|
shown = name;
|
|
img.hidden = !name;
|
|
noImage.hidden = !!name;
|
|
if (img.src) URL.revokeObjectURL(img.src);
|
|
img.removeAttribute("src");
|
|
if (name)
|
|
imageBlobUrl(projectId, routeId).then(
|
|
(url) => (img.src = url),
|
|
(error: unknown) => showToast(`그림을 못 읽었습니다 — ${String(error)}`, "error"),
|
|
);
|
|
};
|
|
const file = el("input", { attrs: { type: "file", accept: "image/*" } });
|
|
file.hidden = true;
|
|
file.addEventListener("change", () => {
|
|
const picked = file.files?.[0];
|
|
file.value = "";
|
|
if (picked)
|
|
uploadImage(projectId, routeId, picked).then(
|
|
() => loadImage(true),
|
|
(error: unknown) => showToast(`그림을 올리지 못했습니다 — ${String(error)}`, "error"),
|
|
);
|
|
});
|
|
const upload = createButton({
|
|
label: "그림 올리기",
|
|
size: "sm",
|
|
variant: "filled",
|
|
onClick: () => file.click(),
|
|
});
|
|
const removeImage = createButton({
|
|
label: "그림 지우기",
|
|
size: "sm",
|
|
variant: "ghost",
|
|
onClick: () =>
|
|
void deleteImage(projectId, routeId).catch((error: unknown) =>
|
|
showToast(`그림을 지우지 못했습니다 — ${String(error)}`, "error"),
|
|
),
|
|
});
|
|
|
|
const bar = (...items: HTMLElement[]): HTMLElement =>
|
|
el("div", { className: "b08-milestone__bar", children: items });
|
|
const root = el("section", {
|
|
className: "b08-milestone__route ui-card",
|
|
attrs: { "data-b08-milestone-route": routeId },
|
|
children: [
|
|
head,
|
|
el("h4", { text: "구간 (km 더블클릭 · 도로 구분 ▾)" }),
|
|
legs.root,
|
|
bar(addOwn, addBase, dropLeg),
|
|
el("h4", { text: "표 줄" }),
|
|
rows.root,
|
|
bar(addRow, dropRow),
|
|
el("h4", { text: "경로 그림" }),
|
|
bar(upload, removeImage, file),
|
|
noImage,
|
|
img,
|
|
],
|
|
});
|
|
|
|
const refresh = (): void => {
|
|
const d = liveMilestone(projectId);
|
|
const r = d.routes.find((x) => x.id === routeId);
|
|
if (!r) return;
|
|
if (document.activeElement !== name.input) name.input.value = r.name;
|
|
if (document.activeElement !== address.input) address.input.value = r.address;
|
|
dir.select.value = r.dir ?? "";
|
|
via.input.checked = r.via_base;
|
|
via.root.hidden = !d.base.enabled;
|
|
addBase.hidden = !(d.base.enabled && r.via_base);
|
|
legs.setRows(legRows(d, r));
|
|
rows.setRows(r.rows.map((row) => ({ ...row, dest: `${DEST_TEXT[row.dest]} ▾` })));
|
|
markRow(legs.root, pickedLeg);
|
|
markRow(rows.root, pickedRow);
|
|
loadImage();
|
|
};
|
|
refresh();
|
|
return { root, refresh };
|
|
}
|