| @@ -77,6 +77,8 @@ interface ApproverStockTakeAllProps { | |||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||
| /** 審核待處理時,把批量保存按鈕掛到分頁列右側 */ | |||
| batchButtonHost?: HTMLElement | null; | |||
| /** History / closed round — disable save / batch save */ | |||
| roundReadOnly?: boolean; | |||
| } | |||
| type QtySelectionType = "first" | "second" | "approver"; | |||
| @@ -368,6 +370,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| onBack, | |||
| onSnackbar, | |||
| batchButtonHost = null, | |||
| roundReadOnly = false, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| @@ -375,6 +378,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const isAdmin = (session?.abilities ?? session?.user?.abilities ?? []).some( | |||
| (ability) => String(ability).trim() === AUTH.ADMIN, | |||
| ); | |||
| const isRoundClosed = | |||
| roundReadOnly || selectedSession?.status?.toLowerCase() === "completed"; | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||
| @@ -896,7 +901,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const handleSaveApproverStockTake = useCallback( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (mode === "approved") return; | |||
| if (mode === "approved" || isRoundClosed) return; | |||
| if (!selectedSession || !currentUserId) { | |||
| return; | |||
| } | |||
| @@ -975,7 +980,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const handleUpdateStatusToNotMatch = useCallback( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (mode === "approved") return; | |||
| if (mode === "approved" || isRoundClosed) return; | |||
| if (!detail.stockTakeRecordId) { | |||
| onSnackbar(t("Stock take record ID is required"), "error"); | |||
| return; | |||
| @@ -1056,7 +1061,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| }, [sortedDetails]); | |||
| const handleOpenBatchSaveConfirm = useCallback(() => { | |||
| if (mode === "approved") return; | |||
| if (mode === "approved" || isRoundClosed) return; | |||
| if (!selectedSession || !currentUserId) return; | |||
| if (inventoryLotDetails.length === 0) { | |||
| onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); | |||
| @@ -1079,7 +1084,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| if (batchSaveInFlightRef.current) return; | |||
| if (mode === "approved") return; | |||
| if (mode === "approved" || isRoundClosed) return; | |||
| if (!selectedSession || !currentUserId) return; | |||
| const records = buildBatchApproverSaveRequests( | |||
| @@ -1169,7 +1174,30 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| if (!ms) return "-"; | |||
| return dayjs(ms).format("YYYY-MM-DD HH:mm"); | |||
| }; | |||
| const rows = sortedDetails; | |||
| /** Word-style: same location grouped under a heading (picker-like). */ | |||
| const locationGroups = useMemo(() => { | |||
| const groups: { key: string; label: string; items: InventoryLotDetailResponse[] }[] = []; | |||
| const indexByKey = new Map<string, number>(); | |||
| for (const detail of sortedDetails) { | |||
| const area = detail.warehouseArea?.trim() || ""; | |||
| const slot = detail.warehouseSlot?.trim() || ""; | |||
| const code = detail.warehouseCode?.trim() || ""; | |||
| const key = code || `${area}|${slot}` || String(detail.id); | |||
| const label = | |||
| code || | |||
| (area || slot ? `${area}${slot}` : "") || | |||
| detail.stockTakeSection?.trim() || | |||
| "-"; | |||
| let idx = indexByKey.get(key); | |||
| if (idx == null) { | |||
| idx = groups.length; | |||
| indexByKey.set(key, idx); | |||
| groups.push({ key, label, items: [] }); | |||
| } | |||
| groups[idx].items.push(detail); | |||
| } | |||
| return groups; | |||
| }, [sortedDetails]); | |||
| const columns = useMemo<GridColDef<InventoryLotDetailResponse>[]>(() => { | |||
| const cols: GridColDef<InventoryLotDetailResponse>[] = []; | |||
| @@ -1190,29 +1218,11 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| } | |||
| cols.push( | |||
| { | |||
| field: "warehouseLocation", | |||
| headerName: t("Warehouse Location"), | |||
| minWidth: 220, | |||
| flex: 1, | |||
| sortable: false, | |||
| renderCell: (params) => ( | |||
| <Stack spacing={0.5}> | |||
| <Typography variant="body2"> | |||
| <strong> | |||
| {params.row.stockTakeSection || "-"}{" "} | |||
| {params.row.stockTakeSectionDescription || "-"} | |||
| </strong> | |||
| </Typography> | |||
| <Typography variant="body2">{params.row.warehouseCode || "-"}</Typography> | |||
| </Stack> | |||
| ), | |||
| }, | |||
| { | |||
| field: "itemLot", | |||
| headerName: t("Item-lotNo-ExpiryDate"), | |||
| minWidth: 160, | |||
| flex: 1, | |||
| minWidth: 220, | |||
| flex: 1.6, | |||
| sortable: false, | |||
| renderCell: (params) => ( | |||
| <Stack spacing={0.5} sx={{ lineHeight: 1.5, py: 0.5 }}> | |||
| @@ -1222,12 +1232,20 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| > | |||
| {params.row.itemCode || "-"} {params.row.itemName || "-"} | |||
| </Typography> | |||
| <Box>{params.row.lotNo || "-"}</Box> | |||
| <Box> | |||
| <Typography | |||
| component="div" | |||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||
| > | |||
| {params.row.lotNo || "-"} | |||
| </Typography> | |||
| <Typography | |||
| component="div" | |||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||
| > | |||
| {params.row.expiryDate | |||
| ? dayjs(params.row.expiryDate).format(OUTPUT_DATE_FORMAT) | |||
| : "-"} | |||
| </Box> | |||
| </Typography> | |||
| </Stack> | |||
| ), | |||
| }, | |||
| @@ -1577,7 +1595,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleSaveApproverStockTake(detail)} | |||
| disabled={!isAdmin || saving} | |||
| disabled={!isAdmin || saving || isRoundClosed} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| @@ -1588,7 +1606,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| variant="outlined" | |||
| color="warning" | |||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | |||
| disabled={!isAdmin || updatingStatus || hasSecond} | |||
| disabled={!isAdmin || updatingStatus || hasSecond || isRoundClosed} | |||
| > | |||
| {t("Not Match")} | |||
| </Button> | |||
| @@ -1626,6 +1644,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| handleSaveApproverStockTake, | |||
| handleUpdateStatusToNotMatch, | |||
| formatRecordEndTime, | |||
| isRoundClosed, | |||
| ]); | |||
| const effectivePageSize = | |||
| pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); | |||
| @@ -1635,7 +1654,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleOpenBatchSaveConfirm} | |||
| disabled={!isAdmin || batchSaving} | |||
| disabled={!isAdmin || batchSaving || isRoundClosed} | |||
| > | |||
| {t("Batch Save All")} | |||
| </Button> | |||
| @@ -2133,24 +2152,48 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </Typography> | |||
| {varianceRangeBar} | |||
| </Stack> | |||
| <Box sx={{ width: "100%", height: 700 }}> | |||
| <StyledDataGrid | |||
| rows={sortedDetails} | |||
| columns={columns} | |||
| getRowId={(row) => row.id} | |||
| loading={loadingDetails} | |||
| disableRowSelectionOnClick | |||
| paginationMode="client" // ← important: client-side | |||
| pageSizeOptions={[50, 100, 200, 500]} | |||
| getRowHeight={() => "auto"} | |||
| sx={{ | |||
| backgroundColor: "transparent", | |||
| "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" }, | |||
| "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" }, | |||
| "& .MuiDataGrid-row": { minHeight: 96 }, | |||
| }} | |||
| /> | |||
| </Box> | |||
| <Stack spacing={2} sx={{ width: "100%" }}> | |||
| {loadingDetails && sortedDetails.length === 0 ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : sortedDetails.length === 0 ? ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | |||
| {t("No data")} | |||
| </Typography> | |||
| ) : ( | |||
| locationGroups.map((group) => ( | |||
| <Box key={group.key} sx={{ width: "100%" }}> | |||
| <Typography variant="subtitle1" fontWeight={700} sx={{ mb: 1, px: 0.5 }}> | |||
| {t("Warehouse Location")}: {group.label} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| width: "100%", | |||
| minHeight: 120, | |||
| height: Math.min(700, 72 + group.items.length * 110), | |||
| }} | |||
| > | |||
| <StyledDataGrid | |||
| rows={group.items} | |||
| columns={columns} | |||
| getRowId={(row) => row.id} | |||
| loading={loadingDetails} | |||
| disableRowSelectionOnClick | |||
| hideFooter | |||
| getRowHeight={() => "auto"} | |||
| sx={{ | |||
| backgroundColor: "transparent", | |||
| "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" }, | |||
| "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" }, | |||
| "& .MuiDataGrid-row": { minHeight: 96 }, | |||
| }} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| )) | |||
| )} | |||
| </Stack> | |||
| <Dialog | |||
| open={openBatchSaveConfirmDialog} | |||
| @@ -1958,11 +1958,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| /> | |||
| </LocalizationProvider> | |||
| </Box> | |||
| {createMode === "item" ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Create by item hint")} | |||
| </Typography> | |||
| ) : sortedFloorKeys.length === 0 && !sectionsLoading ? ( | |||
| {createMode === "item" ? null : sortedFloorKeys.length === 0 && !sectionsLoading ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No stock take sections from warehouse")} | |||
| </Typography> | |||
| @@ -2141,9 +2137,6 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| </Select> | |||
| </FormControl> | |||
| </Stack> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("Create by item hint")} | |||
| </Typography> | |||
| <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}> | |||
| {createDialogSelectedItems.map((it) => ( | |||
| <Chip | |||
| @@ -2167,22 +2160,19 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| }} | |||
| > | |||
| <Box sx={{ minHeight: 0, overflowY: "auto", border: 1, borderColor: "divider", borderRadius: 1, p: 1 }}> | |||
| <Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: "block" }}> | |||
| {t("Create by item search hint")} | |||
| </Typography> | |||
| {createDialogItemSearching ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | |||
| <CircularProgress size={24} /> | |||
| </Box> | |||
| ) : createDialogItemHits.length === 0 ? ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | |||
| {createDialogItemCode.trim() || | |||
| createDialogItemName.trim() || | |||
| (createDialogLotOrigin.trim() !== "" && | |||
| createDialogLotOrigin.trim().toLowerCase() !== "all") | |||
| ? t("No data") | |||
| : t("Create by item search hint")} | |||
| </Typography> | |||
| createDialogItemCode.trim() || | |||
| createDialogItemName.trim() || | |||
| (createDialogLotOrigin.trim() !== "" && | |||
| createDialogLotOrigin.trim().toLowerCase() !== "all") ? ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | |||
| {t("No data")} | |||
| </Typography> | |||
| ) : null | |||
| ) : ( | |||
| <Stack> | |||
| {createDialogItemHits.map((it) => { | |||
| @@ -2244,11 +2234,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | |||
| <CircularProgress size={24} /> | |||
| </Box> | |||
| ) : createDialogSelectedItems.length === 0 ? ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | |||
| {t("Create by item select item first")} | |||
| </Typography> | |||
| ) : createDialogLotOptions.length === 0 ? ( | |||
| ) : createDialogSelectedItems.length === 0 ? null : createDialogLotOptions.length === 0 ? ( | |||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | |||
| {t("No available lots for selected items")} | |||
| </Typography> | |||
| @@ -2671,9 +2657,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||
| <Typography variant="h6" fontWeight={700}> | |||
| {t("Warehouse missing stock take section warn title")} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Warehouse missing stock take section drawer hint")} | |||
| </Typography> | |||
| {missingSectionCount > 0 ? ( | |||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}> | |||
| {t("Warehouse missing stock take section showing", { | |||
| @@ -17,7 +17,7 @@ import { | |||
| TextField, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||
| import { useState, useCallback, useEffect, useRef, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| AllPickedStockTakeListReponse, | |||
| @@ -49,12 +49,15 @@ interface PickerReStockTakeProps { | |||
| selectedSession: AllPickedStockTakeListReponse; | |||
| onBack: () => void; | |||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||
| /** History / closed round — disable all save actions */ | |||
| roundReadOnly?: boolean; | |||
| } | |||
| const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| selectedSession, | |||
| onBack, | |||
| onSnackbar, | |||
| roundReadOnly = false, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| @@ -81,7 +84,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | |||
| const batchInFlightRef = useRef(false); | |||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | |||
| const isSessionCompleted = | |||
| roundReadOnly || selectedSession?.status?.toLowerCase() === "completed"; | |||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| @@ -204,7 +208,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }); | |||
| }; | |||
| const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | |||
| if (!selectedSession || !currentUserId) { | |||
| if (!selectedSession || !currentUserId || isSessionCompleted) { | |||
| return; | |||
| } | |||
| @@ -319,7 +323,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| if (roundReadOnly || selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| @@ -327,10 +331,10 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| return true; | |||
| } | |||
| return false; | |||
| }, [selectedSession?.status]); | |||
| }, [roundReadOnly, selectedSession?.status]); | |||
| const handleBatchTestAutoFill = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) { | |||
| return; | |||
| } | |||
| @@ -368,7 +372,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||
| const handleBatchSaveInputted = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) return; | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) return; | |||
| const built = buildPickerBatchSaveRequests( | |||
| inventoryLotDetails, | |||
| @@ -478,6 +482,31 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }; | |||
| }, []); | |||
| const locationGroups = useMemo(() => { | |||
| const groups: { key: string; label: string; items: InventoryLotDetailResponse[] }[] = []; | |||
| const indexByKey = new Map<string, number>(); | |||
| const itemMode = selectedSession.listMode === "item"; | |||
| for (const detail of inventoryLotDetails) { | |||
| const area = detail.warehouseArea?.trim() || ""; | |||
| const slot = detail.warehouseSlot?.trim() || ""; | |||
| const code = detail.warehouseCode?.trim() || ""; | |||
| const key = itemMode ? `${code}|${area}|${slot}` : `${area}|${slot}`; | |||
| const label = itemMode | |||
| ? [code, [area, slot].filter(Boolean).join(" ")].filter(Boolean).join(" · ") || "-" | |||
| : area || slot | |||
| ? `${area}${slot}` | |||
| : code || "-"; | |||
| let idx = indexByKey.get(key); | |||
| if (idx == null) { | |||
| idx = groups.length; | |||
| indexByKey.set(key, idx); | |||
| groups.push({ key, label, items: [] }); | |||
| } | |||
| groups[idx].items.push(detail); | |||
| } | |||
| return groups; | |||
| }, [inventoryLotDetails, selectedSession.listMode]); | |||
| const uniqueWarehouses = Array.from( | |||
| new Set( | |||
| inventoryLotDetails | |||
| @@ -523,9 +552,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} | |||
| </TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| @@ -537,14 +563,30 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <TableBody> | |||
| {inventoryLotDetails.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={7} align="center"> | |||
| <TableCell colSpan={5} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| locationGroups.flatMap((group) => [ | |||
| <TableRow key={`loc-${group.key}`}> | |||
| <TableCell | |||
| colSpan={5} | |||
| sx={{ | |||
| bgcolor: "grey.50", | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| py: 1.25, | |||
| }} | |||
| > | |||
| <Typography variant="subtitle1" fontWeight={700}> | |||
| {t("Warehouse Location")}: {group.label} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow>, | |||
| ...group.items.map((detail) => { | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| @@ -554,23 +596,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| return ( | |||
| <TableRow key={detail.id}> | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? ( | |||
| <Stack spacing={0.25}> | |||
| <Typography fontWeight={700}>{detail.warehouseCode || "-"}</Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {[detail.warehouseArea, detail.warehouseSlot].filter((v) => v?.trim()).join(" ") || "-"} | |||
| </Typography> | |||
| </Stack> | |||
| ) : ( | |||
| <> | |||
| {detail.warehouseArea || "-"} | |||
| {detail.warehouseSlot || "-"} | |||
| </> | |||
| )} | |||
| </TableCell> | |||
| <TableCell sx={{ | |||
| maxWidth: 280, | |||
| wordBreak: 'break-word', | |||
| whiteSpace: 'normal', | |||
| lineHeight: 1.5 | |||
| @@ -582,8 +608,18 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| > | |||
| {detail.itemCode || "-"} {detail.itemName || "-"} | |||
| </Typography> | |||
| <Box>{detail.lotNo || "-"}</Box> | |||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||
| <Typography | |||
| component="div" | |||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||
| > | |||
| {detail.lotNo || "-"} | |||
| </Typography> | |||
| <Typography | |||
| component="div" | |||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||
| > | |||
| {detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"} | |||
| </Typography> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| @@ -810,7 +846,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| }), | |||
| ]) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| @@ -22,7 +22,7 @@ import { | |||
| InputLabel, | |||
| } from "@mui/material"; | |||
| import { SelectChangeEvent } from "@mui/material/Select"; | |||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||
| import { useState, useCallback, useEffect, useRef, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| AllPickedStockTakeListReponse, | |||
| @@ -67,12 +67,15 @@ interface PickerStockTakeProps { | |||
| selectedSession: AllPickedStockTakeListReponse; | |||
| onBack: () => void; | |||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||
| /** History / closed round — disable all save actions */ | |||
| roundReadOnly?: boolean; | |||
| } | |||
| const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| selectedSession, | |||
| onBack, | |||
| onSnackbar, | |||
| roundReadOnly = false, | |||
| }) => { | |||
| const { t } = useTranslation(["stockTake", "common"]); | |||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | |||
| @@ -107,7 +110,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | |||
| const batchInFlightRef = useRef(false); | |||
| const saveInFlightRef = useRef(false); | |||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | |||
| const isSessionCompleted = | |||
| roundReadOnly || selectedSession?.status?.toLowerCase() === "completed"; | |||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| }, []); | |||
| @@ -215,6 +219,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }; | |||
| const canReviseFirstCount = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (roundReadOnly) return false; | |||
| const sessionStatus = selectedSession?.status?.toLowerCase() ?? ""; | |||
| if (sessionStatus === "completed") { | |||
| return false; | |||
| @@ -236,11 +241,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| return false; | |||
| } | |||
| return true; | |||
| }, [selectedSession?.status]); | |||
| }, [roundReadOnly, selectedSession?.status]); | |||
| const handleSaveStockTake = useCallback( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (!selectedSession || !currentUserId || saveInFlightRef.current) { | |||
| if (!selectedSession || !currentUserId || saveInFlightRef.current || isSessionCompleted) { | |||
| return; | |||
| } | |||
| @@ -361,6 +366,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| remark, | |||
| t, | |||
| currentUserId, | |||
| isSessionCompleted, | |||
| onSnackbar, | |||
| qtyGapWarnPercent, | |||
| canReviseFirstCount, | |||
| @@ -369,7 +375,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| /** 测试快捷键:按账面自动建记录(非用户输入批量) */ | |||
| const handleBatchTestAutoFill = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) { | |||
| return; | |||
| } | |||
| @@ -502,7 +508,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }; | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| if (roundReadOnly || selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| @@ -510,12 +516,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| return true; | |||
| } | |||
| return false; | |||
| }, [selectedSession?.status]); | |||
| }, [roundReadOnly, selectedSession?.status]); | |||
| editableQtyIdsRef.current = inventoryLotDetails | |||
| .filter((detail) => !isSubmitDisabled(detail) && (detail.firstStockTakeQty == null || detail.secondStockTakeQty == null)) | |||
| .map((detail) => detail.id); | |||
| const handleBatchSaveInputted = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) return; | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) return; | |||
| const built = buildPickerBatchSaveRequests( | |||
| inventoryLotDetails, | |||
| @@ -564,6 +570,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }, [ | |||
| selectedSession, | |||
| currentUserId, | |||
| isSessionCompleted, | |||
| inventoryLotDetails, | |||
| recordInputs, | |||
| isSubmitDisabled, | |||
| @@ -573,6 +580,31 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| pageSize, | |||
| loadDetails, | |||
| ]); | |||
| const locationGroups = useMemo(() => { | |||
| const groups: { key: string; label: string; items: InventoryLotDetailResponse[] }[] = []; | |||
| const indexByKey = new Map<string, number>(); | |||
| const itemMode = selectedSession.listMode === "item"; | |||
| for (const detail of inventoryLotDetails) { | |||
| const area = detail.warehouseArea?.trim() || ""; | |||
| const slot = detail.warehouseSlot?.trim() || ""; | |||
| const code = detail.warehouseCode?.trim() || ""; | |||
| const key = itemMode ? `${code}|${area}|${slot}` : `${area}|${slot}`; | |||
| const label = itemMode | |||
| ? [code, [area, slot].filter(Boolean).join(" ")].filter(Boolean).join(" · ") || "-" | |||
| : area || slot | |||
| ? `${area}${slot}` | |||
| : code || "-"; | |||
| let idx = indexByKey.get(key); | |||
| if (idx == null) { | |||
| idx = groups.length; | |||
| indexByKey.set(key, idx); | |||
| groups.push({ key, label, items: [] }); | |||
| } | |||
| groups[idx].items.push(detail); | |||
| } | |||
| return groups; | |||
| }, [inventoryLotDetails, selectedSession.listMode]); | |||
| const uniqueWarehouses = Array.from( | |||
| new Set( | |||
| inventoryLotDetails | |||
| @@ -651,9 +683,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} | |||
| </TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| @@ -666,18 +695,34 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TableBody> | |||
| {inventoryLotDetails.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={7} align="center"> | |||
| <TableCell colSpan={5} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| locationGroups.flatMap((group) => [ | |||
| <TableRow key={`loc-${group.key}`}> | |||
| <TableCell | |||
| colSpan={5} | |||
| sx={{ | |||
| bgcolor: "grey.50", | |||
| borderBottom: 1, | |||
| borderColor: "divider", | |||
| py: 1.25, | |||
| }} | |||
| > | |||
| <Typography variant="subtitle1" fontWeight={700}> | |||
| {t("Warehouse Location")}: {group.label} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow>, | |||
| ...group.items.map((detail) => { | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const revisingFirst = canReviseFirstCount(detail); | |||
| const editingFirst = revisingFirst && !!editingFirstIds[detail.id]; | |||
| const sessionOpen = selectedSession?.status?.toLowerCase() !== "completed"; | |||
| const sessionOpen = !isSessionCompleted; | |||
| const changeUsedUp = | |||
| sessionOpen && | |||
| detail.stockTakeRecordStatus?.toLowerCase() === "pass" && | |||
| @@ -710,24 +755,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ...(canEditQty ? { cursor: "pointer" } : {}), | |||
| }} | |||
| > | |||
| <TableCell> | |||
| {selectedSession.listMode === "item" ? ( | |||
| <Stack spacing={0.25}> | |||
| <Typography fontWeight={700}>{detail.warehouseCode || "-"}</Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {[detail.warehouseArea, detail.warehouseSlot].filter((v) => v?.trim()).join(" ") || "-"} | |||
| </Typography> | |||
| </Stack> | |||
| ) : ( | |||
| <> | |||
| {detail.warehouseArea || "-"} | |||
| {detail.warehouseSlot || "-"} | |||
| </> | |||
| )} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ | |||
| maxWidth: 280, | |||
| wordBreak: "break-word", | |||
| whiteSpace: "normal", | |||
| lineHeight: 1.5, | |||
| @@ -740,12 +769,20 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| > | |||
| {detail.itemCode || "-"} {detail.itemName || "-"} | |||
| </Typography> | |||
| <Box>{detail.lotNo || "-"}</Box> | |||
| <Box> | |||
| <Typography | |||
| component="div" | |||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||
| > | |||
| {detail.lotNo || "-"} | |||
| </Typography> | |||
| <Typography | |||
| component="div" | |||
| sx={{ fontWeight: 800, fontSize: "1.15rem", lineHeight: 1.3, color: "text.primary" }} | |||
| > | |||
| {detail.expiryDate | |||
| ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) | |||
| : "-"} | |||
| </Box> | |||
| </Typography> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| @@ -1040,7 +1077,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| }), | |||
| ]) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| @@ -348,9 +348,18 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||
| <Typography variant="subtitle1" fontWeight={600} noWrap> | |||
| {roundLabel(card)} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {floor ? `${modeLabel} · ${floor}` : modeLabel} | |||
| </Typography> | |||
| <Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25, flexWrap: "wrap" }}> | |||
| <Chip | |||
| size="small" | |||
| label={modeLabel} | |||
| color={isItemRound ? "info" : "primary"} | |||
| /> | |||
| {floor ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {floor} | |||
| </Typography> | |||
| ) : null} | |||
| </Stack> | |||
| </Box> | |||
| <Chip | |||
| size="small" | |||
| @@ -212,6 +212,8 @@ const StockTakeTab: React.FC = () => { | |||
| const showingPickerDetail = Boolean(selectedSession) && viewScope === "picker" && tabValue === 0; | |||
| const roundId = selectedRound?.stockTakeRoundId ?? 0; | |||
| /** History / closed round: disable picker & approver save actions */ | |||
| const roundReadOnly = Boolean(selectedRound && !selectedRound.open); | |||
| const roundLabel = (card: StockTakeRoundCard) => { | |||
| const name = card.stockTakeRoundName?.trim(); | |||
| const title = name || `${t("Stock Take Round")} #${card.stockTakeRoundId}`; | |||
| @@ -228,12 +230,14 @@ const StockTakeTab: React.FC = () => { | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| roundReadOnly={roundReadOnly} | |||
| /> | |||
| ) : ( | |||
| <PickerStockTake | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| roundReadOnly={roundReadOnly} | |||
| /> | |||
| ) | |||
| ) : null} | |||
| @@ -413,6 +417,7 @@ const StockTakeTab: React.FC = () => { | |||
| mode="pending" | |||
| onSnackbar={handleSnackbar} | |||
| batchButtonHost={tabValue === 1 ? batchButtonHost : null} | |||
| roundReadOnly={roundReadOnly} | |||
| /> | |||
| ) : ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| @@ -426,6 +431,7 @@ const StockTakeTab: React.FC = () => { | |||
| selectedSession={approverSession} | |||
| mode="approved" | |||
| onSnackbar={handleSnackbar} | |||
| roundReadOnly={roundReadOnly} | |||
| /> | |||
| ) : !approverLoading ? ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| @@ -65,8 +65,8 @@ | |||
| "Section create item overlap title": "These items are already in an open item stock-take round; this round will skip them", | |||
| "Section create item overlap line": "Item {{item}} is in round #{{round}}{{roundName}}; this round will not include it (section {{section}})", | |||
| "Control Time": "Time spent", | |||
| "Create by item": "By item", | |||
| "Create by section": "By section", | |||
| "Create by item": "Item stock-take", | |||
| "Create by section": "Location stock-take", | |||
| "Cannot create while both section and item rounds are open": "Cannot create a new round while both section and item rounds are open", | |||
| "Lot Origin": "Lot origin", | |||
| "Create by item hint": "Only items with available lots are listed. Pick origin (PP / PF / Other) to load the list, optionally narrow by code/name, then tick lots on the right.", | |||
| @@ -146,9 +146,9 @@ | |||
| "Select area first": "請先選擇倉位", | |||
| "Slot": "位置", | |||
| "Start Stock Take Date": "盤點日期", | |||
| "Create by item": "依貨品", | |||
| "Create by section": "依區域", | |||
| "Cannot create while both section and item rounds are open": "目前同時有開著的依區域與依貨品盤點輪,無法再建立新輪", | |||
| "Create by item": "貨品盤點", | |||
| "Create by section": "區域盤點", | |||
| "Cannot create while both section and item rounds are open": "目前同時有開著的區域盤點與貨品盤點輪,無法再建立新輪", | |||
| "Lot Origin": "來源", | |||
| "Create by item hint": "只列出有可用庫存的貨品。選來源(PP/PF/其他)即可列出;也可再輸入編號/名稱縮小。先勾選貨品,再於右側勾選批號/位置。", | |||
| "Create by item search hint": "可只選來源列出;或再輸入編號/名稱縮小。兩個關鍵字都填時要同時符合", | |||