| @@ -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 | |||
| @@ -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 ( | |||
| @@ -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); | |||
| } | |||
| @@ -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> | |||
| ); | |||
| } | |||
| @@ -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" }, | |||
| }); | |||
| }; | |||
| @@ -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); | |||
| }; | |||
| @@ -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" }, | |||
| }); | |||
| }; | |||
| @@ -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; | |||
| @@ -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", | |||
| @@ -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 */} | |||
| @@ -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, | |||
| }, | |||
| ]; | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -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 }; | |||
| } | |||
| @@ -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", | |||
| @@ -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" | |||
| } | |||
| @@ -450,6 +450,7 @@ | |||
| "Shop": "店鋪", | |||
| "ShopAndTruck": "店鋪路線管理", | |||
| "DO floor (supplier)": "送貨單樓層(供應商)", | |||
| "Route Board": "車線看板", | |||
| "Shop Information": "店鋪資訊", | |||
| "Shop Name": "店鋪名稱", | |||
| "Shop Branch": "店鋪分店", | |||
| @@ -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": "載入車線失敗" | |||
| } | |||