"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Alert, Box, Badge, Button, ButtonGroup, Chip, Card, CardContent, Checkbox, CircularProgress, Collapse, Divider, Dialog, DialogActions, DialogContent, DialogTitle, Drawer, IconButton, FormControl, InputAdornment, InputLabel, List, ListItemButton, ListItemText, ListSubheader, MenuItem, Paper, Popover, Select, Snackbar, Stack, TextField, Tooltip, Typography, Autocomplete, } from "@mui/material"; import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import FilterListIcon from "@mui/icons-material/FilterList"; import { AlertTriangle, ArrowRight, Bell, Building2, Clock, CreditCard, ChevronRight, Download, FileText, History, Info, LayoutDashboard, Phone, Plus, RotateCcw, Save, Search, Trash2, Truck as TruckIcon, Upload, Users, X, MapPin, Pencil, GripVertical, CarFront, } from "lucide-react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { fetchAllShopsClient, diffTruckLaneVersionsClient, listTruckLaneVersionsClient, restoreTruckLaneVersionClient, findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient, findAllForRouteBoardClient, exportRouteLanesExcelClient, exportRouteReportExcelClient, exportTruckLaneVersionReportExcelClient, importRouteLanesExcelClient, parseRouteLanesExcelClient, createTruckLaneSnapshotClient, createTruckLaneScheduleClient, updateTruckLaneVersionNoteClient, updateTruckLaneClient, updateLaneLogisticClient, createTruckClient, createTruckWithoutShopClient, deleteTruckLaneClient, } from "@/app/api/shop/client"; import { deleteLogisticClient, findAllLogisticsClient, saveLogisticClient, saveLogisticsBatchCreateClient, type LogisticRow, type SaveLogisticRequest, } from "@/app/api/logistic/client"; import type { CreateTruckWithoutShopRequest, LogisticMasterDiffLine, MessageResponse, SaveTruckLane, Truck, TruckLaneVersionDiffLine, } from "@/app/api/shop/actions"; import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; import { buildStagedBoardLogEntries, diffLinesToShopRows, formatLaneLabel, resolveHeadVersionId, resolveVersionLogLaneLabel, resolveVersionLogShopHeadline, splitVersionCreated, resolveVersionActor, summarizeVersionRows, VERSION_LOG_LOADING_SEQUENCE_LABEL, type StagedDeleteMeta, type ShopRowBaseline, } from "@/components/Shop/routeBoardVersionLog"; import { computeTruckLaneWarnings, appendSyntheticPendingShopRow, type TruckLaneWarning, type TruckLaneWarningInputRow, type TruckLaneWarningLaneRef, } from "@/components/Shop/computeTruckLaneWarnings"; import { mergeImportPreviewIntoLanes } from "@/components/Shop/routeBoardImportPreview"; import ScheduleChangeModal, { type ScheduleChangePayload, type ScheduleShopRow, } from "@/components/Shop/ScheduleChangeModal"; import ScheduleTaskHistoryModal from "@/components/Shop/ScheduleTaskHistoryModal"; import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; import { formatScheduleValidationErrors } from "@/components/Shop/scheduleUiHelpers"; import { useRouteBoardScheduleIndicators } from "@/components/Shop/useRouteBoardScheduleIndicators"; import { buildRequestFromPayload, validatePayloadSubmit, } from "@/components/Shop/truckLaneMovePlanner"; import { computeMovedLoadingSequence, flattenDisplayOrder, groupByDistrict, formatShopCardSubtitle, toDistrictDisplayName, toDistrictRawValue, } from "@/components/Shop/routeBoardDisplayOrder"; const JAVA_INT_MAX = 2_147_483_647; /** 後端 `driverNumber` 為 Int;只送可安全表示的十進位數字,不靜默改值。 */ function phoneDigitsToDriverNumber(phone: string): number | null { const d = String(phone).replace(/\D/g, ""); if (!d) return null; const fitsIntString = (s: string): boolean => /^\d{1,10}$/.test(s) && BigInt(s) <= BigInt(JAVA_INT_MAX); const parseBlock = (s: string): number | null => { if (!fitsIntString(s)) return null; const n = parseInt(s, 10); return Number.isFinite(n) && n >= 0 && n <= JAVA_INT_MAX ? n : null; }; let n = parseBlock(d); if (n != null) return n; // HK +852 + 8 位本地號(11 位數字) if (d.startsWith("852") && d.length === 11) { n = parseBlock(d.slice(3)); if (n != null) return n; } return null; } type ShopCard = { /** truck table row id */ id: number; /** master shop.id(後端 Truck.shop);新增/去重用 */ shopEntityId?: number | null; /** 分店名(truck table 的 ShopName) */ branchName: string; shopCode: string; /** districtReference raw value from backend (never use display label for save) */ districtReferenceRaw: string | null; /** brand/label is not in current API; keep optional for future */ brand?: string; /** periods is not in current API; keep optional for future */ periods?: string; /** ordering inside a lane */ loadingSequence: number; /** remark shown in existing UIs */ remark?: string | null; /** store ID 2F/4F */ storeId: string; /** departure time HH:mm or HH:mm:ss */ departureTime: string; }; type Lane = { /** 穩定鍵:encodeURIComponent(code)|encodeURIComponent(remark) */ id: string; /** 寫入 truck API 的車線代碼 */ truckLanceCode: string; plate?: string; logisticsCompany?: string; /** `logistic.id`,來自 truck.logistic;無綁定為 null */ logisticId?: number | null; driver?: string; phone?: string; startTime: string; storeId: string; remark?: string | null; shops: ShopCard[]; }; type PendingNewLane = { laneKey: string; payload: CreateTruckWithoutShopRequest; }; const parseTimeForBackend = (time: string): string => { const s = String(time ?? "").trim(); if (!s) return ""; // allow HH:mm or HH:mm:ss if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) { const [hh, mm, ss] = s.split(":"); return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}${ ss != null ? `:${String(ss).padStart(2, "0")}` : "" }`; } return s; }; const toTimeInputValue = (t: string | undefined): string => { const s = String(t ?? "").trim(); if (!s) return "00:00"; const m = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/); if (m) return `${m[1].padStart(2, "0")}:${m[2]}`; return "00:00"; }; /** 物流商總覽欄標題用:條數、店數、樓層 */ function summarizeLogisticsColumnStats(lanes: Lane[]): { laneCount: number; shopCount: number; count2F: number; count4F: number; } { let shopCount = 0; let count2F = 0; let count4F = 0; for (const l of lanes) { shopCount += l.shops.length; if (normalizeStoreId(l.storeId) === "4F") count4F++; else count2F++; } return { laneCount: lanes.length, shopCount, count2F, count4F, }; } function resolveLogisticMasterRow( company: string, lanes: Lane[], masters: LogisticRow[], ): LogisticRow | null { if (company === "未分配物流商") return null; const byLaneId = lanes.find( (l) => l.logisticId != null && Number.isFinite(l.logisticId), )?.logisticId; if (byLaneId != null) { const hit = masters.find((m) => m.id === byLaneId); if (hit) return hit; } const name = String(company).trim(); return ( masters.find((m) => String(m.logisticName ?? "").trim() === name) ?? null ); } /** * 地區區塊標題編輯語意(RouteBoard): * 後端每筆 truck row 存 `districtReference`,無 lane-level overlay 表。 * 編輯某「區塊」顯示名稱=把該 lane 內目前落在該顯示 bucket 的所有 shop 之 * `districtReferenceRaw` 批量改成 `toDistrictRawValue(新顯示名)`,並對 `id > 0` * 的列 `dirtyMoves.set` 以延遲寫 DB。 * 僅前端存在、尚無店鋪的「空區」用 `pendingEmptyDistrictsByLane` 記錄顯示名, * 與 `groupByDistrict` 合併渲染;儲存成功或僅空區時會清掉暫存列。 */ function buildLaneDistrictSections( shops: ShopCard[], pendingExtraDistrictDisplays: string[] | undefined, ): Array<{ district: string; shops: ShopCard[]; isPendingEmpty: boolean }> { const grouped = groupByDistrict(shops); const keysFromShops = new Set(grouped.map((g) => g.district)); const extras = (pendingExtraDistrictDisplays ?? []).filter( (d) => !keysFromShops.has(d), ); const merged = [ ...grouped.map((g) => ({ district: g.district, shops: g.shops, isPendingEmpty: false, })), ...extras.map((district) => ({ district, shops: [] as ShopCard[], isPendingEmpty: true, })), ]; merged.sort((a, b) => { if (a.district === "未分類") return -1; if (b.district === "未分類") return 1; return a.district.localeCompare(b.district, "zh-Hant"); }); return merged; } function districtDisplayExistsInLane( lane: Lane, pendingExtra: string[] | undefined, display: string, ): boolean { const set = new Set(groupByDistrict(lane.shops).map((g) => g.district)); for (const d of pendingExtra ?? []) set.add(d); return set.has(display); } /** pending 顯示名陣列去重(保序),避免 rename 撞名造成重複 key / 重複區塊 */ function dedupeDistrictPendingOrder(items: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const x of items) { if (seen.has(x)) continue; seen.add(x); out.push(x); } return out; } const LANE_ID_SEP = "|"; /** 與後端 lane 唯一鍵一致:`TruckLanceCode` + 正規化後 remark(NULL/空白 視為同組) */ function encodeLaneId( truckLanceCode: string, laneRemark: string | null | undefined, ): string { const code = String(truckLanceCode || "").trim(); const rem = laneRemark != null && String(laneRemark).trim() !== "" ? String(laneRemark).trim() : ""; return `${encodeURIComponent(code)}${LANE_ID_SEP}${encodeURIComponent(rem)}`; } function decodeLaneId(laneId: string): { truckLanceCode: string; remark: string | null; } | null { const i = laneId.indexOf(LANE_ID_SEP); if (i < 0) return null; try { const code = decodeURIComponent(laneId.slice(0, i)); const remEnc = laneId.slice(i + LANE_ID_SEP.length); const rem = decodeURIComponent(remEnc); return { truckLanceCode: code, remark: rem !== "" ? rem : null, }; } catch { return null; } } function lanesToWarningInputRows(lanes: Lane[]): TruckLaneWarningInputRow[] { const out: TruckLaneWarningInputRow[] = []; for (const lane of lanes) { const code = lane.truckLanceCode; const laneRemark = lane.remark ?? null; for (const s of lane.shops) { out.push({ truckRowId: s.id, truckLanceCode: code, laneRemark, storeId: s.storeId, departureTime: s.departureTime, shopEntityId: s.shopEntityId ?? null, shopCode: s.shopCode, shopDisplayName: s.branchName, }); } } return out; } /** 尚未儲存的新增店鋪(負數 truck row id) */ function stripDraftShopRows(lanes: Lane[]): Lane[] { return lanes.map((l) => ({ ...l, shops: l.shops.filter((s) => s.id > 0), })); } function warningTouchesPickedShop( w: TruckLaneWarning, pick: { id: number; code: string }, ): boolean { if ( w.shopEntityId != null && Number.isFinite(w.shopEntityId) && w.shopEntityId === pick.id ) { return true; } const wc = String(w.shopCode || "").trim().toLowerCase(); const pc = String(pick.code || "").trim().toLowerCase(); return wc !== "" && pc !== "" && wc === pc; } function formatLaneWarningDetail( L: TruckLaneWarningLaneRef, tr: TFunction<"shop">, ): string { const dash = tr("emDash"); const parts = [`${tr("warnClipboardStore")} ${L.storeId}`]; if (L.weekday) { parts.push(`${tr("warnClipboardWeekday")} ${L.weekday}`); } parts.push(`${tr("warnClipboardDep")} ${L.departureTimeDisplay ?? dash}`); return parts.join(" · "); } function formatWarningSummary( w: TruckLaneWarning, tr: TFunction<"shop">, ): string { if (w.rule === "RULE_1_WEEKDAY") { return tr("mtmsRouteWarn_conflict4f", { weekday: w.triggerValue }); } return tr("mtmsRouteWarn_conflictDep", { time: w.triggerValue }); } function formatLaneWarningsClipboard( warnings: TruckLaneWarning[], tr: TFunction<"shop">, ): string { return warnings .map((w, idx) => { const shopLine = [w.shopCode, w.shopDisplayName] .map((s) => String(s ?? "").trim()) .filter(Boolean) .join(" "); const lanes = w.lanes .map((L) => { const laneTitle = `${L.truckLanceCode}${ L.laneRemark ? ` · ${L.laneRemark}` : "" }`; return ` - ${laneTitle} | ${formatLaneWarningDetail(L, tr)}`; }) .join("\n"); return `[${idx + 1}] ${tr("mtmsRouteWarn_shop")}: ${shopLine}\n${formatWarningSummary(w, tr)}\n${lanes}`; }) .join("\n\n"); } function formatDiffFieldLabel(label: string, tr: TFunction<"shop">): string { if (label === "versionLogField_logisticId") return tr("diffField_logisticsCompany"); // Translate i18n keys; fallback to raw label if not found const translated = tr(label as any); return translated !== label ? translated : label; } function downloadBase64Xlsx(base64: string, filename: string) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } const blob = new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function sortLanesByCode(lanes: Lane[]): Lane[] { return lanes.slice().sort((a, b) => { const c = a.truckLanceCode.localeCompare(b.truckLanceCode, "zh-Hant"); if (c !== 0) return c; return String(a.remark ?? "").localeCompare( String(b.remark ?? ""), "zh-Hant", ); }); } /** 由尚未儲存的新增車線 payload 建立看板 Lane(shops 為空) */ function buildLaneFromPendingNewLane(p: PendingNewLane): Lane { const payload = p.payload; const code = String(payload.truckLanceCode || "").trim(); const storeNorm = normalizeStoreId(payload.store_id); const remarkRaw = storeNorm === "4F" && payload.remark != null && String(payload.remark).trim() !== "" ? String(payload.remark).trim() : null; const dep = parseTimeForBackend(payload.departureTime || "") || "00:00:00"; return { id: p.laneKey, truckLanceCode: code, logisticsCompany: "", logisticId: payload.logisticId != null && Number.isFinite(Number(payload.logisticId)) ? Number(payload.logisticId) : null, plate: "", driver: "", phone: "", startTime: dep, storeId: storeNorm, remark: remarkRaw, shops: [], }; } /** 合併尚未寫入後端的新增車線,避免 loadLanes 覆蓋後從看板消失 */ function mergePendingNewLanesIntoLanes( lanes: Lane[], pending: PendingNewLane[], ): Lane[] { if (pending.length === 0) return lanes; const ids = new Set(lanes.map((l) => l.id)); const additions = pending .filter((p) => !ids.has(p.laneKey)) .map((p) => buildLaneFromPendingNewLane(p)); if (additions.length === 0) return lanes; return sortLanesByCode([...lanes, ...additions]); } /** 合併增量刷新後的車線(維持排序;追加後端新出現的 lane) */ function mergeRefreshedLanes(prev: Lane[], refreshed: Lane[]): Lane[] { const map = new Map(refreshed.map((l) => [l.id, l])); const prevIds = new Set(prev.map((l) => l.id)); const replaced: Lane[] = []; for (const l of prev) { const n = map.get(l.id); if (n !== undefined) { replaced.push(n); } else { replaced.push(l); } } const additions = refreshed.filter((l) => !prevIds.has(l.id)); return sortLanesByCode([...replaced, ...additions]); } function buildLaneFromTruckRows( truckLanceCode: string, remark: string | null, rows: Truck[] | null | undefined, meta?: Partial | Record | null, ): Lane { const code = String(truckLanceCode || "").trim(); const r = rows || []; const first = r[0]; const startSource = meta != null ? (meta as any).departureTime ?? first?.departureTime ?? "00:00:00" : first != null ? first.departureTime : "00:00:00"; const startTime = parseTimeForBackend(formatDepartureTime(startSource as any)) || "00:00:00"; const storeId = normalizeStoreId( meta != null ? (meta as any)?.storeId ?? (meta as any)?.store_id : (first as any)?.storeId ?? (first as any)?.store_id, ); const id = encodeLaneId(code, remark); const shops: ShopCard[] = r .filter((row) => row.id != null) .filter((row) => { const shopRef = (row as any).shop; const hasShopRef = shopRef != null && typeof shopRef === "object" && shopRef.id != null; const nm = row.shopName != null ? String(row.shopName).trim() : ""; const cd = row.shopCode != null ? String(row.shopCode).trim() : ""; if (hasShopRef) return true; if (nm === "" && cd === "") return false; const u = (s: string) => s.trim().toLowerCase(); if (u(nm) === "unassign" || u(cd) === "unassign") return false; if (u(nm) === "unassigned" || u(cd) === "unassigned") return false; return true; }) .map((row) => { const districtRaw = row.districtReference != null && String(row.districtReference).trim() !== "" ? String(row.districtReference).trim() : null; const shopRef = (row as any).shop; const shopEntityId = shopRef && typeof shopRef === "object" && shopRef.id != null ? Number(shopRef.id) : null; return { id: row.id as number, shopEntityId: Number.isFinite(shopEntityId as number) ? (shopEntityId as number) : null, branchName: row.shopName != null ? String(row.shopName) : "", shopCode: row.shopCode != null ? String(row.shopCode) : "", districtReferenceRaw: districtRaw, loadingSequence: Number(row.loadingSequence ?? 0) || 0, remark: row.remark != null ? String(row.remark) : null, storeId: normalizeStoreId( (row as any).storeId ?? (row as any).store_id, ), departureTime: parseTimeForBackend( formatDepartureTime(row.departureTime as any), ), }; }); let laneLogisticId: number | null = null; let laneLogisticsName = ""; let lanePlate = ""; let laneDriver = ""; let lanePhone = ""; for (const row of r) { const log = (row as { logistic?: Record | null }).logistic; if (!log || typeof log !== "object") continue; if ( laneLogisticId == null && log.id != null && Number.isFinite(Number(log.id)) ) { laneLogisticId = Number(log.id); } const nm = String(log.logisticName ?? "").trim(); if (nm !== "" && laneLogisticsName === "") laneLogisticsName = nm; const p = String(log.carPlate ?? "").trim(); const d = String(log.driverName ?? "").trim(); const ph = log.driverNumber != null && Number.isFinite(Number(log.driverNumber)) ? String(log.driverNumber) : ""; if (p !== "" && lanePlate === "") lanePlate = p; if (d !== "" && laneDriver === "") laneDriver = d; if (ph !== "" && lanePhone === "") lanePhone = ph; } if (laneLogisticId == null && r.length > 0) { for (const row of r) { const lid = (row as { logisticId?: unknown }).logisticId; if (lid != null && Number.isFinite(Number(lid))) { laneLogisticId = Number(lid); break; } } } return { id, truckLanceCode: code, logisticsCompany: laneLogisticsName, logisticId: laneLogisticId, driver: laneDriver, phone: lanePhone, plate: lanePlate, startTime: startTime || "00:00:00", storeId: storeId || "2F", remark, shops, }; } async function fetchLaneByKey( truckLanceCode: string, remark: string | null, meta?: Partial | null, ): Promise { const c = String(truckLanceCode || "").trim(); if (!c) return null; // NOTE: // - Next dev StrictMode 會讓初始化 useEffect 跑兩次,若不去重會把同一批 lane 打兩輪 // - 這裡做「同 key in-flight」去重,避免重複打 API const inflight = (fetchLaneByKey as any)._inflight as | Map> | undefined; const map: Map> = inflight ?? new Map(); (fetchLaneByKey as any)._inflight = map; const key = encodeLaneId(c, remark); const existing = map.get(key); if (existing) return existing; const p = (async () => { const rows = (await findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient( c, remark, )) as Truck[]; return buildLaneFromTruckRows(c, remark, rows, meta); })(); map.set(key, p); try { return await p; } finally { map.delete(key); } } function laneTargetConflicts( shop: ShopCard, lane: Lane, excludeTruckRowId?: number, ): boolean { for (const s of lane.shops) { if (excludeTruckRowId != null && s.id === excludeTruckRowId) continue; if ( shop.shopEntityId != null && s.shopEntityId != null && shop.shopEntityId === s.shopEntityId ) { return true; } const a = String(shop.shopCode || "") .trim() .toLowerCase(); const b = String(s.shopCode || "") .trim() .toLowerCase(); if (a !== "" && b !== "" && a === b) return true; } return false; } /** `/shop/combo/allShop` 常有 join 重複列:依 shop.id、再依 code 去重(保留 id 較小) */ function dedupeShopMasterRows( rows: Array<{ id?: unknown; code?: unknown; name?: unknown }>, ): Array<{ id: number; name: string; code: string }> { const byId = new Map(); for (const s of rows || []) { const id = Number(s?.id); if (!Number.isFinite(id) || id <= 0 || byId.has(id)) continue; const rawCode = String(s?.code ?? "").trim(); const name = String(s?.name ?? "").trim(); byId.set(id, { id, name: name || rawCode || String(id), code: rawCode || String(id), }); } const byCode = new Map(); for (const row of Array.from(byId.values())) { const ck = String(row.code).trim().toLowerCase(); const key = ck || `__id_${row.id}`; const prev = byCode.get(key); if (!prev || row.id < prev.id) byCode.set(key, row); } return Array.from(byCode.values()).sort((a, b) => a.code.localeCompare(b.code, undefined, { numeric: true }), ); } /** * 車線店鋪管理看板(對齊 MTMS_ISSUE_LOG.pdf / 圖1 / 圖2) * - 左:checkbox 多選車線 + search * - 右:所選車線按「地區(districtReference)」分組顯示店鋪,可拖拽跨車線 * - 支援儲存:把被拖動的店鋪批量呼叫 `updateTruckLaneClient` * * Logistic:後端 `truck.logistic` join;車線 Excel 見 MTMS_ROUTE_V1(PDF 圖1)。 */ const RouteBoard: React.FC = () => { const { t } = useTranslation("shop"); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [laneWarnDrawerOpen, setLaneWarnDrawerOpen] = useState(false); const [laneWarnExpandedIdx, setLaneWarnExpandedIdx] = useState( null, ); const [laneWarnSnackbar, setLaneWarnSnackbar] = useState(null); const [searchTerm, setSearchTerm] = useState(""); const didInitialLoadRef = useRef(false); const loadLanesInFlightRef = useRef(false); const importRouteFileInputRef = useRef(null); const pendingImportFileRef = useRef(null); const [pendingImportMeta, setPendingImportMeta] = useState<{ fileName: string; sheetCount: number; rowCount: number; } | null>(null); const [routeExcelBusy, setRouteExcelBusy] = useState(false); const routeExcelExportLockRef = useRef(false); // shopCode(lowercase) -> shop table real name const [shopNameByCodeMap, setShopNameByCodeMap] = useState< Map >(new Map()); const [lanes, setLanes] = useState([]); const laneWarningsMemo = useMemo( () => computeTruckLaneWarnings(lanesToWarningInputRows(lanes)), [lanes], ); const laneWarnCount = laneWarningsMemo.warnings.length; const selectLanesFromWarning = useCallback((w: TruckLaneWarning) => { const ids = Array.from( new Set( w.lanes .map((L) => String(L.laneKey ?? "").trim()) .filter((k) => k !== ""), ), ); if (ids.length === 0) return; setSelectedLaneIds(ids); setLaneWarnDrawerOpen(false); }, []); useEffect(() => { if (!laneWarnDrawerOpen) setLaneWarnExpandedIdx(null); }, [laneWarnDrawerOpen]); // Keep latest lanes snapshot for drag/drop computations. // This avoids relying on React state updater execution timing and prevents // side-effects inside state updaters (which can break under StrictMode/Concurrent). const lanesRef = useRef([]); useEffect(() => { lanesRef.current = lanes; }, [lanes]); const versionDiffReqSeq = useRef(0); const [selectedLaneIds, setSelectedLaneIds] = useState([]); const [routeBoardTab, setRouteBoardTab] = useState<"board" | "logistics">( "board", ); const [laneFilter, setLaneFilter] = useState<{ floor: "all" | "2F" | "4F"; query: string; }>({ floor: "all", query: "", }); const [laneFilterAnchor, setLaneFilterAnchor] = useState( null, ); // drag state (HTML5 drag & drop) const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( null, ); /** 物流商管理頁:拖曳整條車線指派 logistic */ const logisticsLaneDragIdRef = useRef(null); /** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */ const laneLogisticBaselineRef = useRef>(new Map()); /** 店鋪列地區 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */ const shopDistrictBaselineRef = useRef>(new Map()); const shopRowBaselineRef = useRef>(new Map()); const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0); const syncShopDistrictBaselineFromLanes = useCallback((laneList: Lane[]) => { const districtMap = new Map(); const rowMap = new Map(); for (const lane of laneList) { const fromLaneLabel = formatLaneLabel(lane.truckLanceCode, lane.remark); for (const s of lane.shops) { if (s.id <= 0) continue; districtMap.set(s.id, toDistrictDisplayName(s.districtReferenceRaw)); rowMap.set(s.id, { laneId: lane.id, fromLaneLabel, departureTime: parseTimeForBackend( formatDepartureTime(s.departureTime as any), ), loadingSequence: Number(s.loadingSequence ?? 0) || 0, districtDisplay: toDistrictDisplayName(s.districtReferenceRaw), }); } } shopDistrictBaselineRef.current = districtMap; shopRowBaselineRef.current = rowMap; setDistrictBaselineEpoch((e) => e + 1); }, []); const [dropIndicator, setDropIndicator] = useState<{ laneId: string; beforeShopId: number | null; } | null>(null); /** 跨線拖曳等:來源 lane 可能沒有任何 dirty 列,儲存/還原時仍須一併 refetch */ const lanesNeedingRefreshOnSaveRef = useRef>(new Set()); // dirty tracking (shop row id -> new laneId) const [dirtyMoves, setDirtyMoves] = useState>(new Map()); // staged deletes (truck row ids) const [dirtyDeletes, setDirtyDeletes] = useState>(new Set()); const dirtyDeletesRef = useRef>(new Set()); /** 暫刪列在 UI 已移除時仍要在版本 LOG「未儲存」小節顯示店鋪/來源車線 */ const stagedDeleteMetaRef = useRef>(new Map()); useEffect(() => { dirtyDeletesRef.current = dirtyDeletes; }, [dirtyDeletes]); /** 立刻同步 ref,避免同一 tick 內 await 後 setLanes 仍讀到舊暫刪 */ const clearDirtyDeletesState = useCallback(() => { dirtyDeletesRef.current = new Set(); stagedDeleteMetaRef.current.clear(); setDirtyDeletes(new Set()); }, []); /** refresh/load 會用後端資料覆蓋 UI,須再過濾未 Save 的暫刪列 */ const filterStagedDeletedShops = useCallback( (laneList: Lane[], del: Set): Lane[] => { if (del.size === 0) return laneList; return laneList.map((lane) => ({ ...lane, shops: lane.shops.filter((s) => !del.has(s.id)), })); }, [], ); const [departureEditLaneId, setDepartureEditLaneId] = useState( null, ); const [departureEditDraft, setDepartureEditDraft] = useState(""); const [seqEditTarget, setSeqEditTarget] = useState<{ laneId: string; shopId: number; } | null>(null); const [seqEditDraft, setSeqEditDraft] = useState(""); const [saving, setSaving] = useState(false); const saveInFlightRef = useRef(false); const [saveResult, setSaveResult] = useState<{ ok: boolean; message: string; } | null>(null); // version log (snapshot) UI const [logDialogOpen, setLogDialogOpen] = useState(false); const [loadingVersions, setLoadingVersions] = useState(false); const [logVersions, setLogVersions] = useState([]); const [selectedLogVersionId, setSelectedLogVersionId] = useState< number | null >(null); const [diffLoading, setDiffLoading] = useState(false); const [diffError, setDiffError] = useState(null); const [versionFilterAnchor, setVersionFilterAnchor] = useState(null); const [versionFilterQuery, setVersionFilterQuery] = useState(""); const [versionFilterDate, setVersionFilterDate] = useState(""); const [changedShopIds, setChangedShopIds] = useState>(new Set()); const [logisticMasterDiffLines, setLogisticMasterDiffLines] = useState< LogisticMasterDiffLine[] >([]); const [diffLines, setDiffLines] = useState< Array<{ truckRowId: number; shopCode: string | null; changes: Array<{ field: string; from: string | null; to: string | null }>; }> >([]); /** 版本 LOG:已排程、待「儲存更改」時才呼叫 restore API */ const [pendingRestoreVersionId, setPendingRestoreVersionId] = useState< number | null >(null); const [versionNoteDrafts, setVersionNoteDrafts] = useState< Record >({}); const [savingVersionNoteId, setSavingVersionNoteId] = useState( null, ); const [versionNoteSaveError, setVersionNoteSaveError] = useState<{ id: number; message: string; } | null>(null); const headVersionId = useMemo( () => resolveHeadVersionId(logVersions), [logVersions], ); const displayedVersionLabel = useMemo(() => { if ( pendingRestoreVersionId != null && Number.isFinite(pendingRestoreVersionId) && pendingRestoreVersionId > 0 ) { return t("version_ui_pendingRestore", { id: pendingRestoreVersionId }); } if (headVersionId != null) { return t("version_ui_id", { id: headVersionId }); } return t("version_ui_none"); }, [pendingRestoreVersionId, headVersionId, t]); const versionFilterActive = String(versionFilterQuery || "").trim() !== "" || String(versionFilterDate || "").trim() !== ""; const filteredLogVersions = useMemo(() => { const q = String(versionFilterQuery || "") .trim() .toLowerCase(); const exactDate = String(versionFilterDate || "").trim(); const hasDate = exactDate !== ""; const hasQ = q !== ""; if (!hasDate && !hasQ) return logVersions; return (logVersions || []).filter((v) => { const id = Number(v?.id); const created = String(v?.created || ""); const { date } = splitVersionCreated(created); if (hasDate) { if (date !== exactDate) return false; } if (!hasQ) return true; const note = v?.note != null ? String(v.note) : ""; const editor = resolveVersionActor(v ?? {}); const hay = `${id} ${note} ${editor} ${created}`.toLowerCase(); return hay.includes(q); }); }, [logVersions, versionFilterDate, versionFilterQuery]); const versionShopRows = useMemo( () => diffLinesToShopRows(diffLines as TruckLaneVersionDiffLine[]), [diffLines], ); const versionRowSummary = useMemo( () => summarizeVersionRows(versionShopRows), [versionShopRows], ); /** 新增店鋪:從 shop master 挑選 */ const [allShopsMaster, setAllShopsMaster] = useState< Array<{ id: number; name: string; code: string }> >([]); const [scheduleModalOpen, setScheduleModalOpen] = useState(false); const [scheduleHistoryOpen, setScheduleHistoryOpen] = useState(false); const scheduleModalOpenRef = useRef(scheduleModalOpen); scheduleModalOpenRef.current = scheduleModalOpen; const { pendingScheduleShopIds, lockedScheduleShopIds, failedScheduleShopIds, failedScheduleCount, refreshScheduleIndicators, } = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen }); /** 硬鎖:APPLYING 或進入鎖定時間窗的排程;遠期排程僅標記不鎖。 */ const scheduledShopIdSet = lockedScheduleShopIds; const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); const [addShopLaneId, setAddShopLaneId] = useState(null); const [addShopPick, setAddShopPick] = useState<{ id: number; name: string; code: string; } | null>(null); type PendingShopAdd = { tempTruckRowId: number; laneId: string; shopId: number; shopName: string; shopCode: string; loadingSequence: number; }; const [pendingShopAdds, setPendingShopAdds] = useState( [], ); const nextDraftTruckRowIdRef = useRef(-1_000_000_000); const addShopConfirmLockRef = useRef(false); const addRouteInFlightRef = useRef(false); /** 車牌/司機等:truck 表尚無欄位,先暫存於此並在 loadLanes 後疊加到 Lane(刷新頁面會丟失) */ type LaneDisplayOverlay = Partial< Pick >; const laneDisplayOverlayRef = useRef>( new Map(), ); type NewRouteFormState = { truckLanceCode: string; /** 物流主檔 id;null = 未指定(至物流商管理指派) */ logisticId: number | null; startTime: string; storeId: "2F" | "4F"; remark: string; }; const emptyNewRouteForm = (): NewRouteFormState => ({ truckLanceCode: "", logisticId: null, startTime: "07:30", storeId: "2F", remark: "", }); const [addRouteDialogOpen, setAddRouteDialogOpen] = useState(false); const [newRouteForm, setNewRouteForm] = useState(emptyNewRouteForm); const [addRouteSubmitting, setAddRouteSubmitting] = useState(false); const [addRouteError, setAddRouteError] = useState(null); /** 尚未呼叫後端的新增車線(按「儲存更改」才 createTruckWithoutShop) */ const [pendingNewLanes, setPendingNewLanes] = useState([]); const pendingNewLanesRef = useRef([]); useEffect(() => { pendingNewLanesRef.current = pendingNewLanes; }, [pendingNewLanes]); /** 看板末端「+」:從篩選後車線清單選一條,加入左欄勾選並捲動到該欄 */ const [boardQuickPickAnchorEl, setBoardQuickPickAnchorEl] = useState(null); /** 「+」快速選車線 Popover 內關鍵字(不影響左欄篩選) */ const [boardQuickPickSearch, setBoardQuickPickSearch] = useState(""); /** 車線內尚無任何店鋪列的暫存「地區」顯示名(僅前端;見 `buildLaneDistrictSections` 註解) */ const [pendingEmptyDistrictsByLane, setPendingEmptyDistrictsByLane] = useState>({}); type DistrictEditCtx = | { laneId: string; mode: "add" } | { laneId: string; mode: "rename"; oldDisplay: string }; const [districtEditOpen, setDistrictEditOpen] = useState(false); const [districtEditCtx, setDistrictEditCtx] = useState( null, ); const [districtEditDraft, setDistrictEditDraft] = useState(""); const [districtEditError, setDistrictEditError] = useState(null); const districtEditSubmitLockRef = useRef(false); /** `logistic` 表 logisticName(GET /logistic/all) */ const [logisticNamesFromDb, setLogisticNamesFromDb] = useState([]); const [logisticRowsFromDb, setLogisticRowsFromDb] = useState( [], ); const [pendingLogisticMasterAdds, setPendingLogisticMasterAdds] = useState< Array<{ tempId: number } & SaveLogisticRequest> >([]); const [pendingLogisticMasterEdits, setPendingLogisticMasterEdits] = useState< Map >(new Map()); const [pendingLogisticMasterDeletes, setPendingLogisticMasterDeletes] = useState>(new Set()); const nextPendingLogisticTempIdRef = useRef(-1); const addLogisticInFlightRef = useRef(false); const logisticRowsEffective = useMemo(() => { const pendingRows: LogisticRow[] = pendingLogisticMasterAdds.map((p) => ({ id: p.tempId, logisticName: p.logisticName, carPlate: p.carPlate, driverName: p.driverName, driverNumber: p.driverNumber, })); const dbWithEdits = logisticRowsFromDb .filter((r) => !pendingLogisticMasterDeletes.has(Number(r.id))) .map((r) => { const id = Number(r.id); const edit = pendingLogisticMasterEdits.get(id); if (!edit) return r; return { ...r, logisticName: edit.logisticName, carPlate: edit.carPlate, driverName: edit.driverName, driverNumber: edit.driverNumber, }; }); return [...pendingRows, ...dbWithEdits]; }, [ pendingLogisticMasterAdds, pendingLogisticMasterEdits, pendingLogisticMasterDeletes, logisticRowsFromDb, ]); const logisticNameById = useMemo(() => { const m = new Map(); for (const r of logisticRowsEffective) { const id = Number((r as any)?.id); const name = String((r as any)?.logisticName ?? "").trim(); if (!Number.isFinite(id) || id === 0 || name === "") continue; m.set(id, name); } return m; }, [logisticRowsEffective]); const buildPendingLaneFromForm = useCallback( (form: NewRouteFormState): Lane => { const code = String(form.truckLanceCode || "").trim(); const storeNorm = normalizeStoreId(form.storeId); const remarkRaw = storeNorm === "4F" && String(form.remark || "").trim() !== "" ? String(form.remark).trim() : null; const dep = parseTimeForBackend(form.startTime || "") || "00:00:00"; const laneKey = encodeLaneId(code, remarkRaw); const lid = form.logisticId != null && Number.isFinite(Number(form.logisticId)) ? Number(form.logisticId) : null; const master = lid != null ? logisticRowsEffective.find((r) => Number((r as any).id) === lid) ?? null : null; return { id: laneKey, truckLanceCode: code, logisticsCompany: master ? String(master.logisticName ?? "").trim() : "", logisticId: lid, plate: master ? String(master.carPlate ?? "").trim() : "", driver: master ? String(master.driverName ?? "").trim() : "", phone: master != null && (master as any).driverNumber != null && Number.isFinite(Number((master as any).driverNumber)) ? String((master as any).driverNumber) : "", startTime: dep, storeId: storeNorm, remark: remarkRaw, shops: [], }; }, [logisticRowsEffective], ); const buildCreateTruckWithoutShopPayload = useCallback( (form: NewRouteFormState): CreateTruckWithoutShopRequest => { const code = String(form.truckLanceCode || "").trim(); const storeNorm = normalizeStoreId(form.storeId); const remarkRaw = storeNorm === "4F" && String(form.remark || "").trim() !== "" ? String(form.remark).trim() : null; return { store_id: storeNorm, truckLanceCode: code, departureTime: parseTimeForBackend(form.startTime || "") || "00:00:00", loadingSequence: 0, districtReference: null, remark: remarkRaw, logisticId: form.logisticId, }; }, [], ); const [addLogisticOpen, setAddLogisticOpen] = useState(false); const [addLogisticSubmitting, setAddLogisticSubmitting] = useState(false); const [addLogisticError, setAddLogisticError] = useState(null); const [addLogisticForm, setAddLogisticForm] = useState({ logisticName: "", carPlate: "", driverName: "", driverPhone: "", }); const [editLogisticOpen, setEditLogisticOpen] = useState(false); const [editLogisticSubmitting, setEditLogisticSubmitting] = useState(false); const editLogisticInFlightRef = useRef(false); const [editLogisticError, setEditLogisticError] = useState( null, ); const [editLogisticForm, setEditLogisticForm] = useState({ id: 0, logisticName: "", carPlate: "", driverName: "", driverPhone: "", }); const [logisticsDropHoverCompany, setLogisticsDropHoverCompany] = useState< string | null >(null); const enrichLanesWithLogisticMaster = useCallback( (list: Lane[]): Lane[] => { if (!Array.isArray(list) || list.length === 0) return list; if (!Array.isArray(logisticRowsEffective) || logisticRowsEffective.length === 0) return list; const byId = new Map(); for (const r of logisticRowsEffective) { const id = Number((r as any)?.id); if (!Number.isFinite(id) || id === 0) continue; if (!byId.has(id)) byId.set(id, r); } return list.map((lane) => { const lid = lane.logisticId != null ? Number(lane.logisticId) : NaN; if (!Number.isFinite(lid) || lid === 0) return lane; const master = byId.get(lid); if (!master) return lane; const plate = String((master as any).carPlate ?? "").trim(); const driver = String((master as any).driverName ?? "").trim(); const phone = (master as any).driverNumber != null && Number.isFinite(Number((master as any).driverNumber)) ? String((master as any).driverNumber) : ""; return { ...lane, plate: lane.plate && String(lane.plate).trim() !== "" ? lane.plate : plate, driver: lane.driver && String(lane.driver).trim() !== "" ? lane.driver : driver, phone: lane.phone && String(lane.phone).trim() !== "" ? lane.phone : phone, }; }); }, [logisticRowsEffective], ); const applyLaneDisplayOverlays = (list: Lane[]): Lane[] => { const map = laneDisplayOverlayRef.current; return list.map((lane) => { const o = map.get(lane.id); if (!o) return lane; return { ...lane, plate: o.plate !== undefined && o.plate !== "" ? o.plate : lane.plate, driver: o.driver !== undefined && o.driver !== "" ? o.driver : lane.driver, phone: o.phone !== undefined && o.phone !== "" ? o.phone : lane.phone, logisticsCompany: o.logisticsCompany !== undefined && o.logisticsCompany !== "" ? o.logisticsCompany : lane.logisticsCompany, // DB 的 logisticId 不給 overlay 改 logisticId: lane.logisticId, }; }); }; const refreshLanesByIds = async ( laneIds: string[], options: { preserveStagedLogistics?: boolean } = {}, ): Promise => { const uniq = Array.from(new Set(laneIds)).filter(Boolean); if (uniq.length === 0) return null; const preserveStagedLogistics = options.preserveStagedLogistics ?? true; const stagedLogisticsByLane = new Map(); if (preserveStagedLogistics) { for (const lane of lanesRef.current) { const currentLogisticId = lane.logisticId != null ? Number(lane.logisticId) : null; const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id) ? laneLogisticBaselineRef.current.get(lane.id) ?? null : null; if (currentLogisticId !== baselineLogisticId) { stagedLogisticsByLane.set(lane.id, lane); } } } const refreshed: Lane[] = []; for (const laneId of uniq) { const d = decodeLaneId(laneId); if (!d) continue; try { const lane = await fetchLaneByKey(d.truckLanceCode, d.remark); if (lane) refreshed.push(lane); } catch (e) { console.warn("refresh lane failed", laneId, e); } } if (refreshed.length === 0) return null; const mergedRefreshed = refreshed.map((lane) => { const staged = stagedLogisticsByLane.get(lane.id); if (!staged) return lane; return { ...lane, logisticId: staged.logisticId ?? null, logisticsCompany: staged.logisticsCompany, plate: staged.plate, driver: staged.driver, phone: staged.phone, }; }); // 同步 baseline(以 server 回來的 lane 為準) for (const lane of refreshed) { if (stagedLogisticsByLane.has(lane.id)) continue; laneLogisticBaselineRef.current.set( lane.id, lane.logisticId != null ? Number(lane.logisticId) : null, ); } let nextApplied: Lane[] | null = null; setLanes((prev) => { const next = filterStagedDeletedShops( applyLaneDisplayOverlays( enrichLanesWithLogisticMaster( mergePendingNewLanesIntoLanes( mergeRefreshedLanes(prev, mergedRefreshed), pendingNewLanesRef.current, ), ), ), dirtyDeletesRef.current, ); nextApplied = next; return next; }); if (nextApplied != null) { syncShopDistrictBaselineFromLanes(nextApplied); } return nextApplied; }; const loadLanes = async () => { if (loadLanesInFlightRef.current) return; loadLanesInFlightRef.current = true; setLoading(true); setError(null); setPendingEmptyDistrictsByLane({}); closeDistrictEdit(); try { // build shopCode -> real shop name map (from shop table) try { const shopRows = (await fetchAllShopsClient()) as Array<{ id?: any; code?: any; name?: any; }>; const master = dedupeShopMasterRows(shopRows || []); const map = new Map(); master.forEach((row) => { const code = String(row.code ?? "") .trim() .toLowerCase(); const name = String(row.name ?? "").trim(); if (code && name) map.set(code, name); }); setAllShopsMaster(master); setShopNameByCodeMap(map); } catch (e) { // non-blocking: still show branchName if shop table fetch fails console.warn("Failed to load shop table names:", e); } // O(1) load: 後端一次回傳所有 truck rows;前端自行按 lane 分桶 const allRows = (await findAllForRouteBoardClient()) as Truck[]; const bucket = new Map< string, { truckLanceCode: string; remark: string | null; meta: Truck; rows: Truck[]; } >(); for (const row of allRows || []) { const code = String((row as any)?.truckLanceCode ?? "").trim(); if (!code) continue; const remarkRaw = String((row as any)?.remark ?? "").trim(); const remark = remarkRaw !== "" ? remarkRaw : null; const id = encodeLaneId(code, remark); const b = bucket.get(id); if (!b) { bucket.set(id, { truckLanceCode: code, remark, meta: row, rows: [row], }); } else { b.rows.push(row); } } const loaded: Lane[] = Array.from(bucket.values()) .map((b) => { b.rows.sort((a: any, c: any) => { const sa = Number(a?.loadingSequence ?? 0) || 0; const sb = Number(c?.loadingSequence ?? 0) || 0; if (sa !== sb) return sa - sb; const ia = Number(a?.id ?? 0) || 0; const ib = Number(c?.id ?? 0) || 0; return ia - ib; }); return buildLaneFromTruckRows( b.truckLanceCode, b.remark, b.rows, b.meta, ); }); const loadedWithPending = mergePendingNewLanesIntoLanes( sortLanesByCode(loaded), pendingNewLanesRef.current, ); const nextBoard = filterStagedDeletedShops( applyLaneDisplayOverlays( enrichLanesWithLogisticMaster(loadedWithPending), ), dirtyDeletesRef.current, ); lanesRef.current = nextBoard; setLanes(nextBoard); // 初始化 baseline(以 server 回來的 lane 為準) laneLogisticBaselineRef.current = new Map( nextBoard.map((l) => [ l.id, l.logisticId != null ? Number(l.logisticId) : null, ]), ); syncShopDistrictBaselineFromLanes(nextBoard); setPendingLogisticMasterAdds([]); setPendingLogisticMasterEdits(new Map()); setPendingLogisticMasterDeletes(new Set()); pendingImportFileRef.current = null; setPendingImportMeta(null); // default: select none (user will pick lanes) setSelectedLaneIds((prev) => prev); } catch (e: any) { console.error("Failed to load lanes:", e); setError(e?.message ?? String(e) ?? t("err_loadLanes")); } finally { setLoading(false); loadLanesInFlightRef.current = false; } }; useEffect(() => { // Next dev StrictMode 會 double-invoke effect;這裡保證初始化只載一次 if (didInitialLoadRef.current) return; didInitialLoadRef.current = true; void loadLanes(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const reloadLogisticNamesFromDb = useCallback(async () => { try { const rows = await findAllLogisticsClient(); setLogisticRowsFromDb(rows || []); const names = Array.from( new Set( (rows || []) .map((r) => String(r.logisticName ?? "").trim()) .filter((n) => n !== ""), ), ).sort((a, b) => a.localeCompare(b, "zh-Hant")); setLogisticNamesFromDb(names); } catch (e) { console.warn("Failed to load logistic master (logisticName):", e); } }, []); const resolveLogisticIdForCompanyLabel = useCallback( (company: string): number | null => { const c = String(company).trim(); if (c === "" || c === "未分配物流商") return null; const row = logisticRowsEffective.find( (r) => String(r.logisticName ?? "").trim() === c, ); return row != null && row.id != null ? Number(row.id) : null; }, [logisticRowsEffective], ); const getColumnTargetLogisticId = useCallback( (company: string, lanesInColumn: Lane[]): number | null => { if (company === "未分配物流商") return null; const withId = lanesInColumn.find( (l) => l.logisticId != null && Number.isFinite(Number(l.logisticId)) && Number(l.logisticId) !== 0, ); if (withId?.logisticId != null) return Number(withId.logisticId); return resolveLogisticIdForCompanyLabel(company); }, [resolveLogisticIdForCompanyLabel], ); useEffect(() => { void reloadLogisticNamesFromDb(); }, [reloadLogisticNamesFromDb]); useEffect(() => { if (!Array.isArray(logisticRowsEffective) || logisticRowsEffective.length === 0) return; setLanes((prev) => filterStagedDeletedShops( applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(prev)), dirtyDeletesRef.current, ), ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [logisticRowsEffective]); const submitAddLogistic = async () => { if (addLogisticInFlightRef.current) return; const logisticName = String(addLogisticForm.logisticName || "").trim(); const carPlate = String(addLogisticForm.carPlate || "").trim(); const driverName = String(addLogisticForm.driverName || "").trim(); const driverNumber = phoneDigitsToDriverNumber(addLogisticForm.driverPhone); if (!logisticName || !carPlate || !driverName) { setAddLogisticError(t("val_logisticsRequired")); return; } if (driverNumber == null) { setAddLogisticError(t("val_phoneInvalid")); return; } const dup = logisticRowsEffective.some( (r) => String(r.logisticName ?? "").trim().toLowerCase() === logisticName.toLowerCase(), ); if (dup) { setAddLogisticError(t("val_logisticsDuplicateName")); return; } addLogisticInFlightRef.current = true; setAddLogisticError(null); setAddLogisticSubmitting(true); try { const tempId = nextPendingLogisticTempIdRef.current; nextPendingLogisticTempIdRef.current -= 1; setPendingLogisticMasterAdds((prev) => [ ...prev, { tempId, logisticName, carPlate, driverName, driverNumber, }, ]); setAddLogisticForm({ logisticName: "", carPlate: "", driverName: "", driverPhone: "", }); setAddLogisticOpen(false); setSaveResult(null); } finally { setAddLogisticSubmitting(false); addLogisticInFlightRef.current = false; } }; const openEditLogistic = (row: LogisticRow) => { setEditLogisticError(null); const id = Number(row.id); const pending = pendingLogisticMasterEdits.get(id); setEditLogisticForm({ id: row.id, logisticName: String( pending?.logisticName ?? row.logisticName ?? "", ).trim(), carPlate: String(pending?.carPlate ?? row.carPlate ?? "").trim(), driverName: String(pending?.driverName ?? row.driverName ?? "").trim(), driverPhone: pending?.driverNumber != null && Number.isFinite(pending.driverNumber) ? String(pending.driverNumber) : row.driverNumber != null && Number.isFinite(row.driverNumber) ? String(row.driverNumber) : "", }); setEditLogisticOpen(true); }; const submitEditLogistic = async () => { if (editLogisticInFlightRef.current) return; const logisticName = String(editLogisticForm.logisticName || "").trim(); const carPlate = String(editLogisticForm.carPlate || "").trim(); const driverName = String(editLogisticForm.driverName || "").trim(); const driverNumber = phoneDigitsToDriverNumber( editLogisticForm.driverPhone, ); if (!editLogisticForm.id || editLogisticForm.id <= 0) { setEditLogisticError(t("err_invalidMasterId")); return; } if (!logisticName || !carPlate || !driverName) { setEditLogisticError(t("val_logisticsRequired")); return; } if (driverNumber == null) { setEditLogisticError(t("val_phoneInvalid")); return; } setEditLogisticError(null); editLogisticInFlightRef.current = true; setEditLogisticSubmitting(true); try { const lid = editLogisticForm.id; setPendingLogisticMasterEdits((prev) => { const next = new Map(prev); next.set(lid, { id: lid, logisticName, carPlate, driverName, driverNumber, }); return next; }); const phone = driverNumber != null && Number.isFinite(driverNumber) ? String(driverNumber) : ""; setLanes((prev) => filterStagedDeletedShops( applyLaneDisplayOverlays( prev.map((lane) => lane.logisticId === lid ? { ...lane, logisticsCompany: logisticName, plate: carPlate, driver: driverName, phone, } : lane, ), ), dirtyDeletesRef.current, ), ); setSaveResult(null); setEditLogisticOpen(false); } catch (e: any) { setEditLogisticError(e?.message ?? String(e) ?? t("err_save")); } finally { setEditLogisticSubmitting(false); editLogisticInFlightRef.current = false; } }; const stageDeleteLogistic = useCallback( (row: LogisticRow, companyLabel: string) => { const id = Number(row.id); if (!Number.isFinite(id)) return; const name = String(companyLabel || row.logisticName || "").trim() || "—"; if (!window.confirm(t("confirm_deleteLogistic", { name }))) return; if (id < 0) { setPendingLogisticMasterAdds((prev) => prev.filter((p) => p.tempId !== id), ); } else { setPendingLogisticMasterDeletes((prev) => { const next = new Set(prev); next.add(id); return next; }); } setPendingLogisticMasterEdits((prev) => { const next = new Map(prev); next.delete(id); return next; }); setSaveResult(null); }, [t], ); const handleExportSelectedLanesExcel = useCallback(async () => { if (selectedLaneIds.length === 0) { setError(t("err_exportNeedSelection")); return; } setRouteExcelBusy(true); setError(null); try { const { base64, filename } = await exportRouteLanesExcelClient(selectedLaneIds); downloadBase64Xlsx(base64, filename); } catch (e: any) { setError(e?.message ?? String(e) ?? t("err_export")); } finally { setRouteExcelBusy(false); } }, [selectedLaneIds, t]); const handleExportRouteReportExcel = useCallback(async () => { if (lanes.length === 0) { setError(t("err_noLanes")); return; } setRouteExcelBusy(true); setError(null); try { // 空 laneIds = 匯出 RouteBoard 全部(避免傳輸一大串 ids) const { base64, filename } = await exportRouteReportExcelClient([]); downloadBase64Xlsx(base64, filename); } catch (e: any) { setError(e?.message ?? String(e) ?? t("err_export")); } finally { setRouteExcelBusy(false); } }, [lanes, t]); const handleImportRouteExcelChange = async ( e: React.ChangeEvent, ) => { const file = e.target.files?.[0]; e.target.value = ""; if (!file) return; const hadOther = dirtyMoves.size > 0 || dirtyDeletes.size > 0 || pendingShopAdds.length > 0 || pendingNewLanes.length > 0 || pendingLogisticMasterAdds.length > 0 || pendingLogisticMasterEdits.size > 0 || pendingLogisticMasterDeletes.size > 0 || pendingRestoreVersionId != null || pendingImportMeta != null || Object.values(pendingEmptyDistrictsByLane).some( (a) => (a?.length ?? 0) > 0, ); if (hadOther && !window.confirm(t("confirm_importDiscardEdits"))) return; setRouteExcelBusy(true); setError(null); try { const fd = new FormData(); fd.append("multipartFileList", file); const parsed = await parseRouteLanesExcelClient(fd); if (!parsed.rowCount || parsed.rowCount <= 0) { setError(t("err_importEmpty")); return; } setDirtyMoves(new Map()); clearDirtyDeletesState(); setPendingShopAdds([]); setPendingNewLanes([]); setPendingLogisticMasterAdds([]); setPendingLogisticMasterEdits(new Map()); setPendingLogisticMasterDeletes(new Set()); setPendingRestoreVersionId(null); setPendingEmptyDistrictsByLane({}); setDistrictEditOpen(false); setDistrictEditCtx(null); setDepartureEditLaneId(null); setSeqEditTarget(null); lanesNeedingRefreshOnSaveRef.current.clear(); laneDisplayOverlayRef.current.clear(); const merged = mergeImportPreviewIntoLanes(lanesRef.current, parsed.rows); const nextBoard = applyLaneDisplayOverlays( enrichLanesWithLogisticMaster(merged), ); lanesRef.current = nextBoard; setLanes(nextBoard); syncShopDistrictBaselineFromLanes(nextBoard); pendingImportFileRef.current = file; setPendingImportMeta({ fileName: file.name, sheetCount: parsed.sheetCount, rowCount: parsed.rowCount, }); setSaveResult({ ok: true, message: t("import_staged_preview", { file: file.name, sheets: parsed.sheetCount, rows: parsed.rowCount, }), }); } catch (err: any) { setError(err?.message ?? String(err) ?? t("err_import")); } finally { setRouteExcelBusy(false); } }; const filteredLanes = useMemo(() => { const picked = lanes.filter((l) => selectedLaneIds.includes(l.id)); const term = String(searchTerm || "") .trim() .toLowerCase(); if (!term) return picked; return picked.map((lane) => ({ ...lane, shops: lane.shops.filter((s) => { const codeLower = String(s.shopCode || "") .trim() .toLowerCase(); const realName = shopNameByCodeMap.get(codeLower) ?? ""; const name = String(realName || "").toLowerCase(); const branch = String(s.branchName || "").toLowerCase(); const code = String(s.shopCode || "").toLowerCase(); const district = String(s.districtReferenceRaw || "").toLowerCase(); return ( name.includes(term) || branch.includes(term) || code.includes(term) || district.includes(term) ); }), })); }, [lanes, selectedLaneIds, searchTerm, shopNameByCodeMap]); const visibleLaneOptions = useMemo(() => { const q = String(laneFilter.query || "") .trim() .toLowerCase(); return (lanes || []).filter((lane) => { const code = String(lane.truckLanceCode || "").toLowerCase(); const rem = String(lane.remark ?? "").toLowerCase(); const floorOk = laneFilter.floor === "all" ? true : normalizeStoreId(lane.storeId) === laneFilter.floor; const qOk = !q ? true : code.includes(q) || rem.includes(q); return floorOk && qOk; }); }, [lanes, laneFilter.floor, laneFilter.query]); /** 「+」與看板欄:只吃樓層,不吃左欄關鍵字(與 `visibleLaneOptions` 分離) */ const lanesMatchingFloorOnly = useMemo(() => { return (lanes || []).filter((lane) => { const floorOk = laneFilter.floor === "all" ? true : normalizeStoreId(lane.storeId) === laneFilter.floor; return floorOk; }); }, [lanes, laneFilter.floor]); const boardQuickPickFilteredLanes = useMemo(() => { const q = String(boardQuickPickSearch).trim().toLowerCase(); if (!q) return lanesMatchingFloorOnly; return lanesMatchingFloorOnly.filter((lane) => { const code = String(lane.truckLanceCode || "").toLowerCase(); const rem = String(lane.remark ?? "").toLowerCase(); const driver = String(lane.driver ?? "").toLowerCase(); const plate = String(lane.plate ?? "").toLowerCase(); return ( code.includes(q) || rem.includes(q) || driver.includes(q) || plate.includes(q) ); }); }, [lanesMatchingFloorOnly, boardQuickPickSearch]); const scrollBoardLaneCardIntoView = useCallback((laneId: string) => { const safe = typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(laneId) : laneId.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); document .querySelector(`[data-lane-id="${safe}"]`) ?.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest", }); }, []); const applyBoardQuickPickLane = useCallback( (laneId: string) => { setBoardQuickPickAnchorEl(null); setBoardQuickPickSearch(""); setSelectedLaneIds((prev) => prev.includes(laneId) ? prev : [...prev, laneId], ); requestAnimationFrame(() => { requestAnimationFrame(() => scrollBoardLaneCardIntoView(laneId)); }); }, [scrollBoardLaneCardIntoView], ); /** 新增車線:物流公司下拉選單(依名稱排序) */ const logisticRowsSortedForSelect = useMemo( () => [...logisticRowsEffective].sort((a, b) => String(a.logisticName ?? "").localeCompare( String(b.logisticName ?? ""), "zh-Hant", ), ), [logisticRowsEffective], ); const logisticNamesEffective = useMemo(() => { const set = new Set(); for (const p of pendingLogisticMasterAdds) { const n = String(p.logisticName ?? "").trim(); if (n) set.add(n); } for (const r of logisticRowsFromDb) { const id = Number(r.id); if (pendingLogisticMasterDeletes.has(id)) continue; const s = String(r.logisticName ?? "").trim(); if (s) set.add(s); } return Array.from(set).sort((a, b) => a.localeCompare(b, "zh-Hant")); }, [ pendingLogisticMasterAdds, pendingLogisticMasterDeletes, logisticRowsFromDb, ]); /** 依樓層(不含車線下拉搜尋關鍵字)分組;併入 GET /logistic/all 有、但尚未掛車線的公司 */ const lanesByLogisticsCompany = useMemo(() => { const map = new Map(); for (const lane of lanesMatchingFloorOnly) { if ( selectedLaneIds.length > 0 && !selectedLaneIds.includes(lane.id) ) { continue; } const company = String(lane.logisticsCompany ?? "").trim() || "未分配物流商"; const arr = map.get(company) ?? []; arr.push(lane); map.set(company, arr); } for (const name of logisticNamesEffective) { const n = String(name).trim(); if (n && !map.has(n)) map.set(n, []); } const entries = Array.from(map.entries()); entries.sort((a, b) => { if (a[0] === "未分配物流商" && b[0] !== "未分配物流商") return 1; if (b[0] === "未分配物流商" && a[0] !== "未分配物流商") return -1; return a[0].localeCompare(b[0], "zh-Hant"); }); return entries; }, [lanesMatchingFloorOnly, logisticNamesEffective, selectedLaneIds]); const addShopCandidates = useMemo(() => { if (!addShopLaneId) return []; const lane = lanes.find((l) => l.id === addShopLaneId); if (!lane) return []; const codesInLane = new Set( lane.shops .map((s) => String(s.shopCode || "") .trim() .toLowerCase(), ) .filter((c) => c !== ""), ); const idsInLane = new Set( lane.shops .map((s) => s.shopEntityId) .filter((x): x is number => typeof x === "number" && x > 0), ); return allShopsMaster.filter((m) => { const c = String(m.code || "") .trim() .toLowerCase(); if (c && codesInLane.has(c)) return false; if (idsInLane.has(m.id)) return false; return true; }); }, [addShopLaneId, lanes, allShopsMaster]); const assertMsgOk = (res: MessageResponse, fallback: string) => { const msg = res?.message != null ? String(res.message) : ""; if (msg.startsWith("Error:") || msg.startsWith("Error :")) { throw new Error(msg || fallback); } }; const handleLogisticsDropOnCompany = ( targetCompany: string, companyLanes: Lane[], ) => { const dragId = logisticsLaneDragIdRef.current; logisticsLaneDragIdRef.current = null; setLogisticsDropHoverCompany(null); if (!dragId) return; const lane = lanesRef.current.find((l) => l.id === dragId); if (!lane) return; const targetId = getColumnTargetLogisticId(targetCompany, companyLanes); if (targetCompany !== "未分配物流商" && targetId == null) { setError( t("logistic_needMasterTpl", { name: targetCompany }), ); return; } const fromId = lane.logisticId ?? null; if (targetCompany === "未分配物流商") { if (fromId == null) return; } else if (targetId != null && fromId === targetId) { return; } const targetMaster = targetId != null ? logisticRowsEffective.find((r) => Number(r.id) === targetId) : null; const nextLane: Lane = { ...lane, logisticId: targetId, logisticsCompany: targetCompany === "未分配物流商" ? "" : targetMaster?.logisticName?.trim() || targetCompany, plate: targetMaster?.carPlate?.trim() || "", driver: targetMaster?.driverName?.trim() || "", phone: targetMaster?.driverNumber != null && Number.isFinite(Number(targetMaster.driverNumber)) ? String(targetMaster.driverNumber) : "", }; const next = lanesRef.current.map((l) => (l.id === lane.id ? nextLane : l)); lanesNeedingRefreshOnSaveRef.current.add(lane.id); laneDisplayOverlayRef.current.delete(lane.id); lanesRef.current = next; setLanes(next); setSaveResult(null); setError(null); }; const openAddShopDialog = (laneId: string) => { setAddShopLaneId(laneId); setAddShopPick(null); setAddShopDialogOpen(true); }; const closeAddShopDialog = () => { setAddShopDialogOpen(false); setAddShopLaneId(null); setAddShopPick(null); }; const closeDistrictEdit = () => { setDistrictEditOpen(false); setDistrictEditCtx(null); setDistrictEditDraft(""); setDistrictEditError(null); }; const openDistrictAdd = (laneId: string) => { setDistrictEditCtx({ laneId, mode: "add" }); setDistrictEditDraft(""); setDistrictEditError(null); setDistrictEditOpen(true); }; const openDistrictRename = (laneId: string, oldDisplay: string) => { setDistrictEditCtx({ laneId, mode: "rename", oldDisplay }); setDistrictEditDraft(oldDisplay === "未分類" ? "" : oldDisplay); setDistrictEditError(null); setDistrictEditOpen(true); }; const removePendingEmptyDistrict = (laneId: string, display: string) => { setPendingEmptyDistrictsByLane((prev) => { const arr = prev[laneId]; if (!arr?.length) return prev; const nextArr = arr.filter((d) => d !== display); if (nextArr.length === arr.length) return prev; const next = { ...prev }; if (nextArr.length === 0) delete next[laneId]; else next[laneId] = nextArr; return next; }); setSaveResult(null); }; const applyDistrictEdit = () => { if (districtEditSubmitLockRef.current || !districtEditCtx) return; districtEditSubmitLockRef.current = true; setDistrictEditError(null); try { const lane = lanesRef.current.find((l) => l.id === districtEditCtx.laneId); if (!lane) { closeDistrictEdit(); return; } const pendingExtra = pendingEmptyDistrictsByLane[districtEditCtx.laneId] ?? []; const trimmed = districtEditDraft.trim(); if (districtEditCtx.mode === "add") { if (!trimmed) { setDistrictEditError(t("district_err_name")); return; } if (trimmed === "未分類") { setDistrictEditError(t("district_err_reserved")); return; } if (districtDisplayExistsInLane(lane, pendingExtra, trimmed)) { setDistrictEditError(t("district_err_exists")); return; } setPendingEmptyDistrictsByLane((prev) => { const lid = districtEditCtx.laneId; const merged = dedupeDistrictPendingOrder([ ...(prev[lid] ?? []), trimmed, ]); return { ...prev, [lid]: merged }; }); setSaveResult(null); closeDistrictEdit(); return; } const oldDisp = districtEditCtx.oldDisplay; const newDisp = trimmed === "" ? "未分類" : trimmed; if (newDisp === oldDisp) { closeDistrictEdit(); return; } const newRaw = toDistrictRawValue(newDisp); const touchedIds: number[] = []; const next = lanesRef.current.map((l) => { if (l.id !== districtEditCtx.laneId) return l; return { ...l, shops: l.shops.map((s) => { if (toDistrictDisplayName(s.districtReferenceRaw) !== oldDisp) return s; touchedIds.push(s.id); return { ...s, districtReferenceRaw: newRaw }; }), }; }); lanesRef.current = next; setLanes(next); setDirtyMoves((prev) => { const n = new Map(prev); for (const id of touchedIds) { if (id > 0) n.set(id, districtEditCtx.laneId); } return n; }); lanesNeedingRefreshOnSaveRef.current.add(districtEditCtx.laneId); const lid = districtEditCtx.laneId; setPendingEmptyDistrictsByLane((prev) => { const arr = prev[lid]; let pendingList = arr ? [...arr] : []; if (pendingList.includes(oldDisp)) { pendingList = pendingList.map((d) => (d === oldDisp ? newDisp : d)); } const laneNext = next.find((l) => l.id === lid); const withShops = new Set( groupByDistrict(laneNext?.shops ?? []).map((g) => g.district), ); const pruned = dedupeDistrictPendingOrder( pendingList.filter((d) => !withShops.has(d)), ); const out = { ...prev }; if (pruned.length === 0) delete out[lid]; else out[lid] = pruned; return out; }); setSaveResult(null); closeDistrictEdit(); } finally { districtEditSubmitLockRef.current = false; } }; const openAddRouteDialog = () => { setNewRouteForm(emptyNewRouteForm()); setAddRouteError(null); setAddRouteDialogOpen(true); }; const closeAddRouteDialog = () => { setAddRouteDialogOpen(false); setNewRouteForm(emptyNewRouteForm()); setAddRouteError(null); }; const submitAddRoute = () => { const code = String(newRouteForm.truckLanceCode || "").trim(); const storeNorm = normalizeStoreId(newRouteForm.storeId); const remarkRaw = storeNorm === "4F" && String(newRouteForm.remark || "").trim() !== "" ? String(newRouteForm.remark).trim() : null; const laneKey = encodeLaneId(code, remarkRaw); if (!code) { setAddRouteError(t("route_err_code")); return; } const dep = parseTimeForBackend(newRouteForm.startTime || ""); if (!dep) { setAddRouteError(t("route_err_departure")); return; } if ( lanesRef.current.some((l) => l.id === laneKey) || pendingNewLanesRef.current.some((p) => p.laneKey === laneKey) ) { setAddRouteError(t("route_err_duplicate")); return; } if (addRouteInFlightRef.current) return; addRouteInFlightRef.current = true; setAddRouteSubmitting(true); setAddRouteError(null); try { const payload = buildCreateTruckWithoutShopPayload(newRouteForm); const draftLane = buildPendingLaneFromForm(newRouteForm); setPendingNewLanes((prev) => [...prev, { laneKey, payload }]); laneLogisticBaselineRef.current.set( laneKey, newRouteForm.logisticId != null && Number.isFinite(Number(newRouteForm.logisticId)) ? Number(newRouteForm.logisticId) : null, ); setLanes((prev) => { const next = sortLanesByCode([...prev, draftLane]); return applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(next)); }); closeAddRouteDialog(); setSelectedLaneIds((prev) => prev.includes(laneKey) ? prev : [...prev, laneKey], ); requestAnimationFrame(() => { requestAnimationFrame(() => scrollBoardLaneCardIntoView(laneKey)); }); setSaveResult(null); } catch (e: any) { setAddRouteError(e?.message ?? String(e) ?? t("route_err_create")); } finally { setAddRouteSubmitting(false); addRouteInFlightRef.current = false; } }; const submitAddShop = () => { if (!addShopLaneId || !addShopPick) return; if (addShopConfirmLockRef.current) return; const lane = lanes.find((l) => l.id === addShopLaneId); if (!lane) return; addShopConfirmLockRef.current = true; setError(null); try { const flat = flattenDisplayOrder(lane.shops); const last = flat[flat.length - 1]; const newSeq = last != null ? last.loadingSequence : 0; const seq = Number(newSeq) || 0; const storeId = normalizeStoreId(lane.storeId); const remark = storeId === "4F" && lane.remark != null && String(lane.remark).trim() !== "" ? String(lane.remark).trim() : null; const tempId = nextDraftTruckRowIdRef.current; nextDraftTruckRowIdRef.current -= 1; const baseRows = lanesToWarningInputRows(lanes); const hypo = appendSyntheticPendingShopRow( baseRows, { truckLanceCode: lane.truckLanceCode, laneRemark: lane.remark ?? null, storeId: lane.storeId, startTime: lane.startTime, }, addShopPick, tempId, ); const wr = computeTruckLaneWarnings(hypo); const touching = wr.warnings.filter((w) => warningTouchesPickedShop(w, addShopPick), ); if (touching.length > 0) { const ok = window.confirm( t("confirm_addShopConflict", { count: touching.length }), ); if (!ok) return; } const newCard: ShopCard = { id: tempId, shopEntityId: addShopPick.id, branchName: addShopPick.name, shopCode: addShopPick.code, districtReferenceRaw: null, loadingSequence: seq, remark, storeId, departureTime: parseTimeForBackend(lane.startTime), }; setLanes((prev) => prev.map((l) => l.id !== lane.id ? l : { ...l, shops: [...l.shops, newCard] }, ), ); setPendingShopAdds((prev) => [ ...prev, { tempTruckRowId: tempId, laneId: lane.id, shopId: addShopPick.id, shopName: addShopPick.name, shopCode: addShopPick.code, loadingSequence: seq, }, ]); lanesNeedingRefreshOnSaveRef.current.add(lane.id); closeAddShopDialog(); if (wr.warnings.some((w) => warningTouchesPickedShop(w, addShopPick))) { setLaneWarnSnackbar(t("mtmsRouteWarn_postAddConflict")); } } finally { addShopConfirmLockRef.current = false; } }; const handleDeleteTruckRow = async (truckRowId: number) => { if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) { return; } if (truckRowId < 0) { if (!window.confirm(t("confirm_discardDraftShop"))) return; setError(null); setPendingShopAdds((prev) => prev.filter((p) => p.tempTruckRowId !== truckRowId), ); setLanes((prev) => prev.map((lane) => ({ ...lane, shops: lane.shops.filter((s) => s.id !== truckRowId), })), ); setSaveResult(null); return; } if (!window.confirm(t("confirm_removeShop"))) return; setError(null); const affectedLaneId = lanesRef.current.find((l) => l.shops.some((s) => s.id === truckRowId), )?.id; if (affectedLaneId) { lanesNeedingRefreshOnSaveRef.current.add(affectedLaneId); } const srcLane = lanesRef.current.find((l) => l.shops.some((s) => s.id === truckRowId), ); const shop = srcLane?.shops.find((s) => s.id === truckRowId); if (srcLane && shop && truckRowId > 0) { stagedDeleteMetaRef.current.set(truckRowId, { shopCode: String(shop.shopCode ?? "").trim(), branchName: String(shop.branchName ?? "").trim(), fromLane: formatLaneLabel(srcLane.truckLanceCode, srcLane.remark), }); } // delete beats move setDirtyMoves((prev) => { const next = new Map(prev); next.delete(truckRowId); return next; }); setDirtyDeletes((prev) => { const next = new Set(prev); next.add(truckRowId); return next; }); // optimistic UI: remove now; cancel will refetch setLanes((prev) => prev.map((lane) => lane.shops.some((s) => s.id === truckRowId) ? { ...lane, shops: lane.shops.filter((s) => s.id !== truckRowId) } : lane, ), ); setSaveResult(null); }; /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */ const handleClearLaneShops = (lane: Lane) => { if (lane.shops.length === 0) return; if ( lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id)) ) { return; } if ( !window.confirm( t("confirm_clearLane", { laneLabel: `${lane.truckLanceCode}${ lane.remark != null && String(lane.remark).trim() !== "" ? ` · ${lane.remark}` : "" }`, count: lane.shops.length, }), ) ) return; setError(null); const draftIds = new Set( lane.shops.filter((s) => s.id < 0).map((s) => s.id), ); const serverShopIds = lane.shops .filter((s) => s.id > 0) .map((s) => s.id); if (draftIds.size > 0) { setPendingShopAdds((prev) => prev.filter((p) => !draftIds.has(p.tempTruckRowId)), ); } if (serverShopIds.length > 0) { lanesNeedingRefreshOnSaveRef.current.add(lane.id); for (const sid of serverShopIds) { const shop = lane.shops.find((s) => s.id === sid); if (shop && sid > 0) { stagedDeleteMetaRef.current.set(sid, { shopCode: String(shop.shopCode ?? "").trim(), branchName: String(shop.branchName ?? "").trim(), fromLane: formatLaneLabel(lane.truckLanceCode, lane.remark), }); } } setDirtyMoves((prev) => { const next = new Map(prev); for (const id of serverShopIds) next.delete(id); return next; }); setDirtyDeletes((prev) => { const next = new Set(prev); for (const id of serverShopIds) next.add(id); return next; }); } setLanes((prev) => prev.map((l) => (l.id === lane.id ? { ...l, shops: [] } : l)), ); setSaveResult(null); }; const handleDragStart = (shopId: number, fromLaneId: string) => { draggedRef.current = { shopId, fromLaneId }; setDropIndicator(null); }; const clearDragState = () => { draggedRef.current = null; setDropIndicator(null); }; const getBeforeShopIdByPointer = ( laneId: string, clientY: number, ): number | null => { const laneEl = document.querySelector( `[data-lane-id="${CSS.escape(laneId)}"]`, ); if (!laneEl) return null; const cards = Array.from( laneEl.querySelectorAll("[data-shop-id]"), ); for (const cardEl of cards) { const rect = cardEl.getBoundingClientRect(); const midY = rect.top + rect.height / 2; if (clientY < midY) { const idStr = cardEl.getAttribute("data-shop-id"); const id = idStr ? Number(idStr) : NaN; return Number.isFinite(id) ? id : null; } } return null; // append }; const handleDropToLane = (toLaneId: string) => { const before = dropIndicator != null && dropIndicator.laneId === toLaneId ? dropIndicator.beforeShopId : null; handleDropToPosition(toLaneId, before); }; const handleDropToPosition = ( toLaneId: string, beforeShopId: number | null, targetDistrict?: string | null, ) => { const dragged = draggedRef.current; if (!dragged) return; if (dragged.shopId < 0) { setError( t("drag_blockDraftShop"), ); clearDragState(); return; } if (beforeShopId != null && beforeShopId === dragged.shopId) { clearDragState(); return; } const dirtyToAdd: Array<[number, string]> = []; const base = lanesRef.current; const next = base.map((lane) => ({ ...lane, shops: lane.shops.slice() })); const from = next.find((l) => l.id === dragged.fromLaneId); const to = next.find((l) => l.id === toLaneId); if (!from || !to) return; const shop = from.shops.find((s) => s.id === dragged.shopId); if (!shop) return; if (from.id !== to.id && laneTargetConflicts(shop, to)) { setError(t("err_dragDuplicateShop")); clearDragState(); return; } const oldSeqFrom = new Map( from.shops.map((s) => [s.id, s.loadingSequence]), ); const oldSeqTo = new Map( to.shops.map((s) => [s.id, s.loadingSequence]), ); // build display-ordered lists (matches what user sees on screen) const fromFlat = flattenDisplayOrder(from.shops).filter( (s) => s.id !== dragged.shopId, ); const toFlatRaw = flattenDisplayOrder(to.shops); const toFlat = from.id === to.id ? toFlatRaw.filter((s) => s.id !== dragged.shopId) : toFlatRaw.slice(); const beforeShop = beforeShopId != null ? toFlat.find((s) => s.id === beforeShopId) : null; const targetDistrictRaw = targetDistrict !== undefined ? toDistrictRawValue(targetDistrict) : beforeShop ? toDistrictRawValue(beforeShop.districtReferenceRaw) : shop.districtReferenceRaw; // keep fields but update departure/store/remark/district to match target position const moved: ShopCard = { ...shop, districtReferenceRaw: targetDistrictRaw, departureTime: to.startTime, storeId: to.storeId, remark: normalizeStoreId(to.storeId) === "4F" ? to.remark != null && String(to.remark).trim() !== "" ? String(to.remark).trim() : null : null, }; // insert into target by DISPLAY ORDER (beforeShopId if provided, else append) const insertIdx = beforeShopId != null ? toFlat.findIndex((s) => s.id === beforeShopId) : targetDistrict !== undefined ? (() => { const targetDisplay = toDistrictDisplayName(targetDistrictRaw); for (let i = toFlat.length - 1; i >= 0; i -= 1) { if ( toDistrictDisplayName(toFlat[i].districtReferenceRaw) === targetDisplay ) { return i + 1; } } return -1; })() : -1; const inserted = insertIdx >= 0 ? [...toFlat.slice(0, insertIdx), moved, ...toFlat.slice(insertIdx)] : [...toFlat, moved]; // write back lists from.shops = fromFlat; to.shops = inserted; // IMPORTANT: Do NOT renumber all loadingSequence. // loadingSequence can be duplicated intentionally (e.g. 4F grouping). // On drag/drop, only update the moved shop's loadingSequence so it joins the target group. const newSeq = computeMovedLoadingSequence( inserted, moved.id, targetDistrict !== undefined && beforeShopId == null, ); to.shops = to.shops.map((s) => s.id === moved.id ? { ...s, loadingSequence: newSeq } : s, ); if ( from.id !== to.id || toDistrictDisplayName(shop.districtReferenceRaw) !== toDistrictDisplayName(targetDistrictRaw) ) { dirtyToAdd.push([moved.id, to.id]); } // mark dirty for any sequence changes + moved shop from.shops.forEach((s) => { const old = oldSeqFrom.get(s.id); if (old == null || old !== s.loadingSequence) dirtyToAdd.push([s.id, from.id]); }); if (to.id !== from.id) { to.shops.forEach((s) => { const old = oldSeqTo.get(s.id); if (old == null || old !== s.loadingSequence) dirtyToAdd.push([s.id, to.id]); }); } else { // same lane: compare using oldSeqFrom for all shops to.shops.forEach((s) => { const old = oldSeqFrom.get(s.id); if (old == null || old !== s.loadingSequence) dirtyToAdd.push([s.id, to.id]); }); } dirtyToAdd.forEach(([, laneId]) => lanesNeedingRefreshOnSaveRef.current.add(laneId), ); if (from.id !== to.id) { lanesNeedingRefreshOnSaveRef.current.add(from.id); } if (dirtyToAdd.length === 0) { clearDragState(); return; } // Make rapid successive drops in same tick see latest snapshot. setError(null); lanesRef.current = next; setLanes(next); if (dirtyToAdd.length > 0) { setDirtyMoves((prevDirty) => { const nextDirty = new Map(prevDirty); dirtyToAdd.forEach(([shopId, laneId]) => nextDirty.set(shopId, laneId)); return nextDirty; }); } clearDragState(); }; const scheduleLaneOptions = useMemo( () => lanes.map((lane) => ({ id: lane.id, label: formatLaneLabel(lane.truckLanceCode, lane.remark), truckLanceCode: lane.truckLanceCode, remark: lane.remark, storeId: normalizeStoreId(lane.storeId), departureTime: parseTimeForBackend(lane.startTime) || "00:00:00", shops: lane.shops .filter((s) => s.id >= 0) .map((s) => ({ truckRowId: s.id, districtReferenceRaw: s.districtReferenceRaw ?? null, loadingSequence: s.loadingSequence ?? 0, })), })), [lanes], ); const scheduleShopRows = useMemo((): ScheduleShopRow[] => { const rows: ScheduleShopRow[] = []; for (const lane of lanes) { const currentLaneLabel = formatLaneLabel( lane.truckLanceCode, lane.remark, ); for (const shop of lane.shops) { if (shop.id < 0) continue; const codeLower = String(shop.shopCode || "") .trim() .toLowerCase(); const realName = shopNameByCodeMap.get(codeLower); const displayName = realName && String(realName).trim() !== "" ? String(realName).trim() : String(shop.branchName || "").trim() || "-"; const district = toDistrictDisplayName(shop.districtReferenceRaw); const location = [ district, shop.shopCode || "-", shop.storeId ? normalizeStoreId(shop.storeId) : "", ] .filter(Boolean) .join(" · "); rows.push({ truckRowId: shop.id, shopCode: String(shop.shopCode || "").trim() || "-", displayName, branchName: String(shop.branchName || "").trim(), storeId: shop.storeId ? normalizeStoreId(shop.storeId) : "", location, districtDisplay: district, districtReferenceRaw: shop.districtReferenceRaw ?? null, currentLaneId: lane.id, currentLaneLabel, }); } } return rows.sort((a, b) => a.displayName.localeCompare(b.displayName, "zh-Hant"), ); }, [lanes, shopNameByCodeMap]); const handleScheduleConfirmManual = useCallback( async (payload: ScheduleChangePayload) => { const validation = validatePayloadSubmit({ payload, lanes: scheduleLaneOptions, shops: scheduleShopRows, pendingTruckRowIds: pendingScheduleShopIds, }); if (!validation.ok) { setError(formatScheduleValidationErrors(t, validation.errors)); return; } const request = buildRequestFromPayload(payload, scheduleLaneOptions); if (!request || (request.lines?.length ?? 0) === 0) { setError(t("schedule_err_conflict")); return; } try { await createTruckLaneScheduleClient(request); setLaneWarnSnackbar( t("schedule_registered_snackbar", { count: request.lines?.length ?? 0 }), ); } catch (err: unknown) { setError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); throw err; } }, [t, scheduleLaneOptions, scheduleShopRows, pendingScheduleShopIds], ); const prevPendingScheduleSizeRef = useRef(0); useEffect(() => { const n = pendingScheduleShopIds.size; if ( prevPendingScheduleSizeRef.current > 0 && n < prevPendingScheduleSizeRef.current ) { void loadLanes(); } prevPendingScheduleSizeRef.current = n; }, [pendingScheduleShopIds]); const handleDragOver: React.DragEventHandler = (e) => { e.preventDefault(); }; const getLaneLogisticChanges = useCallback((): Array<{ laneId: string; logisticId: number | null; }> => { const changes: Array<{ laneId: string; logisticId: number | null }> = []; for (const lane of lanesRef.current) { const nextId = lane.logisticId != null ? Number(lane.logisticId) : null; const prevId = laneLogisticBaselineRef.current.has(lane.id) ? laneLogisticBaselineRef.current.get(lane.id) ?? null : null; if (prevId !== nextId) changes.push({ laneId: lane.id, logisticId: nextId }); } return changes; }, []); const hasDirtyLaneLogistics = getLaneLogisticChanges().length > 0; const dirtyLaneLogisticIds = new Set( getLaneLogisticChanges().map((c) => c.laneId), ); const hasUnsavedChanges = dirtyMoves.size > 0 || dirtyDeletes.size > 0 || hasDirtyLaneLogistics || pendingShopAdds.length > 0 || pendingNewLanes.length > 0 || pendingLogisticMasterAdds.length > 0 || pendingLogisticMasterEdits.size > 0 || pendingLogisticMasterDeletes.size > 0 || pendingImportMeta != null || pendingRestoreVersionId != null || Object.values(pendingEmptyDistrictsByLane).some( (a) => (a?.length ?? 0) > 0, ); const laneLogisticChangesForStaged = useMemo(() => { const changes: Array<{ laneId: string; logisticId: number | null }> = []; for (const lane of lanes) { const nextId = lane.logisticId != null ? Number(lane.logisticId) : null; const prevId = laneLogisticBaselineRef.current.has(lane.id) ? laneLogisticBaselineRef.current.get(lane.id) ?? null : null; if (prevId !== nextId) changes.push({ laneId: lane.id, logisticId: nextId }); } return changes; }, [lanes]); const stagedDeleteSig = useMemo( () => Array.from(dirtyDeletes.values()) .sort((a, b) => a - b) .join(","), [dirtyDeletes], ); const dirtyMoveDistrictHints = useMemo(() => { const hints = new Map(); dirtyMoves.forEach((laneId, shopId) => { if (!Number.isFinite(shopId) || shopId <= 0) return; const baseDisp = shopDistrictBaselineRef.current.get(shopId); if (baseDisp === undefined) return; const lane = lanes.find((l) => l.id === laneId); const shop = lane?.shops.find((s) => s.id === shopId); if (!shop) return; const curDisp = toDistrictDisplayName(shop.districtReferenceRaw); if (curDisp !== baseDisp) hints.set( shopId, t("diff_staged_shopDistrictHint", { from: baseDisp, to: curDisp, }), ); }); return hints; }, [lanes, dirtyMoves, districtBaselineEpoch, stagedDeleteSig, t]); const stagedLogEntriesView = useMemo( () => buildStagedBoardLogEntries({ pendingRestoreVersionId, dirtyMoves, dirtyMoveDistrictHints, dirtyDeletes, stagedDeleteMeta: stagedDeleteMetaRef.current, pendingShopAdds, pendingNewLanes, pendingLogisticMasterAdds: pendingLogisticMasterAdds.map((p) => ({ tempId: p.tempId, logisticName: p.logisticName, carPlate: p.carPlate, })), pendingLogisticMasterEdits: Array.from( pendingLogisticMasterEdits.entries(), ).map(([id, edit]) => { const prev = logisticRowsFromDb.find((r) => Number(r.id) === id); return { id, logisticName: edit.logisticName, carPlate: edit.carPlate, fromName: prev?.logisticName ?? "", fromPlate: prev?.carPlate ?? "", }; }), pendingLogisticMasterDeletes: Array.from( pendingLogisticMasterDeletes, ).map((id) => { const prev = logisticRowsFromDb.find((r) => Number(r.id) === id); return { id, logisticName: String(prev?.logisticName ?? "").trim() || "—", }; }), pendingImport: pendingImportMeta, laneLogisticChanges: laneLogisticChangesForStaged, lanes, pendingEmptyDistrictsByLane, logisticNameById, shopDistrictBaseline: new Map(shopDistrictBaselineRef.current), shopRowBaseline: new Map(shopRowBaselineRef.current), }), [ pendingRestoreVersionId, dirtyMoves, dirtyMoveDistrictHints, dirtyDeletes, pendingShopAdds, pendingNewLanes, pendingLogisticMasterAdds, pendingLogisticMasterEdits, pendingLogisticMasterDeletes, pendingImportMeta, laneLogisticChangesForStaged, lanes, pendingEmptyDistrictsByLane, logisticNameById, logisticRowsFromDb, stagedDeleteSig, districtBaselineEpoch, ], ); useEffect(() => { if (!hasUnsavedChanges) return; const message = t("nav_unsavedLeave"); const currentUrl = window.location.href; const guardKey = `routeBoard:${Date.now()}:${Math.random()}`; let bypassNavigationGuard = false; let confirmedNavigation = false; let guardReleased = false; const guardState = { ...(window.history.state && typeof window.history.state === "object" && !Array.isArray(window.history.state) ? window.history.state : {}), __routeBoardUnsavedGuard: guardKey, }; const isSameLocation = (url: string | URL | null | undefined) => { if (url == null) return true; const nextUrl = new URL(String(url), window.location.href); const here = new URL(currentUrl); return ( nextUrl.origin === here.origin && nextUrl.pathname === here.pathname && nextUrl.search === here.search ); }; const confirmLeave = () => window.confirm(message); window.history.pushState(guardState, "", currentUrl); const handleBeforeUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = message; return message; }; const handleDocumentClick = (event: MouseEvent) => { if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey ) { return; } const target = event.target as HTMLElement | null; const anchor = target?.closest("a[href]") as HTMLAnchorElement | null; if ( !anchor || anchor.target === "_blank" || anchor.hasAttribute("download") ) return; if (isSameLocation(anchor.href)) return; if (!confirmLeave()) { event.preventDefault(); event.stopPropagation(); return; } releaseGuard(); }; const handlePopState = () => { if (confirmedNavigation) return; if (confirmLeave()) { releaseGuard(); window.history.back(); return; } bypassNavigationGuard = true; window.history.pushState(guardState, "", currentUrl); bypassNavigationGuard = false; }; const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; function releaseGuard() { if (guardReleased) return; guardReleased = true; confirmedNavigation = true; window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("popstate", handlePopState); document.removeEventListener("click", handleDocumentClick, true); window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; } window.history.pushState = function patchedPushState( data: any, unused: string, url?: string | URL | null, ) { if (!bypassNavigationGuard && !isSameLocation(url)) { if (!confirmLeave()) return; releaseGuard(); } return originalPushState.call(window.history, data, unused, url); }; window.history.replaceState = function patchedReplaceState( data: any, unused: string, url?: string | URL | null, ) { if (!bypassNavigationGuard && !isSameLocation(url)) { if (!confirmLeave()) return; releaseGuard(); } return originalReplaceState.call(window.history, data, unused, url); }; window.addEventListener("beforeunload", handleBeforeUnload); window.addEventListener("popstate", handlePopState); document.addEventListener("click", handleDocumentClick, true); return () => { if (guardReleased) return; window.removeEventListener("beforeunload", handleBeforeUnload); window.removeEventListener("popstate", handlePopState); document.removeEventListener("click", handleDocumentClick, true); window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; if ( !confirmedNavigation && window.location.href === currentUrl && window.history.state?.__routeBoardUnsavedGuard === guardKey ) { window.history.back(); } }; }, [hasUnsavedChanges, t]); const handleSave = async () => { if (saveInFlightRef.current) return; saveInFlightRef.current = true; try { const pendingRestoreId = pendingRestoreVersionId != null && Number.isFinite(Number(pendingRestoreVersionId)) && Number(pendingRestoreVersionId) > 0 ? Number(pendingRestoreVersionId) : null; const pendingLogisticMasterAddsSnapshot = [...pendingLogisticMasterAdds]; const pendingLogisticMasterEditsSnapshot = new Map( pendingLogisticMasterEdits, ); const pendingLogisticMasterDeletesSnapshot = new Set( pendingLogisticMasterDeletes, ); const pendingImportFile = pendingImportFileRef.current; let laneLogisticChangesLive = getLaneLogisticChanges(); const pendingSnapshot = [...pendingShopAdds]; const pendingNewLanesSnapshot = [...pendingNewLanesRef.current]; const hasPersistedWork = pendingSnapshot.length > 0 || pendingNewLanesSnapshot.length > 0 || dirtyMoves.size > 0 || dirtyDeletes.size > 0 || laneLogisticChangesLive.length > 0 || pendingLogisticMasterAddsSnapshot.length > 0 || pendingLogisticMasterEditsSnapshot.size > 0 || pendingLogisticMasterDeletesSnapshot.size > 0 || pendingImportFile != null || pendingRestoreId != null; const hasPendingEmptyDistrictsOnly = !hasPersistedWork && Object.values(pendingEmptyDistrictsByLane).some( (a) => (a?.length ?? 0) > 0, ); if (!hasPersistedWork && !hasPendingEmptyDistrictsOnly) { setSaveResult({ ok: true, message: t("No changes") }); return; } if (hasPendingEmptyDistrictsOnly) { setPendingEmptyDistrictsByLane({}); setSaveResult({ ok: true, message: t("save_clearedEmptyDistricts"), }); return; } setSaving(true); setSaveResult(null); if (pendingRestoreId != null) { const hadRestoreWithOtherWork = pendingSnapshot.length > 0 || pendingNewLanesSnapshot.length > 0 || dirtyMoves.size > 0 || dirtyDeletes.size > 0 || laneLogisticChangesLive.length > 0 || pendingLogisticMasterAddsSnapshot.length > 0 || pendingLogisticMasterEditsSnapshot.size > 0 || pendingLogisticMasterDeletesSnapshot.size > 0 || pendingImportFile != null; if (hadRestoreWithOtherWork) { if (!window.confirm(t("confirm_restoreSaveWillDropStaging"))) return; } try { await restoreTruckLaneVersionClient(pendingRestoreId); setPendingRestoreVersionId(null); setDirtyMoves(new Map()); clearDirtyDeletesState(); setPendingShopAdds([]); setPendingNewLanes([]); setPendingLogisticMasterAdds([]); setPendingLogisticMasterEdits(new Map()); setPendingLogisticMasterDeletes(new Set()); pendingImportFileRef.current = null; setPendingImportMeta(null); setPendingEmptyDistrictsByLane({}); closeDistrictEdit(); closeDepartureEdit(); setSeqEditTarget(null); lanesNeedingRefreshOnSaveRef.current.clear(); laneDisplayOverlayRef.current.clear(); await loadLanes(); setSaveResult({ ok: true, message: hadRestoreWithOtherWork ? t("restore_appliedDroppedStaging") : t("restore_applied"), }); } catch (e: any) { console.error("Restore snapshot failed:", e); setSaveResult({ ok: false, message: e?.message ?? String(e) ?? t("diff_restoreFail"), }); } return; } if (pendingLogisticMasterEditsSnapshot.size > 0) { for (const [id, req] of Array.from( pendingLogisticMasterEditsSnapshot.entries(), )) { if (pendingLogisticMasterDeletesSnapshot.has(id)) continue; await saveLogisticClient({ ...req, id }); } setPendingLogisticMasterEdits(new Map()); await reloadLogisticNamesFromDb(); } if (pendingLogisticMasterDeletesSnapshot.size > 0) { for (const id of Array.from(pendingLogisticMasterDeletesSnapshot)) { await deleteLogisticClient(id); } setPendingLogisticMasterDeletes(new Set()); await reloadLogisticNamesFromDb(); } if (pendingLogisticMasterAddsSnapshot.length > 0) { const savedRows = await saveLogisticsBatchCreateClient( pendingLogisticMasterAddsSnapshot.map((p) => ({ logisticName: p.logisticName, carPlate: p.carPlate, driverName: p.driverName, driverNumber: p.driverNumber, })), ); if ( !Array.isArray(savedRows) || savedRows.length !== pendingLogisticMasterAddsSnapshot.length ) { throw new Error( `logistic save-batch: expected ${pendingLogisticMasterAddsSnapshot.length} rows, got ${savedRows?.length ?? 0}`, ); } const byTemp = new Map(); pendingLogisticMasterAddsSnapshot.forEach((p, i) => { byTemp.set(p.tempId, savedRows[i]!); }); const remapped = lanesRef.current.map((lane) => { const lid = lane.logisticId != null ? Number(lane.logisticId) : null; if (lid == null || lid >= 0) return lane; const saved = byTemp.get(lid); if (!saved) return lane; const realId = Number(saved.id); if (!Number.isFinite(realId) || realId <= 0) return lane; return { ...lane, logisticId: realId, logisticsCompany: String(saved.logisticName ?? "").trim(), plate: String(saved.carPlate ?? "").trim(), driver: String(saved.driverName ?? "").trim(), phone: saved.driverNumber != null && Number.isFinite(Number(saved.driverNumber)) ? String(saved.driverNumber) : "", }; }); lanesRef.current = remapped; setLanes(remapped); setPendingLogisticMasterAdds([]); await reloadLogisticNamesFromDb(); laneLogisticChangesLive = getLaneLogisticChanges(); } const laneIdsFromPendingNewLanes = new Set(); if (pendingNewLanesSnapshot.length > 0) { for (const p of pendingNewLanesSnapshot) { const res = await createTruckWithoutShopClient(p.payload); assertMsgOk(res, t("api_fail_createLane")); laneIdsFromPendingNewLanes.add(p.laneKey); } setPendingNewLanes([]); if (laneIdsFromPendingNewLanes.size > 0) { await refreshLanesByIds(Array.from(laneIdsFromPendingNewLanes), { preserveStagedLogistics: true, }); } } const laneIdsTouchedByCreate = new Set(); if (pendingSnapshot.length > 0) { for (const p of pendingSnapshot) { const lane = lanesRef.current.find((l) => l.id === p.laneId); if (!lane) continue; const sid = normalizeStoreId(lane.storeId); const remark = sid === "4F" && lane.remark != null && String(lane.remark).trim() !== "" ? String(lane.remark).trim() : null; const res = await createTruckClient({ store_id: sid, truckLanceCode: lane.truckLanceCode, departureTime: parseTimeForBackend(lane.startTime), shopId: p.shopId, shopName: p.shopName, shopCode: p.shopCode, loadingSequence: p.loadingSequence, districtReference: null, remark, }); assertMsgOk(res, t("api_fail_addShop")); laneIdsTouchedByCreate.add(p.laneId); } setPendingShopAdds([]); setLanes((prev) => stripDraftShopRows(prev)); if (laneIdsTouchedByCreate.size > 0) { await refreshLanesByIds(Array.from(laneIdsTouchedByCreate), { preserveStagedLogistics: true, }); } } // build a map shopId -> lane + shopCard(以 ref 最新列為準,含剛 refresh) const shopById = new Map(); for (const lane of lanesRef.current) { for (const s of lane.shops) { shopById.set(s.id, { lane, shop: s }); } } const updates: SaveTruckLane[] = []; const deletes = Array.from(dirtyDeletes); const deleteSet = new Set(deletes); for (const shopId of Array.from(dirtyMoves.keys())) { if (shopId <= 0) continue; if (deleteSet.has(shopId)) continue; // delete wins const current = shopById.get(shopId); if (!current) continue; const s = current.shop; updates.push({ id: s.id, truckLanceCode: current.lane.truckLanceCode, departureTime: parseTimeForBackend(s.departureTime), loadingSequence: Number(s.loadingSequence ?? 0) || 0, districtReference: s.districtReferenceRaw != null && String(s.districtReferenceRaw).trim() !== "" ? String(s.districtReferenceRaw).trim() : null, storeId: normalizeStoreId(s.storeId), remark: s.remark != null && String(s.remark).trim() !== "" ? String(s.remark) : null, /** 與目標車線桶一致;跨線拖曳時否則後端不會改 truck.logistic(Save 未帶 updateLogistic 時略過) */ logisticId: current.lane.logisticId ?? null, updateLogistic: true, }); } // lane logistic(整桶更新):避免「只改物流商」或「跨線拖曳後 lane 內 logistic 不一致」冇落 DB const laneIdsToUpdateLogistic = new Set([ ...laneLogisticChangesLive.map((c) => c.laneId), ]); const laneLogisticUpdateReqs = Array.from(laneIdsToUpdateLogistic) .map((laneId) => lanesRef.current.find((l) => l.id === laneId)) .filter((l): l is Lane => l != null) .map((lane) => ({ truckLanceCode: lane.truckLanceCode, remark: lane.remark != null && String(lane.remark).trim() !== "" ? String(lane.remark).trim() : null, logisticId: lane.logisticId != null ? Number(lane.logisticId) : null, })); const results = await Promise.allSettled( updates.map(async (u) => { const res = await updateTruckLaneClient(u); assertMsgOk(res, t("api_fail_updateLane")); return res; }), ); const deleteResults = await Promise.allSettled( deletes.map(async (id) => { const res = await deleteTruckLaneClient({ id }); assertMsgOk(res, t("api_fail_deleteShop")); return res; }), ); const logisticResults = await Promise.allSettled( laneLogisticUpdateReqs.map(async (req) => { const res = await updateLaneLogisticClient(req); assertMsgOk(res, t("api_fail_updateLogistics")); return res; }), ); const failedIdx: number[] = []; results.forEach((r, idx) => { if (r.status === "rejected") failedIdx.push(idx); }); const failedDeleteIds = new Set(); deleteResults.forEach((r, idx) => { if (r.status === "rejected") failedDeleteIds.add(deletes[idx]); }); const failedLaneLogistics = new Set(); logisticResults.forEach((r, idx) => { if (r.status !== "rejected") return; const lane = lanesRef.current.find( (l) => l.truckLanceCode === laneLogisticUpdateReqs[idx]?.truckLanceCode && String(l.remark ?? "").trim() === String(laneLogisticUpdateReqs[idx]?.remark ?? "").trim(), ); if (lane) failedLaneLogistics.add(lane.id); }); if ( failedIdx.length === 0 && failedDeleteIds.size === 0 && failedLaneLogistics.size === 0 ) { const hadPendingImport = pendingImportFile != null; if (hadPendingImport) { const importFd = new FormData(); importFd.append("multipartFileList", pendingImportFile); const importRes = await importRouteLanesExcelClient(importFd); assertMsgOk(importRes, t("err_import")); pendingImportFileRef.current = null; setPendingImportMeta(null); } const dm = dirtyMoves; const laneIdsToRefresh = new Set( lanesNeedingRefreshOnSaveRef.current, ); lanesNeedingRefreshOnSaveRef.current.clear(); for (const lid of Array.from(dm.values())) laneIdsToRefresh.add(lid); for (const lid of Array.from(laneIdsToUpdateLogistic)) laneIdsToRefresh.add(lid); setDirtyMoves(new Map()); clearDirtyDeletesState(); setPendingEmptyDistrictsByLane({}); for (const c of laneLogisticChangesLive) { laneLogisticBaselineRef.current.set(c.laneId, c.logisticId); } if (hadPendingImport) { await loadLanes(); } else if (laneIdsToRefresh.size > 0) { await refreshLanesByIds(Array.from(laneIdsToRefresh), { preserveStagedLogistics: false, }); } try { await createTruckLaneSnapshotClient({ truckLanceCode: null, note: "board save", }); await refreshLogVersions(); } catch (snapErr: any) { console.warn("Auto snapshot after board save failed:", snapErr); } setSaveResult({ ok: true, message: t("Saved") }); return; } const failedIds = new Set(failedIdx.map((i) => updates[i].id)); setDirtyMoves((prev) => { const next = new Map(); prev.forEach((laneId, shopId) => { if (failedIds.has(shopId)) next.set(shopId, laneId); }); return next; }); setDirtyDeletes((prev) => { const next = new Set(); prev.forEach((id) => { if (failedDeleteIds.has(id)) next.add(id); }); return next; }); const firstReason = (results[failedIdx[0]] as PromiseRejectedResult) ?.reason as any; const reasonText = firstReason?.message ?? (firstReason != null ? String(firstReason) : ""); setSaveResult({ ok: false, message: `Saved ${updates.length - failedIdx.length}, Failed ${ failedIdx.length }, Deleted ${deletes.length - failedDeleteIds.size}, DeleteFailed ${ failedDeleteIds.size }, LaneLogisticFailed ${failedLaneLogistics.size}${ reasonText ? `: ${reasonText}` : "" }`, }); } catch (e: any) { console.error("Save failed:", e); setSaveResult({ ok: false, message: e?.message ?? String(e) ?? t("Failed to save"), }); } finally { setSaving(false); saveInFlightRef.current = false; } }; const handleCancel = async () => { const pendingLaneKeys = new Set( pendingNewLanesRef.current.map((p) => p.laneKey), ); setPendingShopAdds([]); setPendingNewLanes([]); setPendingLogisticMasterAdds([]); setPendingLogisticMasterEdits(new Map()); setPendingLogisticMasterDeletes(new Set()); pendingImportFileRef.current = null; setPendingImportMeta(null); pendingLaneKeys.forEach((id) => laneLogisticBaselineRef.current.delete(id), ); const snapshot = stripDraftShopRows(lanesRef.current).filter( (l) => !pendingLaneKeys.has(l.id), ); const dm = dirtyMoves; const laneIdsToRefresh = new Set( lanesNeedingRefreshOnSaveRef.current, ); lanesNeedingRefreshOnSaveRef.current.clear(); dm.forEach((lid) => laneIdsToRefresh.add(lid)); snapshot.forEach((lane) => { if (lane.shops.some((s) => dm.has(s.id))) laneIdsToRefresh.add(lane.id); const currentLogisticId = lane.logisticId != null ? Number(lane.logisticId) : null; const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id) ? laneLogisticBaselineRef.current.get(lane.id) ?? null : null; if (currentLogisticId !== baselineLogisticId) laneIdsToRefresh.add(lane.id); }); const restoredLogistics = snapshot.map((lane) => { const currentLogisticId = lane.logisticId != null ? Number(lane.logisticId) : null; const baselineLogisticId = laneLogisticBaselineRef.current.has(lane.id) ? laneLogisticBaselineRef.current.get(lane.id) ?? null : null; if (currentLogisticId === baselineLogisticId) return lane; const master = baselineLogisticId != null ? logisticRowsFromDb.find((r) => Number(r.id) === baselineLogisticId) : null; return { ...lane, logisticId: baselineLogisticId, logisticsCompany: baselineLogisticId == null ? "" : master?.logisticName?.trim() || logisticNameById.get(baselineLogisticId) || "", plate: master?.carPlate?.trim() || "", driver: master?.driverName?.trim() || "", phone: master?.driverNumber != null && Number.isFinite(Number(master.driverNumber)) ? String(master.driverNumber) : "", }; }); lanesRef.current = restoredLogistics; setLanes(restoredLogistics); setDirtyMoves(new Map()); clearDirtyDeletesState(); setPendingEmptyDistrictsByLane({}); closeDistrictEdit(); setSaveResult(null); setPendingRestoreVersionId(null); if (laneIdsToRefresh.size > 0) { await refreshLanesByIds(Array.from(laneIdsToRefresh), { preserveStagedLogistics: false, }); } else { await loadLanes(); } }; const closeDepartureEdit = () => { setDepartureEditLaneId(null); }; const openDepartureEdit = (lane: Lane) => { setDepartureEditDraft(toTimeInputValue(lane.startTime)); setDepartureEditLaneId(lane.id); }; const applyDepartureEdit = () => { if (!departureEditLaneId) return; const backendTime = parseTimeForBackend(departureEditDraft); const targetLane = lanesRef.current.find( (l) => l.id === departureEditLaneId, ); const shopIds = targetLane?.shops.map((s) => s.id) ?? []; if (shopIds.length === 0) { closeDepartureEdit(); return; } const nextLanes = lanesRef.current.map((lane) => lane.id !== departureEditLaneId ? lane : { ...lane, startTime: backendTime, shops: lane.shops.map((s) => ({ ...s, departureTime: backendTime, })), }, ); const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes)); if (wr.warnings.length > 0) { const ok = window.confirm( t("confirm_departureConflict", { count: wr.warnings.length }), ); if (!ok) return; } setLanes(nextLanes); setDirtyMoves((prev) => { const next = new Map(prev); for (const id of shopIds) { if (id > 0) next.set(id, departureEditLaneId); } return next; }); setSaveResult(null); closeDepartureEdit(); }; const closeSeqEdit = () => { setSeqEditTarget(null); }; const openSeqEdit = (lane: Lane, shop: ShopCard) => { setSeqEditDraft(String(shop.loadingSequence ?? 0)); setSeqEditTarget({ laneId: lane.id, shopId: shop.id }); }; const applySeqEdit = () => { if (!seqEditTarget) return; const n = Number(seqEditDraft); const seq = Number.isFinite(n) ? Math.trunc(n) : 0; setLanes((prev) => prev.map((lane) => lane.id !== seqEditTarget.laneId ? lane : { ...lane, shops: lane.shops.map((s) => s.id === seqEditTarget.shopId ? { ...s, loadingSequence: seq } : s, ), }, ), ); setDirtyMoves((prev) => { const next = new Map(prev); next.set(seqEditTarget.shopId, seqEditTarget.laneId); return next; }); setSaveResult(null); closeSeqEdit(); }; const normalizeChangedLines = (diff: any) => { const lines = (diff?.changed || []) as any[]; return lines .map((line: any) => { const truckRowId = Number(line?.truckRowId); const shopCode = line?.shopCode != null ? String(line.shopCode) : null; const changes = Array.isArray(line?.changes) ? (line.changes as any[]).map((c) => ({ field: c?.field != null ? String(c.field) : "", from: c?.from != null ? String(c.from) : null, to: c?.to != null ? String(c.to) : null, })) : []; return { truckRowId, shopCode, changes: changes.filter((c) => c.field), truckLanceCode: line?.truckLanceCode != null ? String(line.truckLanceCode) : null, remark: line?.remark != null ? String(line.remark) : null, }; }) .filter((x) => Number.isFinite(x.truckRowId) && x.truckRowId > 0); }; const loadVersionDiff = async (versionId: number, list: any[]) => { const ticket = ++versionDiffReqSeq.current; setDiffLoading(true); setDiffError(null); try { const idx = list.findIndex((v) => Number(v?.id) === versionId); const olderId = idx >= 0 && idx < list.length - 1 ? Number(list[idx + 1]?.id) : null; if (olderId == null || !Number.isFinite(olderId) || olderId <= 0) { if (versionDiffReqSeq.current === ticket) { setDiffLines([]); setLogisticMasterDiffLines([]); setChangedShopIds(new Set()); } return; } const diff = await diffTruckLaneVersionsClient(olderId, versionId); if (versionDiffReqSeq.current !== ticket) return; const normalized = normalizeChangedLines(diff); const logisticMaster = Array.isArray(diff?.logisticMasterChanges) ? (diff.logisticMasterChanges as LogisticMasterDiffLine[]) : []; if (versionDiffReqSeq.current !== ticket) return; const ids = new Set(); normalized.forEach((line) => { const id = Number(line?.truckRowId); if (Number.isFinite(id) && id > 0) ids.add(id); }); setChangedShopIds(ids); setDiffLines(normalized); setLogisticMasterDiffLines(logisticMaster); } catch (e: any) { if (versionDiffReqSeq.current === ticket) { setDiffError(e?.message ?? String(e) ?? t("diff_loadFail")); setChangedShopIds(new Set()); setDiffLines([]); setLogisticMasterDiffLines([]); } } finally { if (versionDiffReqSeq.current === ticket) setDiffLoading(false); } }; const refreshLogVersions = useCallback(async () => { try { const list = await listTruckLaneVersionsClient(); const arr = Array.isArray(list) ? list : []; setLogVersions(arr); return arr; } catch (e) { console.warn("Failed to load truck lane versions:", e); return []; } }, []); useEffect(() => { void refreshLogVersions(); }, [refreshLogVersions]); const openLogDialog = async () => { setLogDialogOpen(true); setDiffError(null); setChangedShopIds(new Set()); setSelectedLogVersionId(null); setDiffLines([]); setLoadingVersions(true); try { const arr = await refreshLogVersions(); const head = resolveHeadVersionId(arr); setSelectedLogVersionId(head); if (head != null) { await loadVersionDiff(head, arr); } } finally { setLoadingVersions(false); } }; const closeLogDialog = () => { versionDiffReqSeq.current += 1; setLogDialogOpen(false); setDiffError(null); setChangedShopIds(new Set()); setSelectedLogVersionId(null); setDiffLines([]); setLogisticMasterDiffLines([]); setVersionNoteDrafts({}); setVersionNoteSaveError(null); setSavingVersionNoteId(null); }; const saveVersionNote = useCallback( async (id: number, serverNote: string, currentValue: string) => { const trimmed = currentValue.trim(); const prev = (serverNote ?? "").trim(); if (trimmed === prev) { setVersionNoteDrafts((p) => { if (p[id] === undefined) return p; const n = { ...p }; delete n[id]; return n; }); return; } setVersionNoteSaveError(null); setSavingVersionNoteId(id); try { const res = await updateTruckLaneVersionNoteClient(id, { note: trimmed === "" ? null : trimmed, }); setLogVersions((list) => list.map((x) => Number(x?.id) === id ? { ...x, note: res.note } : x, ), ); setVersionNoteDrafts((p) => { const n = { ...p }; delete n[id]; return n; }); } catch (e: any) { setVersionNoteSaveError({ id, message: e?.message ?? String(e) ?? t("versionNote_saveFail"), }); } finally { setSavingVersionNoteId(null); } }, [t], ); const restoreVersion = (versionId: number) => { if (!Number.isFinite(versionId) || versionId <= 0) return; if (pendingRestoreVersionId === versionId) { setSaveResult({ ok: true, message: t("diff_restoreAlreadyPending") }); closeLogDialog(); return; } const hasOtherPending = dirtyMoves.size > 0 || dirtyDeletes.size > 0 || pendingShopAdds.length > 0 || pendingNewLanes.length > 0 || pendingLogisticMasterAdds.length > 0 || pendingLogisticMasterEdits.size > 0 || pendingLogisticMasterDeletes.size > 0 || pendingImportMeta != null || getLaneLogisticChanges().length > 0 || Object.values(pendingEmptyDistrictsByLane).some( (a) => (a?.length ?? 0) > 0, ); if (hasOtherPending && !window.confirm(t("confirm_restoreDiscardsEdits"))) return; if (!hasOtherPending && pendingRestoreVersionId != null) { laneDisplayOverlayRef.current.clear(); setPendingRestoreVersionId(versionId); setSaveResult({ ok: true, message: t("diff_restoreScheduled", { versionId }), }); closeLogDialog(); return; } laneDisplayOverlayRef.current.clear(); const pendingLaneKeys = new Set( pendingNewLanesRef.current.map((p) => p.laneKey), ); pendingLaneKeys.forEach((id) => laneLogisticBaselineRef.current.delete(id)); setPendingNewLanes([]); setLanes((prev) => { const next = stripDraftShopRows(prev).filter( (l) => !pendingLaneKeys.has(l.id), ); lanesRef.current = next; return next; }); setPendingShopAdds([]); setDirtyMoves(new Map()); clearDirtyDeletesState(); setPendingEmptyDistrictsByLane({}); closeDistrictEdit(); closeDepartureEdit(); setSeqEditTarget(null); lanesNeedingRefreshOnSaveRef.current.clear(); for (const lane of lanesRef.current) { laneLogisticBaselineRef.current.set( lane.id, lane.logisticId != null ? Number(lane.logisticId) : null, ); } setPendingRestoreVersionId(versionId); setSaveResult({ ok: true, message: t("diff_restoreScheduled", { versionId }), }); closeLogDialog(); }; const handleExportVersionLogReportExcel = useCallback(async () => { if (routeExcelExportLockRef.current) return; if (hasUnsavedChanges) { setDiffError(t("diff_export_blockedError")); return; } routeExcelExportLockRef.current = true; if (selectedLogVersionId == null) { routeExcelExportLockRef.current = false; return; } const idx = logVersions.findIndex( (v) => Number(v?.id) === Number(selectedLogVersionId), ); const olderId = idx >= 0 && idx < logVersions.length - 1 ? Number(logVersions[idx + 1]?.id) : null; if (olderId == null || !Number.isFinite(olderId) || olderId <= 0) { setDiffError(t("diff_noOlderCompare")); routeExcelExportLockRef.current = false; return; } setRouteExcelBusy(true); setDiffError(null); try { const { base64, filename } = await exportTruckLaneVersionReportExcelClient( olderId, selectedLogVersionId, ); downloadBase64Xlsx(base64, filename); } catch (e: any) { setDiffError(e?.message ?? String(e) ?? t("err_export")); } finally { setRouteExcelBusy(false); routeExcelExportLockRef.current = false; } }, [hasUnsavedChanges, logVersions, selectedLogVersionId, t]); const renderLaneHeader = (lane: Lane) => { const isDirty = lane.shops.some((s) => dirtyMoves.has(s.id)); return ( {lane.truckLanceCode} {lane.remark != null && String(lane.remark).trim() !== "" && ( )} {isDirty && ( {t("Changed")} )} {lane.logisticsCompany || t("Logistic")} {lane.driver ? lane.driver : t("Driver")} {lane.phone || "—"} {lane.plate || t("Plate")} {t("Departure")}: {lane.startTime || "-"} { e.stopPropagation(); openDepartureEdit(lane); }} disabled={loading || lane.shops.length === 0} aria-label={t("departureEditAria")} > {t("Shops")}: {lane.shops.length} ); }; return ( {/* Header (match your reference code structure) */} {t("pageTitle")} {t("Current version")}: {displayedVersionLabel} 0 ? t("schedule_log_failed_hint", { count: failedScheduleCount }) : "" } > 0 ? t("mtmsRouteWarn_tooltipHas", { count: laneWarnCount }) : t("mtmsRouteWarn_tooltipNone") } > setLaneWarnDrawerOpen(true)} aria-label={t("mtmsRouteWarn_title")} sx={{ color: laneWarnCount > 0 ? "warning.main" : "text.secondary", }} > {saveResult && ( {saveResult.message} )} setLaneWarnDrawerOpen(false)} PaperProps={{ sx: { width: { xs: "100%", sm: 440 }, p: 0, display: "flex", flexDirection: "column", maxHeight: "100vh", }, }} > {t("mtmsRouteWarn_title")} setLaneWarnDrawerOpen(false)} > {laneWarningsMemo.weekdayParseFailures.length > 0 && ( {t("mtmsRouteWarn_parseHint", { count: laneWarningsMemo.weekdayParseFailures.length, })} )} {laneWarningsMemo.warnings.length === 0 ? ( {t("mtmsRouteWarn_empty")} ) : ( laneWarningsMemo.warnings.map((w, i) => { const shopHeadline = [w.shopCode, w.shopDisplayName] .map((s) => String(s ?? "").trim()) .filter(Boolean) .join(" "); const expanded = laneWarnExpandedIdx === i; return ( selectLanesFromWarning(w)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectLanesFromWarning(w); } }} sx={{ flex: 1, minWidth: 0, cursor: "pointer" }} > {t("mtmsRouteWarn_shop")}: {shopHeadline || "—"} {formatWarningSummary(w, t)} { e.stopPropagation(); setLaneWarnExpandedIdx((prev) => prev === i ? null : i, ); }} > {w.lanes.map((L) => ( {L.truckLanceCode} {L.laneRemark ? ` · ${L.laneRemark}` : ""} {formatLaneWarningDetail(L, t)} ))} ); }) )} setLaneWarnSnackbar(null)} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} > setLaneWarnSnackbar(null)} severity="warning" variant="filled" sx={{ width: "100%" }} > {laneWarnSnackbar} {scheduleModalOpen ? ( setScheduleModalOpen(false)} lanes={scheduleLaneOptions} shops={scheduleShopRows} allShopsMaster={allShopsMaster} pendingTruckRowIds={pendingScheduleShopIds} onConfirmManual={handleScheduleConfirmManual} onAfterScheduleChange={async () => { await refreshScheduleIndicators(); }} /> ) : null} {scheduleHistoryOpen ? ( setScheduleHistoryOpen(false)} lanes={scheduleLaneOptions} onAfterChange={async () => { await refreshScheduleIndicators(); await loadLanes(); }} /> ) : null} {t("versionLogDialogTitle")} {/* 左:版本列表 */} {t("version_ui_historyTitle")} {!loadingVersions && logVersions.length > 0 && ( {filteredLogVersions.length}/{logVersions.length} )} setVersionFilterAnchor(e.currentTarget)} disabled={loadingVersions || logVersions.length === 0} aria-label={t("version_ui_filterAria")} > {loadingVersions ? ( ) : ( {filteredLogVersions.map((v) => { const id = Number(v?.id); const created = String(v?.created || ""); const { date, time } = splitVersionCreated(created); const note = v?.note != null ? String(v.note) : ""; const isSel = selectedLogVersionId === id; const isHead = headVersionId != null && id === headVersionId; return ( { setSelectedLogVersionId(id); void loadVersionDiff(id, logVersions); }} sx={{ p: 2, mb: 1.5, flexDirection: "column", alignItems: "stretch", border: 2, borderColor: isSel ? "primary.main" : "transparent", bgcolor: "background.paper", borderRadius: 2, boxShadow: isSel ? 2 : 1, outline: isSel ? "4px solid" : "none", outlineColor: isSel ? "primary.light" : "transparent", transition: "box-shadow 0.15s, border-color 0.15s", "&.Mui-selected": { bgcolor: "background.paper", }, "&:hover": { borderColor: isSel ? "primary.main" : "divider", }, }} > {date} {time ? ` ${time}` : ""} {isHead && ( {t("version_ui_snapshotBadge")} )} {t("version_ui_id", { id })} {(() => { const actor = resolveVersionActor(v ?? {}); return actor ? ( {t("version_ui_editedBy", { name: actor, })} ) : null; })()} e.stopPropagation()} onClick={(e) => e.stopPropagation()} sx={{ mb: 1 }} > setVersionNoteDrafts((p) => ({ ...p, [id]: e.target.value, })) } onBlur={(e) => { void saveVersionNote( id, note, e.target.value, ); }} inputProps={{ maxLength: 500 }} helperText={ savingVersionNoteId === id ? t("version_note_saving") : versionNoteSaveError?.id === id ? versionNoteSaveError.message : "" } FormHelperTextProps={{ sx: versionNoteSaveError?.id === id ? { mt: 0.25, color: "error.main" } : { mt: 0.25 }, }} onFocus={() => setVersionNoteSaveError(null)} sx={{ "& .MuiInputBase-input": { fontSize: "0.8125rem", fontStyle: "italic", }, }} /> ); })} {!loadingVersions && logVersions.length > 0 && filteredLogVersions.length === 0 && ( {t("version_empty_filtered")} )} {logVersions.length === 0 && ( {t("version_empty_list")} )} )} setVersionFilterAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "right" }} transformOrigin={{ vertical: "top", horizontal: "right" }} > setVersionFilterQuery(e.target.value)} autoFocus /> setVersionFilterDate(e.target.value)} InputLabelProps={{ shrink: true }} fullWidth /> {/* 右:異動詳情 */} {diffError && ( {diffError} )} {selectedLogVersionId == null && !loadingVersions && ( {t("diff_clickLeft")} )} {selectedLogVersionId != null && ( <> {(() => { const idx = logVersions.findIndex( (v) => Number(v?.id) === selectedLogVersionId, ); const hasOlder = idx >= 0 && idx < logVersions.length - 1; const sel = logVersions[idx]; const note = sel?.note != null ? String(sel.note) : ""; const editor = sel ? resolveVersionActor(sel) : null; return ( <> {!hasOlder && !diffLoading && ( {t("diff_oldestSnapshot")} )} alpha(theme.palette.primary.main, 0.08), borderColor: "primary.light", flexShrink: 0, }} > {t("diff_summary_title")} {editor != null && ( {t("version_ui_editedBy", { name: editor })} )} {note || "—"} {t("diff_summary_added")} {diffLoading ? t("diff_loadingEllipsis") : versionRowSummary.added} {t("diff_summary_moved")} {diffLoading ? t("diff_loadingEllipsis") : versionRowSummary.moved} {t("diff_summary_deleted")} {diffLoading ? t("diff_loadingEllipsis") : versionRowSummary.deleted} {t("diff_summary_fieldChange")} {diffLoading ? t("diff_loadingEllipsis") : versionRowSummary.fieldChanges} {hasOlder && stagedLogEntriesView.length > 0 && ( {t("diff_staged_boardPendingLine", { count: stagedLogEntriesView.length, })} )} {t("diff_shopList_title")} {changedShopIds.size > 0 && ( {t("diff_markedCount", { count: changedShopIds.size, })} )} {stagedLogEntriesView.length > 0 && ( <> {t("diff_staged_section_title")} {t("diff_staged_section_subtitle")} {stagedLogEntriesView.map((entry) => { if (entry.kind === "restore") { return ( {t("diff_staged_restoreScheduled", { versionId: entry.versionId, })} ); } if (entry.kind === "text") { return ( {t( entry.titleKey as | "diff_staged_deleteUnknown" | "diff_staged_newLane" | "diff_staged_laneLogistic" | "diff_staged_emptyDistricts" | "diff_staged_shopPendingOnLane" | "diff_staged_shopDistrictOnly" | "diff_staged_pendingLogisticMaster" | "diff_staged_editLogisticMaster" | "diff_staged_deleteLogisticMaster" | "diff_staged_importPending", entry.titleParams as Record< string, string | number >, )} ); } const row = entry.row; const { headline, detail } = resolveVersionLogShopHeadline( row, shopNameByCodeMap, ); return ( {headline} {detail != null && detail !== "" && ( {detail} )} {row.shopCode || "—"} ); })} )} {diffLoading && ( )} {!diffLoading && ( <> {logisticMasterDiffLines.length > 0 && ( <> {t("diff_logisticMaster_section")} {logisticMasterDiffLines.map((lm) => ( {lm.changeText || `${lm.logisticName}(${lm.carPlate})`} ))} )} {hasOlder && versionShopRows.length === 0 && logisticMasterDiffLines.length === 0 && stagedLogEntriesView.length === 0 && !diffError && ( {t("diff_noDiffFromPrev")} )} {hasOlder && versionShopRows.length === 0 && logisticMasterDiffLines.length === 0 && stagedLogEntriesView.length > 0 && !diffError && ( {t("diff_noShopDiffHasBoardStaged")} )} {versionShopRows.map((row, ri) => { const { headline, detail } = resolveVersionLogShopHeadline( row, shopNameByCodeMap, ); const laneLabelForFields = resolveVersionLogLaneLabel(row); return ( {headline} {detail != null && detail !== "" && ( {detail} )} {row.shopCode || "—"} {row.type === "moved" && ( <> {t("diff_moveFrom", { lane: row.fromLane ?? t("emDash"), })} {t("diff_moveTo", { lane: row.toLane ?? t("emDash"), })} )} {row.type === "added" && ( {t("diff_addedToLane", { lane: row.toLane ?? t("emDash"), })} )} {row.type === "deleted" && ( {t("diff_removedFromLane", { lane: row.fromLane ?? t("emDash"), })} )} {row.fieldEdits != null && row.fieldEdits.length > 0 && ( {row.fieldEdits.map( (fe, fei) => { const isLogistic = fe.label === "versionLogField_logisticId"; const resolveLogisticDisplay = (raw: string) => { const s = String( raw ?? "", ).trim(); if ( s === "" || s === "—" || s === "未分配" || s === "未分配物流商" ) return t( "diffLogistic_unassigned", ); const n = Number(s); if ( Number.isFinite(n) ) { return ( logisticNameById.get( n, ) ?? s ); } return s; }; const from = isLogistic ? resolveLogisticDisplay( fe.from, ) : fe.from; const to = isLogistic ? resolveLogisticDisplay( fe.to, ) : fe.to; const isLoadingSeq = fe.label === VERSION_LOG_LOADING_SEQUENCE_LABEL || fe.label === "loadingSequence"; const showLaneOnSeq = isLoadingSeq && laneLabelForFields != null && laneLabelForFields !== ""; return ( {formatDiffFieldLabel( fe.label, t, )} {showLaneOnSeq && ( {" "} ( {t( "diff_onLane", { lane: laneLabelForFields, }, )} ) )} {":"} {from} → {to} ); }, )} )} {row.type === "edited" && (!row.fieldEdits || row.fieldEdits.length === 0) && ( {t("diff_editedCaption")} )} ); })} )} ); })()} )} {t("addShop_dialogTitle")}{" "} {(() => { const lane = addShopLaneId ? lanes.find((l) => l.id === addShopLaneId) : null; if (!lane) return ""; return `「${lane.truckLanceCode}${ lane.remark != null && String(lane.remark).trim() !== "" ? ` · ${lane.remark}` : "" }」`; })()} `${o.name} (${o.code})`} isOptionEqualToValue={(a, b) => a.id === b.id} value={addShopPick} onChange={(_e, v) => setAddShopPick(v)} renderInput={(params) => ( )} noOptionsText={ allShopsMaster.length === 0 ? t("shop_autocomplete_loading") : t("shop_autocomplete_noOptions") } /> {districtEditCtx?.mode === "add" ? t("district_dialog_add") : t("district_dialog_edit")} { setDistrictEditDraft(e.target.value); setDistrictEditError(null); }} error={Boolean(districtEditError)} InputLabelProps={{ shrink: true }} /> {t("departureDialog_title")} setDepartureEditDraft(e.target.value)} InputLabelProps={{ shrink: true }} sx={{ mt: 1 }} /> {t("departureDialog_hint")} {t("seqDialog_title")} setSeqEditDraft(e.target.value)} inputProps={{ step: 1 }} sx={{ mt: 1 }} /> { if (!addRouteSubmitting) closeAddRouteDialog(); }} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3 } }} > {t("addRoute_dialogTitle")} {addRouteError && ( {addRouteError} )} setNewRouteForm((p) => ({ ...p, truckLanceCode: e.target.value, })) } sx={{ "& .MuiInputBase-input": { fontWeight: 800, color: "primary.main", }, }} InputProps={{ startAdornment: ( ), }} /> setNewRouteForm((p) => ({ ...p, startTime: e.target.value })) } InputLabelProps={{ shrink: true }} InputProps={{ startAdornment: ( ), }} /> {t("route_new_logistic_label")} {t("route_new_store_label")} {newRouteForm.storeId === "4F" && ( setNewRouteForm((p) => ({ ...p, remark: e.target.value })) } /> )} { if (!addLogisticSubmitting) setAddLogisticOpen(false); }} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3 } }} > {t("dialog_addLogisticsTitle")} {addLogisticError && ( {addLogisticError} )} setAddLogisticForm((p) => ({ ...p, logisticName: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> setAddLogisticForm((p) => ({ ...p, carPlate: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> setAddLogisticForm((p) => ({ ...p, driverName: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> setAddLogisticForm((p) => ({ ...p, driverPhone: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> { if (!editLogisticSubmitting) { setEditLogisticOpen(false); setEditLogisticError(null); } }} maxWidth="sm" fullWidth PaperProps={{ sx: { borderRadius: 3 } }} > {t("dialog_editLogisticsTitle")} {editLogisticError && ( {editLogisticError} )} setEditLogisticForm((p) => ({ ...p, logisticName: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> setEditLogisticForm((p) => ({ ...p, carPlate: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> setEditLogisticForm((p) => ({ ...p, driverName: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> setEditLogisticForm((p) => ({ ...p, driverPhone: e.target.value, })) } InputProps={{ startAdornment: ( ), }} /> {/* Sidebar */} {(() => { const laneIds = visibleLaneOptions.map((l) => l.id); const total = laneIds.length; const selectedVisible = laneIds.filter((id) => selectedLaneIds.includes(id), ).length; const filterActive = laneFilter.floor !== "all" || String(laneFilter.query || "").trim() !== ""; return ( {t("lane_selectTitle")} setLaneFilterAnchor(null)} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} > {t("floor_label")} {routeBoardTab === "board" && ( )} ); })()} {routeBoardTab === "logistics" && ( {t("quickIndex")} {lanesByLogisticsCompany.map(([company, list]) => { const logisticMaster = resolveLogisticMasterRow( company, list, logisticRowsEffective, ); const canManage = company !== "未分配物流商" && logisticMaster != null; const masterId = logisticMaster != null ? Number(logisticMaster.id) : null; const isPendingDelete = masterId != null && Number.isFinite(masterId) && masterId > 0 && pendingLogisticMasterDeletes.has(masterId); const canDelete = canManage && list.length === 0 && !isPendingDelete; return ( {company} {canManage && ( openEditLogistic(logisticMaster) } sx={{ width: 28, height: 28, p: 0, }} > 0 ? t("err_logisticDeleteHasLanes", { count: list.length, }) : t("tooltip_deleteLogistics") } > stageDeleteLogistic( logisticMaster, company, ) } sx={{ width: 28, height: 28, p: 0, }} > )} {isPendingDelete && ( {t("diff_staged_tag_unsaved")} ·{" "} {t("diff_staged_deleteLogisticMaster", { name: company, })} )} ); })} {lanesByLogisticsCompany.length === 0 && ( {t("logistics_sidebarEmpty")} )} )} {routeBoardTab !== "logistics" && ( <> {t("tools_title")} setSearchTerm(e.target.value)} InputProps={{ startAdornment: ( ), sx: { alignItems: "center", }, }} sx={{ "& .MuiInputBase-root": { alignItems: "center", }, "& .MuiInputBase-input": { textAlign: "left", py: 0.75, }, "& .MuiInputBase-input::placeholder": { opacity: 1, color: "text.disabled", textAlign: "left", }, }} /> )} {/* Board */} {error && ( {error} )} {loading ? ( ) : routeBoardTab === "logistics" ? ( {t("logistics_overviewTitle")} {lanesByLogisticsCompany.map(([company, companyLanes]) => { const colStats = summarizeLogisticsColumnStats(companyLanes); const columnHasDirtyLogistics = companyLanes.some((lane) => dirtyLaneLogisticIds.has(lane.id), ); const logisticMaster = resolveLogisticMasterRow( company, companyLanes, logisticRowsEffective, ); return ( { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (logisticsLaneDragIdRef.current) { setLogisticsDropHoverCompany(company); } }} onDrop={(e) => { e.preventDefault(); handleLogisticsDropOnCompany(company, companyLanes); }} sx={{ borderRadius: 3, overflow: "hidden", display: "flex", flexDirection: "column", transition: "border-color 0.15s, outline-color 0.15s", borderColor: columnHasDirtyLogistics ? "warning.main" : "divider", outline: logisticsDropHoverCompany === company ? "2px solid" : "1px solid transparent", outlineColor: logisticsDropHoverCompany === company ? "primary.main" : "transparent", }} > {company} {columnHasDirtyLogistics && ( )} {logisticMaster && ( {String( logisticMaster.driverName ?? "", ).trim() || "—"} {logisticMaster.driverNumber != null && Number.isFinite(logisticMaster.driverNumber) ? String(logisticMaster.driverNumber) : "—"} {String( logisticMaster.carPlate ?? "", ).trim() || "—"} )} {companyLanes.map((lane) => { const logisticDirty = dirtyLaneLogisticIds.has( lane.id, ); return ( { logisticsLaneDragIdRef.current = lane.id; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData( "application/x-fpsms-lane-id", lane.id, ); }} onDragEnd={() => { logisticsLaneDragIdRef.current = null; setLogisticsDropHoverCompany(null); }} variant="outlined" sx={{ p: 1.5, borderRadius: 2, display: "flex", flexDirection: "column", gap: 1, bgcolor: logisticDirty ? "warning.50" : "grey.50", borderColor: logisticDirty ? "warning.main" : "divider", boxShadow: logisticDirty ? "0 0 0 2px rgba(245, 124, 0, 0.18)" : "none", minHeight: 88, cursor: "grab", overflow: "hidden", "&:hover": { bgcolor: logisticDirty ? "warning.50" : "action.hover", borderColor: logisticDirty ? "warning.dark" : "primary.light", }, }} > {lane.truckLanceCode} {lane.remark != null && String(lane.remark).trim() !== "" && ( ·{lane.remark} )} {logisticDirty && ( )} {(lane.driver || lane.plate) && ( {!!lane.driver && ( {lane.driver} )} {lane.plate && ( )} )} { setRouteBoardTab("board"); setSelectedLaneIds([lane.id]); }} aria-label={t("aria_openLaneBoard")} > {lane.startTime || "—"} {t("lane_shopCountInline", { count: lane.shops.length, })} ); })} ); })} ) : ( {filteredLanes .filter((lane) => lanesMatchingFloorOnly.some((v) => v.id === lane.id), ) .map((lane) => { const districtSections = buildLaneDistrictSections( lane.shops, pendingEmptyDistrictsByLane[lane.id], ); return ( { handleDragOver(e); if (!draggedRef.current) return; const beforeShopId = getBeforeShopIdByPointer( lane.id, e.clientY, ); setDropIndicator((prev) => { if ( prev?.laneId === lane.id && prev.beforeShopId === beforeShopId ) return prev; return { laneId: lane.id, beforeShopId }; }); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleDropToLane(lane.id); }} sx={{ width: 360, flexShrink: 0, borderTop: "4px solid", borderTopColor: "primary.main", overflow: "hidden", }} > {renderLaneHeader(lane)} {districtSections.map( ({ district, shops, isPendingEmpty }) => ( { e.preventDefault(); if (!draggedRef.current) return; setDropIndicator({ laneId: lane.id, beforeShopId: null, }); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleDropToPosition(lane.id, null, district); }} sx={{ mb: 2 }} > {district} {shops.length} e.stopPropagation()} onClick={(e) => { e.stopPropagation(); openDistrictRename(lane.id, district); }} disabled={loading} aria-label={t("aria_editDistrict")} > {isPendingEmpty && ( e.stopPropagation()} onClick={(e) => { e.stopPropagation(); removePendingEmptyDistrict( lane.id, district, ); }} disabled={loading} aria-label={t("aria_removeEmptyDistrict")} > )} {shops.map((shop) => { const changed = dirtyMoves.has(shop.id); const isScheduledMove = shop.id > 0 && scheduledShopIdSet.has(shop.id); const isScheduledLater = shop.id > 0 && !isScheduledMove && pendingScheduleShopIds.has(shop.id); const isFailedScheduledMove = shop.id > 0 && failedScheduleShopIds.has(shop.id); const showInsertLine = dropIndicator != null && dropIndicator.laneId === lane.id && dropIndicator.beforeShopId === shop.id; return ( 0 && !isScheduledMove} onDragStart={() => handleDragStart(shop.id, lane.id) } onDragEnd={() => clearDragState()} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); if (draggedRef.current) setDropIndicator({ laneId: lane.id, beforeShopId: shop.id, }); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleDropToPosition( lane.id, shop.id, district, ); }} sx={{ cursor: shop.id > 0 && !isScheduledMove ? "grab" : "default", opacity: isScheduledMove ? 0.55 : 1, borderColor: changed ? "warning.main" : shop.id < 0 ? "warning.light" : changedShopIds.has(shop.id) ? "info.main" : "divider", bgcolor: isScheduledMove ? "action.hover" : changed ? "warning.50" : shop.id < 0 ? "grey.100" : changedShopIds.has(shop.id) ? "info.50" : "background.paper", "&:active": shop.id > 0 ? { cursor: "grabbing" } : undefined, position: "relative", }} > {isFailedScheduledMove && ( )} {isScheduledMove && !isFailedScheduledMove && ( )} {isScheduledLater && !isFailedScheduledMove && ( )} {showInsertLine && ( )} {(() => { const codeLower = String( shop.shopCode || "", ) .trim() .toLowerCase(); const realName = shopNameByCodeMap.get( codeLower, ); return realName && String(realName).trim() !== "" ? realName : shop.branchName || "-"; })()} {formatShopCardSubtitle(shop)} Seq: {shop.loadingSequence ?? "-"} e.stopPropagation() } onClick={(e) => { e.stopPropagation(); openSeqEdit(lane, shop); }} disabled={loading} aria-label={t("aria_editSeq")} > e.stopPropagation() } onClick={(e) => { e.stopPropagation(); void handleDeleteTruckRow( shop.id, ); }} disabled={ loading || dirtyDeletes.has(shop.id) || isScheduledMove } > ); })} ), )} {dropIndicator != null && dropIndicator.laneId === lane.id && dropIndicator.beforeShopId == null && lane.shops.length > 0 && ( )} {districtSections.length === 0 && ( {t("empty_lane_noShops")} )} s.id > 0 && scheduledShopIdSet.has(s.id), ) ? t("schedule_shop_locked") : t("tooltip_clearLaneShops") } > handleClearLaneShops(lane)} disabled={ loading || lane.shops.length === 0 || lane.shops.some( (s) => s.id > 0 && scheduledShopIdSet.has(s.id), ) } > ); })} setBoardQuickPickAnchorEl(e.currentTarget)} disabled={loading || lanesMatchingFloorOnly.length === 0} > { setBoardQuickPickAnchorEl(null); setBoardQuickPickSearch(""); }} anchorOrigin={{ vertical: "center", horizontal: "right" }} transformOrigin={{ vertical: "center", horizontal: "left" }} slotProps={{ paper: { sx: { width: "min(100vw - 32px, 360px)", maxHeight: 420, overflow: "hidden", display: "flex", flexDirection: "column", }, }, }} > setBoardQuickPickSearch(e.target.value)} sx={{ "& .MuiOutlinedInput-root": { borderRadius: 2, bgcolor: "grey.50", }, "& .MuiInputBase-input": { textAlign: "left", fontSize: "0.8125rem", color: "text.secondary", py: 0.75, }, "& .MuiInputBase-input::placeholder": { opacity: 1, color: "text.disabled", }, }} InputProps={{ startAdornment: ( ), }} inputProps={{ "aria-label": t("aria_searchLanes") }} onKeyDown={(e) => e.stopPropagation()} /> {boardQuickPickFilteredLanes.length === 0 ? ( {lanesMatchingFloorOnly.length === 0 ? t("quickPick_noLanes") : t("quickPick_noKeyword")} ) : ( boardQuickPickFilteredLanes.map((lane) => { const rem = lane.remark != null && String(lane.remark).trim() !== "" ? String(lane.remark).trim() : null; const picked = selectedLaneIds.includes(lane.id); return ( applyBoardQuickPickLane(lane.id)} sx={{ alignItems: "flex-start", py: 1 }} > ); }) )} )} ); }; export default RouteBoard;