From 5624639013979bd3b063b725b259a070e4cf8707 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 26 May 2026 17:45:27 +0800 Subject: [PATCH] stock take update --- src/app/(main)/report/page.tsx | 98 +- src/app/api/bom/index.ts | 3 + src/app/api/stockTake/actions.ts | 76 +- src/app/api/warehouse/client.ts | 31 +- src/app/api/warehouse/index.ts | 18 + .../ImportBom/ImportBomDetailTab.tsx | 30 +- .../ImportBom/ImportBomResultForm.tsx | 35 +- .../ApproverStockTakeAll.tsx | 618 ++++++---- .../PickerBatchSaveFab.tsx | 50 + .../StockTakeManagement/PickerCardList.tsx | 1031 ++++++++++++++++- .../StockTakeManagement/PickerReStockTake.tsx | 146 ++- .../StockTakeManagement/PickerStockTake.tsx | 211 ++-- .../buildPickerBatchSaveRequests.ts | 72 ++ src/config/reportConfig.ts | 9 +- src/i18n/en/common.json | 6 +- src/i18n/en/inventory.json | 18 +- src/i18n/zh/common.json | 17 +- src/i18n/zh/dashboard.json | 2 +- src/i18n/zh/detailScheduling.json | 6 +- src/i18n/zh/do.json | 2 +- src/i18n/zh/inventory.json | 64 +- src/i18n/zh/items.json | 2 +- src/i18n/zh/jo.json | 30 +- src/i18n/zh/pickOrder.json | 16 +- src/i18n/zh/purchaseOrder.json | 2 +- src/i18n/zh/routeboard.json | 10 +- src/i18n/zh/schedule.json | 2 +- src/i18n/zh/user.json | 2 +- src/i18n/zh/warehouse.json | 4 +- 29 files changed, 2168 insertions(+), 443 deletions(-) create mode 100644 src/components/StockTakeManagement/PickerBatchSaveFab.tsx create mode 100644 src/components/StockTakeManagement/buildPickerBatchSaveRequests.ts diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index b9add6a..7d025f9 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -15,7 +15,9 @@ import { Grid, Divider, Chip, - Autocomplete + Autocomplete, + Checkbox, + FormControlLabel, } from '@mui/material'; import DownloadIcon from '@mui/icons-material/Download'; import { REPORTS, ReportDefinition } from '@/config/reportConfig'; @@ -50,6 +52,16 @@ export default function ReportPage() { const [showConfirmDialog, setShowConfirmDialog] = useState(false); // Find the configuration for the currently selected report + const rep012RoundIds = useMemo(() => { + if (selectedReportId !== 'rep-012') return [] as string[]; + return (criteria.stockTakeRoundId || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + }, [selectedReportId, criteria.stockTakeRoundId]); + + const rep012MultiRound = rep012RoundIds.length > 1; + const currentReport = useMemo(() => REPORTS.find((r) => r.id === selectedReportId), [selectedReportId]); @@ -151,6 +163,13 @@ export default function ReportPage() { } }, [selectedReportId]); + /** rep-012:多選輪次時狀態固定為已審核 */ + useEffect(() => { + if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; + if (criteria.status === 'completed') return; + setCriteria((prev) => ({ ...prev, status: 'completed' })); + }, [selectedReportId, rep012MultiRound, criteria.status]); + // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. // Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit. useEffect(() => { @@ -167,9 +186,20 @@ export default function ReportPage() { const validateRequiredFields = () => { if (!currentReport) return true; + if (currentReport.id === 'rep-012') { + if (rep012RoundIds.length === 0) { + alert('缺少必填條件:\n- 盤點輪次'); + return false; + } + return true; + } + // Mandatory Field Validation const missingFields = currentReport.fields - .filter(field => field.required && !criteria[field.name]) + .filter((field) => { + if (!field.required) return false; + return !criteria[field.name]; + }) .map(field => field.label); if (missingFields.length > 0) { @@ -180,6 +210,23 @@ export default function ReportPage() { return true; }; + /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ + const buildRep012QueryString = (): string => { + const p = new URLSearchParams(); + p.set('stockTakeRoundId', rep012RoundIds.join(',')); + const code = criteria.itemCode?.trim(); + if (code) p.set('itemCode', code); + const store = criteria.store_id?.trim(); + if (store && store !== 'All') p.set('store_id', store); + if (rep012MultiRound) { + p.set('status', 'completed'); + } else { + const status = criteria.status?.trim(); + if (status && status !== 'All') p.set('status', status); + } + return p.toString(); + }; + const handlePrint = async () => { if (!currentReport) return; if (!validateRequiredFields()) return; @@ -214,7 +261,10 @@ export default function ReportPage() { ); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. - const queryParams = new URLSearchParams(criteria).toString(); + const queryParams = + currentReport.id === 'rep-012' + ? buildRep012QueryString() + : new URLSearchParams(criteria).toString(); const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; const response = await clientAuthFetch(excelUrl, { @@ -267,7 +317,10 @@ export default function ReportPage() { setLoading(true); try { - const queryParams = new URLSearchParams(criteria).toString(); + const queryParams = + currentReport.id === 'rep-012' + ? buildRep012QueryString() + : new URLSearchParams(criteria).toString(); const url = `${currentReport.apiEndpoint}?${queryParams}`; const response = await clientAuthFetch(url, { @@ -346,7 +399,7 @@ export default function ReportPage() { - 搜尋條件: {currentReport.title} + 搜索條件: {currentReport.title} @@ -363,6 +416,33 @@ export default function ReportPage() { // Use larger grid size for 成品/半成品生產分析報告 const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const disabledByCheckedCheckbox = currentReport.fields.some((f) => { + if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; + return f.disablesFieldsWhenChecked?.includes(field.name) ?? false; + }); + const disabledRep012Status = + currentReport.id === 'rep-012' && + field.name === 'status' && + rep012MultiRound; + + if (field.type === 'checkbox') { + return ( + + + handleFieldChange(field.name, e.target.checked ? 'true' : '') + } + /> + } + label={field.label} + /> + + ); + } + // Use Autocomplete for fields that allow input if (field.type === 'select' && field.allowInput) { const autocompleteValue = field.multiple @@ -459,6 +539,7 @@ export default function ReportPage() { label={field.label} type={field.type} placeholder={field.placeholder} + disabled={disabledByCheckedCheckbox || disabledRep012Status} InputLabelProps={field.type === 'date' ? { shrink: true } : {}} sx={currentReport.id === 'rep-005' ? { '& .MuiOutlinedInput-root': { @@ -517,7 +598,12 @@ export default function ReportPage() { multiple: true, renderValue: (selected: any) => { if (Array.isArray(selected)) { - return selected.join(', '); + return selected + .map((v) => { + const opt = options.find((o) => o.value === v); + return opt?.label ?? String(v); + }) + .join(', '); } return selected; } diff --git a/src/app/api/bom/index.ts b/src/app/api/bom/index.ts index 968a0d9..4e63c2d 100644 --- a/src/app/api/bom/index.ts +++ b/src/app/api/bom/index.ts @@ -32,6 +32,7 @@ export interface ImportBomItemPayload { fileName: string; isAlsoWip: boolean; isDrink: boolean; + isPowderMixture: boolean; } export const preloadBomCombo = (() => { @@ -90,6 +91,7 @@ export interface BomDetailResponse { isFloat?: number; isDense?: number; isDrink?: boolean; + isPowderMixture?: boolean; scrapRate?: number; allergicSubstances?: number; timeSequence?: number; @@ -118,6 +120,7 @@ export interface EditBomRequest { timeSequence?: number; complexity?: number; isDrink?: boolean; + isPowderMixture?: boolean; materials?: EditBomMaterialRequest[]; processes?: EditBomProcessRequest[]; diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 77f6eed..c2a971d 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -150,6 +150,9 @@ export type ApproverInventoryLotDetailsQuery = { sectionDescription?: string | null; stockTakeSections?: string | null; warehouseKeyword?: string | null; + variancePercentTolerance?: string | null; + varianceFilterInclusive?: boolean | null; + varianceFilterStrict?: boolean | null; }; function appendApproverInventoryLotQueryParams( @@ -173,6 +176,15 @@ function appendApproverInventoryLotQueryParams( if (query.stockTakeSections != null && query.stockTakeSections.trim() !== "") { params.append("stockTakeSections", query.stockTakeSections.trim()); } + if (query.variancePercentTolerance != null && query.variancePercentTolerance.trim() !== "") { + params.append("variancePercentTolerance", query.variancePercentTolerance.trim()); + } + if (query.varianceFilterInclusive === true) { + params.append("varianceFilterInclusive", "true"); + } + if (query.varianceFilterStrict === true) { + params.append("varianceFilterStrict", "true"); + } } export const getApproverInventoryLotDetailsAll = async ( @@ -306,15 +318,29 @@ export const getLatestApproverStockTakeHeader = async () => { { method: "GET" } ); } -export const createStockTakeForSections = async () => { - const createStockTakeForSections = await serverFetchJson>( +export const createStockTakeForSections = async ( + sections: string[], + stockTakeRoundName?: string | null, + planStart?: string | null, +) => { + const trimmedName = stockTakeRoundName?.trim(); + const trimmedPlanStart = planStart?.trim(); + return serverFetchJson>( `${BASE_API_URL}/stockTake/createForSections`, { method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sections, + ...(trimmedName ? { stockTakeRoundName: trimmedName } : {}), + ...(trimmedPlanStart ? { planStart: trimmedPlanStart } : {}), + }), }, ); - return createStockTakeForSections; } + export const saveStockTakeRecord = async ( request: SaveStockTakeRecordRequest, stockTakeId: number, @@ -374,6 +400,48 @@ export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRe return r }) + +export interface BatchSavePickerStockTakeInputRequest { + stockTakeId: number; + stockTakeSection: string; + stockTakerId: number; + records: SaveStockTakeRecordRequest[]; +} + +export const batchSavePickerStockTakeInputs = async ( + data: BatchSavePickerStockTakeInputRequest +) => { + try { + return await serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/batchSavePickerStockTakeInputs`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + } + ); + } catch (error: unknown) { + if (error && typeof error === "object" && "response" in error) { + const err = error as { response?: Response }; + if (err.response) { + try { + const errorData = await err.response.json(); + const errorWithMessage = new Error( + (errorData as { message?: string }).message || + (errorData as { error?: string }).error || + "Failed to batch save picker stock take inputs" + ); + throw errorWithMessage; + } catch (inner) { + if (inner instanceof Error && inner.message !== "Failed to batch save picker stock take inputs") { + throw inner; + } + } + } + } + throw error; + } +}; // Add these interfaces and functions export interface SaveApproverStockTakeRecordRequest { @@ -410,7 +478,7 @@ export interface BatchSaveApproverStockTakeAllRequest { approverId: number; // UI 用,batch 不應該用它來 skip variancePercentTolerance?: number | null; - // 新增:讓 batch 只處理搜尋結果那批 + // 新增:讓 batch 只處理搜索結果那批 itemKeyword?: string | null; warehouseKeyword?: string | null; sectionDescription?: string | null; diff --git a/src/app/api/warehouse/client.ts b/src/app/api/warehouse/client.ts index 8d49b36..aff2e23 100644 --- a/src/app/api/warehouse/client.ts +++ b/src/app/api/warehouse/client.ts @@ -1,7 +1,7 @@ "use client"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; -import { WarehouseResult } from "./index"; +import { MissingStockTakeSectionIssuesResponse, WarehouseResult } from "./index"; export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { @@ -78,4 +78,31 @@ export const fetchWarehouseListClient = async (): Promise => return response.json(); }; -//test \ No newline at end of file + +export const fetchMissingStockTakeSectionIssues = async ( + limit = 50, +): Promise => { + const token = localStorage.getItem("accessToken"); + const params = new URLSearchParams({ limit: String(limit) }); + const response = await fetch( + `${NEXT_PUBLIC_API_URL}/warehouse/missingStockTakeSectionIssues?${params.toString()}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + ...(token && { Authorization: `Bearer ${token}` }), + }, + }, + ); + + if (!response.ok) { + if (response.status === 401) { + throw new Error("Unauthorized: Please log in again"); + } + throw new Error( + `Failed to fetch missing stock take section issues: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); +}; \ No newline at end of file diff --git a/src/app/api/warehouse/index.ts b/src/app/api/warehouse/index.ts index cc1b185..8ef75ca 100644 --- a/src/app/api/warehouse/index.ts +++ b/src/app/api/warehouse/index.ts @@ -40,5 +40,23 @@ export interface StockTakeSectionInfo { stockTakeSection: string; stockTakeSectionDescription: string | null; storeId?: string | null; + /** 倉庫區域(area),與盤點卡片上的 warehouseArea 對應 */ + warehouseArea?: string | null; warehouseCount: number; +} + +export interface MissingStockTakeSectionIssueItem { + id: number; + code?: string | null; + storeId?: string | null; + warehouse?: string | null; + area?: string | null; + slot?: string | null; + order?: string | null; +} + +export interface MissingStockTakeSectionIssuesResponse { + count: number; + limit: number; + items: MissingStockTakeSectionIssueItem[]; } \ No newline at end of file diff --git a/src/components/ImportBom/ImportBomDetailTab.tsx b/src/components/ImportBom/ImportBomDetailTab.tsx index 583dc33..88b0b7d 100644 --- a/src/components/ImportBom/ImportBomDetailTab.tsx +++ b/src/components/ImportBom/ImportBomDetailTab.tsx @@ -117,6 +117,7 @@ const ImportBomDetailTab: React.FC = () => { timeSequence: number; complexity: number; isDrink: boolean; + isPowderMixture: boolean; } | null>(null); const [editMaterials, setEditMaterials] = useState([]); @@ -315,6 +316,7 @@ const ImportBomDetailTab: React.FC = () => { timeSequence: detail.timeSequence ?? 0, complexity: detail.complexity ?? 0, isDrink: detail.isDrink ?? false, + isPowderMixture: detail.isPowderMixture ?? false, }); setEditMaterials( @@ -520,6 +522,7 @@ const ImportBomDetailTab: React.FC = () => { timeSequence: editBasic.timeSequence, complexity: editBasic.complexity, isDrink: editBasic.isDrink, + isPowderMixture: editBasic.isPowderMixture, processes: editProcesses.map((p) => { const ed = p.equipmentDescription.trim(); const en = p.equipmentName.trim(); @@ -838,13 +841,38 @@ const ImportBomDetailTab: React.FC = () => { checked={editBasic.isDrink} onChange={(e) => setEditBasic((p) => - p ? { ...p, isDrink: e.target.checked } : p + p + ? { + ...p, + isDrink: e.target.checked, + isPowderMixture: e.target.checked ? false : p.isPowderMixture, + } + : p ) } /> } label={t("Is Drink")} /> + + setEditBasic((p) => + p + ? { + ...p, + isPowderMixture: e.target.checked, + isDrink: e.target.checked ? false : p.isDrink, + } + : p + ) + } + /> + } + label={t("Powder_Mixture")} + /> )} diff --git a/src/components/ImportBom/ImportBomResultForm.tsx b/src/components/ImportBom/ImportBomResultForm.tsx index 7b0e007..223db70 100644 --- a/src/components/ImportBom/ImportBomResultForm.tsx +++ b/src/components/ImportBom/ImportBomResultForm.tsx @@ -18,7 +18,12 @@ import SearchIcon from "@mui/icons-material/Search"; import type { BomFormatFileGroup } from "@/app/api/bom"; import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client"; import { useTranslation } from "react-i18next"; -type CorrectItem = { fileName: string; isAlsoWip: boolean; isDrink: boolean }; +type CorrectItem = { + fileName: string; + isAlsoWip: boolean; + isDrink: boolean; + isPowderMixture: boolean; +}; type Props = { batchId: string; @@ -40,7 +45,12 @@ type Props = { const { t } = useTranslation("common"); const [search, setSearch] = useState(""); const [items, setItems] = useState(() => - correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false, isDrink: false })) + correctFileNames.map((fileName) => ({ + fileName, + isAlsoWip: false, + isDrink: false, + isPowderMixture: fileName.includes("箱料粉"), + })) ); const [submitting, setSubmitting] = useState(false); const [successMsg, setSuccessMsg] = useState(null); @@ -64,7 +74,16 @@ type Props = { setItems((prev) => prev.map((x) => x.fileName === fileName - ? { ...x, isDrink: !x.isDrink } + ? { ...x, isDrink: !x.isDrink, isPowderMixture: false } + : x + ) + ); + }; + const handleTogglePowderMixture = (fileName: string) => { + setItems((prev) => + prev.map((x) => + x.fileName === fileName + ? { ...x, isPowderMixture: !x.isPowderMixture, isDrink: false } : x ) ); @@ -99,6 +118,8 @@ type Props = { }; const wipCount = items.filter((i) => i.isAlsoWip).length; + const powderMixtureCount = items.filter((i) => i.isPowderMixture).length; + const drinkCount = items.filter((i) => i.isDrink).length; const totalChecked = correctFileNames.length + failList.length; return ( @@ -151,6 +172,7 @@ type Props = { {t("WIP")} {t("Drink")} + {t("Powder_Mixture")} {t("File Name")} {filteredCorrect.map((item) => ( @@ -170,6 +192,11 @@ type Props = { onChange={() => handleToggleDrink(item.fileName)} size="small" /> + handleTogglePowderMixture(item.fileName)} + size="small" + /> 將匯入 {items.length} 個 BOM {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""} + {drinkCount > 0 ? `,${drinkCount} 個飲料` : ""} + {powderMixtureCount > 0 ? `,${powderMixtureCount} 個箱料粉` : ""} )} diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 16d9be8..81ace7d 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -27,8 +27,14 @@ import { Select, MenuItem, Autocomplete, + FormControlLabel, + Checkbox, + Dialog, + DialogTitle, + DialogContent, + DialogActions, } from "@mui/material"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { Collapse } from "@mui/material"; import Accordion from "@mui/material/Accordion"; import AccordionSummary from "@mui/material/AccordionSummary"; @@ -81,14 +87,21 @@ type ApproverSearchFilters = { warehouseKeyword: string; storeId: string; status: string; + variancePercentTolerance: string; + varianceFilterInclusive: boolean; + varianceFilterStrict: boolean; }; function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverInventoryLotDetailsQuery { + const tolerance = filters.variancePercentTolerance.trim(); return { sectionDescription: filters.sectionDescription !== "All" ? filters.sectionDescription : undefined, stockTakeSections: filters.stockTakeSession.trim() ? filters.stockTakeSession.trim() : undefined, itemKeyword: filters.itemKeyword.trim() ? filters.itemKeyword.trim() : undefined, warehouseKeyword: filters.warehouseKeyword.trim() ? filters.warehouseKeyword.trim() : undefined, + variancePercentTolerance: tolerance !== "" ? tolerance : undefined, + varianceFilterInclusive: filters.varianceFilterInclusive, + varianceFilterStrict: filters.varianceFilterStrict, }; } @@ -188,7 +201,9 @@ const ApproverStockTakeAll: React.FC = ({ const [inventoryLotDetails, setInventoryLotDetails] = useState([]); const [loadingDetails, setLoadingDetails] = useState(false); - const [variancePercentTolerance, setVariancePercentTolerance] = useState("5"); + const [searchVariancePercentTolerance, setSearchVariancePercentTolerance] = useState("5"); + const [searchVarianceFilterInclusive, setSearchVarianceFilterInclusive] = useState(false); + const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); const [qtySelection, setQtySelection] = useState>({}); const [approverQty, setApproverQty] = useState>({}); const [approverBadQty, setApproverBadQty] = useState>({}); @@ -211,6 +226,8 @@ const ApproverStockTakeAll: React.FC = ({ const [searchStatus, setSearchStatus] = useState(mode === "pending" ? "pass" : "All"); const [showFilters, setShowFilters] = useState(true) const [appliedFilters, setAppliedFilters] = useState(null); + const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false); + const batchSaveInFlightRef = useRef(false); const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -239,10 +256,24 @@ const ApproverStockTakeAll: React.FC = ({ warehouseKeyword: searchWarehouseKeyword || "", storeId: searchStoreId || "All", status: mode === "pending" ? (searchStatus || "pass") : "All", + variancePercentTolerance: searchVariancePercentTolerance, + varianceFilterInclusive: searchVarianceFilterInclusive, + varianceFilterStrict: searchVarianceFilterStrict, }; setAppliedFilters(next); setPage(0); - }, [searchSectionDescription, searchStockTakeSession, searchItemKeyword, searchWarehouseKeyword, searchStoreId, searchStatus, mode]); + }, [ + searchSectionDescription, + searchStockTakeSession, + searchItemKeyword, + searchWarehouseKeyword, + searchStoreId, + searchStatus, + searchVariancePercentTolerance, + searchVarianceFilterInclusive, + searchVarianceFilterStrict, + mode, + ]); const handleResetSearch = useCallback(() => { const defaultStatus = mode === "pending" ? "pass" : "All"; @@ -252,6 +283,9 @@ const ApproverStockTakeAll: React.FC = ({ setSearchWarehouseKeyword(""); setSearchStoreId("All"); setSearchStatus(defaultStatus); + setSearchVariancePercentTolerance("5"); + setSearchVarianceFilterInclusive(false); + setSearchVarianceFilterStrict(false); setAppliedFilters(null); setPage(0); setInventoryLotDetails([]); @@ -283,6 +317,7 @@ const ApproverStockTakeAll: React.FC = ({ console.timeEnd("🔥 Total time from API call to DataGrid ready"); setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); + setTotal(response.total ?? response.records?.length ?? 0); console.log(`Loaded ${response.records?.length || 0} rows from backend`); } catch (e) { console.error(e); @@ -455,53 +490,22 @@ const ApproverStockTakeAll: React.FC = ({ .toLowerCase() .replaceAll("_", ""); const filteredDetails = useMemo(() => { - const percent = parseFloat(variancePercentTolerance || "0"); - const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; - const statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All"; const storeIdFilter = appliedFilters?.storeId ?? "All"; - return inventoryLotDetails.filter((detail) => { - if (storeIdFilter !== "All") { - if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) { - return false; - } - } - if (statusFilter !== "All") { - const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); - const wanted = normalizeStatus(statusFilter); - if (rowStatus !== wanted) return false; - } - /* - if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") { - return true; + return inventoryLotDetails.filter((detail) => { + if (storeIdFilter !== "All") { + if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) { + return false; + } } - */ - const selection = - qtySelection[detail.id] ?? - (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0 - ? "second" - : "first"); - // 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交” - - if (selection === "approver") { - return true; + if (statusFilter !== "All") { + const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); + const wanted = normalizeStatus(statusFilter); + if (rowStatus !== wanted) return false; } - - 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 true; }); - }, [ - inventoryLotDetails, - variancePercentTolerance, - qtySelection, - calculateDifference, - appliedFilters, - mode, - ]); + }, [inventoryLotDetails, appliedFilters, mode]); const sortedDetails = useMemo(() => { const list = [...filteredDetails]; @@ -708,34 +712,71 @@ const ApproverStockTakeAll: React.FC = ({ // 只保留数字 return value.replace(/[^\d]/g, ""); }; - + + const sanitizePositiveDecimalInput = (value: string) => { + const unsigned = value.replace(/[^\d.]/g, ""); + const parts = unsigned.split("."); + return parts.length <= 1 ? unsigned : `${parts[0]}.${parts.slice(1).join("")}`; + }; + const isIntegerString = (value: string) => /^\d+$/.test(value); - const handleBatchSubmitAll = useCallback(async () => { + const batchSaveRecordIds = useMemo( + () => + sortedDetails + .map((d) => d.stockTakeRecordId) + .filter((id): id is number => typeof id === "number" && id > 0), + [sortedDetails], + ); + + const batchSaveSectionLabels = useMemo(() => { + const labels = new Set(); + sortedDetails.forEach((d) => { + const sec = d.stockTakeSection?.trim(); + if (sec) labels.add(sec); + }); + return Array.from(labels).sort((a, b) => a.localeCompare(b)); + }, [sortedDetails]); + + const handleOpenBatchSaveConfirm = useCallback(() => { if (mode === "approved") return; - if (!selectedSession || !currentUserId) { - return; - } + if (!selectedSession || !currentUserId) return; if (inventoryLotDetails.length === 0) { onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); return; } + if (batchSaveRecordIds.length === 0) { + onSnackbar(t("No valid records to batch save"), "warning"); + return; + } + setOpenBatchSaveConfirmDialog(true); + }, [ + mode, + selectedSession, + currentUserId, + inventoryLotDetails.length, + batchSaveRecordIds.length, + onSnackbar, + t, + ]); + + const handleBatchSubmitAll = useCallback(async () => { + if (batchSaveInFlightRef.current) return; + if (mode === "approved") return; + if (!selectedSession || !currentUserId) return; + if (batchSaveRecordIds.length === 0) { + onSnackbar(t("No valid records to batch save"), "warning"); + return; + } + batchSaveInFlightRef.current = true; setBatchSaving(true); + setOpenBatchSaveConfirmDialog(false); try { - const recordIds = sortedDetails - .map((d) => d.stockTakeRecordId) - .filter((id): id is number => typeof id === "number" && id > 0); - - if (recordIds.length === 0) { - onSnackbar(t("No valid records to batch save"), "warning"); - return; - } - const result = await batchSaveApproverStockTakeRecordsByIds({ stockTakeId: selectedSession.stockTakeId, approverId: currentUserId, - recordIds, + recordIds: batchSaveRecordIds, }); onSnackbar( @@ -743,31 +784,32 @@ const ApproverStockTakeAll: React.FC = ({ success: result.successCount, errors: result.errorCount, }), - result.errorCount > 0 ? "warning" : "success" + result.errorCount > 0 ? "warning" : "success", ); if (appliedFilters && result.successCount > 0) { await loadDetails(appliedFilters); } - } catch (e: any) { + } catch (e: unknown) { console.error("handleBatchSubmitAll (all): Error:", e); let errorMessage = t("Failed to batch save approver stock take records"); - if (e?.message) { + if (e instanceof Error && e.message) { errorMessage = e.message; } onSnackbar(errorMessage, "error"); } finally { setBatchSaving(false); + batchSaveInFlightRef.current = false; } }, [ + mode, selectedSession, currentUserId, + batchSaveRecordIds, t, onSnackbar, loadDetails, - mode, appliedFilters, - inventoryLotDetails.length, ]); const formatNumber = (num: number | null | undefined): string => { @@ -864,9 +906,9 @@ const ApproverStockTakeAll: React.FC = ({ }, { field: "qtyBlock", - headerName: t("Stock Take Qty(include Bad Qty)= Available Qty"), - minWidth: 320, - flex: 3, + headerName: t("Stock Take Qty Data and Variance Analysis"), + minWidth: 480, + flex: 3.2, sortable: false, renderCell: (params: GridRenderCellParams) => { const detail = params.row; @@ -897,7 +939,38 @@ const ApproverStockTakeAll: React.FC = ({ const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; const approverGoodQty = approverQtyNum - approverBadQtyNum; - const variancePercentage = (difference / bookQty) * 100; + const variancePercentage = + bookQty !== 0 + ? (difference / bookQty) * 100 + : difference !== 0 + ? difference > 0 + ? 100 + : -100 + : 0; + const hasVariance = difference !== 0; + const pctLabel = `${variancePercentage >= 0 ? "" : ""}${variancePercentage.toFixed(0)}%`; + + const summaryLine = (label: string, value: string, valueColor?: string) => ( + + + {label} + + + {value} + + + ); + return ( {!showRadioBlock ? ( @@ -905,130 +978,156 @@ const ApproverStockTakeAll: React.FC = ({ - ) : ( - - {hasFirst && ( - - - setQtySelection({ - ...qtySelection, - [detail.id]: "first", - }) - } - /> - - {t("First")}:{" "} - {formatNumber( - (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) - )}{" "} - {/* ({detail.firstBadQty ?? 0}) */} - ={" "} - {formatNumber(detail.firstStockTakeQty ?? 0)} - - - )} - - {hasSecond && ( - - - setQtySelection({ - ...qtySelection, - [detail.id]: "second", - }) - } - /> - - {t("Second")}:{" "} - {formatNumber( - (detail.secondStockTakeQty ?? 0) + - (detail.secondBadQty ?? 0) - )}{" "} - {/* ({detail.secondBadQty ?? 0}) */} - ={" "} - {formatNumber(detail.secondStockTakeQty ?? 0)} - - - )} - - {canApprover && ( - - - setQtySelection({ - ...qtySelection, - [detail.id]: "approver", - }) - } - /> - {t("Approver Input")}: - - { - const clean = sanitizeIntegerInput(e.target.value); - setApproverQty({ - ...approverQty, - [detail.id]: clean, - }); - }} - sx={{ width: 90, minWidth: 90 }} - placeholder={t("Stock Take Qty")} - disabled={mode === "approved" || selection !== "approver"} - inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} - /> - - {/* - { - const clean = sanitizeIntegerInput(e.target.value); - setApproverBadQty({ - ...approverBadQty, - [detail.id]: clean, - }); + + + {hasFirst && ( + + + setQtySelection({ + ...qtySelection, + [detail.id]: "first", + }) + } + /> + + {t("First")}:{" "} + {formatNumber( + (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) + )}{" "} + {/* + = {formatNumber(detail.firstStockTakeQty ?? 0)} + */} + + + )} + + {hasSecond && ( + + + setQtySelection({ + ...qtySelection, + [detail.id]: "second", + }) + } + /> + + {t("Second")}:{" "} + {formatNumber( + (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0) + )}{" "} + {/* + = {formatNumber(detail.secondStockTakeQty ?? 0)} + */} + + + + )} + + {canApprover && ( + + + setQtySelection({ + ...qtySelection, + [detail.id]: "approver", + }) + } + /> + + {t("Approver Input")}: + + { + const clean = sanitizeIntegerInput(e.target.value); + setApproverQty({ + ...approverQty, + [detail.id]: clean, + }); + }} + sx={{ + width: 72, + minWidth: 72, + "& .MuiInputBase-input": { + py: 0.5, + px: 1, + }, + }} + // placeholder={t("Stock Take Qty")} + disabled={mode === "approved" || selection !== "approver"} + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + /> + {/* + + = {formatNumber(approverGoodQty)} + + */} + + )} + + + + + {summaryLine( + `${t("Selected Qty")}:`, + formatNumber(selectedQty) + )} + {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} + {summaryLine( + `${t("Inventory Difference")}:`, + formatNumber(difference), + hasVariance ? "error.main" : "text.primary" + )} + - */ - } - - = {formatNumber(approverGoodQty)} - + > + + {t("variance Percentage")}: {pctLabel} + + - )} - - - - - {t("Selected Qty")}: {formatNumber(selectedQty)}{" "} - - {t("Book Qty")}: {formatNumber(bookQty)}{" "} - = {t("Difference")}: {formatNumber(difference)} - - - - {t("variance Percentage")}: {variancePercentage.toFixed(0) + "%"} - - - + )} @@ -1049,6 +1148,7 @@ const ApproverStockTakeAll: React.FC = ({ } cols.push( + /* { field: "remarks", headerName: t("Remark"), @@ -1061,6 +1161,7 @@ const ApproverStockTakeAll: React.FC = ({ ), }, + */ { field: "stockTakeRecordStatus", headerName: t("Record Status"), @@ -1211,32 +1312,11 @@ const ApproverStockTakeAll: React.FC = ({ - - {t("-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out", { - Variance: variancePercentTolerance || "0", - })} - - { - const clean = sanitizeIntegerInput(e.target.value); - setVariancePercentTolerance(clean); - - }} - - label={t("Variance %")} - sx={{ width: 100 }} - - inputProps={{ min: 0, max: 100, step: 0.1 }} - /> {mode === "pending" && ( + + + ); }; diff --git a/src/components/StockTakeManagement/PickerBatchSaveFab.tsx b/src/components/StockTakeManagement/PickerBatchSaveFab.tsx new file mode 100644 index 0000000..54660f9 --- /dev/null +++ b/src/components/StockTakeManagement/PickerBatchSaveFab.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Box, CircularProgress, Fab, Tooltip } from "@mui/material"; +import SaveIcon from "@mui/icons-material/Save"; + +interface PickerBatchSaveFabProps { + onClick: () => void; + disabled: boolean; + loading: boolean; + label: string; +} + +const PickerBatchSaveFab: React.FC = ({ + onClick, + disabled, + loading, + label, +}) => ( + + + + {loading ? ( + + ) : ( + + )} + + + +); + +export default PickerBatchSaveFab; diff --git a/src/components/StockTakeManagement/PickerCardList.tsx b/src/components/StockTakeManagement/PickerCardList.tsx index 3e6529a..12d6f35 100644 --- a/src/components/StockTakeManagement/PickerCardList.tsx +++ b/src/components/StockTakeManagement/PickerCardList.tsx @@ -15,18 +15,26 @@ import { Dialog, DialogTitle, DialogContent, - DialogContentText, DialogActions, TextField, + InputAdornment, FormControl, InputLabel, Select, MenuItem, Autocomplete, + Badge, + IconButton, + Tooltip, + Drawer, + Divider, } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import NotificationsNoneOutlinedIcon from "@mui/icons-material/NotificationsNoneOutlined"; +import { useRouter } from "next/navigation"; import { SessionWithTokens } from "@/config/authConfig"; import { useSession } from "next-auth/react"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { useTranslation } from "react-i18next"; import duration from "dayjs/plugin/duration"; import { @@ -36,10 +44,56 @@ import { } from "@/app/api/stockTake/actions"; import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; -import dayjs from "dayjs"; -import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; +import { fetchMissingStockTakeSectionIssues } from "@/app/api/warehouse/client"; +import type { MissingStockTakeSectionIssueItem, StockTakeSectionInfo } from "@/app/api/warehouse"; +import dayjs, { type Dayjs } from "dayjs"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { + OUTPUT_DATE_FORMAT, + OUTPUT_DATETIME_FORMAT, + dayjsToDateTimeString, +} from "@/app/utils/formatUtil"; import { AUTH } from "@/authorities"; +const FLOOR_GROUP_UNSET = "__UNSET__"; +/** 移動超過此距離才視為框選(Windows 橡皮筋),否則視為單擊 */ +const MARQUEE_DRAG_THRESHOLD_PX = 4; +/** 左側樓層列表超過此數量才啟用捲動 */ +const FLOOR_LIST_SCROLL_THRESHOLD = 8; + +const createDialogCompactInputSx = { + mt: 0, + mb: 0, + "& .MuiInputBase-root": { + bgcolor: "background.paper", + }, + "& .MuiOutlinedInput-input": { + py: "8.5px", + }, + "& .MuiInputAdornment-positionStart": { + marginRight: 0.5, + }, + "& .MuiInputBase-input::placeholder": { + opacity: 1, // MUI 預設 opacity 較低,設 1 顏色才準 + color: "text.secondary", // 中等灰;要更淡用 "text.disabled" + }, +} as const; + +type MarqueeRect = { left: number; top: number; width: number; height: number }; + +function rectsIntersect(a: DOMRect, b: DOMRect): boolean { + return !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom); +} + +function clientMarqueeRect(startX: number, startY: number, endX: number, endY: number): DOMRect { + const left = Math.min(startX, endX); + const top = Math.min(startY, endY); + const right = Math.max(startX, endX); + const bottom = Math.max(startY, endY); + return { left, top, right, bottom, width: right - left, height: bottom - top } as DOMRect; +} + interface PickerCardListProps { /** 由父層保存,從明細返回時仍回到同一頁 */ page: number; @@ -73,6 +127,7 @@ const PickerCardList: React.FC = ({ onAppliedFiltersChange, }) => { const { t } = useTranslation(["inventory", "common"]); + const router = useRouter(); dayjs.extend(duration); const [loading, setLoading] = useState(false); @@ -85,6 +140,36 @@ const PickerCardList: React.FC = ({ const [listRefreshNonce, setListRefreshNonce] = useState(0); const [creating, setCreating] = useState(false); const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + const [openCreateStockTakeSummaryConfirm, setOpenCreateStockTakeSummaryConfirm] = useState(false); + const [missingSectionWarnDrawerOpen, setMissingSectionWarnDrawerOpen] = useState(false); + const [missingSectionCount, setMissingSectionCount] = useState(0); + const [missingSectionItems, setMissingSectionItems] = useState([]); + const [missingSectionIssuesLimit, setMissingSectionIssuesLimit] = useState(50); + const [missingSectionIssuesLoading, setMissingSectionIssuesLoading] = useState(false); + const [createDialogSelectedSections, setCreateDialogSelectedSections] = useState([]); + const [createDialogRoundName, setCreateDialogRoundName] = useState(""); + const [createDialogPlanStart, setCreateDialogPlanStart] = useState(() => dayjs()); + /** 建立盤點對話框:目前選中的樓層分頁(對應 sortedFloorKeys 索引) */ + const [createFloorTabIndex, setCreateFloorTabIndex] = useState(0); + const [createDialogSearchQuery, setCreateDialogSearchQuery] = useState(""); + const [sectionsLoading, setSectionsLoading] = useState(false); + const [stockTakeSectionRows, setStockTakeSectionRows] = useState([]); + const createStockTakeInFlightRef = useRef(false); + const createSectionGridScrollRef = useRef(null); + const createSectionCardRefs = useRef>(new Map()); + const marqueeDragRef = useRef<{ + pointerId: number; + startClientX: number; + startClientY: number; + currentClientX: number; + currentClientY: number; + baseSelection: Set; + isMarquee: boolean; + pointerDownSection: string | null; + sectionIds: string[]; + } | null>(null); + const [marqueeRect, setMarqueeRect] = useState(null); + const [marqueePreviewIds, setMarqueePreviewIds] = useState>(() => new Set()); const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState([]); const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState([]); const [storeIdOptions, setStoreIdOptions] = useState(["2F", "4F"]); @@ -124,6 +209,7 @@ const PickerCardList: React.FC = ({ status: appliedFilters.status, area: appliedFilters.area, storeId: appliedFilters.storeId, + onlyLatestRound: true, }) .then((res) => { if (cancelled) return; @@ -148,33 +234,354 @@ const PickerCardList: React.FC = ({ //const startIdx = page * PER_PAGE; //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); + useEffect(() => { + if (openConfirmDialog) { + setCreateDialogSelectedSections([]); + setCreateDialogRoundName(""); + setCreateDialogPlanStart(dayjs()); + setCreateFloorTabIndex(0); + setCreateDialogSearchQuery(""); + marqueeDragRef.current = null; + setMarqueeRect(null); + setMarqueePreviewIds(new Set()); + setOpenCreateStockTakeSummaryConfirm(false); + } + }, [openConfirmDialog]); + + const loadMissingStockTakeSectionIssues = useCallback(async () => { + setMissingSectionIssuesLoading(true); + try { + const res = await fetchMissingStockTakeSectionIssues(50); + setMissingSectionCount(res.count); + setMissingSectionItems(Array.isArray(res.items) ? res.items : []); + setMissingSectionIssuesLimit(res.limit ?? 50); + } catch (e) { + console.error("Failed to load missing stock take section issues:", e); + setMissingSectionCount(0); + setMissingSectionItems([]); + } finally { + setMissingSectionIssuesLoading(false); + } + }, []); + + useEffect(() => { + if (!openConfirmDialog) return; + void loadMissingStockTakeSectionIssues(); + }, [openConfirmDialog, loadMissingStockTakeSectionIssues]); + + const handleGoWarehouseSettings = useCallback(() => { + setMissingSectionWarnDrawerOpen(false); + setOpenConfirmDialog(false); + router.push("/settings/warehouse"); + }, [router]); + + const createStockTakeSummarySections = useMemo(() => { + return [...createDialogSelectedSections].sort((a, b) => a.localeCompare(b)); + }, [createDialogSelectedSections]); + + const handleOpenCreateStockTakeSummaryConfirm = useCallback(() => { + if (createDialogSelectedSections.length === 0) return; + if (createDialogPlanStart == null || !createDialogPlanStart.isValid()) return; + setOpenCreateStockTakeSummaryConfirm(true); + }, [createDialogSelectedSections.length, createDialogPlanStart]); + + /** 盤點區域 → 樓層:優先 API(warehouse 帶 storeId),否則用列表卡片上的 storeId */ + const sectionsByStore = useMemo(() => { + const sectionToFloor = new Map(); + stockTakeSectionRows.forEach((row) => { + const sec = row.stockTakeSection?.trim(); + if (!sec) return; + const sid = row.storeId?.trim(); + if (sid) sectionToFloor.set(sec, sid); + }); + stockTakeSessions.forEach((session) => { + const sec = session.stockTakeSession?.trim(); + if (!sec) return; + const sid = session.storeId?.trim(); + if (sid && !sectionToFloor.has(sec)) sectionToFloor.set(sec, sid); + }); + + const allSecs = new Set(); + stockTakeSectionRows.forEach((r) => { + const s = r.stockTakeSection?.trim(); + if (s) allSecs.add(s); + }); + stockTakeSectionOptions.forEach((s) => { + const x = s.trim(); + if (x) allSecs.add(x); + }); + + const byFloor = new Map>(); + allSecs.forEach((sec) => { + const floorKey = sectionToFloor.get(sec)?.trim() || FLOOR_GROUP_UNSET; + if (!byFloor.has(floorKey)) byFloor.set(floorKey, new Set()); + byFloor.get(floorKey)!.add(sec); + }); + + const out = new Map(); + byFloor.forEach((set, key) => { + out.set(key, Array.from(set).sort((a, b) => a.localeCompare(b))); + }); + return out; + }, [stockTakeSectionRows, stockTakeSessions, stockTakeSectionOptions]); + + const sortedFloorKeys = useMemo(() => { + const keys = Array.from(sectionsByStore.keys()); + keys.sort((a, b) => { + if (a === FLOOR_GROUP_UNSET) return 1; + if (b === FLOOR_GROUP_UNSET) return -1; + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); + }); + return keys; + }, [sectionsByStore]); + + const allSectionsForCreateDialog = useMemo(() => { + const s = new Set(); + sectionsByStore.forEach((arr) => arr.forEach((x) => s.add(x))); + return Array.from(s).sort((a, b) => a.localeCompare(b)); + }, [sectionsByStore]); + + useEffect(() => { + if (createFloorTabIndex >= sortedFloorKeys.length && sortedFloorKeys.length > 0) { + setCreateFloorTabIndex(0); + } + }, [sortedFloorKeys.length, createFloorTabIndex]); + + const floorGroupLabel = useCallback( + (storeKey: string) => (storeKey === FLOOR_GROUP_UNSET ? t("Floor unassigned") : storeKey), + [t], + ); + + const toggleFloorSelectAll = useCallback( + (storeKey: string) => { + const list = sectionsByStore.get(storeKey) ?? []; + setCreateDialogSelectedSections((prev) => { + const allIn = list.length > 0 && list.every((s) => prev.includes(s)); + if (allIn) { + return prev.filter((s) => !list.includes(s)); + } + const next = new Set(prev); + list.forEach((s) => next.add(s)); + return Array.from(next).sort((a, b) => a.localeCompare(b)); + }); + }, + [sectionsByStore], + ); + + /** 與卡片標題括號內相同:描述 / 區域 / 樓層 */ + const getCreateDialogSectionMeta = useCallback( + (section: string) => { + const row = stockTakeSectionRows.find((r) => r.stockTakeSection?.trim() === section); + const fromList = stockTakeSessions.find((s) => s.stockTakeSession?.trim() === section); + const desc = row?.stockTakeSectionDescription?.trim() || fromList?.stockTakeSectionDescription?.trim() || ""; + const area = row?.warehouseArea?.trim() || fromList?.warehouseArea?.trim() || ""; + // const store = row?.storeId?.trim() || fromList?.storeId?.trim() || ""; + const parts = [desc, area].filter((v) => Boolean(v && v.trim())); + return parts.length ? parts.join(" / ") : ""; + }, + [stockTakeSectionRows, stockTakeSessions], + ); + + const activeCreateFloorKey = useMemo(() => { + if (sortedFloorKeys.length === 0) return null; + return sortedFloorKeys[Math.min(createFloorTabIndex, sortedFloorKeys.length - 1)]; + }, [sortedFloorKeys, createFloorTabIndex]); + + const activeCreateFloorSections = useMemo(() => { + if (activeCreateFloorKey == null) return []; + return sectionsByStore.get(activeCreateFloorKey) ?? []; + }, [activeCreateFloorKey, sectionsByStore]); + + const createDialogFilteredSections = useMemo(() => { + const q = createDialogSearchQuery.trim().toLowerCase(); + if (!q) return activeCreateFloorSections; + return activeCreateFloorSections.filter((section) => { + const meta = getCreateDialogSectionMeta(section); + return section.toLowerCase().includes(q) || meta.toLowerCase().includes(q); + }); + }, [activeCreateFloorSections, createDialogSearchQuery, getCreateDialogSectionMeta]); + + const getSectionIdsInMarquee = useCallback( + (startX: number, startY: number, endX: number, endY: number, sectionIds: string[]) => { + const marquee = clientMarqueeRect(startX, startY, endX, endY); + const hit = new Set(); + sectionIds.forEach((section) => { + const el = createSectionCardRefs.current.get(section); + if (!el) return; + if (rectsIntersect(el.getBoundingClientRect(), marquee)) { + hit.add(section); + } + }); + return hit; + }, + [], + ); + + const syncMarqueeDragUi = useCallback(() => { + const drag = marqueeDragRef.current; + const scrollEl = createSectionGridScrollRef.current; + if (!drag || !scrollEl) return; + + const dist = Math.hypot( + drag.currentClientX - drag.startClientX, + drag.currentClientY - drag.startClientY, + ); + if (!drag.isMarquee && dist < MARQUEE_DRAG_THRESHOLD_PX) { + setMarqueeRect(null); + setMarqueePreviewIds(new Set()); + return; + } + + drag.isMarquee = true; + const scrollRect = scrollEl.getBoundingClientRect(); + setMarqueeRect({ + left: Math.min(drag.startClientX, drag.currentClientX) - scrollRect.left + scrollEl.scrollLeft, + top: Math.min(drag.startClientY, drag.currentClientY) - scrollRect.top + scrollEl.scrollTop, + width: Math.abs(drag.currentClientX - drag.startClientX), + height: Math.abs(drag.currentClientY - drag.startClientY), + }); + setMarqueePreviewIds( + getSectionIdsInMarquee( + drag.startClientX, + drag.startClientY, + drag.currentClientX, + drag.currentClientY, + drag.sectionIds, + ), + ); + }, [getSectionIdsInMarquee]); + + const finishMarqueeDrag = useCallback(() => { + const drag = marqueeDragRef.current; + if (drag?.isMarquee) { + const hit = getSectionIdsInMarquee( + drag.startClientX, + drag.startClientY, + drag.currentClientX, + drag.currentClientY, + drag.sectionIds, + ); + const merged = new Set(drag.baseSelection); + hit.forEach((id) => merged.add(id)); + setCreateDialogSelectedSections( + Array.from(merged).sort((a, b) => a.localeCompare(b)), + ); + } else if (drag?.pointerDownSection) { + const sec = drag.pointerDownSection; + setCreateDialogSelectedSections((prev) => { + if (prev.includes(sec)) return prev.filter((s) => s !== sec); + return [...prev, sec].sort((a, b) => a.localeCompare(b)); + }); + } + marqueeDragRef.current = null; + setMarqueeRect(null); + setMarqueePreviewIds(new Set()); + }, [getSectionIdsInMarquee]); + + const handleCreateSectionGridPointerDown = useCallback( + (e: React.PointerEvent) => { + if (e.button !== 0) return; + const scrollEl = createSectionGridScrollRef.current; + if (!scrollEl || createDialogFilteredSections.length === 0) return; + + const cardEl = (e.target as HTMLElement).closest("[data-section-card]"); + const pointerDownSection = cardEl?.getAttribute("data-section") ?? null; + + e.preventDefault(); + + marqueeDragRef.current = { + pointerId: e.pointerId, + startClientX: e.clientX, + startClientY: e.clientY, + currentClientX: e.clientX, + currentClientY: e.clientY, + baseSelection: new Set(createDialogSelectedSections), + isMarquee: false, + pointerDownSection, + sectionIds: createDialogFilteredSections, + }; + + const onPointerMove = (ev: PointerEvent) => { + const drag = marqueeDragRef.current; + if (!drag || ev.pointerId !== drag.pointerId) return; + drag.currentClientX = ev.clientX; + drag.currentClientY = ev.clientY; + syncMarqueeDragUi(); + }; + + const onPointerEnd = (ev: PointerEvent) => { + const drag = marqueeDragRef.current; + if (!drag || ev.pointerId !== drag.pointerId) return; + drag.currentClientX = ev.clientX; + drag.currentClientY = ev.clientY; + syncMarqueeDragUi(); + finishMarqueeDrag(); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerEnd); + window.removeEventListener("pointercancel", onPointerEnd); + try { + scrollEl.releasePointerCapture(ev.pointerId); + } catch { + /* ignore */ + } + }; + + try { + scrollEl.setPointerCapture(e.pointerId); + } catch { + /* ignore */ + } + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerEnd); + window.addEventListener("pointercancel", onPointerEnd); + }, + [createDialogFilteredSections, createDialogSelectedSections, finishMarqueeDrag, syncMarqueeDragUi], + ); + const handleCreateStockTake = useCallback(async () => { + if (createStockTakeInFlightRef.current) return; + if (createDialogSelectedSections.length === 0) return; + createStockTakeInFlightRef.current = true; + setOpenCreateStockTakeSummaryConfirm(false); setOpenConfirmDialog(false); setCreating(true); try { - const result = await createStockTakeForSections(); - const createdCount = Object.values(result).filter(msg => msg.startsWith("Created:")).length; - const skippedCount = Object.values(result).filter(msg => msg.startsWith("Skipped:")).length; - const errorCount = Object.values(result).filter(msg => msg.startsWith("Error:")).length; - + const planStart = + createDialogPlanStart != null + ? dayjsToDateTimeString(createDialogPlanStart.startOf("day")) + : dayjsToDateTimeString(dayjs().startOf("day")); + const result = await createStockTakeForSections( + createDialogSelectedSections, + createDialogRoundName, + planStart, + ); + const createdCount = Object.values(result).filter((msg) => msg.startsWith("Created:")).length; + const skippedCount = Object.values(result).filter((msg) => msg.startsWith("Skipped:")).length; + const errorCount = Object.values(result).filter((msg) => msg.startsWith("Error:")).length; + let message = `${t("Created")}: ${createdCount}, ${t("Skipped")}: ${skippedCount}`; if (errorCount > 0) { message += `, ${t("Errors")}: ${errorCount}`; } - + console.log(message); - + onListPageChange(0); setListRefreshNonce((n) => n + 1); + setCreateDialogRoundName(""); + setCreateDialogPlanStart(dayjs()); } catch (e) { console.error(e); } finally { setCreating(false); + createStockTakeInFlightRef.current = false; } - }, [onListPageChange, t]); + }, [createDialogPlanStart, createDialogRoundName, createDialogSelectedSections, onListPageChange, t]); useEffect(() => { + setSectionsLoading(true); fetchStockTakeSections() .then((sections) => { + setStockTakeSectionRows(Array.isArray(sections) ? sections : []); const descSet = new Set(); const sectionSet = new Set(); const storeIdSet = new Set(["2F", "4F"]); @@ -192,6 +599,9 @@ const PickerCardList: React.FC = ({ }) .catch((e) => { console.error("Failed to load section descriptions for filter:", e); + }) + .finally(() => { + setSectionsLoading(false); }); }, []); useEffect(() => { @@ -432,7 +842,7 @@ const PickerCardList: React.FC = ({ onClick={() => setOpenConfirmDialog(true)} disabled={creating || !canManageStockTake} > - {creating ? : t("Create Stock Take for All Sections")} + {creating ? : t("Create Stock Take (Select Sections)")} @@ -532,30 +942,605 @@ const PickerCardList: React.FC = ({ {/* Create Stock Take 確認 Dialog */} setOpenConfirmDialog(false)} - maxWidth="xs" + onClose={() => { + setOpenConfirmDialog(false); + setCreateDialogRoundName(""); + setCreateDialogPlanStart(dayjs()); + }} + maxWidth={false} fullWidth + PaperProps={{ + sx: { + width: "100%", + maxWidth: { xs: "100%", sm: 960, md: 1120 }, + }, + }} > - {t("Create Stock Take for All Sections")} - - - {t("Confirm create stock take for all sections?")} - + + + {t("Create Stock Take (Select Sections)")} + + 0 + ? t("Warehouse missing stock take section tooltip has", { count: missingSectionCount }) + : t("Warehouse missing stock take section tooltip none") + } + > + + setMissingSectionWarnDrawerOpen(true)} + sx={{ + color: missingSectionCount > 0 ? "warning.main" : "text.secondary", + }} + > + 99 ? "99+" : missingSectionCount} + invisible={missingSectionCount === 0} + > + + + + + + + + + {/* 左側:盤點設定與樓層 */} + + + + {t("Stock take round name")} + + + setCreateDialogRoundName(e.target.value)} + inputProps={{ maxLength: 255, "aria-label": t("Stock take round name") }} + sx={createDialogCompactInputSx} + /> + + + + {t("Creation date")} + + + setCreateDialogPlanStart(newValue)} + format={OUTPUT_DATE_FORMAT} + slotProps={{ + textField: { + fullWidth: true, + size: "small", + hiddenLabel: true, + inputProps: { "aria-label": t("Creation date") }, + sx: createDialogCompactInputSx, + }, + }} + /> + + + {sortedFloorKeys.length === 0 && !sectionsLoading ? ( + + {t("No stock take sections from warehouse")} + + ) : ( + FLOOR_LIST_SCROLL_THRESHOLD + ? { maxHeight: 300, overflowY: "auto", pr: 0.5 } + : {}), + }} + > + {sortedFloorKeys.map((floorKey, idx) => { + const floorSections = sectionsByStore.get(floorKey) ?? []; + const selected = idx === createFloorTabIndex; + return ( + + ); + })} + + )} + + + + + + {t("Total selected sections label")}{" "} + + {createDialogSelectedSections.length} + {" "} + {t("sections unit")} + + + + {/* 右側:區域卡片網格 */} + + {activeCreateFloorKey != null ? ( + <> + + + {t("Floor area selection header", { + floor: floorGroupLabel(activeCreateFloorKey), + count: activeCreateFloorSections.length, + })} + + + + setCreateDialogSearchQuery(e.target.value)} + inputProps={{ "aria-label": t("Search section code or name") }} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={createDialogCompactInputSx} + /> + + {sectionsLoading ? ( + + + + ) : createDialogFilteredSections.length === 0 ? ( + + {createDialogSearchQuery.trim() + ? t("No sections match search") + : t("No stock take sections from warehouse")} + + ) : ( + <> + {marqueeRect ? ( + + ) : null} + + {createDialogFilteredSections.map((section) => { + const meta = getCreateDialogSectionMeta(section); + const checked = createDialogSelectedSections.includes(section); + const previewSelected = marqueePreviewIds.has(section); + const visuallySelected = checked || previewSelected; + return ( + { + if (el) { + createSectionCardRefs.current.set(section, el); + } else { + createSectionCardRefs.current.delete(section); + } + }} + sx={{ + cursor: "default", + borderWidth: visuallySelected ? 2 : 1, + borderColor: visuallySelected ? "primary.main" : "divider", + bgcolor: visuallySelected + ? previewSelected && !checked + ? "action.hover" + : "action.selected" + : "background.paper", + transition: marqueeRect ? "none" : "border-color 0.15s, background-color 0.15s", + "&:hover": { + borderColor: visuallySelected ? "primary.main" : "grey.400", + }, + }} + > + + + {section} + + {meta ? ( + + {meta} + + ) : null} + + + ); + })} + + + )} + + + ) : ( + + {sectionsLoading ? ( + + ) : ( + + {t("No stock take sections from warehouse")} + + )} + + )} + + - - + + + + { + if (!creating) setOpenCreateStockTakeSummaryConfirm(false); + }} + maxWidth="sm" + fullWidth + > + {t("Confirm create stock take")} + + + + + {t("Stock take round name")}:{" "} + + {createDialogRoundName.trim() || t("Not filled")} + + + + {t("Creation date")}:{" "} + + {createDialogPlanStart?.isValid() + ? createDialogPlanStart.format(OUTPUT_DATE_FORMAT) + : "-"} + + + {t("Total selected sections label")}{" "} + + {createStockTakeSummarySections.length} + {" "} + {t("sections unit")} + + + + + {createStockTakeSummarySections.map((section) => { + const meta = getCreateDialogSectionMeta(section); + return ( + + + {section} + + {meta ? ( + + {meta} + + ) : null} + + ); + })} + + + + + + + + setMissingSectionWarnDrawerOpen(false)} + ModalProps={{ + sx: (theme) => ({ + // Drawer 預設 z-index 1200,低於 Dialog (1300),會被建立盤點對話框遮住 + zIndex: theme.zIndex.modal + 2, + }), + }} + PaperProps={{ + sx: { + width: { xs: "100%", sm: 400 }, + p: 0, + display: "flex", + flexDirection: "column", + maxHeight: "100vh", + }, + }} + > + + + {t("Warehouse missing stock take section warn title")} + + + {t("Warehouse missing stock take section drawer hint")} + + {missingSectionCount > 0 ? ( + + {t("Warehouse missing stock take section showing", { + shown: Math.min(missingSectionItems.length, missingSectionIssuesLimit), + count: missingSectionCount, + })} + + ) : null} + + + {missingSectionIssuesLoading ? ( + + + + ) : missingSectionItems.length === 0 ? ( + + {t("Warehouse missing stock take section empty")} + + ) : ( + }> + {missingSectionItems.map((row) => ( + + + {row.code || `#${row.id}`} + + + {[row.storeId, row.warehouse, row.area, row.slot].filter(Boolean).join(" / ") || + "—"} + + {row.order ? ( + + {row.order} + + ) : null} + + ))} + + )} + + + + + + ); }; diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index c301e28..6a7db22 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -26,8 +26,11 @@ import { SaveStockTakeRecordRequest, BatchSaveStockTakeRecordRequest, batchSaveStockTakeRecords, + batchSavePickerStockTakeInputs, getInventoryLotDetailsBySectionNotMatch } from "@/app/api/stockTake/actions"; +import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; +import PickerBatchSaveFab from "./PickerBatchSaveFab"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; @@ -65,7 +68,9 @@ const PickerReStockTake: React.FC = ({ const [total, setTotal] = useState(0); const currentUserId = session?.id ? parseInt(session.id) : undefined; - const handleBatchSubmitAllRef = useRef<() => Promise>(); + const handleBatchTestAllRef = useRef<() => Promise>(); + const batchInFlightRef = useRef(false); + const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); @@ -186,8 +191,9 @@ const PickerReStockTake: React.FC = ({ return; } - const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; - const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; + const isFirstSubmit = detail.firstStockTakeQty == null; + const isSecondSubmit = + detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; // 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致) const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; @@ -273,11 +279,23 @@ const PickerReStockTake: React.FC = ({ } }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); - const handleBatchSubmitAll = useCallback(async () => { - if (!selectedSession || !currentUserId) { + const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { + if (selectedSession?.status?.toLowerCase() === "completed") { + return true; + } + const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); + if (recordStatus === "pass" || recordStatus === "completed") { + return true; + } + return false; + }, [selectedSession?.status]); + + const handleBatchTestAutoFill = useCallback(async () => { + if (!selectedSession || !currentUserId || batchInFlightRef.current) { return; } + batchInFlightRef.current = true; setBatchSaving(true); try { const request: BatchSaveStockTakeRecordRequest = { @@ -297,30 +315,82 @@ const PickerReStockTake: React.FC = ({ ); await loadDetails(page, pageSize); - } catch (e: any) { - console.error("handleBatchSubmitAll: Error:", e); + } catch (e: unknown) { + console.error("handleBatchTestAutoFill:", e); let errorMessage = t("Failed to batch save stock take records"); - - if (e?.message) { + if (e instanceof Error && 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); + batchInFlightRef.current = false; } }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); + const handleBatchSaveInputted = useCallback(async () => { + if (!selectedSession || !currentUserId || batchInFlightRef.current) return; + + const built = buildPickerBatchSaveRequests( + inventoryLotDetails, + recordInputs, + isSubmitDisabled + ); + if (!built.ok) { + onSnackbar(t(built.message), "error"); + return; + } + if (built.records.length === 0) { + onSnackbar(t("No valid input to submit"), "warning"); + return; + } + + batchInFlightRef.current = true; + setBatchSaving(true); + try { + const result = await batchSavePickerStockTakeInputs({ + stockTakeId: selectedSession.stockTakeId, + stockTakeSection: selectedSession.stockTakeSession, + stockTakerId: currentUserId, + records: built.records, + }); + + onSnackbar( + t("Batch save completed: {{success}} success, {{errors}} errors", { + success: result.successCount, + errors: result.errorCount, + }), + result.errorCount > 0 ? "warning" : "success" + ); + + await loadDetails(page, pageSize); + } catch (e: unknown) { + console.error("handleBatchSaveInputted:", e); + let errorMessage = t("Failed to batch save stock take records"); + if (e instanceof Error && e.message) { + errorMessage = e.message; + } + onSnackbar(errorMessage, "error"); + } finally { + setBatchSaving(false); + batchInFlightRef.current = false; + } + }, [ + selectedSession, + currentUserId, + inventoryLotDetails, + recordInputs, + isSubmitDisabled, + t, + onSnackbar, + page, + pageSize, + loadDetails, + ]); + useEffect(() => { - handleBatchSubmitAllRef.current = handleBatchSubmitAll; - }, [handleBatchSubmitAll]); + handleBatchTestAllRef.current = handleBatchTestAutoFill; + }, [handleBatchTestAutoFill]); useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { @@ -343,11 +413,9 @@ const PickerReStockTake: React.FC = ({ if (newInput === '{2fitestall}') { setTimeout(() => { - if (handleBatchSubmitAllRef.current) { - handleBatchSubmitAllRef.current().catch(err => { - console.error('Error in handleBatchSubmitAll:', err); - }); - } + handleBatchTestAllRef.current?.().catch((err) => { + console.error("Error in handleBatchTestAutoFill:", err); + }); }, 0); return ""; } @@ -371,17 +439,6 @@ const PickerReStockTake: React.FC = ({ }; }, []); - const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { - 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( inventoryLotDetails @@ -393,7 +450,7 @@ const PickerReStockTake: React.FC = ({ const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; return ( - + @@ -428,7 +485,7 @@ const PickerReStockTake: React.FC = ({ {t("UOM")} {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Action")} - {t("Remark")} + {/*{t("Remark")}*/} {t("Record Status")} @@ -444,8 +501,10 @@ const PickerReStockTake: React.FC = ({ ) : ( inventoryLotDetails.map((detail) => { const submitDisabled = isSubmitDisabled(detail); - const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; - const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; + const isFirstSubmit = detail.firstStockTakeQty == null; + const isSecondSubmit = + detail.firstStockTakeQty != null && + detail.secondStockTakeQty == null; const inputs = recordInputs[detail.id] ?? defaultInputs; return ( @@ -625,6 +684,7 @@ const PickerReStockTake: React.FC = ({ + {/* {!submitDisabled && isSecondSubmit ? ( <> @@ -650,7 +710,7 @@ const PickerReStockTake: React.FC = ({ )} - + */} {detail.stockTakeRecordStatus === "completed" ? ( @@ -683,6 +743,12 @@ const PickerReStockTake: React.FC = ({ /> )} + ); }; diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index 7abbcf7..428bc4d 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -32,7 +32,10 @@ import { SaveStockTakeRecordRequest, BatchSaveStockTakeRecordRequest, batchSaveStockTakeRecords, + batchSavePickerStockTakeInputs, } from "@/app/api/stockTake/actions"; +import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; +import PickerBatchSaveFab from "./PickerBatchSaveFab"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; @@ -74,7 +77,9 @@ const PickerStockTake: React.FC = ({ const totalPages = pageSize === "all" ? 1 : Math.ceil(total / (pageSize as number)); const currentUserId = session?.id ? parseInt(session.id) : undefined; - const handleBatchSubmitAllRef = useRef<() => Promise>(); + const handleBatchTestAllRef = useRef<() => Promise>(); + const batchInFlightRef = useRef(false); + const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); }, []); @@ -183,9 +188,9 @@ const PickerStockTake: React.FC = ({ return; } - const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; + const isFirstSubmit = detail.firstStockTakeQty == null; const isSecondSubmit = - detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; + detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; // 现在用户输入的是 total 和 bad,需要算 available = total - bad const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; @@ -272,60 +277,48 @@ const PickerStockTake: React.FC = ({ ] ); - 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 = { - stockTakeId: selectedSession.stockTakeId, - stockTakeSection: selectedSession.stockTakeSession, - stockTakerId: currentUserId, - }; - - const result = await batchSaveStockTakeRecords(request); - console.log("handleBatchSubmitAll: Result:", result); - - onSnackbar( - t("Batch 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 stock take records"); + /** 测试快捷键:按账面自动建记录(非用户输入批量) */ + const handleBatchTestAutoFill = useCallback(async () => { + if (!selectedSession || !currentUserId || batchInFlightRef.current) { + return; + } - 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 - } - } + batchInFlightRef.current = true; + setBatchSaving(true); + try { + const request: BatchSaveStockTakeRecordRequest = { + stockTakeId: selectedSession.stockTakeId, + stockTakeSection: selectedSession.stockTakeSession, + stockTakerId: currentUserId, + }; + + const result = await batchSaveStockTakeRecords(request); + + onSnackbar( + t("Batch save completed: {{success}} success, {{errors}} errors", { + success: result.successCount, + errors: result.errorCount, + }), + result.errorCount > 0 ? "warning" : "success" + ); - onSnackbar(errorMessage, "error"); - } finally { - setBatchSaving(false); + await loadDetails(page, pageSize); + } catch (e: unknown) { + console.error("handleBatchTestAutoFill: Error:", e); + let errorMessage = t("Failed to batch save stock take records"); + if (e instanceof Error && e.message) { + errorMessage = e.message; } - }, - [selectedSession, t, currentUserId, onSnackbar] - ); + onSnackbar(errorMessage, "error"); + } finally { + setBatchSaving(false); + batchInFlightRef.current = false; + } + }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); useEffect(() => { - handleBatchSubmitAllRef.current = handleBatchSubmitAll; - }, [handleBatchSubmitAll]); + handleBatchTestAllRef.current = handleBatchTestAutoFill; + }, [handleBatchTestAutoFill]); useEffect(() => { const handleKeyPress = (e: KeyboardEvent) => { @@ -348,16 +341,10 @@ const PickerStockTake: React.FC = ({ 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"); - } + handleBatchTestAllRef.current?.().catch((err) => { + console.error("Error in handleBatchTestAutoFill:", err); + }); }, 0); return ""; } @@ -411,40 +398,65 @@ const PickerStockTake: React.FC = ({ } return false; }, [selectedSession?.status]); - const handleSubmitAllInputted = useCallback(async () => { - if (!selectedSession || !currentUserId) return; - - const toSave = inventoryLotDetails.filter((detail) => { - const submitDisabled = isSubmitDisabled(detail); - if (submitDisabled) return false; - const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; - const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; - const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; - return !!totalQtyStr && totalQtyStr.trim() !== ""; - }); - - if (toSave.length === 0) { + const handleBatchSaveInputted = useCallback(async () => { + if (!selectedSession || !currentUserId || batchInFlightRef.current) return; + + const built = buildPickerBatchSaveRequests( + inventoryLotDetails, + recordInputs, + isSubmitDisabled + ); + if (!built.ok) { + onSnackbar(t(built.message), "error"); + return; + } + if (built.records.length === 0) { onSnackbar(t("No valid input to submit"), "warning"); return; } - + + batchInFlightRef.current = true; setBatchSaving(true); - let successCount = 0; - let errorCount = 0; - for (const detail of toSave) { - try { - await handleSaveStockTake(detail); - successCount++; - } catch { - errorCount++; + try { + const result = await batchSavePickerStockTakeInputs({ + stockTakeId: selectedSession.stockTakeId, + stockTakeSection: selectedSession.stockTakeSession, + stockTakerId: currentUserId, + records: built.records, + }); + + onSnackbar( + t("Batch save completed: {{success}} success, {{errors}} errors", { + success: result.successCount, + errors: result.errorCount, + }), + result.errorCount > 0 ? "warning" : "success" + ); + + await loadDetails(page, pageSize); + } catch (e: unknown) { + console.error("handleBatchSaveInputted:", e); + let errorMessage = t("Failed to batch save stock take records"); + if (e instanceof Error && e.message) { + errorMessage = e.message; } + onSnackbar(errorMessage, "error"); + } finally { + setBatchSaving(false); + batchInFlightRef.current = false; } - setBatchSaving(false); - onSnackbar( - t("Submit completed: {{success}} success, {{errors}} errors", { success: successCount, errors: errorCount }), - errorCount > 0 ? "warning" : "success" - ); - }, [inventoryLotDetails, recordInputs, isSubmitDisabled, handleSaveStockTake, selectedSession, currentUserId, onSnackbar, t]); + }, [ + selectedSession, + currentUserId, + inventoryLotDetails, + recordInputs, + isSubmitDisabled, + t, + onSnackbar, + page, + pageSize, + loadDetails, + ]); const uniqueWarehouses = Array.from( new Set( inventoryLotDetails @@ -454,7 +466,7 @@ const PickerStockTake: React.FC = ({ ).join(", "); return ( - +