| @@ -1,10 +1,23 @@ | |||||
| import { I18nProvider } from "@/i18n"; | import { I18nProvider } from "@/i18n"; | ||||
| import { authOptions } from "@/config/authConfig"; | |||||
| import { AUTH } from "@/authorities"; | |||||
| import { getServerSession } from "next-auth"; | |||||
| import { redirect } from "next/navigation"; | |||||
| export default function ReportLayout({ | |||||
| export default async function ReportLayout({ | |||||
| children, | children, | ||||
| }: { | }: { | ||||
| children: React.ReactNode; | children: React.ReactNode; | ||||
| }) { | }) { | ||||
| const session = await getServerSession(authOptions); | |||||
| const abilities = session?.user?.abilities ?? []; | |||||
| const canAccess = | |||||
| abilities.includes(AUTH.REPORT_MGMT) || abilities.includes(AUTH.ADMIN); | |||||
| if (!canAccess) { | |||||
| redirect("/dashboard"); | |||||
| } | |||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["report", "navigation", "common"]}> | <I18nProvider namespaces={["report", "navigation", "common"]}> | ||||
| {children} | {children} | ||||
| @@ -27,6 +27,8 @@ export interface DoDetail { | |||||
| status: string; | status: string; | ||||
| /** 加單 DO */ | /** 加單 DO */ | ||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | |||||
| handlerName?: string | null; | |||||
| deliveryOrderLines: DoDetailLine[]; | deliveryOrderLines: DoDetailLine[]; | ||||
| } | } | ||||
| @@ -56,6 +58,7 @@ export interface DoSearchAll { | |||||
| shopName: string; | shopName: string; | ||||
| shopAddress?: string; | shopAddress?: string; | ||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| truckLanceCode?: string | null; | |||||
| } | } | ||||
| export interface DoSearchLiteResponse { | export interface DoSearchLiteResponse { | ||||
| records: DoSearchAll[]; | records: DoSearchAll[]; | ||||
| @@ -855,6 +855,18 @@ export const retryFailedTruckLaneScheduleAction = async ( | |||||
| }); | }); | ||||
| }; | }; | ||||
| export const reactivateCancelledTruckLaneScheduleAction = async ( | |||||
| id: number, | |||||
| body?: RetryFailedTruckLaneScheduleRequest, | |||||
| ): Promise<TruckLaneScheduleResponse> => { | |||||
| const endpoint = `${BASE_API_URL}/truckLaneSchedule/${id}/reactivate`; | |||||
| return serverFetchJson<TruckLaneScheduleResponse>(endpoint, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(body ?? {}), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| export const ignoreTruckLaneScheduleAction = async ( | export const ignoreTruckLaneScheduleAction = async ( | ||||
| id: number, | id: number, | ||||
| ): Promise<TruckLaneScheduleResponse> => { | ): Promise<TruckLaneScheduleResponse> => { | ||||
| @@ -38,6 +38,7 @@ import { | |||||
| cancelTruckLaneScheduleAction, | cancelTruckLaneScheduleAction, | ||||
| applyNowTruckLaneScheduleAction, | applyNowTruckLaneScheduleAction, | ||||
| retryFailedTruckLaneScheduleAction, | retryFailedTruckLaneScheduleAction, | ||||
| reactivateCancelledTruckLaneScheduleAction, | |||||
| ignoreTruckLaneScheduleAction, | ignoreTruckLaneScheduleAction, | ||||
| type RetryFailedTruckLaneScheduleRequest, | type RetryFailedTruckLaneScheduleRequest, | ||||
| parseTruckLaneScheduleExcelAction, | parseTruckLaneScheduleExcelAction, | ||||
| @@ -255,6 +256,13 @@ export const retryFailedTruckLaneScheduleClient = async ( | |||||
| return await retryFailedTruckLaneScheduleAction(id, body); | return await retryFailedTruckLaneScheduleAction(id, body); | ||||
| }; | }; | ||||
| export const reactivateCancelledTruckLaneScheduleClient = async ( | |||||
| id: number, | |||||
| body?: RetryFailedTruckLaneScheduleRequest, | |||||
| ): Promise<TruckLaneScheduleResponse> => { | |||||
| return await reactivateCancelledTruckLaneScheduleAction(id, body); | |||||
| }; | |||||
| export const ignoreTruckLaneScheduleClient = async ( | export const ignoreTruckLaneScheduleClient = async ( | ||||
| id: number, | id: number, | ||||
| ): Promise<TruckLaneScheduleResponse> => { | ): Promise<TruckLaneScheduleResponse> => { | ||||
| @@ -16,6 +16,6 @@ export const AUTH = { | |||||
| JOB_CREATE: "JOB_CREATE", | JOB_CREATE: "JOB_CREATE", | ||||
| JOB_PICK: "JOB_PICK", | JOB_PICK: "JOB_PICK", | ||||
| JOB_MAT: "JOB_MAT", | JOB_MAT: "JOB_MAT", | ||||
| JOB_PROD: "JOB_PROD", | |||||
| JOB_PROD: "JOB_PROD", | |||||
| REPORT_MGMT: "REPORT_MGMT", | |||||
| } as const; | } as const; | ||||
| @@ -0,0 +1,785 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useMemo, useRef, useState } from "react"; | |||||
| import { | |||||
| Autocomplete, | |||||
| Box, | |||||
| Button, | |||||
| Dialog, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| FormControl, | |||||
| IconButton, | |||||
| InputLabel, | |||||
| MenuItem, | |||||
| Paper, | |||||
| Select, | |||||
| Stack, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Tooltip, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | |||||
| import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; | |||||
| import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; | |||||
| import StorefrontIcon from "@mui/icons-material/Storefront"; | |||||
| import { Add, Close, Delete, Search } from "@mui/icons-material"; | |||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import dayjs, { Dayjs } from "dayjs"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { GridColDef } from "@mui/x-data-grid"; | |||||
| import Swal from "sweetalert2"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | |||||
| import { DoDetail, DoDetailLine, fetchDoDetail, fetchDoSearch } from "@/app/api/do/actions"; | |||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | |||||
| import { | |||||
| REPLENISHMENT_FIELD_ICON_SX, | |||||
| REPLENISHMENT_TABLE_AUTOCOMPLETE_SX, | |||||
| REPLENISHMENT_TABLE_ENTRY_ROW_SX, | |||||
| REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX, | |||||
| REPLENISHMENT_TABLE_SX, | |||||
| REPLENISHMENT_LOOKUP_BUTTON_SX, | |||||
| REPLENISHMENT_SOURCE_HEADER_SX, | |||||
| REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX, | |||||
| REPLENISHMENT_TEXTFIELD_SX, | |||||
| ReplenishmentFieldLabel, | |||||
| ReplenishmentItemEntryPlainText, | |||||
| ReplenishmentTextField, | |||||
| replenishmentSearchGridInputSx, | |||||
| replenishmentSearchGridLabelSx, | |||||
| replenishmentSearchGridShopRowSx, | |||||
| } from "./ReplenishmentFilterField"; | |||||
| export type ReplenishmentStatus = "pending" | "processing" | "completed"; | |||||
| export type ReplenishmentDraftRow = { | |||||
| rowId: string; | |||||
| deliveryDate: string; | |||||
| sourceDoId: number; | |||||
| sourceDoCode: string; | |||||
| sourceDoLineId: number; | |||||
| itemId?: number; | |||||
| itemNo: string; | |||||
| itemName: string; | |||||
| originalQty: number; | |||||
| replenishQty: number; | |||||
| shortUom?: string; | |||||
| shopCode?: string; | |||||
| shopName?: string; | |||||
| truckLaneCode?: string; | |||||
| }; | |||||
| export type ReplenishmentRecord = ReplenishmentDraftRow & { | |||||
| id: number; | |||||
| code: string; | |||||
| targetDoId?: number; | |||||
| targetDoCode?: string; | |||||
| pickOrderLineId?: number; | |||||
| status: ReplenishmentStatus; | |||||
| created: string; | |||||
| }; | |||||
| type SourceDoContext = { | |||||
| doId: number; | |||||
| doCode: string; | |||||
| shopCode?: string; | |||||
| shopName?: string; | |||||
| truckLaneCode?: string | null; | |||||
| status: string; | |||||
| lines: DoDetailLine[]; | |||||
| }; | |||||
| let localIdSeq = 1; | |||||
| let replenishmentCodeSeq = 1; | |||||
| function nextReplenishmentCode(deliveryDate: string): string { | |||||
| const ymd = deliveryDate.replace(/-/g, ""); | |||||
| const seq = String(replenishmentCodeSeq++).padStart(3, "0"); | |||||
| return `RP-${ymd}-${seq}`; | |||||
| } | |||||
| /** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */ | |||||
| function matchesShopInput(detail: DoDetail, shopInput: string): boolean { | |||||
| const normalized = shopInput.trim().toLowerCase(); | |||||
| if (!normalized) return false; | |||||
| const code = detail.shopCode?.toLowerCase() ?? ""; | |||||
| const name = detail.shopName?.toLowerCase() ?? ""; | |||||
| return code.includes(normalized) || name.startsWith(normalized); | |||||
| } | |||||
| function matchesDeliveryDate( | |||||
| estimatedArrivalDate: number[] | undefined, | |||||
| deliveryDateStr: string, | |||||
| ): boolean { | |||||
| if (!estimatedArrivalDate?.length) return false; | |||||
| return arrayToDateString(estimatedArrivalDate) === deliveryDateStr; | |||||
| } | |||||
| function lineUomDisplay(line?: DoDetailLine | null): string { | |||||
| if (!line) return ""; | |||||
| return (line.shortUom ?? line.uomCode ?? line.uom ?? "").trim(); | |||||
| } | |||||
| const DoReplenishmentTab: React.FC = () => { | |||||
| const { t } = useTranslation("do"); | |||||
| const inFlightRef = useRef(false); | |||||
| const itemCodeInputRef = useRef<HTMLInputElement>(null); | |||||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||||
| const [isLookingUp, setIsLookingUp] = useState(false); | |||||
| const [deliveryDate, setDeliveryDate] = useState<Dayjs | null>(dayjs()); | |||||
| const [doCodeSuffix, setDoCodeSuffix] = useState(""); | |||||
| const [shopInput, setShopInput] = useState(""); | |||||
| const [sourceDo, setSourceDo] = useState<SourceDoContext | null>(null); | |||||
| const [selectedLine, setSelectedLine] = useState<DoDetailLine | null>(null); | |||||
| const [replenishQtyInput, setReplenishQtyInput] = useState(""); | |||||
| const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]); | |||||
| const [records, setRecords] = useState<ReplenishmentRecord[]>([]); | |||||
| const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all"); | |||||
| const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null); | |||||
| const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); | |||||
| const deliveryDateStr = deliveryDate?.format("YYYY-MM-DD") ?? ""; | |||||
| const handleLookupSourceDo = useCallback(async () => { | |||||
| const suffix = doCodeSuffix.trim(); | |||||
| const shop = shopInput.trim(); | |||||
| if (suffix.length !== 4) { | |||||
| await Swal.fire({ | |||||
| icon: "warning", | |||||
| title: t("DO code suffix must be exactly 4 characters"), | |||||
| }); | |||||
| return; | |||||
| } | |||||
| if (!deliveryDateStr) { | |||||
| await Swal.fire({ icon: "warning", title: t("Delivery date is required") }); | |||||
| return; | |||||
| } | |||||
| if (!shop) { | |||||
| await Swal.fire({ icon: "warning", title: t("Shop code or name is required") }); | |||||
| return; | |||||
| } | |||||
| setIsLookingUp(true); | |||||
| try { | |||||
| const searchRes = await fetchDoSearch( | |||||
| suffix, | |||||
| "", | |||||
| "completed", | |||||
| "", | |||||
| "", | |||||
| `${deliveryDateStr}T00:00:00`, | |||||
| "", | |||||
| 1, | |||||
| 100, | |||||
| undefined, | |||||
| null, | |||||
| null, | |||||
| ); | |||||
| const candidates = searchRes.records.filter( | |||||
| (r) => | |||||
| r.code.endsWith(suffix) && | |||||
| matchesDeliveryDate(r.estimatedArrivalDate, deliveryDateStr), | |||||
| ); | |||||
| if (candidates.length === 0) { | |||||
| await Swal.fire({ icon: "error", title: t("Source DO not found") }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| const details = await Promise.all(candidates.map((c) => fetchDoDetail(c.id))); | |||||
| const matched = details.filter((d) => matchesShopInput(d, shop)); | |||||
| if (matched.length === 0) { | |||||
| await Swal.fire({ icon: "error", title: t("Source DO not found") }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| if (matched.length > 1) { | |||||
| await Swal.fire({ | |||||
| icon: "error", | |||||
| title: t("Multiple source DOs matched"), | |||||
| text: t("Please verify DO code suffix, delivery date and shop."), | |||||
| }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| const detail = matched[0]; | |||||
| const matchedCandidate = candidates.find((c) => c.id === detail.id); | |||||
| if (detail.status !== "completed") { | |||||
| await Swal.fire({ | |||||
| icon: "error", | |||||
| title: t("Source DO must be completed"), | |||||
| text: t("Only completed delivery orders can be used as replenishment source."), | |||||
| }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| setSourceDo({ | |||||
| doId: detail.id, | |||||
| doCode: detail.code, | |||||
| shopCode: detail.shopCode, | |||||
| shopName: detail.shopName, | |||||
| truckLaneCode: matchedCandidate?.truckLanceCode ?? null, | |||||
| status: detail.status, | |||||
| lines: detail.deliveryOrderLines ?? [], | |||||
| }); | |||||
| setDraftRows([]); | |||||
| setSelectedLine(null); | |||||
| setReplenishQtyInput(""); | |||||
| } catch { | |||||
| await Swal.fire({ icon: "error", title: t("Failed to lookup source DO") }); | |||||
| } finally { | |||||
| setIsLookingUp(false); | |||||
| } | |||||
| }, [deliveryDateStr, doCodeSuffix, shopInput, t]); | |||||
| const handleAddDraftRow = useCallback(() => { | |||||
| if (!sourceDo) { | |||||
| void Swal.fire({ icon: "warning", title: t("Please lookup source DO first") }); | |||||
| return; | |||||
| } | |||||
| if (!deliveryDateStr) { | |||||
| void Swal.fire({ icon: "warning", title: t("Delivery date is required") }); | |||||
| return; | |||||
| } | |||||
| if (!selectedLine) { | |||||
| void Swal.fire({ icon: "warning", title: t("Please select an item") }); | |||||
| return; | |||||
| } | |||||
| const qty = Number(replenishQtyInput); | |||||
| if (!Number.isFinite(qty) || qty <= 0) { | |||||
| void Swal.fire({ icon: "warning", title: t("Replenish qty must be greater than zero") }); | |||||
| return; | |||||
| } | |||||
| const line = selectedLine; | |||||
| const duplicate = draftRows.some( | |||||
| (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | |||||
| ); | |||||
| if (duplicate) { | |||||
| void Swal.fire({ icon: "warning", title: t("This item is already in the draft list") }); | |||||
| return; | |||||
| } | |||||
| setDraftRows((prev) => [ | |||||
| ...prev, | |||||
| { | |||||
| rowId: `draft-${Date.now()}-${prev.length}`, | |||||
| deliveryDate: deliveryDateStr, | |||||
| sourceDoId: sourceDo.doId, | |||||
| sourceDoCode: sourceDo.doCode, | |||||
| sourceDoLineId: line.id, | |||||
| itemNo: line.itemNo ?? "", | |||||
| itemName: line.itemName ?? line.itemNo ?? "", | |||||
| originalQty: line.qty ?? 0, | |||||
| replenishQty: qty, | |||||
| shortUom: lineUomDisplay(line) || undefined, | |||||
| shopCode: sourceDo.shopCode, | |||||
| shopName: sourceDo.shopName, | |||||
| truckLaneCode: undefined, | |||||
| }, | |||||
| ]); | |||||
| setSelectedLine(null); | |||||
| setReplenishQtyInput(""); | |||||
| window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); | |||||
| }, [ | |||||
| deliveryDateStr, | |||||
| draftRows, | |||||
| replenishQtyInput, | |||||
| selectedLine, | |||||
| sourceDo, | |||||
| t, | |||||
| ]); | |||||
| const handleRemoveDraftRow = useCallback((rowId: string) => { | |||||
| setDraftRows((prev) => prev.filter((r) => r.rowId !== rowId)); | |||||
| }, []); | |||||
| const handleSubmit = useCallback(async () => { | |||||
| if (inFlightRef.current) return; | |||||
| if (draftRows.length === 0) { | |||||
| await Swal.fire({ icon: "warning", title: t("No draft rows to submit") }); | |||||
| return; | |||||
| } | |||||
| inFlightRef.current = true; | |||||
| setIsSubmitting(true); | |||||
| try { | |||||
| const now = new Date().toISOString(); | |||||
| const newRecords: ReplenishmentRecord[] = draftRows.map((row) => ({ | |||||
| ...row, | |||||
| id: localIdSeq++, | |||||
| code: nextReplenishmentCode(row.deliveryDate), | |||||
| status: "pending", | |||||
| created: now, | |||||
| })); | |||||
| setRecords((prev) => [...newRecords, ...prev]); | |||||
| setDraftRows([]); | |||||
| await Swal.fire({ | |||||
| icon: "info", | |||||
| title: t("Replenishment API not ready"), | |||||
| text: t("Records saved locally for preview. Backend integration pending."), | |||||
| }); | |||||
| } finally { | |||||
| setIsSubmitting(false); | |||||
| inFlightRef.current = false; | |||||
| } | |||||
| }, [draftRows, t]); | |||||
| const trackColumns: GridColDef<ReplenishmentRecord>[] = useMemo( | |||||
| () => [ | |||||
| { field: "code", headerName: t("Replenishment Code"), width: 140 }, | |||||
| { field: "sourceDoCode", headerName: t("Source DO"), width: 120 }, | |||||
| { field: "shopName", headerName: t("Shop Name"), flex: 1, minWidth: 120 }, | |||||
| { | |||||
| field: "truckLaneCode", | |||||
| headerName: t("Truck Lance Code"), | |||||
| width: 120, | |||||
| valueGetter: (params) => params.row.truckLaneCode ?? "—", | |||||
| }, | |||||
| { field: "itemNo", headerName: t("Item No."), width: 100 }, | |||||
| { field: "itemName", headerName: t("Item Name"), flex: 1, minWidth: 120 }, | |||||
| { | |||||
| field: "replenishQty", | |||||
| headerName: t("Replenish Qty"), | |||||
| width: 120, | |||||
| valueGetter: (params) => { | |||||
| const row = params.row as ReplenishmentRecord; | |||||
| return row.shortUom ? `${row.replenishQty} ${row.shortUom}` : row.replenishQty; | |||||
| }, | |||||
| }, | |||||
| { | |||||
| field: "targetDoCode", | |||||
| headerName: t("Target DO"), | |||||
| width: 120, | |||||
| valueGetter: (params) => params.row.targetDoCode ?? "—", | |||||
| }, | |||||
| { | |||||
| field: "status", | |||||
| headerName: t("Status"), | |||||
| width: 110, | |||||
| valueFormatter: (params) => t(String(params.value)), | |||||
| }, | |||||
| { | |||||
| field: "created", | |||||
| headerName: t("Created"), | |||||
| width: 160, | |||||
| valueFormatter: (params) => | |||||
| params.value ? dayjs(String(params.value)).format("YYYY-MM-DD HH:mm") : "", | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const selectedLineUom = lineUomDisplay(selectedLine); | |||||
| const filteredRecords = useMemo(() => { | |||||
| return records.filter((r) => { | |||||
| if (trackStatusFilter !== "all" && r.status !== trackStatusFilter) return false; | |||||
| if (trackDateFilter && r.deliveryDate !== trackDateFilter.format("YYYY-MM-DD")) { | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| }); | |||||
| }, [records, trackDateFilter, trackStatusFilter]); | |||||
| const datePickerSlotProps = useMemo( | |||||
| () => ({ | |||||
| textField: { | |||||
| size: "small" as const, | |||||
| fullWidth: true, | |||||
| variant: "filled" as const, | |||||
| placeholder: t("replenishmentDatePlaceholder"), | |||||
| sx: REPLENISHMENT_TEXTFIELD_SX, | |||||
| InputProps: { disableUnderline: true }, | |||||
| }, | |||||
| }), | |||||
| [t], | |||||
| ); | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| position: "relative", | |||||
| p: 2, | |||||
| bgcolor: (theme) => (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), | |||||
| }} | |||||
| > | |||||
| <Tooltip title={t("Replenishment Tracking")}> | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={() => setTrackingDialogOpen(true)} | |||||
| aria-label={t("Replenishment Tracking")} | |||||
| sx={{ | |||||
| position: "absolute", | |||||
| top: 8, | |||||
| right: 8, | |||||
| zIndex: 1, | |||||
| color: "text.secondary", | |||||
| }} | |||||
| > | |||||
| <InfoOutlinedIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| </Tooltip> | |||||
| <Stack spacing={2}> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "grid", | |||||
| gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" }, | |||||
| columnGap: 2, | |||||
| rowGap: 1, | |||||
| alignItems: "stretch", | |||||
| pr: { xs: 4, lg: 4 }, | |||||
| }} | |||||
| > | |||||
| <ReplenishmentFieldLabel | |||||
| icon={<CalendarTodayIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />} | |||||
| title={t("Estimated Arrival Date")} | |||||
| required | |||||
| sx={replenishmentSearchGridLabelSx(1)} | |||||
| /> | |||||
| <Box sx={replenishmentSearchGridInputSx(1)}> | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| format="YYYY-MM-DD" | |||||
| value={deliveryDate} | |||||
| onChange={(v) => { | |||||
| setDeliveryDate(v); | |||||
| setSourceDo(null); | |||||
| }} | |||||
| slotProps={datePickerSlotProps} | |||||
| sx={{ width: "100%" }} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| </Box> | |||||
| <ReplenishmentFieldLabel | |||||
| icon={<ReceiptLongIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />} | |||||
| title={t("DO Code Last 4")} | |||||
| required | |||||
| sx={replenishmentSearchGridLabelSx(2)} | |||||
| /> | |||||
| <Box sx={replenishmentSearchGridInputSx(2)}> | |||||
| <ReplenishmentTextField | |||||
| value={doCodeSuffix} | |||||
| onChange={(e) => { | |||||
| setDoCodeSuffix(e.target.value.slice(0, 4)); | |||||
| setSourceDo(null); | |||||
| }} | |||||
| placeholder={t("replenishmentDoSuffixPlaceholder")} | |||||
| inputProps={{ maxLength: 4 }} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter") { | |||||
| e.preventDefault(); | |||||
| void handleLookupSourceDo(); | |||||
| } | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| <ReplenishmentFieldLabel | |||||
| icon={<StorefrontIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />} | |||||
| title={t("Shop Code")} | |||||
| required | |||||
| sx={replenishmentSearchGridLabelSx(3)} | |||||
| /> | |||||
| <Box sx={replenishmentSearchGridShopRowSx}> | |||||
| <ReplenishmentTextField | |||||
| value={shopInput} | |||||
| onChange={(e) => { | |||||
| setShopInput(e.target.value); | |||||
| setSourceDo(null); | |||||
| }} | |||||
| placeholder={t("replenishmentShopPlaceholder")} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter") { | |||||
| e.preventDefault(); | |||||
| void handleLookupSourceDo(); | |||||
| } | |||||
| }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| disableElevation | |||||
| startIcon={<Search fontSize="small" />} | |||||
| onClick={() => void handleLookupSourceDo()} | |||||
| disabled={isLookingUp} | |||||
| sx={REPLENISHMENT_LOOKUP_BUTTON_SX} | |||||
| > | |||||
| {t("Lookup")} | |||||
| </Button> | |||||
| </Box> | |||||
| </Box> | |||||
| {sourceDo && ( | |||||
| <Box sx={REPLENISHMENT_SOURCE_HEADER_SX}> | |||||
| <Typography | |||||
| variant="body2" | |||||
| fontWeight={700} | |||||
| sx={{ wordBreak: "break-word", lineHeight: 1.5, width: "100%" }} | |||||
| > | |||||
| {t("Delivery Order Code")}: {sourceDo.doCode} | |||||
| {" "} | |||||
| {t("Shop Name")}: {sourceDo.shopName ?? "—"} | |||||
| {" "} | |||||
| {t("Truck Lance Code")}:{" "} | |||||
| {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")} | |||||
| </Typography> | |||||
| </Box> | |||||
| )} | |||||
| {sourceDo && ( | |||||
| <Stack spacing={1.5}> | |||||
| <TableContainer | |||||
| sx={(theme) => ({ | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| borderRadius: 2, | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", | |||||
| })} | |||||
| > | |||||
| <Table size="small" sx={REPLENISHMENT_TABLE_SX}> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ width: { md: "18%" }, minWidth: { md: 168 } }}> | |||||
| {t("Replenishment item code")} | |||||
| </TableCell> | |||||
| <TableCell sx={{ width: { md: "28%" } }}>{t("Item Name")}</TableCell> | |||||
| <TableCell align="right" sx={{ width: { md: "11%" }, whiteSpace: "nowrap" }}> | |||||
| {t("Original Shipment Qty")} | |||||
| </TableCell> | |||||
| <TableCell align="right" sx={{ width: { md: "12%" }, whiteSpace: "nowrap" }}> | |||||
| {t("Replenish Qty")} | |||||
| </TableCell> | |||||
| <TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}> | |||||
| {t("uom")} | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ width: { md: 120 }, whiteSpace: "nowrap" }}> | |||||
| {t("Action")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {draftRows.map((row) => ( | |||||
| <TableRow key={row.rowId} hover> | |||||
| <TableCell>{row.itemNo}</TableCell> | |||||
| <TableCell sx={{ wordBreak: "break-word" }}>{row.itemName}</TableCell> | |||||
| <TableCell align="right">{row.originalQty}</TableCell> | |||||
| <TableCell align="right">{row.replenishQty}</TableCell> | |||||
| <TableCell>{row.shortUom || "—"}</TableCell> | |||||
| <TableCell align="center"> | |||||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | |||||
| <IconButton | |||||
| size="small" | |||||
| color="error" | |||||
| onClick={() => handleRemoveDraftRow(row.rowId)} | |||||
| aria-label={t("Delete")} | |||||
| > | |||||
| <Delete fontSize="small" /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| <TableRow sx={REPLENISHMENT_TABLE_ENTRY_ROW_SX}> | |||||
| <TableCell> | |||||
| <Autocomplete | |||||
| size="small" | |||||
| fullWidth | |||||
| options={sourceDo.lines.filter( | |||||
| (line) => | |||||
| !draftRows.some( | |||||
| (r) => | |||||
| r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | |||||
| ), | |||||
| )} | |||||
| value={selectedLine} | |||||
| onChange={(_, newValue) => setSelectedLine(newValue)} | |||||
| getOptionLabel={(line) => line.itemNo ?? ""} | |||||
| isOptionEqualToValue={(a, b) => a.id === b.id} | |||||
| filterOptions={(options, { inputValue }) => { | |||||
| const query = inputValue.trim().toLowerCase(); | |||||
| if (!query) return options; | |||||
| return options.filter((line) => | |||||
| (line.itemNo ?? "").toLowerCase().includes(query), | |||||
| ); | |||||
| }} | |||||
| renderInput={(params) => { | |||||
| const { inputProps } = params; | |||||
| return ( | |||||
| <ReplenishmentTextField | |||||
| {...params} | |||||
| placeholder={t("Replenishment item code")} | |||||
| inputProps={{ | |||||
| ...inputProps, | |||||
| ref: (node: HTMLInputElement | null) => { | |||||
| itemCodeInputRef.current = node; | |||||
| const { ref } = inputProps; | |||||
| if (typeof ref === "function") ref(node); | |||||
| else if (ref) { | |||||
| ( | |||||
| ref as React.MutableRefObject<HTMLInputElement | null> | |||||
| ).current = node; | |||||
| } | |||||
| }, | |||||
| }} | |||||
| InputProps={{ ...params.InputProps, disableUnderline: true }} | |||||
| /> | |||||
| ); | |||||
| }} | |||||
| sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| value={ | |||||
| selectedLine ? (selectedLine.itemName ?? selectedLine.itemNo ?? "") : "" | |||||
| } | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| reserveSpace | |||||
| value={selectedLine != null ? String(selectedLine.qty ?? "") : ""} | |||||
| sx={{ whiteSpace: "nowrap", textAlign: "right", minHeight: "unset" }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <Box sx={{ display: "flex", justifyContent: "flex-end" }}> | |||||
| <ReplenishmentTextField | |||||
| type="number" | |||||
| hiddenLabel | |||||
| fullWidth={false} | |||||
| value={replenishQtyInput} | |||||
| onChange={(e) => setReplenishQtyInput(e.target.value)} | |||||
| inputProps={{ min: 0, step: "any", style: { textAlign: "right" } }} | |||||
| sx={(theme) => ({ | |||||
| ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme), | |||||
| width: 72, | |||||
| "& .MuiFilledInput-input": { | |||||
| textAlign: "right", | |||||
| }, | |||||
| })} | |||||
| /> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| reserveSpace | |||||
| value={selectedLineUom} | |||||
| sx={{ whiteSpace: "nowrap" }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| startIcon={<Add />} | |||||
| onClick={handleAddDraftRow} | |||||
| sx={{ | |||||
| borderRadius: 2, | |||||
| textTransform: "none", | |||||
| whiteSpace: "nowrap", | |||||
| px: 1.5, | |||||
| fontSize: (theme) => theme.typography.body2.fontSize, | |||||
| }} | |||||
| > | |||||
| {t("Add Row")} | |||||
| </Button> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| {draftRows.length > 0 && ( | |||||
| <Box sx={{ display: "flex", justifyContent: "flex-end" }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={() => void handleSubmit()} | |||||
| disabled={isSubmitting} | |||||
| > | |||||
| {t("Submit")} | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| </Stack> | |||||
| )} | |||||
| </Stack> | |||||
| </Paper> | |||||
| <Dialog | |||||
| open={trackingDialogOpen} | |||||
| onClose={() => setTrackingDialogOpen(false)} | |||||
| maxWidth="lg" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle | |||||
| sx={{ | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "space-between", | |||||
| pr: 1, | |||||
| }} | |||||
| > | |||||
| {t("Replenishment Tracking")} | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={() => setTrackingDialogOpen(false)} | |||||
| aria-label={t("Cancel")} | |||||
| > | |||||
| <Close fontSize="small" /> | |||||
| </IconButton> | |||||
| </DialogTitle> | |||||
| <DialogContent dividers sx={{ p: 0 }}> | |||||
| <Box sx={{ px: 2, pt: 1.5, pb: 1 }}> | |||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||||
| <Box sx={{ minWidth: 200, maxWidth: 280 }}> | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| format="YYYY-MM-DD" | |||||
| value={trackDateFilter} | |||||
| onChange={(v) => setTrackDateFilter(v)} | |||||
| slotProps={datePickerSlotProps} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| </Box> | |||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||||
| <InputLabel>{t("Status")}</InputLabel> | |||||
| <Select | |||||
| label={t("Status")} | |||||
| value={trackStatusFilter} | |||||
| onChange={(e) => | |||||
| setTrackStatusFilter(e.target.value as ReplenishmentStatus | "all") | |||||
| } | |||||
| > | |||||
| <MenuItem value="all">{t("All")}</MenuItem> | |||||
| <MenuItem value="pending">{t("pending")}</MenuItem> | |||||
| <MenuItem value="processing">{t("processing")}</MenuItem> | |||||
| <MenuItem value="completed">{t("completed")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Stack> | |||||
| </Box> | |||||
| <StyledDataGrid | |||||
| rows={filteredRecords} | |||||
| columns={trackColumns} | |||||
| autoHeight | |||||
| disableRowSelectionOnClick | |||||
| pageSizeOptions={[10, 25, 50]} | |||||
| initialState={{ pagination: { paginationModel: { pageSize: 10 } } }} | |||||
| /> | |||||
| </DialogContent> | |||||
| </Dialog> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| export default DoReplenishmentTab; | |||||
| @@ -37,6 +37,7 @@ import Swal from "sweetalert2"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; | import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; | ||||
| import DoReplenishmentTab from "./DoReplenishmentTab"; | |||||
| type Props = { | type Props = { | ||||
| filterArgs?: Record<string, any>; | filterArgs?: Record<string, any>; | ||||
| @@ -45,7 +46,7 @@ type Props = { | |||||
| }; | }; | ||||
| type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>; | type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>; | ||||
| type SearchParamNames = keyof SearchBoxInputs; | type SearchParamNames = keyof SearchBoxInputs; | ||||
| type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA"; | |||||
| type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA" | "REPLENISH"; | |||||
| type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; | type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; | ||||
| // put all this into a new component | // put all this into a new component | ||||
| @@ -313,8 +314,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| case "TRUCK_X": | case "TRUCK_X": | ||||
| return { floor: null, isExtra: false, forceTruckKeyword: "x" }; | return { floor: null, isExtra: false, forceTruckKeyword: "x" }; | ||||
| case "ETRA": | case "ETRA": | ||||
| default: | |||||
| return { floor: null, isExtra: true }; | return { floor: null, isExtra: true }; | ||||
| case "REPLENISH": | |||||
| default: | |||||
| return { floor: null, isExtra: false }; | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| @@ -741,9 +744,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| <Tab value="4F" label="4/F" /> | <Tab value="4F" label="4/F" /> | ||||
| <Tab value="TRUCK_X" label={t("Truck X")} /> | <Tab value="TRUCK_X" label={t("Truck X")} /> | ||||
| <Tab value="ETRA" label={t("Etra")} /> | <Tab value="ETRA" label={t("Etra")} /> | ||||
| <Tab value="REPLENISH" label={t("Replenishment")} /> | |||||
| </Tabs> | </Tabs> | ||||
| {hasSearched && hasResults && ( | |||||
| {activeTab !== "REPLENISH" && hasSearched && hasResults && ( | |||||
| <Button | <Button | ||||
| name="batch_release" | name="batch_release" | ||||
| variant="contained" | variant="contained" | ||||
| @@ -756,35 +760,41 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| </Paper> | </Paper> | ||||
| <SearchBox | |||||
| key={`tab-reset-${searchBoxResetKey}`} | |||||
| criteria={searchCriteria} | |||||
| onSearch={handleSearch} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | |||||
| <StyledDataGrid | |||||
| rows={searchAllDos} | |||||
| columns={columns} | |||||
| checkboxSelection | |||||
| rowSelectionModel={rowSelectionModel} | |||||
| onRowSelectionModelChange={applyRowSelectionChange} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| /> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={totalCount} | |||||
| page={(pagingController.pageNum - 1)} | |||||
| rowsPerPage={pagingController.pageSize} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handlePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| /> | |||||
| </Paper> | |||||
| {activeTab === "REPLENISH" ? ( | |||||
| <DoReplenishmentTab /> | |||||
| ) : ( | |||||
| <> | |||||
| <SearchBox | |||||
| key={`tab-reset-${searchBoxResetKey}`} | |||||
| criteria={searchCriteria} | |||||
| onSearch={handleSearch} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | |||||
| <StyledDataGrid | |||||
| rows={searchAllDos} | |||||
| columns={columns} | |||||
| checkboxSelection | |||||
| rowSelectionModel={rowSelectionModel} | |||||
| onRowSelectionModelChange={applyRowSelectionChange} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| /> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={totalCount} | |||||
| page={(pagingController.pageNum - 1)} | |||||
| rowsPerPage={pagingController.pageSize} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handlePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| /> | |||||
| </Paper> | |||||
| </> | |||||
| )} | |||||
| </Stack> | </Stack> | ||||
| </FormProvider> | </FormProvider> | ||||
| @@ -0,0 +1,430 @@ | |||||
| "use client"; | |||||
| import React, { type ReactNode } from "react"; | |||||
| import { Box, Stack, TextField, Typography } from "@mui/material"; | |||||
| import type { Theme } from "@mui/material/styles"; | |||||
| import type { SxProps } from "@mui/material/styles"; | |||||
| import type { TextFieldProps } from "@mui/material/TextField"; | |||||
| export const REPLENISHMENT_FIELD_LABEL_SX = (theme: Theme) => ({ | |||||
| color: | |||||
| theme.palette.mode === "dark" | |||||
| ? theme.palette.grey[100] | |||||
| : theme.palette.common.black, | |||||
| fontWeight: 600, | |||||
| }); | |||||
| export const REPLENISHMENT_FIELD_ICON_SX = (theme: Theme) => ({ | |||||
| color: | |||||
| theme.palette.mode === "dark" | |||||
| ? theme.palette.grey[100] | |||||
| : theme.palette.common.black, | |||||
| }); | |||||
| export const REPLENISHMENT_TEXTFIELD_SX = (theme: Theme) => | |||||
| ({ | |||||
| "& .MuiFilledInput-root": { | |||||
| alignItems: "center", | |||||
| borderRadius: 2, | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| }, | |||||
| "& .MuiFilledInput-root.Mui-focused": { | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| }, | |||||
| "& .MuiFilledInput-input": { | |||||
| paddingTop: REPLENISHMENT_FIELD_BODY_PY, | |||||
| paddingBottom: REPLENISHMENT_FIELD_BODY_PY, | |||||
| }, | |||||
| "& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": { | |||||
| color: theme.palette.text.secondary, | |||||
| fontWeight: 400, | |||||
| opacity: 1, | |||||
| }, | |||||
| "& .MuiFilledInput-root.Mui-focused .MuiFilledInput-input::placeholder, & .MuiFilledInput-root.Mui-focused .MuiInputBase-input::placeholder": | |||||
| { | |||||
| opacity: 0, | |||||
| }, | |||||
| }) as const; | |||||
| /** Autocomplete filled input — override theme default padding to match {@link REPLENISHMENT_TEXTFIELD_SX}. */ | |||||
| export const REPLENISHMENT_AUTOCOMPLETE_SX = (theme: Theme) => | |||||
| ({ | |||||
| width: "100%", | |||||
| ...REPLENISHMENT_TEXTFIELD_SX(theme), | |||||
| "& .MuiFormControl-root": { | |||||
| width: "100%", | |||||
| }, | |||||
| "& .MuiAutocomplete-inputRoot": { | |||||
| paddingTop: `${REPLENISHMENT_FIELD_BODY_PY} !important`, | |||||
| paddingBottom: `${REPLENISHMENT_FIELD_BODY_PY} !important`, | |||||
| paddingLeft: `${theme.spacing(REPLENISHMENT_FIELD_BODY_PX)} !important`, | |||||
| paddingRight: `${theme.spacing(3)} !important`, | |||||
| }, | |||||
| "& .MuiAutocomplete-input": { | |||||
| padding: "0 !important", | |||||
| }, | |||||
| "& .MuiAutocomplete-endAdornment": { | |||||
| right: theme.spacing(1), | |||||
| }, | |||||
| }) as const; | |||||
| /** Vertical padding inside replenishment filled inputs (see REPLENISHMENT_TEXTFIELD_SX). */ | |||||
| export const REPLENISHMENT_FIELD_BODY_PY = "12px"; | |||||
| /** Horizontal padding aligned with MUI filled input (spacing 1.5 = 12px). */ | |||||
| export const REPLENISHMENT_FIELD_BODY_PX = 1.5; | |||||
| /** Source DO summary header: same inset as textbox content area. */ | |||||
| export const REPLENISHMENT_SOURCE_HEADER_SX = (theme: Theme) => | |||||
| ({ | |||||
| bgcolor: "action.hover", | |||||
| borderRadius: 2, | |||||
| px: REPLENISHMENT_FIELD_BODY_PX, | |||||
| py: REPLENISHMENT_FIELD_BODY_PY, | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| boxSizing: "border-box", | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| }) as const; | |||||
| type ReplenishmentFieldLabelProps = { | |||||
| icon: ReactNode; | |||||
| title: string; | |||||
| required?: boolean; | |||||
| sx?: SxProps<Theme>; | |||||
| }; | |||||
| export function ReplenishmentFieldLabel({ | |||||
| icon, | |||||
| title, | |||||
| required = false, | |||||
| sx, | |||||
| }: ReplenishmentFieldLabelProps) { | |||||
| return ( | |||||
| <Stack direction="row" spacing={1} alignItems="center" sx={sx}> | |||||
| {icon} | |||||
| <Typography variant="body2" sx={REPLENISHMENT_FIELD_LABEL_SX} component="span"> | |||||
| {title} | |||||
| {required ? ( | |||||
| <Typography component="span" color="error.main" aria-hidden="true"> | |||||
| {" *"} | |||||
| </Typography> | |||||
| ) : null} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| type ReplenishmentFilterFieldProps = ReplenishmentFieldLabelProps & { | |||||
| children: ReactNode; | |||||
| }; | |||||
| export function ReplenishmentFilterField({ | |||||
| icon, | |||||
| title, | |||||
| children, | |||||
| required = false, | |||||
| }: ReplenishmentFilterFieldProps) { | |||||
| return ( | |||||
| <Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}> | |||||
| <ReplenishmentFieldLabel icon={icon} title={title} required={required} /> | |||||
| {children} | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| type ReplenishmentTextFieldProps = Omit<TextFieldProps, "variant" | "size">; | |||||
| export function ReplenishmentTextField(props: ReplenishmentTextFieldProps) { | |||||
| const { sx, InputProps, ...rest } = props; | |||||
| return ( | |||||
| <TextField | |||||
| size="small" | |||||
| fullWidth | |||||
| variant="filled" | |||||
| sx={(theme) => ({ | |||||
| ...REPLENISHMENT_TEXTFIELD_SX(theme), | |||||
| ...(typeof sx === "function" ? sx(theme) : sx), | |||||
| })} | |||||
| InputProps={{ disableUnderline: true, ...InputProps }} | |||||
| {...rest} | |||||
| /> | |||||
| ); | |||||
| } | |||||
| /** Read-only item row value — blank until a line is selected. */ | |||||
| export function ReplenishmentItemEntryPlainText({ | |||||
| value, | |||||
| reserveSpace = false, | |||||
| sx, | |||||
| }: { | |||||
| value: string; | |||||
| /** Keep column width/height when empty (e.g. original shipment qty). */ | |||||
| reserveSpace?: boolean; | |||||
| sx?: SxProps<Theme>; | |||||
| }) { | |||||
| const isEmpty = value.trim() === ""; | |||||
| if (isEmpty && !reserveSpace) return null; | |||||
| return ( | |||||
| <Box | |||||
| component="span" | |||||
| sx={(theme) => ({ | |||||
| display: "block", | |||||
| color: theme.palette.text.primary, | |||||
| wordBreak: "break-word", | |||||
| minWidth: 0, | |||||
| minHeight: reserveSpace ? theme.spacing(5) : undefined, | |||||
| ...(typeof sx === "function" ? sx(theme) : sx), | |||||
| })} | |||||
| > | |||||
| {isEmpty ? "\u00A0" : value} | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| type ReplenishmentQtyWithUomFieldProps = { | |||||
| placeholder?: string; | |||||
| value: string; | |||||
| onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||||
| uom?: string; | |||||
| sx?: SxProps<Theme>; | |||||
| inputProps?: React.InputHTMLAttributes<HTMLInputElement>; | |||||
| }; | |||||
| /** Replenish qty textbox (search-field style) with uom label attached on the right outside. */ | |||||
| export function ReplenishmentQtyWithUomField({ | |||||
| placeholder, | |||||
| value, | |||||
| onChange, | |||||
| uom = "", | |||||
| sx, | |||||
| inputProps, | |||||
| }: ReplenishmentQtyWithUomFieldProps) { | |||||
| return ( | |||||
| <Stack | |||||
| direction="row" | |||||
| alignItems="center" | |||||
| spacing={0.75} | |||||
| sx={{ flexShrink: 0, ...(typeof sx === "function" ? undefined : sx) }} | |||||
| > | |||||
| <ReplenishmentTextField | |||||
| type="number" | |||||
| hiddenLabel | |||||
| placeholder={placeholder ?? ""} | |||||
| value={value} | |||||
| onChange={onChange} | |||||
| inputProps={inputProps} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| <Box component="span" sx={REPLENISHMENT_TABLE_UOM_SLOT_SX}> | |||||
| {uom || "\u00A0"} | |||||
| </Box> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| export const REPLENISHMENT_TABLE_SX = { | |||||
| tableLayout: { md: "fixed" }, | |||||
| width: "100%", | |||||
| "& .MuiTableCell-root": { | |||||
| typography: "body2", | |||||
| borderColor: "divider", | |||||
| py: 1.25, | |||||
| px: 2, | |||||
| }, | |||||
| "& .MuiTableCell-root:first-of-type": { | |||||
| pl: 3.5, | |||||
| }, | |||||
| "& .MuiTableHead-root .MuiTableCell-root": { | |||||
| fontWeight: 600, | |||||
| color: "text.secondary", | |||||
| bgcolor: "action.hover", | |||||
| borderBottom: "1px solid", | |||||
| borderColor: "divider", | |||||
| }, | |||||
| "& .MuiTableBody-root .MuiTableRow-root:not(:last-of-type) .MuiTableCell-root": { | |||||
| borderBottom: "1px solid", | |||||
| borderColor: "divider", | |||||
| }, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_HEAD_CELL_SX = { | |||||
| typography: "body2", | |||||
| fontWeight: 600, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_BODY_CELL_SX = { | |||||
| typography: "body2", | |||||
| verticalAlign: "middle", | |||||
| } as const; | |||||
| /** Extra inset for the first column (item code). */ | |||||
| export const REPLENISHMENT_TABLE_FIRST_CELL_SX = { | |||||
| pl: 3, | |||||
| } as const; | |||||
| /** Entry row: no bottom border on last row. */ | |||||
| export const REPLENISHMENT_TABLE_ENTRY_ROW_SX = { | |||||
| "& .MuiTableCell-root": { | |||||
| borderBottom: 0, | |||||
| }, | |||||
| } as const; | |||||
| /** In-table inputs — always show filled border; compact padding for row alignment. */ | |||||
| export const REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX = (theme: Theme) => | |||||
| ({ | |||||
| ...REPLENISHMENT_TEXTFIELD_SX(theme), | |||||
| "& .MuiFilledInput-root": { | |||||
| alignItems: "center", | |||||
| borderRadius: 1.5, | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| "&:hover": { | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| }, | |||||
| "&.Mui-focused": { | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| borderColor: theme.palette.primary.main, | |||||
| }, | |||||
| }, | |||||
| "& .MuiFilledInput-input": { | |||||
| paddingTop: "6px", | |||||
| paddingBottom: "6px", | |||||
| paddingLeft: 0, | |||||
| paddingRight: 0, | |||||
| }, | |||||
| "& input[type=number]": { | |||||
| MozAppearance: "textfield", | |||||
| }, | |||||
| "& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button": | |||||
| { | |||||
| WebkitAppearance: "none", | |||||
| margin: 0, | |||||
| }, | |||||
| }) as const; | |||||
| /** Table autocomplete — same inset as plain text in first column. */ | |||||
| export const REPLENISHMENT_TABLE_AUTOCOMPLETE_SX = (theme: Theme) => | |||||
| ({ | |||||
| width: "100%", | |||||
| ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme), | |||||
| "& .MuiFormControl-root": { | |||||
| width: "100%", | |||||
| }, | |||||
| "& .MuiAutocomplete-inputRoot": { | |||||
| paddingTop: "6px !important", | |||||
| paddingBottom: "6px !important", | |||||
| paddingLeft: `${theme.spacing(1)} !important`, | |||||
| paddingRight: "56px !important", | |||||
| bgcolor: `${theme.palette.mode === "dark" ? theme.palette.grey[800] : theme.palette.common.white} !important`, | |||||
| border: `1px solid ${theme.palette.divider} !important`, | |||||
| borderRadius: `${theme.shape.borderRadius * 1.5}px !important`, | |||||
| "&.Mui-focused": { | |||||
| borderColor: `${theme.palette.primary.main} !important`, | |||||
| }, | |||||
| }, | |||||
| "& .MuiAutocomplete-input": { | |||||
| padding: "0 !important", | |||||
| minWidth: 48, | |||||
| }, | |||||
| "& .MuiAutocomplete-endAdornment": { | |||||
| right: theme.spacing(0.75), | |||||
| gap: theme.spacing(0.25), | |||||
| "& .MuiAutocomplete-clearIndicator": { | |||||
| visibility: "visible", | |||||
| }, | |||||
| }, | |||||
| }) as const; | |||||
| /** Right-aligned qty + uom slot shared by data rows and entry row. */ | |||||
| export const REPLENISHMENT_TABLE_QTY_CELL_INNER_SX = { | |||||
| display: "flex", | |||||
| justifyContent: "flex-end", | |||||
| alignItems: "center", | |||||
| width: "100%", | |||||
| gap: 0.75, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_UOM_SLOT_SX = { | |||||
| minWidth: 28, | |||||
| whiteSpace: "nowrap", | |||||
| display: "inline-block", | |||||
| flexShrink: 0, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX = { | |||||
| display: "flex", | |||||
| justifyContent: "center", | |||||
| alignItems: "center", | |||||
| width: "100%", | |||||
| } as const; | |||||
| export const replenishmentSearchGridLabelSx = (col: number) => ({ | |||||
| gridColumn: { xs: 1, lg: col }, | |||||
| gridRow: { xs: "auto", lg: 1 }, | |||||
| minWidth: 0, | |||||
| }); | |||||
| export const replenishmentSearchGridInputSx = (col: number) => ({ | |||||
| gridColumn: { xs: 1, lg: col }, | |||||
| gridRow: { xs: "auto", lg: 2 }, | |||||
| minWidth: 0, | |||||
| display: "flex", | |||||
| alignItems: "stretch", | |||||
| "& .MuiFormControl-root": { | |||||
| width: "100%", | |||||
| }, | |||||
| }); | |||||
| /** Shop input + lookup button share one row; button height follows the textbox. */ | |||||
| export const replenishmentSearchGridShopRowSx = { | |||||
| gridColumn: { xs: 1, lg: 3 }, | |||||
| gridRow: { xs: "auto", lg: 2 }, | |||||
| minWidth: 0, | |||||
| display: "flex", | |||||
| alignItems: "stretch", | |||||
| gap: 1, | |||||
| "& .MuiTextField-root": { | |||||
| flex: 1, | |||||
| minWidth: 0, | |||||
| }, | |||||
| "& .MuiFormControl-root": { | |||||
| height: "100%", | |||||
| }, | |||||
| "& .MuiFilledInput-root": { | |||||
| height: "100%", | |||||
| boxSizing: "border-box", | |||||
| }, | |||||
| }; | |||||
| /** Match {@link ReplenishmentFieldLabel} typography on contained buttons. */ | |||||
| export const REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX = (theme: Theme) => ({ | |||||
| fontSize: theme.typography.body2.fontSize, | |||||
| fontWeight: 600, | |||||
| lineHeight: 1, | |||||
| }); | |||||
| export const REPLENISHMENT_LOOKUP_BUTTON_SX = (theme: Theme) => ({ | |||||
| ...REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX(theme), | |||||
| alignSelf: "stretch", | |||||
| minHeight: "unset", | |||||
| height: "auto", | |||||
| py: 0, | |||||
| px: 1.5, | |||||
| borderRadius: 2, | |||||
| boxShadow: "none", | |||||
| textTransform: "none", | |||||
| whiteSpace: "nowrap", | |||||
| flexShrink: 0, | |||||
| minWidth: { xs: "100%", lg: 108 }, | |||||
| "& .MuiButton-startIcon": { | |||||
| margin: 0, | |||||
| marginRight: theme.spacing(0.75), | |||||
| "& > *:nth-of-type(1)": { | |||||
| fontSize: 20, | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| @@ -226,7 +226,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Assessment />, | icon: <Assessment />, | ||||
| labelKey: "nav.report", | labelKey: "nav.report", | ||||
| path: "/report", | path: "/report", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.REPORT_MGMT, AUTH.ADMIN], | |||||
| isHidden: false, | isHidden: false, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -363,7 +363,7 @@ const NavigationContent: React.FC = () => { | |||||
| id: "nav.settings.shopAndTruck", | id: "nav.settings.shopAndTruck", | ||||
| icon: <Storefront />, | icon: <Storefront />, | ||||
| labelKey: "nav.settings.shopAndTruck", | labelKey: "nav.settings.shopAndTruck", | ||||
| path: "/settings/shop", | |||||
| path: "/settings/shop/board", | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.deliveryOrderFloor", | id: "nav.settings.deliveryOrderFloor", | ||||
| @@ -495,6 +495,13 @@ const NavigationContent: React.FC = () => { | |||||
| if (!pathname.startsWith(p + "/")) return false; | if (!pathname.startsWith(p + "/")) return false; | ||||
| // `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash) | // `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash) | ||||
| if (p === "/doworkbench" && pathname.startsWith("/doworkbenchsearch")) return false; | if (p === "/doworkbench" && pathname.startsWith("/doworkbenchsearch")) return false; | ||||
| // Shop sub-routes (detail, truckdetail, legacy tab page) share one nav entry → board | |||||
| if ( | |||||
| p === "/settings/shop/board" && | |||||
| (pathname === "/settings/shop" || pathname.startsWith("/settings/shop/")) | |||||
| ) { | |||||
| return true; | |||||
| } | |||||
| return true; | return true; | ||||
| }); | }); | ||||
| matches.sort((a, b) => b.length - a.length); | matches.sort((a, b) => b.length - a.length); | ||||
| @@ -2425,6 +2425,9 @@ const RouteBoard: React.FC = () => { | |||||
| }; | }; | ||||
| const handleDeleteTruckRow = async (truckRowId: number) => { | const handleDeleteTruckRow = async (truckRowId: number) => { | ||||
| if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) { | |||||
| return; | |||||
| } | |||||
| if (truckRowId < 0) { | if (truckRowId < 0) { | ||||
| if (!window.confirm(t("confirm_discardDraftShop"))) return; | if (!window.confirm(t("confirm_discardDraftShop"))) return; | ||||
| setError(null); | setError(null); | ||||
| @@ -2488,6 +2491,11 @@ const RouteBoard: React.FC = () => { | |||||
| /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */ | /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */ | ||||
| const handleClearLaneShops = (lane: Lane) => { | const handleClearLaneShops = (lane: Lane) => { | ||||
| if (lane.shops.length === 0) return; | if (lane.shops.length === 0) return; | ||||
| if ( | |||||
| lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id)) | |||||
| ) { | |||||
| return; | |||||
| } | |||||
| if ( | if ( | ||||
| !window.confirm( | !window.confirm( | ||||
| t("confirm_clearLane", { | t("confirm_clearLane", { | ||||
| @@ -5845,13 +5853,6 @@ const RouteBoard: React.FC = () => { | |||||
| setDistrictEditError(null); | setDistrictEditError(null); | ||||
| }} | }} | ||||
| error={Boolean(districtEditError)} | error={Boolean(districtEditError)} | ||||
| helperText={ | |||||
| districtEditError || | |||||
| (districtEditCtx?.mode === "rename" && | |||||
| districtEditCtx.oldDisplay === "未分類" | |||||
| ? t("district_help_null") | |||||
| : t("district_help_mapped")) | |||||
| } | |||||
| InputLabelProps={{ shrink: true }} | InputLabelProps={{ shrink: true }} | ||||
| /> | /> | ||||
| </DialogContent> | </DialogContent> | ||||
| @@ -5915,13 +5916,6 @@ const RouteBoard: React.FC = () => { | |||||
| inputProps={{ step: 1 }} | inputProps={{ step: 1 }} | ||||
| sx={{ mt: 1 }} | sx={{ mt: 1 }} | ||||
| /> | /> | ||||
| <Typography | |||||
| variant="caption" | |||||
| color="text.secondary" | |||||
| sx={{ mt: 1, display: "block" }} | |||||
| > | |||||
| {t("seqDialog_hint")} | |||||
| </Typography> | |||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={closeSeqEdit}>{t("cancel")}</Button> | <Button onClick={closeSeqEdit}>{t("cancel")}</Button> | ||||
| @@ -5982,9 +5976,6 @@ const RouteBoard: React.FC = () => { | |||||
| {addRouteError} | {addRouteError} | ||||
| </Alert> | </Alert> | ||||
| )} | )} | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 2 }}> | |||||
| {t("addRoute_hint")} | |||||
| </Typography> | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| display: "grid", | display: "grid", | ||||
| @@ -7846,7 +7837,13 @@ const RouteBoard: React.FC = () => { | |||||
| spacing={0.5} | spacing={0.5} | ||||
| alignItems="center" | alignItems="center" | ||||
| > | > | ||||
| <Tooltip title={t("tooltip_removeFromLane")}> | |||||
| <Tooltip | |||||
| title={ | |||||
| isScheduledMove | |||||
| ? t("schedule_shop_locked") | |||||
| : t("tooltip_removeFromLane") | |||||
| } | |||||
| > | |||||
| <span> | <span> | ||||
| <IconButton | <IconButton | ||||
| size="small" | size="small" | ||||
| @@ -7861,7 +7858,8 @@ const RouteBoard: React.FC = () => { | |||||
| }} | }} | ||||
| disabled={ | disabled={ | ||||
| loading || | loading || | ||||
| dirtyDeletes.has(shop.id) | |||||
| dirtyDeletes.has(shop.id) || | |||||
| isScheduledMove | |||||
| } | } | ||||
| > | > | ||||
| <Trash2 size={16} /> | <Trash2 size={16} /> | ||||
| @@ -7947,12 +7945,29 @@ const RouteBoard: React.FC = () => { | |||||
| > | > | ||||
| {t("btn_addShopToLane")} | {t("btn_addShopToLane")} | ||||
| </Button> | </Button> | ||||
| <Tooltip title={t("tooltip_clearLaneShops")}> | |||||
| <Tooltip | |||||
| title={ | |||||
| lane.shops.some( | |||||
| (s) => | |||||
| s.id > 0 && scheduledShopIdSet.has(s.id), | |||||
| ) | |||||
| ? t("schedule_shop_locked") | |||||
| : t("tooltip_clearLaneShops") | |||||
| } | |||||
| > | |||||
| <span> | <span> | ||||
| <IconButton | <IconButton | ||||
| size="small" | size="small" | ||||
| onClick={() => handleClearLaneShops(lane)} | onClick={() => handleClearLaneShops(lane)} | ||||
| disabled={loading || lane.shops.length === 0} | |||||
| disabled={ | |||||
| loading || | |||||
| lane.shops.length === 0 || | |||||
| lane.shops.some( | |||||
| (s) => | |||||
| s.id > 0 && | |||||
| scheduledShopIdSet.has(s.id), | |||||
| ) | |||||
| } | |||||
| > | > | ||||
| <Trash2 size={16} /> | <Trash2 size={16} /> | ||||
| </IconButton> | </IconButton> | ||||
| @@ -14,6 +14,7 @@ import { | |||||
| DialogTitle, | DialogTitle, | ||||
| IconButton, | IconButton, | ||||
| Stack, | Stack, | ||||
| TextField, | |||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { | import { | ||||
| @@ -38,16 +39,23 @@ import { | |||||
| applyNowTruckLaneScheduleClient, | applyNowTruckLaneScheduleClient, | ||||
| cancelTruckLaneScheduleClient, | cancelTruckLaneScheduleClient, | ||||
| createTruckLaneScheduleClient, | createTruckLaneScheduleClient, | ||||
| reactivateCancelledTruckLaneScheduleClient, | |||||
| getTruckLaneScheduleClient, | getTruckLaneScheduleClient, | ||||
| ignoreTruckLaneScheduleClient, | ignoreTruckLaneScheduleClient, | ||||
| listTruckLaneSchedulesClient, | listTruckLaneSchedulesClient, | ||||
| retryFailedTruckLaneScheduleClient, | retryFailedTruckLaneScheduleClient, | ||||
| type TruckLaneScheduleLineRequest, | |||||
| type TruckLaneScheduleLineResponse, | type TruckLaneScheduleLineResponse, | ||||
| type TruckLaneScheduleResponse, | type TruckLaneScheduleResponse, | ||||
| } from "@/app/api/shop/client"; | } from "@/app/api/shop/client"; | ||||
| import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal"; | import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal"; | ||||
| import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; | import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; | ||||
| import { resolveRescheduleExecuteAt, formatScheduleDisplayDateTime } from "@/components/Shop/scheduleExecuteAt"; | |||||
| import { | |||||
| buildExecuteAtIso, | |||||
| isExecuteAtTooEarly, | |||||
| resolveRescheduleExecuteAt, | |||||
| formatScheduleDisplayDateTime, | |||||
| } from "@/components/Shop/scheduleExecuteAt"; | |||||
| import { | import { | ||||
| buildScheduleLineDescription, | buildScheduleLineDescription, | ||||
| } from "@/components/Shop/scheduleLineDisplay"; | } from "@/components/Shop/scheduleLineDisplay"; | ||||
| @@ -84,7 +92,7 @@ function uiStatusFromLines( | |||||
| return null; | return null; | ||||
| } | } | ||||
| /** Prefer line outcomes over header status when they disagree (e.g. PARTIAL still stored as PENDING). */ | |||||
| /** Prefer fresh list aggregates over cached detail lines (detail can be stale after mutations). */ | |||||
| function resolveUiStatus( | function resolveUiStatus( | ||||
| task: TruckLaneScheduleResponse, | task: TruckLaneScheduleResponse, | ||||
| detail?: TruckLaneScheduleResponse | null, | detail?: TruckLaneScheduleResponse | null, | ||||
| @@ -93,11 +101,6 @@ function resolveUiStatus( | |||||
| if (st === "CANCELLED") return "cancelled"; | if (st === "CANCELLED") return "cancelled"; | ||||
| if (st === "IGNORED") return "ignored"; | if (st === "IGNORED") return "ignored"; | ||||
| const fromDetailLines = detail?.lines?.length | |||||
| ? uiStatusFromLines(detail.lines) | |||||
| : null; | |||||
| if (fromDetailLines) return fromDetailLines; | |||||
| if (task.lineCounts) { | if (task.lineCounts) { | ||||
| const fromCounts = uiStatusFromLineCounts(task.lineCounts); | const fromCounts = uiStatusFromLineCounts(task.lineCounts); | ||||
| if (fromCounts) return fromCounts; | if (fromCounts) return fromCounts; | ||||
| @@ -108,6 +111,12 @@ function resolveUiStatus( | |||||
| if (st === "PARTIAL") { | if (st === "PARTIAL") { | ||||
| return (task.lineCounts?.failed ?? 0) > 0 ? "failed" : "success"; | return (task.lineCounts?.failed ?? 0) > 0 ? "failed" : "success"; | ||||
| } | } | ||||
| const fromDetailLines = detail?.lines?.length | |||||
| ? uiStatusFromLines(detail.lines) | |||||
| : null; | |||||
| if (fromDetailLines) return fromDetailLines; | |||||
| if (st === "PENDING" || st === "APPLYING") return "pending"; | if (st === "PENDING" || st === "APPLYING") return "pending"; | ||||
| return "pending"; | return "pending"; | ||||
| } | } | ||||
| @@ -118,13 +127,34 @@ function canManagePendingSchedule( | |||||
| ): boolean { | ): boolean { | ||||
| const st = String(task.status ?? "").toUpperCase(); | const st = String(task.status ?? "").toUpperCase(); | ||||
| if (st !== "PENDING" && st !== "APPLYING") return false; | if (st !== "PENDING" && st !== "APPLYING") return false; | ||||
| const c = task.lineCounts; | |||||
| if (c) { | |||||
| return c.pending > 0 && c.applied === 0 && c.failed === 0; | |||||
| } | |||||
| const lines = detail?.lines ?? []; | const lines = detail?.lines ?? []; | ||||
| if (lines.length > 0) { | if (lines.length > 0) { | ||||
| return lines.every((l) => l.lineStatus === "PENDING"); | return lines.every((l) => l.lineStatus === "PENDING"); | ||||
| } | } | ||||
| const c = task.lineCounts; | |||||
| if (!c) return true; | |||||
| return c.pending > 0 && c.applied === 0 && c.failed === 0; | |||||
| return true; | |||||
| } | |||||
| function scheduleLinesToRequests( | |||||
| lines: TruckLaneScheduleLineResponse[], | |||||
| ): TruckLaneScheduleLineRequest[] { | |||||
| return lines | |||||
| .filter((l) => l.lineStatus !== "APPLIED") | |||||
| .map((l) => ({ | |||||
| action: l.action, | |||||
| truckRowId: l.truckRowId, | |||||
| toTruckLanceCode: l.toTruckLanceCode, | |||||
| toRemark: l.toRemark, | |||||
| toStoreId: l.toStoreId, | |||||
| toLoadingSequence: l.toLoadingSequence ?? 0, | |||||
| toDistrictReference: l.toDistrictReference ?? null, | |||||
| shopCode: l.shopCode, | |||||
| shopName: l.shopName, | |||||
| departureTime: l.departureTime, | |||||
| })); | |||||
| } | } | ||||
| function splitExecuteAt(executeAt: string): { date: string; time: string } { | function splitExecuteAt(executeAt: string): { date: string; time: string } { | ||||
| @@ -163,6 +193,12 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| const [actionId, setActionId] = useState<number | null>(null); | const [actionId, setActionId] = useState<number | null>(null); | ||||
| const [actionError, setActionError] = useState<string | null>(null); | const [actionError, setActionError] = useState<string | null>(null); | ||||
| const [actionNotice, setActionNotice] = useState<string | null>(null); | const [actionNotice, setActionNotice] = useState<string | null>(null); | ||||
| const [redoTarget, setRedoTarget] = useState<TruckLaneScheduleResponse | null>( | |||||
| null, | |||||
| ); | |||||
| const [redoDate, setRedoDate] = useState(""); | |||||
| const [redoTime, setRedoTime] = useState(""); | |||||
| const [redoSubmitting, setRedoSubmitting] = useState(false); | |||||
| const laneById = useMemo( | const laneById = useMemo( | ||||
| () => new Map(lanes.map((l) => [l.id, l])), | () => new Map(lanes.map((l) => [l.id, l])), | ||||
| @@ -218,6 +254,9 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| setDetailById({}); | setDetailById({}); | ||||
| setActionError(null); | setActionError(null); | ||||
| setActionNotice(null); | setActionNotice(null); | ||||
| setRedoTarget(null); | |||||
| setRedoDate(""); | |||||
| setRedoTime(""); | |||||
| void loadTasks(); | void loadTasks(); | ||||
| }, [open, loadTasks]); | }, [open, loadTasks]); | ||||
| @@ -238,15 +277,22 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| return tasks.filter((task) => { | return tasks.filter((task) => { | ||||
| const detail = detailById[task.id]; | const detail = detailById[task.id]; | ||||
| const ui = resolveUiStatus(task, detail); | const ui = resolveUiStatus(task, detail); | ||||
| if (filter === "all") return ui !== "cancelled"; | |||||
| if (filter === "all") return true; | |||||
| if (filter === "pending") return ui === "pending"; | if (filter === "pending") return ui === "pending"; | ||||
| if (filter === "success") return ui === "success"; | if (filter === "success") return ui === "success"; | ||||
| return ui === "failed"; | return ui === "failed"; | ||||
| }); | }); | ||||
| }, [tasks, filter, detailById]); | }, [tasks, filter, detailById]); | ||||
| const ensureDetail = async (id: number) => { | |||||
| if (detailById[id]) return detailById[id]; | |||||
| const invalidateTaskDetail = useCallback((id: number) => { | |||||
| setDetailById((prev) => { | |||||
| if (!prev[id]) return prev; | |||||
| const { [id]: _removed, ...rest } = prev; | |||||
| return rest; | |||||
| }); | |||||
| }, []); | |||||
| const refreshTaskDetail = useCallback(async (id: number) => { | |||||
| setDetailLoadingId(id); | setDetailLoadingId(id); | ||||
| try { | try { | ||||
| const detail = await getTruckLaneScheduleClient(id); | const detail = await getTruckLaneScheduleClient(id); | ||||
| @@ -255,8 +301,31 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| } finally { | } finally { | ||||
| setDetailLoadingId(null); | setDetailLoadingId(null); | ||||
| } | } | ||||
| }, []); | |||||
| const ensureDetail = async (id: number) => { | |||||
| if (detailById[id]) return detailById[id]; | |||||
| return refreshTaskDetail(id); | |||||
| }; | }; | ||||
| const reloadAfterMutation = useCallback( | |||||
| async (id: number) => { | |||||
| invalidateTaskDetail(id); | |||||
| await onAfterChange?.(); | |||||
| await loadTasks(); | |||||
| if (expandedId === id) { | |||||
| await refreshTaskDetail(id); | |||||
| } | |||||
| }, | |||||
| [ | |||||
| expandedId, | |||||
| invalidateTaskDetail, | |||||
| loadTasks, | |||||
| onAfterChange, | |||||
| refreshTaskDetail, | |||||
| ], | |||||
| ); | |||||
| const toggleExpand = async (id: number) => { | const toggleExpand = async (id: number) => { | ||||
| if (expandedId === id) { | if (expandedId === id) { | ||||
| setExpandedId(null); | setExpandedId(null); | ||||
| @@ -271,8 +340,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| setActionError(null); | setActionError(null); | ||||
| try { | try { | ||||
| await cancelTruckLaneScheduleClient(id); | await cancelTruckLaneScheduleClient(id); | ||||
| await onAfterChange?.(); | |||||
| await loadTasks(); | |||||
| await reloadAfterMutation(id); | |||||
| } catch (err: unknown) { | } catch (err: unknown) { | ||||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | ||||
| } finally { | } finally { | ||||
| @@ -285,8 +353,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| setActionError(null); | setActionError(null); | ||||
| try { | try { | ||||
| await applyNowTruckLaneScheduleClient(id); | await applyNowTruckLaneScheduleClient(id); | ||||
| await onAfterChange?.(); | |||||
| await loadTasks(); | |||||
| await reloadAfterMutation(id); | |||||
| } catch (err: unknown) { | } catch (err: unknown) { | ||||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | ||||
| } finally { | } finally { | ||||
| @@ -294,6 +361,59 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| }; | }; | ||||
| const openRedoDialog = (task: TruckLaneScheduleResponse) => { | |||||
| const { executeAt } = resolveRescheduleExecuteAt(task.executeAt); | |||||
| const { date, time } = splitExecuteAt(executeAt); | |||||
| setRedoTarget(task); | |||||
| setRedoDate(date !== "-" ? date : ""); | |||||
| setRedoTime(time && time !== "-" ? time : ""); | |||||
| setActionError(null); | |||||
| }; | |||||
| const closeRedoDialog = () => { | |||||
| if (redoSubmitting) return; | |||||
| setRedoTarget(null); | |||||
| setRedoDate(""); | |||||
| setRedoTime(""); | |||||
| }; | |||||
| const handleConfirmRedo = async () => { | |||||
| if (!redoTarget) return; | |||||
| const executeAt = buildExecuteAtIso(redoDate, redoTime); | |||||
| if (!executeAt) { | |||||
| setActionError(t("schedule_err_execute_at_past")); | |||||
| return; | |||||
| } | |||||
| if (isExecuteAtTooEarly(executeAt)) { | |||||
| setActionError(t("schedule_err_execute_at_past")); | |||||
| return; | |||||
| } | |||||
| setRedoSubmitting(true); | |||||
| setActionId(redoTarget.id); | |||||
| setActionError(null); | |||||
| setActionNotice(null); | |||||
| try { | |||||
| const updated = await reactivateCancelledTruckLaneScheduleClient( | |||||
| redoTarget.id, | |||||
| { executeAt }, | |||||
| ); | |||||
| setActionNotice( | |||||
| t("schedule_reschedule_created", { | |||||
| id: updated.id, | |||||
| at: formatScheduleDisplayDateTime(executeAt), | |||||
| }), | |||||
| ); | |||||
| closeRedoDialog(); | |||||
| await reloadAfterMutation(redoTarget.id); | |||||
| } catch (err: unknown) { | |||||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | |||||
| } finally { | |||||
| setRedoSubmitting(false); | |||||
| setActionId(null); | |||||
| } | |||||
| }; | |||||
| const handleReschedule = async (task: TruckLaneScheduleResponse) => { | const handleReschedule = async (task: TruckLaneScheduleResponse) => { | ||||
| if (String(task.status ?? "").toUpperCase() === "PARTIAL") { | if (String(task.status ?? "").toUpperCase() === "PARTIAL") { | ||||
| setActionError(t("schedule_retry_rejects_partial")); | setActionError(t("schedule_retry_rejects_partial")); | ||||
| @@ -315,18 +435,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| (l) => l.lineStatus === "FAILED" || l.lineStatus === "PENDING", | (l) => l.lineStatus === "FAILED" || l.lineStatus === "PENDING", | ||||
| ); | ); | ||||
| if (retryLines.length === 0) throw retryErr; | if (retryLines.length === 0) throw retryErr; | ||||
| const lines = retryLines.map((l) => ({ | |||||
| action: l.action, | |||||
| truckRowId: l.truckRowId, | |||||
| toTruckLanceCode: l.toTruckLanceCode, | |||||
| toRemark: l.toRemark, | |||||
| toStoreId: l.toStoreId, | |||||
| toLoadingSequence: l.toLoadingSequence ?? 0, | |||||
| toDistrictReference: l.toDistrictReference ?? null, | |||||
| shopCode: l.shopCode, | |||||
| shopName: l.shopName, | |||||
| departureTime: l.departureTime, | |||||
| })); | |||||
| const lines = scheduleLinesToRequests(retryLines); | |||||
| await createTruckLaneScheduleClient({ executeAt, lines }); | await createTruckLaneScheduleClient({ executeAt, lines }); | ||||
| } | } | ||||
| if (adjusted) { | if (adjusted) { | ||||
| @@ -334,8 +443,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| t("schedule_reschedule_time_adjusted", { at: executeAt }), | t("schedule_reschedule_time_adjusted", { at: executeAt }), | ||||
| ); | ); | ||||
| } | } | ||||
| await onAfterChange?.(); | |||||
| await loadTasks(); | |||||
| await reloadAfterMutation(task.id); | |||||
| } catch (err: unknown) { | } catch (err: unknown) { | ||||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | ||||
| } finally { | } finally { | ||||
| @@ -348,8 +456,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| setActionError(null); | setActionError(null); | ||||
| try { | try { | ||||
| await ignoreTruckLaneScheduleClient(id); | await ignoreTruckLaneScheduleClient(id); | ||||
| await onAfterChange?.(); | |||||
| await loadTasks(); | |||||
| await reloadAfterMutation(id); | |||||
| } catch (err: unknown) { | } catch (err: unknown) { | ||||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | ||||
| } finally { | } finally { | ||||
| @@ -452,6 +559,22 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| /> | /> | ||||
| ); | ); | ||||
| } | } | ||||
| if (ui === "cancelled") { | |||||
| return ( | |||||
| <Chip | |||||
| size="small" | |||||
| icon={<Ban size={12} />} | |||||
| label={t("schedule_history_status_cancelled")} | |||||
| sx={{ | |||||
| fontWeight: 700, | |||||
| bgcolor: "grey.100", | |||||
| color: "text.secondary", | |||||
| border: 1, | |||||
| borderColor: "grey.400", | |||||
| }} | |||||
| /> | |||||
| ); | |||||
| } | |||||
| return ( | return ( | ||||
| <Chip | <Chip | ||||
| size="small" | size="small" | ||||
| @@ -966,6 +1089,24 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| {t("schedule_history_status_ignored")} | {t("schedule_history_status_ignored")} | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| {ui === "cancelled" && ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="contained" | |||||
| disabled={busy} | |||||
| startIcon={ | |||||
| busy ? ( | |||||
| <CircularProgress size={14} color="inherit" /> | |||||
| ) : ( | |||||
| <RotateCcw size={14} /> | |||||
| ) | |||||
| } | |||||
| onClick={() => openRedoDialog(task)} | |||||
| sx={{ fontWeight: 700 }} | |||||
| > | |||||
| {t("schedule_history_reschedule")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -988,6 +1129,58 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||||
| {t("schedule_history_close")} | {t("schedule_history_close")} | ||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| <Dialog | |||||
| open={redoTarget != null} | |||||
| onClose={closeRedoDialog} | |||||
| maxWidth="xs" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle sx={{ fontWeight: 800 }}> | |||||
| {t("schedule_reschedule_dialog_title")} | |||||
| </DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||||
| <TextField | |||||
| label={t("schedule_exec_date")} | |||||
| type="date" | |||||
| size="small" | |||||
| fullWidth | |||||
| value={redoDate} | |||||
| onChange={(e) => setRedoDate(e.target.value)} | |||||
| InputLabelProps={{ shrink: true }} | |||||
| /> | |||||
| <TextField | |||||
| label={t("schedule_exec_time")} | |||||
| type="time" | |||||
| size="small" | |||||
| fullWidth | |||||
| value={redoTime} | |||||
| onChange={(e) => setRedoTime(e.target.value)} | |||||
| InputLabelProps={{ shrink: true }} | |||||
| /> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions sx={{ px: 2, pb: 2 }}> | |||||
| <Button onClick={closeRedoDialog} disabled={redoSubmitting}> | |||||
| {t("cancel")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="contained" | |||||
| disabled={ | |||||
| redoSubmitting || !redoDate.trim() || !redoTime.trim() | |||||
| } | |||||
| onClick={() => void handleConfirmRedo()} | |||||
| sx={{ fontWeight: 700 }} | |||||
| > | |||||
| {redoSubmitting ? ( | |||||
| <CircularProgress size={18} color="inherit" /> | |||||
| ) : ( | |||||
| t("schedule_reschedule_dialog_confirm") | |||||
| )} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Dialog> | </Dialog> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -325,7 +325,7 @@ const ShopDetail: React.FC = () => { | |||||
| <Alert severity="error" sx={{ mb: 2 }}> | <Alert severity="error" sx={{ mb: 2 }}> | ||||
| {error} | {error} | ||||
| </Alert> | </Alert> | ||||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||||
| <Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -336,7 +336,7 @@ const ShopDetail: React.FC = () => { | |||||
| <Alert severity="warning" sx={{ mb: 2 }}> | <Alert severity="warning" sx={{ mb: 2 }}> | ||||
| {t("Shop not found")} | {t("Shop not found")} | ||||
| </Alert> | </Alert> | ||||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||||
| <Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -347,7 +347,7 @@ const ShopDetail: React.FC = () => { | |||||
| <CardContent> | <CardContent> | ||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | ||||
| <Typography variant="h6">{t("Shop Information")}</Typography> | <Typography variant="h6">{t("Shop Information")}</Typography> | ||||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||||
| <Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button> | |||||
| </Box> | </Box> | ||||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | <Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | ||||
| @@ -493,7 +493,7 @@ const TruckLaneDetail: React.FC = () => { | |||||
| }; | }; | ||||
| const handleBack = () => { | const handleBack = () => { | ||||
| router.push("/settings/shop?tab=1"); | |||||
| router.push("/settings/shop/board"); | |||||
| }; | }; | ||||
| const handleOpenAddShopDialog = () => { | const handleOpenAddShopDialog = () => { | ||||
| @@ -63,6 +63,51 @@ | |||||
| "Problem DO(s): ": "Problem DO(s): ", | "Problem DO(s): ": "Problem DO(s): ", | ||||
| "Progress": "Progress", | "Progress": "Progress", | ||||
| "Quantity": "Quantity", | "Quantity": "Quantity", | ||||
| "All": "All", | |||||
| "Add Row": "Add", | |||||
| "Created": "Created", | |||||
| "DO code suffix must be exactly 4 characters": "DO code suffix must be exactly 4 characters", | |||||
| "DO Code Last 4": "DO No. (last 4)", | |||||
| "Delivery Date": "Delivery Date", | |||||
| "Enter last 4 characters of DO code": "Enter last 4 characters of DO code", | |||||
| "Shop code, or first characters of shop name": "Shop code (partial match), or first characters of shop name", | |||||
| "Multiple source DOs matched": "Multiple source DOs matched", | |||||
| "Please verify DO code suffix, delivery date and shop.": "Please verify DO code suffix, delivery date and shop.", | |||||
| "Shop code or name is required": "Shop code or name is required", | |||||
| "Draft List": "Draft List", | |||||
| "Enter item code to search": "Enter item code to search", | |||||
| "Failed to lookup source DO": "Failed to lookup source DO", | |||||
| "Item": "Item", | |||||
| "Lookup": "Lookup", | |||||
| "No draft rows to submit": "No draft rows to submit", | |||||
| "Only completed delivery orders can be used as replenishment source.": "Only completed delivery orders can be used as replenishment source.", | |||||
| "Original Shipment Qty": "Original Shipment Qty", | |||||
| "Please lookup source DO first": "Please lookup source DO first", | |||||
| "Picker Name": "Picker Name", | |||||
| "Please select an item": "Please select an item", | |||||
| "Records saved locally for preview. Backend integration pending.": "Records saved locally for preview. Backend integration pending.", | |||||
| "Replenishment Code": "Replenishment No.", | |||||
| "Ref Code": "Ref Code", | |||||
| "Replenish Qty": "Replenish Qty", | |||||
| "Replenish qty must be greater than zero": "Replenish qty must be greater than zero", | |||||
| "Replenishment": "Replenishment", | |||||
| "Replenishment API not ready": "Replenishment API not ready", | |||||
| "Replenishment Entry": "Replenishment Entry", | |||||
| "Replenishment item code": "Item Code", | |||||
| "Replenishment Tracking": "Replenishment Tracking", | |||||
| "Required field": "Required field", | |||||
| "replenishmentDatePlaceholder": "YYYY-MM-DD", | |||||
| "replenishmentDoSuffixPlaceholder": "DO No. (last 4)", | |||||
| "replenishmentShopPlaceholder": "Shop Code", | |||||
| "Source DO": "Source DO", | |||||
| "Source DO Code": "Source DO Code", | |||||
| "Source DO code is required": "Source DO code is required", | |||||
| "Source DO must be completed": "Source DO must be completed", | |||||
| "Source DO not found": "Source DO not found", | |||||
| "Submit": "Submit", | |||||
| "Target DO": "Target DO", | |||||
| "This item is already in the draft list": "This item is already in the draft list", | |||||
| "processing": "Processing", | |||||
| "Receiving": "Receiving", | "Receiving": "Receiving", | ||||
| "Release": "Release", | "Release": "Release", | ||||
| "Release 2/F": "Release 2/F", | "Release 2/F": "Release 2/F", | ||||
| @@ -95,5 +140,5 @@ | |||||
| "code": "code", | "code": "code", | ||||
| "do workbench": "do workbench", | "do workbench": "do workbench", | ||||
| "row selected": "row selected", | "row selected": "row selected", | ||||
| "uom": "uom" | |||||
| "uom": "Unit" | |||||
| } | } | ||||
| @@ -29,6 +29,8 @@ | |||||
| "if need just edit number, please scan the lot again": "if need just edit number, please scan the lot again", | "if need just edit number, please scan the lot again": "if need just edit number, please scan the lot again", | ||||
| "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}", | "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}", | ||||
| "Confirm Assignment": "Confirm Assignment", | "Confirm Assignment": "Confirm Assignment", | ||||
| "Assigned successfully": "Assigned successfully", | |||||
| "Assignment failed": "Assignment failed", | |||||
| "Required Date": "Required Date", | "Required Date": "Required Date", | ||||
| "Store": "Store", | "Store": "Store", | ||||
| "Available Orders": "Available Orders", | "Available Orders": "Available Orders", | ||||
| @@ -169,15 +169,12 @@ | |||||
| "diff_loadingEllipsis": "…", | "diff_loadingEllipsis": "…", | ||||
| "addShop_dialogTitle": "Add shop to lane", | "addShop_dialogTitle": "Add shop to lane", | ||||
| "addRoute_dialogTitle": "Add delivery 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_confirm": "Confirm add lane", | ||||
| "addRoute_submitting": "Adding…", | "addRoute_submitting": "Adding…", | ||||
| "district_dialog_add": "Add district", | "district_dialog_add": "Add district", | ||||
| "district_dialog_edit": "Edit district", | "district_dialog_edit": "Edit district", | ||||
| "district_name_label": "District display name", | "district_name_label": "District display name", | ||||
| "district_name_ph": "Blank means \"Unclassified\"", | "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_departureLabel": "Departure time", | ||||
| "seq_edit_seqLabel": "Load sequence (Seq)", | "seq_edit_seqLabel": "Load sequence (Seq)", | ||||
| "route_new_code_label": "Lane code", | "route_new_code_label": "Lane code", | ||||
| @@ -207,7 +204,6 @@ | |||||
| "departureDialog_title": "Edit departure time", | "departureDialog_title": "Edit departure time", | ||||
| "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", | "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", | ||||
| "seqDialog_title": "Edit load sequence", | "seqDialog_title": "Edit load sequence", | ||||
| "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", | |||||
| "logistics_colLaneCount": "{{count}} lane(s)", | "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_masterNoLanes": "Master record exists but no lanes are bound yet; pick this company when adding/editing lanes on the route board.", | ||||
| "tooltip_openLaneBoard": "Open this lane on the route board", | "tooltip_openLaneBoard": "Open this lane on the route board", | ||||
| @@ -196,8 +196,6 @@ | |||||
| "district_err_exists": "This district already exists", | "district_err_exists": "This district already exists", | ||||
| "district_err_name": "Enter a district name", | "district_err_name": "Enter a district name", | ||||
| "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", | "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", | ||||
| "district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"", | |||||
| "district_help_null": "Unclassified maps to districtReference = null on server", | |||||
| "district_name_label": "District display name", | "district_name_label": "District display name", | ||||
| "district_name_ph": "Blank means \"Unclassified\"", | "district_name_ph": "Blank means \"Unclassified\"", | ||||
| "drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.", | "drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.", | ||||
| @@ -324,6 +322,11 @@ | |||||
| "schedule_history_reschedule": "Reschedule", | "schedule_history_reschedule": "Reschedule", | ||||
| "schedule_history_status_failed": "Failed", | "schedule_history_status_failed": "Failed", | ||||
| "schedule_history_status_ignored": "Ignored", | "schedule_history_status_ignored": "Ignored", | ||||
| "schedule_history_status_cancelled": "Cancelled", | |||||
| "schedule_reschedule_dialog_title": "Reschedule", | |||||
| "schedule_reschedule_dialog_subtitle": "Pick a new run date and time. This cancelled task will be updated in place.", | |||||
| "schedule_reschedule_dialog_confirm": "Update schedule", | |||||
| "schedule_reschedule_created": "Schedule #{{id}} updated to run at {{at}}.", | |||||
| "schedule_history_status_pending": "Scheduled", | "schedule_history_status_pending": "Scheduled", | ||||
| "schedule_history_status_success": "Succeeded", | "schedule_history_status_success": "Succeeded", | ||||
| "schedule_history_subtitle": "Monitor, run, or troubleshoot scheduled shop-lane changes", | "schedule_history_subtitle": "Monitor, run, or troubleshoot scheduled shop-lane changes", | ||||
| @@ -406,7 +409,6 @@ | |||||
| "schedule_tab_import": "Import route Excel", | "schedule_tab_import": "Import route Excel", | ||||
| "schedule_tab_manual": "Drag scheduling", | "schedule_tab_manual": "Drag scheduling", | ||||
| "schedule_target_unset": "Not set", | "schedule_target_unset": "Not set", | ||||
| "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", | |||||
| "seqDialog_title": "Edit load sequence", | "seqDialog_title": "Edit load sequence", | ||||
| "seq_edit_departureLabel": "Departure time", | "seq_edit_departureLabel": "Departure time", | ||||
| "seq_edit_seqLabel": "Load sequence (Seq)", | "seq_edit_seqLabel": "Load sequence (Seq)", | ||||
| @@ -10,6 +10,51 @@ | |||||
| "Estimated Arrival From": "預計送貨日期", | "Estimated Arrival From": "預計送貨日期", | ||||
| "Estimated Arrival To": "預計送貨日期至", | "Estimated Arrival To": "預計送貨日期至", | ||||
| "Status": "來貨狀態", | "Status": "來貨狀態", | ||||
| "All": "全部", | |||||
| "Add Row": "新增", | |||||
| "Created": "建立時間", | |||||
| "DO code suffix must be exactly 4 characters": "送貨訂單號末四位必須為四個字元", | |||||
| "DO Code Last 4": "送貨單號末四位", | |||||
| "Delivery Date": "送貨日期", | |||||
| "Enter last 4 characters of DO code": "請輸入送貨單號末四位", | |||||
| "Enter item code to search": "輸入貨品編號搜尋", | |||||
| "Shop code, or first characters of shop name": "店鋪代碼(部分符合),或店鋪名稱開頭字元", | |||||
| "Multiple source DOs matched": "找到多張符合的來源送貨單", | |||||
| "Please verify DO code suffix, delivery date and shop.": "請核對送貨單號末四位、送貨日及店鋪資料。", | |||||
| "Shop code or name is required": "請輸入店鋪代碼或名稱", | |||||
| "Draft List": "待提交列表", | |||||
| "Failed to lookup source DO": "查詢來源送貨單失敗", | |||||
| "Item": "物品", | |||||
| "Lookup": "查詢", | |||||
| "No draft rows to submit": "沒有待提交的行", | |||||
| "Only completed delivery orders can be used as replenishment source.": "只有已送貨(completed)的送貨單可作為補貨來源。", | |||||
| "Original Shipment Qty": "原出貨數", | |||||
| "Please lookup source DO first": "請先查詢來源送貨單", | |||||
| "Picker Name": "揀貨員名稱", | |||||
| "Please select an item": "請選擇物品", | |||||
| "Records saved locally for preview. Backend integration pending.": "記錄已暫存於本地預覽,後端 API 尚未就緒。", | |||||
| "Replenishment Code": "補貨編號", | |||||
| "Ref Code": "參考編號", | |||||
| "Replenish Qty": "補貨數量", | |||||
| "Replenish qty must be greater than zero": "補貨數量必須大於零", | |||||
| "Replenishment": "補貨", | |||||
| "Replenishment API not ready": "補貨 API 尚未就緒", | |||||
| "Replenishment Entry": "補貨填表", | |||||
| "Replenishment item code": "貨品編號", | |||||
| "Replenishment Tracking": "補貨進度追蹤", | |||||
| "Required field": "為必填項", | |||||
| "replenishmentDatePlaceholder": "YYYY-MM-DD", | |||||
| "replenishmentDoSuffixPlaceholder": "送貨單號末四位", | |||||
| "replenishmentShopPlaceholder": "店鋪編號", | |||||
| "Source DO": "來源送貨單", | |||||
| "Source DO Code": "來源送貨單編號", | |||||
| "Source DO code is required": "請輸入來源送貨單編號", | |||||
| "Source DO must be completed": "來源送貨單須為已送貨狀態", | |||||
| "Source DO not found": "找不到來源送貨單", | |||||
| "Submit": "提交", | |||||
| "Target DO": "目標送貨單", | |||||
| "This item is already in the draft list": "此物品已在待提交列表中", | |||||
| "processing": "處理中", | |||||
| "Etra": "加單", | "Etra": "加單", | ||||
| "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | ||||
| "Confirm merge release": "確認合併放單", | "Confirm merge release": "確認合併放單", | ||||
| @@ -71,7 +116,7 @@ | |||||
| "Supplier Code": "供應商編號", | "Supplier Code": "供應商編號", | ||||
| "Estimated Arrival Date": "預計送貨日期", | "Estimated Arrival Date": "預計送貨日期", | ||||
| "Item No.": "商品編號", | "Item No.": "商品編號", | ||||
| "Item Name": "商品名稱", | |||||
| "Item Name": "貨品名稱", | |||||
| "Quantity": "數量", | "Quantity": "數量", | ||||
| "uom": "單位", | "uom": "單位", | ||||
| "Lot No.": "批號", | "Lot No.": "批號", | ||||
| @@ -120,7 +165,6 @@ | |||||
| "Yes": "是", | "Yes": "是", | ||||
| "No": "否", | "No": "否", | ||||
| "Replenishment input section": "補貨資料", | "Replenishment input section": "補貨資料", | ||||
| "Replenishment item code": "貨品編號", | |||||
| "Replenishment search candidates": "搜尋候選送貨單", | "Replenishment search candidates": "搜尋候選送貨單", | ||||
| "Replenishment reset": "重設", | "Replenishment reset": "重設", | ||||
| "Replenishment candidate section": "候選送貨單(待放單)", | "Replenishment candidate section": "候選送貨單(待放單)", | ||||
| @@ -29,6 +29,8 @@ | |||||
| "if need just edit number, please scan the lot again": "如果需要只修改數量,請重新掃描批次。", | "if need just edit number, please scan the lot again": "如果需要只修改數量,請重新掃描批次。", | ||||
| "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "總數量(實際提料 + 遺失 + 不良)不能超過可用數量:{{available}}", | "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "總數量(實際提料 + 遺失 + 不良)不能超過可用數量:{{available}}", | ||||
| "Confirm Assignment": "確認分配", | "Confirm Assignment": "確認分配", | ||||
| "Assigned successfully": "分派成功", | |||||
| "Assignment failed": "分配失敗", | |||||
| "Required Date": "所需日期", | "Required Date": "所需日期", | ||||
| "Store": "位置", | "Store": "位置", | ||||
| "Available Orders": "可用訂單", | "Available Orders": "可用訂單", | ||||
| @@ -34,7 +34,7 @@ | |||||
| "exportRoutes": "匯出車線", | "exportRoutes": "匯出車線", | ||||
| "routeReport": "車線報告", | "routeReport": "車線報告", | ||||
| "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", | "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", | ||||
| "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", | |||||
| "departureTooltipEditSave": "編輯出車時間", | |||||
| "departureEditAria": "編輯出車時間", | "departureEditAria": "編輯出車時間", | ||||
| "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", | "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", | ||||
| "cancel": "取消", | "cancel": "取消", | ||||
| @@ -80,9 +80,9 @@ | |||||
| "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | ||||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | ||||
| "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | ||||
| "restore_applied": "已從 snapshot 還原並重新載入看板。", | |||||
| "restore_appliedDroppedStaging": "已套用 snapshot 還原;本次儲存略過其他暫存變更(請重新編輯)。", | |||||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用 snapshot 還原,本次其他暫存變更會被略過。確定繼續?", | |||||
| "restore_applied": "已從 版本還原並重新載入看板。", | |||||
| "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | |||||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", | |||||
| "diff_noOlderCompare": "沒有上一筆版本可比較(請選擇較新的版本)", | "diff_noOlderCompare": "沒有上一筆版本可比較(請選擇較新的版本)", | ||||
| "logistic_needMasterTpl": "「{{name}}」尚無對應物流公司,請先用「新增物流商」建立。", | "logistic_needMasterTpl": "「{{name}}」尚無對應物流公司,請先用「新增物流商」建立。", | ||||
| "diffField_logisticsCompany": "物流公司", | "diffField_logisticsCompany": "物流公司", | ||||
| @@ -140,11 +140,11 @@ | |||||
| "diff_shopList_title": "店鋪異動清單", | "diff_shopList_title": "店鋪異動清單", | ||||
| "diff_staged_serverCountsOnly": "上列四格為「後端相鄰兩版快照」統計,不含看板上尚未儲存的編輯。", | "diff_staged_serverCountsOnly": "上列四格為「後端相鄰兩版快照」統計,不含看板上尚未儲存的編輯。", | ||||
| "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", | "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", | ||||
| "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", | |||||
| "diff_staged_section_title": "看板未儲存/已排程", | |||||
| "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端快照)混淆。", | "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端快照)混淆。", | ||||
| "diff_staged_tag_unsaved": "未儲存", | "diff_staged_tag_unsaved": "未儲存", | ||||
| "diff_staged_tag_scheduled": "已排程", | "diff_staged_tag_scheduled": "已排程", | ||||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", | |||||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||||
| "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", | "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", | ||||
| "diff_staged_newLane": "新增車線(未儲存):{{lane}}", | "diff_staged_newLane": "新增車線(未儲存):{{lane}}", | ||||
| "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", | "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", | ||||
| @@ -154,9 +154,9 @@ | |||||
| "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", | "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", | ||||
| "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", | "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", | ||||
| "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", | "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", | ||||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", | |||||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列", | |||||
| "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", | "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", | ||||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", | |||||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。", | |||||
| "err_importEmpty": "匯入檔案無有效車線資料列", | "err_importEmpty": "匯入檔案無有效車線資料列", | ||||
| "diff_logisticMaster_section": "物流公司異動", | "diff_logisticMaster_section": "物流公司異動", | ||||
| "diff_logisticMaster_added": "新增", | "diff_logisticMaster_added": "新增", | ||||
| @@ -169,17 +169,14 @@ | |||||
| "diff_loadingEllipsis": "…", | "diff_loadingEllipsis": "…", | ||||
| "addShop_dialogTitle": "新增店鋪到車線", | "addShop_dialogTitle": "新增店鋪到車線", | ||||
| "addRoute_dialogTitle": "新增配送車線", | "addRoute_dialogTitle": "新增配送車線", | ||||
| "addRoute_hint": "確認後先加入看板暫存;須按頂部「儲存更改」才會在後端建立車線(不建立假店鋪列)。", | |||||
| "addRoute_confirm": "確認新增車線", | "addRoute_confirm": "確認新增車線", | ||||
| "addRoute_submitting": "新增中…", | "addRoute_submitting": "新增中…", | ||||
| "district_dialog_add": "新增地區", | "district_dialog_add": "新增地區", | ||||
| "district_dialog_edit": "編輯地區", | "district_dialog_edit": "編輯地區", | ||||
| "district_name_label": "地區顯示名稱", | "district_name_label": "地區顯示名稱", | ||||
| "district_name_ph": "空白表示「未分類」", | "district_name_ph": "空白表示「未分類」", | ||||
| "district_help_null": "未分類對應後端 districtReference 為 null", | |||||
| "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", | |||||
| "seq_edit_departureLabel": "出車時間", | "seq_edit_departureLabel": "出車時間", | ||||
| "seq_edit_seqLabel": "裝車順序 (Seq)", | |||||
| "seq_edit_seqLabel": "裝車順序", | |||||
| "route_new_code_label": "車線編號", | "route_new_code_label": "車線編號", | ||||
| "route_new_time_label": "出車時間", | "route_new_time_label": "出車時間", | ||||
| "route_new_logistic_label": "物流公司", | "route_new_logistic_label": "物流公司", | ||||
| @@ -203,34 +200,33 @@ | |||||
| "dialog_editLogisticsTitle": "編輯物流公司", | "dialog_editLogisticsTitle": "編輯物流公司", | ||||
| "btn_apply": "套用", | "btn_apply": "套用", | ||||
| "addShop_confirm": "確認", | "addShop_confirm": "確認", | ||||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。", | |||||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳。", | |||||
| "departureDialog_title": "編輯出車時間", | "departureDialog_title": "編輯出車時間", | ||||
| "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | |||||
| "departureDialog_hint": "套用至此車線所有店鋪列。", | |||||
| "seqDialog_title": "編輯裝車順序", | "seqDialog_title": "編輯裝車順序", | ||||
| "seqDialog_hint": "按「儲存更改」後寫入 truck 列。", | |||||
| "logistics_colLaneCount": "{{count}} 條車線", | "logistics_colLaneCount": "{{count}} 條車線", | ||||
| "logistics_masterNoLanes": "主檔已建立,尚無綁定車線;至「車線看板」新增/編輯車線時可填此公司名稱。", | "logistics_masterNoLanes": "主檔已建立,尚無綁定車線;至「車線看板」新增/編輯車線時可填此公司名稱。", | ||||
| "tooltip_openLaneBoard": "在車線看板開此車線", | "tooltip_openLaneBoard": "在車線看板開此車線", | ||||
| "aria_openLaneBoard": "開啟車線看板", | "aria_openLaneBoard": "開啟車線看板", | ||||
| "tooltip_removeFromLane": "從此車線移除", | "tooltip_removeFromLane": "從此車線移除", | ||||
| "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | |||||
| "tooltip_clearLaneShops": "清空此車線所有店鋪", | |||||
| "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | ||||
| "aria_pickLane": "選擇車線", | "aria_pickLane": "選擇車線", | ||||
| "aria_searchLanes": "搜索車線", | "aria_searchLanes": "搜索車線", | ||||
| "logistics_colShopCount": "{{count}} 家店鋪", | "logistics_colShopCount": "{{count}} 家店鋪", | ||||
| "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | |||||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | |||||
| "tooltip_editLogisticsDb": "編輯物流公司", | |||||
| "tooltip_deleteLogistics": "刪除物流公司", | |||||
| "aria_editLogistics": "編輯物流公司", | "aria_editLogistics": "編輯物流公司", | ||||
| "aria_deleteLogistics": "刪除物流公司", | "aria_deleteLogistics": "刪除物流公司", | ||||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。", | |||||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | |||||
| "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", | "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", | ||||
| "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", | "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", | ||||
| "logistic_btn_apply": "套用", | "logistic_btn_apply": "套用", | ||||
| "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", | |||||
| "tooltip_editDistrict": "編輯地區名稱", | |||||
| "aria_editDistrict": "編輯地區", | "aria_editDistrict": "編輯地區", | ||||
| "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", | "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", | ||||
| "aria_removeEmptyDistrict": "移除空區", | "aria_removeEmptyDistrict": "移除空區", | ||||
| "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", | |||||
| "tooltip_editSeq": "編輯裝車順序", | |||||
| "aria_editSeq": "編輯裝車順序", | "aria_editSeq": "編輯裝車順序", | ||||
| "diff_moveFrom": "從 {{lane}}", | "diff_moveFrom": "從 {{lane}}", | ||||
| "logistics_dirtyColumnBadge": "有未儲存物流更改", | "logistics_dirtyColumnBadge": "有未儲存物流更改", | ||||
| @@ -34,7 +34,7 @@ | |||||
| "exportRoutes": "匯出車線", | "exportRoutes": "匯出車線", | ||||
| "routeReport": "車線報告", | "routeReport": "車線報告", | ||||
| "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", | "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", | ||||
| "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", | |||||
| "departureTooltipEditSave": "編輯出車時間", | |||||
| "departureEditAria": "編輯出車時間", | "departureEditAria": "編輯出車時間", | ||||
| "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", | "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", | ||||
| "cancel": "取消", | "cancel": "取消", | ||||
| @@ -139,11 +139,11 @@ | |||||
| "diff_summary_fieldChange": "欄位變更", | "diff_summary_fieldChange": "欄位變更", | ||||
| "diff_shopList_title": "店鋪變更清單", | "diff_shopList_title": "店鋪變更清單", | ||||
| "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", | "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", | ||||
| "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", | |||||
| "diff_staged_section_title": "看板未儲存/已排程", | |||||
| "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端版本)混淆。", | "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端版本)混淆。", | ||||
| "diff_staged_tag_unsaved": "未儲存", | "diff_staged_tag_unsaved": "未儲存", | ||||
| "diff_staged_tag_scheduled": "已排程", | "diff_staged_tag_scheduled": "已排程", | ||||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", | |||||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||||
| "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", | "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", | ||||
| "diff_staged_newLane": "新增車線(未儲存):{{lane}}", | "diff_staged_newLane": "新增車線(未儲存):{{lane}}", | ||||
| "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", | "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", | ||||
| @@ -153,9 +153,9 @@ | |||||
| "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", | "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", | ||||
| "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", | "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", | ||||
| "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", | "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", | ||||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", | |||||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列", | |||||
| "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", | "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", | ||||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", | |||||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。", | |||||
| "err_importEmpty": "匯入檔案無有效車線資料列", | "err_importEmpty": "匯入檔案無有效車線資料列", | ||||
| "diff_logisticMaster_section": "物流公司變更", | "diff_logisticMaster_section": "物流公司變更", | ||||
| "diff_logisticMaster_added": "新增", | "diff_logisticMaster_added": "新增", | ||||
| @@ -175,10 +175,8 @@ | |||||
| "district_dialog_edit": "編輯地區", | "district_dialog_edit": "編輯地區", | ||||
| "district_name_label": "地區顯示名稱", | "district_name_label": "地區顯示名稱", | ||||
| "district_name_ph": "空白表示「未分類」", | "district_name_ph": "空白表示「未分類」", | ||||
| "district_help_null": "未分類對應後端 districtReference 為 null", | |||||
| "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", | |||||
| "seq_edit_departureLabel": "出車時間", | "seq_edit_departureLabel": "出車時間", | ||||
| "seq_edit_seqLabel": "裝車順序 (Seq)", | |||||
| "seq_edit_seqLabel": "裝車順序", | |||||
| "route_new_code_label": "車線編號", | "route_new_code_label": "車線編號", | ||||
| "route_new_time_label": "出車時間", | "route_new_time_label": "出車時間", | ||||
| "route_new_logistic_label": "物流公司", | "route_new_logistic_label": "物流公司", | ||||
| @@ -206,7 +204,6 @@ | |||||
| "departureDialog_title": "編輯出車時間", | "departureDialog_title": "編輯出車時間", | ||||
| "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | ||||
| "seqDialog_title": "編輯裝車順序", | "seqDialog_title": "編輯裝車順序", | ||||
| "seqDialog_hint": "按「儲存更改」後寫入 truck 列。", | |||||
| "logistics_colLaneCount": "{{count}} 條車線", | "logistics_colLaneCount": "{{count}} 條車線", | ||||
| "tooltip_openLaneBoard": "在車線看板開此車線", | "tooltip_openLaneBoard": "在車線看板開此車線", | ||||
| "aria_openLaneBoard": "開啟車線看板", | "aria_openLaneBoard": "開啟車線看板", | ||||
| @@ -220,15 +217,15 @@ | |||||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | ||||
| "aria_editLogistics": "編輯物流公司", | "aria_editLogistics": "編輯物流公司", | ||||
| "aria_deleteLogistics": "刪除物流公司", | "aria_deleteLogistics": "刪除物流公司", | ||||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。", | |||||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | |||||
| "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", | "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", | ||||
| "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", | "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", | ||||
| "logistic_btn_apply": "套用", | "logistic_btn_apply": "套用", | ||||
| "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", | |||||
| "tooltip_editDistrict": "編輯地區名稱", | |||||
| "aria_editDistrict": "編輯地區", | "aria_editDistrict": "編輯地區", | ||||
| "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", | "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", | ||||
| "aria_removeEmptyDistrict": "移除空區", | "aria_removeEmptyDistrict": "移除空區", | ||||
| "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", | |||||
| "tooltip_editSeq": "編輯裝車順序", | |||||
| "aria_editSeq": "編輯裝車順序", | "aria_editSeq": "編輯裝車順序", | ||||
| "diff_moveFrom": "從 {{lane}}", | "diff_moveFrom": "從 {{lane}}", | ||||
| "logistics_dirtyColumnBadge": "有未儲存物流更改", | "logistics_dirtyColumnBadge": "有未儲存物流更改", | ||||
| @@ -312,7 +309,7 @@ | |||||
| "schedule_review_seq": "裝載順序:", | "schedule_review_seq": "裝載順序:", | ||||
| "schedule_drop_hint": "請拖曳店鋪卡片至此車線", | "schedule_drop_hint": "請拖曳店鋪卡片至此車線", | ||||
| "schedule_moved_badge": "移入", | "schedule_moved_badge": "移入", | ||||
| "schedule_drag_seq": "裝載順序 Seq: {{seq}}", | |||||
| "schedule_drag_seq": "裝載順序: {{seq}}", | |||||
| "schedule_seq_edit_btn": "編輯裝載順序", | "schedule_seq_edit_btn": "編輯裝載順序", | ||||
| "schedule_seq_dialog_hint": "變更會加入右側預覽佇列,確認排程後才套用。", | "schedule_seq_dialog_hint": "變更會加入右側預覽佇列,確認排程後才套用。", | ||||
| "schedule_planned_label": "執行預定", | "schedule_planned_label": "執行預定", | ||||
| @@ -320,7 +317,7 @@ | |||||
| "schedule_applied_snackbar": "已登記 {{count}} 筆預約變更並更新看板,請按「儲存更改」寫入後端。", | "schedule_applied_snackbar": "已登記 {{count}} 筆預約變更並更新看板,請按「儲存更改」寫入後端。", | ||||
| "schedule_err_conflict": "部分店舖無法移至目標車線(重複店鋪或草稿列)。", | "schedule_err_conflict": "部分店舖無法移至目標車線(重複店鋪或草稿列)。", | ||||
| "schedule_err_execute_at_past": "排程執行時間已過去,請選擇未來的日期與時間。", | "schedule_err_execute_at_past": "排程執行時間已過去,請選擇未來的日期與時間。", | ||||
| "schedule_err_open_pending": "店舖列 #{{id}} 已有待執行的排程。", | |||||
| "schedule_err_open_pending": "店舖已有待執行的排程。", | |||||
| "schedule_err_duplicate_shop": "目標車線上已有店舖 {{shop}}。", | "schedule_err_duplicate_shop": "目標車線上已有店舖 {{shop}}。", | ||||
| "schedule_err_target_lane_missing": "找不到目標車線 {{lane}}。", | "schedule_err_target_lane_missing": "找不到目標車線 {{lane}}。", | ||||
| "schedule_err_target_lane_empty": "目標車線 {{lane}} 尚無店舖,請先加入店舖。", | "schedule_err_target_lane_empty": "目標車線 {{lane}} 尚無店舖,請先加入店舖。", | ||||
| @@ -329,7 +326,7 @@ | |||||
| "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", | "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", | ||||
| "schedule_shop_badge": "已排程變更", | "schedule_shop_badge": "已排程變更", | ||||
| "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", | "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", | ||||
| "schedule_retry_rejects_partial": "PARTIAL 排程不可重試,請先還原看板後重新建立排程", | |||||
| "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", | |||||
| "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", | "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", | ||||
| "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", | "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", | ||||
| "schedule_import_confirm_btn": "確認匯入並建立排程", | "schedule_import_confirm_btn": "確認匯入並建立排程", | ||||
| @@ -349,8 +346,8 @@ | |||||
| "schedule_history_applied_at": "成功執行時間:{{at}}", | "schedule_history_applied_at": "成功執行時間:{{at}}", | ||||
| "schedule_history_view_lines": "檢視店舖明細", | "schedule_history_view_lines": "檢視店舖明細", | ||||
| "schedule_history_no_lines": "尚無明細資料", | "schedule_history_no_lines": "尚無明細資料", | ||||
| "schedule_history_failed_lines": "{{count}} 筆店舖移線失敗", | |||||
| "schedule_history_success_lines": "{{count}} 筆店舖已成功移線", | |||||
| "schedule_history_failed_lines": "{{count}} 筆店舖移動失敗", | |||||
| "schedule_history_success_lines": "{{count}} 筆店舖已成功移動", | |||||
| "schedule_history_apply_now": "立即執行", | "schedule_history_apply_now": "立即執行", | ||||
| "schedule_history_cancel": "取消排程", | "schedule_history_cancel": "取消排程", | ||||
| "schedule_history_archived": "已執行", | "schedule_history_archived": "已執行", | ||||
| @@ -358,8 +355,13 @@ | |||||
| "schedule_history_reschedule": "重新排程", | "schedule_history_reschedule": "重新排程", | ||||
| "schedule_history_ignore": "忽略", | "schedule_history_ignore": "忽略", | ||||
| "schedule_history_status_ignored": "已忽略", | "schedule_history_status_ignored": "已忽略", | ||||
| "schedule_history_status_cancelled": "已取消", | |||||
| "schedule_reschedule_dialog_title": "重新排程", | |||||
| "schedule_reschedule_dialog_subtitle": "選擇新的執行日期與時間,將更新此筆已取消的預約任務。", | |||||
| "schedule_reschedule_dialog_confirm": "更新排程", | |||||
| "schedule_reschedule_created": "排程 #{{id}} 已更新,將於 {{at}} 執行。", | |||||
| "schedule_history_close": "關閉視窗", | "schedule_history_close": "關閉視窗", | ||||
| "PENDING": "待處理", | |||||
| "PENDING": "待執行", | |||||
| "APPLYING": "執行中", | "APPLYING": "執行中", | ||||
| "APPLIED": "已套用", | "APPLIED": "已套用", | ||||
| "PARTIAL": "部分完成", | "PARTIAL": "部分完成", | ||||