Переглянути джерело

ui update

stockTakeUpdate
CANCERYS\kw093 1 тиждень тому
джерело
коміт
63abd5b22c
8 змінених файлів з 266 додано та 149 видалено
  1. +92
    -49
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  2. +11
    -27
      src/components/StockTakeManagement/PickerCardList.tsx
  3. +68
    -31
      src/components/StockTakeManagement/PickerReStockTake.tsx
  4. +72
    -34
      src/components/StockTakeManagement/PickerStockTake.tsx
  5. +12
    -3
      src/components/StockTakeManagement/StockTakeRoundCardList.tsx
  6. +6
    -0
      src/components/StockTakeManagement/StockTakeTab.tsx
  7. +2
    -2
      src/i18n/en/stockTake.json
  8. +3
    -3
      src/i18n/zh/stockTake.json

+ 92
- 49
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Переглянути файл

@@ -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}


+ 11
- 27
src/components/StockTakeManagement/PickerCardList.tsx Переглянути файл

@@ -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", {


+ 68
- 31
src/components/StockTakeManagement/PickerReStockTake.tsx Переглянути файл

@@ -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>


+ 72
- 34
src/components/StockTakeManagement/PickerStockTake.tsx Переглянути файл

@@ -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>


+ 12
- 3
src/components/StockTakeManagement/StockTakeRoundCardList.tsx Переглянути файл

@@ -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"


+ 6
- 0
src/components/StockTakeManagement/StockTakeTab.tsx Переглянути файл

@@ -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">


+ 2
- 2
src/i18n/en/stockTake.json Переглянути файл

@@ -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.",


+ 3
- 3
src/i18n/zh/stockTake.json Переглянути файл

@@ -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": "可只選來源列出;或再輸入編號/名稱縮小。兩個關鍵字都填時要同時符合",


Завантаження…
Відмінити
Зберегти