Files
Aislo/B08_Quantity/B08_Quantity_UI_Milestone.ts
T
eomsangdonandClaude Opus 5.5 d70b66f57e feat(b08): 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 처음 배율 맞춤 · 지도 높이 · 도로 구분 = 선 자리 (250)
- 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」
- 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤
- 카카오 지도 높이 어디서나 480px
- 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:08:57 +09:00

210 lines
8.1 KiB
TypeScript

/* =============================================================================
* B08_Quantity_UI_Milestone.ts
* B08 메인 탭 「거리이정표」(PLAN 244-1 · 244-4) — 하위 탭 줄 [거리이정표] + 경로 탭(거래처마다 · 이름 = 거래처 이름).
* 경로 탭은 다이어그램 [+] 등록 · 항목 정보 모달 [삭제]로만 생기고 없어짐(하위 탭 줄 [추가] · [삭제] 없음).
*
* [거리이정표] = 표(항목 · 거래처 이름 · 목적지 ▾ · 도로 구분 · 계 · 비고) — 열(244-13) = 기준점 있음 =
* 2차포장 · 1차포장 (· 포장) · 비포장 + 계 포장 · 비포장 · 합계 / 없음 = 포장 · 비포장 · 합계
* 표 합 = `…_Milestone_Calc` TS 짝(조작 즉시) · [저장] 때 서버가 파이썬 짝으로 다시 셈
* 입력 = 더블클릭(243) · 거래처 이름을 고치면 그 경로 이름(하위 탭)도
* 위쪽 다이어그램 = `…_UI_Milestone_Diagram`(상자 누름 = 모달 · [+] = 항목 추가 모달 · 팬 · 줌)
* 경로 탭 = `…_UI_Milestone_Route`(길찾기 지도 그림만) · 데이터 · [저장] = `…_UI_Milestone_State`
* ========================================================================== */
import "./B08_Quantity_UI_Milestone.css";
import { createDataTable, type DataColumn } from "@ui/ui_template_data_table";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { computeMilestone, type MilestoneRoute } from "./B08_Quantity_Milestone_Calc";
import { createDiagram } from "./B08_Quantity_UI_Milestone_Diagram";
import {
liveMilestone,
loadMilestone,
onMilestoneChange,
putMilestone,
siteInfo,
} from "./B08_Quantity_UI_Milestone_State";
import {
DEST_TEXT,
editRoute,
pickAtCell,
routeView,
trackCells,
type MilestoneView,
} from "./B08_Quantity_UI_Milestone_Route";
const KM = { kind: "number", decimals: 3, trim: true, width: 72 } as const;
const ROAD = ["도로 구분 (km)"];
const SUM = ["계 (km)"];
/** 표 열(244-13) — 2차포장 열 = 기준점 있을 때만 · 기준점 없음 = 포장(2차 + 1차 + 포장) · 비포장 · 합계 ·
* 기준점 있음 = 거침 안 한 거래처 「포장」 값이 있으면 포장 열도 */
export function milestoneColumns(withBase: boolean, anyPaved: boolean): DataColumn[] {
const roads: DataColumn[] = withBase
? [
{ key: "s2", label: "2차포장", group: ROAD, ...KM },
{ key: "s1", label: "1차포장", group: ROAD, ...KM },
...(anyPaved ? [{ key: "pv", label: "포장", group: ROAD, ...KM }] : []),
{ key: "un", label: "비포장", group: ROAD, ...KM },
{ key: "paved", label: "포장", group: SUM, ...KM },
{ key: "unpaved", label: "비포장", group: SUM, ...KM },
]
: [
{ key: "paved", label: "포장", group: ROAD, ...KM },
{ key: "un", label: "비포장", group: ROAD, ...KM },
];
return [
{ key: "item", label: "항목", width: 120 },
{ key: "company", label: "거래처 이름", width: 120 },
{ key: "dest", label: "목적지", width: 72 },
...roads,
{ key: "total", label: "합계", group: SUM, ...KM, width: 108 },
{ key: "note", label: "비고", width: 160 },
];
}
/** 새 경로(거래처) — 캐시에 넣고 id 를 돌려줌 */
export function addRoute(projectId: string, init: Partial<MilestoneRoute> = {}): string {
const data = liveMilestone(projectId);
const id = `r${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
data.routes.push({
id,
name: `거래처 ${data.routes.length + 1}`,
address: "",
via_base: true,
dir: null,
legs: [{ road: null, km: null }], // 도로 구분 = 사용자 고름(244-7)
rows: [{ item: "", dest: "start", note: "" }],
...init,
});
putMilestone(projectId, data);
return id;
}
/** [거리이정표] 하위 탭 — 위 다이어그램 + 표 */
function tableView(projectId: string): MilestoneView {
const diagram = createDiagram(projectId, (init) => addRoute(projectId, init));
let keys: Array<{ route: string; index: number }> = [];
const makeTable = (columns: DataColumn[]) => {
const t = createDataTable({
columns,
rows: [],
caption: "거리이정표",
empty: "",
editOnDouble: true,
editable: (_i, key) => key === "item" || key === "company" || key === "note",
onEdit: (i, key, text) => {
const at = keys[i];
if (!at) return;
editRoute(projectId, at.route, (_, r) => {
if (key === "company") r.name = text;
else if (r.rows[at.index]) r.rows[at.index][key as "item" | "note"] = text;
});
},
onCellClick: (i, key) => {
const at = keys[i];
if (key !== "dest" || !at) return;
pickAtCell(
(["start", "mid"] as const).map((dest) => [
DEST_TEXT[dest],
() =>
editRoute(projectId, at.route, (_, r) => {
if (r.rows[at.index]) r.rows[at.index].dest = dest;
}),
]),
);
},
});
trackCells(t.root);
t.root.dataset.b08Milestone = "table";
return t;
};
let mode = "false:false";
let table = makeTable(milestoneColumns(false, false));
const none = el("p", {
className: "ui-general-block__empty",
text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.",
});
const refresh = (): void => {
const data = liveMilestone(projectId);
const rows = computeMilestone(data, siteInfo()?.mid_default_m ?? null);
// 기준점 생김 · 없앰 = 열 바뀜 → 표 새로
const next = `${data.base.enabled}:${rows.some((r) => r.pv > 0)}`;
if (next !== mode) {
mode = next;
const fresh = makeTable(
milestoneColumns(
data.base.enabled,
rows.some((r) => r.pv > 0),
),
);
table.root.replaceWith(fresh.root);
table = fresh;
}
keys = rows.map(({ route, index }) => ({ route, index }));
none.hidden = rows.length > 0;
table.setRows(rows.map((r) => ({ ...r, dest: `${DEST_TEXT[r.dest]} ▾` })));
diagram.refresh();
};
refresh();
return {
root: el("div", { className: "b08-milestone", children: [diagram.root, none, table.root] }),
refresh,
};
}
export function renderMilestone(host: HTMLElement, projectId: string): void {
const tabs = el("div", {
className: "b08-quantity__tabs b08-structure__tabs",
attrs: { role: "tablist" },
});
const view = el("div");
let current = "table";
let body: MilestoneView | null = null;
const drawTabs = (): void => {
const routes = liveMilestone(projectId).routes;
const tab = (id: string, label: string): HTMLButtonElement => {
const button = createButton({
label,
variant: id === current ? "filled" : "ghost",
size: "sm",
onClick: () => id !== current && open(id),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(id === current));
button.dataset.b08MilestoneTab = id;
return button;
};
tabs.replaceChildren(
tab("table", "거리이정표"),
...routes.map((r, i) => tab(r.id, r.name.trim() || `거래처 ${i + 1}`)),
);
};
function open(id: string): void {
current = id;
drawTabs();
body = id === "table" ? tableView(projectId) : routeView(projectId, id);
view.replaceChildren(body.root);
}
host.replaceChildren(
tabs,
el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }),
);
loadMilestone(projectId).then(
() => {
host.replaceChildren(tabs, view);
open("table");
onMilestoneChange(host, () => {
// 지운 경로 탭이면 [거리이정표] 로 · 아니면 탭 이름 · 몸만 다시 셈(입력 칸은 그대로)
if (current !== "table" && !liveMilestone(projectId).routes.some((r) => r.id === current))
return open("table");
drawTabs();
body?.refresh();
});
},
(error: unknown) => showToast(`거리이정표를 불러오지 못했습니다 — ${String(error)}`, "error"),
);
}