| @@ -27,6 +27,8 @@ export interface DoDetail { | |||||
| status: string; | status: string; | ||||
| /** 加單 DO */ | /** 加單 DO */ | ||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | |||||
| handlerName?: string | null; | |||||
| deliveryOrderLines: DoDetailLine[]; | deliveryOrderLines: DoDetailLine[]; | ||||
| } | } | ||||
| @@ -56,6 +58,7 @@ export interface DoSearchAll { | |||||
| shopName: string; | shopName: string; | ||||
| shopAddress?: string; | shopAddress?: string; | ||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| truckLanceCode?: string | null; | |||||
| } | } | ||||
| export interface DoSearchLiteResponse { | export interface DoSearchLiteResponse { | ||||
| records: DoSearchAll[]; | records: DoSearchAll[]; | ||||
| @@ -0,0 +1,785 @@ | |||||
| "use client"; | |||||
| import React, { useCallback, useMemo, useRef, useState } from "react"; | |||||
| import { | |||||
| Autocomplete, | |||||
| Box, | |||||
| Button, | |||||
| Dialog, | |||||
| DialogContent, | |||||
| DialogTitle, | |||||
| FormControl, | |||||
| IconButton, | |||||
| InputLabel, | |||||
| MenuItem, | |||||
| Paper, | |||||
| Select, | |||||
| Stack, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Tooltip, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; | |||||
| import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; | |||||
| import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; | |||||
| import StorefrontIcon from "@mui/icons-material/Storefront"; | |||||
| import { Add, Close, Delete, Search } from "@mui/icons-material"; | |||||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import dayjs, { Dayjs } from "dayjs"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { GridColDef } from "@mui/x-data-grid"; | |||||
| import Swal from "sweetalert2"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | |||||
| import { DoDetail, DoDetailLine, fetchDoDetail, fetchDoSearch } from "@/app/api/do/actions"; | |||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | |||||
| import { | |||||
| REPLENISHMENT_FIELD_ICON_SX, | |||||
| REPLENISHMENT_TABLE_AUTOCOMPLETE_SX, | |||||
| REPLENISHMENT_TABLE_ENTRY_ROW_SX, | |||||
| REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX, | |||||
| REPLENISHMENT_TABLE_SX, | |||||
| REPLENISHMENT_LOOKUP_BUTTON_SX, | |||||
| REPLENISHMENT_SOURCE_HEADER_SX, | |||||
| REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX, | |||||
| REPLENISHMENT_TEXTFIELD_SX, | |||||
| ReplenishmentFieldLabel, | |||||
| ReplenishmentItemEntryPlainText, | |||||
| ReplenishmentTextField, | |||||
| replenishmentSearchGridInputSx, | |||||
| replenishmentSearchGridLabelSx, | |||||
| replenishmentSearchGridShopRowSx, | |||||
| } from "./ReplenishmentFilterField"; | |||||
| export type ReplenishmentStatus = "pending" | "processing" | "completed"; | |||||
| export type ReplenishmentDraftRow = { | |||||
| rowId: string; | |||||
| deliveryDate: string; | |||||
| sourceDoId: number; | |||||
| sourceDoCode: string; | |||||
| sourceDoLineId: number; | |||||
| itemId?: number; | |||||
| itemNo: string; | |||||
| itemName: string; | |||||
| originalQty: number; | |||||
| replenishQty: number; | |||||
| shortUom?: string; | |||||
| shopCode?: string; | |||||
| shopName?: string; | |||||
| truckLaneCode?: string; | |||||
| }; | |||||
| export type ReplenishmentRecord = ReplenishmentDraftRow & { | |||||
| id: number; | |||||
| code: string; | |||||
| targetDoId?: number; | |||||
| targetDoCode?: string; | |||||
| pickOrderLineId?: number; | |||||
| status: ReplenishmentStatus; | |||||
| created: string; | |||||
| }; | |||||
| type SourceDoContext = { | |||||
| doId: number; | |||||
| doCode: string; | |||||
| shopCode?: string; | |||||
| shopName?: string; | |||||
| truckLaneCode?: string | null; | |||||
| status: string; | |||||
| lines: DoDetailLine[]; | |||||
| }; | |||||
| let localIdSeq = 1; | |||||
| let replenishmentCodeSeq = 1; | |||||
| function nextReplenishmentCode(deliveryDate: string): string { | |||||
| const ymd = deliveryDate.replace(/-/g, ""); | |||||
| const seq = String(replenishmentCodeSeq++).padStart(3, "0"); | |||||
| return `RP-${ymd}-${seq}`; | |||||
| } | |||||
| /** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */ | |||||
| function matchesShopInput(detail: DoDetail, shopInput: string): boolean { | |||||
| const normalized = shopInput.trim().toLowerCase(); | |||||
| if (!normalized) return false; | |||||
| const code = detail.shopCode?.toLowerCase() ?? ""; | |||||
| const name = detail.shopName?.toLowerCase() ?? ""; | |||||
| return code.includes(normalized) || name.startsWith(normalized); | |||||
| } | |||||
| function matchesDeliveryDate( | |||||
| estimatedArrivalDate: number[] | undefined, | |||||
| deliveryDateStr: string, | |||||
| ): boolean { | |||||
| if (!estimatedArrivalDate?.length) return false; | |||||
| return arrayToDateString(estimatedArrivalDate) === deliveryDateStr; | |||||
| } | |||||
| function lineUomDisplay(line?: DoDetailLine | null): string { | |||||
| if (!line) return ""; | |||||
| return (line.shortUom ?? line.uomCode ?? line.uom ?? "").trim(); | |||||
| } | |||||
| const DoReplenishmentTab: React.FC = () => { | |||||
| const { t } = useTranslation("do"); | |||||
| const inFlightRef = useRef(false); | |||||
| const itemCodeInputRef = useRef<HTMLInputElement>(null); | |||||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||||
| const [isLookingUp, setIsLookingUp] = useState(false); | |||||
| const [deliveryDate, setDeliveryDate] = useState<Dayjs | null>(dayjs()); | |||||
| const [doCodeSuffix, setDoCodeSuffix] = useState(""); | |||||
| const [shopInput, setShopInput] = useState(""); | |||||
| const [sourceDo, setSourceDo] = useState<SourceDoContext | null>(null); | |||||
| const [selectedLine, setSelectedLine] = useState<DoDetailLine | null>(null); | |||||
| const [replenishQtyInput, setReplenishQtyInput] = useState(""); | |||||
| const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]); | |||||
| const [records, setRecords] = useState<ReplenishmentRecord[]>([]); | |||||
| const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all"); | |||||
| const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null); | |||||
| const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); | |||||
| const deliveryDateStr = deliveryDate?.format("YYYY-MM-DD") ?? ""; | |||||
| const handleLookupSourceDo = useCallback(async () => { | |||||
| const suffix = doCodeSuffix.trim(); | |||||
| const shop = shopInput.trim(); | |||||
| if (suffix.length !== 4) { | |||||
| await Swal.fire({ | |||||
| icon: "warning", | |||||
| title: t("DO code suffix must be exactly 4 characters"), | |||||
| }); | |||||
| return; | |||||
| } | |||||
| if (!deliveryDateStr) { | |||||
| await Swal.fire({ icon: "warning", title: t("Delivery date is required") }); | |||||
| return; | |||||
| } | |||||
| if (!shop) { | |||||
| await Swal.fire({ icon: "warning", title: t("Shop code or name is required") }); | |||||
| return; | |||||
| } | |||||
| setIsLookingUp(true); | |||||
| try { | |||||
| const searchRes = await fetchDoSearch( | |||||
| suffix, | |||||
| "", | |||||
| "completed", | |||||
| "", | |||||
| "", | |||||
| `${deliveryDateStr}T00:00:00`, | |||||
| "", | |||||
| 1, | |||||
| 100, | |||||
| undefined, | |||||
| null, | |||||
| null, | |||||
| ); | |||||
| const candidates = searchRes.records.filter( | |||||
| (r) => | |||||
| r.code.endsWith(suffix) && | |||||
| matchesDeliveryDate(r.estimatedArrivalDate, deliveryDateStr), | |||||
| ); | |||||
| if (candidates.length === 0) { | |||||
| await Swal.fire({ icon: "error", title: t("Source DO not found") }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| const details = await Promise.all(candidates.map((c) => fetchDoDetail(c.id))); | |||||
| const matched = details.filter((d) => matchesShopInput(d, shop)); | |||||
| if (matched.length === 0) { | |||||
| await Swal.fire({ icon: "error", title: t("Source DO not found") }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| if (matched.length > 1) { | |||||
| await Swal.fire({ | |||||
| icon: "error", | |||||
| title: t("Multiple source DOs matched"), | |||||
| text: t("Please verify DO code suffix, delivery date and shop."), | |||||
| }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| const detail = matched[0]; | |||||
| const matchedCandidate = candidates.find((c) => c.id === detail.id); | |||||
| if (detail.status !== "completed") { | |||||
| await Swal.fire({ | |||||
| icon: "error", | |||||
| title: t("Source DO must be completed"), | |||||
| text: t("Only completed delivery orders can be used as replenishment source."), | |||||
| }); | |||||
| setSourceDo(null); | |||||
| return; | |||||
| } | |||||
| setSourceDo({ | |||||
| doId: detail.id, | |||||
| doCode: detail.code, | |||||
| shopCode: detail.shopCode, | |||||
| shopName: detail.shopName, | |||||
| truckLaneCode: matchedCandidate?.truckLanceCode ?? null, | |||||
| status: detail.status, | |||||
| lines: detail.deliveryOrderLines ?? [], | |||||
| }); | |||||
| setDraftRows([]); | |||||
| setSelectedLine(null); | |||||
| setReplenishQtyInput(""); | |||||
| } catch { | |||||
| await Swal.fire({ icon: "error", title: t("Failed to lookup source DO") }); | |||||
| } finally { | |||||
| setIsLookingUp(false); | |||||
| } | |||||
| }, [deliveryDateStr, doCodeSuffix, shopInput, t]); | |||||
| const handleAddDraftRow = useCallback(() => { | |||||
| if (!sourceDo) { | |||||
| void Swal.fire({ icon: "warning", title: t("Please lookup source DO first") }); | |||||
| return; | |||||
| } | |||||
| if (!deliveryDateStr) { | |||||
| void Swal.fire({ icon: "warning", title: t("Delivery date is required") }); | |||||
| return; | |||||
| } | |||||
| if (!selectedLine) { | |||||
| void Swal.fire({ icon: "warning", title: t("Please select an item") }); | |||||
| return; | |||||
| } | |||||
| const qty = Number(replenishQtyInput); | |||||
| if (!Number.isFinite(qty) || qty <= 0) { | |||||
| void Swal.fire({ icon: "warning", title: t("Replenish qty must be greater than zero") }); | |||||
| return; | |||||
| } | |||||
| const line = selectedLine; | |||||
| const duplicate = draftRows.some( | |||||
| (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | |||||
| ); | |||||
| if (duplicate) { | |||||
| void Swal.fire({ icon: "warning", title: t("This item is already in the draft list") }); | |||||
| return; | |||||
| } | |||||
| setDraftRows((prev) => [ | |||||
| ...prev, | |||||
| { | |||||
| rowId: `draft-${Date.now()}-${prev.length}`, | |||||
| deliveryDate: deliveryDateStr, | |||||
| sourceDoId: sourceDo.doId, | |||||
| sourceDoCode: sourceDo.doCode, | |||||
| sourceDoLineId: line.id, | |||||
| itemNo: line.itemNo ?? "", | |||||
| itemName: line.itemName ?? line.itemNo ?? "", | |||||
| originalQty: line.qty ?? 0, | |||||
| replenishQty: qty, | |||||
| shortUom: lineUomDisplay(line) || undefined, | |||||
| shopCode: sourceDo.shopCode, | |||||
| shopName: sourceDo.shopName, | |||||
| truckLaneCode: undefined, | |||||
| }, | |||||
| ]); | |||||
| setSelectedLine(null); | |||||
| setReplenishQtyInput(""); | |||||
| window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); | |||||
| }, [ | |||||
| deliveryDateStr, | |||||
| draftRows, | |||||
| replenishQtyInput, | |||||
| selectedLine, | |||||
| sourceDo, | |||||
| t, | |||||
| ]); | |||||
| const handleRemoveDraftRow = useCallback((rowId: string) => { | |||||
| setDraftRows((prev) => prev.filter((r) => r.rowId !== rowId)); | |||||
| }, []); | |||||
| const handleSubmit = useCallback(async () => { | |||||
| if (inFlightRef.current) return; | |||||
| if (draftRows.length === 0) { | |||||
| await Swal.fire({ icon: "warning", title: t("No draft rows to submit") }); | |||||
| return; | |||||
| } | |||||
| inFlightRef.current = true; | |||||
| setIsSubmitting(true); | |||||
| try { | |||||
| const now = new Date().toISOString(); | |||||
| const newRecords: ReplenishmentRecord[] = draftRows.map((row) => ({ | |||||
| ...row, | |||||
| id: localIdSeq++, | |||||
| code: nextReplenishmentCode(row.deliveryDate), | |||||
| status: "pending", | |||||
| created: now, | |||||
| })); | |||||
| setRecords((prev) => [...newRecords, ...prev]); | |||||
| setDraftRows([]); | |||||
| await Swal.fire({ | |||||
| icon: "info", | |||||
| title: t("Replenishment API not ready"), | |||||
| text: t("Records saved locally for preview. Backend integration pending."), | |||||
| }); | |||||
| } finally { | |||||
| setIsSubmitting(false); | |||||
| inFlightRef.current = false; | |||||
| } | |||||
| }, [draftRows, t]); | |||||
| const trackColumns: GridColDef<ReplenishmentRecord>[] = useMemo( | |||||
| () => [ | |||||
| { field: "code", headerName: t("Replenishment Code"), width: 140 }, | |||||
| { field: "sourceDoCode", headerName: t("Source DO"), width: 120 }, | |||||
| { field: "shopName", headerName: t("Shop Name"), flex: 1, minWidth: 120 }, | |||||
| { | |||||
| field: "truckLaneCode", | |||||
| headerName: t("Truck Lance Code"), | |||||
| width: 120, | |||||
| valueGetter: (params) => params.row.truckLaneCode ?? "—", | |||||
| }, | |||||
| { field: "itemNo", headerName: t("Item No."), width: 100 }, | |||||
| { field: "itemName", headerName: t("Item Name"), flex: 1, minWidth: 120 }, | |||||
| { | |||||
| field: "replenishQty", | |||||
| headerName: t("Replenish Qty"), | |||||
| width: 120, | |||||
| valueGetter: (params) => { | |||||
| const row = params.row as ReplenishmentRecord; | |||||
| return row.shortUom ? `${row.replenishQty} ${row.shortUom}` : row.replenishQty; | |||||
| }, | |||||
| }, | |||||
| { | |||||
| field: "targetDoCode", | |||||
| headerName: t("Target DO"), | |||||
| width: 120, | |||||
| valueGetter: (params) => params.row.targetDoCode ?? "—", | |||||
| }, | |||||
| { | |||||
| field: "status", | |||||
| headerName: t("Status"), | |||||
| width: 110, | |||||
| valueFormatter: (params) => t(String(params.value)), | |||||
| }, | |||||
| { | |||||
| field: "created", | |||||
| headerName: t("Created"), | |||||
| width: 160, | |||||
| valueFormatter: (params) => | |||||
| params.value ? dayjs(String(params.value)).format("YYYY-MM-DD HH:mm") : "", | |||||
| }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const selectedLineUom = lineUomDisplay(selectedLine); | |||||
| const filteredRecords = useMemo(() => { | |||||
| return records.filter((r) => { | |||||
| if (trackStatusFilter !== "all" && r.status !== trackStatusFilter) return false; | |||||
| if (trackDateFilter && r.deliveryDate !== trackDateFilter.format("YYYY-MM-DD")) { | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| }); | |||||
| }, [records, trackDateFilter, trackStatusFilter]); | |||||
| const datePickerSlotProps = useMemo( | |||||
| () => ({ | |||||
| textField: { | |||||
| size: "small" as const, | |||||
| fullWidth: true, | |||||
| variant: "filled" as const, | |||||
| placeholder: t("replenishmentDatePlaceholder"), | |||||
| sx: REPLENISHMENT_TEXTFIELD_SX, | |||||
| InputProps: { disableUnderline: true }, | |||||
| }, | |||||
| }), | |||||
| [t], | |||||
| ); | |||||
| return ( | |||||
| <Stack spacing={2}> | |||||
| <Paper | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| position: "relative", | |||||
| p: 2, | |||||
| bgcolor: (theme) => (theme.palette.mode === "dark" ? "grey.900" : "grey.50"), | |||||
| }} | |||||
| > | |||||
| <Tooltip title={t("Replenishment Tracking")}> | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={() => setTrackingDialogOpen(true)} | |||||
| aria-label={t("Replenishment Tracking")} | |||||
| sx={{ | |||||
| position: "absolute", | |||||
| top: 8, | |||||
| right: 8, | |||||
| zIndex: 1, | |||||
| color: "text.secondary", | |||||
| }} | |||||
| > | |||||
| <InfoOutlinedIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| </Tooltip> | |||||
| <Stack spacing={2}> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "grid", | |||||
| gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" }, | |||||
| columnGap: 2, | |||||
| rowGap: 1, | |||||
| alignItems: "stretch", | |||||
| pr: { xs: 4, lg: 4 }, | |||||
| }} | |||||
| > | |||||
| <ReplenishmentFieldLabel | |||||
| icon={<CalendarTodayIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />} | |||||
| title={t("Estimated Arrival Date")} | |||||
| required | |||||
| sx={replenishmentSearchGridLabelSx(1)} | |||||
| /> | |||||
| <Box sx={replenishmentSearchGridInputSx(1)}> | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| format="YYYY-MM-DD" | |||||
| value={deliveryDate} | |||||
| onChange={(v) => { | |||||
| setDeliveryDate(v); | |||||
| setSourceDo(null); | |||||
| }} | |||||
| slotProps={datePickerSlotProps} | |||||
| sx={{ width: "100%" }} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| </Box> | |||||
| <ReplenishmentFieldLabel | |||||
| icon={<ReceiptLongIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />} | |||||
| title={t("DO Code Last 4")} | |||||
| required | |||||
| sx={replenishmentSearchGridLabelSx(2)} | |||||
| /> | |||||
| <Box sx={replenishmentSearchGridInputSx(2)}> | |||||
| <ReplenishmentTextField | |||||
| value={doCodeSuffix} | |||||
| onChange={(e) => { | |||||
| setDoCodeSuffix(e.target.value.slice(0, 4)); | |||||
| setSourceDo(null); | |||||
| }} | |||||
| placeholder={t("replenishmentDoSuffixPlaceholder")} | |||||
| inputProps={{ maxLength: 4 }} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter") { | |||||
| e.preventDefault(); | |||||
| void handleLookupSourceDo(); | |||||
| } | |||||
| }} | |||||
| /> | |||||
| </Box> | |||||
| <ReplenishmentFieldLabel | |||||
| icon={<StorefrontIcon fontSize="small" sx={REPLENISHMENT_FIELD_ICON_SX} />} | |||||
| title={t("Shop Code")} | |||||
| required | |||||
| sx={replenishmentSearchGridLabelSx(3)} | |||||
| /> | |||||
| <Box sx={replenishmentSearchGridShopRowSx}> | |||||
| <ReplenishmentTextField | |||||
| value={shopInput} | |||||
| onChange={(e) => { | |||||
| setShopInput(e.target.value); | |||||
| setSourceDo(null); | |||||
| }} | |||||
| placeholder={t("replenishmentShopPlaceholder")} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter") { | |||||
| e.preventDefault(); | |||||
| void handleLookupSourceDo(); | |||||
| } | |||||
| }} | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| disableElevation | |||||
| startIcon={<Search fontSize="small" />} | |||||
| onClick={() => void handleLookupSourceDo()} | |||||
| disabled={isLookingUp} | |||||
| sx={REPLENISHMENT_LOOKUP_BUTTON_SX} | |||||
| > | |||||
| {t("Lookup")} | |||||
| </Button> | |||||
| </Box> | |||||
| </Box> | |||||
| {sourceDo && ( | |||||
| <Box sx={REPLENISHMENT_SOURCE_HEADER_SX}> | |||||
| <Typography | |||||
| variant="body2" | |||||
| fontWeight={700} | |||||
| sx={{ wordBreak: "break-word", lineHeight: 1.5, width: "100%" }} | |||||
| > | |||||
| {t("Delivery Order Code")}: {sourceDo.doCode} | |||||
| {" "} | |||||
| {t("Shop Name")}: {sourceDo.shopName ?? "—"} | |||||
| {" "} | |||||
| {t("Truck Lance Code")}:{" "} | |||||
| {sourceDo.truckLaneCode?.trim() ? sourceDo.truckLaneCode : t("Truck X")} | |||||
| </Typography> | |||||
| </Box> | |||||
| )} | |||||
| {sourceDo && ( | |||||
| <Stack spacing={1.5}> | |||||
| <TableContainer | |||||
| sx={(theme) => ({ | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| borderRadius: 2, | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.900" : "common.white", | |||||
| })} | |||||
| > | |||||
| <Table size="small" sx={REPLENISHMENT_TABLE_SX}> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ width: { md: "18%" }, minWidth: { md: 168 } }}> | |||||
| {t("Replenishment item code")} | |||||
| </TableCell> | |||||
| <TableCell sx={{ width: { md: "28%" } }}>{t("Item Name")}</TableCell> | |||||
| <TableCell align="right" sx={{ width: { md: "11%" }, whiteSpace: "nowrap" }}> | |||||
| {t("Original Shipment Qty")} | |||||
| </TableCell> | |||||
| <TableCell align="right" sx={{ width: { md: "12%" }, whiteSpace: "nowrap" }}> | |||||
| {t("Replenish Qty")} | |||||
| </TableCell> | |||||
| <TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}> | |||||
| {t("uom")} | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ width: { md: 120 }, whiteSpace: "nowrap" }}> | |||||
| {t("Action")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {draftRows.map((row) => ( | |||||
| <TableRow key={row.rowId} hover> | |||||
| <TableCell>{row.itemNo}</TableCell> | |||||
| <TableCell sx={{ wordBreak: "break-word" }}>{row.itemName}</TableCell> | |||||
| <TableCell align="right">{row.originalQty}</TableCell> | |||||
| <TableCell align="right">{row.replenishQty}</TableCell> | |||||
| <TableCell>{row.shortUom || "—"}</TableCell> | |||||
| <TableCell align="center"> | |||||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | |||||
| <IconButton | |||||
| size="small" | |||||
| color="error" | |||||
| onClick={() => handleRemoveDraftRow(row.rowId)} | |||||
| aria-label={t("Delete")} | |||||
| > | |||||
| <Delete fontSize="small" /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ))} | |||||
| <TableRow sx={REPLENISHMENT_TABLE_ENTRY_ROW_SX}> | |||||
| <TableCell> | |||||
| <Autocomplete | |||||
| size="small" | |||||
| fullWidth | |||||
| options={sourceDo.lines.filter( | |||||
| (line) => | |||||
| !draftRows.some( | |||||
| (r) => | |||||
| r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | |||||
| ), | |||||
| )} | |||||
| value={selectedLine} | |||||
| onChange={(_, newValue) => setSelectedLine(newValue)} | |||||
| getOptionLabel={(line) => line.itemNo ?? ""} | |||||
| isOptionEqualToValue={(a, b) => a.id === b.id} | |||||
| filterOptions={(options, { inputValue }) => { | |||||
| const query = inputValue.trim().toLowerCase(); | |||||
| if (!query) return options; | |||||
| return options.filter((line) => | |||||
| (line.itemNo ?? "").toLowerCase().includes(query), | |||||
| ); | |||||
| }} | |||||
| renderInput={(params) => { | |||||
| const { inputProps } = params; | |||||
| return ( | |||||
| <ReplenishmentTextField | |||||
| {...params} | |||||
| placeholder={t("Replenishment item code")} | |||||
| inputProps={{ | |||||
| ...inputProps, | |||||
| ref: (node: HTMLInputElement | null) => { | |||||
| itemCodeInputRef.current = node; | |||||
| const { ref } = inputProps; | |||||
| if (typeof ref === "function") ref(node); | |||||
| else if (ref) { | |||||
| ( | |||||
| ref as React.MutableRefObject<HTMLInputElement | null> | |||||
| ).current = node; | |||||
| } | |||||
| }, | |||||
| }} | |||||
| InputProps={{ ...params.InputProps, disableUnderline: true }} | |||||
| /> | |||||
| ); | |||||
| }} | |||||
| sx={REPLENISHMENT_TABLE_AUTOCOMPLETE_SX} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| value={ | |||||
| selectedLine ? (selectedLine.itemName ?? selectedLine.itemNo ?? "") : "" | |||||
| } | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| reserveSpace | |||||
| value={selectedLine != null ? String(selectedLine.qty ?? "") : ""} | |||||
| sx={{ whiteSpace: "nowrap", textAlign: "right", minHeight: "unset" }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <Box sx={{ display: "flex", justifyContent: "flex-end" }}> | |||||
| <ReplenishmentTextField | |||||
| type="number" | |||||
| hiddenLabel | |||||
| fullWidth={false} | |||||
| value={replenishQtyInput} | |||||
| onChange={(e) => setReplenishQtyInput(e.target.value)} | |||||
| inputProps={{ min: 0, step: "any", style: { textAlign: "right" } }} | |||||
| sx={(theme) => ({ | |||||
| ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme), | |||||
| width: 72, | |||||
| "& .MuiFilledInput-input": { | |||||
| textAlign: "right", | |||||
| }, | |||||
| })} | |||||
| /> | |||||
| </Box> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| reserveSpace | |||||
| value={selectedLineUom} | |||||
| sx={{ whiteSpace: "nowrap" }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="small" | |||||
| startIcon={<Add />} | |||||
| onClick={handleAddDraftRow} | |||||
| sx={{ | |||||
| borderRadius: 2, | |||||
| textTransform: "none", | |||||
| whiteSpace: "nowrap", | |||||
| px: 1.5, | |||||
| fontSize: (theme) => theme.typography.body2.fontSize, | |||||
| }} | |||||
| > | |||||
| {t("Add Row")} | |||||
| </Button> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| {draftRows.length > 0 && ( | |||||
| <Box sx={{ display: "flex", justifyContent: "flex-end" }}> | |||||
| <Button | |||||
| variant="contained" | |||||
| onClick={() => void handleSubmit()} | |||||
| disabled={isSubmitting} | |||||
| > | |||||
| {t("Submit")} | |||||
| </Button> | |||||
| </Box> | |||||
| )} | |||||
| </Stack> | |||||
| )} | |||||
| </Stack> | |||||
| </Paper> | |||||
| <Dialog | |||||
| open={trackingDialogOpen} | |||||
| onClose={() => setTrackingDialogOpen(false)} | |||||
| maxWidth="lg" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle | |||||
| sx={{ | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "space-between", | |||||
| pr: 1, | |||||
| }} | |||||
| > | |||||
| {t("Replenishment Tracking")} | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={() => setTrackingDialogOpen(false)} | |||||
| aria-label={t("Cancel")} | |||||
| > | |||||
| <Close fontSize="small" /> | |||||
| </IconButton> | |||||
| </DialogTitle> | |||||
| <DialogContent dividers sx={{ p: 0 }}> | |||||
| <Box sx={{ px: 2, pt: 1.5, pb: 1 }}> | |||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||||
| <Box sx={{ minWidth: 200, maxWidth: 280 }}> | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||||
| <DatePicker | |||||
| format="YYYY-MM-DD" | |||||
| value={trackDateFilter} | |||||
| onChange={(v) => setTrackDateFilter(v)} | |||||
| slotProps={datePickerSlotProps} | |||||
| /> | |||||
| </LocalizationProvider> | |||||
| </Box> | |||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | |||||
| <InputLabel>{t("Status")}</InputLabel> | |||||
| <Select | |||||
| label={t("Status")} | |||||
| value={trackStatusFilter} | |||||
| onChange={(e) => | |||||
| setTrackStatusFilter(e.target.value as ReplenishmentStatus | "all") | |||||
| } | |||||
| > | |||||
| <MenuItem value="all">{t("All")}</MenuItem> | |||||
| <MenuItem value="pending">{t("pending")}</MenuItem> | |||||
| <MenuItem value="processing">{t("processing")}</MenuItem> | |||||
| <MenuItem value="completed">{t("completed")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Stack> | |||||
| </Box> | |||||
| <StyledDataGrid | |||||
| rows={filteredRecords} | |||||
| columns={trackColumns} | |||||
| autoHeight | |||||
| disableRowSelectionOnClick | |||||
| pageSizeOptions={[10, 25, 50]} | |||||
| initialState={{ pagination: { paginationModel: { pageSize: 10 } } }} | |||||
| /> | |||||
| </DialogContent> | |||||
| </Dialog> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| export default DoReplenishmentTab; | |||||
| @@ -37,6 +37,7 @@ import Swal from "sweetalert2"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; | import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; | ||||
| import DoReplenishmentTab from "./DoReplenishmentTab"; | |||||
| type Props = { | type Props = { | ||||
| filterArgs?: Record<string, any>; | filterArgs?: Record<string, any>; | ||||
| @@ -45,7 +46,7 @@ type Props = { | |||||
| }; | }; | ||||
| type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>; | type SearchBoxInputs = Record<"code" | "status" | "estimatedArrivalDate" | "orderDate" | "supplierName" | "shopName" | "deliveryOrderLines" | "truckLanceCode" | "codeTo" | "statusTo" | "estimatedArrivalDateTo" | "orderDateTo" | "supplierNameTo" | "shopNameTo" | "deliveryOrderLinesTo" | "truckLanceCodeTo", string>; | ||||
| type SearchParamNames = keyof SearchBoxInputs; | type SearchParamNames = keyof SearchBoxInputs; | ||||
| type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA"; | |||||
| type DoSearchTab = "2F" | "4F" | "TRUCK_X" | "ETRA" | "REPLENISH"; | |||||
| type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; | type TabFilter = { floor: "2F" | "4F" | null; isExtra: boolean; forceTruckKeyword?: string }; | ||||
| // put all this into a new component | // put all this into a new component | ||||
| @@ -313,8 +314,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| case "TRUCK_X": | case "TRUCK_X": | ||||
| return { floor: null, isExtra: false, forceTruckKeyword: "x" }; | return { floor: null, isExtra: false, forceTruckKeyword: "x" }; | ||||
| case "ETRA": | case "ETRA": | ||||
| default: | |||||
| return { floor: null, isExtra: true }; | return { floor: null, isExtra: true }; | ||||
| case "REPLENISH": | |||||
| default: | |||||
| return { floor: null, isExtra: false }; | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| @@ -747,9 +750,10 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| <Tab value="4F" label="4/F" /> | <Tab value="4F" label="4/F" /> | ||||
| <Tab value="TRUCK_X" label={t("Truck X")} /> | <Tab value="TRUCK_X" label={t("Truck X")} /> | ||||
| <Tab value="ETRA" label={t("Etra")} /> | <Tab value="ETRA" label={t("Etra")} /> | ||||
| <Tab value="REPLENISH" label={t("Replenishment")} /> | |||||
| </Tabs> | </Tabs> | ||||
| {hasSearched && hasResults && ( | |||||
| {activeTab !== "REPLENISH" && hasSearched && hasResults && ( | |||||
| <Button | <Button | ||||
| name="batch_release" | name="batch_release" | ||||
| variant="contained" | variant="contained" | ||||
| @@ -762,35 +766,41 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| </Paper> | </Paper> | ||||
| <SearchBox | |||||
| key={`tab-reset-${searchBoxResetKey}`} | |||||
| criteria={searchCriteria} | |||||
| onSearch={handleSearch} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | |||||
| <StyledDataGrid | |||||
| rows={searchAllDos} | |||||
| columns={columns} | |||||
| checkboxSelection | |||||
| rowSelectionModel={rowSelectionModel} | |||||
| onRowSelectionModelChange={applyRowSelectionChange} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| /> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={totalCount} | |||||
| page={(pagingController.pageNum - 1)} | |||||
| rowsPerPage={pagingController.pageSize} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handlePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| /> | |||||
| </Paper> | |||||
| {activeTab === "REPLENISH" ? ( | |||||
| <DoReplenishmentTab /> | |||||
| ) : ( | |||||
| <> | |||||
| <SearchBox | |||||
| key={`tab-reset-${searchBoxResetKey}`} | |||||
| criteria={searchCriteria} | |||||
| onSearch={handleSearch} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <Paper variant="outlined" sx={{ overflow: "hidden" }}> | |||||
| <StyledDataGrid | |||||
| rows={searchAllDos} | |||||
| columns={columns} | |||||
| checkboxSelection | |||||
| rowSelectionModel={rowSelectionModel} | |||||
| onRowSelectionModelChange={applyRowSelectionChange} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| /> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={totalCount} | |||||
| page={(pagingController.pageNum - 1)} | |||||
| rowsPerPage={pagingController.pageSize} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handlePageSizeChange} | |||||
| rowsPerPageOptions={[10, 25, 50]} | |||||
| /> | |||||
| </Paper> | |||||
| </> | |||||
| )} | |||||
| </Stack> | </Stack> | ||||
| </FormProvider> | </FormProvider> | ||||
| @@ -0,0 +1,430 @@ | |||||
| "use client"; | |||||
| import React, { type ReactNode } from "react"; | |||||
| import { Box, Stack, TextField, Typography } from "@mui/material"; | |||||
| import type { Theme } from "@mui/material/styles"; | |||||
| import type { SxProps } from "@mui/material/styles"; | |||||
| import type { TextFieldProps } from "@mui/material/TextField"; | |||||
| export const REPLENISHMENT_FIELD_LABEL_SX = (theme: Theme) => ({ | |||||
| color: | |||||
| theme.palette.mode === "dark" | |||||
| ? theme.palette.grey[100] | |||||
| : theme.palette.common.black, | |||||
| fontWeight: 600, | |||||
| }); | |||||
| export const REPLENISHMENT_FIELD_ICON_SX = (theme: Theme) => ({ | |||||
| color: | |||||
| theme.palette.mode === "dark" | |||||
| ? theme.palette.grey[100] | |||||
| : theme.palette.common.black, | |||||
| }); | |||||
| export const REPLENISHMENT_TEXTFIELD_SX = (theme: Theme) => | |||||
| ({ | |||||
| "& .MuiFilledInput-root": { | |||||
| alignItems: "center", | |||||
| borderRadius: 2, | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| }, | |||||
| "& .MuiFilledInput-root.Mui-focused": { | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| }, | |||||
| "& .MuiFilledInput-input": { | |||||
| paddingTop: REPLENISHMENT_FIELD_BODY_PY, | |||||
| paddingBottom: REPLENISHMENT_FIELD_BODY_PY, | |||||
| }, | |||||
| "& .MuiFilledInput-input::placeholder, & .MuiInputBase-input::placeholder": { | |||||
| color: theme.palette.text.secondary, | |||||
| fontWeight: 400, | |||||
| opacity: 1, | |||||
| }, | |||||
| "& .MuiFilledInput-root.Mui-focused .MuiFilledInput-input::placeholder, & .MuiFilledInput-root.Mui-focused .MuiInputBase-input::placeholder": | |||||
| { | |||||
| opacity: 0, | |||||
| }, | |||||
| }) as const; | |||||
| /** Autocomplete filled input — override theme default padding to match {@link REPLENISHMENT_TEXTFIELD_SX}. */ | |||||
| export const REPLENISHMENT_AUTOCOMPLETE_SX = (theme: Theme) => | |||||
| ({ | |||||
| width: "100%", | |||||
| ...REPLENISHMENT_TEXTFIELD_SX(theme), | |||||
| "& .MuiFormControl-root": { | |||||
| width: "100%", | |||||
| }, | |||||
| "& .MuiAutocomplete-inputRoot": { | |||||
| paddingTop: `${REPLENISHMENT_FIELD_BODY_PY} !important`, | |||||
| paddingBottom: `${REPLENISHMENT_FIELD_BODY_PY} !important`, | |||||
| paddingLeft: `${theme.spacing(REPLENISHMENT_FIELD_BODY_PX)} !important`, | |||||
| paddingRight: `${theme.spacing(3)} !important`, | |||||
| }, | |||||
| "& .MuiAutocomplete-input": { | |||||
| padding: "0 !important", | |||||
| }, | |||||
| "& .MuiAutocomplete-endAdornment": { | |||||
| right: theme.spacing(1), | |||||
| }, | |||||
| }) as const; | |||||
| /** Vertical padding inside replenishment filled inputs (see REPLENISHMENT_TEXTFIELD_SX). */ | |||||
| export const REPLENISHMENT_FIELD_BODY_PY = "12px"; | |||||
| /** Horizontal padding aligned with MUI filled input (spacing 1.5 = 12px). */ | |||||
| export const REPLENISHMENT_FIELD_BODY_PX = 1.5; | |||||
| /** Source DO summary header: same inset as textbox content area. */ | |||||
| export const REPLENISHMENT_SOURCE_HEADER_SX = (theme: Theme) => | |||||
| ({ | |||||
| bgcolor: "action.hover", | |||||
| borderRadius: 2, | |||||
| px: REPLENISHMENT_FIELD_BODY_PX, | |||||
| py: REPLENISHMENT_FIELD_BODY_PY, | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| boxSizing: "border-box", | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| }) as const; | |||||
| type ReplenishmentFieldLabelProps = { | |||||
| icon: ReactNode; | |||||
| title: string; | |||||
| required?: boolean; | |||||
| sx?: SxProps<Theme>; | |||||
| }; | |||||
| export function ReplenishmentFieldLabel({ | |||||
| icon, | |||||
| title, | |||||
| required = false, | |||||
| sx, | |||||
| }: ReplenishmentFieldLabelProps) { | |||||
| return ( | |||||
| <Stack direction="row" spacing={1} alignItems="center" sx={sx}> | |||||
| {icon} | |||||
| <Typography variant="body2" sx={REPLENISHMENT_FIELD_LABEL_SX} component="span"> | |||||
| {title} | |||||
| {required ? ( | |||||
| <Typography component="span" color="error.main" aria-hidden="true"> | |||||
| {" *"} | |||||
| </Typography> | |||||
| ) : null} | |||||
| </Typography> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| type ReplenishmentFilterFieldProps = ReplenishmentFieldLabelProps & { | |||||
| children: ReactNode; | |||||
| }; | |||||
| export function ReplenishmentFilterField({ | |||||
| icon, | |||||
| title, | |||||
| children, | |||||
| required = false, | |||||
| }: ReplenishmentFilterFieldProps) { | |||||
| return ( | |||||
| <Stack spacing={1} sx={{ flex: 1, minWidth: 0 }}> | |||||
| <ReplenishmentFieldLabel icon={icon} title={title} required={required} /> | |||||
| {children} | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| type ReplenishmentTextFieldProps = Omit<TextFieldProps, "variant" | "size">; | |||||
| export function ReplenishmentTextField(props: ReplenishmentTextFieldProps) { | |||||
| const { sx, InputProps, ...rest } = props; | |||||
| return ( | |||||
| <TextField | |||||
| size="small" | |||||
| fullWidth | |||||
| variant="filled" | |||||
| sx={(theme) => ({ | |||||
| ...REPLENISHMENT_TEXTFIELD_SX(theme), | |||||
| ...(typeof sx === "function" ? sx(theme) : sx), | |||||
| })} | |||||
| InputProps={{ disableUnderline: true, ...InputProps }} | |||||
| {...rest} | |||||
| /> | |||||
| ); | |||||
| } | |||||
| /** Read-only item row value — blank until a line is selected. */ | |||||
| export function ReplenishmentItemEntryPlainText({ | |||||
| value, | |||||
| reserveSpace = false, | |||||
| sx, | |||||
| }: { | |||||
| value: string; | |||||
| /** Keep column width/height when empty (e.g. original shipment qty). */ | |||||
| reserveSpace?: boolean; | |||||
| sx?: SxProps<Theme>; | |||||
| }) { | |||||
| const isEmpty = value.trim() === ""; | |||||
| if (isEmpty && !reserveSpace) return null; | |||||
| return ( | |||||
| <Box | |||||
| component="span" | |||||
| sx={(theme) => ({ | |||||
| display: "block", | |||||
| color: theme.palette.text.primary, | |||||
| wordBreak: "break-word", | |||||
| minWidth: 0, | |||||
| minHeight: reserveSpace ? theme.spacing(5) : undefined, | |||||
| ...(typeof sx === "function" ? sx(theme) : sx), | |||||
| })} | |||||
| > | |||||
| {isEmpty ? "\u00A0" : value} | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| type ReplenishmentQtyWithUomFieldProps = { | |||||
| placeholder?: string; | |||||
| value: string; | |||||
| onChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||||
| uom?: string; | |||||
| sx?: SxProps<Theme>; | |||||
| inputProps?: React.InputHTMLAttributes<HTMLInputElement>; | |||||
| }; | |||||
| /** Replenish qty textbox (search-field style) with uom label attached on the right outside. */ | |||||
| export function ReplenishmentQtyWithUomField({ | |||||
| placeholder, | |||||
| value, | |||||
| onChange, | |||||
| uom = "", | |||||
| sx, | |||||
| inputProps, | |||||
| }: ReplenishmentQtyWithUomFieldProps) { | |||||
| return ( | |||||
| <Stack | |||||
| direction="row" | |||||
| alignItems="center" | |||||
| spacing={0.75} | |||||
| sx={{ flexShrink: 0, ...(typeof sx === "function" ? undefined : sx) }} | |||||
| > | |||||
| <ReplenishmentTextField | |||||
| type="number" | |||||
| hiddenLabel | |||||
| placeholder={placeholder ?? ""} | |||||
| value={value} | |||||
| onChange={onChange} | |||||
| inputProps={inputProps} | |||||
| sx={{ width: 100 }} | |||||
| /> | |||||
| <Box component="span" sx={REPLENISHMENT_TABLE_UOM_SLOT_SX}> | |||||
| {uom || "\u00A0"} | |||||
| </Box> | |||||
| </Stack> | |||||
| ); | |||||
| } | |||||
| export const REPLENISHMENT_TABLE_SX = { | |||||
| tableLayout: { md: "fixed" }, | |||||
| width: "100%", | |||||
| "& .MuiTableCell-root": { | |||||
| typography: "body2", | |||||
| borderColor: "divider", | |||||
| py: 1.25, | |||||
| px: 2, | |||||
| }, | |||||
| "& .MuiTableCell-root:first-of-type": { | |||||
| pl: 3.5, | |||||
| }, | |||||
| "& .MuiTableHead-root .MuiTableCell-root": { | |||||
| fontWeight: 600, | |||||
| color: "text.secondary", | |||||
| bgcolor: "action.hover", | |||||
| borderBottom: "1px solid", | |||||
| borderColor: "divider", | |||||
| }, | |||||
| "& .MuiTableBody-root .MuiTableRow-root:not(:last-of-type) .MuiTableCell-root": { | |||||
| borderBottom: "1px solid", | |||||
| borderColor: "divider", | |||||
| }, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_HEAD_CELL_SX = { | |||||
| typography: "body2", | |||||
| fontWeight: 600, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_BODY_CELL_SX = { | |||||
| typography: "body2", | |||||
| verticalAlign: "middle", | |||||
| } as const; | |||||
| /** Extra inset for the first column (item code). */ | |||||
| export const REPLENISHMENT_TABLE_FIRST_CELL_SX = { | |||||
| pl: 3, | |||||
| } as const; | |||||
| /** Entry row: no bottom border on last row. */ | |||||
| export const REPLENISHMENT_TABLE_ENTRY_ROW_SX = { | |||||
| "& .MuiTableCell-root": { | |||||
| borderBottom: 0, | |||||
| }, | |||||
| } as const; | |||||
| /** In-table inputs — always show filled border; compact padding for row alignment. */ | |||||
| export const REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX = (theme: Theme) => | |||||
| ({ | |||||
| ...REPLENISHMENT_TEXTFIELD_SX(theme), | |||||
| "& .MuiFilledInput-root": { | |||||
| alignItems: "center", | |||||
| borderRadius: 1.5, | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| border: `1px solid ${theme.palette.divider}`, | |||||
| "&:hover": { | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| }, | |||||
| "&.Mui-focused": { | |||||
| bgcolor: theme.palette.mode === "dark" ? "grey.800" : "common.white", | |||||
| borderColor: theme.palette.primary.main, | |||||
| }, | |||||
| }, | |||||
| "& .MuiFilledInput-input": { | |||||
| paddingTop: "6px", | |||||
| paddingBottom: "6px", | |||||
| paddingLeft: 0, | |||||
| paddingRight: 0, | |||||
| }, | |||||
| "& input[type=number]": { | |||||
| MozAppearance: "textfield", | |||||
| }, | |||||
| "& input[type=number]::-webkit-outer-spin-button, & input[type=number]::-webkit-inner-spin-button": | |||||
| { | |||||
| WebkitAppearance: "none", | |||||
| margin: 0, | |||||
| }, | |||||
| }) as const; | |||||
| /** Table autocomplete — same inset as plain text in first column. */ | |||||
| export const REPLENISHMENT_TABLE_AUTOCOMPLETE_SX = (theme: Theme) => | |||||
| ({ | |||||
| width: "100%", | |||||
| ...REPLENISHMENT_TABLE_INLINE_TEXTFIELD_SX(theme), | |||||
| "& .MuiFormControl-root": { | |||||
| width: "100%", | |||||
| }, | |||||
| "& .MuiAutocomplete-inputRoot": { | |||||
| paddingTop: "6px !important", | |||||
| paddingBottom: "6px !important", | |||||
| paddingLeft: `${theme.spacing(1)} !important`, | |||||
| paddingRight: "56px !important", | |||||
| bgcolor: `${theme.palette.mode === "dark" ? theme.palette.grey[800] : theme.palette.common.white} !important`, | |||||
| border: `1px solid ${theme.palette.divider} !important`, | |||||
| borderRadius: `${theme.shape.borderRadius * 1.5}px !important`, | |||||
| "&.Mui-focused": { | |||||
| borderColor: `${theme.palette.primary.main} !important`, | |||||
| }, | |||||
| }, | |||||
| "& .MuiAutocomplete-input": { | |||||
| padding: "0 !important", | |||||
| minWidth: 48, | |||||
| }, | |||||
| "& .MuiAutocomplete-endAdornment": { | |||||
| right: theme.spacing(0.75), | |||||
| gap: theme.spacing(0.25), | |||||
| "& .MuiAutocomplete-clearIndicator": { | |||||
| visibility: "visible", | |||||
| }, | |||||
| }, | |||||
| }) as const; | |||||
| /** Right-aligned qty + uom slot shared by data rows and entry row. */ | |||||
| export const REPLENISHMENT_TABLE_QTY_CELL_INNER_SX = { | |||||
| display: "flex", | |||||
| justifyContent: "flex-end", | |||||
| alignItems: "center", | |||||
| width: "100%", | |||||
| gap: 0.75, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_UOM_SLOT_SX = { | |||||
| minWidth: 28, | |||||
| whiteSpace: "nowrap", | |||||
| display: "inline-block", | |||||
| flexShrink: 0, | |||||
| } as const; | |||||
| export const REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX = { | |||||
| display: "flex", | |||||
| justifyContent: "center", | |||||
| alignItems: "center", | |||||
| width: "100%", | |||||
| } as const; | |||||
| export const replenishmentSearchGridLabelSx = (col: number) => ({ | |||||
| gridColumn: { xs: 1, lg: col }, | |||||
| gridRow: { xs: "auto", lg: 1 }, | |||||
| minWidth: 0, | |||||
| }); | |||||
| export const replenishmentSearchGridInputSx = (col: number) => ({ | |||||
| gridColumn: { xs: 1, lg: col }, | |||||
| gridRow: { xs: "auto", lg: 2 }, | |||||
| minWidth: 0, | |||||
| display: "flex", | |||||
| alignItems: "stretch", | |||||
| "& .MuiFormControl-root": { | |||||
| width: "100%", | |||||
| }, | |||||
| }); | |||||
| /** Shop input + lookup button share one row; button height follows the textbox. */ | |||||
| export const replenishmentSearchGridShopRowSx = { | |||||
| gridColumn: { xs: 1, lg: 3 }, | |||||
| gridRow: { xs: "auto", lg: 2 }, | |||||
| minWidth: 0, | |||||
| display: "flex", | |||||
| alignItems: "stretch", | |||||
| gap: 1, | |||||
| "& .MuiTextField-root": { | |||||
| flex: 1, | |||||
| minWidth: 0, | |||||
| }, | |||||
| "& .MuiFormControl-root": { | |||||
| height: "100%", | |||||
| }, | |||||
| "& .MuiFilledInput-root": { | |||||
| height: "100%", | |||||
| boxSizing: "border-box", | |||||
| }, | |||||
| }; | |||||
| /** Match {@link ReplenishmentFieldLabel} typography on contained buttons. */ | |||||
| export const REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX = (theme: Theme) => ({ | |||||
| fontSize: theme.typography.body2.fontSize, | |||||
| fontWeight: 600, | |||||
| lineHeight: 1, | |||||
| }); | |||||
| export const REPLENISHMENT_LOOKUP_BUTTON_SX = (theme: Theme) => ({ | |||||
| ...REPLENISHMENT_LOOKUP_BUTTON_TEXT_SX(theme), | |||||
| alignSelf: "stretch", | |||||
| minHeight: "unset", | |||||
| height: "auto", | |||||
| py: 0, | |||||
| px: 1.5, | |||||
| borderRadius: 2, | |||||
| boxShadow: "none", | |||||
| textTransform: "none", | |||||
| whiteSpace: "nowrap", | |||||
| flexShrink: 0, | |||||
| minWidth: { xs: "100%", lg: 108 }, | |||||
| "& .MuiButton-startIcon": { | |||||
| margin: 0, | |||||
| marginRight: theme.spacing(0.75), | |||||
| "& > *:nth-of-type(1)": { | |||||
| fontSize: 20, | |||||
| }, | |||||
| }, | |||||
| }); | |||||
| @@ -61,6 +61,51 @@ | |||||
| "Problem DO(s): ": "Problem DO(s): ", | "Problem DO(s): ": "Problem DO(s): ", | ||||
| "Progress": "Progress", | "Progress": "Progress", | ||||
| "Quantity": "Quantity", | "Quantity": "Quantity", | ||||
| "All": "All", | |||||
| "Add Row": "Add", | |||||
| "Created": "Created", | |||||
| "DO code suffix must be exactly 4 characters": "DO code suffix must be exactly 4 characters", | |||||
| "DO Code Last 4": "DO No. (last 4)", | |||||
| "Delivery Date": "Delivery Date", | |||||
| "Enter last 4 characters of DO code": "Enter last 4 characters of DO code", | |||||
| "Shop code, or first characters of shop name": "Shop code (partial match), or first characters of shop name", | |||||
| "Multiple source DOs matched": "Multiple source DOs matched", | |||||
| "Please verify DO code suffix, delivery date and shop.": "Please verify DO code suffix, delivery date and shop.", | |||||
| "Shop code or name is required": "Shop code or name is required", | |||||
| "Draft List": "Draft List", | |||||
| "Enter item code to search": "Enter item code to search", | |||||
| "Failed to lookup source DO": "Failed to lookup source DO", | |||||
| "Item": "Item", | |||||
| "Lookup": "Lookup", | |||||
| "No draft rows to submit": "No draft rows to submit", | |||||
| "Only completed delivery orders can be used as replenishment source.": "Only completed delivery orders can be used as replenishment source.", | |||||
| "Original Shipment Qty": "Original Shipment Qty", | |||||
| "Please lookup source DO first": "Please lookup source DO first", | |||||
| "Picker Name": "Picker Name", | |||||
| "Please select an item": "Please select an item", | |||||
| "Records saved locally for preview. Backend integration pending.": "Records saved locally for preview. Backend integration pending.", | |||||
| "Replenishment Code": "Replenishment No.", | |||||
| "Ref Code": "Ref Code", | |||||
| "Replenish Qty": "Replenish Qty", | |||||
| "Replenish qty must be greater than zero": "Replenish qty must be greater than zero", | |||||
| "Replenishment": "Replenishment", | |||||
| "Replenishment API not ready": "Replenishment API not ready", | |||||
| "Replenishment Entry": "Replenishment Entry", | |||||
| "Replenishment item code": "Item Code", | |||||
| "Replenishment Tracking": "Replenishment Tracking", | |||||
| "Required field": "Required field", | |||||
| "replenishmentDatePlaceholder": "YYYY-MM-DD", | |||||
| "replenishmentDoSuffixPlaceholder": "DO No. (last 4)", | |||||
| "replenishmentShopPlaceholder": "Shop Code", | |||||
| "Source DO": "Source DO", | |||||
| "Source DO Code": "Source DO Code", | |||||
| "Source DO code is required": "Source DO code is required", | |||||
| "Source DO must be completed": "Source DO must be completed", | |||||
| "Source DO not found": "Source DO not found", | |||||
| "Submit": "Submit", | |||||
| "Target DO": "Target DO", | |||||
| "This item is already in the draft list": "This item is already in the draft list", | |||||
| "processing": "Processing", | |||||
| "Receiving": "Receiving", | "Receiving": "Receiving", | ||||
| "Release": "Release", | "Release": "Release", | ||||
| "Release 2/F": "Release 2/F", | "Release 2/F": "Release 2/F", | ||||
| @@ -93,5 +138,5 @@ | |||||
| "code": "code", | "code": "code", | ||||
| "do workbench": "do workbench", | "do workbench": "do workbench", | ||||
| "row selected": "row selected", | "row selected": "row selected", | ||||
| "uom": "uom" | |||||
| "uom": "Unit" | |||||
| } | } | ||||
| @@ -10,6 +10,51 @@ | |||||
| "Estimated Arrival From": "預計送貨日期", | "Estimated Arrival From": "預計送貨日期", | ||||
| "Estimated Arrival To": "預計送貨日期至", | "Estimated Arrival To": "預計送貨日期至", | ||||
| "Status": "來貨狀態", | "Status": "來貨狀態", | ||||
| "All": "全部", | |||||
| "Add Row": "新增", | |||||
| "Created": "建立時間", | |||||
| "DO code suffix must be exactly 4 characters": "送貨訂單號末四位必須為四個字元", | |||||
| "DO Code Last 4": "送貨單號末四位", | |||||
| "Delivery Date": "送貨日期", | |||||
| "Enter last 4 characters of DO code": "請輸入送貨單號末四位", | |||||
| "Enter item code to search": "輸入貨品編號搜尋", | |||||
| "Shop code, or first characters of shop name": "店鋪代碼(部分符合),或店鋪名稱開頭字元", | |||||
| "Multiple source DOs matched": "找到多張符合的來源送貨單", | |||||
| "Please verify DO code suffix, delivery date and shop.": "請核對送貨單號末四位、送貨日及店鋪資料。", | |||||
| "Shop code or name is required": "請輸入店鋪代碼或名稱", | |||||
| "Draft List": "待提交列表", | |||||
| "Failed to lookup source DO": "查詢來源送貨單失敗", | |||||
| "Item": "物品", | |||||
| "Lookup": "查詢", | |||||
| "No draft rows to submit": "沒有待提交的行", | |||||
| "Only completed delivery orders can be used as replenishment source.": "只有已送貨(completed)的送貨單可作為補貨來源。", | |||||
| "Original Shipment Qty": "原出貨數", | |||||
| "Please lookup source DO first": "請先查詢來源送貨單", | |||||
| "Picker Name": "揀貨員名稱", | |||||
| "Please select an item": "請選擇物品", | |||||
| "Records saved locally for preview. Backend integration pending.": "記錄已暫存於本地預覽,後端 API 尚未就緒。", | |||||
| "Replenishment Code": "補貨編號", | |||||
| "Ref Code": "參考編號", | |||||
| "Replenish Qty": "補貨數量", | |||||
| "Replenish qty must be greater than zero": "補貨數量必須大於零", | |||||
| "Replenishment": "補貨", | |||||
| "Replenishment API not ready": "補貨 API 尚未就緒", | |||||
| "Replenishment Entry": "補貨填表", | |||||
| "Replenishment item code": "貨品編號", | |||||
| "Replenishment Tracking": "補貨進度追蹤", | |||||
| "Required field": "為必填項", | |||||
| "replenishmentDatePlaceholder": "YYYY-MM-DD", | |||||
| "replenishmentDoSuffixPlaceholder": "送貨單號末四位", | |||||
| "replenishmentShopPlaceholder": "店鋪編號", | |||||
| "Source DO": "來源送貨單", | |||||
| "Source DO Code": "來源送貨單編號", | |||||
| "Source DO code is required": "請輸入來源送貨單編號", | |||||
| "Source DO must be completed": "來源送貨單須為已送貨狀態", | |||||
| "Source DO not found": "找不到來源送貨單", | |||||
| "Submit": "提交", | |||||
| "Target DO": "目標送貨單", | |||||
| "This item is already in the draft list": "此物品已在待提交列表中", | |||||
| "processing": "處理中", | |||||
| "Etra": "加單", | "Etra": "加單", | ||||
| "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | "Merge extra orders into lane batch ticket": "合併同車線送貨訂單(TI-M- 合併票)", | ||||
| "Loading": "正在加載...", | "Loading": "正在加載...", | ||||
| @@ -69,7 +114,7 @@ | |||||
| "Supplier Code": "供應商編號", | "Supplier Code": "供應商編號", | ||||
| "Estimated Arrival Date": "預計送貨日期", | "Estimated Arrival Date": "預計送貨日期", | ||||
| "Item No.": "商品編號", | "Item No.": "商品編號", | ||||
| "Item Name": "商品名稱", | |||||
| "Item Name": "貨品名稱", | |||||
| "Quantity": "數量", | "Quantity": "數量", | ||||
| "uom": "單位", | "uom": "單位", | ||||
| "Lot No.": "批號", | "Lot No.": "批號", | ||||
| @@ -118,7 +163,6 @@ | |||||
| "Yes": "是", | "Yes": "是", | ||||
| "No": "否", | "No": "否", | ||||
| "Replenishment input section": "補貨資料", | "Replenishment input section": "補貨資料", | ||||
| "Replenishment item code": "貨品編號", | |||||
| "Replenishment search candidates": "搜尋候選送貨單", | "Replenishment search candidates": "搜尋候選送貨單", | ||||
| "Replenishment reset": "重設", | "Replenishment reset": "重設", | ||||
| "Replenishment candidate section": "候選送貨單(待放單)", | "Replenishment candidate section": "候選送貨單(待放單)", | ||||