|
- "use client";
-
- import {
- Box,
- Button,
- Stack,
- Typography,
- Chip,
- CircularProgress,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- Paper,
- Checkbox,
- TextField,
- FormControlLabel,
- Radio,
- TablePagination,
- ToggleButton
- } from "@mui/material";
- import { useState, useCallback, useEffect, useRef, useMemo } from "react";
- import { useTranslation } from "react-i18next";
- import {
- AllPickedStockTakeListReponse,
- getInventoryLotDetailsBySection,
- InventoryLotDetailResponse,
- saveApproverStockTakeRecord,
- SaveApproverStockTakeRecordRequest,
- BatchSaveApproverStockTakeRecordRequest,
- batchSaveApproverStockTakeRecords,
- updateStockTakeRecordStatusToNotMatch,
- } from "@/app/api/stockTake/actions";
- import { useSession } from "next-auth/react";
- import { SessionWithTokens } from "@/config/authConfig";
- import dayjs from "dayjs";
- import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
-
- interface ApproverStockTakeProps {
- selectedSession: AllPickedStockTakeListReponse;
- onBack: () => void;
- onSnackbar: (message: string, severity: "success" | "error" | "warning") => void;
- }
-
- type QtySelectionType = "first" | "second" | "approver";
-
- const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
- selectedSession,
- onBack,
- onSnackbar,
- }) => {
- const { t } = useTranslation(["inventory", "common"]);
- const { data: session } = useSession() as { data: SessionWithTokens | null };
-
- const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
- const [loadingDetails, setLoadingDetails] = useState(false);
- const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false);
-
- // 每个记录的选择状态,key 为 detail.id
- const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
- const [approverQty, setApproverQty] = useState<Record<number, string>>({});
- const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({});
- const [saving, setSaving] = useState(false);
- const [batchSaving, setBatchSaving] = useState(false);
- const [updatingStatus, setUpdatingStatus] = useState(false);
- const [page, setPage] = useState(0);
- const [pageSize, setPageSize] = useState<number | string>("all");
- const [total, setTotal] = useState(0);
-
- const currentUserId = session?.id ? parseInt(session.id) : undefined;
- const handleBatchSubmitAllRef = useRef<() => Promise<void>>();
-
- const handleChangePage = useCallback((event: unknown, newPage: number) => {
- setPage(newPage);
- }, []);
-
- const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
- const newSize = parseInt(event.target.value, 10);
- if (newSize === -1) {
- setPageSize("all");
- } else if (!isNaN(newSize)) {
- setPageSize(newSize);
- }
- setPage(0);
- }, []);
-
- const loadDetails = useCallback(async (pageNum: number, size: number | string) => {
- setLoadingDetails(true);
- try {
- let actualSize: number;
- if (size === "all") {
- if (selectedSession.totalInventoryLotNumber > 0) {
- actualSize = selectedSession.totalInventoryLotNumber;
- } else if (total > 0) {
- actualSize = total;
- } else {
- actualSize = 10000;
- }
- } else {
- actualSize = typeof size === 'string' ? parseInt(size, 10) : size;
- }
-
- const response = await getInventoryLotDetailsBySection(
- selectedSession.stockTakeSession,
- selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null,
- pageNum,
- actualSize
- );
- setInventoryLotDetails(Array.isArray(response.records) ? response.records : []);
- setTotal(response.total || 0);
- } catch (e) {
- console.error(e);
- setInventoryLotDetails([]);
- setTotal(0);
- } finally {
- setLoadingDetails(false);
- }
- }, [selectedSession, total]);
-
- useEffect(() => {
- loadDetails(page, pageSize);
- }, [page, pageSize, loadDetails]);
- const calculateDifference = useCallback((detail: InventoryLotDetailResponse, selection: QtySelectionType): number => {
- let selectedQty = 0;
-
- if (selection === "first") {
- selectedQty = detail.firstStockTakeQty || 0;
- } else if (selection === "second") {
- selectedQty = detail.secondStockTakeQty || 0;
- } else if (selection === "approver") {
- selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0;
- }
-
- const bookQty = detail.availableQty || 0;
- return selectedQty - bookQty;
- }, [approverQty, approverBadQty]);
-
- // 3. 修改默认选择逻辑(在 loadDetails 的 useEffect 中,或创建一个新的 useEffect)
- useEffect(() => {
- // 初始化默认选择:如果 second 存在则选择 second,否则选择 first
- const newSelections: Record<number, QtySelectionType> = {};
- inventoryLotDetails.forEach(detail => {
- if (!qtySelection[detail.id]) {
- // 如果 second 不为 null 且大于 0,默认选择 second,否则选择 first
- if (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0) {
- newSelections[detail.id] = "second";
- } else {
- newSelections[detail.id] = "first";
- }
- }
- });
-
- if (Object.keys(newSelections).length > 0) {
- setQtySelection(prev => ({ ...prev, ...newSelections }));
- }
- }, [inventoryLotDetails]);
-
- // 4. 添加过滤逻辑(在渲染表格之前)
- const filteredDetails = useMemo(() => {
- if (!showOnlyWithDifference) {
- return inventoryLotDetails;
- }
-
- return inventoryLotDetails.filter(detail => {
- const selection = qtySelection[detail.id] || (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first");
- const difference = calculateDifference(detail, selection);
- return difference !== 0;
- });
- }, [inventoryLotDetails, showOnlyWithDifference, qtySelection, calculateDifference]);
-
- const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => {
- if (!selectedSession || !currentUserId) {
- return;
- }
-
- const selection = qtySelection[detail.id] || "first";
- let finalQty: number;
- let finalBadQty: number;
-
- if (selection === "first") {
- if (detail.firstStockTakeQty == null) {
- onSnackbar(t("First QTY is not available"), "error");
- return;
- }
- finalQty = detail.firstStockTakeQty;
- finalBadQty = detail.firstBadQty || 0;
- } else if (selection === "second") {
- if (detail.secondStockTakeQty == null) {
- onSnackbar(t("Second QTY is not available"), "error");
- return;
- }
-
- finalQty = detail.secondStockTakeQty;
- finalBadQty = detail.secondBadQty || 0;
- } else {
- // Approver input
- const approverQtyValue = approverQty[detail.id];
- const approverBadQtyValue = approverBadQty[detail.id];
-
-
- if (approverQtyValue === undefined || approverQtyValue === null || approverQtyValue === "") {
- onSnackbar(t("Please enter Approver QTY"), "error");
- return;
- }
- if (approverBadQtyValue === undefined || approverBadQtyValue === null || approverBadQtyValue === "") {
- onSnackbar(t("Please enter Approver Bad QTY"), "error");
- return;
- }
-
- finalQty = parseFloat(approverQtyValue) || 0;
- finalBadQty = parseFloat(approverBadQtyValue) || 0;
- }
-
- setSaving(true);
- try {
- const request: SaveApproverStockTakeRecordRequest = {
- stockTakeRecordId: detail.stockTakeRecordId || null,
- qty: finalQty,
- badQty: finalBadQty,
- approverId: currentUserId,
- approverQty: selection === "approver" ? finalQty : null,
- approverBadQty: selection === "approver" ? finalBadQty : null,
- };
-
- await saveApproverStockTakeRecord(
- request,
- selectedSession.stockTakeId
- );
-
- onSnackbar(t("Approver stock take record saved successfully"), "success");
-
- await loadDetails(page, pageSize);
- } catch (e: any) {
- console.error("Save approver stock take record error:", e);
- let errorMessage = t("Failed to save approver stock take record");
-
- if (e?.message) {
- errorMessage = e.message;
- } else if (e?.response) {
- try {
- const errorData = await e.response.json();
- errorMessage = errorData.message || errorData.error || errorMessage;
- } catch {
- // ignore
- }
- }
-
- onSnackbar(errorMessage, "error");
- } finally {
- setSaving(false);
- }
- }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);
-
- const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => {
- if (!detail.stockTakeRecordId) {
- onSnackbar(t("Stock take record ID is required"), "error");
- return;
- }
-
- setUpdatingStatus(true);
- try {
- await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);
-
- onSnackbar(t("Stock take record status updated to not match"), "success");
- } catch (e: any) {
- console.error("Update stock take record status error:", e);
- let errorMessage = t("Failed to update stock take record status");
-
- if (e?.message) {
- errorMessage = e.message;
- } else if (e?.response) {
- try {
- const errorData = await e.response.json();
- errorMessage = errorData.message || errorData.error || errorMessage;
- } catch {
- // ignore
- }
- }
-
- onSnackbar(errorMessage, "error");
- } finally {
- setUpdatingStatus(false);
- // Reload after status update - the useEffect will handle it with current page/pageSize
- // Or explicitly reload:
- setPage((currentPage) => {
- setPageSize((currentPageSize) => {
- setTimeout(() => {
- loadDetails(currentPage, currentPageSize);
- }, 0);
- return currentPageSize;
- });
- return currentPage;
- });
- }
- }, [selectedSession, t, onSnackbar, loadDetails]);
-
- const handleBatchSubmitAll = useCallback(async () => {
- if (!selectedSession || !currentUserId) {
- console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId');
- return;
- }
-
- console.log('handleBatchSubmitAll: Starting batch approver save...');
- setBatchSaving(true);
- try {
- const request: BatchSaveApproverStockTakeRecordRequest = {
- stockTakeId: selectedSession.stockTakeId,
- stockTakeSection: selectedSession.stockTakeSession,
- approverId: currentUserId,
- };
-
- const result = await batchSaveApproverStockTakeRecords(request);
- console.log('handleBatchSubmitAll: Result:', result);
-
- onSnackbar(
- t("Batch approver save completed: {{success}} success, {{errors}} errors", {
- success: result.successCount,
- errors: result.errorCount,
- }),
- result.errorCount > 0 ? "warning" : "success"
- );
-
- await loadDetails(page, pageSize);
- } catch (e: any) {
- console.error("handleBatchSubmitAll: Error:", e);
- let errorMessage = t("Failed to batch save approver stock take records");
-
- if (e?.message) {
- errorMessage = e.message;
- } else if (e?.response) {
- try {
- const errorData = await e.response.json();
- errorMessage = errorData.message || errorData.error || errorMessage;
- } catch {
- // ignore
- }
- }
-
- onSnackbar(errorMessage, "error");
- } finally {
- setBatchSaving(false);
- }
- }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);
-
- useEffect(() => {
- handleBatchSubmitAllRef.current = handleBatchSubmitAll;
- }, [handleBatchSubmitAll]);
-
- const formatNumber = (num: number | null | undefined): string => {
- if (num == null) return "0.00";
- return num.toLocaleString('en-US', {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2
- });
- };
-
- const uniqueWarehouses = Array.from(
- new Set(
- inventoryLotDetails
- .map(detail => detail.warehouse)
- .filter(warehouse => warehouse && warehouse.trim() !== "")
- )
- ).join(", ");
-
- const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
- // 如果已经有 finalQty(已完成审批),不允许再次编辑
- if (detail.finalQty != null) {
- return true;
- }
-
- // 获取当前选择模式
- const hasFirst = detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0;
- const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
- const selection = qtySelection[detail.id] || (hasSecond ? "second" : "first");
-
- // 如果选择了 "approver" 模式,检查用户是否已经输入了值
- if (selection === "approver") {
- const approverQtyValue = approverQty[detail.id];
- const approverBadQtyValue = approverBadQty[detail.id];
- // 如果用户已经输入了值(包括0),允许保存
- if (approverQtyValue !== undefined && approverQtyValue !== null && approverQtyValue !== "" &&
- approverBadQtyValue !== undefined && approverBadQtyValue !== null && approverBadQtyValue !== "") {
- return false; // 允许保存
- }
- // 如果用户还没有输入值,禁用按钮
- return true;
- }
-
- // 对于 first 或 second 模式,需要检查是否有有效的数量(允许0)
- // 只要 firstStockTakeQty 不为 null,就允许保存(即使为0)
- if (detail.firstStockTakeQty == null) {
- return true; // 如果 firstStockTakeQty 为 null,禁用
- }
-
- return false; // 允许保存
- }, [qtySelection, approverQty, approverBadQty]);
-
- return (
- <Box>
- <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}>
- {t("Back to List")}
- </Button>
- <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
- <Typography variant="h6" sx={{ mb: 2 }}>
- {t("Stock Take Section")}: {selectedSession.stockTakeSession}
- {uniqueWarehouses && (
- <> {t("Warehouse")}: {uniqueWarehouses}</>
- )}
- </Typography>
-
- <Stack direction="row" spacing={2} alignItems="center">
- <Button
- variant={showOnlyWithDifference ? "contained" : "outlined"}
- color="primary"
- onClick={() => setShowOnlyWithDifference(!showOnlyWithDifference)}
- startIcon={
- <Checkbox
- checked={showOnlyWithDifference}
- onChange={(e) => setShowOnlyWithDifference(e.target.checked)}
- sx={{ p: 0, pointerEvents: 'none' }}
- />
- }
- sx={{ textTransform: 'none' }}
- >
- {t("Only Variance")}
- </Button>
- <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}>
- {t("Batch Save All")}
- </Button>
- </Stack>
- </Stack>
- {loadingDetails ? (
- <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
- <CircularProgress />
- </Box>
- ) : (
- <>
- <TablePagination
-
- component="div"
- count={total}
- page={page}
- onPageChange={handleChangePage}
- rowsPerPage={pageSize === "all" ? total : (pageSize as number)}
- onRowsPerPageChange={handleChangeRowsPerPage}
- rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]}
- labelRowsPerPage={t("Rows per page")}
- />
- <TableContainer component={Paper}>
- <Table>
- <TableHead>
- <TableRow>
- <TableCell>{t("Warehouse Location")}</TableCell>
- <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell>
- <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell>
- <TableCell>{t("Remark")}</TableCell>
- <TableCell>{t("UOM")}</TableCell>
- <TableCell>{t("Record Status")}</TableCell>
- <TableCell>{t("Action")}</TableCell>
- </TableRow>
- </TableHead>
- <TableBody>
- {filteredDetails.length === 0 ? (
- <TableRow>
- <TableCell colSpan={7} align="center">
- <Typography variant="body2" color="text.secondary">
- {t("No data")}
- </Typography>
- </TableCell>
- </TableRow>
- ) : (
- filteredDetails.map((detail) => {
- // const submitDisabled = isSubmitDisabled(detail);
- const hasFirst = detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0;
- const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0; // 改为 >= 0,允许0值
- const selection = qtySelection[detail.id] || (hasSecond ? "second" : "first");
-
- return (
- <TableRow key={detail.id}>
- <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell>
- <TableCell sx={{
- maxWidth: 150,
- wordBreak: 'break-word',
- whiteSpace: 'normal',
- lineHeight: 1.5
- }}>
- <Stack spacing={0.5}>
- <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box>
- <Box>{detail.lotNo || "-"}</Box>
- <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box>
- </Stack>
- </TableCell>
-
- <TableCell sx={{ minWidth: 300 }}>
- {detail.finalQty != null ? (
- <Stack spacing={0.5}>
- {(() => {
- const finalDifference = (detail.finalQty || 0) - (detail.availableQty || 0);
- const differenceColor = finalDifference > 0
- ? 'error.main'
- : finalDifference < 0
- ? 'error.main'
- : 'success.main';
-
- return (
- <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}>
- {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber(finalDifference)}
- </Typography>
- );
- })()}
- </Stack>
- ) : (
- <Stack spacing={1}>
- {hasFirst && (
- <Stack direction="row" spacing={1} alignItems="center">
- <Radio
- size="small"
- checked={selection === "first"}
- onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "first" })}
- />
- <Typography variant="body2">
- {t("First")}: {formatNumber((detail.firstStockTakeQty??0)+(detail.firstBadQty??0))} ({detail.firstBadQty??0}) = {formatNumber(detail.firstStockTakeQty??0)}
- </Typography>
- </Stack>
- )}
-
- {hasSecond && (
- <Stack direction="row" spacing={1} alignItems="center">
- <Radio
- size="small"
- checked={selection === "second"}
- onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "second" })}
- />
- <Typography variant="body2">
- {t("Second")}: {formatNumber((detail.secondStockTakeQty??0)+(detail.secondBadQty??0))} ({detail.secondBadQty??0}) = {formatNumber(detail.secondStockTakeQty??0)}
- </Typography>
- </Stack>
- )}
-
- {hasSecond && (
- <Stack direction="row" spacing={1} alignItems="center">
- <Radio
- size="small"
- checked={selection === "approver"}
- onChange={() => setQtySelection({ ...qtySelection, [detail.id]: "approver" })}
- />
- <Typography variant="body2">{t("Approver Input")}:</Typography>
- <TextField
- size="small"
- type="number"
- value={approverQty[detail.id] || ""}
- onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })}
- sx={{
- width: 130,
- minWidth: 130,
- '& .MuiInputBase-input': {
- height: '1.4375em',
- padding: '4px 8px'
- }
- }}
- placeholder={t("Stock Take Qty") }
- disabled={selection !== "approver"}
- />
-
- <TextField
- size="small"
- type="number"
- value={approverBadQty[detail.id] || ""}
- onChange={(e) => setApproverBadQty({ ...approverBadQty, [detail.id]: e.target.value })}
- sx={{
- width: 130,
- minWidth: 130,
- '& .MuiInputBase-input': {
- height: '1.4375em',
- padding: '4px 8px'
- }
- }}
- placeholder={t("Bad Qty")}
- disabled={selection !== "approver"}
- />
- <Typography variant="body2">
- ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))}
- </Typography>
- </Stack>
- )}
-
- {(() => {
- let selectedQty = 0;
-
- if (selection === "first") {
- selectedQty = detail.firstStockTakeQty || 0;
- } else if (selection === "second") {
- selectedQty = detail.secondStockTakeQty || 0;
- } else if (selection === "approver") {
- selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0;
- }
-
- const bookQty = detail.availableQty || 0;
- const difference = selectedQty - bookQty;
- const differenceColor = difference > 0
- ? 'error.main'
- : difference < 0
- ? 'error.main'
- : 'success.main';
-
- return (
- <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}>
- {t("Difference")}: {t("selected stock take qty")}({formatNumber(selectedQty)}) - {t("book qty")}({formatNumber(bookQty)}) = {formatNumber(difference)}
- </Typography>
- );
- })()}
- </Stack>
- )}
- </TableCell>
-
- <TableCell>
- <Typography variant="body2">
- {detail.remarks || "-"}
- </Typography>
- </TableCell>
-
- <TableCell>{detail.uom || "-"}</TableCell>
-
- <TableCell>
- {detail.stockTakeRecordStatus === "pass" ? (
- <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" />
- ) : detail.stockTakeRecordStatus === "notMatch" ? (
- <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" />
- ) : (
- <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" />
- )}
- </TableCell>
- <TableCell>
- {detail.stockTakeRecordId && detail.stockTakeRecordStatus !== "notMatch" && (
- <Box>
- <Button
- size="small"
- variant="outlined"
- color="warning"
- onClick={() => handleUpdateStatusToNotMatch(detail)}
- disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"}
- >
- {t("ReStockTake")}
- </Button>
- </Box>
- )}
- <br/>
- {detail.finalQty == null && (
- <Box>
- <Button
- size="small"
- variant="contained"
- onClick={() => handleSaveApproverStockTake(detail)}
- //disabled={saving || submitDisabled || detail.stockTakeRecordStatus === "completed"}
- >
- {t("Save")}
- </Button>
- </Box>
- )}
- </TableCell>
- </TableRow>
- );
- })
- )}
- </TableBody>
- </Table>
- </TableContainer>
- <TablePagination
- component="div"
- count={total}
- page={page}
- onPageChange={handleChangePage}
- rowsPerPage={pageSize === "all" ? total : (pageSize as number)}
- onRowsPerPageChange={handleChangeRowsPerPage}
- rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]}
- labelRowsPerPage={t("Rows per page")}
- />
- </>
- )}
- </Box>
- );
- };
-
- export default ApproverStockTake;
|