diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 36f7853..fcb66f8 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -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[]; diff --git a/src/components/DoSearch/DoReplenishmentTab.tsx b/src/components/DoSearch/DoReplenishmentTab.tsx new file mode 100644 index 0000000..71b4725 --- /dev/null +++ b/src/components/DoSearch/DoReplenishmentTab.tsx @@ -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(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLookingUp, setIsLookingUp] = useState(false); + + const [deliveryDate, setDeliveryDate] = useState(dayjs()); + const [doCodeSuffix, setDoCodeSuffix] = useState(""); + const [shopInput, setShopInput] = useState(""); + const [sourceDo, setSourceDo] = useState(null); + const [selectedLine, setSelectedLine] = useState(null); + const [replenishQtyInput, setReplenishQtyInput] = useState(""); + + const [draftRows, setDraftRows] = useState([]); + const [records, setRecords] = useState([]); + const [trackStatusFilter, setTrackStatusFilter] = useState("all"); + const [trackDateFilter, setTrackDateFilter] = useState(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[] = 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 ( + + (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), + }} + > + + setTrackingDialogOpen(true)} + aria-label={t("Replenishment Tracking")} + sx={{ + position: "absolute", + top: 8, + right: 8, + zIndex: 1, + color: "text.secondary", + }} + > + + + + + + } + title={t("Estimated Arrival Date")} + required + sx={replenishmentSearchGridLabelSx(1)} + /> + + + { + setDeliveryDate(v); + setSourceDo(null); + }} + slotProps={datePickerSlotProps} + sx={{ width: "100%" }} + /> + + + + } + title={t("DO Code Last 4")} + required + sx={replenishmentSearchGridLabelSx(2)} + /> + + { + 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(); + } + }} + /> + + + } + title={t("Shop Code")} + required + sx={replenishmentSearchGridLabelSx(3)} + /> + + { + setShopInput(e.target.value); + setSourceDo(null); + }} + placeholder={t("replenishmentShopPlaceholder")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleLookupSourceDo(); + } + }} + /> + + + + + {sourceDo && ( + + + {t("Delivery Order Code")}: {sourceDo.doCode} + {" "} + {t("Shop Name")}: {sourceDo.shopName ?? "—"} + {" "} + {t("Truck Lance Code")}:{" "} + {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")} + + + )} + + {sourceDo && ( + + ({ + border: `1px solid ${theme.palette.divider}`, + borderRadius: 2, + bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", + })} + > + + + + + {t("Replenishment item code")} + + {t("Item Name")} + + {t("Original Shipment Qty")} + + + {t("Replenish Qty")} + + + {t("uom")} + + + {t("Action")} + + + + + {draftRows.map((row) => ( + + {row.itemNo} + {row.itemName} + {row.originalQty} + {row.replenishQty} + {row.shortUom || "—"} + + + handleRemoveDraftRow(row.rowId)} + aria-label={t("Delete")} + > + + + + + + ))} + + + + !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 ( + { + itemCodeInputRef.current = node; + const { ref } = inputProps; + if (typeof ref === "function") ref(node); + else if (ref) { + ( + ref as React.MutableRefObject + ).current = node; + } + }, + }} + InputProps={{ ...params.InputProps, disableUnderline: true }} + /> + ); + }} + sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX} + /> + + + + + + + + + + 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", + }, + })} + /> + + + + + + + + + + + + +
+
+ + {draftRows.length > 0 && ( + + + + )} +
+ )} +
+
+ + setTrackingDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + {t("Replenishment Tracking")} + setTrackingDialogOpen(false)} + aria-label={t("Cancel")} + > + + + + + + + + + setTrackDateFilter(v)} + slotProps={datePickerSlotProps} + /> + + + + {t("Status")} + + + + + + + +
+ ); +}; + +export default DoReplenishmentTab; diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index 86bbefa..c528b2e 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -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; @@ -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 = ({ 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 }; } }, []); @@ -747,9 +750,10 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea + - {hasSearched && hasResults && ( + {activeTab !== "REPLENISH" && hasSearched && hasResults && (