| @@ -77,6 +77,8 @@ interface ApproverStockTakeAllProps { | |||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | ||||
| /** 審核待處理時,把批量保存按鈕掛到分頁列右側 */ | /** 審核待處理時,把批量保存按鈕掛到分頁列右側 */ | ||||
| batchButtonHost?: HTMLElement | null; | batchButtonHost?: HTMLElement | null; | ||||
| /** History / closed round — disable save / batch save */ | |||||
| roundReadOnly?: boolean; | |||||
| } | } | ||||
| type QtySelectionType = "first" | "second" | "approver"; | type QtySelectionType = "first" | "second" | "approver"; | ||||
| @@ -368,6 +370,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| onBack, | onBack, | ||||
| onSnackbar, | onSnackbar, | ||||
| batchButtonHost = null, | batchButtonHost = null, | ||||
| roundReadOnly = false, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | ||||
| @@ -375,6 +378,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const isAdmin = (session?.abilities ?? session?.user?.abilities ?? []).some( | const isAdmin = (session?.abilities ?? session?.user?.abilities ?? []).some( | ||||
| (ability) => String(ability).trim() === AUTH.ADMIN, | (ability) => String(ability).trim() === AUTH.ADMIN, | ||||
| ); | ); | ||||
| const isRoundClosed = | |||||
| roundReadOnly || selectedSession?.status?.toLowerCase() === "completed"; | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | const [loadingDetails, setLoadingDetails] = useState(false); | ||||
| @@ -896,7 +901,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const handleSaveApproverStockTake = useCallback( | const handleSaveApproverStockTake = useCallback( | ||||
| async (detail: InventoryLotDetailResponse) => { | async (detail: InventoryLotDetailResponse) => { | ||||
| if (mode === "approved") return; | |||||
| if (mode === "approved" || isRoundClosed) return; | |||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -975,7 +980,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const handleUpdateStatusToNotMatch = useCallback( | const handleUpdateStatusToNotMatch = useCallback( | ||||
| async (detail: InventoryLotDetailResponse) => { | async (detail: InventoryLotDetailResponse) => { | ||||
| if (mode === "approved") return; | |||||
| if (mode === "approved" || isRoundClosed) return; | |||||
| if (!detail.stockTakeRecordId) { | if (!detail.stockTakeRecordId) { | ||||
| onSnackbar(t("Stock take record ID is required"), "error"); | onSnackbar(t("Stock take record ID is required"), "error"); | ||||
| return; | return; | ||||
| @@ -1056,7 +1061,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| }, [sortedDetails]); | }, [sortedDetails]); | ||||
| const handleOpenBatchSaveConfirm = useCallback(() => { | const handleOpenBatchSaveConfirm = useCallback(() => { | ||||
| if (mode === "approved") return; | |||||
| if (mode === "approved" || isRoundClosed) return; | |||||
| if (!selectedSession || !currentUserId) return; | if (!selectedSession || !currentUserId) return; | ||||
| if (inventoryLotDetails.length === 0) { | if (inventoryLotDetails.length === 0) { | ||||
| onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); | 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 () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (batchSaveInFlightRef.current) return; | if (batchSaveInFlightRef.current) return; | ||||
| if (mode === "approved") return; | |||||
| if (mode === "approved" || isRoundClosed) return; | |||||
| if (!selectedSession || !currentUserId) return; | if (!selectedSession || !currentUserId) return; | ||||
| const records = buildBatchApproverSaveRequests( | const records = buildBatchApproverSaveRequests( | ||||
| @@ -1169,7 +1174,30 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| if (!ms) return "-"; | if (!ms) return "-"; | ||||
| return dayjs(ms).format("YYYY-MM-DD HH:mm"); | 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 columns = useMemo<GridColDef<InventoryLotDetailResponse>[]>(() => { | ||||
| const cols: GridColDef<InventoryLotDetailResponse>[] = []; | const cols: GridColDef<InventoryLotDetailResponse>[] = []; | ||||
| @@ -1190,29 +1218,11 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| } | } | ||||
| cols.push( | 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", | field: "itemLot", | ||||
| headerName: t("Item-lotNo-ExpiryDate"), | headerName: t("Item-lotNo-ExpiryDate"), | ||||
| minWidth: 160, | |||||
| flex: 1, | |||||
| minWidth: 220, | |||||
| flex: 1.6, | |||||
| sortable: false, | sortable: false, | ||||
| renderCell: (params) => ( | renderCell: (params) => ( | ||||
| <Stack spacing={0.5} sx={{ lineHeight: 1.5, py: 0.5 }}> | <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 || "-"} | {params.row.itemCode || "-"} {params.row.itemName || "-"} | ||||
| </Typography> | </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 | {params.row.expiryDate | ||||
| ? dayjs(params.row.expiryDate).format(OUTPUT_DATE_FORMAT) | ? dayjs(params.row.expiryDate).format(OUTPUT_DATE_FORMAT) | ||||
| : "-"} | : "-"} | ||||
| </Box> | |||||
| </Typography> | |||||
| </Stack> | </Stack> | ||||
| ), | ), | ||||
| }, | }, | ||||
| @@ -1577,7 +1595,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| size="small" | size="small" | ||||
| variant="contained" | variant="contained" | ||||
| onClick={() => handleSaveApproverStockTake(detail)} | onClick={() => handleSaveApproverStockTake(detail)} | ||||
| disabled={!isAdmin || saving} | |||||
| disabled={!isAdmin || saving || isRoundClosed} | |||||
| > | > | ||||
| {t("Save")} | {t("Save")} | ||||
| </Button> | </Button> | ||||
| @@ -1588,7 +1606,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| variant="outlined" | variant="outlined" | ||||
| color="warning" | color="warning" | ||||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | onClick={() => handleUpdateStatusToNotMatch(detail)} | ||||
| disabled={!isAdmin || updatingStatus || hasSecond} | |||||
| disabled={!isAdmin || updatingStatus || hasSecond || isRoundClosed} | |||||
| > | > | ||||
| {t("Not Match")} | {t("Not Match")} | ||||
| </Button> | </Button> | ||||
| @@ -1626,6 +1644,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| handleSaveApproverStockTake, | handleSaveApproverStockTake, | ||||
| handleUpdateStatusToNotMatch, | handleUpdateStatusToNotMatch, | ||||
| formatRecordEndTime, | formatRecordEndTime, | ||||
| isRoundClosed, | |||||
| ]); | ]); | ||||
| const effectivePageSize = | const effectivePageSize = | ||||
| pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); | pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); | ||||
| @@ -1635,7 +1654,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={handleOpenBatchSaveConfirm} | onClick={handleOpenBatchSaveConfirm} | ||||
| disabled={!isAdmin || batchSaving} | |||||
| disabled={!isAdmin || batchSaving || isRoundClosed} | |||||
| > | > | ||||
| {t("Batch Save All")} | {t("Batch Save All")} | ||||
| </Button> | </Button> | ||||
| @@ -2133,24 +2152,48 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| {varianceRangeBar} | {varianceRangeBar} | ||||
| </Stack> | </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 | <Dialog | ||||
| open={openBatchSaveConfirmDialog} | open={openBatchSaveConfirmDialog} | ||||
| @@ -1958,11 +1958,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| /> | /> | ||||
| </LocalizationProvider> | </LocalizationProvider> | ||||
| </Box> | </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"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No stock take sections from warehouse")} | {t("No stock take sections from warehouse")} | ||||
| </Typography> | </Typography> | ||||
| @@ -2141,9 +2137,6 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| </Select> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("Create by item hint")} | |||||
| </Typography> | |||||
| <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}> | <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75 }}> | ||||
| {createDialogSelectedItems.map((it) => ( | {createDialogSelectedItems.map((it) => ( | ||||
| <Chip | <Chip | ||||
| @@ -2167,22 +2160,19 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| }} | }} | ||||
| > | > | ||||
| <Box sx={{ minHeight: 0, overflowY: "auto", border: 1, borderColor: "divider", borderRadius: 1, p: 1 }}> | <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 ? ( | {createDialogItemSearching ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | ||||
| <CircularProgress size={24} /> | <CircularProgress size={24} /> | ||||
| </Box> | </Box> | ||||
| ) : createDialogItemHits.length === 0 ? ( | ) : 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> | <Stack> | ||||
| {createDialogItemHits.map((it) => { | {createDialogItemHits.map((it) => { | ||||
| @@ -2244,11 +2234,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | ||||
| <CircularProgress size={24} /> | <CircularProgress size={24} /> | ||||
| </Box> | </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 }}> | <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | ||||
| {t("No available lots for selected items")} | {t("No available lots for selected items")} | ||||
| </Typography> | </Typography> | ||||
| @@ -2671,9 +2657,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| <Typography variant="h6" fontWeight={700}> | <Typography variant="h6" fontWeight={700}> | ||||
| {t("Warehouse missing stock take section warn title")} | {t("Warehouse missing stock take section warn title")} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||||
| {t("Warehouse missing stock take section drawer hint")} | |||||
| </Typography> | |||||
| {missingSectionCount > 0 ? ( | {missingSectionCount > 0 ? ( | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}> | ||||
| {t("Warehouse missing stock take section showing", { | {t("Warehouse missing stock take section showing", { | ||||
| @@ -17,7 +17,7 @@ import { | |||||
| TextField, | TextField, | ||||
| TablePagination, | TablePagination, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useState, useCallback, useEffect, useRef, useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| @@ -49,12 +49,15 @@ interface PickerReStockTakeProps { | |||||
| selectedSession: AllPickedStockTakeListReponse; | selectedSession: AllPickedStockTakeListReponse; | ||||
| onBack: () => void; | onBack: () => void; | ||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | ||||
| /** History / closed round — disable all save actions */ | |||||
| roundReadOnly?: boolean; | |||||
| } | } | ||||
| const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | ||||
| selectedSession, | selectedSession, | ||||
| onBack, | onBack, | ||||
| onSnackbar, | onSnackbar, | ||||
| roundReadOnly = false, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | ||||
| @@ -81,7 +84,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | const handleBatchTestAllRef = useRef<() => Promise<void>>(); | ||||
| const batchInFlightRef = useRef(false); | const batchInFlightRef = useRef(false); | ||||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | |||||
| const isSessionCompleted = | |||||
| roundReadOnly || selectedSession?.status?.toLowerCase() === "completed"; | |||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| @@ -204,7 +208,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }); | }); | ||||
| }; | }; | ||||
| const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | |||||
| if (!selectedSession || !currentUserId || isSessionCompleted) { | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -319,7 +323,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); | }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||||
| if (roundReadOnly || selectedSession?.status?.toLowerCase() === "completed") { | |||||
| return true; | return true; | ||||
| } | } | ||||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | ||||
| @@ -327,10 +331,10 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| return true; | return true; | ||||
| } | } | ||||
| return false; | return false; | ||||
| }, [selectedSession?.status]); | |||||
| }, [roundReadOnly, selectedSession?.status]); | |||||
| const handleBatchTestAutoFill = useCallback(async () => { | const handleBatchTestAutoFill = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) { | |||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) { | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -368,7 +372,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | ||||
| const handleBatchSaveInputted = useCallback(async () => { | const handleBatchSaveInputted = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) return; | |||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) return; | |||||
| const built = buildPickerBatchSaveRequests( | const built = buildPickerBatchSaveRequests( | ||||
| inventoryLotDetails, | 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( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| inventoryLotDetails | inventoryLotDetails | ||||
| @@ -523,9 +552,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} | |||||
| </TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | <TableCell>{t("UOM")}</TableCell> | ||||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</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> | <TableBody> | ||||
| {inventoryLotDetails.length === 0 ? ( | {inventoryLotDetails.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={7} align="center"> | |||||
| <TableCell colSpan={5} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data")} | {t("No data")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </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 submitDisabled = isSubmitDisabled(detail); | ||||
| const isFirstSubmit = detail.firstStockTakeQty == null; | const isFirstSubmit = detail.firstStockTakeQty == null; | ||||
| const isSecondSubmit = | const isSecondSubmit = | ||||
| @@ -554,23 +596,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| return ( | return ( | ||||
| <TableRow key={detail.id}> | <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={{ | <TableCell sx={{ | ||||
| maxWidth: 280, | |||||
| wordBreak: 'break-word', | wordBreak: 'break-word', | ||||
| whiteSpace: 'normal', | whiteSpace: 'normal', | ||||
| lineHeight: 1.5 | lineHeight: 1.5 | ||||
| @@ -582,8 +608,18 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| > | > | ||||
| {detail.itemCode || "-"} {detail.itemName || "-"} | {detail.itemCode || "-"} {detail.itemName || "-"} | ||||
| </Typography> | </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> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | <TableCell>{detail.uom || "-"}</TableCell> | ||||
| @@ -810,7 +846,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| }) | |||||
| }), | |||||
| ]) | |||||
| )} | )} | ||||
| </TableBody> | </TableBody> | ||||
| </Table> | </Table> | ||||
| @@ -22,7 +22,7 @@ import { | |||||
| InputLabel, | InputLabel, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { SelectChangeEvent } from "@mui/material/Select"; | 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 { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| @@ -67,12 +67,15 @@ interface PickerStockTakeProps { | |||||
| selectedSession: AllPickedStockTakeListReponse; | selectedSession: AllPickedStockTakeListReponse; | ||||
| onBack: () => void; | onBack: () => void; | ||||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | ||||
| /** History / closed round — disable all save actions */ | |||||
| roundReadOnly?: boolean; | |||||
| } | } | ||||
| const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | ||||
| selectedSession, | selectedSession, | ||||
| onBack, | onBack, | ||||
| onSnackbar, | onSnackbar, | ||||
| roundReadOnly = false, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(["stockTake", "common"]); | const { t } = useTranslation(["stockTake", "common"]); | ||||
| const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); | ||||
| @@ -107,7 +110,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | const handleBatchTestAllRef = useRef<() => Promise<void>>(); | ||||
| const batchInFlightRef = useRef(false); | const batchInFlightRef = useRef(false); | ||||
| const saveInFlightRef = 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) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| }, []); | }, []); | ||||
| @@ -215,6 +219,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }; | }; | ||||
| const canReviseFirstCount = useCallback((detail: InventoryLotDetailResponse): boolean => { | const canReviseFirstCount = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (roundReadOnly) return false; | |||||
| const sessionStatus = selectedSession?.status?.toLowerCase() ?? ""; | const sessionStatus = selectedSession?.status?.toLowerCase() ?? ""; | ||||
| if (sessionStatus === "completed") { | if (sessionStatus === "completed") { | ||||
| return false; | return false; | ||||
| @@ -236,11 +241,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| return false; | return false; | ||||
| } | } | ||||
| return true; | return true; | ||||
| }, [selectedSession?.status]); | |||||
| }, [roundReadOnly, selectedSession?.status]); | |||||
| const handleSaveStockTake = useCallback( | const handleSaveStockTake = useCallback( | ||||
| async (detail: InventoryLotDetailResponse) => { | async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId || saveInFlightRef.current) { | |||||
| if (!selectedSession || !currentUserId || saveInFlightRef.current || isSessionCompleted) { | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -361,6 +366,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| remark, | remark, | ||||
| t, | t, | ||||
| currentUserId, | currentUserId, | ||||
| isSessionCompleted, | |||||
| onSnackbar, | onSnackbar, | ||||
| qtyGapWarnPercent, | qtyGapWarnPercent, | ||||
| canReviseFirstCount, | canReviseFirstCount, | ||||
| @@ -369,7 +375,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| /** 测试快捷键:按账面自动建记录(非用户输入批量) */ | /** 测试快捷键:按账面自动建记录(非用户输入批量) */ | ||||
| const handleBatchTestAutoFill = useCallback(async () => { | const handleBatchTestAutoFill = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) { | |||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) { | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -502,7 +508,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }; | }; | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | ||||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||||
| if (roundReadOnly || selectedSession?.status?.toLowerCase() === "completed") { | |||||
| return true; | return true; | ||||
| } | } | ||||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | ||||
| @@ -510,12 +516,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| return true; | return true; | ||||
| } | } | ||||
| return false; | return false; | ||||
| }, [selectedSession?.status]); | |||||
| }, [roundReadOnly, selectedSession?.status]); | |||||
| editableQtyIdsRef.current = inventoryLotDetails | editableQtyIdsRef.current = inventoryLotDetails | ||||
| .filter((detail) => !isSubmitDisabled(detail) && (detail.firstStockTakeQty == null || detail.secondStockTakeQty == null)) | .filter((detail) => !isSubmitDisabled(detail) && (detail.firstStockTakeQty == null || detail.secondStockTakeQty == null)) | ||||
| .map((detail) => detail.id); | .map((detail) => detail.id); | ||||
| const handleBatchSaveInputted = useCallback(async () => { | const handleBatchSaveInputted = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) return; | |||||
| if (!selectedSession || !currentUserId || batchInFlightRef.current || isSessionCompleted) return; | |||||
| const built = buildPickerBatchSaveRequests( | const built = buildPickerBatchSaveRequests( | ||||
| inventoryLotDetails, | inventoryLotDetails, | ||||
| @@ -564,6 +570,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }, [ | }, [ | ||||
| selectedSession, | selectedSession, | ||||
| currentUserId, | currentUserId, | ||||
| isSessionCompleted, | |||||
| inventoryLotDetails, | inventoryLotDetails, | ||||
| recordInputs, | recordInputs, | ||||
| isSubmitDisabled, | isSubmitDisabled, | ||||
| @@ -573,6 +580,31 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| pageSize, | pageSize, | ||||
| loadDetails, | 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( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| inventoryLotDetails | inventoryLotDetails | ||||
| @@ -651,9 +683,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| {selectedSession.listMode === "item" ? t("Warehouse code") : t("Warehouse Location")} | |||||
| </TableCell> | |||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | <TableCell>{t("UOM")}</TableCell> | ||||
| <TableCell sx={{ width: 250, minWidth: 250 }}>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</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> | <TableBody> | ||||
| {inventoryLotDetails.length === 0 ? ( | {inventoryLotDetails.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={7} align="center"> | |||||
| <TableCell colSpan={5} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data")} | {t("No data")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </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 submitDisabled = isSubmitDisabled(detail); | ||||
| const revisingFirst = canReviseFirstCount(detail); | const revisingFirst = canReviseFirstCount(detail); | ||||
| const editingFirst = revisingFirst && !!editingFirstIds[detail.id]; | const editingFirst = revisingFirst && !!editingFirstIds[detail.id]; | ||||
| const sessionOpen = selectedSession?.status?.toLowerCase() !== "completed"; | |||||
| const sessionOpen = !isSessionCompleted; | |||||
| const changeUsedUp = | const changeUsedUp = | ||||
| sessionOpen && | sessionOpen && | ||||
| detail.stockTakeRecordStatus?.toLowerCase() === "pass" && | detail.stockTakeRecordStatus?.toLowerCase() === "pass" && | ||||
| @@ -710,24 +755,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ...(canEditQty ? { cursor: "pointer" } : {}), | ...(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 | <TableCell | ||||
| sx={{ | sx={{ | ||||
| maxWidth: 280, | |||||
| wordBreak: "break-word", | wordBreak: "break-word", | ||||
| whiteSpace: "normal", | whiteSpace: "normal", | ||||
| lineHeight: 1.5, | lineHeight: 1.5, | ||||
| @@ -740,12 +769,20 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| > | > | ||||
| {detail.itemCode || "-"} {detail.itemName || "-"} | {detail.itemCode || "-"} {detail.itemName || "-"} | ||||
| </Typography> | </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 | {detail.expiryDate | ||||
| ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) | ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) | ||||
| : "-"} | : "-"} | ||||
| </Box> | |||||
| </Typography> | |||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | <TableCell>{detail.uom || "-"}</TableCell> | ||||
| @@ -1040,7 +1077,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| }) | |||||
| }), | |||||
| ]) | |||||
| )} | )} | ||||
| </TableBody> | </TableBody> | ||||
| </Table> | </Table> | ||||
| @@ -348,9 +348,18 @@ const StockTakeRoundCardList: React.FC<StockTakeRoundCardListProps> = ({ | |||||
| <Typography variant="subtitle1" fontWeight={600} noWrap> | <Typography variant="subtitle1" fontWeight={600} noWrap> | ||||
| {roundLabel(card)} | {roundLabel(card)} | ||||
| </Typography> | </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> | </Box> | ||||
| <Chip | <Chip | ||||
| size="small" | size="small" | ||||
| @@ -212,6 +212,8 @@ const StockTakeTab: React.FC = () => { | |||||
| const showingPickerDetail = Boolean(selectedSession) && viewScope === "picker" && tabValue === 0; | const showingPickerDetail = Boolean(selectedSession) && viewScope === "picker" && tabValue === 0; | ||||
| const roundId = selectedRound?.stockTakeRoundId ?? 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 roundLabel = (card: StockTakeRoundCard) => { | ||||
| const name = card.stockTakeRoundName?.trim(); | const name = card.stockTakeRoundName?.trim(); | ||||
| const title = name || `${t("Stock Take Round")} #${card.stockTakeRoundId}`; | const title = name || `${t("Stock Take Round")} #${card.stockTakeRoundId}`; | ||||
| @@ -228,12 +230,14 @@ const StockTakeTab: React.FC = () => { | |||||
| selectedSession={selectedSession} | selectedSession={selectedSession} | ||||
| onBack={handleBackToList} | onBack={handleBackToList} | ||||
| onSnackbar={handleSnackbar} | onSnackbar={handleSnackbar} | ||||
| roundReadOnly={roundReadOnly} | |||||
| /> | /> | ||||
| ) : ( | ) : ( | ||||
| <PickerStockTake | <PickerStockTake | ||||
| selectedSession={selectedSession} | selectedSession={selectedSession} | ||||
| onBack={handleBackToList} | onBack={handleBackToList} | ||||
| onSnackbar={handleSnackbar} | onSnackbar={handleSnackbar} | ||||
| roundReadOnly={roundReadOnly} | |||||
| /> | /> | ||||
| ) | ) | ||||
| ) : null} | ) : null} | ||||
| @@ -413,6 +417,7 @@ const StockTakeTab: React.FC = () => { | |||||
| mode="pending" | mode="pending" | ||||
| onSnackbar={handleSnackbar} | onSnackbar={handleSnackbar} | ||||
| batchButtonHost={tabValue === 1 ? batchButtonHost : null} | batchButtonHost={tabValue === 1 ? batchButtonHost : null} | ||||
| roundReadOnly={roundReadOnly} | |||||
| /> | /> | ||||
| ) : ( | ) : ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| @@ -426,6 +431,7 @@ const StockTakeTab: React.FC = () => { | |||||
| selectedSession={approverSession} | selectedSession={approverSession} | ||||
| mode="approved" | mode="approved" | ||||
| onSnackbar={handleSnackbar} | onSnackbar={handleSnackbar} | ||||
| roundReadOnly={roundReadOnly} | |||||
| /> | /> | ||||
| ) : !approverLoading ? ( | ) : !approverLoading ? ( | ||||
| <Typography variant="body2" color="text.secondary"> | <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 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}})", | "Section create item overlap line": "Item {{item}} is in round #{{round}}{{roundName}}; this round will not include it (section {{section}})", | ||||
| "Control Time": "Time spent", | "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", | "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", | "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.", | "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": "請先選擇倉位", | "Select area first": "請先選擇倉位", | ||||
| "Slot": "位置", | "Slot": "位置", | ||||
| "Start Stock Take Date": "盤點日期", | "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": "來源", | "Lot Origin": "來源", | ||||
| "Create by item hint": "只列出有可用庫存的貨品。選來源(PP/PF/其他)即可列出;也可再輸入編號/名稱縮小。先勾選貨品,再於右側勾選批號/位置。", | "Create by item hint": "只列出有可用庫存的貨品。選來源(PP/PF/其他)即可列出;也可再輸入編號/名稱縮小。先勾選貨品,再於右側勾選批號/位置。", | ||||
| "Create by item search hint": "可只選來源列出;或再輸入編號/名稱縮小。兩個關鍵字都填時要同時符合", | "Create by item search hint": "可只選來源列出;或再輸入編號/名稱縮小。兩個關鍵字都填時要同時符合", | ||||