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