CANCERYS\kw093 1 месяц назад
Родитель
Сommit
f4063d5a61
18 измененных файлов: 9840 добавлений и 9 удалений
  1. +2
    -2
      src/app/(main)/MainContentArea.tsx
  2. +2
    -2
      src/app/(main)/MainLayoutBody.tsx
  3. +14
    -0
      src/app/(main)/isFullBleedMainRoute.ts
  4. +27
    -0
      src/app/(main)/settings/shop/board/page.tsx
  5. +50
    -0
      src/app/api/logistic/actions.ts
  6. +27
    -0
      src/app/api/logistic/client.ts
  7. +346
    -1
      src/app/api/shop/actions.ts
  8. +100
    -0
      src/app/api/shop/client.ts
  9. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  10. +7608
    -0
      src/components/Shop/RouteBoard.tsx
  11. +6
    -3
      src/components/Shop/Shop.tsx
  12. +364
    -0
      src/components/Shop/computeTruckLaneWarnings.ts
  13. +192
    -0
      src/components/Shop/routeBoardImportPreview.ts
  14. +624
    -0
      src/components/Shop/routeBoardVersionLog.ts
  15. +2
    -1
      src/i18n/en/common.json
  16. +237
    -0
      src/i18n/en/routeboard.json
  17. +1
    -0
      src/i18n/zh/common.json
  18. +237
    -0
      src/i18n/zh/routeboard.json

+ 2
- 2
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 (
<Box


+ 2
- 2
src/app/(main)/MainLayoutBody.tsx Просмотреть файл

@@ -1,6 +1,6 @@
"use client";

import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";
import { isFullBleedMainRoute } from "@/app/(main)/isFullBleedMainRoute";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";

@@ -18,7 +18,7 @@ export default function MainLayoutBody({
mainContent,
}: MainLayoutBodyProps) {
const pathname = usePathname();
const isWorkbench = isPoWorkbenchRoute(pathname);
const isWorkbench = isFullBleedMainRoute(pathname);

if (isWorkbench) {
return (


+ 14
- 0
src/app/(main)/isFullBleedMainRoute.ts Просмотреть файл

@@ -0,0 +1,14 @@
import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute";

/** MTMS route board (`/settings/shop/board`). */
export function isRouteBoardRoute(pathname: string | null): boolean {
if (!pathname) {
return false;
}
return pathname === "/settings/shop/board";
}

/** Routes that use the full viewport under the AppBar (no main padding / min-h-screen gap). */
export function isFullBleedMainRoute(pathname: string | null): boolean {
return isPoWorkbenchRoute(pathname) || isRouteBoardRoute(pathname);
}

+ 27
- 0
src/app/(main)/settings/shop/board/page.tsx Просмотреть файл

@@ -0,0 +1,27 @@
import Box from "@mui/material/Box";
import { Suspense } from "react";
import { I18nProvider, getServerI18n } from "@/i18n";
import GeneralLoading from "@/components/General/GeneralLoading";
import RouteBoard from "@/components/Shop/RouteBoard";

export default async function ShopRouteBoardPage() {
await getServerI18n("shop", "common", "routeboard");
return (
<Box
sx={{
flex: 1,
minHeight: 0,
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
<I18nProvider namespaces={["shop", "common", "routeboard"]}>
<Suspense fallback={<GeneralLoading />}>
<RouteBoard />
</Suspense>
</I18nProvider>
</Box>
);
}


+ 50
- 0
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<LogisticRow[]> => {
const endpoint = `${BASE_API_URL}/logistic/all`;
return serverFetchJson<LogisticRow[]>(endpoint, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
};

export const saveLogisticAction = async (
data: SaveLogisticRequest,
): Promise<LogisticRow> => {
const endpoint = `${BASE_API_URL}/logistic/save`;
return serverFetchJson<LogisticRow>(endpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

export const saveLogisticsBatchCreateAction = async (
items: Omit<SaveLogisticRequest, "id">[],
): Promise<LogisticRow[]> => {
const endpoint = `${BASE_API_URL}/logistic/save-batch`;
return serverFetchJson<LogisticRow[]>(endpoint, {
method: "POST",
body: JSON.stringify({ items }),
headers: { "Content-Type": "application/json" },
});
};

+ 27
- 0
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<LogisticRow[]> => {
return await findAllLogisticsAction();
};

export const saveLogisticsBatchCreateClient = async (
items: Omit<SaveLogisticRequest, "id">[],
): Promise<LogisticRow[]> => {
return await saveLogisticsBatchCreateAction(items);
};

export const saveLogisticClient = async (
data: SaveLogisticRequest,
): Promise<LogisticRow> => {
return await saveLogisticAction(data);
};

+ 346
- 1
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<MessageResponse> => {
const endpoint = `${BASE_API_URL}/truck/updateLaneLogistic`;
return serverFetchJson<MessageResponse>(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<Truck[]>(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<Truck[]>(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<MessageResponse> => {
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<ParseRouteLanesExcelResponse> => {
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" },
});
});
});

// ---- 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<TruckLaneVersionResponse>(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<TruckLaneVersionResponse[]>(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<TruckLaneVersionLineResponse[]>(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<TruckLaneVersionDiffResponse>(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
};

export const restoreTruckLaneVersionAction = async (versionId: number) => {
const endpoint = `${BASE_API_URL}/truckLaneVersion/${versionId}/restore`;
return serverFetchJson<MessageResponse>(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<TruckLaneVersionResponse>(endpoint, {
method: "PATCH",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
};

+ 100
- 0
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<Messag
return await updateTruckLaneAction(data);
};

export const updateLaneLogisticClient = async (
data: UpdateLaneLogisticRequest,
): Promise<MessageResponse> => {
return await updateLaneLogisticAction(data);
};

export const deleteTruckLaneClient = async (data: DeleteTruckLane): Promise<MessageResponse> => {
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<MessageResponse> => {
return await updateTruckShopDetailsAction(data);
};
@@ -84,4 +148,40 @@ export const findAllUniqueShopNamesFromTrucksClient = async () => {
return await findAllUniqueShopNamesFromTrucksAction();
};

export const createTruckLaneSnapshotClient = async (
data: CreateTruckLaneSnapshotRequest,
): Promise<TruckLaneVersionResponse> => {
return await createTruckLaneSnapshotAction(data);
};

export const listTruckLaneVersionsClient = async (
truckLanceCode?: string | null,
): Promise<TruckLaneVersionResponse[]> => {
return await listTruckLaneVersionsAction(truckLanceCode);
};

export const getTruckLaneVersionLinesClient = async (
versionId: number,
): Promise<TruckLaneVersionLineResponse[]> => {
return await getTruckLaneVersionLinesAction(versionId);
};

export const diffTruckLaneVersionsClient = async (
fromVersionId: number,
toVersionId: number,
): Promise<TruckLaneVersionDiffResponse> => {
return await diffTruckLaneVersionsAction(fromVersionId, toVersionId);
};

export const restoreTruckLaneVersionClient = async (versionId: number): Promise<MessageResponse> => {
return await restoreTruckLaneVersionAction(versionId);
};

export const updateTruckLaneVersionNoteClient = async (
versionId: number,
data: UpdateTruckLaneVersionNoteRequest,
): Promise<TruckLaneVersionResponse> => {
return await updateTruckLaneVersionNoteAction(versionId, data);
};

export default fetchAllShopsClient;

+ 1
- 0
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",


+ 7608
- 0
src/components/Shop/RouteBoard.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 6
- 3
src/components/Shop/Shop.tsx Просмотреть файл

@@ -314,9 +314,12 @@ const Shop: React.FC = () => {
p: 2,
borderBottom: '1px solid #e0e0e0'
}}>
<Typography variant="h4">
店鋪路線管理
</Typography>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={2}>
<Typography variant="h4">店鋪路線管理</Typography>
<Button variant="outlined" onClick={() => router.push("/settings/shop/board")}>
MTMS 車線看板
</Button>
</Stack>
</Box>

{/* Tabs section */}


+ 364
- 0
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<string, string> = {
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<string, number> {
const m = new Map<string, number>();
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, number>,
): 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<string, TruckLaneWarningLaneRef>,
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<string, TruckLaneWarningLaneRef>();
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<string, Set<string>>
>();
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<string, Map<string, Set<string>>>();
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,
},
];
}

+ 192
- 0
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<string, T>(
current.map((l) => [l.id, { ...l, shops: [...l.shops] } as T]),
);

const byLane = new Map<string, RouteLaneImportPreviewRow[]>();
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;
}

+ 624
- 0
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<string, string> = {
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<string, string | number>;
};

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<number, string>;
dirtyMoveDistrictHints?: ReadonlyMap<number, string>;
dirtyDeletes: Set<number>;
stagedDeleteMeta: ReadonlyMap<number, StagedDeleteMeta>;
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<string, string[]>;
logisticNameById: ReadonlyMap<number, string>;
shopDistrictBaseline: ReadonlyMap<number, string>;
shopRowBaseline?: ReadonlyMap<number, ShopRowBaseline>;
}): 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<number>();

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<string, string>,
): { 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 };
}

+ 2
- 1
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",


+ 237
- 0
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"
}

+ 1
- 0
src/i18n/zh/common.json Просмотреть файл

@@ -450,6 +450,7 @@
"Shop": "店鋪",
"ShopAndTruck": "店鋪路線管理",
"DO floor (supplier)": "送貨單樓層(供應商)",
"Route Board": "車線看板",
"Shop Information": "店鋪資訊",
"Shop Name": "店鋪名稱",
"Shop Branch": "店鋪分店",


+ 237
- 0
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": "載入車線失敗"
}

Загрузка…
Отмена
Сохранить