Kaynağa Gözat

update stock take

reset-do-picking-order
CANCERYS\kw093 3 hafta önce
ebeveyn
işleme
a40305f880
7 değiştirilmiş dosya ile 618 ekleme ve 470 silme
  1. +11
    -4
      src/app/api/stockTake/actions.ts
  2. +26
    -16
      src/components/Qc/QcStockInModal.tsx
  3. +53
    -59
      src/components/StockRecord/SearchPage.tsx
  4. +112
    -66
      src/components/StockTakeManagement/ApproverStockTake.tsx
  5. +259
    -202
      src/components/StockTakeManagement/PickerReStockTake.tsx
  6. +155
    -123
      src/components/StockTakeManagement/PickerStockTake.tsx
  7. +2
    -0
      src/i18n/zh/inventory.json

+ 11
- 4
src/app/api/stockTake/actions.ts Dosyayı Görüntüle

@@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse {
approverQty: number | null;
approverBadQty: number | null;
finalQty: number | null;
bookQty: number | null;
}

export const getInventoryLotDetailsBySection = async (
@@ -207,6 +208,7 @@ export interface BatchSaveApproverStockTakeRecordRequest {
stockTakeId: number;
stockTakeSection: string;
approverId: number;
variancePercentTolerance?: number | null;
}

export interface BatchSaveApproverStockTakeRecordResponse {
@@ -312,7 +314,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async (
);
return response;
}

export interface SearchStockTransactionResult {
records: StockTransactionResponse[];
total: number;
}
export interface SearchStockTransactionRequest {
startDate: string | null;
endDate: string | null;
@@ -345,7 +350,6 @@ export interface StockTransactionListResponse {
}

export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => {
// 构建查询字符串
const params = new URLSearchParams();
if (request.itemCode) params.append("itemCode", request.itemCode);
@@ -366,7 +370,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact
next: { tags: ["Stock Transaction List"] },
}
);
// 确保返回正确的格式
return response?.records || [];
// 回傳 records 與 total,供分頁正確顯示
return {
records: response?.records || [],
total: response?.total ?? 0,
};
});


+ 26
- 16
src/components/Qc/QcStockInModal.tsx Dosyayı Görüntüle

@@ -68,6 +68,7 @@ interface CommonProps extends Omit<ModalProps, "children"> {
interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
}

const QcStockInModal: React.FC<Props> = ({
open,
onClose,
@@ -94,6 +95,10 @@ const QcStockInModal: React.FC<Props> = ({
() => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`,
[session?.id],
);
const labelPrinterCombo = useMemo(
() => (printerCombo || []).filter((p) => p.type === "Label"),
[printerCombo],
);
const getDefaultPrinter = useMemo(() => {
if (!printerCombo.length) return undefined;
if (typeof window === "undefined") return printerCombo[0];
@@ -102,7 +107,7 @@ const QcStockInModal: React.FC<Props> = ({
const matched = savedId ? printerCombo.find(p => p.id === Number(savedId)) : undefined;
return matched ?? printerCombo[0];
}, [printerCombo, printerStorageKey]);
const [selectedPrinter, setSelectedPrinter] = useState(printerCombo[0]);
const [selectedPrinter, setSelectedPrinter] = useState(labelPrinterCombo[0]);
const [printQty, setPrintQty] = useState(1);
const [tabIndex, setTabIndex] = useState(0);

@@ -504,6 +509,7 @@ const QcStockInModal: React.FC<Props> = ({
// Put away model
const [pafRowModesModel, setPafRowModesModel] = useState<GridRowModesModel>({})
const [pafRowSelectionModel, setPafRowSelectionModel] = useState<GridRowSelectionModel>([])

const pafSubmitDisable = useMemo(() => {
return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit)
}, [pafRowModesModel])
@@ -749,21 +755,25 @@ const printQrcode = useCallback(
{tabIndex == 1 && (
<Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}>
<Autocomplete
disableClearable
options={printerCombo}
defaultValue={selectedPrinter}
onChange={(event, value) => {
setSelectedPrinter(value)
}}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label={t("Printer")}
sx={{ width: 300}}
/>
)}
/>
disableClearable
options={labelPrinterCombo}
getOptionLabel={(option) =>
option.name || option.label || option.code || `Printer ${option.id}`
}
value={selectedPrinter}
onChange={(_, newValue) => {
if (newValue) setSelectedPrinter(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label={t("Printer")}
sx={{ width: 300 }}
inputProps={{ ...params.inputProps, readOnly: true }}
/>
)}
/>
<TextField
variant="outlined"
label={t("Print Qty")}


+ 53
- 59
src/components/StockRecord/SearchPage.tsx Dosyayı Görüntüle

@@ -134,7 +134,7 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => {
// 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环)
useEffect(() => {
setFilteredList(processedData);
setTotalCount(processedData.length);
// 只在初始加载时设置 pageSize
if (isInitialMount.current && processedData.length > 0) {
setPageSize("all");
@@ -146,55 +146,53 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => {

// API 调用函数(参考 PoSearch 的实现)
// API 调用函数(参考 PoSearch 的实现)
const newPageFetch = useCallback(
async (
pagingController: Record<string, number>,
filterArgs: Record<string, any>,
) => {
setLoading(true);
try {
// 处理空字符串,转换为 null
const itemCode = filterArgs.itemCode?.trim() || null;
const itemName = filterArgs.itemName?.trim() || null;
// 验证:至少需要 itemCode 或 itemName
if (!itemCode && !itemName) {
console.warn("Search requires at least itemCode or itemName");
const newPageFetch = useCallback(
async (
pagingController: Record<string, number>,
filterArgs: Record<string, any>,
) => {
setLoading(true);
try {
const itemCode = filterArgs.itemCode?.trim() || null;
const itemName = filterArgs.itemName?.trim() || null;
if (!itemCode && !itemName) {
console.warn("Search requires at least itemCode or itemName");
setDataList([]);
setTotalCount(0);
return;
}
const params: SearchStockTransactionRequest = {
itemCode: itemCode,
itemName: itemName,
type: filterArgs.type?.trim() || null,
startDate: filterArgs.startDate || null,
endDate: filterArgs.endDate || null,
pageNum: pagingController.pageNum - 1 || 0,
pageSize: pagingController.pageSize || 100,
};
const res = await searchStockTransactions(params);
if (res && typeof res === 'object' && Array.isArray(res.records)) {
setDataList(res.records);
setTotalCount(res.total ?? res.records.length);
} else {
console.error("Invalid response format:", res);
setDataList([]);
setTotalCount(0);
}
} catch (error) {
console.error("Fetch error:", error);
setDataList([]);
setTotalCount(0);
return;
} finally {
setLoading(false);
}
const params: SearchStockTransactionRequest = {
itemCode: itemCode,
itemName: itemName,
type: filterArgs.type?.trim() || null,
startDate: filterArgs.startDate || null,
endDate: filterArgs.endDate || null,
pageNum: pagingController.pageNum - 1 || 0,
pageSize: pagingController.pageSize || 100,
};
console.log("Search params:", params); // 添加调试日志
const res = await searchStockTransactions(params);
console.log("Search response:", res); // 添加调试日志
if (res && Array.isArray(res)) {
setDataList(res);
} else {
console.error("Invalid response format:", res);
setDataList([]);
}
} catch (error) {
console.error("Fetch error:", error);
setDataList([]);
} finally {
setLoading(false);
}
},
[],
);
},
[],
);

// 使用 useRef 来存储上一次的值,避免不必要的 API 调用
const prevPagingControllerRef = useRef(pagingController);
@@ -240,13 +238,13 @@ const newPageFetch = useCallback(
const newSize = parseInt(event.target.value, 10);
if (newSize === -1) {
setPageSize("all");
setPagingController(prev => ({ ...prev, pageSize: filteredList.length, pageNum: 1 }));
setPagingController(prev => ({ ...prev, pageSize: 100, pageNum: 1 })); // 用 100 觸發後端回傳全部
} else if (!isNaN(newSize)) {
setPageSize(newSize);
setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 }));
}
setPage(0);
}, [filteredList.length]);
}, []);

const searchCriteria: Criterion<string>[] = useMemo(
() => [
@@ -390,29 +388,25 @@ const newPageFetch = useCallback(
setPagingController(prev => ({ ...prev, pageNum: 1 }));
}, []);

// 计算实际显示的 items(分页)
const paginatedItems = useMemo(() => {
if (pageSize === "all") {
return filteredList;
}
const actualPageSize = typeof pageSize === 'number' ? pageSize : 10;
const startIndex = page * actualPageSize;
const endIndex = startIndex + actualPageSize;
return filteredList.slice(startIndex, endIndex);
}, [filteredList, page, pageSize]);
return filteredList;
}, [filteredList, pageSize]);

// 计算传递给 SearchResults 的 pageSize(确保在选项中)
const actualPageSizeForTable = useMemo(() => {
if (pageSize === "all") {
return filteredList.length;
return totalCount > 0 ? totalCount : filteredList.length;
}
const size = typeof pageSize === 'number' ? pageSize : 10;
// 如果 size 不在标准选项中,使用 "all" 模式
if (![10, 25, 100].includes(size)) {
return filteredList.length;
return size;
}
return size;
}, [pageSize, filteredList.length]);
}, [pageSize, filteredList.length, totalCount]);

return (
<>


+ 112
- 66
src/components/StockTakeManagement/ApproverStockTake.tsx Dosyayı Görüntüle

@@ -56,8 +56,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false);
const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(true);
const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5");
// 每个记录的选择状态,key 为 detail.id
const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
const [approverQty, setApproverQty] = useState<Record<number, string>>({});
@@ -71,7 +71,17 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const handleBatchSubmitAllRef = useRef<() => Promise<void>>();

const isWithinVarianceTolerance = useCallback((
difference: number,
bookQty: number,
percentStr: string
): boolean => {
const percent = parseFloat(percentStr || "0");
if (isNaN(percent) || percent < 0) return true; // 无效输入时视为全部通过
if (bookQty === 0) return difference === 0;
const threshold = Math.abs(bookQty) * (percent / 100);
return Math.abs(difference) <= threshold;
}, []);
const handleChangePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage);
}, []);
@@ -133,7 +143,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0;
}
const bookQty = detail.availableQty || 0;
const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0);
return selectedQty - bookQty;
}, [approverQty, approverBadQty]);
@@ -159,16 +169,29 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
// 4. 添加过滤逻辑(在渲染表格之前)
const filteredDetails = useMemo(() => {
if (!showOnlyWithDifference) {
return inventoryLotDetails;
let result = inventoryLotDetails;
if (showOnlyWithDifference) {
const percent = parseFloat(variancePercentTolerance || "0");
const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent;
result = result.filter(detail => {
// 已完成項目一律顯示
if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") {
return true;
}
const selection = qtySelection[detail.id] ??
(detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first");
const difference = calculateDifference(detail, selection);
const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0);
if (bookQty === 0) return difference !== 0;
const threshold = Math.abs(bookQty) * (thresholdPercent / 100);
return Math.abs(difference) > threshold;
});
}
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]);
return result;
}, [inventoryLotDetails, showOnlyWithDifference, variancePercentTolerance, qtySelection, calculateDifference]);
const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => {
if (!selectedSession || !currentUserId) {
@@ -231,7 +254,22 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
onSnackbar(t("Approver stock take record saved successfully"), "success");

await loadDetails(page, pageSize);
// 計算最終數量(合格數)
const goodQty = finalQty - finalBadQty;

setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id
? {
...d,
finalQty: goodQty,
approverQty: selection === "approver" ? finalQty : d.approverQty,
approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty,
stockTakeRecordStatus: "completed",
}
: d
)
);
} catch (e: any) {
console.error("Save approver stock take record error:", e);
let errorMessage = t("Failed to save approver stock take record");
@@ -264,6 +302,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);
onSnackbar(t("Stock take record status updated to not match"), "success");
setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d
)
);
} catch (e: any) {
console.error("Update stock take record status error:", e);
let errorMessage = t("Failed to update stock take record status");
@@ -284,17 +327,9 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
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]);
}, [selectedSession, t, onSnackbar, ]);
const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
@@ -309,6 +344,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
approverId: currentUserId,
variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined,
};

const result = await batchSaveApproverStockTakeRecords(request);
@@ -349,10 +385,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
}, [handleBatchSubmitAll]);
const formatNumber = (num: number | null | undefined): string => {
if (num == null) return "0.00";
if (num == null) return "0";
return num.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
minimumFractionDigits: 0,
maximumFractionDigits: 0
});
};
@@ -411,25 +447,30 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
</Typography>

<Stack direction="row" spacing={2} alignItems="center">
<Button
variant={showOnlyWithDifference ? "contained" : "outlined"}
color="primary"
onClick={() => setShowOnlyWithDifference(!showOnlyWithDifference)}
startIcon={
<TextField
size="small"
type="number"
value={variancePercentTolerance}
onChange={(e) => setVariancePercentTolerance(e.target.value)}
label={t("Variance %")}
sx={{ width: 100 }}
inputProps={{ min: 0, max: 100, step: 0.1 }}
/>
{/*
<FormControlLabel
control={
<Checkbox
checked={showOnlyWithDifference}
onChange={(e) => setShowOnlyWithDifference(e.target.checked)}
sx={{ p: 0, pointerEvents: 'none' }}
/>
}
sx={{ textTransform: 'none' }}
>
{t("Only Variance")}
label={t("Only Variance")}
/>
*/}
<Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}>
{t("Batch Save All")}
</Button>
<Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}>
{t("Batch Save All")}
</Button>
</Stack>
</Stack>
</Stack>
{loadingDetails ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
@@ -454,9 +495,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell>
<TableCell>{t("UOM")}</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>
@@ -492,25 +534,27 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
<Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box>
</Stack>
</TableCell>
<TableCell>{detail.uom || "-"}</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>
{detail.finalQty != null ? (
<Stack spacing={0.5}>
{(() => {
// 若有 bookQty(盤點當時帳面),用它來算差異;否則用 availableQty
const bookQtyToUse = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0);
const finalDifference = (detail.finalQty || 0) - bookQtyToUse;
const differenceColor = detail.stockTakeRecordStatus === "completed"
? 'text.secondary'
: finalDifference !== 0
? 'error.main'
: 'success.main';
return (
<Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}>
{t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(bookQtyToUse)} = {formatNumber(finalDifference)}
</Typography>
);
})()}
</Stack>
) : (
<Stack spacing={1}>
{hasFirst && (
@@ -581,7 +625,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
disabled={selection !== "approver"}
/>
<Typography variant="body2">
={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))}
= {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))}
</Typography>
</Stack>
)}
@@ -597,12 +641,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0;
}
const bookQty = detail.availableQty || 0;
const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0);
const difference = selectedQty - bookQty;
const differenceColor = difference > 0
? 'error.main'
: difference < 0
? 'error.main'
const differenceColor = detail.stockTakeRecordStatus === "completed"
? 'text.secondary'
: difference !== 0
? 'error.main'
: 'success.main';
return (
@@ -621,11 +665,13 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({
</Typography>
</TableCell>
<TableCell>{detail.uom || "-"}</TableCell>
<TableCell>
{detail.stockTakeRecordStatus === "pass" ? (
{detail.stockTakeRecordStatus === "completed" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" />
) : detail.stockTakeRecordStatus === "pass" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" />
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" />
) : (


+ 259
- 202
src/components/StockTakeManagement/PickerReStockTake.tsx Dosyayı Görüntüle

@@ -21,7 +21,6 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
getInventoryLotDetailsBySection,
InventoryLotDetailResponse,
saveStockTakeRecord,
SaveStockTakeRecordRequest,
@@ -51,13 +50,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
// 编辑状态
const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null);
const [firstQty, setFirstQty] = useState<string>("");
const [secondQty, setSecondQty] = useState<string>("");
const [firstBadQty, setFirstBadQty] = useState<string>("");
const [secondBadQty, setSecondBadQty] = useState<string>("");
const [remark, setRemark] = useState<string>("");
const [recordInputs, setRecordInputs] = useState<Record<number, {
firstQty: string;
secondQty: string;
firstBadQty: string;
secondBadQty: string;
remark: string;
}>>({});
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [shortcutInput, setShortcutInput] = useState<string>("");
@@ -115,28 +114,36 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}
}, [selectedSession, total]);
useEffect(() => {
const inputs: Record<number, { firstQty: string; secondQty: string; firstBadQty: string; secondBadQty: string; remark: string }> = {};
inventoryLotDetails.forEach((detail) => {
const firstTotal = detail.firstStockTakeQty != null
? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString()
: "";
const secondTotal = detail.secondStockTakeQty != null
? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString()
: "";
inputs[detail.id] = {
firstQty: firstTotal,
secondQty: secondTotal,
firstBadQty: detail.firstBadQty?.toString() || "",
secondBadQty: detail.secondBadQty?.toString() || "",
remark: detail.remarks || "",
};
});
setRecordInputs(inputs);
}, [inventoryLotDetails]);

useEffect(() => {
loadDetails(page, pageSize);
}, [page, pageSize, loadDetails]);

const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => {
setEditingRecord(detail);
setFirstQty(detail.firstStockTakeQty?.toString() || "");
setSecondQty(detail.secondStockTakeQty?.toString() || "");
setFirstBadQty(detail.firstBadQty?.toString() || "");
setSecondBadQty(detail.secondBadQty?.toString() || "");
setRemark(detail.remarks || "");
}, []);

const handleCancelEdit = useCallback(() => {
setEditingRecord(null);
setFirstQty("");
setSecondQty("");
setFirstBadQty("");
setSecondBadQty("");
setRemark("");
}, []);

const formatNumber = (num: number | null | undefined): string => {
if (num == null || Number.isNaN(num)) return "0";
return num.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
};
const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => {
if (!selectedSession || !currentUserId) {
return;
@@ -145,41 +152,69 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const qty = isFirstSubmit ? firstQty : secondQty;
const badQty = isFirstSubmit ? firstBadQty : secondBadQty;
if (!qty || !badQty) {
// 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致)
const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty;
const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty;

if (!totalQtyStr) {
onSnackbar(
isFirstSubmit
? t("Please enter QTY and Bad QTY")
: t("Please enter Second QTY and Bad QTY"),
? t("Please enter QTY")
: t("Please enter Second QTY"),
"error"
);
return;
}

const totalQty = parseFloat(totalQtyStr);
const badQty = parseFloat(badQtyStr || "0") || 0;

if (Number.isNaN(totalQty)) {
onSnackbar(t("Invalid QTY"), "error");
return;
}

const availableQty = totalQty - badQty;

if (availableQty < 0) {
onSnackbar(t("Available QTY cannot be negative"), "error");
return;
}

setSaving(true);
try {
const request: SaveStockTakeRecordRequest = {
stockTakeRecordId: detail.stockTakeRecordId || null,
inventoryLotLineId: detail.id,
qty: parseFloat(qty),
badQty: parseFloat(badQty),
remark: isSecondSubmit ? (remark || null) : null,
qty: availableQty,
badQty: badQty,
remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null,
};
console.log('handleSaveStockTake: request:', request);
console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId);
console.log('handleSaveStockTake: currentUserId:', currentUserId);
await saveStockTakeRecord(
const result = await saveStockTakeRecord(
request,
selectedSession.stockTakeId,
currentUserId
);
onSnackbar(t("Stock take record saved successfully"), "success");
handleCancelEdit();
await loadDetails(page, pageSize);

const savedId = result?.id ?? detail.stockTakeRecordId;
setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id
? {
...d,
stockTakeRecordId: savedId ?? d.stockTakeRecordId,
firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty,
firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null,
secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty,
secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null,
remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks,
stockTakeRecordStatus: "pass",
}
: d
)
);
} catch (e: any) {
console.error("Save stock take record error:", e);
let errorMessage = t("Failed to save stock take record");
@@ -199,15 +234,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
} finally {
setSaving(false);
}
}, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);
}, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);

const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId');
return;
}

console.log('handleBatchSubmitAll: Starting batch save...');
setBatchSaving(true);
try {
const request: BatchSaveStockTakeRecordRequest = {
@@ -217,7 +250,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
};

const result = await batchSaveStockTakeRecords(request);
console.log('handleBatchSubmitAll: Result:', result);

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
@@ -273,31 +305,19 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const newInput = prev + e.key;
if (newInput === '{2fitestall}') {
console.log('✅ Shortcut {2fitestall} detected!');
setTimeout(() => {
if (handleBatchSubmitAllRef.current) {
console.log('Calling handleBatchSubmitAll...');
handleBatchSubmitAllRef.current().catch(err => {
console.error('Error in handleBatchSubmitAll:', err);
});
} else {
console.error('handleBatchSubmitAllRef.current is null');
}
}, 0);
return "";
}
if (newInput.length > 15) {
return "";
}
if (newInput.length > 0 && !newInput.startsWith('{')) {
return "";
}
if (newInput.length > 5 && !newInput.startsWith('{2fi')) {
return "";
}
if (newInput.length > 15) return "";
if (newInput.length > 0 && !newInput.startsWith('{')) return "";
if (newInput.length > 5 && !newInput.startsWith('{2fi')) return "";
return newInput;
});
@@ -315,11 +335,15 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}, []);

const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
if (detail.stockTakeRecordStatus === "pass") {
if (selectedSession?.status?.toLowerCase() === "completed") {
return true;
}
const recordStatus = detail.stockTakeRecordStatus?.toLowerCase();
if (recordStatus === "pass" || recordStatus === "completed") {
return true;
}
return false;
}, []);
}, [selectedSession?.status]);
const uniqueWarehouses = Array.from(
new Set(
@@ -328,6 +352,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
.filter(warehouse => warehouse && warehouse.trim() !== "")
)
).join(", ");

const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" };

return (
<Box>
<Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}>
@@ -339,42 +366,31 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
<> {t("Warehouse")}: {uniqueWarehouses}</>
)}
</Typography>
{/*
{shortcutInput && (
<Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}>
<Typography variant="body2" color="info.dark" fontWeight={500}>
{t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong>
</Typography>
</Box>
)}
*/}
{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")}
/>
<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>
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell>
<TableCell>{t("Qty")}</TableCell>
<TableCell>{t("Bad Qty")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("UOM")}</TableCell>
<TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("Record Status")}</TableCell>
<TableCell>{t("Action")}</TableCell>
</TableRow>
@@ -382,7 +398,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
<TableBody>
{inventoryLotDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center">
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
@@ -390,99 +406,156 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
</TableRow>
) : (
inventoryLotDetails.map((detail) => {
const isEditing = editingRecord?.id === detail.id;
const submitDisabled = isSubmitDisabled(detail);
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const inputs = recordInputs[detail.id] ?? defaultInputs;

return (
<TableRow key={detail.id}>
<TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell>
<TableCell sx={{
maxWidth: 150,
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.5
}}>
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>
<Stack spacing={0.5}>
{isEditing && isFirstSubmit ? (
<TextField
size="small"
type="number"
value={firstQty}
onChange={(e) => setFirstQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.firstStockTakeQty ? (
<Typography variant="body2">
{t("First")}: {detail.firstStockTakeQty.toFixed(2)}
</Typography>
) : null}
{isEditing && isSecondSubmit ? (
<TextField
size="small"
type="number"
value={secondQty}
onChange={(e) => setSecondQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.secondStockTakeQty ? (
<TableCell>{detail.uom || "-"}</TableCell>
<TableCell sx={{ minWidth: 300 }}>
<Stack spacing={1}>
{/* First */}
{!submitDisabled && isFirstSubmit ? (
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">{t("First")}:</Typography>
<TextField
size="small"
type="number"
value={inputs.firstQty}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({
...prev,
[detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val }
}));
}}
sx={{
width: 130,
minWidth: 130,
"& .MuiInputBase-input": {
height: "1.4375em",
padding: "4px 8px",
},
}}
placeholder={t("Stock Take Qty")}
/>
<TextField
size="small"
type="number"
value={inputs.firstBadQty}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({
...prev,
[detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstBadQty: val }
}));
}}
sx={{
width: 130,
minWidth: 130,
"& .MuiInputBase-input": {
height: "1.4375em",
padding: "4px 8px",
},
}}
placeholder={t("Bad Qty")}
/>
<Typography variant="body2">
= {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))}
</Typography>
</Stack>
) : detail.firstStockTakeQty != null ? (
<Typography variant="body2">
{t("Second")}: {detail.secondStockTakeQty.toFixed(2)}
{t("First")}:{" "}
{formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "}
({formatNumber(detail.firstBadQty ?? 0)}) ={" "}
{formatNumber(detail.firstStockTakeQty ?? 0)}
</Typography>
) : null}
{!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && (
<Typography variant="body2" color="text.secondary">
-
</Typography>
)}
</Stack>
</TableCell>
<TableCell>
<Stack spacing={0.5}>
{isEditing && isFirstSubmit ? (
<TextField
size="small"
type="number"
value={firstBadQty}
onChange={(e) => setFirstBadQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.firstBadQty != null && detail.firstBadQty > 0 ? (
<Typography variant="body2">
{t("First")}: {detail.firstBadQty.toFixed(2)}
</Typography>
) : (
<Typography variant="body2" sx={{ visibility: 'hidden' }}>
{t("First")}: 0.00
</Typography>
)}
{isEditing && isSecondSubmit ? (
<TextField
size="small"
type="number"
value={secondBadQty}
onChange={(e) => setSecondBadQty(e.target.value)}
sx={{ width: 100 }}
/>
) : detail.secondBadQty != null && detail.secondBadQty > 0 ? (

{/* Second */}
{!submitDisabled && isSecondSubmit ? (
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">{t("Second")}:</Typography>
<TextField
size="small"
type="number"
value={inputs.secondQty}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({
...prev,
[detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: val }
}));
}}
sx={{
width: 130,
minWidth: 130,
"& .MuiInputBase-input": {
height: "1.4375em",
padding: "4px 8px",
},
}}
placeholder={t("Stock Take Qty")}
/>
<TextField
size="small"
type="number"
value={inputs.secondBadQty}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({
...prev,
[detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondBadQty: val }
}));
}}
sx={{
width: 130,
minWidth: 130,
"& .MuiInputBase-input": {
height: "1.4375em",
padding: "4px 8px",
},
}}
placeholder={t("Bad Qty")}
/>
<Typography variant="body2">
= {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))}
</Typography>
</Stack>
) : detail.secondStockTakeQty != null ? (
<Typography variant="body2">
{t("Second")}: {detail.secondBadQty.toFixed(2)}
{t("Second")}:{" "}
{formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "}
({formatNumber(detail.secondBadQty ?? 0)}) ={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</Typography>
) : null}
{!detail.firstBadQty && !detail.secondBadQty && !isEditing && (
{!detail.firstStockTakeQty && !detail.secondStockTakeQty && !submitDisabled && (
<Typography variant="body2" color="text.secondary">
-
</Typography>
@@ -490,13 +563,16 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
</Stack>
</TableCell>
<TableCell sx={{ width: 180 }}>
{isEditing && isSecondSubmit ? (
{!submitDisabled && isSecondSubmit ? (
<>
<Typography variant="body2">{t("Remark")}</Typography>
<TextField
size="small"
value={remark}
onChange={(e) => setRemark(e.target.value)}
value={inputs.remark}
onChange={(e) => setRecordInputs(prev => ({
...prev,
[detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: e.target.value }
}))}
sx={{ width: 150 }}
/>
</>
@@ -506,49 +582,30 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
</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>
{detail.stockTakeRecordStatus === "completed" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" />
) : detail.stockTakeRecordStatus === "pass" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" />
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" />
) : (
<Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" />
)}
</TableCell>
<TableCell>
{isEditing ? (
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="contained"
onClick={() => handleSaveStockTake(detail)}
disabled={saving || submitDisabled}
>
{t("Save")}
</Button>
<Button
size="small"
onClick={handleCancelEdit}
>
{t("Cancel")}
</Button>
</Stack>
) : (
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
onClick={() => handleStartEdit(detail)}
disabled={submitDisabled}
variant="contained"
onClick={() => handleSaveStockTake(detail)}
disabled={saving || submitDisabled }
>
{!detail.stockTakeRecordId
? t("Input")
: detail.stockTakeRecordStatus === "notMatch"
? t("Input")
: t("View")}
{t("Save")}
</Button>
)}
</Stack>
</TableCell>
</TableRow>
);


+ 155
- 123
src/components/StockTakeManagement/PickerStockTake.tsx Dosyayı Görüntüle

@@ -55,13 +55,14 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
// 编辑状态
const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null);
// firstQty / secondQty 保存的是 total = available + bad
const [firstQty, setFirstQty] = useState<string>("");
const [secondQty, setSecondQty] = useState<string>("");
const [firstBadQty, setFirstBadQty] = useState<string>("");
const [secondBadQty, setSecondBadQty] = useState<string>("");
const [recordInputs, setRecordInputs] = useState<Record<number, {
firstQty: string;
secondQty: string;
firstBadQty: string;
secondBadQty: string;
remark: string;
}>>({});
const [savingRecordId, setSavingRecordId] = useState<number | null>(null);
const [remark, setRemark] = useState<string>("");
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
@@ -91,7 +92,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}
setPage(0);
}, []);
const loadDetails = useCallback(async (pageNum: number, size: number | string) => {
const loadDetails = useCallback(async (
pageNum: number,
size: number | string,
options?: { silent?: boolean }
) => {
console.log('loadDetails called with:', { pageNum, size, selectedSessionTotal: selectedSession.totalInventoryLotNumber });
setLoadingDetails(true);
try {
@@ -132,44 +137,34 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
setLoadingDetails(false);
}
}, [selectedSession, total]);
useEffect(() => {
loadDetails(page, pageSize);
}, [page, pageSize, loadDetails]);
const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => {
setEditingRecord(detail);

// 编辑时,输入 total = qty + badQty
const firstTotal =
detail.firstStockTakeQty != null
const inputs: Record<number, { firstQty: string; secondQty: string; firstBadQty: string; secondBadQty: string; remark: string }> = {};
inventoryLotDetails.forEach((detail) => {
const firstTotal = detail.firstStockTakeQty != null
? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString()
: "";
const secondTotal =
detail.secondStockTakeQty != null
const secondTotal = detail.secondStockTakeQty != null
? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString()
: "";

setFirstQty(firstTotal);
setSecondQty(secondTotal);
setFirstBadQty(detail.firstBadQty?.toString() || "");
setSecondBadQty(detail.secondBadQty?.toString() || "");
setRemark(detail.remarks || "");
}, []);

const handleCancelEdit = useCallback(() => {
setEditingRecord(null);
setFirstQty("");
setSecondQty("");
setFirstBadQty("");
setSecondBadQty("");
setRemark("");
}, []);
inputs[detail.id] = {
firstQty: firstTotal,
secondQty: secondTotal,
firstBadQty: detail.firstBadQty?.toString() || "",
secondBadQty: detail.secondBadQty?.toString() || "",
remark: detail.remarks || "",
};
});
setRecordInputs(inputs);
}, [inventoryLotDetails]);
useEffect(() => {
loadDetails(page, pageSize);
}, [page, pageSize, loadDetails]);

const formatNumber = (num: number | null | undefined): string => {
if (num == null || Number.isNaN(num)) return "0.00";
if (num == null || Number.isNaN(num)) return "0";
return num.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
};

@@ -184,24 +179,25 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;

// 现在用户输入的是 total 和 bad,需要算 available = total - bad
const totalQtyStr = isFirstSubmit ? firstQty : secondQty;
const badQtyStr = isFirstSubmit ? firstBadQty : secondBadQty;

if (!totalQtyStr || !badQtyStr) {
const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty;
const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty;
// 只檢查 totalQty,Bad Qty 未輸入時預設為 0
if (!totalQtyStr) {
onSnackbar(
isFirstSubmit
? t("Please enter QTY and Bad QTY")
: t("Please enter Second QTY and Bad QTY"),
? t("Please enter QTY")
: t("Please enter Second QTY"),
"error"
);
return;
}
const totalQty = parseFloat(totalQtyStr);
const badQty = parseFloat(badQtyStr);
if (Number.isNaN(totalQty) || Number.isNaN(badQty)) {
onSnackbar(t("Invalid QTY or Bad QTY"), "error");
const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0
if (Number.isNaN(totalQty)) {
onSnackbar(t("Invalid QTY"), "error");
return;
}

@@ -219,7 +215,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
inventoryLotLineId: detail.id,
qty: availableQty, // 保存 available qty
badQty: badQty, // 保存 bad qty
remark: isSecondSubmit ? (remark || null) : null,
remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null,
};
console.log("handleSaveStockTake: request:", request);
console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId);
@@ -228,10 +224,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId);

onSnackbar(t("Stock take record saved successfully"), "success");
handleCancelEdit();
await loadDetails(page, pageSize);
//await loadDetails(page, pageSize, { silent: true });
setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id
? {
...d,
stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留
firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty,
firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null,
secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty,
secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null,
remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks,
stockTakeRecordStatus: "pass",
}
: d
)
);
} catch (e: any) {
console.error("Save stock take record error:", e);
let errorMessage = t("Failed to save stock take record");
@@ -254,18 +264,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
},
[
selectedSession,
firstQty,
secondQty,
firstBadQty,
secondBadQty,
recordInputs,
remark,
handleCancelEdit,
t,
currentUserId,
onSnackbar,
loadDetails,
page,
pageSize,
]
);

@@ -387,11 +390,15 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}, []);

const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
if (detail.stockTakeRecordStatus === "pass") {
if (selectedSession?.status?.toLowerCase() === "completed") {
return true;
}
const recordStatus = detail.stockTakeRecordStatus?.toLowerCase();
if (recordStatus === "pass" || recordStatus === "completed") {
return true;
}
return false;
}, []);
}, [selectedSession?.status]);

const uniqueWarehouses = Array.from(
new Set(
@@ -460,9 +467,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell>
<TableCell>{t("UOM")}</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>
@@ -478,7 +486,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
</TableRow>
) : (
inventoryLotDetails.map((detail) => {
const isEditing = editingRecord?.id === detail.id;
const submitDisabled = isSubmitDisabled(detail);
const isFirstSubmit =
!detail.stockTakeRecordId || !detail.firstStockTakeQty;
@@ -513,19 +520,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
</Box>
</Stack>
</TableCell>
<TableCell>{detail.uom || "-"}</TableCell>
{/* Qty + Bad Qty 合并显示/输入 */}
<TableCell sx={{ minWidth: 300 }}>
<Stack spacing={1}>
{/* First */}
{isEditing && isFirstSubmit ? (
{!submitDisabled && isFirstSubmit ? (
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">{t("First")}:</Typography>
<TextField
size="small"
type="number"
value={firstQty}
onChange={(e) => setFirstQty(e.target.value)}
value={recordInputs[detail.id]?.firstQty || ""}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } }));
}}
sx={{
width: 130,
minWidth: 130,
@@ -533,14 +545,23 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
height: "1.4375em",
padding: "4px 8px",
},
"& .MuiInputBase-input::placeholder": {
color: "grey.400", // MUI light grey
opacity: 1,
},
}}
placeholder={t("Stock Take Qty")}
/>
<TextField
size="small"
type="number"
value={firstBadQty}
onChange={(e) => setFirstBadQty(e.target.value)}
value={recordInputs[detail.id]?.firstBadQty || ""}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstBadQty: val } }));
}}
sx={{
width: 130,
minWidth: 130,
@@ -548,14 +569,18 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
height: "1.4375em",
padding: "4px 8px",
},
"& .MuiInputBase-input::placeholder": {
color: "grey.400", // MUI light grey
opacity: 1,
},
}}
placeholder={t("Bad Qty")}
/>
<Typography variant="body2">
=
{formatNumber(
parseFloat(firstQty || "0") -
parseFloat(firstBadQty || "0")
parseFloat(recordInputs[detail.id]?.firstQty || "0") -
parseFloat(recordInputs[detail.id]?.firstBadQty || "0")
)}
</Typography>
</Stack>
@@ -576,14 +601,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
) : null}

{/* Second */}
{isEditing && isSecondSubmit ? (
{!submitDisabled && isSecondSubmit ? (
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2">{t("Second")}:</Typography>
<TextField
size="small"
type="number"
value={secondQty}
onChange={(e) => setSecondQty(e.target.value)}
value={recordInputs[detail.id]?.secondQty || ""}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } }));
}}
sx={{
width: 130,
minWidth: 130,
@@ -597,8 +627,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
<TextField
size="small"
type="number"
value={secondBadQty}
onChange={(e) => setSecondBadQty(e.target.value)}
value={recordInputs[detail.id]?.secondBadQty || ""}
inputProps={{ min: 0, step: "any" }}
onChange={(e) => {
const val = e.target.value;
if (val.includes("-")) return;
setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondBadQty: val } }));
}}
sx={{
width: 130,
minWidth: 130,
@@ -612,8 +647,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
<Typography variant="body2">
=
{formatNumber(
parseFloat(secondQty || "0") -
parseFloat(secondBadQty || "0")
parseFloat(recordInputs[detail.id]?.secondQty || "0") -
parseFloat(recordInputs[detail.id]?.secondBadQty || "0")
)}
</Typography>
</Stack>
@@ -635,7 +670,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({

{!detail.firstStockTakeQty &&
!detail.secondStockTakeQty &&
!isEditing && (
!submitDisabled && (
<Typography
variant="body2"
color="text.secondary"
@@ -648,13 +683,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({

{/* Remark */}
<TableCell sx={{ width: 180 }}>
{isEditing && isSecondSubmit ? (
{!submitDisabled && isSecondSubmit ? (
<>
<Typography variant="body2">{t("Remark")}</Typography>
<TextField
size="small"
value={remark}
onChange={(e) => setRemark(e.target.value)}
value={recordInputs[detail.id]?.remark || ""}
onChange={(e) => setRecordInputs(prev => ({
...prev,
[detail.id]: {
...(prev[detail.id] ?? { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }),
remark: e.target.value
}
}))}
sx={{ width: 150 }}
/>
</>
@@ -665,32 +706,38 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
)}
</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>
{detail.stockTakeRecordStatus === "completed" ? (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus)}
color="success"
/>
) : detail.stockTakeRecordStatus === "pass" ? (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus)}
color="default"
/>
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus)}
color="warning"
/>
) : (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus || "")}
color="default"
/>
)}
</TableCell>

<TableCell>
{isEditing ? (
<Stack direction="row" spacing={1}>
<Button
size="small"
@@ -700,24 +747,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
>
{t("Save")}
</Button>
<Button size="small" onClick={handleCancelEdit}>
{t("Cancel")}
</Button>
</Stack>
) : (
<Button
size="small"
variant="outlined"
onClick={() => handleStartEdit(detail)}
disabled={submitDisabled}
>
{!detail.stockTakeRecordId
? t("Input")
: detail.stockTakeRecordStatus === "notMatch"
? t("Input")
: t("View")}
</Button>
)}
</TableCell>
</TableRow>
);


+ 2
- 0
src/i18n/zh/inventory.json Dosyayı Görüntüle

@@ -7,6 +7,8 @@
"Qty": "盤點數量",
"UoM": "單位",
"mat": "物料",
"variance": "差異",
"Variance %": "差異百分比",
"fg": "成品",
"Back to List": "返回列表",
"Record Status": "記錄狀態",


Yükleniyor…
İptal
Kaydet