Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts
T
eomsangdonandClaude Opus 5.5 a52fe78751 feat(b08): 거리이정표 메인 탭 · 경로 하위 탭 추가 · 삭제 · 공사지 · 기준점 · 표 합 짝 · 그림 올리기 (244-1)
- 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제]
- 경로 탭 = 구간 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
2026-10-10 20:53:01 +09:00

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 };
}