diff --git a/src/app/(main)/MainContentArea.tsx b/src/app/(main)/MainContentArea.tsx index 2439426..682889b 100644 --- a/src/app/(main)/MainContentArea.tsx +++ b/src/app/(main)/MainContentArea.tsx @@ -3,7 +3,7 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import { usePathname } from "next/navigation"; -import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute"; +import { isFullBleedMainRoute } from "@/app/(main)/isFullBleedMainRoute"; import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900"; @@ -28,7 +28,7 @@ export default function MainContentArea({ }) { const pathname = usePathname(); /** True when the active route is PO Workbench (full-bleed main area). */ - const fullBleedWorkbench = isPoWorkbenchRoute(pathname); + const fullBleedWorkbench = isFullBleedMainRoute(pathname); return ( + + }> + + + + + ); +} + diff --git a/src/app/api/logistic/actions.ts b/src/app/api/logistic/actions.ts new file mode 100644 index 0000000..3121c7c --- /dev/null +++ b/src/app/api/logistic/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { serverFetchJson } from "../../utils/fetchUtil"; +import { BASE_API_URL } from "../../../config/api"; + +export interface LogisticRow { + id: number; + logisticName: string; + carPlate: string; + driverName: string; + driverNumber: number; +} + +export type SaveLogisticRequest = { + id?: number | null; + logisticName: string; + carPlate: string; + driverName: string; + driverNumber: number; +}; + +export const findAllLogisticsAction = async (): Promise => { + const endpoint = `${BASE_API_URL}/logistic/all`; + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}; + +export const saveLogisticAction = async ( + data: SaveLogisticRequest, +): Promise => { + const endpoint = `${BASE_API_URL}/logistic/save`; + return serverFetchJson(endpoint, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; + +export const saveLogisticsBatchCreateAction = async ( + items: Omit[], +): Promise => { + const endpoint = `${BASE_API_URL}/logistic/save-batch`; + return serverFetchJson(endpoint, { + method: "POST", + body: JSON.stringify({ items }), + headers: { "Content-Type": "application/json" }, + }); +}; diff --git a/src/app/api/logistic/client.ts b/src/app/api/logistic/client.ts new file mode 100644 index 0000000..1f4f9ac --- /dev/null +++ b/src/app/api/logistic/client.ts @@ -0,0 +1,27 @@ +"use client"; + +import { + findAllLogisticsAction, + saveLogisticAction, + saveLogisticsBatchCreateAction, + type LogisticRow, + type SaveLogisticRequest, +} from "./actions"; + +export type { LogisticRow, SaveLogisticRequest }; + +export const findAllLogisticsClient = async (): Promise => { + return await findAllLogisticsAction(); +}; + +export const saveLogisticsBatchCreateClient = async ( + items: Omit[], +): Promise => { + return await saveLogisticsBatchCreateAction(items); +}; + +export const saveLogisticClient = async ( + data: SaveLogisticRequest, +): Promise => { + return await saveLogisticAction(data); +}; diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index 4585eef..7fae0b3 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -3,8 +3,10 @@ // import { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; // import { BASE_API_URL } from "@/config/api"; import { + serverFetch, serverFetchJson, serverFetchWithNoContent, + ServerFetchError, } from "../../utils/fetchUtil"; import { BASE_API_URL } from "../../../config/api"; import { revalidateTag } from "next/cache"; @@ -58,6 +60,16 @@ export interface SaveTruckLane { districtReference: string | null; storeId: string; remark?: string | null; + logisticId?: number | null; + /** When true, set truck.logistic to logisticId (null clears). When false/omit, do not change logistic. */ + updateLogistic?: boolean; +} + +/** POST /truck/updateLaneLogistic — 同線桶內 truck 列一次更新 logistic(單一 transaction) */ +export interface UpdateLaneLogisticRequest { + truckLanceCode: string; + remark?: string | null; + logisticId?: number | null; } export interface DeleteTruckLane { @@ -84,6 +96,7 @@ export interface SaveTruckRequest { loadingSequence: number; districtReference?: string | null; remark?: string | null; + logisticId?: number | null; } export interface CreateTruckWithoutShopRequest { @@ -92,6 +105,7 @@ export interface CreateTruckWithoutShopRequest { departureTime: string; loadingSequence?: number; districtReference?: string | null; + logisticId?: number | null; remark?: string | null; } @@ -141,6 +155,17 @@ export const updateTruckLaneAction = async (data: SaveTruckLane) => { }); }; +export const updateLaneLogisticAction = async ( + data: UpdateLaneLogisticRequest, +): Promise => { + const endpoint = `${BASE_API_URL}/truck/updateLaneLogistic`; + return serverFetchJson(endpoint, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; + export const deleteTruckLaneAction = async (data: DeleteTruckLane) => { const endpoint = `${BASE_API_URL}/truck/deleteTruckLane`; @@ -170,6 +195,15 @@ export const findAllUniqueTruckLaneCombinationsAction = cache(async () => { }); }); +/** O(1) 取整個 RouteBoard 所需 truck rows;前端自行按 (truckLanceCode, remark) 分桶。 */ +export const findAllForRouteBoardAction = cache(async () => { + const endpoint = `${BASE_API_URL}/truck/findAllForRouteBoard`; + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + export const findAllShopsByTruckLanceCodeAndRemarkAction = cache(async (truckLanceCode: string, remark: string) => { const endpoint = `${BASE_API_URL}/truck/findAllFromShopAndTruckByTruckLanceCodeAndRemarkAndDeletedFalse`; const url = `${endpoint}?truckLanceCode=${encodeURIComponent(truckLanceCode)}&remark=${encodeURIComponent(remark)}`; @@ -200,6 +234,22 @@ export const findAllByTruckLanceCodeAndDeletedFalseAction = cache(async (truckLa }); }); +/** 與 `findAllUniqueTruckLanceCodeAndRemarkCombinations` 同一 (code, remark) 桶;remark 空則不帶參數。 */ +export const findAllByTruckLanceCodeAndRemarkAndDeletedFalseAction = cache( + async (truckLanceCode: string, remark: string | null | undefined) => { + const endpoint = `${BASE_API_URL}/truck/findAllByTruckLanceCodeAndRemarkAndDeletedFalse`; + const params = new URLSearchParams(); + params.set("truckLanceCode", truckLanceCode); + const r = remark != null && String(remark).trim() !== "" ? String(remark).trim() : ""; + if (r !== "") params.set("remark", r); + const url = `${endpoint}?${params.toString()}`; + return serverFetchJson(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + }, +); + export const updateTruckShopDetailsAction = async (data: UpdateTruckShopDetailsRequest) => { const endpoint = `${BASE_API_URL}/truck/updateTruckShopDetails`; @@ -220,6 +270,180 @@ export const createTruckWithoutShopAction = async (data: CreateTruckWithoutShopR }); }; +/** PDF 圖1:每車線一個 worksheet(MTMS_ROUTE_V1)。回傳 base64 方便 client 下載。 */ +export const exportRouteLanesExcelAction = async ( + laneIds: string[], +): Promise<{ base64: string; filename: string }> => { + const response = await serverFetch(`${BASE_API_URL}/truck/exportRouteLanesExcel`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + body: JSON.stringify({ laneIds }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new ServerFetchError( + `Export failed: ${response.status} ${text}`.trim(), + response, + ); + } + const cd = response.headers.get("content-disposition") ?? ""; + let filename = `MTMS_車線_${Date.now()}.xlsx`; + const quoted = /filename="([^"]+)"/i.exec(cd); + const star = /filename\*=UTF-8''([^;\s]+)/i.exec(cd); + const raw = (star?.[1] || quoted?.[1])?.trim(); + if (raw) { + try { + filename = decodeURIComponent(raw); + } catch { + filename = raw; + } + } + const buf = await response.arrayBuffer(); + return { + base64: Buffer.from(buf).toString("base64"), + filename, + }; +}; + +/** 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊)。 */ +export const exportRouteReportExcelAction = async ( + laneIds: string[], +): Promise<{ base64: string; filename: string }> => { + const response = await serverFetch(`${BASE_API_URL}/truck/exportRouteReportExcel`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + body: JSON.stringify({ laneIds }), + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new ServerFetchError( + `Export failed: ${response.status} ${text}`.trim(), + response, + ); + } + const cd = response.headers.get("content-disposition") ?? ""; + let filename = `車線Report_${Date.now()}.xlsx`; + const quoted = /filename="([^"]+)"/i.exec(cd); + const star = /filename\*=UTF-8''([^;\s]+)/i.exec(cd); + const raw = (star?.[1] || quoted?.[1])?.trim(); + if (raw) { + try { + filename = decodeURIComponent(raw); + } catch { + filename = raw; + } + } + const buf = await response.arrayBuffer(); + return { + base64: Buffer.from(buf).toString("base64"), + filename, + }; +}; + +export const exportTruckLaneVersionReportExcelAction = async ( + fromVersionId: number, + toVersionId: number, +): Promise<{ base64: string; filename: string }> => { + const response = await serverFetch( + `${BASE_API_URL}/truck/exportTruckLaneVersionReportExcel`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + body: JSON.stringify({ fromVersionId, toVersionId }), + }, + ); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new ServerFetchError( + `Export failed: ${response.status} ${text}`.trim(), + response, + ); + } + const cd = response.headers.get("content-disposition") ?? ""; + let filename = `車線版本報告_${Date.now()}.xlsx`; + const quoted = /filename="([^"]+)"/i.exec(cd); + const star = /filename\*=UTF-8''([^;\s]+)/i.exec(cd); + const raw = (star?.[1] || quoted?.[1])?.trim(); + if (raw) { + try { + filename = decodeURIComponent(raw); + } catch { + filename = raw; + } + } + const buf = await response.arrayBuffer(); + return { + base64: Buffer.from(buf).toString("base64"), + filename, + }; +}; + +export const importRouteLanesExcelAction = async ( + formData: FormData, +): Promise => { + const response = await serverFetch(`${BASE_API_URL}/truck/importRouteLanesExcel`, { + method: "POST", + body: formData, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new ServerFetchError( + `Import failed: ${response.status} ${text}`.trim(), + response, + ); + } + return (await response.json()) as MessageResponse; +}; + +export type RouteLaneImportPreviewRow = { + truckRowId: number | null; + truckLanceCode: string; + remark: string | null; + storeId: string; + departureTime: string; + shopId: number; + shopName: string; + shopCode: string; + loadingSequence: number; + districtReference: string | null; + logisticId: number | null; +}; + +export type ParseRouteLanesExcelResponse = { + sheetCount: number; + rowCount: number; + rows: RouteLaneImportPreviewRow[]; +}; + +export const parseRouteLanesExcelAction = async ( + formData: FormData, +): Promise => { + const response = await serverFetch(`${BASE_API_URL}/truck/parseRouteLanesExcel`, { + method: "POST", + body: formData, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new ServerFetchError( + `Parse import failed: ${response.status} ${text}`.trim(), + response, + ); + } + return (await response.json()) as ParseRouteLanesExcelResponse; +}; + export const findAllUniqueShopNamesAndCodesFromTrucksAction = cache(async () => { const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`; @@ -254,4 +478,125 @@ export const findAllUniqueShopNamesFromTrucksAction = cache(async () => { method: "GET", headers: { "Content-Type": "application/json" }, }); -}); \ No newline at end of file +}); + +// ---- Truck lane version snapshot (DB snapshot) ---- + +export interface CreateTruckLaneSnapshotRequest { + truckLanceCode?: string | null; + note?: string | null; +} + +export interface TruckLaneVersionResponse { + id: number; + truckLanceCode: string; + note: string | null; + created: string | null; + /** truck_lane_version.modifiedBy(BaseEntity) */ + modifiedBy?: string | null; +} + +export interface TruckLaneVersionLineResponse { + truckRowId: number; + truckLanceCode: string | null; + shopCode: string | null; + branchName: string | null; + districtReference: string | null; + loadingSequence: number | null; + departureTime: string | null; + storeId: string; + remark: string | null; + logisticId: number | null; +} + +export type DiffFieldChange = { + field: string; + from: string | null; + to: string | null; +}; + +export type TruckLaneVersionDiffLine = { + truckRowId: number; + shopCode: string | null; + changes: DiffFieldChange[]; +}; + +export type LogisticMasterDiffLine = { + logisticId: number; + type: string; + logisticName: string; + carPlate: string; + changeText: string; +}; + +export type TruckLaneVersionDiffResponse = { + fromVersionId: number; + toVersionId: number; + changed: TruckLaneVersionDiffLine[]; + logisticMasterChanges?: LogisticMasterDiffLine[]; +}; + +export const createTruckLaneSnapshotAction = async (data: CreateTruckLaneSnapshotRequest) => { + const endpoint = `${BASE_API_URL}/truckLaneVersion/snapshot`; + return serverFetchJson(endpoint, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; + +export const listTruckLaneVersionsAction = cache(async (truckLanceCode?: string | null) => { + const endpoint = `${BASE_API_URL}/truckLaneVersion`; + const url = + truckLanceCode != null && String(truckLanceCode).trim() !== "" + ? `${endpoint}?truckLanceCode=${encodeURIComponent(String(truckLanceCode))}` + : endpoint; + return serverFetchJson(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const getTruckLaneVersionLinesAction = cache(async (versionId: number) => { + const endpoint = `${BASE_API_URL}/truckLaneVersion/${versionId}/lines`; + return serverFetchJson(endpoint, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}); + +export const diffTruckLaneVersionsAction = async ( + fromVersionId: number, + toVersionId: number, +) => { + const endpoint = `${BASE_API_URL}/truckLaneVersion/diff`; + const url = `${endpoint}?fromVersionId=${encodeURIComponent(String(fromVersionId))}&toVersionId=${encodeURIComponent(String(toVersionId))}`; + return serverFetchJson(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); +}; + +export const restoreTruckLaneVersionAction = async (versionId: number) => { + const endpoint = `${BASE_API_URL}/truckLaneVersion/${versionId}/restore`; + return serverFetchJson(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); +}; + +export type UpdateTruckLaneVersionNoteRequest = { + note: string | null; +}; + +export const updateTruckLaneVersionNoteAction = async ( + versionId: number, + data: UpdateTruckLaneVersionNoteRequest, +) => { + const endpoint = `${BASE_API_URL}/truckLaneVersion/${versionId}/note`; + return serverFetchJson(endpoint, { + method: "PATCH", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; \ No newline at end of file diff --git a/src/app/api/shop/client.ts b/src/app/api/shop/client.ts index 5b9fa87..d86dcf0 100644 --- a/src/app/api/shop/client.ts +++ b/src/app/api/shop/client.ts @@ -4,9 +4,18 @@ import { fetchAllShopsAction, findTruckLaneByShopIdAction, updateTruckLaneAction, + updateLaneLogisticAction, deleteTruckLaneAction, createTruckAction, findAllUniqueTruckLaneCombinationsAction, + findAllForRouteBoardAction, + exportRouteLanesExcelAction, + exportRouteReportExcelAction, + exportTruckLaneVersionReportExcelAction, + importRouteLanesExcelAction, + parseRouteLanesExcelAction, + type ParseRouteLanesExcelResponse, + type RouteLaneImportPreviewRow, findAllShopsByTruckLanceCodeAndRemarkAction, findAllShopsByTruckLanceCodeAction, createTruckWithoutShopAction, @@ -15,8 +24,21 @@ import { findAllUniqueRemarksFromTrucksAction, findAllUniqueShopCodesFromTrucksAction, findAllUniqueShopNamesFromTrucksAction, + createTruckLaneSnapshotAction, + listTruckLaneVersionsAction, + getTruckLaneVersionLinesAction, + diffTruckLaneVersionsAction, + restoreTruckLaneVersionAction, + updateTruckLaneVersionNoteAction, + type CreateTruckLaneSnapshotRequest, + type UpdateTruckLaneVersionNoteRequest, + type TruckLaneVersionResponse, + type TruckLaneVersionLineResponse, + type TruckLaneVersionDiffResponse, findAllByTruckLanceCodeAndDeletedFalseAction, + findAllByTruckLanceCodeAndRemarkAndDeletedFalseAction, type SaveTruckLane, + type UpdateLaneLogisticRequest, type DeleteTruckLane, type SaveTruckRequest, type UpdateTruckShopDetailsRequest, @@ -36,6 +58,12 @@ export const updateTruckLaneClient = async (data: SaveTruckLane): Promise => { + return await updateLaneLogisticAction(data); +}; + export const deleteTruckLaneClient = async (data: DeleteTruckLane): Promise => { return await deleteTruckLaneAction(data); }; @@ -48,6 +76,35 @@ export const findAllUniqueTruckLaneCombinationsClient = async () => { return await findAllUniqueTruckLaneCombinationsAction(); }; +export const findAllForRouteBoardClient = async () => { + return await findAllForRouteBoardAction(); +}; + +export const exportRouteLanesExcelClient = async (laneIds: string[]) => { + return await exportRouteLanesExcelAction(laneIds); +}; + +export const exportRouteReportExcelClient = async (laneIds: string[]) => { + return await exportRouteReportExcelAction(laneIds); +}; + +export const exportTruckLaneVersionReportExcelClient = async ( + fromVersionId: number, + toVersionId: number, +) => { + return await exportTruckLaneVersionReportExcelAction(fromVersionId, toVersionId); +}; + +export const importRouteLanesExcelClient = async (formData: FormData) => { + return await importRouteLanesExcelAction(formData); +}; + +export const parseRouteLanesExcelClient = async (formData: FormData) => { + return await parseRouteLanesExcelAction(formData); +}; + +export type { ParseRouteLanesExcelResponse, RouteLaneImportPreviewRow }; + export const findAllShopsByTruckLanceCodeAndRemarkClient = async (truckLanceCode: string, remark: string) => { return await findAllShopsByTruckLanceCodeAndRemarkAction(truckLanceCode, remark); }; @@ -60,6 +117,13 @@ export const findAllByTruckLanceCodeAndDeletedFalseClient = async (truckLanceCod return await findAllByTruckLanceCodeAndDeletedFalseAction(truckLanceCode); }; +export const findAllByTruckLanceCodeAndRemarkAndDeletedFalseClient = async ( + truckLanceCode: string, + remark: string | null | undefined, +) => { + return await findAllByTruckLanceCodeAndRemarkAndDeletedFalseAction(truckLanceCode, remark); +}; + export const updateTruckShopDetailsClient = async (data: UpdateTruckShopDetailsRequest): Promise => { return await updateTruckShopDetailsAction(data); }; @@ -84,4 +148,40 @@ export const findAllUniqueShopNamesFromTrucksClient = async () => { return await findAllUniqueShopNamesFromTrucksAction(); }; +export const createTruckLaneSnapshotClient = async ( + data: CreateTruckLaneSnapshotRequest, +): Promise => { + return await createTruckLaneSnapshotAction(data); +}; + +export const listTruckLaneVersionsClient = async ( + truckLanceCode?: string | null, +): Promise => { + return await listTruckLaneVersionsAction(truckLanceCode); +}; + +export const getTruckLaneVersionLinesClient = async ( + versionId: number, +): Promise => { + return await getTruckLaneVersionLinesAction(versionId); +}; + +export const diffTruckLaneVersionsClient = async ( + fromVersionId: number, + toVersionId: number, +): Promise => { + return await diffTruckLaneVersionsAction(fromVersionId, toVersionId); +}; + +export const restoreTruckLaneVersionClient = async (versionId: number): Promise => { + return await restoreTruckLaneVersionAction(versionId); +}; + +export const updateTruckLaneVersionNoteClient = async ( + versionId: number, + data: UpdateTruckLaneVersionNoteRequest, +): Promise => { + return await updateTruckLaneVersionNoteAction(versionId, data); +}; + export default fetchAllShopsClient; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 4d856cd..df23a13 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -27,6 +27,7 @@ const pathToLabelMap: { [path: string]: string } = { "/settings/equipment": "Equipment", "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", "/settings/shop": "ShopAndTruck", + "/settings/shop/board": "Route Board", "/settings/shop/detail": "Shop Detail", "/settings/shop/truckdetail": "Truck Lane Detail", "/settings/printer": "Printer", diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx new file mode 100644 index 0000000..546f4f5 --- /dev/null +++ b/src/components/Shop/RouteBoard.tsx @@ -0,0 +1,7608 @@ +"use client"; + +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Alert, + Box, + Badge, + Button, + 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 { + 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, + updateTruckLaneVersionNoteClient, + updateTruckLaneClient, + updateLaneLogisticClient, + createTruckClient, + createTruckWithoutShopClient, + deleteTruckLaneClient, +} from "@/app/api/shop/client"; +import { + 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, + resolveVersionLogShopHeadline, + splitVersionCreated, + summarizeVersionRows, + 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"; + +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 + ); +} + +const groupByDistrict = (shops: ShopCard[]) => { + const map = new Map(); + for (const s of shops) { + const key = toDistrictDisplayName(s.districtReferenceRaw); + const arr = map.get(key) ?? []; + arr.push(s); + map.set(key, arr); + } + // stable order: 未分類 always first, then district ASC, then loadingSequence ASC (duplicates allowed) + return Array.from(map.entries()) + .sort(([a], [b]) => { + if (a === "未分類") return -1; + if (b === "未分類") return 1; + return a.localeCompare(b, "zh-Hant"); + }) + .map(([district, list]) => ({ + district, + shops: list + .slice() + .sort((x, y) => (x.loadingSequence ?? 0) - (y.loadingSequence ?? 0)), + })); +}; + +const flattenDisplayOrder = (shops: ShopCard[]): ShopCard[] => { + return groupByDistrict(shops).flatMap((g) => g.shops); +}; + +const toDistrictDisplayName = (district: string | null | undefined): string => { + const value = String(district ?? "").trim(); + return value === "" ? "未分類" : value; +}; + +const toDistrictRawValue = ( + district: string | null | undefined, +): string | null => { + const value = String(district ?? "").trim(); + return value === "" || value === "未分類" ? null : value; +}; + +/** + * 地區區塊標題編輯語意(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<"routeboard">, +): 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<"routeboard">, +): 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<"routeboard">, +): 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<"routeboard">): string { + if (label === "物流公司") return tr("diffField_logisticsCompany"); + return 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); +} + +/** 合併增量刷新後的車線(維持排序;追加後端新出現的 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)); + const merged = [...replaced, ...additions]; + merged.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", + ); + }); + return merged; +} + +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("routeboard"); + 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 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 = v?.modifiedBy != null ? String(v.modifiedBy).trim() : ""; + 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 [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 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.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, 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( + mergeRefreshedLanes(prev, mergedRefreshed), + ), + ), + 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, + ); + }) + .filter((l) => l.shops.length > 0); + + loaded.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", + ); + }); + + const nextBoard = filterStagedDeletedShops( + applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(loaded)), + 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()); + 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 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 || + 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()); + 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 n of logisticNamesFromDb) { + const s = String(n ?? "").trim(); + if (s) set.add(s); + } + return Array.from(set).sort((a, b) => a.localeCompare(b, "zh-Hant")); + }, [pendingLogisticMasterAdds, logisticNamesFromDb]); + + /** 依左欄篩選後的車線,再依物流公司名稱(含 overlay)分組;併入 GET /logistic/all 有、但尚未掛車線的公司 */ + const lanesByLogisticsCompany = useMemo(() => { + const map = new Map(); + for (const lane of visibleLaneOptions) { + 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; + }, [visibleLaneOptions, logisticNamesEffective]); + + 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 = [...prev, draftLane]; + next.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", + ); + }); + return applyLaneDisplayOverlays(enrichLanesWithLogisticMaster(next)); + }); + closeAddRouteDialog(); + setSelectedLaneIds((prev) => + prev.includes(laneKey) ? prev : [...prev, 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) { + 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 ( + !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 computeMovedLoadingSequence = ( + inserted: ShopCard[], + movedId: number, + preferPrevious = false, + ): number => { + const idx = inserted.findIndex((s) => s.id === movedId); + if (idx < 0) return 0; + if (preferPrevious) { + const prev = inserted[idx - 1]; + if (prev && typeof prev.loadingSequence === "number") + return prev.loadingSequence; + } + // If inserted before some shop, join that shop's sequence group. + const next = inserted[idx + 1]; + if (next && typeof next.loadingSequence === "number") + return next.loadingSequence; + // Otherwise append: keep current group (if any), else use max existing. + const prev = inserted[idx - 1]; + if (prev && typeof prev.loadingSequence === "number") + return prev.loadingSequence; + const max = inserted.reduce( + (m, s) => Math.max(m, Number(s.loadingSequence ?? 0) || 0), + 0, + ); + return max; + }; + + 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 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 || + 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 ?? "", + }; + }), + 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, + 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 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 || + 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 || + 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()); + 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(), + )) { + await saveLogisticClient({ ...req, id }); + } + setPendingLogisticMasterEdits(new Map()); + 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", + }); + } 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()); + 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), + }; + }) + .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 openLogDialog = async () => { + setLogDialogOpen(true); + setDiffError(null); + setChangedShopIds(new Set()); + setSelectedLogVersionId(null); + setDiffLines([]); + + setLoadingVersions(true); + try { + const list = await listTruckLaneVersionsClient(); + const arr = Array.isArray(list) ? list : []; + setLogVersions(arr); + 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 || + 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")}: 2026-04-16 {t("new arrangement")} + + + + + + + + + + 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} + + + + + + + + + + {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 })} + + {v?.modifiedBy != null && + String(v.modifiedBy).trim() !== "" && ( + + {t("version_ui_editedBy", { + name: String(v.modifiedBy).trim(), + })} + + )} + 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?.modifiedBy != null && + String(sel.modifiedBy).trim() !== "" + ? String(sel.modifiedBy).trim() + : 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 && ( + + {t("diff_staged_serverCountsOnly")} + {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_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, + ); + 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 === + "物流公司"; + 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; + + return ( + + + {formatDiffFieldLabel( + fe.label, + t, + )} + + {":"} + {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") + } + /> + + {t("addShop_listHint")} + + + + + + + + + + + + {districtEditCtx?.mode === "add" + ? t("district_dialog_add") + : t("district_dialog_edit")} + + + { + setDistrictEditDraft(e.target.value); + setDistrictEditError(null); + }} + error={Boolean(districtEditError)} + helperText={ + districtEditError || + (districtEditCtx?.mode === "rename" && + districtEditCtx.oldDisplay === "未分類" + ? t("district_help_null") + : t("district_help_mapped")) + } + 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 }} + /> + + {t("seqDialog_hint")} + + + + + + + + + { + if (!addRouteSubmitting) closeAddRouteDialog(); + }} + maxWidth="sm" + fullWidth + PaperProps={{ sx: { borderRadius: 3 } }} + > + + + + + + + {t("addRoute_dialogTitle")} + + + + + + + + {addRouteError && ( + + {addRouteError} + + )} + + {t("addRoute_hint")} + + + + 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, + })) + } + helperText={t("logistic_phone_helper")} + 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, + })) + } + helperText={t("logistic_phone_helper")} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + + + + {/* Sidebar */} + + + + + + + {routeBoardTab === "logistics" && ( + + + {t("quickIndex")} + + + + {lanesByLogisticsCompany.map(([company, list]) => ( + + + {company} + + + + ))} + {lanesByLogisticsCompany.length === 0 && ( + + {t("logistics_sidebarEmpty")} + + )} + + + )} + + {routeBoardTab === "board" && + (() => { + 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(e.currentTarget)} + disabled={(lanes || []).length === 0} + > + + + + + + + + setLaneFilterAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + transformOrigin={{ vertical: "top", horizontal: "right" }} + > + + + + {t("floor_label")} + + + + + + + + + + + + + ); + })()} + + {routeBoardTab !== "logistics" && ( + <> + + {t("tools_title")} + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + + + + )} + + + {/* 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 && ( + + + openEditLogistic(logisticMaster) + } + aria-label={t("aria_editLogistics")} + sx={{ flexShrink: 0 }} + > + + + + )} + + {logisticMaster && ( + + + + + + + {String( + logisticMaster.driverName ?? "", + ).trim() || "—"} + + + + + + + + {logisticMaster.driverNumber != null && + Number.isFinite(logisticMaster.driverNumber) + ? String(logisticMaster.driverNumber) + : "—"} + + + + + + + + {String( + logisticMaster.carPlate ?? "", + ).trim() || "—"} + + + + )} + + + + + + + + + + + + {companyLanes.length === 0 && ( + + {t("logistics_masterNoLanes")} + + )} + {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, + })} + + + + ); + })} + + + + {t("logistics_dataSource")} + + + + ); + })} + + + ) : ( + + {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 showInsertLine = + dropIndicator != null && + dropIndicator.laneId === lane.id && + dropIndicator.beforeShopId === shop.id; + return ( + 0} + 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 ? "grab" : "default", + borderColor: changed + ? "warning.main" + : shop.id < 0 + ? "warning.light" + : changedShopIds.has(shop.id) + ? "info.main" + : "divider", + bgcolor: 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", + }} + > + {showInsertLine && ( + + )} + + + + + {(() => { + const codeLower = String( + shop.shopCode || "", + ) + .trim() + .toLowerCase(); + const realName = + shopNameByCodeMap.get( + codeLower, + ); + return realName && + String(realName).trim() !== "" + ? realName + : shop.branchName || "-"; + })()} + + + {shop.branchName + ? `${shop.branchName} · ` + : ""} + {shop.shopCode || "-"} + {shop.storeId + ? ` · ${normalizeStoreId( + shop.storeId, + )}` + : ""} + + + + 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) + } + > + + + + + + + + + ); + })} + + + ), + )} + + + + {dropIndicator != null && + dropIndicator.laneId === lane.id && + dropIndicator.beforeShopId == null && + lane.shops.length > 0 && ( + + )} + + {districtSections.length === 0 && ( + + + + + {t("empty_lane_noShops")} + + + + )} + + + + + + + + handleClearLaneShops(lane)} + disabled={loading || lane.shops.length === 0} + > + + + + + + + + ); + })} + + + + + 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; diff --git a/src/components/Shop/Shop.tsx b/src/components/Shop/Shop.tsx index 53c5da3..8f7a2e8 100644 --- a/src/components/Shop/Shop.tsx +++ b/src/components/Shop/Shop.tsx @@ -314,9 +314,12 @@ const Shop: React.FC = () => { p: 2, borderBottom: '1px solid #e0e0e0' }}> - - 店鋪路線管理 - + + 店鋪路線管理 + + {/* Tabs section */} diff --git a/src/components/Shop/computeTruckLaneWarnings.ts b/src/components/Shop/computeTruckLaneWarnings.ts new file mode 100644 index 0000000..edd0e31 --- /dev/null +++ b/src/components/Shop/computeTruckLaneWarnings.ts @@ -0,0 +1,364 @@ +/** + * 車線看板警示:Rule1(4F 同店跨線 weekday)、Rule2(非 4F 同店跨線發車時分)。 + * 規則細節見 `README-ROUTE-BOARD-WARNINGS.md`。 + */ +import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; + +const LANE_KEY_SEP = "|"; + +/** 與 RouteBoard `encodeLaneId` 同演算法,避免從巨型元件 re-export。 */ +export function encodeLaneKeyForWarning( + 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_KEY_SEP}${encodeURIComponent(rem)}`; +} + +const WEEKDAY_LOWER = new Set([ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", +]); + +const CANON_WEEKDAY: Record = { + mon: "Mon", + tue: "Tue", + wed: "Wed", + thu: "Thu", + fri: "Fri", + sat: "Sat", + sun: "Sun", +}; + +/** 第二段 `_` 為 weekday;normalize 成 Mon..Sun(DB 慣例大小寫)。無法辨識回傳 null。 */ +export function parseWeekdayFromTruckLanceCode( + truckLanceCode: string, +): string | null { + const parts = String(truckLanceCode || "").split("_"); + if (parts.length < 2) return null; + const token = String(parts[1] ?? "").trim(); + if (!token) return null; + const key3 = token.slice(0, 3).toLowerCase(); + if (!WEEKDAY_LOWER.has(key3)) return null; + return CANON_WEEKDAY[key3] ?? null; +} + +/** Rule2:發車時間比到 HH:mm;無效 / 空 / "-" → null(不參與 Rule2)。 */ +export function departureTimeToHHmmKey( + dep: string | number[] | null | undefined, +): string | null { + const fmt = formatDepartureTime(dep as any); + if (!fmt || fmt === "-") return null; + const m = fmt.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/); + if (!m) return null; + return `${m[1].padStart(2, "0")}:${m[2].padStart(2, "0")}`; +} + +export type TruckLaneWarningInputRow = { + truckRowId: number; + truckLanceCode: string; + /** 車線桶 remark(與 RouteBoard lane.remark 一致) */ + laneRemark: string | null; + /** truck 列 store(已 normalize 與否皆可,內部會 normalize) */ + storeId: string; + departureTime: string | number[] | null | undefined; + shopEntityId: number | null; + shopCode: string; + shopDisplayName: string; + deleted?: boolean; +}; + +export type TruckLaneWarningRule = "RULE_1_WEEKDAY" | "RULE_2_DEPARTURE"; + +export type TruckLaneWarningLaneRef = { + laneKey: string; + truckLanceCode: string; + laneRemark: string | null; + storeId: string; + departureTimeDisplay: string | null; + weekday: string | null; + truckRowId: number; +}; + +export type TruckLaneWarning = { + rule: TruckLaneWarningRule; + shopEntityId: number | null; + shopCode: string; + shopDisplayName: string; + /** Rule1: Mon..Sun;Rule2: HH:mm */ + triggerValue: string; + lanes: TruckLaneWarningLaneRef[]; +}; + +export type TruckLaneWarningResult = { + warnings: TruckLaneWarning[]; + /** 4F 列但 TruckLanceCode 無法解析 weekday(不進 Rule1) */ + weekdayParseFailures: Array<{ truckRowId: number; truckLanceCode: string }>; +}; + +/** + * 店鋪主鍵:優先 master `shopEntityId`;若本列無 id,但同 `shopCode + store` 的其他列有 id,則併入該 id(避免 join 不一致導致同店被拆成 `id:` 與 `fallback:` 兩桶)。 + * 若仍無法對應 master,則 fallback `shopCode(小寫) + storeId`。 + */ +export function buildMasterShopIdByCodeStore( + rows: readonly { + shopEntityId: number | null; + shopCode: string; + storeId: string; + }[], +): Map { + const m = new Map(); + for (const row of rows) { + const id = row.shopEntityId; + if (id == null || !Number.isFinite(id) || id <= 0) continue; + const code = String(row.shopCode || "").trim().toLowerCase(); + if (!code) continue; + const store = normalizeStoreId(row.storeId); + const ck = `${code}|${store}`; + if (!m.has(ck)) m.set(ck, id); + } + return m; +} + +function codeStoreKey(row: { + shopCode: string; + storeId: string; +}): string { + const code = String(row.shopCode || "").trim().toLowerCase(); + const store = normalizeStoreId(row.storeId); + return `${code}|${store}`; +} + +export function shopIdentityKeyFromRow( + row: { + shopEntityId: number | null; + shopCode: string; + storeId: string; + }, + masterByCodeStore?: ReadonlyMap, +): string { + if ( + row.shopEntityId != null && + Number.isFinite(row.shopEntityId) && + row.shopEntityId > 0 + ) { + return `id:${row.shopEntityId}`; + } + if (masterByCodeStore != null) { + const ck = codeStoreKey(row); + const inferred = masterByCodeStore.get(ck); + if (inferred != null && inferred > 0) return `id:${inferred}`; + } + const code = String(row.shopCode || "").trim().toLowerCase(); + const store = normalizeStoreId(row.storeId); + return `fallback:${code}|store:${store}`; +} + +function rowToLaneRef(row: TruckLaneWarningInputRow): TruckLaneWarningLaneRef { + const laneKey = encodeLaneKeyForWarning(row.truckLanceCode, row.laneRemark); + const storeNorm = normalizeStoreId(row.storeId); + const depDisp = departureTimeToHHmmKey(row.departureTime); + const weekday = + storeNorm === "4F" + ? parseWeekdayFromTruckLanceCode(row.truckLanceCode) + : null; + return { + laneKey, + truckLanceCode: String(row.truckLanceCode || "").trim(), + laneRemark: row.laneRemark, + storeId: storeNorm, + departureTimeDisplay: depDisp, + weekday, + truckRowId: row.truckRowId, + }; +} + +function pickLaneRef( + byLane: Map, + laneKey: string, +): TruckLaneWarningLaneRef { + const hit = byLane.get(laneKey); + if (hit) return hit; + return { + laneKey, + truckLanceCode: "", + laneRemark: null, + storeId: "", + departureTimeDisplay: null, + weekday: null, + truckRowId: 0, + }; +} + +export function computeTruckLaneWarnings( + rows: TruckLaneWarningInputRow[], +): TruckLaneWarningResult { + const active = (rows || []).filter((r) => r.deleted !== true); + const masterByCodeStore = buildMasterShopIdByCodeStore(active); + const shopKOf = (r: TruckLaneWarningInputRow) => + shopIdentityKeyFromRow(r, masterByCodeStore); + const entityIdFromShopKey = (shopK: string): number | null => { + if (!shopK.startsWith("id:")) return null; + const n = Number(shopK.slice(3)); + return Number.isFinite(n) && n > 0 ? n : null; + }; + const weekdayParseFailures: TruckLaneWarningResult["weekdayParseFailures"] = + []; + + const byLaneRef = new Map(); + for (const row of active) { + const lk = encodeLaneKeyForWarning(row.truckLanceCode, row.laneRemark); + if (!byLaneRef.has(lk)) byLaneRef.set(lk, rowToLaneRef(row)); + } + + // Rule 1: 4F, same shop, >=2 distinct lanes, same parsed weekday + const r1Map = new Map< + string, + Map> + >(); + for (const row of active) { + const store = normalizeStoreId(row.storeId); + if (store !== "4F") continue; + const wd = parseWeekdayFromTruckLanceCode(row.truckLanceCode); + if (!wd) { + weekdayParseFailures.push({ + truckRowId: row.truckRowId, + truckLanceCode: String(row.truckLanceCode || ""), + }); + continue; + } + const shopK = shopKOf(row); + const lk = encodeLaneKeyForWarning(row.truckLanceCode, row.laneRemark); + let byWd = r1Map.get(shopK); + if (!byWd) { + byWd = new Map(); + r1Map.set(shopK, byWd); + } + let set = byWd.get(wd); + if (!set) { + set = new Set(); + byWd.set(wd, set); + } + set.add(lk); + } + + const warnings: TruckLaneWarning[] = []; + r1Map.forEach((byWd, shopK) => { + byWd.forEach((laneKeys, wd) => { + if (laneKeys.size < 2) return; + const sampleRow = active.find( + (r) => + shopKOf(r) === shopK && + normalizeStoreId(r.storeId) === "4F" && + parseWeekdayFromTruckLanceCode(r.truckLanceCode) === wd, + ); + const laneKeyList: string[] = Array.from(laneKeys); + warnings.push({ + rule: "RULE_1_WEEKDAY", + shopEntityId: + sampleRow?.shopEntityId ?? entityIdFromShopKey(shopK) ?? null, + shopCode: sampleRow?.shopCode ?? "", + shopDisplayName: sampleRow?.shopDisplayName ?? "", + triggerValue: wd, + lanes: laneKeyList.map((lk) => pickLaneRef(byLaneRef, lk)), + }); + }); + }); + + // Rule 2: non-4F, same shop, >=2 lanes, same HH:mm departure + const r2Map = new Map>>(); + for (const row of active) { + const store = normalizeStoreId(row.storeId); + if (store === "4F") continue; + const hhmm = departureTimeToHHmmKey(row.departureTime); + if (!hhmm) continue; + const shopK = shopKOf(row); + const lk = encodeLaneKeyForWarning(row.truckLanceCode, row.laneRemark); + let byT = r2Map.get(shopK); + if (!byT) { + byT = new Map(); + r2Map.set(shopK, byT); + } + let set = byT.get(hhmm); + if (!set) { + set = new Set(); + byT.set(hhmm, set); + } + set.add(lk); + } + + r2Map.forEach((byT, shopK) => { + byT.forEach((laneKeys, hhmm) => { + if (laneKeys.size < 2) return; + const sampleRow = active.find( + (r) => + shopKOf(r) === shopK && + normalizeStoreId(r.storeId) !== "4F" && + departureTimeToHHmmKey(r.departureTime) === hhmm, + ); + const laneKeyList: string[] = Array.from(laneKeys); + warnings.push({ + rule: "RULE_2_DEPARTURE", + shopEntityId: + sampleRow?.shopEntityId ?? entityIdFromShopKey(shopK) ?? null, + shopCode: sampleRow?.shopCode ?? "", + shopDisplayName: sampleRow?.shopDisplayName ?? "", + triggerValue: hhmm, + lanes: laneKeyList.map((lk) => pickLaneRef(byLaneRef, lk)), + }); + }); + }); + + // 穩定排序:規則、店名、觸發值 + warnings.sort((a, b) => { + if (a.rule !== b.rule) return a.rule.localeCompare(b.rule); + const na = a.shopDisplayName || a.shopCode; + const nb = b.shopDisplayName || b.shopCode; + const c = na.localeCompare(nb, "zh-Hant"); + if (c !== 0) return c; + return a.triggerValue.localeCompare(b.triggerValue); + }); + + return { warnings, weekdayParseFailures }; +} + +/** 模擬「即將新增」的一筆店鋪列(`tempTruckRowId` 建議用負數);供 Rule1/2 試算。 */ +export function appendSyntheticPendingShopRow( + baseRows: TruckLaneWarningInputRow[], + lane: { + truckLanceCode: string; + laneRemark: string | null; + storeId: string | number; + startTime: string; + }, + pick: { id: number; name: string; code: string }, + tempTruckRowId: number, +): TruckLaneWarningInputRow[] { + const code = String(lane.truckLanceCode || "").trim(); + const rem = + lane.laneRemark != null && String(lane.laneRemark).trim() !== "" + ? String(lane.laneRemark).trim() + : null; + return [ + ...baseRows, + { + truckRowId: tempTruckRowId, + truckLanceCode: code, + laneRemark: rem, + storeId: normalizeStoreId(lane.storeId), + departureTime: lane.startTime, + shopEntityId: pick.id, + shopCode: pick.code, + shopDisplayName: pick.name, + }, + ]; +} diff --git a/src/components/Shop/routeBoardImportPreview.ts b/src/components/Shop/routeBoardImportPreview.ts new file mode 100644 index 0000000..75385ed --- /dev/null +++ b/src/components/Shop/routeBoardImportPreview.ts @@ -0,0 +1,192 @@ +import type { RouteLaneImportPreviewRow } from "@/app/api/shop/client"; +import type { Truck } from "@/app/api/shop/actions"; +import { normalizeStoreId } from "@/app/utils/formatUtil"; + +export type ImportPreviewLane = { + id: string; + truckLanceCode: string; + plate?: string; + logisticsCompany?: string; + logisticId?: number | null; + driver?: string; + phone?: string; + startTime: string; + storeId: string; + remark?: string | null; + shops: ImportPreviewShopCard[]; +}; + +export type ImportPreviewShopCard = { + id: number; + shopEntityId?: number | null; + branchName: string; + shopCode: string; + districtReferenceRaw: string | null; + loadingSequence: number; + remark?: string | null; + storeId: string; + departureTime: string; +}; + +const LANE_KEY_SEP = "|"; + +export function encodeLaneIdForImport( + truckLanceCode: string, + remark: string | null | undefined, +): string { + const code = String(truckLanceCode || "").trim(); + const rem = + remark != null && String(remark).trim() !== "" + ? String(remark).trim() + : ""; + return `${encodeURIComponent(code)}${LANE_KEY_SEP}${encodeURIComponent(rem)}`; +} + +function parseTimeForPreview(raw: string): string { + const s = String(raw ?? "").trim(); + if (!s) return "00:00:00"; + const m = s.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/); + if (!m) return s; + const h = String(Math.min(23, Math.max(0, Number(m[1]) || 0))).padStart(2, "0"); + const min = String(Math.min(59, Math.max(0, Number(m[2]) || 0))).padStart(2, "0"); + const sec = m[3] != null ? String(Math.min(59, Math.max(0, Number(m[3]) || 0))).padStart(2, "0") : "00"; + return `${h}:${min}:${sec}`; +} + +function shopCardFromPreview( + row: RouteLaneImportPreviewRow, + id: number, +): ImportPreviewShopCard { + return { + id, + shopEntityId: row.shopId, + branchName: String(row.shopName || "").trim(), + shopCode: String(row.shopCode || "").trim(), + districtReferenceRaw: + row.districtReference != null && + String(row.districtReference).trim() !== "" + ? String(row.districtReference).trim() + : null, + loadingSequence: Number(row.loadingSequence ?? 0) || 0, + remark: row.remark != null ? String(row.remark) : null, + storeId: normalizeStoreId(row.storeId), + departureTime: parseTimeForPreview(row.departureTime), + }; +} + +function previewRowToTruck(row: RouteLaneImportPreviewRow, id: number): Truck { + return { + id, + truckLanceCode: row.truckLanceCode, + departureTime: row.departureTime, + loadingSequence: row.loadingSequence, + districtReference: row.districtReference, + storeId: row.storeId, + remark: row.remark, + shopName: row.shopName, + shopCode: row.shopCode, + logisticId: row.logisticId, + shop: { id: row.shopId }, + } as Truck; +} + +/** Merge parsed import rows into current lanes (upsert only; does not remove shops omitted from file). */ +export function mergeImportPreviewIntoLanes< + T extends ImportPreviewLane, +>(current: T[], rows: RouteLaneImportPreviewRow[]): T[] { + let nextTempId = -1; + const laneMap = new Map( + current.map((l) => [l.id, { ...l, shops: [...l.shops] } as T]), + ); + + const byLane = new Map(); + for (const row of rows) { + const lid = encodeLaneIdForImport(row.truckLanceCode, row.remark); + const arr = byLane.get(lid) ?? []; + arr.push(row); + byLane.set(lid, arr); + } + + for (const [laneId, previewRows] of Array.from(byLane.entries())) { + previewRows.sort( + (a: RouteLaneImportPreviewRow, b: RouteLaneImportPreviewRow) => + (a.loadingSequence ?? 0) - (b.loadingSequence ?? 0), + ); + const code = String(previewRows[0]?.truckLanceCode ?? "").trim(); + const remark = + previewRows[0]?.remark != null && + String(previewRows[0].remark).trim() !== "" + ? String(previewRows[0].remark).trim() + : null; + const storeId = normalizeStoreId(previewRows[0]?.storeId ?? "2F"); + const startTime = parseTimeForPreview(previewRows[0]?.departureTime ?? ""); + + const existing = laneMap.get(laneId); + if (existing) { + const shops = [...existing.shops]; + for (const pr of previewRows) { + const sid = + pr.truckRowId != null && Number.isFinite(pr.truckRowId) && pr.truckRowId > 0 + ? pr.truckRowId + : nextTempId--; + const card = shopCardFromPreview(pr, sid); + const idx = shops.findIndex( + (s) => + (pr.truckRowId != null && pr.truckRowId > 0 && s.id === pr.truckRowId) || + String(s.shopCode).trim().toLowerCase() === + String(pr.shopCode).trim().toLowerCase(), + ); + if (idx >= 0) { + shops[idx] = { ...shops[idx], ...card, id: shops[idx]!.id }; + } else { + shops.push(card as T["shops"][number]); + } + } + shops.sort((a, b) => a.loadingSequence - b.loadingSequence); + laneMap.set(laneId, { + ...existing, + startTime, + storeId, + shops, + } as T); + } else { + const trucks: Truck[] = previewRows.map((pr: RouteLaneImportPreviewRow) => { + const sid = + pr.truckRowId != null && Number.isFinite(pr.truckRowId) && pr.truckRowId > 0 + ? pr.truckRowId + : nextTempId--; + return previewRowToTruck(pr, sid); + }); + const shops = trucks.map((t, i) => + shopCardFromPreview(previewRows[i]!, Number(t.id)), + ); + const logisticId = + previewRows.find((r: RouteLaneImportPreviewRow) => r.logisticId != null) + ?.logisticId ?? null; + laneMap.set(laneId, { + id: laneId, + truckLanceCode: code, + remark, + storeId, + startTime, + logisticId, + logisticsCompany: "", + plate: "", + driver: "", + phone: "", + shops: shops as T["shops"], + } as T); + } + } + + const merged = Array.from(laneMap.values()); + merged.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", + ); + }); + return merged; +} diff --git a/src/components/Shop/routeBoardVersionLog.ts b/src/components/Shop/routeBoardVersionLog.ts new file mode 100644 index 0000000..d3fdeb6 --- /dev/null +++ b/src/components/Shop/routeBoardVersionLog.ts @@ -0,0 +1,624 @@ +import type { + DiffFieldChange, + TruckLaneVersionDiffLine, +} from "@/app/api/shop/actions"; + +export type VersionLogFieldEdit = { + label: string; + from: string; + to: string; +}; + +export type VersionLogShopRow = { + type: "added" | "deleted" | "moved" | "edited"; + shopName: string; + shopCode: string; + fromLane?: string; + toLane?: string; + truckRowId: number; + /** 非新增/刪除時:各欄位 before → after(發車時段、順序等) */ + fieldEdits?: VersionLogFieldEdit[]; +}; + +function pickField( + changes: DiffFieldChange[], + field: string, +): DiffFieldChange | undefined { + return changes.find((c) => c.field === field); +} + +const VERSION_LOG_FIELD_LABEL: Record = { + departureTime: "發車時段", + loadingSequence: "裝載順序", + branchName: "分店名稱", + districtReference: "區域", + shopCode: "店鋪代碼", + storeId: "樓層/店別", + remark: "備註", + truckLanceCode: "車線代碼", + logisticId: "物流公司", +}; + +/** 版本 LOG 用:時段顯示為 HH:mm,避免 ISO / 帶秒過長 */ +export function formatVersionLogTimeDisplay( + raw: string | null | undefined, +): string { + if (raw == null || !String(raw).trim()) return "—"; + const s = String(raw).trim(); + const afterT = s.includes("T") ? s.split("T")[1] ?? "" : s; + const tail = afterT.split(".")[0] ?? afterT; + const m = tail.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/); + if (m) { + const h = Math.min(23, Math.max(0, Number(m[1]) || 0)); + const min = Math.min(59, Math.max(0, Number(m[2]) || 0)); + return `${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`; + } + return s; +} + +function formatVersionLogFieldValue( + field: string, + raw: string | null | undefined, +): string { + if (raw == null || String(raw).trim() === "") return "—"; + if (field === "departureTime") return formatVersionLogTimeDisplay(raw); + if (field === "loadingSequence") { + const n = Number(raw); + return Number.isFinite(n) ? String(Math.trunc(n)) : String(raw); + } + return String(raw).trim(); +} + +function buildFieldEditsForRow( + line: TruckLaneVersionDiffLine, + rowType: VersionLogShopRow["type"], +): VersionLogFieldEdit[] | undefined { + const ch = line.changes ?? []; + if (ch.length === 0) return undefined; + // 新增/刪除整列時後端仍會列欄位差異,清單已有「新加入/移除」,不再重複細項 + if (rowType === "added" || rowType === "deleted") return undefined; + + const skipWhenMoved = new Set(["truckLanceCode", "remark"]); + const out: VersionLogFieldEdit[] = []; + for (const c of ch) { + if (rowType === "moved" && skipWhenMoved.has(c.field)) continue; + out.push({ + label: VERSION_LOG_FIELD_LABEL[c.field] ?? c.field, + from: formatVersionLogFieldValue(c.field, c.from), + to: formatVersionLogFieldValue(c.field, c.to), + }); + } + return out.length > 0 ? out : undefined; +} + +export function formatLaneLabel( + truckLanceCode?: string | null, + remark?: string | null, +): string { + const c = String(truckLanceCode ?? "").trim(); + const r = + remark != null && String(remark).trim() !== "" ? String(remark).trim() : ""; + if (!c && !r) return "—"; + if (!r) return c; + return `${c} · ${r}`; +} + +export function splitVersionCreated(created: string | null | undefined): { + date: string; + time: string; +} { + if (!created || !String(created).trim()) return { date: "—", time: "" }; + const raw = String(created).trim(); + const d = new Date(raw); + if (!Number.isNaN(d.getTime())) { + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const hh = String(d.getHours()).padStart(2, "0"); + const mi = String(d.getMinutes()).padStart(2, "0"); + return { date: `${yyyy}-${mm}-${dd}`, time: `${hh}:${mi}` }; + } + const parts = raw.split(/[T ]/); + return { date: parts[0] || raw, time: (parts[1] ?? "").slice(0, 5) }; +} + +export function diffLinesToShopRows( + lines: TruckLaneVersionDiffLine[], +): VersionLogShopRow[] { + return lines.map(diffLineToShopRow); +} + +export function diffLineToShopRow( + line: TruckLaneVersionDiffLine, +): VersionLogShopRow { + const ch = line.changes ?? []; + const tc = pickField(ch, "truckLanceCode"); + const rem = pickField(ch, "remark"); + const br = pickField(ch, "branchName"); + const code = line.shopCode != null ? String(line.shopCode) : ""; + const shopName = String(br?.to ?? br?.from ?? (code || "—")).trim() || "—"; + + const fromLane = formatLaneLabel(tc?.from, rem?.from); + const toLane = formatLaneLabel(tc?.to, rem?.to); + const fromEmpty = fromLane === "—"; + const toEmpty = toLane === "—"; + + let type: VersionLogShopRow["type"]; + if (fromEmpty && !toEmpty) type = "added"; + else if (!fromEmpty && toEmpty) type = "deleted"; + else if (fromLane !== toLane) type = "moved"; + else type = "edited"; + + return { + type, + shopName, + shopCode: code, + fromLane: fromEmpty ? undefined : fromLane, + toLane: toEmpty ? undefined : toLane, + truckRowId: line.truckRowId, + fieldEdits: buildFieldEditsForRow(line, type), + }; +} + +export function summarizeVersionRows(rows: VersionLogShopRow[]): { + added: number; + moved: number; + deleted: number; + /** 有欄位級 before→after 的列數(同列可同時算入移動) */ + fieldChanges: number; +} { + const o = { added: 0, moved: 0, deleted: 0, fieldChanges: 0 }; + for (const r of rows) { + if (r.type === "added") o.added++; + else if (r.type === "deleted") o.deleted++; + else if (r.type === "moved") o.moved++; + if (r.fieldEdits != null && r.fieldEdits.length > 0) o.fieldChanges++; + } + return o; +} + +export function resolveHeadVersionId( + versions: Array<{ id?: unknown }>, +): number | null { + const first = versions[0]; + const id = Number(first?.id); + return Number.isFinite(id) && id > 0 ? id : null; +} + +/** 版本 LOG modal「看板未儲存」小節:與後端 diff 分開列,避免與 server snapshot 混淆 */ +export type StagedLogEntry = + | { key: string; tag: "scheduled"; kind: "restore"; versionId: number } + | { key: string; tag: "unsaved"; kind: "shop"; row: VersionLogShopRow } + | { + key: string; + tag: "unsaved"; + kind: "text"; + titleKey: string; + titleParams?: Record; + }; + +export type StagedDeleteMeta = { + shopCode: string; + branchName: string; + fromLane: string; +}; + +/** Per shop row baseline at load / refresh (for staged move vs field-edit). */ +export type ShopRowBaseline = { + laneId: string; + fromLaneLabel: string; + departureTime: string; + loadingSequence: number; + districtDisplay: string; +}; + +function stagedDistrictDisplay( + district: string | null | undefined, +): string { + const value = String(district ?? "").trim(); + return value === "" ? "未分類" : value; +} + +export function buildStagedBoardLogEntries(input: { + pendingRestoreVersionId: number | null; + dirtyMoves: Map; + dirtyMoveDistrictHints?: ReadonlyMap; + dirtyDeletes: Set; + stagedDeleteMeta: ReadonlyMap; + pendingShopAdds: Array<{ + tempTruckRowId: number; + laneId: string; + shopCode: string; + shopName: string; + loadingSequence: number; + }>; + pendingNewLanes: Array<{ + laneKey: string; + payload: { truckLanceCode: string; remark?: string | null }; + }>; + pendingLogisticMasterAdds: Array<{ + tempId: number; + logisticName: string; + carPlate: string; + }>; + pendingLogisticMasterEdits?: Array<{ + id: number; + logisticName: string; + carPlate: string; + fromName?: string; + fromPlate?: string; + }>; + pendingImport?: { + fileName: string; + sheetCount: number; + rowCount: number; + } | null; + laneLogisticChanges: Array<{ laneId: string; logisticId: number | null }>; + lanes: Array<{ + id: string; + truckLanceCode?: string | null; + remark?: string | null; + shops: Array<{ + id: number; + shopCode?: string | null; + branchName?: string | null; + districtReferenceRaw?: string | null; + departureTime?: string; + loadingSequence?: number; + }>; + }>; + pendingEmptyDistrictsByLane: Record; + logisticNameById: ReadonlyMap; + shopDistrictBaseline: ReadonlyMap; + shopRowBaseline?: ReadonlyMap; +}): StagedLogEntry[] { + const out: StagedLogEntry[] = []; + const { + pendingRestoreVersionId, + dirtyMoves, + dirtyMoveDistrictHints, + dirtyDeletes, + stagedDeleteMeta, + pendingShopAdds, + pendingNewLanes, + pendingLogisticMasterAdds, + pendingLogisticMasterEdits, + pendingImport, + laneLogisticChanges, + lanes, + pendingEmptyDistrictsByLane, + logisticNameById, + shopDistrictBaseline, + shopRowBaseline, + } = input; + + if ( + pendingRestoreVersionId != null && + Number.isFinite(pendingRestoreVersionId) && + pendingRestoreVersionId > 0 + ) { + out.push({ + key: `restore-${pendingRestoreVersionId}`, + tag: "scheduled", + kind: "restore", + versionId: pendingRestoreVersionId, + }); + } + + const laneById = new Map(lanes.map((l) => [l.id, l])); + + if (pendingImport != null && pendingImport.rowCount > 0) { + out.push({ + key: `import-${pendingImport.fileName}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_importPending", + titleParams: { + file: pendingImport.fileName, + sheets: pendingImport.sheetCount, + rows: pendingImport.rowCount, + }, + }); + } + + for (const p of pendingLogisticMasterAdds) { + const tid = Number((p as { tempId?: number }).tempId); + if (Number.isFinite(tid) && tid > 0) continue; + const name = String(p.logisticName ?? "").trim() || "—"; + const plate = String(p.carPlate ?? "").trim() || "—"; + out.push({ + key: `plog-${p.tempId}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_pendingLogisticMaster", + titleParams: { name, plate }, + }); + } + + for (const p of pendingLogisticMasterEdits ?? []) { + const name = String(p.logisticName ?? "").trim() || "—"; + const plate = String(p.carPlate ?? "").trim() || "—"; + const fromName = String(p.fromName ?? "").trim() || "—"; + const fromPlate = String(p.fromPlate ?? "").trim() || "—"; + out.push({ + key: `plog-edit-${p.id}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_editLogisticMaster", + titleParams: { name, plate, fromName, fromPlate }, + }); + } + + const dirtyMoveShopIds = new Set(); + + dirtyMoves.forEach((laneId, shopId) => { + if (!Number.isFinite(shopId) || shopId <= 0) return; + dirtyMoveShopIds.add(shopId); + const lane = laneById.get(laneId); + const shop = lane?.shops.find((s) => s.id === shopId); + if (!lane || !shop) return; + const laneLabel = formatLaneLabel(lane.truckLanceCode, lane.remark); + const shopName = String(shop.branchName ?? "").trim() || "—"; + const shopCode = String(shop.shopCode ?? "").trim(); + const base = shopRowBaseline?.get(shopId); + + if (base != null) { + const fromLaneResolved = base.fromLaneLabel; + const toLane = laneLabel; + const fieldEdits: VersionLogFieldEdit[] = []; + const curDep = formatVersionLogTimeDisplay(shop.departureTime); + const baseDep = formatVersionLogTimeDisplay(base.departureTime); + if (curDep !== baseDep) { + fieldEdits.push({ + label: VERSION_LOG_FIELD_LABEL.departureTime ?? "departureTime", + from: baseDep, + to: curDep, + }); + } + const curSeq = Number(shop.loadingSequence ?? 0) || 0; + if (curSeq !== base.loadingSequence) { + fieldEdits.push({ + label: VERSION_LOG_FIELD_LABEL.loadingSequence ?? "loadingSequence", + from: String(base.loadingSequence), + to: String(curSeq), + }); + } + const curDist = stagedDistrictDisplay(shop.districtReferenceRaw); + if (curDist !== base.districtDisplay) { + fieldEdits.push({ + label: VERSION_LOG_FIELD_LABEL.districtReference ?? "districtReference", + from: base.districtDisplay, + to: curDist, + }); + } + const moved = base.laneId !== laneId; + if (moved) { + out.push({ + key: `dirty-${shopId}`, + tag: "unsaved", + kind: "shop", + row: { + type: "moved", + shopName, + shopCode, + fromLane: fromLaneResolved, + toLane, + truckRowId: shopId, + fieldEdits: fieldEdits.length > 0 ? fieldEdits : undefined, + }, + }); + } else if (fieldEdits.length > 0) { + out.push({ + key: `dirty-${shopId}`, + tag: "unsaved", + kind: "shop", + row: { + type: "edited", + shopName, + shopCode, + truckRowId: shopId, + fieldEdits, + }, + }); + } else { + const hint = + dirtyMoveDistrictHints != null + ? dirtyMoveDistrictHints.get(shopId) + : undefined; + const districtPart = + hint != null && String(hint).trim() !== "" + ? String(hint).trim() + : ""; + out.push({ + key: `dirty-${shopId}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_shopPendingOnLane", + titleParams: { + name: shopName, + code: shopCode || "—", + lane: laneLabel, + districtPart, + }, + }); + } + return; + } + + const hint = + dirtyMoveDistrictHints != null + ? dirtyMoveDistrictHints.get(shopId) + : undefined; + const districtPart = + hint != null && String(hint).trim() !== "" + ? String(hint).trim() + : ""; + out.push({ + key: `dirty-${shopId}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_shopPendingOnLane", + titleParams: { + name: shopName, + code: shopCode || "—", + lane: laneLabel, + districtPart, + }, + }); + }); + + for (const lane of lanes) { + for (const shop of lane.shops) { + const sid = shop.id; + if (!Number.isFinite(sid) || sid <= 0) continue; + if (dirtyDeletes.has(sid)) continue; + if (dirtyMoveShopIds.has(sid)) continue; + const base = shopDistrictBaseline.get(sid); + if (base == null) continue; + const cur = stagedDistrictDisplay(shop.districtReferenceRaw); + if (cur === base) continue; + const laneLabel = formatLaneLabel(lane.truckLanceCode, lane.remark); + out.push({ + key: `distonly-${sid}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_shopDistrictOnly", + titleParams: { + name: String(shop.branchName ?? "").trim() || "—", + code: String(shop.shopCode ?? "").trim() || "—", + lane: laneLabel, + from: base, + to: cur, + }, + }); + } + } + + dirtyDeletes.forEach((shopId) => { + if (!Number.isFinite(shopId) || shopId <= 0) return; + const meta = stagedDeleteMeta.get(shopId); + if (meta) { + out.push({ + key: `del-${shopId}`, + tag: "unsaved", + kind: "shop", + row: { + type: "deleted", + shopName: meta.branchName.trim() || "—", + shopCode: meta.shopCode.trim(), + fromLane: meta.fromLane, + truckRowId: shopId, + fieldEdits: undefined, + }, + }); + } else { + out.push({ + key: `del-${shopId}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_deleteUnknown", + titleParams: { id: shopId }, + }); + } + }); + + for (const p of pendingShopAdds) { + const lane = laneById.get(p.laneId); + const toLane = lane + ? formatLaneLabel(lane.truckLanceCode, lane.remark) + : "—"; + out.push({ + key: `addshop-${p.tempTruckRowId}`, + tag: "unsaved", + kind: "shop", + row: { + type: "added", + shopName: String(p.shopName || "").trim() || "—", + shopCode: String(p.shopCode || "").trim(), + toLane, + truckRowId: p.tempTruckRowId, + fieldEdits: undefined, + }, + }); + } + + for (const p of pendingNewLanes) { + const code = String(p.payload?.truckLanceCode ?? "").trim(); + const remark = + p.payload?.remark != null && String(p.payload.remark).trim() !== "" + ? String(p.payload.remark).trim() + : null; + out.push({ + key: `newlane-${p.laneKey}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_newLane", + titleParams: { lane: formatLaneLabel(code || "—", remark) }, + }); + } + + for (const c of laneLogisticChanges) { + const lane = laneById.get(c.laneId); + const laneLabel = lane + ? formatLaneLabel(lane.truckLanceCode, lane.remark) + : c.laneId; + const lid = c.logisticId; + const company = + lid != null && lid > 0 + ? String(logisticNameById.get(lid) ?? "").trim() || `id=${lid}` + : "—"; + out.push({ + key: `log-${c.laneId}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_laneLogistic", + titleParams: { lane: laneLabel, company }, + }); + } + + for (const [laneId, names] of Object.entries(pendingEmptyDistrictsByLane)) { + const arr = Array.isArray(names) ? names.filter(Boolean) : []; + if (arr.length === 0) continue; + const lane = laneById.get(laneId); + const laneLabel = lane + ? formatLaneLabel(lane.truckLanceCode, lane.remark) + : laneId; + out.push({ + key: `dist-${laneId}-${arr.join(",")}`, + tag: "unsaved", + kind: "text", + titleKey: "diff_staged_emptyDistricts", + titleParams: { lane: laneLabel, names: arr.join("、") }, + }); + } + + return out; +} + +/** + * 異動列標題:優先用 shop 主檔名稱,其次快照 branchName(與代碼不同時),最後才代碼。 + */ +export function resolveVersionLogShopHeadline( + row: VersionLogShopRow, + shopNameByCode: Map, +): { headline: string; detail?: string } { + const code = String(row.shopCode || "").trim(); + const lower = code.toLowerCase(); + const master = lower ? String(shopNameByCode.get(lower) ?? "").trim() : ""; + const snap = String(row.shopName || "").trim(); + const snapIsEmpty = !snap || snap === "—"; + const snapIsJustCode = snap.toLowerCase() === lower; + + const headline = + (master ? master : "") || + (!snapIsEmpty && !snapIsJustCode ? snap : "") || + code || + "—"; + + const bits: string[] = []; + if (!snapIsEmpty && !snapIsJustCode && snap !== master) + bits.push(`快照分店:${snap}`); + const detail = bits.length > 0 ? bits.join(" · ") : undefined; + + return { headline, detail }; +} diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index 0d15345..864556b 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -18,8 +18,9 @@ "No": "No", "Equipment Name": "Equipment Name", "Equipment Code": "Equipment Code", - "ShopAndTruck": "ShopAndTruck", + "ShopAndTruck": "Shop & route management", "DO floor (supplier)": "DO floor (supplier)", + "Route Board": "Route board", "TruckLance Code is required": "TruckLance Code is required", "Truck shop details updated successfully": "Truck shop details updated successfully", "Failed to save truck shop details": "Failed to save truck shop details", diff --git a/src/i18n/en/routeboard.json b/src/i18n/en/routeboard.json new file mode 100644 index 0000000..90580b8 --- /dev/null +++ b/src/i18n/en/routeboard.json @@ -0,0 +1,237 @@ +{ + "mtmsRouteWarn_title": "Route data alerts", + "mtmsRouteWarn_tooltipHas": "{{count}} potential conflict(s)", + "mtmsRouteWarn_tooltipNone": "No alerts", + "mtmsRouteWarn_refresh": "Reload data", + "mtmsRouteWarn_refreshing": "Loading…", + "mtmsRouteWarn_copyAll": "Copy all", + "mtmsRouteWarn_parseHint": "{{count}} 4F lane(s): weekday could not be determined (excluded from alerts)", + "mtmsRouteWarn_empty": "No data conflicts.", + "mtmsRouteWarn_conflict4f": "4F: same shop on different lanes · weekday {{weekday}}", + "mtmsRouteWarn_conflictDep": "Non-4F: same shop on different lanes · departure {{time}}", + "mtmsRouteWarn_shop": "Shop", + "mtmsRouteWarn_postAddConflict": "After adding the shop, data conflicts with other lane(s). Open the bell for details.", + "No changes": "No changes", + "Saved": "Saved", + "Failed to save": "Failed to save", + "Changed": "Changed", + "Logistic": "Logistic", + "Driver": "Driver", + "Plate": "Plate", + "Departure": "Departure", + "Shops": "Shops", + "Current version": "Current version", + "new arrangement": "new arrangement", + "Submitting...": "Submitting…", + "saveChanges": "Save changes", + "warnExpand": "Expand", + "warnCollapse": "Collapse", + "warnClipboardStore": "Store", + "warnClipboardDep": "Dep", + "warnClipboardWeekday": "Weekday", + "pageTitle": "MTMS route & shop board", + "importRoutes": "Import routes", + "exportRoutes": "Export routes", + "routeReport": "Route report", + "departureTooltipNeedShops": "Add shops before setting departure time", + "departureTooltipEditSave": "Edit departure time (saved with \"Save changes\")", + "departureEditAria": "Edit departure time", + "saveDisabledTooltip": "Make changes (drag, departure time, load order, logistics, etc.) before saving", + "cancel": "Cancel", + "drawerClose": "Close", + "tabBoard": "Route board", + "tabLogistics": "Logistics", + "quickIndex": "Quick index", + "versionLogDialogTitle": "Version change log", + "emDash": "—", + "val_logisticsRequired": "Enter logistics company, plate, and driver name", + "val_logisticsDuplicateName": "A logistics master or staged add with this name already exists", + "val_phoneInvalid": "Enter a valid phone number (digits)", + "err_save": "Save failed", + "err_invalidMasterId": "Invalid master record id", + "err_exportNeedSelection": "Select at least one lane on the left to export", + "err_export": "Export failed", + "err_noLanes": "No lane data", + "err_import": "Import failed", + "err_dragDuplicateShop": "Target lane already has this shop (same shop / same shop code)", + "district_err_name": "Enter a district name", + "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", + "district_err_exists": "This district already exists", + "route_err_code": "Enter a lane code", + "route_err_departure": "Select or enter departure time", + "route_err_duplicate": "This lane (including remark group) already exists", + "route_err_create": "Failed to add lane", + "confirm_addShopConflict": "Detected {{count}} potential conflict(s) with other lanes (Rules 1/2; see bell). It will be added to the board first; press \"Save changes\" to persist. Continue?", + "confirm_discardDraftShop": "Discard unsaved \"new shop\" draft?", + "confirm_removeShop": "Remove this shop from the lane? (Press \"Save changes\" to persist)", + "confirm_clearLane": "Clear all {{count}} shop(s) from lane \"{{laneLabel}}\"? (Press \"Save changes\" to delete on server)", + "confirm_departureConflict": "After changing departure time, {{count}} potential conflict(s) detected (Rules 1/2; see bell). Apply anyway?", + "drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.", + "nav_unsavedLeave": "You have unsaved changes. Leave this page?", + "save_clearedEmptyDistricts": "Only empty district blocks (no shops); cleared staging", + "api_fail_createLane": "Failed to create lane", + "api_fail_addShop": "Failed to add shop", + "api_fail_updateLane": "Failed to update lane", + "api_fail_deleteShop": "Failed to delete shop", + "api_fail_updateLogistics": "Failed to update logistics", + "diff_loadFail": "Failed to load version diff", + "versionNote_saveFail": "Failed to save note", + "diff_restoreFail": "Restore failed", + "confirm_restoreDiscardsEdits": "Scheduling a version restore will discard other unsaved board changes (drags, deletes, pending shops/lanes, logistics fields, etc.). Continue?", + "diff_restoreScheduled": "Restore to version #{{versionId}} is scheduled; press \"Save changes\" to persist.", + "diff_restoreAlreadyPending": "This version is already scheduled; press \"Save changes\" to apply.", + "restore_applied": "Snapshot restore applied; board reloaded.", + "restore_appliedDroppedStaging": "Snapshot restore applied; other staged edits in this save were skipped (edit again if needed).", + "confirm_restoreSaveWillDropStaging": "Save will apply the snapshot restore first; other staged edits in this save will be skipped. Continue?", + "diff_noOlderCompare": "No older version to compare (pick a newer version)", + "logistic_needMasterTpl": "\"{{name}}\" has no logistics master id—create it with \"Add logistics\" first.", + "diffField_logisticsCompany": "Logistics company", + "diffLogistic_unassigned": "Unassigned", + "diff_moveTo": "Move to {{lane}}", + "diff_addedToLane": "Added to lane {{lane}}", + "diff_removedFromLane": "Removed from {{lane}}", + "diff_editedCaption": "Field edits (sequence / branch name / time window, etc.)", + "diff_restoreToHead": "Schedule restore to latest snapshot (requires Save)", + "diff_restoreToSelected": "Schedule restore to this version (requires Save)", + "dialog_close": "Close", + "btn_addLogistics": "Add logistics", + "logistics_sidebarEmpty": "No lanes (refresh or relax filters)", + "lane_companyChip": "{{count}} lane(s)", + "lane_selectTitle": "Lanes", + "lane_selectedNone": "No lanes selected", + "lane_selectedCount": "{{count}} selected", + "lane_searchPh": "Search…", + "lane_selectAll": "Select all", + "lane_noMatchFilter": "No lanes match (clear search or floor filter)", + "floor_label": "Floor", + "floor_all": "All", + "filter_clear": "Clear", + "filter_apply": "OK", + "btn_addLane": "Add lane", + "tools_title": "Tools", + "shop_searchPh": "Search shop name / code / district…", + "btn_openVersionLog": "Version log", + "btn_loading": "Loading…", + "btn_refresh": "Refresh", + "logistics_overviewTitle": "Logistics overview", + "version_ui_historyTitle": "Version history", + "version_ui_filterAria": "Filter version list", + "version_ui_listAria": "Version history list", + "version_ui_snapshotBadge": "Current snapshot", + "version_ui_id": "Version #{{id}}", + "version_ui_editedBy": "Editor: {{name}}", + "version_note_placeholder": "Note (saved on blur)", + "version_note_saving": "Saving…", + "version_search_label": "Search", + "version_search_placeholder": "Version id / note / editor", + "version_date_label": "Date", + "version_empty_filtered": "No versions match filters", + "version_empty_list": "No versions yet (use \"Save version log\")", + "diff_clickLeft": "Select a version on the left to view changes", + "diff_oldestSnapshot": "Oldest snapshot—no older version to diff against.", + "diff_summary_title": "Summary", + "diff_export_reportBtn": "Export version lane report", + "diff_summary_added": "Added", + "diff_summary_moved": "Moved", + "diff_summary_deleted": "Deleted", + "diff_summary_fieldChange": "Field changes", + "diff_shopList_title": "Shop changes", + "diff_staged_serverCountsOnly": "The four counts above compare persisted snapshots only; they exclude unsaved board edits.", + "diff_staged_boardPendingLine": "{{count}} unsaved / scheduled board item(s) — see the list below.", + "diff_staged_section_title": "Board: unsaved / scheduled (not persisted yet)", + "diff_staged_section_subtitle": "These match what will hit the DB after \"Save changes\"; listed separately from the version diff above (Excel is server snapshots only).", + "diff_staged_tag_unsaved": "Unsaved", + "diff_staged_tag_scheduled": "Scheduled", + "diff_staged_restoreScheduled": "Restore to version #{{versionId}} is scheduled (calls restore only after \"Save changes\").", + "diff_staged_deleteUnknown": "Delete truck id={{id}} (unsaved; save or cancel to refresh details)", + "diff_staged_newLane": "New lane (unsaved): {{lane}}", + "diff_staged_laneLogistic": "Lane logistics (unsaved): {{lane}} → {{company}}", + "diff_staged_emptyDistricts": "Empty-district blocks (unsaved): {{lane}} — {{names}}", + "diff_staged_shopDistrictHint": " · District: {{from}}→{{to}}", + "diff_staged_shopPendingOnLane": "{{name}} ({{code}}) — lane {{lane}}: unsaved edits (drag / departure / load order; persisted on \"Save changes\"){{districtPart}}", + "diff_staged_shopDistrictOnly": "{{name}} ({{code}}) — lane {{lane}}: district {{from}}→{{to}} (unsaved; persisted on \"Save changes\")", + "diff_staged_pendingLogisticMaster": "New logistics company (not saved yet): {{name}} (plate {{plate}}); will be created on \"Save changes\" together with route edits", + "diff_staged_editLogisticMaster": "Edit logistics company (unsaved): {{fromName}} ({{fromPlate}}) → {{name}} ({{plate}})", + "diff_staged_importPending": "Import Excel (unsaved): {{file}} — {{sheets}} sheet(s), {{rows}} row(s) (persisted on \"Save changes\")", + "confirm_importDiscardEdits": "Import will replace unsaved board edits. Continue?", + "import_staged_preview": "Import preview loaded: {{file}} ({{sheets}} sheet(s) / {{rows}} rows). Press \"Save changes\" to persist.", + "err_importEmpty": "No valid lane rows found in the import file", + "diff_logisticMaster_section": "Logistics company changes", + "diff_logisticMaster_added": "Added", + "diff_logisticMaster_edited": "Edited", + "diff_noShopDiffHasBoardStaged": "No shop-row changes vs the previous snapshot. Below are unsaved board edits (including new logistics company records).", + "diff_export_blockedTooltip": "Export compares two persisted snapshots only. Save or discard board changes first, then export.", + "diff_export_blockedError": "Cannot export while the board has unsaved changes (Excel is persisted snapshots only).", + "diff_markedCount": "{{count}} truck row change(s) marked (see board)", + "diff_noDiffFromPrev": "No differences vs previous version", + "diff_loadingEllipsis": "…", + "addShop_dialogTitle": "Add shop to lane", + "addRoute_dialogTitle": "Add delivery lane", + "addRoute_hint": "After confirm, the lane is staged on the board; press \"Save changes\" in the header to create it on the server (no dummy shop rows).", + "addRoute_confirm": "Confirm add lane", + "addRoute_submitting": "Adding…", + "district_dialog_add": "Add district", + "district_dialog_edit": "Edit district", + "district_name_label": "District display name", + "district_name_ph": "Blank means \"Unclassified\"", + "district_help_null": "Unclassified maps to districtReference = null on server", + "district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"", + "seq_edit_departureLabel": "Departure time", + "seq_edit_seqLabel": "Load sequence (Seq)", + "route_new_code_label": "Lane code", + "route_new_time_label": "Departure time", + "route_new_logistic_label": "Logistics company", + "route_new_store_label": "Floor", + "route_new_remark_label": "Lane remark (4F)", + "logistic_companyName": "Company name", + "logistic_plate": "Plate", + "logistic_driver": "Driver name", + "logistic_phone": "Phone", + "logistic_phone_helper": "Server stores Int digits (e.g. 9811-5780); +852 8-digit local numbers are OK", + "logistic_btn_save": "Save", + "logistic_btn_saveDb": "Save to database", + "shop_autocomplete_label": "Select shop", + "shop_autocomplete_ph": "Filter by name or code", + "shop_autocomplete_loading": "Shop master not loaded", + "shop_autocomplete_noOptions": "All shops already on this lane or no options", + "dialog_addLogisticsTitle": "Add logistics", + "btn_cancelBack": "Cancel and go back", + "quickPick_noLanes": "No lanes (relax floor filter or refresh)", + "quickPick_noKeyword": "No lanes match the keyword", + "route_logisticUnspecified": "(Unassigned — assign later in Logistics)", + "dialog_editLogisticsTitle": "Edit logistics master", + "btn_apply": "Apply", + "addShop_confirm": "Confirm", + "addShop_listHint": "Shop codes already on this lane are hidden from the list. After adding, reorder by drag; like other edits, press \"Save changes\" to persist to truck rows.", + "departureDialog_title": "Edit departure time", + "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", + "seqDialog_title": "Edit load sequence", + "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", + "logistics_colLaneCount": "{{count}} lane(s)", + "logistics_masterNoLanes": "Master record exists but no lanes are bound yet; pick this company when adding/editing lanes on the route board.", + "logistics_dataSource": "Data: board lanes (including left filter)", + "tooltip_openLaneBoard": "Open this lane on the route board", + "aria_openLaneBoard": "Open lane on route board", + "tooltip_removeFromLane": "Remove from this lane", + "tooltip_clearLaneShops": "Clear all shops on this lane (press \"Save changes\" to persist)", + "tooltip_pickLane": "Pick lane (add to selection and scroll into view)", + "aria_pickLane": "Pick lane", + "aria_searchLanes": "Search lanes", + "logistics_colShopCount": "{{count}} shop(s)", + "tooltip_editLogisticsDb": "Edit logistics master (save to database)", + "aria_editLogistics": "Edit logistics master", + "tooltip_editDistrict": "Edit district name (press \"Save changes\" to persist)", + "aria_editDistrict": "Edit district", + "tooltip_removeEmptyDistrict": "Remove this staged empty block (deletable before save)", + "aria_removeEmptyDistrict": "Remove empty district block", + "tooltip_editSeq": "Edit load sequence (press \"Save changes\" to persist)", + "aria_editSeq": "Edit load sequence", + "diff_moveFrom": "From {{lane}}", + "logistics_dirtyColumnBadge": "Unsaved logistics changes", + "logistics_dirtyLaneBadge": "Unsaved logistics on lane", + "lane_shopCountInline": "{{count}} shop(s)", + "btn_addDistrict": "Add district", + "empty_lane_noShops": "No assigned shops", + "btn_addShopToLane": "Add shop", + "err_loadLanes": "Failed to load lanes" +} diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 268be30..4bbb770 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -450,6 +450,7 @@ "Shop": "店鋪", "ShopAndTruck": "店鋪路線管理", "DO floor (supplier)": "送貨單樓層(供應商)", + "Route Board": "車線看板", "Shop Information": "店鋪資訊", "Shop Name": "店鋪名稱", "Shop Branch": "店鋪分店", diff --git a/src/i18n/zh/routeboard.json b/src/i18n/zh/routeboard.json new file mode 100644 index 0000000..fc0bd18 --- /dev/null +++ b/src/i18n/zh/routeboard.json @@ -0,0 +1,237 @@ +{ + "mtmsRouteWarn_title": "車線資料警示", + "mtmsRouteWarn_tooltipHas": "有 {{count}} 筆潛在衝突", + "mtmsRouteWarn_tooltipNone": "目前無警示", + "mtmsRouteWarn_refresh": "重新整理資料", + "mtmsRouteWarn_refreshing": "載入中…", + "mtmsRouteWarn_copyAll": "複製全部", + "mtmsRouteWarn_parseHint": "有 {{count}} 筆 4F 車線無法判斷星期(未列入警示)", + "mtmsRouteWarn_empty": "目前沒有資料衝突。", + "mtmsRouteWarn_conflict4f": "4F 同店跨車線 · 星期 {{weekday}}", + "mtmsRouteWarn_conflictDep": "非 4F 同店跨車線 · 出車 {{time}}", + "mtmsRouteWarn_shop": "店鋪", + "mtmsRouteWarn_postAddConflict": "新增店鋪後與其他車線資料衝突(請開啟右上角鈴鐺查看明細)。", + "No changes": "沒有變更", + "Saved": "已儲存", + "Failed to save": "儲存失敗", + "Changed": "已變更", + "Logistic": "物流商", + "Driver": "司機", + "Plate": "車牌", + "Departure": "出車", + "Shops": "店鋪", + "Current version": "目前版本", + "new arrangement": "新編排", + "Submitting...": "提交中…", + "saveChanges": "儲存更改", + "warnExpand": "展開", + "warnCollapse": "收合", + "warnClipboardStore": "樓層", + "warnClipboardDep": "出車", + "warnClipboardWeekday": "星期", + "pageTitle": "MTMS 車線店鋪管理", + "importRoutes": "匯入車線", + "exportRoutes": "匯出車線", + "routeReport": "車線報告", + "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", + "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", + "departureEditAria": "編輯出車時間", + "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", + "cancel": "取消", + "drawerClose": "關閉", + "tabBoard": "車線看板", + "tabLogistics": "物流商管理", + "quickIndex": "快速索引", + "versionLogDialogTitle": "版本異動紀錄", + "emDash": "—", + "val_logisticsRequired": "請填寫物流公司、車牌、司機姓名", + "val_logisticsDuplicateName": "已有同名物流公司或暫存新增(請換名稱)", + "val_phoneInvalid": "請輸入有效聯絡電話(數字)", + "err_save": "儲存失敗", + "err_invalidMasterId": "無效的主檔 id", + "err_exportNeedSelection": "請先於左側勾選要匯出的車線", + "err_export": "匯出失敗", + "err_noLanes": "目前無車線資料", + "err_import": "匯入失敗", + "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", + "district_err_name": "請輸入地區名稱", + "district_err_reserved": "「未分類」已內建,請勿重複新增", + "district_err_exists": "此地區已存在", + "route_err_code": "請填車線編號", + "route_err_departure": "請選擇或輸入出車時間", + "route_err_duplicate": "此車線(含備註組合)已存在", + "route_err_create": "新增車線失敗", + "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", + "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", + "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", + "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", + "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", + "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", + "nav_unsavedLeave": "有未儲存的更改,確定要離開?", + "save_clearedEmptyDistricts": "僅有空地區區塊(尚無店鋪),已清除暫存", + "api_fail_createLane": "新增車線失敗", + "api_fail_addShop": "新增店鋪失敗", + "api_fail_updateLane": "更新車線失敗", + "api_fail_deleteShop": "刪除店鋪失敗", + "api_fail_updateLogistics": "更新物流商失敗", + "diff_loadFail": "載入版本異動失敗", + "versionNote_saveFail": "備註儲存失敗", + "diff_restoreFail": "恢復失敗", + "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", + "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」才會寫入後端。", + "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", + "restore_applied": "已從 snapshot 還原並重新載入看板。", + "restore_appliedDroppedStaging": "已套用 snapshot 還原;本次儲存略過其他暫存變更(請重新編輯)。", + "confirm_restoreSaveWillDropStaging": "儲存時將先套用 snapshot 還原,本次其他暫存變更會被略過。確定繼續?", + "diff_noOlderCompare": "沒有上一筆版本可比較(請選擇較新的版本)", + "logistic_needMasterTpl": "「{{name}}」尚無對應物流公司,請先用「新增物流商」建立。", + "diffField_logisticsCompany": "物流公司", + "diffLogistic_unassigned": "未分配", + "diff_moveTo": "移至 {{lane}}", + "diff_addedToLane": "新加入車線 {{lane}}", + "diff_removedFromLane": "自 {{lane}} 移除", + "diff_editedCaption": "欄位調整(順序 / 分店名 / 時段等)", + "diff_restoreToHead": "排程還原至最新快照(須儲存)", + "diff_restoreToSelected": "排程還原至此版本(須儲存)", + "dialog_close": "關閉", + "btn_addLogistics": "新增物流商", + "logistics_sidebarEmpty": "無車線(請重新整理或放寬篩選)", + "lane_companyChip": "{{count}} 車線", + "lane_selectTitle": "車線選擇", + "lane_selectedNone": "未選擇車線", + "lane_selectedCount": "已選 {{count}} 條", + "lane_searchPh": "搜尋…", + "lane_selectAll": "全選", + "lane_noMatchFilter": "無符合條件的車線(清除搜尋或樓層篩選)", + "floor_label": "樓層", + "floor_all": "全部", + "filter_clear": "清除", + "filter_apply": "確定", + "btn_addLane": "新增車線", + "tools_title": "操作工具", + "shop_searchPh": "搜尋店鋪名稱/編號/地區...", + "btn_openVersionLog": "查看版本異動", + "btn_loading": "載入中…", + "btn_refresh": "重新整理", + "logistics_overviewTitle": "物流供應商總覽", + "version_ui_historyTitle": "版本歷史列表", + "version_ui_filterAria": "版本列表篩選", + "version_ui_listAria": "版本歷史列表", + "version_ui_snapshotBadge": "目前快照", + "version_ui_id": "版本 #{{id}}", + "version_ui_editedBy": "編輯者:{{name}}", + "version_note_placeholder": "備註(離開欄位即儲存)", + "version_note_saving": "儲存中…", + "version_search_label": "搜尋", + "version_search_placeholder": "版本號 / 備註 / 編輯者", + "version_date_label": "日期", + "version_empty_filtered": "沒有符合篩選條件的版本", + "version_empty_list": "暫時無版本(請先按「儲存更改」)", + "diff_clickLeft": "請點擊左側版本查看異動", + "diff_oldestSnapshot": "此為最早一筆快照,無上一版可比較異動。", + "diff_summary_title": "版本摘要", + "diff_export_reportBtn": "匯出版本車線報告", + "diff_summary_added": "新增", + "diff_summary_moved": "移動", + "diff_summary_deleted": "刪除", + "diff_summary_fieldChange": "欄位變更", + "diff_shopList_title": "店鋪異動清單", + "diff_staged_serverCountsOnly": "上列四格為「後端相鄰兩版快照」統計,不含看板上尚未儲存的編輯。", + "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", + "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", + "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端快照)混淆。", + "diff_staged_tag_unsaved": "未儲存", + "diff_staged_tag_scheduled": "已排程", + "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", + "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", + "diff_staged_newLane": "新增車線(未儲存):{{lane}}", + "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", + "diff_staged_emptyDistricts": "空地區區塊(未儲存):{{lane}} — {{names}}", + "diff_staged_shopDistrictHint": " · 地區:{{from}}→{{to}}", + "diff_staged_shopPendingOnLane": "{{name}}({{code}})— 車線 {{lane}}:有未儲存變更(拖曳/出車/裝載順序等;按「儲存更改」寫入){{districtPart}}", + "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", + "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", + "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", + "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", + "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", + "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", + "err_importEmpty": "匯入檔案無有效車線資料列", + "diff_logisticMaster_section": "物流公司異動", + "diff_logisticMaster_added": "新增", + "diff_logisticMaster_edited": "修改", + "diff_noShopDiffHasBoardStaged": "與上一版快照相比,店鋪列無差異;下列為看板上尚未按「儲存更改」寫入的變更(含新增物流公司)。", + "diff_export_blockedTooltip": "匯出檔為後端兩版快照比對,不含看板未儲存變更。請先按「儲存更改」或取消變更後再匯出。", + "diff_export_blockedError": "有看板未儲存變更時無法匯出(Excel 僅含已落庫快照)。", + "diff_markedCount": "已標記 {{count}} 筆 truck 異動(看板可對照)", + "diff_noDiffFromPrev": "與上一版無差異", + "diff_loadingEllipsis": "…", + "addShop_dialogTitle": "新增店鋪到車線", + "addRoute_dialogTitle": "新增配送車線", + "addRoute_hint": "確認後先加入看板暫存;須按頂部「儲存更改」才會在後端建立車線(不建立假店鋪列)。", + "addRoute_confirm": "確認新增車線", + "addRoute_submitting": "新增中…", + "district_dialog_add": "新增地區", + "district_dialog_edit": "編輯地區", + "district_name_label": "地區顯示名稱", + "district_name_ph": "空白表示「未分類」", + "district_help_null": "未分類對應後端 districtReference 為 null", + "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", + "seq_edit_departureLabel": "出車時間", + "seq_edit_seqLabel": "裝車順序 (Seq)", + "route_new_code_label": "車線編號", + "route_new_time_label": "出車時間", + "route_new_logistic_label": "物流公司", + "route_new_store_label": "樓層", + "route_new_remark_label": "車線備註 (4F)", + "logistic_companyName": "物流公司名稱", + "logistic_plate": "車牌", + "logistic_driver": "司機姓名", + "logistic_phone": "聯絡電話", + "logistic_phone_helper": "後端為 Int:輸入數字即可(含 9811-5780);+852 八位本地號亦可", + "logistic_btn_save": "儲存", + "logistic_btn_saveDb": "儲存至資料庫", + "shop_autocomplete_label": "選擇店鋪", + "shop_autocomplete_ph": "輸入名稱或代碼篩選", + "shop_autocomplete_loading": "尚未載入店鋪主檔", + "shop_autocomplete_noOptions": "此車線已含所有可選店鋪或無可選項", + "dialog_addLogisticsTitle": "新增物流商", + "btn_cancelBack": "取消返回", + "quickPick_noLanes": "目前無車線(請放寬樓層篩選或重新整理)", + "quickPick_noKeyword": "無符合關鍵字的車線", + "route_logisticUnspecified": "(未指定——稍後於物流商管理指派)", + "dialog_editLogisticsTitle": "編輯物流公司", + "btn_apply": "套用", + "addShop_confirm": "確認", + "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。", + "departureDialog_title": "編輯出車時間", + "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", + "seqDialog_title": "編輯裝車順序", + "seqDialog_hint": "按「儲存更改」後寫入 truck 列。", + "logistics_colLaneCount": "{{count}} 條車線", + "logistics_masterNoLanes": "主檔已建立,尚無綁定車線;至「車線看板」新增/編輯車線時可填此公司名稱。", + "logistics_dataSource": "資料來源:看板車線(含左欄篩選)", + "tooltip_openLaneBoard": "在車線看板開此車線", + "aria_openLaneBoard": "開啟車線看板", + "tooltip_removeFromLane": "從此車線移除", + "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", + "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", + "aria_pickLane": "選擇車線", + "aria_searchLanes": "搜尋車線", + "logistics_colShopCount": "{{count}} 家店鋪", + "tooltip_editLogisticsDb": "編輯物流公司(寫入資料庫)", + "aria_editLogistics": "編輯物流公司", + "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", + "aria_editDistrict": "編輯地區", + "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", + "aria_removeEmptyDistrict": "移除空區", + "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", + "aria_editSeq": "編輯裝車順序", + "diff_moveFrom": "從 {{lane}}", + "logistics_dirtyColumnBadge": "有未儲存物流更改", + "logistics_dirtyLaneBadge": "未儲存物流更改", + "lane_shopCountInline": "{{count}} 間店鋪", + "btn_addDistrict": "新增地區", + "empty_lane_noShops": "無分配店鋪", + "btn_addShopToLane": "新增店鋪", + "err_loadLanes": "載入車線失敗" +}