| @@ -1,10 +1,23 @@ | |||
| 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: 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 ( | |||
| <I18nProvider namespaces={["report", "navigation", "common"]}> | |||
| {children} | |||
| @@ -27,6 +27,8 @@ export interface DoDetail { | |||
| status: string; | |||
| /** 加單 DO */ | |||
| isExtra?: boolean; | |||
| /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | |||
| handlerName?: string | null; | |||
| deliveryOrderLines: DoDetailLine[]; | |||
| } | |||
| @@ -56,6 +58,7 @@ export interface DoSearchAll { | |||
| shopName: string; | |||
| shopAddress?: string; | |||
| isExtra?: boolean; | |||
| truckLanceCode?: string | null; | |||
| } | |||
| export interface DoSearchLiteResponse { | |||
| 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 ( | |||
| id: number, | |||
| ): Promise<TruckLaneScheduleResponse> => { | |||
| @@ -38,6 +38,7 @@ import { | |||
| cancelTruckLaneScheduleAction, | |||
| applyNowTruckLaneScheduleAction, | |||
| retryFailedTruckLaneScheduleAction, | |||
| reactivateCancelledTruckLaneScheduleAction, | |||
| ignoreTruckLaneScheduleAction, | |||
| type RetryFailedTruckLaneScheduleRequest, | |||
| parseTruckLaneScheduleExcelAction, | |||
| @@ -255,6 +256,13 @@ export const retryFailedTruckLaneScheduleClient = async ( | |||
| return await retryFailedTruckLaneScheduleAction(id, body); | |||
| }; | |||
| export const reactivateCancelledTruckLaneScheduleClient = async ( | |||
| id: number, | |||
| body?: RetryFailedTruckLaneScheduleRequest, | |||
| ): Promise<TruckLaneScheduleResponse> => { | |||
| return await reactivateCancelledTruckLaneScheduleAction(id, body); | |||
| }; | |||
| export const ignoreTruckLaneScheduleClient = async ( | |||
| id: number, | |||
| ): Promise<TruckLaneScheduleResponse> => { | |||
| @@ -16,6 +16,6 @@ export const AUTH = { | |||
| JOB_CREATE: "JOB_CREATE", | |||
| JOB_PICK: "JOB_PICK", | |||
| JOB_MAT: "JOB_MAT", | |||
| JOB_PROD: "JOB_PROD", | |||
| JOB_PROD: "JOB_PROD", | |||
| REPORT_MGMT: "REPORT_MGMT", | |||
| } 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 { SessionWithTokens } from "@/config/authConfig"; | |||
| import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; | |||
| import DoReplenishmentTab from "./DoReplenishmentTab"; | |||
| type Props = { | |||
| 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 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 }; | |||
| // put all this into a new component | |||
| @@ -313,8 +314,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| case "TRUCK_X": | |||
| return { floor: null, isExtra: false, forceTruckKeyword: "x" }; | |||
| case "ETRA": | |||
| default: | |||
| 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="TRUCK_X" label={t("Truck X")} /> | |||
| <Tab value="ETRA" label={t("Etra")} /> | |||
| <Tab value="REPLENISH" label={t("Replenishment")} /> | |||
| </Tabs> | |||
| {hasSearched && hasResults && ( | |||
| {activeTab !== "REPLENISH" && hasSearched && hasResults && ( | |||
| <Button | |||
| name="batch_release" | |||
| variant="contained" | |||
| @@ -756,35 +760,41 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||
| </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> | |||
| </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 />, | |||
| labelKey: "nav.report", | |||
| path: "/report", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.REPORT_MGMT, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| @@ -363,7 +363,7 @@ const NavigationContent: React.FC = () => { | |||
| id: "nav.settings.shopAndTruck", | |||
| icon: <Storefront />, | |||
| labelKey: "nav.settings.shopAndTruck", | |||
| path: "/settings/shop", | |||
| path: "/settings/shop/board", | |||
| }, | |||
| { | |||
| id: "nav.settings.deliveryOrderFloor", | |||
| @@ -495,6 +495,13 @@ const NavigationContent: React.FC = () => { | |||
| if (!pathname.startsWith(p + "/")) return false; | |||
| // `/doworkbench` must not claim `/doworkbenchsearch` (prefix without trailing slash) | |||
| 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; | |||
| }); | |||
| matches.sort((a, b) => b.length - a.length); | |||
| @@ -2425,6 +2425,9 @@ const RouteBoard: React.FC = () => { | |||
| }; | |||
| const handleDeleteTruckRow = async (truckRowId: number) => { | |||
| if (truckRowId > 0 && scheduledShopIdSet.has(truckRowId)) { | |||
| return; | |||
| } | |||
| if (truckRowId < 0) { | |||
| if (!window.confirm(t("confirm_discardDraftShop"))) return; | |||
| setError(null); | |||
| @@ -2488,6 +2491,11 @@ const RouteBoard: React.FC = () => { | |||
| /** 清空整桶店鋪:與單筆刪除相同,僅標記 dirtyDeletes,按「儲存更改」才 deleteTruckLaneClient */ | |||
| const handleClearLaneShops = (lane: Lane) => { | |||
| if (lane.shops.length === 0) return; | |||
| if ( | |||
| lane.shops.some((s) => s.id > 0 && scheduledShopIdSet.has(s.id)) | |||
| ) { | |||
| return; | |||
| } | |||
| if ( | |||
| !window.confirm( | |||
| t("confirm_clearLane", { | |||
| @@ -5845,13 +5853,6 @@ const RouteBoard: React.FC = () => { | |||
| setDistrictEditError(null); | |||
| }} | |||
| error={Boolean(districtEditError)} | |||
| helperText={ | |||
| districtEditError || | |||
| (districtEditCtx?.mode === "rename" && | |||
| districtEditCtx.oldDisplay === "未分類" | |||
| ? t("district_help_null") | |||
| : t("district_help_mapped")) | |||
| } | |||
| InputLabelProps={{ shrink: true }} | |||
| /> | |||
| </DialogContent> | |||
| @@ -5915,13 +5916,6 @@ const RouteBoard: React.FC = () => { | |||
| inputProps={{ step: 1 }} | |||
| sx={{ mt: 1 }} | |||
| /> | |||
| <Typography | |||
| variant="caption" | |||
| color="text.secondary" | |||
| sx={{ mt: 1, display: "block" }} | |||
| > | |||
| {t("seqDialog_hint")} | |||
| </Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={closeSeqEdit}>{t("cancel")}</Button> | |||
| @@ -5982,9 +5976,6 @@ const RouteBoard: React.FC = () => { | |||
| {addRouteError} | |||
| </Alert> | |||
| )} | |||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 2 }}> | |||
| {t("addRoute_hint")} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "grid", | |||
| @@ -7846,7 +7837,13 @@ const RouteBoard: React.FC = () => { | |||
| spacing={0.5} | |||
| alignItems="center" | |||
| > | |||
| <Tooltip title={t("tooltip_removeFromLane")}> | |||
| <Tooltip | |||
| title={ | |||
| isScheduledMove | |||
| ? t("schedule_shop_locked") | |||
| : t("tooltip_removeFromLane") | |||
| } | |||
| > | |||
| <span> | |||
| <IconButton | |||
| size="small" | |||
| @@ -7861,7 +7858,8 @@ const RouteBoard: React.FC = () => { | |||
| }} | |||
| disabled={ | |||
| loading || | |||
| dirtyDeletes.has(shop.id) | |||
| dirtyDeletes.has(shop.id) || | |||
| isScheduledMove | |||
| } | |||
| > | |||
| <Trash2 size={16} /> | |||
| @@ -7947,12 +7945,29 @@ const RouteBoard: React.FC = () => { | |||
| > | |||
| {t("btn_addShopToLane")} | |||
| </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> | |||
| <IconButton | |||
| size="small" | |||
| 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} /> | |||
| </IconButton> | |||
| @@ -14,6 +14,7 @@ import { | |||
| DialogTitle, | |||
| IconButton, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { | |||
| @@ -38,16 +39,23 @@ import { | |||
| applyNowTruckLaneScheduleClient, | |||
| cancelTruckLaneScheduleClient, | |||
| createTruckLaneScheduleClient, | |||
| reactivateCancelledTruckLaneScheduleClient, | |||
| getTruckLaneScheduleClient, | |||
| ignoreTruckLaneScheduleClient, | |||
| listTruckLaneSchedulesClient, | |||
| retryFailedTruckLaneScheduleClient, | |||
| type TruckLaneScheduleLineRequest, | |||
| type TruckLaneScheduleLineResponse, | |||
| type TruckLaneScheduleResponse, | |||
| } from "@/app/api/shop/client"; | |||
| import type { ScheduleLaneOption } from "@/components/Shop/ScheduleChangeModal"; | |||
| import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; | |||
| import { resolveRescheduleExecuteAt, formatScheduleDisplayDateTime } from "@/components/Shop/scheduleExecuteAt"; | |||
| import { | |||
| buildExecuteAtIso, | |||
| isExecuteAtTooEarly, | |||
| resolveRescheduleExecuteAt, | |||
| formatScheduleDisplayDateTime, | |||
| } from "@/components/Shop/scheduleExecuteAt"; | |||
| import { | |||
| buildScheduleLineDescription, | |||
| } from "@/components/Shop/scheduleLineDisplay"; | |||
| @@ -84,7 +92,7 @@ function uiStatusFromLines( | |||
| 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( | |||
| task: TruckLaneScheduleResponse, | |||
| detail?: TruckLaneScheduleResponse | null, | |||
| @@ -93,11 +101,6 @@ function resolveUiStatus( | |||
| if (st === "CANCELLED") return "cancelled"; | |||
| if (st === "IGNORED") return "ignored"; | |||
| const fromDetailLines = detail?.lines?.length | |||
| ? uiStatusFromLines(detail.lines) | |||
| : null; | |||
| if (fromDetailLines) return fromDetailLines; | |||
| if (task.lineCounts) { | |||
| const fromCounts = uiStatusFromLineCounts(task.lineCounts); | |||
| if (fromCounts) return fromCounts; | |||
| @@ -108,6 +111,12 @@ function resolveUiStatus( | |||
| if (st === "PARTIAL") { | |||
| 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"; | |||
| return "pending"; | |||
| } | |||
| @@ -118,13 +127,34 @@ function canManagePendingSchedule( | |||
| ): boolean { | |||
| const st = String(task.status ?? "").toUpperCase(); | |||
| 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 ?? []; | |||
| if (lines.length > 0) { | |||
| 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 } { | |||
| @@ -163,6 +193,12 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| const [actionId, setActionId] = useState<number | null>(null); | |||
| const [actionError, setActionError] = 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( | |||
| () => new Map(lanes.map((l) => [l.id, l])), | |||
| @@ -218,6 +254,9 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| setDetailById({}); | |||
| setActionError(null); | |||
| setActionNotice(null); | |||
| setRedoTarget(null); | |||
| setRedoDate(""); | |||
| setRedoTime(""); | |||
| void loadTasks(); | |||
| }, [open, loadTasks]); | |||
| @@ -238,15 +277,22 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| return tasks.filter((task) => { | |||
| const detail = detailById[task.id]; | |||
| const ui = resolveUiStatus(task, detail); | |||
| if (filter === "all") return ui !== "cancelled"; | |||
| if (filter === "all") return true; | |||
| if (filter === "pending") return ui === "pending"; | |||
| if (filter === "success") return ui === "success"; | |||
| return ui === "failed"; | |||
| }); | |||
| }, [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); | |||
| try { | |||
| const detail = await getTruckLaneScheduleClient(id); | |||
| @@ -255,8 +301,31 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| } finally { | |||
| 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) => { | |||
| if (expandedId === id) { | |||
| setExpandedId(null); | |||
| @@ -271,8 +340,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| setActionError(null); | |||
| try { | |||
| await cancelTruckLaneScheduleClient(id); | |||
| await onAfterChange?.(); | |||
| await loadTasks(); | |||
| await reloadAfterMutation(id); | |||
| } catch (err: unknown) { | |||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | |||
| } finally { | |||
| @@ -285,8 +353,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| setActionError(null); | |||
| try { | |||
| await applyNowTruckLaneScheduleClient(id); | |||
| await onAfterChange?.(); | |||
| await loadTasks(); | |||
| await reloadAfterMutation(id); | |||
| } catch (err: unknown) { | |||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | |||
| } 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) => { | |||
| if (String(task.status ?? "").toUpperCase() === "PARTIAL") { | |||
| setActionError(t("schedule_retry_rejects_partial")); | |||
| @@ -315,18 +435,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| (l) => l.lineStatus === "FAILED" || l.lineStatus === "PENDING", | |||
| ); | |||
| 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 }); | |||
| } | |||
| if (adjusted) { | |||
| @@ -334,8 +443,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| t("schedule_reschedule_time_adjusted", { at: executeAt }), | |||
| ); | |||
| } | |||
| await onAfterChange?.(); | |||
| await loadTasks(); | |||
| await reloadAfterMutation(task.id); | |||
| } catch (err: unknown) { | |||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | |||
| } finally { | |||
| @@ -348,8 +456,7 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| setActionError(null); | |||
| try { | |||
| await ignoreTruckLaneScheduleClient(id); | |||
| await onAfterChange?.(); | |||
| await loadTasks(); | |||
| await reloadAfterMutation(id); | |||
| } catch (err: unknown) { | |||
| setActionError(extractApiErrorMessage(err) ?? t("schedule_err_generic")); | |||
| } 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 ( | |||
| <Chip | |||
| size="small" | |||
| @@ -966,6 +1089,24 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| {t("schedule_history_status_ignored")} | |||
| </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> | |||
| </Box> | |||
| @@ -988,6 +1129,58 @@ const ScheduleTaskHistoryModal: React.FC<Props> = ({ | |||
| {t("schedule_history_close")} | |||
| </Button> | |||
| </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> | |||
| ); | |||
| }; | |||
| @@ -325,7 +325,7 @@ const ShopDetail: React.FC = () => { | |||
| <Alert severity="error" sx={{ mb: 2 }}> | |||
| {error} | |||
| </Alert> | |||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||
| <Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -336,7 +336,7 @@ const ShopDetail: React.FC = () => { | |||
| <Alert severity="warning" sx={{ mb: 2 }}> | |||
| {t("Shop not found")} | |||
| </Alert> | |||
| <Button onClick={() => router.push("/settings/shop?tab=0")}>{t("Back")}</Button> | |||
| <Button onClick={() => router.push("/settings/shop/board")}>{t("Back")}</Button> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -347,7 +347,7 @@ const ShopDetail: React.FC = () => { | |||
| <CardContent> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <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 sx={{ display: "flex", flexDirection: "column", gap: 2 }}> | |||
| @@ -493,7 +493,7 @@ const TruckLaneDetail: React.FC = () => { | |||
| }; | |||
| const handleBack = () => { | |||
| router.push("/settings/shop?tab=1"); | |||
| router.push("/settings/shop/board"); | |||
| }; | |||
| const handleOpenAddShopDialog = () => { | |||
| @@ -63,6 +63,51 @@ | |||
| "Problem DO(s): ": "Problem DO(s): ", | |||
| "Progress": "Progress", | |||
| "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", | |||
| "Release": "Release", | |||
| "Release 2/F": "Release 2/F", | |||
| @@ -95,5 +140,5 @@ | |||
| "code": "code", | |||
| "do workbench": "do workbench", | |||
| "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", | |||
| "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", | |||
| "Assigned successfully": "Assigned successfully", | |||
| "Assignment failed": "Assignment failed", | |||
| "Required Date": "Required Date", | |||
| "Store": "Store", | |||
| "Available Orders": "Available Orders", | |||
| @@ -169,15 +169,12 @@ | |||
| "diff_loadingEllipsis": "…", | |||
| "addShop_dialogTitle": "Add shop to lane", | |||
| "addRoute_dialogTitle": "Add delivery lane", | |||
| "addRoute_hint": "After confirm, the lane is staged on the board; press \"Save changes\" in the header to create it on the server (no dummy shop rows).", | |||
| "addRoute_confirm": "Confirm add lane", | |||
| "addRoute_submitting": "Adding…", | |||
| "district_dialog_add": "Add district", | |||
| "district_dialog_edit": "Edit district", | |||
| "district_name_label": "District display name", | |||
| "district_name_ph": "Blank means \"Unclassified\"", | |||
| "district_help_null": "Unclassified maps to districtReference = null on server", | |||
| "district_help_mapped": "Display name is written via toDistrictRawValue to each shop's districtReference; API runs on \"Save changes\"", | |||
| "seq_edit_departureLabel": "Departure time", | |||
| "seq_edit_seqLabel": "Load sequence (Seq)", | |||
| "route_new_code_label": "Lane code", | |||
| @@ -207,7 +204,6 @@ | |||
| "departureDialog_title": "Edit departure time", | |||
| "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", | |||
| "seqDialog_title": "Edit load sequence", | |||
| "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", | |||
| "logistics_colLaneCount": "{{count}} lane(s)", | |||
| "logistics_masterNoLanes": "Master record exists but no lanes are bound yet; pick this company when adding/editing lanes on the route board.", | |||
| "tooltip_openLaneBoard": "Open this lane on the route board", | |||
| @@ -196,8 +196,6 @@ | |||
| "district_err_exists": "This district already exists", | |||
| "district_err_name": "Enter a district name", | |||
| "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_ph": "Blank means \"Unclassified\"", | |||
| "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_status_failed": "Failed", | |||
| "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_success": "Succeeded", | |||
| "schedule_history_subtitle": "Monitor, run, or troubleshoot scheduled shop-lane changes", | |||
| @@ -406,7 +409,6 @@ | |||
| "schedule_tab_import": "Import route Excel", | |||
| "schedule_tab_manual": "Drag scheduling", | |||
| "schedule_target_unset": "Not set", | |||
| "seqDialog_hint": "Press \"Save changes\" to persist to truck rows.", | |||
| "seqDialog_title": "Edit load sequence", | |||
| "seq_edit_departureLabel": "Departure time", | |||
| "seq_edit_seqLabel": "Load sequence (Seq)", | |||
| @@ -10,6 +10,51 @@ | |||
| "Estimated Arrival From": "預計送貨日期", | |||
| "Estimated Arrival To": "預計送貨日期至", | |||
| "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": "加單", | |||
| "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | |||
| "Confirm merge release": "確認合併放單", | |||
| @@ -71,7 +116,7 @@ | |||
| "Supplier Code": "供應商編號", | |||
| "Estimated Arrival Date": "預計送貨日期", | |||
| "Item No.": "商品編號", | |||
| "Item Name": "商品名稱", | |||
| "Item Name": "貨品名稱", | |||
| "Quantity": "數量", | |||
| "uom": "單位", | |||
| "Lot No.": "批號", | |||
| @@ -120,7 +165,6 @@ | |||
| "Yes": "是", | |||
| "No": "否", | |||
| "Replenishment input section": "補貨資料", | |||
| "Replenishment item code": "貨品編號", | |||
| "Replenishment search candidates": "搜尋候選送貨單", | |||
| "Replenishment reset": "重設", | |||
| "Replenishment candidate section": "候選送貨單(待放單)", | |||
| @@ -29,6 +29,8 @@ | |||
| "if need just edit number, please scan the lot again": "如果需要只修改數量,請重新掃描批次。", | |||
| "Total qty (actual pick + miss + bad) cannot exceed available qty: {available}": "總數量(實際提料 + 遺失 + 不良)不能超過可用數量:{{available}}", | |||
| "Confirm Assignment": "確認分配", | |||
| "Assigned successfully": "分派成功", | |||
| "Assignment failed": "分配失敗", | |||
| "Required Date": "所需日期", | |||
| "Store": "位置", | |||
| "Available Orders": "可用訂單", | |||
| @@ -34,7 +34,7 @@ | |||
| "exportRoutes": "匯出車線", | |||
| "routeReport": "車線報告", | |||
| "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", | |||
| "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", | |||
| "departureTooltipEditSave": "編輯出車時間", | |||
| "departureEditAria": "編輯出車時間", | |||
| "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", | |||
| "cancel": "取消", | |||
| @@ -80,9 +80,9 @@ | |||
| "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | |||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | |||
| "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | |||
| "restore_applied": "已從 snapshot 還原並重新載入看板。", | |||
| "restore_appliedDroppedStaging": "已套用 snapshot 還原;本次儲存略過其他暫存變更(請重新編輯)。", | |||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用 snapshot 還原,本次其他暫存變更會被略過。確定繼續?", | |||
| "restore_applied": "已從 版本還原並重新載入看板。", | |||
| "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | |||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", | |||
| "diff_noOlderCompare": "沒有上一筆版本可比較(請選擇較新的版本)", | |||
| "logistic_needMasterTpl": "「{{name}}」尚無對應物流公司,請先用「新增物流商」建立。", | |||
| "diffField_logisticsCompany": "物流公司", | |||
| @@ -140,11 +140,11 @@ | |||
| "diff_shopList_title": "店鋪異動清單", | |||
| "diff_staged_serverCountsOnly": "上列四格為「後端相鄰兩版快照」統計,不含看板上尚未儲存的編輯。", | |||
| "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", | |||
| "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", | |||
| "diff_staged_section_title": "看板未儲存/已排程", | |||
| "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端快照)混淆。", | |||
| "diff_staged_tag_unsaved": "未儲存", | |||
| "diff_staged_tag_scheduled": "已排程", | |||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", | |||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||
| "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", | |||
| "diff_staged_newLane": "新增車線(未儲存):{{lane}}", | |||
| "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", | |||
| @@ -154,9 +154,9 @@ | |||
| "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", | |||
| "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", | |||
| "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", | |||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", | |||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列", | |||
| "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", | |||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", | |||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。", | |||
| "err_importEmpty": "匯入檔案無有效車線資料列", | |||
| "diff_logisticMaster_section": "物流公司異動", | |||
| "diff_logisticMaster_added": "新增", | |||
| @@ -169,17 +169,14 @@ | |||
| "diff_loadingEllipsis": "…", | |||
| "addShop_dialogTitle": "新增店鋪到車線", | |||
| "addRoute_dialogTitle": "新增配送車線", | |||
| "addRoute_hint": "確認後先加入看板暫存;須按頂部「儲存更改」才會在後端建立車線(不建立假店鋪列)。", | |||
| "addRoute_confirm": "確認新增車線", | |||
| "addRoute_submitting": "新增中…", | |||
| "district_dialog_add": "新增地區", | |||
| "district_dialog_edit": "編輯地區", | |||
| "district_name_label": "地區顯示名稱", | |||
| "district_name_ph": "空白表示「未分類」", | |||
| "district_help_null": "未分類對應後端 districtReference 為 null", | |||
| "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", | |||
| "seq_edit_departureLabel": "出車時間", | |||
| "seq_edit_seqLabel": "裝車順序 (Seq)", | |||
| "seq_edit_seqLabel": "裝車順序", | |||
| "route_new_code_label": "車線編號", | |||
| "route_new_time_label": "出車時間", | |||
| "route_new_logistic_label": "物流公司", | |||
| @@ -203,34 +200,33 @@ | |||
| "dialog_editLogisticsTitle": "編輯物流公司", | |||
| "btn_apply": "套用", | |||
| "addShop_confirm": "確認", | |||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。", | |||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳。", | |||
| "departureDialog_title": "編輯出車時間", | |||
| "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | |||
| "departureDialog_hint": "套用至此車線所有店鋪列。", | |||
| "seqDialog_title": "編輯裝車順序", | |||
| "seqDialog_hint": "按「儲存更改」後寫入 truck 列。", | |||
| "logistics_colLaneCount": "{{count}} 條車線", | |||
| "logistics_masterNoLanes": "主檔已建立,尚無綁定車線;至「車線看板」新增/編輯車線時可填此公司名稱。", | |||
| "tooltip_openLaneBoard": "在車線看板開此車線", | |||
| "aria_openLaneBoard": "開啟車線看板", | |||
| "tooltip_removeFromLane": "從此車線移除", | |||
| "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | |||
| "tooltip_clearLaneShops": "清空此車線所有店鋪", | |||
| "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | |||
| "aria_pickLane": "選擇車線", | |||
| "aria_searchLanes": "搜索車線", | |||
| "logistics_colShopCount": "{{count}} 家店鋪", | |||
| "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | |||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | |||
| "tooltip_editLogisticsDb": "編輯物流公司", | |||
| "tooltip_deleteLogistics": "刪除物流公司", | |||
| "aria_editLogistics": "編輯物流公司", | |||
| "aria_deleteLogistics": "刪除物流公司", | |||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。", | |||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | |||
| "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", | |||
| "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", | |||
| "logistic_btn_apply": "套用", | |||
| "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", | |||
| "tooltip_editDistrict": "編輯地區名稱", | |||
| "aria_editDistrict": "編輯地區", | |||
| "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", | |||
| "aria_removeEmptyDistrict": "移除空區", | |||
| "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", | |||
| "tooltip_editSeq": "編輯裝車順序", | |||
| "aria_editSeq": "編輯裝車順序", | |||
| "diff_moveFrom": "從 {{lane}}", | |||
| "logistics_dirtyColumnBadge": "有未儲存物流更改", | |||
| @@ -34,7 +34,7 @@ | |||
| "exportRoutes": "匯出車線", | |||
| "routeReport": "車線報告", | |||
| "departureTooltipNeedShops": "先新增店鋪才能設定出車時間", | |||
| "departureTooltipEditSave": "編輯出車時間(按「儲存更改」寫回)", | |||
| "departureTooltipEditSave": "編輯出車時間", | |||
| "departureEditAria": "編輯出車時間", | |||
| "saveDisabledTooltip": "請先拖曳、編輯出車時間/裝車順序/物流商等變更後再儲存", | |||
| "cancel": "取消", | |||
| @@ -139,11 +139,11 @@ | |||
| "diff_summary_fieldChange": "欄位變更", | |||
| "diff_shopList_title": "店鋪變更清單", | |||
| "diff_staged_boardPendingLine": "看板另有 {{count}} 筆未儲存/已排程項(見下方清單)。", | |||
| "diff_staged_section_title": "看板未儲存/已排程(尚未寫入後端)", | |||
| "diff_staged_section_title": "看板未儲存/已排程", | |||
| "diff_staged_section_subtitle": "下列與「儲存更改」後才會落庫的狀態一致;與上方版本 diff 分開列,避免與 Excel(僅後端版本)混淆。", | |||
| "diff_staged_tag_unsaved": "未儲存", | |||
| "diff_staged_tag_scheduled": "已排程", | |||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}(須按「儲存更改」才會呼叫 restore)。", | |||
| "diff_staged_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||
| "diff_staged_deleteUnknown": "刪除 truck id={{id}}(未儲存;明細請先儲存或取消後重試)", | |||
| "diff_staged_newLane": "新增車線(未儲存):{{lane}}", | |||
| "diff_staged_laneLogistic": "整線物流(未儲存):{{lane}} → {{company}}", | |||
| @@ -153,9 +153,9 @@ | |||
| "diff_staged_shopDistrictOnly": "{{name}}({{code}})— 車線 {{lane}}:地區 {{from}}→{{to}}(未儲存;按「儲存更改」寫入)", | |||
| "diff_staged_pendingLogisticMaster": "新增物流公司(未落庫):{{name}}(車牌 {{plate}});將與路線一併於「儲存更改」寫入", | |||
| "diff_staged_editLogisticMaster": "修改物流公司(未落庫):{{fromName}}({{fromPlate}})→ {{name}}({{plate}})", | |||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列(按「儲存更改」寫入)", | |||
| "diff_staged_importPending": "匯入 Excel(未落庫):{{file}} — {{sheets}} 個工作表、{{rows}} 列", | |||
| "confirm_importDiscardEdits": "匯入將取代目前看板上未儲存的變更,是否繼續?", | |||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。按「儲存更改」才會寫入後端。", | |||
| "import_staged_preview": "已載入匯入預覽:{{file}}({{sheets}} 表 / {{rows}} 列)。", | |||
| "err_importEmpty": "匯入檔案無有效車線資料列", | |||
| "diff_logisticMaster_section": "物流公司變更", | |||
| "diff_logisticMaster_added": "新增", | |||
| @@ -175,10 +175,8 @@ | |||
| "district_dialog_edit": "編輯地區", | |||
| "district_name_label": "地區顯示名稱", | |||
| "district_name_ph": "空白表示「未分類」", | |||
| "district_help_null": "未分類對應後端 districtReference 為 null", | |||
| "district_help_mapped": "顯示名稱經 toDistrictRawValue 寫入各店鋪 districtReference;按「儲存更改」才打 API", | |||
| "seq_edit_departureLabel": "出車時間", | |||
| "seq_edit_seqLabel": "裝車順序 (Seq)", | |||
| "seq_edit_seqLabel": "裝車順序", | |||
| "route_new_code_label": "車線編號", | |||
| "route_new_time_label": "出車時間", | |||
| "route_new_logistic_label": "物流公司", | |||
| @@ -206,7 +204,6 @@ | |||
| "departureDialog_title": "編輯出車時間", | |||
| "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | |||
| "seqDialog_title": "編輯裝車順序", | |||
| "seqDialog_hint": "按「儲存更改」後寫入 truck 列。", | |||
| "logistics_colLaneCount": "{{count}} 條車線", | |||
| "tooltip_openLaneBoard": "在車線看板開此車線", | |||
| "aria_openLaneBoard": "開啟車線看板", | |||
| @@ -220,15 +217,15 @@ | |||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | |||
| "aria_editLogistics": "編輯物流公司", | |||
| "aria_deleteLogistics": "刪除物流公司", | |||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?須按「儲存更改」寫入。", | |||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | |||
| "err_logisticDeleteHasLanes": "此物流公司尚有 {{count}} 條車線,請先移走或改派後再刪除。", | |||
| "diff_staged_deleteLogisticMaster": "刪除物流公司(未落庫):{{name}}", | |||
| "logistic_btn_apply": "套用", | |||
| "tooltip_editDistrict": "編輯地區名稱(按「儲存更改」才寫入)", | |||
| "tooltip_editDistrict": "編輯地區名稱", | |||
| "aria_editDistrict": "編輯地區", | |||
| "tooltip_removeEmptyDistrict": "移除此暫存區塊(未儲存前可刪)", | |||
| "aria_removeEmptyDistrict": "移除空區", | |||
| "tooltip_editSeq": "編輯裝車順序(按「儲存更改」寫回)", | |||
| "tooltip_editSeq": "編輯裝車順序", | |||
| "aria_editSeq": "編輯裝車順序", | |||
| "diff_moveFrom": "從 {{lane}}", | |||
| "logistics_dirtyColumnBadge": "有未儲存物流更改", | |||
| @@ -312,7 +309,7 @@ | |||
| "schedule_review_seq": "裝載順序:", | |||
| "schedule_drop_hint": "請拖曳店鋪卡片至此車線", | |||
| "schedule_moved_badge": "移入", | |||
| "schedule_drag_seq": "裝載順序 Seq: {{seq}}", | |||
| "schedule_drag_seq": "裝載順序: {{seq}}", | |||
| "schedule_seq_edit_btn": "編輯裝載順序", | |||
| "schedule_seq_dialog_hint": "變更會加入右側預覽佇列,確認排程後才套用。", | |||
| "schedule_planned_label": "執行預定", | |||
| @@ -320,7 +317,7 @@ | |||
| "schedule_applied_snackbar": "已登記 {{count}} 筆預約變更並更新看板,請按「儲存更改」寫入後端。", | |||
| "schedule_err_conflict": "部分店舖無法移至目標車線(重複店鋪或草稿列)。", | |||
| "schedule_err_execute_at_past": "排程執行時間已過去,請選擇未來的日期與時間。", | |||
| "schedule_err_open_pending": "店舖列 #{{id}} 已有待執行的排程。", | |||
| "schedule_err_open_pending": "店舖已有待執行的排程。", | |||
| "schedule_err_duplicate_shop": "目標車線上已有店舖 {{shop}}。", | |||
| "schedule_err_target_lane_missing": "找不到目標車線 {{lane}}。", | |||
| "schedule_err_target_lane_empty": "目標車線 {{lane}} 尚無店舖,請先加入店舖。", | |||
| @@ -329,7 +326,7 @@ | |||
| "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", | |||
| "schedule_shop_badge": "已排程變更", | |||
| "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", | |||
| "schedule_retry_rejects_partial": "PARTIAL 排程不可重試,請先還原看板後重新建立排程", | |||
| "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", | |||
| "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", | |||
| "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", | |||
| "schedule_import_confirm_btn": "確認匯入並建立排程", | |||
| @@ -349,8 +346,8 @@ | |||
| "schedule_history_applied_at": "成功執行時間:{{at}}", | |||
| "schedule_history_view_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_cancel": "取消排程", | |||
| "schedule_history_archived": "已執行", | |||
| @@ -358,8 +355,13 @@ | |||
| "schedule_history_reschedule": "重新排程", | |||
| "schedule_history_ignore": "忽略", | |||
| "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": "關閉視窗", | |||
| "PENDING": "待處理", | |||
| "PENDING": "待執行", | |||
| "APPLYING": "執行中", | |||
| "APPLIED": "已套用", | |||
| "PARTIAL": "部分完成", | |||