"use client"; import React, { useCallback, useEffect, 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, DoReplenishmentRecord, fetchDoDetail, fetchDoReplenishmentList, fetchDoSearch, submitDoReplenishment, } 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[]; }; function mapApiRecord(record: DoReplenishmentRecord): ReplenishmentRecord { return { rowId: `record-${record.id}`, deliveryDate: record.deliveryDate, sourceDoId: record.sourceDoId, sourceDoCode: record.sourceDoCode ?? "", sourceDoLineId: record.sourceDoLineId, itemId: record.itemId, itemNo: record.itemNo ?? "", itemName: record.itemName ?? "", originalQty: 0, replenishQty: Number(record.replenishQty), shortUom: record.shortUom, shopCode: record.shopCode, shopName: record.shopName, truckLaneCode: record.truckLaneCode, id: record.id, code: record.code, targetDoId: record.targetDoId, targetDoCode: record.targetDoCode, pickOrderLineId: record.pickOrderLineId, status: record.status as ReplenishmentStatus, created: record.created ?? "", }; } /** 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(); } type DraftDoGroup = { sourceDoId: number; sourceDoCode: string; rows: ReplenishmentDraftRow[]; }; type DraftShopGroup = { shopKey: string; shopCode: string; shopName?: string; dos: DraftDoGroup[]; }; function draftShopGroupKey(row: ReplenishmentDraftRow): string { return row.shopCode?.trim() || row.shopName?.trim() || "—"; } function groupDraftRowsByShopAndDo(rows: ReplenishmentDraftRow[]): DraftShopGroup[] { const shopMap = new Map(); for (const row of rows) { const shopKey = draftShopGroupKey(row); let shopGroup = shopMap.get(shopKey); if (!shopGroup) { shopGroup = { shopKey, shopCode: row.shopCode?.trim() || "", shopName: row.shopName, dos: [], }; shopMap.set(shopKey, shopGroup); } let doGroup = shopGroup.dos.find((group) => group.sourceDoId === row.sourceDoId); if (!doGroup) { doGroup = { sourceDoId: row.sourceDoId, sourceDoCode: row.sourceDoCode, rows: [], }; shopGroup.dos.push(doGroup); } doGroup.rows.push(row); } return Array.from(shopMap.values()) .sort((a, b) => a.shopKey.localeCompare(b.shopKey, undefined, { numeric: true })) .map((shopGroup) => ({ ...shopGroup, dos: shopGroup.dos .sort((a, b) => a.sourceDoCode.localeCompare(b.sourceDoCode, undefined, { numeric: true }), ) .map((doGroup) => ({ ...doGroup, rows: [...doGroup.rows], })), })); } 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 [isLoadingTracking, setIsLoadingTracking] = useState(false); 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); const resolvedTruckLaneCode = detail.truckLaneCode?.trim() || matchedCandidate?.truckLanceCode?.trim() || null; 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: resolvedTruckLaneCode, status: detail.status, lines: detail.deliveryOrderLines ?? [], }); 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 existingRowIndex = draftRows.findIndex( (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, ); if (existingRowIndex >= 0) { setDraftRows((prev) => prev.map((row, index) => index === existingRowIndex ? { ...row, replenishQty: row.replenishQty + qty } : row, ), ); } else { 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: sourceDo.truckLaneCode?.trim() || 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 handleClearDraftRows = useCallback(() => { setDraftRows([]); }, []); 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 created = await submitDoReplenishment( draftRows.map((row) => ({ deliveryDate: row.deliveryDate, sourceDoId: row.sourceDoId, sourceDoLineId: row.sourceDoLineId, replenishQty: row.replenishQty, truckLaneCode: row.truckLaneCode, })), ); setDraftRows([]); await Swal.fire({ icon: "success", title: t("Replenishment submitted successfully"), text: created.map((row) => row.code).join(", "), }); } catch (error: unknown) { const message = error instanceof Error ? error.message : t("Failed to submit replenishment"); await Swal.fire({ icon: "error", title: message }); } finally { setIsSubmitting(false); inFlightRef.current = false; } }, [draftRows, t]); const loadTrackingRecords = useCallback(async () => { setIsLoadingTracking(true); try { const data = await fetchDoReplenishmentList({ deliveryDate: trackDateFilter?.format("YYYY-MM-DD"), status: trackStatusFilter, }); setRecords(data.map(mapApiRecord)); } catch { await Swal.fire({ icon: "error", title: t("Failed to load replenishment records") }); } finally { setIsLoadingTracking(false); } }, [trackDateFilter, trackStatusFilter, t]); useEffect(() => { if (trackingDialogOpen) { void loadTrackingRecords(); } }, [trackingDialogOpen, loadTrackingRecords]); 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 sourceTruckLaneDisplay = sourceDo ? sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X") : ""; 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], ); const groupedDraftRows = useMemo( () => groupDraftRowsByShopAndDo(draftRows), [draftRows], ); const currentDoDraftRows = useMemo( () => sourceDo ? draftRows.filter((row) => row.sourceDoId === sourceDo.doId) : [], [draftRows, sourceDo], ); const draftPreviewPanel = ( (theme.palette.mode === "dark" ? "grey.900" : "common.white"), }} > {t("Draft List")} {draftRows.length > 0 ? ` (${draftRows.length})` : ""} {t("Replenishment preview hint")} {draftRows.length === 0 ? ( `1px dashed ${theme.palette.divider}`, borderRadius: 2, px: 2, }} > {t("Replenishment preview empty")} ) : ( *": { flexShrink: 0 }, }} > {groupedDraftRows.map((shopGroup) => { const shopDisplay = shopGroup.shopCode || shopGroup.shopName?.trim() || shopGroup.shopKey; return ( theme.palette.mode === "dark" ? "grey.800" : "grey.50", }} > {t("Shop Code")} {shopDisplay} {shopGroup.dos.map((doGroup) => ( ({ border: `1px solid ${theme.palette.divider}`, borderRadius: 1.5, p: 1.25, bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", })} > {t("Delivery Order Code")} {doGroup.sourceDoCode} {doGroup.rows.map((row) => { const qtyLabel = row.shortUom ? `${row.replenishQty} ${row.shortUom}` : String(row.replenishQty); return ( ({ position: "relative", pr: 4, py: 0.75, px: 1, borderRadius: 1, bgcolor: theme.palette.mode === "dark" ? "grey.800" : "grey.50", })} > handleRemoveDraftRow(row.rowId)} aria-label={t("Delete")} sx={{ position: "absolute", top: 2, right: 2 }} > {row.itemNo} {row.itemName} {t("Replenish Qty")}:{" "} {qtyLabel} ); })} ))} ); })} )} {draftRows.length > 0 && ( )} ); 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("Truck Lance Code")} {t("Action")} {currentDoDraftRows.map((row) => ( ({ bgcolor: theme.palette.mode === "dark" ? "action.selected" : "action.hover", })} > {row.itemNo} {row.itemName} {row.originalQty} {row.replenishQty} {row.shortUom || "—"} {row.truckLaneCode?.trim() || sourceDo.truckLaneCode?.trim() || t("Truck X")} handleRemoveDraftRow(row.rowId)} aria-label={t("Delete")} > ))} 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", }, })} /> theme.spacing(5), lineHeight: (theme) => theme.spacing(5), }} > {sourceTruckLaneDisplay}
)}
{draftPreviewPanel}
{draftPreviewPanel} setTrackingDialogOpen(false)} maxWidth="lg" fullWidth > {t("Replenishment Tracking")} setTrackingDialogOpen(false)} aria-label={t("Cancel")} > setTrackDateFilter(v)} slotProps={datePickerSlotProps} /> {t("Status")}
); }; export default DoReplenishmentTab;