| @@ -141,6 +141,14 @@ export default function ReportPage() { | |||||
| } | } | ||||
| // Clear dynamic options when report changes | // Clear dynamic options when report changes | ||||
| setDynamicOptions({}); | setDynamicOptions({}); | ||||
| // Default "All" (no filter) for stock take variance report conditions. | |||||
| if (selectedReportId === 'rep-012') { | |||||
| setCriteria({ | |||||
| store_id: 'All', | |||||
| status: 'All', | |||||
| }); | |||||
| } | |||||
| }, [selectedReportId]); | }, [selectedReportId]); | ||||
| // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. | // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. | ||||
| @@ -29,6 +29,7 @@ export interface InventoryLotDetailResponse { | |||||
| warehouseSlot: string; | warehouseSlot: string; | ||||
| warehouseArea: string; | warehouseArea: string; | ||||
| warehouse: string; | warehouse: string; | ||||
| storeId?: string | null; | |||||
| varianceQty: number | null; | varianceQty: number | null; | ||||
| status: string; | status: string; | ||||
| remarks: string | null; | remarks: string | null; | ||||
| @@ -137,6 +138,8 @@ export interface AllPickedStockTakeListReponse { | |||||
| endTime: string | null; | endTime: string | null; | ||||
| planStartDate: string | null; | planStartDate: string | null; | ||||
| stockTakeSectionDescription: string | null; | stockTakeSectionDescription: string | null; | ||||
| warehouseArea: string | null; | |||||
| storeId: string | null; | |||||
| reStockTakeTrueFalse: boolean; | reStockTakeTrueFalse: boolean; | ||||
| } | } | ||||
| @@ -254,7 +257,7 @@ export const getStockTakeRecords = async () => { | |||||
| export const getStockTakeRecordsPaged = async ( | export const getStockTakeRecordsPaged = async ( | ||||
| pageNum: number, | pageNum: number, | ||||
| pageSize: number, | pageSize: number, | ||||
| params?: { sectionDescription?: string; stockTakeSections?: string } | |||||
| params?: { sectionDescription?: string; stockTakeSections?: string; status?: string; area?: string; storeId?: string } | |||||
| ) => { | ) => { | ||||
| const searchParams = new URLSearchParams(); | const searchParams = new URLSearchParams(); | ||||
| searchParams.set("pageNum", String(pageNum)); | searchParams.set("pageNum", String(pageNum)); | ||||
| @@ -265,6 +268,15 @@ export const getStockTakeRecordsPaged = async ( | |||||
| if (params?.stockTakeSections?.trim()) { | if (params?.stockTakeSections?.trim()) { | ||||
| searchParams.set("stockTakeSections", params.stockTakeSections.trim()); | searchParams.set("stockTakeSections", params.stockTakeSections.trim()); | ||||
| } | } | ||||
| if (params?.status && params.status !== "All") { | |||||
| searchParams.set("status", params.status); | |||||
| } | |||||
| if (params?.area?.trim()) { | |||||
| searchParams.set("area", params.area.trim()); | |||||
| } | |||||
| if (params?.storeId && params.storeId !== "All") { | |||||
| searchParams.set("storeId", params.storeId); | |||||
| } | |||||
| const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`; | const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`; | ||||
| const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" }); | const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" }); | ||||
| return res; | return res; | ||||
| @@ -39,5 +39,6 @@ export interface StockTakeSectionInfo { | |||||
| id: string; | id: string; | ||||
| stockTakeSection: string; | stockTakeSection: string; | ||||
| stockTakeSectionDescription: string | null; | stockTakeSectionDescription: string | null; | ||||
| storeId?: string | null; | |||||
| warehouseCount: number; | warehouseCount: number; | ||||
| } | } | ||||
| @@ -44,9 +44,6 @@ import { | |||||
| checkAndCompletePickOrderByConsoCode, | checkAndCompletePickOrderByConsoCode, | ||||
| confirmLotSubstitution, | confirmLotSubstitution, | ||||
| updateStockOutLineStatusByQRCodeAndLotNo, // ✅ 添加 | updateStockOutLineStatusByQRCodeAndLotNo, // ✅ 添加 | ||||
| batchSubmitList, // ✅ 添加 | |||||
| batchSubmitListRequest, // ✅ 添加 | |||||
| batchSubmitListLineRequest, | |||||
| } from "@/app/api/pickOrder/actions"; | } from "@/app/api/pickOrder/actions"; | ||||
| // 修改:使用 Job Order API | // 修改:使用 Job Order API | ||||
| import { | import { | ||||
| @@ -3783,92 +3780,6 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| }); | }); | ||||
| } | } | ||||
| // Hold-only:與整批相同規則,只送一筆 batch,把實揈/完成狀態寫回 DB | |||||
| if (useHoldOnlyApi && pickOrderIdEarly && solId > 0) { | |||||
| const freshData = | |||||
| await fetchJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderIdEarly); | |||||
| const flatLots = getAllLotsFromHierarchical(freshData); | |||||
| const lotRow = flatLots.find( | |||||
| (l: any) => Number(l.stockOutLineId) === solId, | |||||
| ); | |||||
| if (!lotRow) { | |||||
| throw new Error( | |||||
| "Could not find lot row after refresh for batch submit", | |||||
| ); | |||||
| } | |||||
| const requiredQty = Number( | |||||
| lotRow.requiredQty || lotRow.pickOrderLineRequiredQty || 0, | |||||
| ); | |||||
| const issuePickedVal = picked; | |||||
| const currentActualPickQty = Number( | |||||
| issuePickedVal ?? lotRow.actualPickQty ?? 0, | |||||
| ); | |||||
| const onlyComplete = | |||||
| lotRow.stockOutLineStatus === "partially_completed" || | |||||
| lotRow.stockOutLineStatus === "PARTIALLY_COMPLETE" || | |||||
| issuePickedVal !== undefined; | |||||
| const expired = isLotAvailabilityExpired(lotRow); | |||||
| const unavailable = isInventoryLotLineUnavailable(lotRow); | |||||
| let targetActual: number; | |||||
| let newStatus: string; | |||||
| if (unavailable) { | |||||
| targetActual = currentActualPickQty; | |||||
| newStatus = "completed"; | |||||
| } else if (expired && issuePickedVal === undefined) { | |||||
| targetActual = 0; | |||||
| newStatus = "completed"; | |||||
| } else if (onlyComplete) { | |||||
| targetActual = currentActualPickQty; | |||||
| newStatus = "completed"; | |||||
| } else { | |||||
| const remainingQty = Math.max( | |||||
| 0, | |||||
| requiredQty - currentActualPickQty, | |||||
| ); | |||||
| const cumulativeQty = currentActualPickQty + remainingQty; | |||||
| targetActual = cumulativeQty; | |||||
| newStatus = "partially_completed"; | |||||
| if (requiredQty > 0 && cumulativeQty >= requiredQty) { | |||||
| newStatus = "completed"; | |||||
| } | |||||
| } | |||||
| const line: batchSubmitListLineRequest = { | |||||
| stockOutLineId: solId, | |||||
| pickOrderLineId: Number(lotRow.pickOrderLineId), | |||||
| inventoryLotLineId: lotRow.lotId ? Number(lotRow.lotId) : null, | |||||
| requiredQty, | |||||
| actualPickQty: targetActual, | |||||
| stockOutLineStatus: newStatus, | |||||
| pickOrderConsoCode: String(lotRow.pickOrderConsoCode || ""), | |||||
| noLot: Boolean(lotRow.noLot === true), | |||||
| }; | |||||
| const batchResult = await batchSubmitList({ | |||||
| userId: currentUserId || 0, | |||||
| lines: [line], | |||||
| }); | |||||
| if (!batchResult || batchResult.code !== "SUCCESS") { | |||||
| throw new Error( | |||||
| batchResult?.message || | |||||
| "Batch submit failed after hold adjustment", | |||||
| ); | |||||
| } | |||||
| const conso = String(lotRow.pickOrderConsoCode || "").trim(); | |||||
| if (conso) { | |||||
| try { | |||||
| await checkAndCompletePickOrderByConsoCode(conso); | |||||
| } catch (e) { | |||||
| console.error("❌ completion check after single batch:", e); | |||||
| } | |||||
| } | |||||
| } | |||||
| setPickExecutionFormOpen(false); | setPickExecutionFormOpen(false); | ||||
| setSelectedLotForExecutionForm(null); | setSelectedLotForExecutionForm(null); | ||||
| @@ -3880,16 +3791,11 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| }, | }, | ||||
| [ | [ | ||||
| fetchJobOrderData, | fetchJobOrderData, | ||||
| getAllLotsFromHierarchical, | |||||
| currentUserId, | currentUserId, | ||||
| selectedLotForExecutionForm, | selectedLotForExecutionForm, | ||||
| updateHandledBy, | updateHandledBy, | ||||
| filterArgs, | filterArgs, | ||||
| session?.user?.name, | session?.user?.name, | ||||
| batchSubmitList, | |||||
| checkAndCompletePickOrderByConsoCode, | |||||
| isLotAvailabilityExpired, | |||||
| isInventoryLotLineUnavailable, | |||||
| ], | ], | ||||
| ); | ); | ||||
| // Calculate remaining required quantity | // Calculate remaining required quantity | ||||
| @@ -689,7 +689,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| variant="outlined" | variant="outlined" | ||||
| color="warning" | color="warning" | ||||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | onClick={() => handleUpdateStatusToNotMatch(detail)} | ||||
| disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"} | |||||
| disabled={updatingStatus || detail.stockTakeRecordStatus === "completed"||hasSecond} | |||||
| > | > | ||||
| {t("ReStockTake")} | {t("ReStockTake")} | ||||
| </Button> | </Button> | ||||
| @@ -18,6 +18,15 @@ import { | |||||
| Radio, | Radio, | ||||
| TablePagination, | TablePagination, | ||||
| TableSortLabel, | TableSortLabel, | ||||
| Card, | |||||
| CardContent, | |||||
| CardActions, | |||||
| Grid, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Autocomplete, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect, useMemo } from "react"; | import { useState, useCallback, useEffect, useMemo } from "react"; | ||||
| import { Collapse } from "@mui/material"; | import { Collapse } from "@mui/material"; | ||||
| @@ -37,7 +46,6 @@ import { | |||||
| updateStockTakeRecordStatusToNotMatch, | updateStockTakeRecordStatusToNotMatch, | ||||
| type ApproverInventoryLotDetailsQuery, | type ApproverInventoryLotDetailsQuery, | ||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||||
| import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| @@ -66,13 +74,12 @@ type ApprovedSortKey = | |||||
| | "stockTakerName" | | "stockTakerName" | ||||
| | "variance"; | | "variance"; | ||||
| type ApproverSearchKey = "sectionDescription" | "stockTakeSession" | "itemKeyword" | "warehouseKeyword"|"status"; | |||||
| type ApproverSearchFilters = { | type ApproverSearchFilters = { | ||||
| sectionDescription: string; | sectionDescription: string; | ||||
| stockTakeSession: string; | stockTakeSession: string; | ||||
| itemKeyword: string; | itemKeyword: string; | ||||
| warehouseKeyword: string; | warehouseKeyword: string; | ||||
| storeId: string; | |||||
| status: string; | status: string; | ||||
| }; | }; | ||||
| @@ -85,15 +92,6 @@ function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverIn | |||||
| }; | }; | ||||
| } | } | ||||
| function hasAnyApproverSearchCriterion(f: ApproverSearchFilters): boolean { | |||||
| return ( | |||||
| (f.sectionDescription && f.sectionDescription !== "All") || | |||||
| f.stockTakeSession.trim() !== "" || | |||||
| f.itemKeyword.trim() !== "" || | |||||
| f.warehouseKeyword.trim() !== "" | |||||
| ); | |||||
| } | |||||
| function isBlankApproverField(value: string | undefined): boolean { | function isBlankApproverField(value: string | undefined): boolean { | ||||
| return value == null || String(value).trim() === ""; | return value == null || String(value).trim() === ""; | ||||
| } | } | ||||
| @@ -202,9 +200,15 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const [total, setTotal] = useState(0); | const [total, setTotal] = useState(0); | ||||
| const [approvedSortKey, setApprovedSortKey] = useState<ApprovedSortKey | null>(null); | const [approvedSortKey, setApprovedSortKey] = useState<ApprovedSortKey | null>(null); | ||||
| const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc"); | const [approvedSortDir, setApprovedSortDir] = useState<"asc" | "desc">("asc"); | ||||
| const [sectionDescriptionAutocompleteOptions, setSectionDescriptionAutocompleteOptions] = useState< | |||||
| { value: string; label: string }[] | |||||
| >([]); | |||||
| const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]); | |||||
| const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]); | |||||
| const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]); | |||||
| const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All"); | |||||
| const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>(""); | |||||
| const [searchItemKeyword, setSearchItemKeyword] = useState<string>(""); | |||||
| const [searchWarehouseKeyword, setSearchWarehouseKeyword] = useState<string>(""); | |||||
| const [searchStoreId, setSearchStoreId] = useState<string>("All"); | |||||
| const [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All"); | |||||
| const [showFilters, setShowFilters] = useState(true) | const [showFilters, setShowFilters] = useState(true) | ||||
| const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null); | const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null); | ||||
| @@ -227,76 +231,32 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| [] | [] | ||||
| ); | ); | ||||
| const handleApproverSearchBoxSearch = useCallback( | |||||
| (inputs: Record<ApproverSearchKey | `${ApproverSearchKey}To`, string>) => { | |||||
| const next: ApproverSearchFilters = { | |||||
| sectionDescription: inputs.sectionDescription || "All", | |||||
| stockTakeSession: inputs.stockTakeSession || "", | |||||
| itemKeyword: inputs.itemKeyword || "", | |||||
| warehouseKeyword: inputs.warehouseKeyword || "", | |||||
| status: inputs.status || "All", | |||||
| }; | |||||
| /* | |||||
| if (!hasAnyApproverSearchCriterion(next)) { | |||||
| onSnackbar(t("Please set at least one search criterion"), "warning"); | |||||
| return; | |||||
| } | |||||
| */ | |||||
| setAppliedFilters(next); | |||||
| setPage(0); | |||||
| }, | |||||
| [onSnackbar, t] | |||||
| ); | |||||
| const handleApproverSearchBoxReset = useCallback(() => { | |||||
| const handleSearch = useCallback(() => { | |||||
| const next: ApproverSearchFilters = { | |||||
| sectionDescription: searchSectionDescription || "All", | |||||
| stockTakeSession: searchStockTakeSession || "", | |||||
| itemKeyword: searchItemKeyword || "", | |||||
| warehouseKeyword: searchWarehouseKeyword || "", | |||||
| storeId: searchStoreId || "All", | |||||
| status: mode === "pending" ? (searchStatus || "pass") : "All", | |||||
| }; | |||||
| setAppliedFilters(next); | |||||
| setPage(0); | |||||
| }, [searchSectionDescription, searchStockTakeSession, searchItemKeyword, searchWarehouseKeyword, searchStoreId, searchStatus, mode]); | |||||
| const handleResetSearch = useCallback(() => { | |||||
| const defaultStatus = mode === "pending" ? "pass" : "All"; | |||||
| setSearchSectionDescription("All"); | |||||
| setSearchStockTakeSession(""); | |||||
| setSearchItemKeyword(""); | |||||
| setSearchWarehouseKeyword(""); | |||||
| setSearchStoreId("All"); | |||||
| setSearchStatus(defaultStatus); | |||||
| setAppliedFilters(null); | setAppliedFilters(null); | ||||
| setPage(0); | setPage(0); | ||||
| setInventoryLotDetails([]); | setInventoryLotDetails([]); | ||||
| setTotal(0); | setTotal(0); | ||||
| }, []); | |||||
| const approverSearchCriteria: Criterion<ApproverSearchKey>[] = useMemo( | |||||
| () => [ | |||||
| { | |||||
| type: "autocomplete", | |||||
| label: t("Stock Take Section Description"), | |||||
| paramName: "sectionDescription", | |||||
| options: sectionDescriptionAutocompleteOptions, | |||||
| needAll: true, | |||||
| }, | |||||
| { | |||||
| type: "text", | |||||
| label: t("Stock Take Section (can use , to search multiple sections)"), | |||||
| paramName: "stockTakeSession", | |||||
| placeholder: "", | |||||
| }, | |||||
| { | |||||
| type: "text", | |||||
| label: t("Item"), | |||||
| paramName: "itemKeyword", | |||||
| placeholder: "", | |||||
| }, | |||||
| { | |||||
| type: "text", | |||||
| label: t("Warehouse"), | |||||
| paramName: "warehouseKeyword", | |||||
| placeholder: "", | |||||
| }, | |||||
| { | |||||
| type: "select-labelled", | |||||
| label: t("Record Status"), | |||||
| paramName: "status", | |||||
| options: [ | |||||
| { label: t("All"), value: "All" }, | |||||
| { label: t("Pending"), value: "pending" }, | |||||
| { label: t("Not Match"), value: "notMatch" }, | |||||
| { label: t("Pass"), value: "pass" }, // UI=Pass,值=completed | |||||
| ], | |||||
| } | |||||
| ], | |||||
| [t, sectionDescriptionAutocompleteOptions] | |||||
| ); | |||||
| }, [mode]); | |||||
| const loadDetails = useCallback(async (filters: ApproverSearchFilters) => { | const loadDetails = useCallback(async (filters: ApproverSearchFilters) => { | ||||
| setLoadingDetails(true); | setLoadingDetails(true); | ||||
| @@ -342,13 +302,19 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| fetchStockTakeSections() | fetchStockTakeSections() | ||||
| .then((sections) => { | .then((sections) => { | ||||
| const descSet = new Set<string>(); | const descSet = new Set<string>(); | ||||
| const sectionSet = new Set<string>(); | |||||
| const storeSet = new Set<string>(["2F", "4F"]); | |||||
| sections.forEach((s) => { | sections.forEach((s) => { | ||||
| const section = s.stockTakeSection?.trim(); | |||||
| if (section) sectionSet.add(section); | |||||
| const desc = s.stockTakeSectionDescription?.trim(); | const desc = s.stockTakeSectionDescription?.trim(); | ||||
| if (desc) descSet.add(desc); | if (desc) descSet.add(desc); | ||||
| const storeId = s.storeId?.trim(); | |||||
| if (storeId) storeSet.add(storeId); | |||||
| }); | }); | ||||
| setSectionDescriptionAutocompleteOptions( | |||||
| Array.from(descSet).map((desc) => ({ value: desc, label: desc })) | |||||
| ); | |||||
| setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b))); | |||||
| setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b))); | |||||
| setStoreIdOptions(Array.from(storeSet).sort((a, b) => a.localeCompare(b))); | |||||
| }) | }) | ||||
| .catch((e) => { | .catch((e) => { | ||||
| console.error("Failed to load section descriptions for approver search:", e); | console.error("Failed to load section descriptions for approver search:", e); | ||||
| @@ -361,6 +327,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| setApproverQty({}); | setApproverQty({}); | ||||
| setApproverBadQty({}); | setApproverBadQty({}); | ||||
| setAppliedFilters(null); | setAppliedFilters(null); | ||||
| setSearchStatus(mode === "pending" ? "pass" : "All"); | |||||
| setPage(0); | setPage(0); | ||||
| setInventoryLotDetails([]); | setInventoryLotDetails([]); | ||||
| setTotal(0); | setTotal(0); | ||||
| @@ -491,8 +458,14 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const percent = parseFloat(variancePercentTolerance || "0"); | const percent = parseFloat(variancePercentTolerance || "0"); | ||||
| const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; | const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; | ||||
| const statusFilter = appliedFilters?.status ?? "All"; | |||||
| const statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All"; | |||||
| const storeIdFilter = appliedFilters?.storeId ?? "All"; | |||||
| return inventoryLotDetails.filter((detail) => { | return inventoryLotDetails.filter((detail) => { | ||||
| if (storeIdFilter !== "All") { | |||||
| if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (statusFilter !== "All") { | if (statusFilter !== "All") { | ||||
| const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); | const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); | ||||
| const wanted = normalizeStatus(statusFilter); | const wanted = normalizeStatus(statusFilter); | ||||
| @@ -526,6 +499,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| variancePercentTolerance, | variancePercentTolerance, | ||||
| qtySelection, | qtySelection, | ||||
| calculateDifference, | calculateDifference, | ||||
| appliedFilters, | |||||
| mode, | |||||
| ]); | ]); | ||||
| const sortedDetails = useMemo(() => { | const sortedDetails = useMemo(() => { | ||||
| @@ -890,7 +865,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| { | { | ||||
| field: "qtyBlock", | field: "qtyBlock", | ||||
| headerName: t("Stock Take Qty(include Bad Qty)= Available Qty"), | headerName: t("Stock Take Qty(include Bad Qty)= Available Qty"), | ||||
| minWidth: 420, | |||||
| minWidth: 320, | |||||
| flex: 3, | flex: 3, | ||||
| sortable: false, | sortable: false, | ||||
| renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => { | renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => { | ||||
| @@ -949,7 +924,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| {formatNumber( | {formatNumber( | ||||
| (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) | (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) | ||||
| )}{" "} | )}{" "} | ||||
| ({detail.firstBadQty ?? 0}) ={" "} | |||||
| {/* ({detail.firstBadQty ?? 0}) */} | |||||
| ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | {formatNumber(detail.firstStockTakeQty ?? 0)} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -974,7 +950,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| (detail.secondStockTakeQty ?? 0) + | (detail.secondStockTakeQty ?? 0) + | ||||
| (detail.secondBadQty ?? 0) | (detail.secondBadQty ?? 0) | ||||
| )}{" "} | )}{" "} | ||||
| ({detail.secondBadQty ?? 0}) ={" "} | |||||
| {/* ({detail.secondBadQty ?? 0}) */} | |||||
| ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | {formatNumber(detail.secondStockTakeQty ?? 0)} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -1013,6 +990,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| /> | /> | ||||
| {/* | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| @@ -1030,6 +1008,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| disabled={mode === "approved" || selection !== "approver"} | disabled={mode === "approved" || selection !== "approver"} | ||||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | ||||
| /> | /> | ||||
| */ | |||||
| } | |||||
| <Typography variant="body2" sx={{ minWidth: 90 }}> | <Typography variant="body2" sx={{ minWidth: 90 }}> | ||||
| = {formatNumber(approverGoodQty)} | = {formatNumber(approverGoodQty)} | ||||
| </Typography> | </Typography> | ||||
| @@ -1124,8 +1104,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| sortable: false, | sortable: false, | ||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| const detail = params.row; | const detail = params.row; | ||||
| const hasSecond = | |||||
| detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0; | |||||
| const hasSecond = detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0; | |||||
| const selection = | const selection = | ||||
| qtySelection[detail.id] || (hasSecond ? "second" : "first"); | qtySelection[detail.id] || (hasSecond ? "second" : "first"); | ||||
| @@ -1149,7 +1128,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| variant="outlined" | variant="outlined" | ||||
| color="warning" | color="warning" | ||||
| onClick={() => handleUpdateStatusToNotMatch(detail)} | onClick={() => handleUpdateStatusToNotMatch(detail)} | ||||
| disabled={updatingStatus} | |||||
| disabled={updatingStatus||hasSecond} | |||||
| > | > | ||||
| {t("Not Match")} | {t("Not Match")} | ||||
| </Button> | </Button> | ||||
| @@ -1189,6 +1168,25 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| ]); | ]); | ||||
| const effectivePageSize = | const effectivePageSize = | ||||
| pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); | pageSize === "all" ? Math.max(total || 0, 0) : (pageSize as number); | ||||
| useEffect(() => { | |||||
| setStockTakeSectionOptions((prev) => { | |||||
| const sectionSet = new Set<string>(prev); | |||||
| inventoryLotDetails.forEach((item) => { | |||||
| const section = item.stockTakeSection?.trim(); | |||||
| if (section) sectionSet.add(section); | |||||
| }); | |||||
| return Array.from(sectionSet).sort((a, b) => a.localeCompare(b)); | |||||
| }); | |||||
| setStoreIdOptions((prev) => { | |||||
| const storeSet = new Set<string>([...prev, "2F", "4F"]); | |||||
| inventoryLotDetails.forEach((item) => { | |||||
| const storeId = item.storeId?.trim(); | |||||
| if (storeId) storeSet.add(storeId); | |||||
| }); | |||||
| return Array.from(storeSet).sort((a, b) => a.localeCompare(b)); | |||||
| }); | |||||
| }, [inventoryLotDetails]); | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| {onBack && ( | {onBack && ( | ||||
| @@ -1260,11 +1258,101 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| <AccordionDetails> | <AccordionDetails> | ||||
| <Box sx={{ width: "100%" }}> | <Box sx={{ width: "100%" }}> | ||||
| <SearchBox<ApproverSearchKey> | |||||
| criteria={approverSearchCriteria} | |||||
| onSearch={handleApproverSearchBoxSearch} | |||||
| onReset={handleApproverSearchBoxReset} | |||||
| /> | |||||
| <Card elevation={0}> | |||||
| <CardContent> | |||||
| <Grid container spacing={2}> | |||||
| <Grid item xs={12} md={4}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel>{t("Stock Take Section")}</InputLabel> | |||||
| <Select | |||||
| value={searchSectionDescription} | |||||
| label={t("Stock Take Section")} | |||||
| onChange={(e) => setSearchSectionDescription(e.target.value)} | |||||
| > | |||||
| <MenuItem value="All">{t("All")}</MenuItem> | |||||
| {sectionDescriptionOptions.map((desc) => ( | |||||
| <MenuItem key={desc} value={desc}> | |||||
| {desc} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={4}> | |||||
| <Autocomplete | |||||
| freeSolo | |||||
| options={stockTakeSectionOptions} | |||||
| value={searchStockTakeSession} | |||||
| onInputChange={(_, newValue) => setSearchStockTakeSession(newValue)} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| fullWidth | |||||
| label={t("Stock Take Section (can use , to search multiple sections)")} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={4}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Item")} | |||||
| value={searchItemKeyword} | |||||
| onChange={(e) => setSearchItemKeyword(e.target.value)} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={4}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Warehouse")} | |||||
| value={searchWarehouseKeyword} | |||||
| onChange={(e) => setSearchWarehouseKeyword(e.target.value)} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={4}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel>{t("Store ID")}</InputLabel> | |||||
| <Select | |||||
| value={searchStoreId} | |||||
| label={t("Store ID")} | |||||
| onChange={(e) => setSearchStoreId(e.target.value)} | |||||
| > | |||||
| <MenuItem value="All">{t("All")}</MenuItem> | |||||
| {storeIdOptions.map((storeId) => ( | |||||
| <MenuItem key={storeId} value={storeId}> | |||||
| {storeId} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| {mode === "pending" && ( | |||||
| <Grid item xs={12} md={4}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel>{t("Record Status")}</InputLabel> | |||||
| <Select | |||||
| value={searchStatus} | |||||
| label={t("Record Status")} | |||||
| onChange={(e) => setSearchStatus(e.target.value)} | |||||
| > | |||||
| <MenuItem value="pending">{t("Pending")}</MenuItem> | |||||
| <MenuItem value="notMatch">{t("Not Match")}</MenuItem> | |||||
| <MenuItem value="pass">{t("Pass")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| )} | |||||
| </Grid> | |||||
| <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | |||||
| <Button variant="outlined" onClick={handleResetSearch}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={handleSearch}> | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </Box> | </Box> | ||||
| </AccordionDetails> | </AccordionDetails> | ||||
| </Accordion> | </Accordion> | ||||
| @@ -12,21 +12,24 @@ import { | |||||
| CircularProgress, | CircularProgress, | ||||
| TablePagination, | TablePagination, | ||||
| Grid, | Grid, | ||||
| LinearProgress, | |||||
| Dialog, | Dialog, | ||||
| DialogTitle, | DialogTitle, | ||||
| DialogContent, | DialogContent, | ||||
| DialogContentText, | DialogContentText, | ||||
| DialogActions, | DialogActions, | ||||
| TextField, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Select, | |||||
| MenuItem, | |||||
| Autocomplete, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||||
| import { useState, useCallback, useEffect } from "react"; | import { useState, useCallback, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import duration from "dayjs/plugin/duration"; | import duration from "dayjs/plugin/duration"; | ||||
| import { | import { | ||||
| getStockTakeRecords, | |||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| createStockTakeForSections, | createStockTakeForSections, | ||||
| getStockTakeRecordsPaged, | getStockTakeRecordsPaged, | ||||
| @@ -36,7 +39,6 @@ import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import { AUTH } from "@/authorities"; | import { AUTH } from "@/authorities"; | ||||
| const PER_PAGE = 6; | |||||
| interface PickerCardListProps { | interface PickerCardListProps { | ||||
| /** 由父層保存,從明細返回時仍回到同一頁 */ | /** 由父層保存,從明細返回時仍回到同一頁 */ | ||||
| @@ -57,7 +59,6 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| const { t } = useTranslation(["inventory", "common"]); | const { t } = useTranslation(["inventory", "common"]); | ||||
| dayjs.extend(duration); | dayjs.extend(duration); | ||||
| const PER_PAGE = 6; | |||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | ||||
| const [total, setTotal] = useState(0); | const [total, setTotal] = useState(0); | ||||
| @@ -68,66 +69,45 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ | |||||
| const [listRefreshNonce, setListRefreshNonce] = useState(0); | const [listRefreshNonce, setListRefreshNonce] = useState(0); | ||||
| const [creating, setCreating] = useState(false); | const [creating, setCreating] = useState(false); | ||||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | ||||
| const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | |||||
| const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>(""); | |||||
| const [sectionDescriptionAutocompleteOptions, setSectionDescriptionAutocompleteOptions] = useState<{ value: string; label: string }[]>([]); | |||||
| type PickerSearchKey = "sectionDescription" | "stockTakeSession"; | |||||
| const sectionDescriptionOptions = Array.from( | |||||
| new Set( | |||||
| stockTakeSessions | |||||
| .map((s) => s.stockTakeSectionDescription) | |||||
| .filter((v): v is string => !!v) | |||||
| ) | |||||
| ); | |||||
| /* | |||||
| // 按 description + section 双条件过滤 | |||||
| const filteredSessions = stockTakeSessions.filter((s) => { | |||||
| const matchDesc = | |||||
| filterSectionDescription === "All" || | |||||
| s.stockTakeSectionDescription === filterSectionDescription; | |||||
| const [sectionDescriptionOptions, setSectionDescriptionOptions] = useState<string[]>([]); | |||||
| const [stockTakeSectionOptions, setStockTakeSectionOptions] = useState<string[]>([]); | |||||
| const [storeIdOptions, setStoreIdOptions] = useState<string[]>(["2F", "4F"]); | |||||
| const [searchSectionDescription, setSearchSectionDescription] = useState<string>("All"); | |||||
| const [searchStockTakeSession, setSearchStockTakeSession] = useState<string>(""); | |||||
| const [searchStatus, setSearchStatus] = useState<string>("All"); | |||||
| const [searchArea, setSearchArea] = useState<string>(""); | |||||
| const [searchStoreId, setSearchStoreId] = useState<string>("All"); | |||||
| const sessionParts = (filterStockTakeSession ?? "") | |||||
| .split(",") | |||||
| .map((p) => p.trim().toLowerCase()) | |||||
| .filter(Boolean); | |||||
| const matchSession = | |||||
| sessionParts.length === 0 || | |||||
| sessionParts.some((part) => | |||||
| (s.stockTakeSession ?? "").toString().toLowerCase().includes(part) | |||||
| ); | |||||
| const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | |||||
| const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>(""); | |||||
| const [filterStatus, setFilterStatus] = useState<string>("All"); | |||||
| const [filterArea, setFilterArea] = useState<string>(""); | |||||
| const [filterStoreId, setFilterStoreId] = useState<string>("All"); | |||||
| return matchDesc && matchSession; | |||||
| }); | |||||
| */ | |||||
| const statusOptions = ["pending", "stockTaking", "approving", "completed"]; | |||||
| // SearchBox 的条件配置 | |||||
| const criteria: Criterion<PickerSearchKey>[] = [ | |||||
| { | |||||
| type: "autocomplete", | |||||
| label: t("Stock Take Section Description"), | |||||
| paramName: "sectionDescription", | |||||
| options: sectionDescriptionAutocompleteOptions, | |||||
| needAll: true, | |||||
| }, | |||||
| { | |||||
| type: "text", | |||||
| label: t("Stock Take Section (can use , to search multiple sections)"), | |||||
| paramName: "stockTakeSession", | |||||
| placeholder: "", | |||||
| }, | |||||
| ]; | |||||
| const handleSearch = () => { | |||||
| setFilterSectionDescription(searchSectionDescription || "All"); | |||||
| setFilterStockTakeSession(searchStockTakeSession || ""); | |||||
| setFilterStatus(searchStatus || "All"); | |||||
| setFilterArea(searchArea || ""); | |||||
| setFilterStoreId(searchStoreId || "All"); | |||||
| onListPageChange(0); | |||||
| }; | |||||
| const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => { | |||||
| setFilterSectionDescription(inputs.sectionDescription || "All"); | |||||
| setFilterStockTakeSession(inputs.stockTakeSession || ""); | |||||
| onListPageChange(0); | |||||
| }; | |||||
| const handleResetSearch = () => { | |||||
| setFilterSectionDescription("All"); | |||||
| setFilterStockTakeSession(""); | |||||
| onListPageChange(0); | |||||
| }; | |||||
| const handleResetSearch = () => { | |||||
| setSearchSectionDescription("All"); | |||||
| setSearchStockTakeSession(""); | |||||
| setSearchStatus("All"); | |||||
| setSearchArea(""); | |||||
| setSearchStoreId("All"); | |||||
| setFilterSectionDescription("All"); | |||||
| setFilterStockTakeSession(""); | |||||
| setFilterStatus("All"); | |||||
| setFilterArea(""); | |||||
| setFilterStoreId("All"); | |||||
| onListPageChange(0); | |||||
| }; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| let cancelled = false; | let cancelled = false; | ||||
| @@ -135,6 +115,9 @@ const handleResetSearch = () => { | |||||
| getStockTakeRecordsPaged(page, pageSize, { | getStockTakeRecordsPaged(page, pageSize, { | ||||
| sectionDescription: filterSectionDescription, | sectionDescription: filterSectionDescription, | ||||
| stockTakeSections: filterStockTakeSession, | stockTakeSections: filterStockTakeSession, | ||||
| status: filterStatus, | |||||
| area: filterArea, | |||||
| storeId: filterStoreId, | |||||
| }) | }) | ||||
| .then((res) => { | .then((res) => { | ||||
| if (cancelled) return; | if (cancelled) return; | ||||
| @@ -154,7 +137,7 @@ const handleResetSearch = () => { | |||||
| return () => { | return () => { | ||||
| cancelled = true; | cancelled = true; | ||||
| }; | }; | ||||
| }, [page, pageSize, filterSectionDescription, filterStockTakeSession, listRefreshNonce]); | |||||
| }, [page, pageSize, filterSectionDescription, filterStockTakeSession, filterStatus, filterArea, filterStoreId, listRefreshNonce]); | |||||
| //const startIdx = page * PER_PAGE; | //const startIdx = page * PER_PAGE; | ||||
| //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | //const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | ||||
| @@ -187,18 +170,44 @@ const handleResetSearch = () => { | |||||
| fetchStockTakeSections() | fetchStockTakeSections() | ||||
| .then((sections) => { | .then((sections) => { | ||||
| const descSet = new Set<string>(); | const descSet = new Set<string>(); | ||||
| const sectionSet = new Set<string>(); | |||||
| const storeIdSet = new Set<string>(["2F", "4F"]); | |||||
| sections.forEach((s) => { | sections.forEach((s) => { | ||||
| const section = s.stockTakeSection?.trim(); | |||||
| if (section) sectionSet.add(section); | |||||
| const desc = s.stockTakeSectionDescription?.trim(); | const desc = s.stockTakeSectionDescription?.trim(); | ||||
| if (desc) descSet.add(desc); | if (desc) descSet.add(desc); | ||||
| const storeId = s.storeId?.trim(); | |||||
| if (storeId) storeIdSet.add(storeId); | |||||
| }); | }); | ||||
| setSectionDescriptionAutocompleteOptions( | |||||
| Array.from(descSet).map((desc) => ({ value: desc, label: desc })) | |||||
| ); | |||||
| setStockTakeSectionOptions(Array.from(sectionSet).sort((a, b) => a.localeCompare(b))); | |||||
| setSectionDescriptionOptions(Array.from(descSet).sort((a, b) => a.localeCompare(b))); | |||||
| setStoreIdOptions(Array.from(storeIdSet).sort((a, b) => a.localeCompare(b))); | |||||
| }) | }) | ||||
| .catch((e) => { | .catch((e) => { | ||||
| console.error("Failed to load section descriptions for filter:", e); | console.error("Failed to load section descriptions for filter:", e); | ||||
| }); | }); | ||||
| }, []); | }, []); | ||||
| useEffect(() => { | |||||
| setStockTakeSectionOptions((prev) => { | |||||
| const sectionSet = new Set<string>(prev); | |||||
| stockTakeSessions.forEach((item) => { | |||||
| const section = item.stockTakeSession?.trim(); | |||||
| if (section) sectionSet.add(section); | |||||
| }); | |||||
| return Array.from(sectionSet).sort((a, b) => a.localeCompare(b)); | |||||
| }); | |||||
| }, [stockTakeSessions]); | |||||
| useEffect(() => { | |||||
| setStoreIdOptions((prev) => { | |||||
| const storeIdSet = new Set<string>([...prev, "2F", "4F"]); | |||||
| stockTakeSessions.forEach((item) => { | |||||
| const storeId = item.storeId?.trim(); | |||||
| if (storeId) storeIdSet.add(storeId); | |||||
| }); | |||||
| return Array.from(storeIdSet).sort((a, b) => a.localeCompare(b)); | |||||
| }); | |||||
| }, [stockTakeSessions]); | |||||
| const getStatusColor = (status: string) => { | const getStatusColor = (status: string) => { | ||||
| const statusLower = status.toLowerCase(); | const statusLower = status.toLowerCase(); | ||||
| if (statusLower === "completed") return "success"; | if (statusLower === "completed") return "success"; | ||||
| @@ -277,23 +286,99 @@ const handleResetSearch = () => { | |||||
| if (!first?.planStartDate) return null; | if (!first?.planStartDate) return null; | ||||
| return dayjs(first.planStartDate).format(OUTPUT_DATE_FORMAT); | return dayjs(first.planStartDate).format(OUTPUT_DATE_FORMAT); | ||||
| })(); | })(); | ||||
| if (loading) { | |||||
| return ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ); | |||||
| } | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <Box sx={{ width: "100%", mb: 2 }}> | |||||
| <SearchBox<PickerSearchKey> | |||||
| criteria={criteria} | |||||
| onSearch={handleSearch} | |||||
| onReset={handleResetSearch} | |||||
| /> | |||||
| </Box> | |||||
| <Card elevation={0} sx={{ mb: 2 }}> | |||||
| <CardContent> | |||||
| <Typography variant="overline" sx={{ display: "block", mb: 1 }}> | |||||
| {t("Search Criteria")} | |||||
| </Typography> | |||||
| <Grid container spacing={2}> | |||||
| <Grid item xs={12} md={4}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel>{t("Stock Take Section")}</InputLabel> | |||||
| <Select | |||||
| value={searchSectionDescription} | |||||
| label={t("Stock Take Section")} | |||||
| onChange={(e) => setSearchSectionDescription(e.target.value)} | |||||
| > | |||||
| <MenuItem value="All">{t("All")}</MenuItem> | |||||
| {sectionDescriptionOptions.map((desc) => ( | |||||
| <MenuItem key={desc} value={desc}> | |||||
| {desc} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={4}> | |||||
| <Autocomplete | |||||
| freeSolo | |||||
| options={stockTakeSectionOptions} | |||||
| value={searchStockTakeSession} | |||||
| onInputChange={(_, newValue) => setSearchStockTakeSession(newValue)} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| fullWidth | |||||
| label={t("Stock Take Section (can use , to search multiple sections)")} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={4}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel>{t("Status")}</InputLabel> | |||||
| <Select | |||||
| value={searchStatus} | |||||
| label={t("Status")} | |||||
| onChange={(e) => setSearchStatus(e.target.value)} | |||||
| > | |||||
| <MenuItem value="All">{t("All")}</MenuItem> | |||||
| {statusOptions.map((status) => ( | |||||
| <MenuItem key={status} value={status}> | |||||
| {t(status)} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={6}> | |||||
| <TextField | |||||
| fullWidth | |||||
| label={t("Area")} | |||||
| value={searchArea} | |||||
| onChange={(e) => setSearchArea(e.target.value)} | |||||
| /> | |||||
| </Grid> | |||||
| <Grid item xs={12} md={6}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel>{t("Store ID")}</InputLabel> | |||||
| <Select | |||||
| value={searchStoreId} | |||||
| label={t("Store ID")} | |||||
| onChange={(e) => setSearchStoreId(e.target.value)} | |||||
| > | |||||
| <MenuItem value="All">{t("All")}</MenuItem> | |||||
| {storeIdOptions.map((storeId) => ( | |||||
| <MenuItem key={storeId} value={storeId}> | |||||
| {storeId} | |||||
| </MenuItem> | |||||
| ))} | |||||
| </Select> | |||||
| </FormControl> | |||||
| </Grid> | |||||
| </Grid> | |||||
| <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | |||||
| <Button variant="outlined" onClick={handleResetSearch}> | |||||
| {t("Reset")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={handleSearch}> | |||||
| {t("Search")} | |||||
| </Button> | |||||
| </CardActions> | |||||
| </CardContent> | |||||
| </Card> | |||||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | ||||
| @@ -314,13 +399,23 @@ const handleResetSearch = () => { | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| {loading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => { | |||||
| {stockTakeSessions.map((session: AllPickedStockTakeListReponse) => { | |||||
| const statusColor = getStatusColor(session.status || ""); | const statusColor = getStatusColor(session.status || ""); | ||||
| const lastStockTakeDate = session.lastStockTakeDate | const lastStockTakeDate = session.lastStockTakeDate | ||||
| ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT) | ? dayjs(session.lastStockTakeDate).format(OUTPUT_DATE_FORMAT) | ||||
| : "-"; | : "-"; | ||||
| const completionRate = getCompletionRate(session); | const completionRate = getCompletionRate(session); | ||||
| const sectionMeta = [ | |||||
| session.stockTakeSectionDescription, | |||||
| session.warehouseArea, | |||||
| session.storeId, | |||||
| ].filter((v): v is string => Boolean(v && v.trim())).join(" / "); | |||||
| return ( | return ( | ||||
| <Grid key={session.id} item xs={12} sm={6} md={4}> | <Grid key={session.id} item xs={12} sm={6} md={4}> | ||||
| @@ -337,7 +432,7 @@ const handleResetSearch = () => { | |||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | ||||
| <Typography variant="subtitle1" fontWeight={600}> | <Typography variant="subtitle1" fontWeight={600}> | ||||
| {t("Section")}: {session.stockTakeSession} | {t("Section")}: {session.stockTakeSession} | ||||
| {session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null} | |||||
| {sectionMeta ? ` (${sectionMeta})` : null} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -383,6 +478,7 @@ const handleResetSearch = () => { | |||||
| ); | ); | ||||
| })} | })} | ||||
| </Grid> | </Grid> | ||||
| )} | |||||
| {total > 0 && ( | {total > 0 && ( | ||||
| <TablePagination | <TablePagination | ||||
| @@ -495,6 +495,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| /> | /> | ||||
| {/* | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| @@ -520,6 +521,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} | = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} | ||||
| </Typography> | </Typography> | ||||
| @@ -528,7 +530,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("First")}:{" "} | {t("First")}:{" "} | ||||
| {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} | {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} | ||||
| ({formatNumber(detail.firstBadQty ?? 0)}) ={" "} | |||||
| {/* ({formatNumber(detail.firstBadQty ?? 0)}) */} | |||||
| ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | {formatNumber(detail.firstStockTakeQty ?? 0)} | ||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| @@ -562,6 +565,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| /> | /> | ||||
| {/* | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| @@ -587,6 +591,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} | = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} | ||||
| </Typography> | </Typography> | ||||
| @@ -595,7 +600,8 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| {t("Second")}:{" "} | {t("Second")}:{" "} | ||||
| {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} | {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} | ||||
| ({formatNumber(detail.secondBadQty ?? 0)}) ={" "} | |||||
| {/* ({formatNumber(detail.secondBadQty ?? 0)}) */} | |||||
| ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | {formatNumber(detail.secondStockTakeQty ?? 0)} | ||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| @@ -191,18 +191,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | ||||
| const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; | const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; | ||||
| // 只檢查 totalQty,Bad Qty 未輸入時預設為 0 | |||||
| if (!totalQtyStr) { | |||||
| onSnackbar( | |||||
| isFirstSubmit | |||||
| ? t("Please enter QTY") | |||||
| : t("Please enter Second QTY"), | |||||
| "error" | |||||
| ); | |||||
| return; | |||||
| } | |||||
| const totalQty = parseFloat(totalQtyStr); | |||||
| const totalQty = parseFloat(totalQtyStr || "0") || 0; | |||||
| const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0 | const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0 | ||||
| if (Number.isNaN(totalQty)) { | if (Number.isNaN(totalQty)) { | ||||
| @@ -617,6 +608,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| /> | /> | ||||
| {/* | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| @@ -643,6 +635,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | = | ||||
| {formatNumber( | {formatNumber( | ||||
| @@ -658,11 +651,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| (detail.firstStockTakeQty ?? 0) + | (detail.firstStockTakeQty ?? 0) + | ||||
| (detail.firstBadQty ?? 0) | (detail.firstBadQty ?? 0) | ||||
| )}{" "} | )}{" "} | ||||
| {/* | |||||
| ( | ( | ||||
| {formatNumber( | {formatNumber( | ||||
| detail.firstBadQty ?? 0 | detail.firstBadQty ?? 0 | ||||
| )} | )} | ||||
| ) ={" "} | |||||
| */} | |||||
| ={" "} | |||||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | {formatNumber(detail.firstStockTakeQty ?? 0)} | ||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| @@ -693,6 +688,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| /> | /> | ||||
| {/* | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| @@ -715,6 +711,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| */} | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | = | ||||
| {formatNumber( | {formatNumber( | ||||
| @@ -730,11 +727,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| (detail.secondStockTakeQty ?? 0) + | (detail.secondStockTakeQty ?? 0) + | ||||
| (detail.secondBadQty ?? 0) | (detail.secondBadQty ?? 0) | ||||
| )}{" "} | )}{" "} | ||||
| {/* | |||||
| ( | ( | ||||
| {formatNumber( | {formatNumber( | ||||
| detail.secondBadQty ?? 0 | detail.secondBadQty ?? 0 | ||||
| )} | )} | ||||
| ) ={" "} | |||||
| */} | |||||
| ={" "} | |||||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | {formatNumber(detail.secondStockTakeQty ?? 0)} | ||||
| </Typography> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| @@ -110,7 +110,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`, | ||||
| fields: [ | fields: [ | ||||
| { | { | ||||
| label: "盤點輪次 Stock Take Round", | |||||
| label: "盤點輪次", | |||||
| name: "stockTakeRoundId", | name: "stockTakeRoundId", | ||||
| type: "select", | type: "select", | ||||
| required: true, | required: true, | ||||
| @@ -118,7 +118,31 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | ||||
| options: [] | options: [] | ||||
| }, | }, | ||||
| { label: "物料編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| { label: "物料編號", name: "itemCode", type: "text", required: false}, | |||||
| { | |||||
| label: "倉庫樓層", | |||||
| name: "store_id", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "1F", value: "1F" }, | |||||
| { label: "2F", value: "2F" }, | |||||
| { label: "3F", value: "3F" }, | |||||
| { label: "4F", value: "4F" } | |||||
| ], | |||||
| }, | |||||
| { | |||||
| label: "狀態", | |||||
| name: "status", | |||||
| type: "select", | |||||
| required: false, | |||||
| options: [ | |||||
| { label: "全部", value: "All" }, | |||||
| { label: "待盤點", value: "pending" }, | |||||
| { label: "已審核", value: "completed" } | |||||
| ], | |||||
| }, | |||||
| ] | ] | ||||
| }, | }, | ||||
| { id: "rep-011", | { id: "rep-011", | ||||
| @@ -15,8 +15,6 @@ | |||||
| "Floor": "樓層", | "Floor": "樓層", | ||||
| "Job Order Type": "工單類型", | "Job Order Type": "工單類型", | ||||
| "FG": "成品", | |||||
| "WIP": "半成品", | |||||
| "BOM Type": "BOM 類型", | "BOM Type": "BOM 類型", | ||||
| "No Lot": "沒有批號", | "No Lot": "沒有批號", | ||||
| "Select All": "全選", | "Select All": "全選", | ||||
| @@ -110,7 +108,7 @@ | |||||
| "code": "編號", | "code": "編號", | ||||
| "Name": "名稱", | "Name": "名稱", | ||||
| "Assignment successful": "分配成功", | "Assignment successful": "分配成功", | ||||
| "Pass": "通過", | |||||
| "Unable to get user ID": "無法獲取用戶ID", | "Unable to get user ID": "無法獲取用戶ID", | ||||
| "Unknown error: ": "未知錯誤: ", | "Unknown error: ": "未知錯誤: ", | ||||
| "Please try again later.": "請稍後重試。", | "Please try again later.": "請稍後重試。", | ||||
| @@ -468,8 +466,8 @@ | |||||
| "Powder_Mixture": "箱料粉", | "Powder_Mixture": "箱料粉", | ||||
| "Powder Mixture": "箱料粉", | "Powder Mixture": "箱料粉", | ||||
| "Not Match": "數值不符", | "Not Match": "數值不符", | ||||
| "Pass": "通過", | |||||
| "pass": "通過", | |||||
| "Pass": "已盤點", | |||||
| "pass": "已盤點", | |||||
| "Actions": "操作", | "Actions": "操作", | ||||
| "Insert": "插入", | "Insert": "插入", | ||||
| "Move to order": "移動到指定順序", | "Move to order": "移動到指定順序", | ||||
| @@ -50,11 +50,13 @@ | |||||
| "Batch Save All": "批量保存所有", | "Batch Save All": "批量保存所有", | ||||
| "not match": "數值不符", | "not match": "數值不符", | ||||
| "Not Match": "數值不符", | "Not Match": "數值不符", | ||||
| "Pass": "通過", | |||||
| "Pass": "已盤點", | |||||
| "Area": "區域", | |||||
| "Selected Qty": "選擇數量", | "Selected Qty": "選擇數量", | ||||
| "Show Search Filters": "顯示搜索器", | "Show Search Filters": "顯示搜索器", | ||||
| "Hide Search Filters": "隱藏搜索器", | "Hide Search Filters": "隱藏搜索器", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數(含壞品)= 可用數", | |||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", | |||||
| "View ReStockTake": "查看重新盤點", | "View ReStockTake": "查看重新盤點", | ||||
| "Stock Take Qty": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "variance Percentage": "差異百分比", | "variance Percentage": "差異百分比", | ||||
| @@ -97,9 +99,11 @@ | |||||
| "book qty": "帳面庫存", | "book qty": "帳面庫存", | ||||
| "start time": "開始時間", | "start time": "開始時間", | ||||
| "end time": "結束時間", | "end time": "結束時間", | ||||
| "notmatch": "數值不符", | |||||
| "Only Variance": "僅差異", | "Only Variance": "僅差異", | ||||
| "Control Time": "操作時間", | "Control Time": "操作時間", | ||||
| "pass": "通過", | |||||
| "Batch approver save completed: {{success}} success, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,錯誤 {{errors}} 筆", | |||||
| "pass": "已盤點", | |||||
| "not pass": "不通過", | "not pass": "不通過", | ||||
| "Available": "可用", | "Available": "可用", | ||||
| "approving": "審核中", | "approving": "審核中", | ||||
| @@ -126,6 +130,7 @@ | |||||
| "Create Stock Take for All Sections": "為所有區域創建盤點", | "Create Stock Take for All Sections": "為所有區域創建盤點", | ||||
| "section": "區域", | "section": "區域", | ||||
| "Stock Take Section": "盤點區域", | "Stock Take Section": "盤點區域", | ||||
| "Store ID":"樓層", | |||||
| "Warehouse Location": "倉庫位置", | "Warehouse Location": "倉庫位置", | ||||
| "UOM": "單位", | "UOM": "單位", | ||||
| "First Qty": "第一次盤點數量", | "First Qty": "第一次盤點數量", | ||||