| @@ -3,7 +3,7 @@ | |||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute"; | |||||
| import { isFullBleedMainRoute } from "@/app/(main)/isFullBleedMainRoute"; | |||||
| import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; | ||||
| const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900"; | const MAIN_SURFACE = "min-h-screen bg-slate-50 dark:bg-slate-900"; | ||||
| @@ -28,7 +28,7 @@ export default function MainContentArea({ | |||||
| }) { | }) { | ||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| /** True when the active route is PO Workbench (full-bleed main area). */ | /** True when the active route is PO Workbench (full-bleed main area). */ | ||||
| const fullBleedWorkbench = isPoWorkbenchRoute(pathname); | |||||
| const fullBleedWorkbench = isFullBleedMainRoute(pathname); | |||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { isPoWorkbenchRoute } from "@/app/(main)/isPoWorkbenchRoute"; | |||||
| import { isFullBleedMainRoute } from "@/app/(main)/isFullBleedMainRoute"; | |||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import type { ReactNode } from "react"; | import type { ReactNode } from "react"; | ||||
| @@ -18,7 +18,7 @@ export default function MainLayoutBody({ | |||||
| mainContent, | mainContent, | ||||
| }: MainLayoutBodyProps) { | }: MainLayoutBodyProps) { | ||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| const isWorkbench = isPoWorkbenchRoute(pathname); | |||||
| const isWorkbench = isFullBleedMainRoute(pathname); | |||||
| if (isWorkbench) { | if (isWorkbench) { | ||||
| return ( | 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 { serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil"; | ||||
| // import { BASE_API_URL } from "@/config/api"; | // import { BASE_API_URL } from "@/config/api"; | ||||
| import { | import { | ||||
| serverFetch, | |||||
| serverFetchJson, | serverFetchJson, | ||||
| serverFetchWithNoContent, | serverFetchWithNoContent, | ||||
| ServerFetchError, | |||||
| } from "../../utils/fetchUtil"; | } from "../../utils/fetchUtil"; | ||||
| import { BASE_API_URL } from "../../../config/api"; | import { BASE_API_URL } from "../../../config/api"; | ||||
| import { revalidateTag } from "next/cache"; | import { revalidateTag } from "next/cache"; | ||||
| @@ -58,6 +60,16 @@ export interface SaveTruckLane { | |||||
| districtReference: string | null; | districtReference: string | null; | ||||
| storeId: string; | storeId: string; | ||||
| remark?: string | null; | 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 { | export interface DeleteTruckLane { | ||||
| @@ -84,6 +96,7 @@ export interface SaveTruckRequest { | |||||
| loadingSequence: number; | loadingSequence: number; | ||||
| districtReference?: string | null; | districtReference?: string | null; | ||||
| remark?: string | null; | remark?: string | null; | ||||
| logisticId?: number | null; | |||||
| } | } | ||||
| export interface CreateTruckWithoutShopRequest { | export interface CreateTruckWithoutShopRequest { | ||||
| @@ -92,6 +105,7 @@ export interface CreateTruckWithoutShopRequest { | |||||
| departureTime: string; | departureTime: string; | ||||
| loadingSequence?: number; | loadingSequence?: number; | ||||
| districtReference?: string | null; | districtReference?: string | null; | ||||
| logisticId?: number | null; | |||||
| remark?: string | 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) => { | export const deleteTruckLaneAction = async (data: DeleteTruckLane) => { | ||||
| const endpoint = `${BASE_API_URL}/truck/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) => { | export const findAllShopsByTruckLanceCodeAndRemarkAction = cache(async (truckLanceCode: string, remark: string) => { | ||||
| const endpoint = `${BASE_API_URL}/truck/findAllFromShopAndTruckByTruckLanceCodeAndRemarkAndDeletedFalse`; | const endpoint = `${BASE_API_URL}/truck/findAllFromShopAndTruckByTruckLanceCodeAndRemarkAndDeletedFalse`; | ||||
| const url = `${endpoint}?truckLanceCode=${encodeURIComponent(truckLanceCode)}&remark=${encodeURIComponent(remark)}`; | 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) => { | export const updateTruckShopDetailsAction = async (data: UpdateTruckShopDetailsRequest) => { | ||||
| const endpoint = `${BASE_API_URL}/truck/updateTruckShopDetails`; | 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 () => { | export const findAllUniqueShopNamesAndCodesFromTrucksAction = cache(async () => { | ||||
| const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`; | const endpoint = `${BASE_API_URL}/truck/findAllUniqueShopNamesAndCodesFromTrucks`; | ||||
| @@ -254,4 +478,125 @@ export const findAllUniqueShopNamesFromTrucksAction = cache(async () => { | |||||
| method: "GET", | method: "GET", | ||||
| headers: { "Content-Type": "application/json" }, | 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, | fetchAllShopsAction, | ||||
| findTruckLaneByShopIdAction, | findTruckLaneByShopIdAction, | ||||
| updateTruckLaneAction, | updateTruckLaneAction, | ||||
| updateLaneLogisticAction, | |||||
| deleteTruckLaneAction, | deleteTruckLaneAction, | ||||
| createTruckAction, | createTruckAction, | ||||
| findAllUniqueTruckLaneCombinationsAction, | findAllUniqueTruckLaneCombinationsAction, | ||||
| findAllForRouteBoardAction, | |||||
| exportRouteLanesExcelAction, | |||||
| exportRouteReportExcelAction, | |||||
| exportTruckLaneVersionReportExcelAction, | |||||
| importRouteLanesExcelAction, | |||||
| parseRouteLanesExcelAction, | |||||
| type ParseRouteLanesExcelResponse, | |||||
| type RouteLaneImportPreviewRow, | |||||
| findAllShopsByTruckLanceCodeAndRemarkAction, | findAllShopsByTruckLanceCodeAndRemarkAction, | ||||
| findAllShopsByTruckLanceCodeAction, | findAllShopsByTruckLanceCodeAction, | ||||
| createTruckWithoutShopAction, | createTruckWithoutShopAction, | ||||
| @@ -15,8 +24,21 @@ import { | |||||
| findAllUniqueRemarksFromTrucksAction, | findAllUniqueRemarksFromTrucksAction, | ||||
| findAllUniqueShopCodesFromTrucksAction, | findAllUniqueShopCodesFromTrucksAction, | ||||
| findAllUniqueShopNamesFromTrucksAction, | findAllUniqueShopNamesFromTrucksAction, | ||||
| createTruckLaneSnapshotAction, | |||||
| listTruckLaneVersionsAction, | |||||
| getTruckLaneVersionLinesAction, | |||||
| diffTruckLaneVersionsAction, | |||||
| restoreTruckLaneVersionAction, | |||||
| updateTruckLaneVersionNoteAction, | |||||
| type CreateTruckLaneSnapshotRequest, | |||||
| type UpdateTruckLaneVersionNoteRequest, | |||||
| type TruckLaneVersionResponse, | |||||
| type TruckLaneVersionLineResponse, | |||||
| type TruckLaneVersionDiffResponse, | |||||
| findAllByTruckLanceCodeAndDeletedFalseAction, | findAllByTruckLanceCodeAndDeletedFalseAction, | ||||
| findAllByTruckLanceCodeAndRemarkAndDeletedFalseAction, | |||||
| type SaveTruckLane, | type SaveTruckLane, | ||||
| type UpdateLaneLogisticRequest, | |||||
| type DeleteTruckLane, | type DeleteTruckLane, | ||||
| type SaveTruckRequest, | type SaveTruckRequest, | ||||
| type UpdateTruckShopDetailsRequest, | type UpdateTruckShopDetailsRequest, | ||||
| @@ -36,6 +58,12 @@ export const updateTruckLaneClient = async (data: SaveTruckLane): Promise<Messag | |||||
| return await updateTruckLaneAction(data); | return await updateTruckLaneAction(data); | ||||
| }; | }; | ||||
| export const updateLaneLogisticClient = async ( | |||||
| data: UpdateLaneLogisticRequest, | |||||
| ): Promise<MessageResponse> => { | |||||
| return await updateLaneLogisticAction(data); | |||||
| }; | |||||
| export const deleteTruckLaneClient = async (data: DeleteTruckLane): Promise<MessageResponse> => { | export const deleteTruckLaneClient = async (data: DeleteTruckLane): Promise<MessageResponse> => { | ||||
| return await deleteTruckLaneAction(data); | return await deleteTruckLaneAction(data); | ||||
| }; | }; | ||||
| @@ -48,6 +76,35 @@ export const findAllUniqueTruckLaneCombinationsClient = async () => { | |||||
| return await findAllUniqueTruckLaneCombinationsAction(); | 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) => { | export const findAllShopsByTruckLanceCodeAndRemarkClient = async (truckLanceCode: string, remark: string) => { | ||||
| return await findAllShopsByTruckLanceCodeAndRemarkAction(truckLanceCode, remark); | return await findAllShopsByTruckLanceCodeAndRemarkAction(truckLanceCode, remark); | ||||
| }; | }; | ||||
| @@ -60,6 +117,13 @@ export const findAllByTruckLanceCodeAndDeletedFalseClient = async (truckLanceCod | |||||
| return await findAllByTruckLanceCodeAndDeletedFalseAction(truckLanceCode); | 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> => { | export const updateTruckShopDetailsClient = async (data: UpdateTruckShopDetailsRequest): Promise<MessageResponse> => { | ||||
| return await updateTruckShopDetailsAction(data); | return await updateTruckShopDetailsAction(data); | ||||
| }; | }; | ||||
| @@ -84,4 +148,40 @@ export const findAllUniqueShopNamesFromTrucksClient = async () => { | |||||
| return await findAllUniqueShopNamesFromTrucksAction(); | 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; | export default fetchAllShopsClient; | ||||
| @@ -27,6 +27,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/settings/equipment": "Equipment", | "/settings/equipment": "Equipment", | ||||
| "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", | "/settings/equipment/MaintenanceEdit": "MaintenanceEdit", | ||||
| "/settings/shop": "ShopAndTruck", | "/settings/shop": "ShopAndTruck", | ||||
| "/settings/shop/board": "Route Board", | |||||
| "/settings/shop/detail": "Shop Detail", | "/settings/shop/detail": "Shop Detail", | ||||
| "/settings/shop/truckdetail": "Truck Lane Detail", | "/settings/shop/truckdetail": "Truck Lane Detail", | ||||
| "/settings/printer": "Printer", | "/settings/printer": "Printer", | ||||
| @@ -314,9 +314,12 @@ const Shop: React.FC = () => { | |||||
| p: 2, | p: 2, | ||||
| borderBottom: '1px solid #e0e0e0' | 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> | </Box> | ||||
| {/* Tabs section */} | {/* 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,13 @@ | |||||
| "No": "No", | "No": "No", | ||||
| "Equipment Name": "Equipment Name", | "Equipment Name": "Equipment Name", | ||||
| "Equipment Code": "Equipment Code", | "Equipment Code": "Equipment Code", | ||||
| <<<<<<< Updated upstream | |||||
| "ShopAndTruck": "ShopAndTruck", | "ShopAndTruck": "ShopAndTruck", | ||||
| "DO floor (supplier)": "DO floor (supplier)", | "DO floor (supplier)": "DO floor (supplier)", | ||||
| ======= | |||||
| "ShopAndTruck": "Shop & route management", | |||||
| "Route Board": "Route board", | |||||
| >>>>>>> Stashed changes | |||||
| "TruckLance Code is required": "TruckLance Code is required", | "TruckLance Code is required": "TruckLance Code is required", | ||||
| "Truck shop details updated successfully": "Truck shop details updated successfully", | "Truck shop details updated successfully": "Truck shop details updated successfully", | ||||
| "Failed to save truck shop details": "Failed to save truck shop details", | "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" | |||||
| } | |||||
| @@ -449,7 +449,11 @@ | |||||
| "Batch Count": "批數", | "Batch Count": "批數", | ||||
| "Shop": "店鋪", | "Shop": "店鋪", | ||||
| "ShopAndTruck": "店鋪路線管理", | "ShopAndTruck": "店鋪路線管理", | ||||
| <<<<<<< Updated upstream | |||||
| "DO floor (supplier)": "送貨單樓層(供應商)", | "DO floor (supplier)": "送貨單樓層(供應商)", | ||||
| ======= | |||||
| "Route Board": "車線看板", | |||||
| >>>>>>> Stashed changes | |||||
| "Shop Information": "店鋪資訊", | "Shop Information": "店鋪資訊", | ||||
| "Shop Name": "店鋪名稱", | "Shop Name": "店鋪名稱", | ||||
| "Shop Branch": "店鋪分店", | "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": "載入車線失敗" | |||||
| } | |||||