Просмотр исходного кода

replenishment setup

production
kelvin.yau 1 неделю назад
Родитель
Сommit
3617a02292
6 измененных файлов: 1352 добавлений и 35 удалений
  1. +3
    -0
      src/app/api/do/actions.tsx
  2. +785
    -0
      src/components/DoSearch/DoReplenishmentTab.tsx
  3. +42
    -32
      src/components/DoSearch/DoSearch.tsx
  4. +430
    -0
      src/components/DoSearch/ReplenishmentFilterField.tsx
  5. +46
    -1
      src/i18n/en/do.json
  6. +46
    -2
      src/i18n/zh/do.json

+ 3
- 0
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[];


+ 785
- 0
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<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;

+ 42
- 32
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<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 };
}
}, []);

@@ -747,9 +750,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"
@@ -762,35 +766,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>


+ 430
- 0
src/components/DoSearch/ReplenishmentFilterField.tsx Просмотреть файл

@@ -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,
},
},
});


+ 46
- 1
src/i18n/en/do.json Просмотреть файл

@@ -61,6 +61,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",
@@ -93,5 +138,5 @@
"code": "code",
"do workbench": "do workbench",
"row selected": "row selected",
"uom": "uom"
"uom": "Unit"
}

+ 46
- 2
src/i18n/zh/do.json Просмотреть файл

@@ -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- 合併票)",
"Loading": "正在加載...",
@@ -69,7 +114,7 @@
"Supplier Code": "供應商編號",
"Estimated Arrival Date": "預計送貨日期",
"Item No.": "商品編號",
"Item Name": "品名稱",
"Item Name": "品名稱",
"Quantity": "數量",
"uom": "單位",
"Lot No.": "批號",
@@ -118,7 +163,6 @@
"Yes": "是",
"No": "否",
"Replenishment input section": "補貨資料",
"Replenishment item code": "貨品編號",
"Replenishment search candidates": "搜尋候選送貨單",
"Replenishment reset": "重設",
"Replenishment candidate section": "候選送貨單(待放單)",


Загрузка…
Отмена
Сохранить