diff --git a/src/app/(main)/stocktakemanagement/page.tsx b/src/app/(main)/stocktakemanagement/page.tsx new file mode 100644 index 0000000..252bdf4 --- /dev/null +++ b/src/app/(main)/stocktakemanagement/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from "react"; +import StockTakeManagementWrapper from "@/components/StockTakeManagement"; +import { I18nProvider, getServerI18n } from "@/i18n"; +import { Typography } from "@mui/material"; +import { isArray } from "lodash"; +import { Metadata } from "next"; +import { notFound } from "next/navigation"; + + +export default async function InventoryManagementPage() { + const { t } = await getServerI18n("inventory"); + return ( + + }> + + + + ); +} \ No newline at end of file diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 4be8783..aabc77b 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -101,7 +101,31 @@ export interface PrintDNLabelsRespone{ success: boolean; message?: string } +export interface BatchReleaseRequest { + ids: number[]; +} +export interface BatchReleaseResponse { + success: boolean; + message?: string +} +export const startBatchReleaseAsync = cache(async (data: { ids: number[]; userId: number }) => { + const { ids, userId } = data; + return await serverFetchJson<{ id: number|null; code: string; entity?: any }>( + `${BASE_API_URL}/doPickOrder/batch-release/async?userId=${userId}`, + { + method: "POST", + body: JSON.stringify(ids), + headers: { "Content-Type": "application/json" }, + } + ); +}); +export const getBatchReleaseProgress = cache(async (jobId: string) => { + return await serverFetchJson<{ id: number|null; code: string; entity?: any }>( + `${BASE_API_URL}/doPickOrder/batch-release/progress/${jobId}`, + { method: "GET" } + ); +}); export const assignPickOrderByStore = cache(async (data: AssignByStoreRequest) => { return await serverFetchJson(`${BASE_API_URL}/doPickOrder/assign-by-store`, { diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index ead62f8..8e885a9 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -400,8 +400,11 @@ export const updatePickExecutionIssueStatus = async ( return result; }; export async function fetchStoreLaneSummary(storeId: string): Promise { + // ✅ 硬编码测试日期 - 改成你想测试的日期 + const testDate = "2025-10-16"; // 或者 "2025-10-16", "2025-10-17" 等 + const response = await serverFetchJson( - `${BASE_API_URL}/doPickOrder/summary-by-store?storeId=${encodeURIComponent(storeId)}`, + `${BASE_API_URL}/doPickOrder/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${testDate}`, { method: "GET", } diff --git a/src/components/DoDetail/DoDetail.tsx b/src/components/DoDetail/DoDetail.tsx index e7d70c1..9f3bd0b 100644 --- a/src/components/DoDetail/DoDetail.tsx +++ b/src/components/DoDetail/DoDetail.tsx @@ -33,7 +33,8 @@ const DoDetail: React.FC = ({ const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Use correct session type const currentUserId = session?.id ? parseInt(session.id) : undefined; // ✅ Get user ID from session.id - + console.log("🔍 DoSearch - session:", session); +console.log("🔍 DoSearch - currentUserId:", currentUserId); const formProps = useForm({ defaultValues: defaultValues }) diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index 520d5a6..b2acb47 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -1,7 +1,7 @@ "use client"; import { DoResult } from "@/app/api/do"; -import { DoSearchAll, fetchDoSearch, releaseDo } from "@/app/api/do/actions"; +import { DoSearchAll, fetchDoSearch, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions"; import { useRouter } from "next/navigation"; import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -30,7 +30,8 @@ import { Box, Button, Grid, Stack, Typography, TablePagination } from "@mui/mate import StyledDataGrid from "../StyledDataGrid"; import { GridRowSelectionModel } from "@mui/x-data-grid"; import Swal from "sweetalert2"; - +import { useSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; type Props = { filterArgs?: Record; searchQuery?: Record; @@ -60,6 +61,11 @@ const DoSearch: React.FC = ({filterArgs, searchQuery, onDeliveryOrderSear const { t } = useTranslation("do"); const router = useRouter(); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const currentUserId = session?.id ? parseInt(session.id) : undefined; + console.log("🔍 DoSearch - session:", session); +console.log("🔍 DoSearch - currentUserId:", currentUserId); + const [searchTimeout, setSearchTimeout] = useState(null); const [rowSelectionModel, setRowSelectionModel] = useState([]); @@ -293,15 +299,14 @@ const DoSearch: React.FC = ({filterArgs, searchQuery, onDeliveryOrderSear //SEARCH FUNCTION const handleSearch = useCallback(async (query: SearchBoxInputs) => { try { - setCurrentSearchParams(query); - + let orderStartDate = query.orderDate; let orderEndDate = query.orderDateTo; let estArrStartDate = query.estimatedArrivalDate; let estArrEndDate = query.estimatedArrivalDateTo; const time = "T00:00:00"; - + if(orderStartDate != ""){ orderStartDate = query.orderDate + time; } @@ -332,7 +337,7 @@ const DoSearch: React.FC = ({filterArgs, searchQuery, onDeliveryOrderSear estArrStartDate, estArrEndDate ); - + setSearchAllDos(data); setHasSearched(true); setHasResults(data.length > 0); @@ -340,94 +345,126 @@ const DoSearch: React.FC = ({filterArgs, searchQuery, onDeliveryOrderSear console.error("Error: ", error); } }, []); - - const handleBatchRelease = useCallback(async () => { - const query = currentSearchParams; - - let orderStartDate = query.orderDate; - let orderEndDate = query.orderDateTo; - let estArrStartDate = query.estimatedArrivalDate; - let estArrEndDate = query.estimatedArrivalDateTo; - const time = "T00:00:00"; - -if(orderStartDate != ""){ - orderStartDate = query.orderDate + time; - } - if(orderEndDate != ""){ - orderEndDate = query.orderDateTo + time; - } - if(estArrStartDate != ""){ - estArrStartDate = query.estimatedArrivalDate + time; - } - if(estArrEndDate != ""){ - estArrEndDate = query.estimatedArrivalDateTo + time; + const debouncedSearch = useCallback((query: SearchBoxInputs) => { + if (searchTimeout) { + clearTimeout(searchTimeout); } - let status = ""; - if(query.status == "All"){ - status = ""; - } - else{ - status = query.status; + const timeout = setTimeout(() => { + handleSearch(query); + }, 300); + + setSearchTimeout(timeout); + }, [handleSearch, searchTimeout]); + const handleBatchRelease = useCallback(async () => { + const selectedIds = rowSelectionModel as number[]; + if (!selectedIds.length) return; + + console.log("🔍 handleBatchRelease - currentUserId:", currentUserId); + console.log("🔍 handleBatchRelease - selectedIds:", selectedIds); + + const result = await Swal.fire({ + icon: "question", + title: t("Batch Release"), + html: ` +
+

${t("Selected items on current page")}: ${selectedIds.length}

+

${t("Total search results")}: ${searchAllDos.length}

+
+

${t("Choose release option")}:

+
+ `, + showCancelButton: true, + confirmButtonText: t("Release All Search Results"), + cancelButtonText: t("Release Selected Only"), + denyButtonText: t("Cancel"), + showDenyButton: true, + confirmButtonColor: "#8dba00", + cancelButtonColor: "#2196f3", + denyButtonColor: "#F04438" + }); + + if (result.isDenied) return; + + let idsToRelease: number[]; + if (result.isConfirmed) { + idsToRelease = searchAllDos.map(d => d.id); + } else { + idsToRelease = selectedIds; } - - const batchReleaseData = await fetchDoSearch( - query.code || "", - query.shopName || "", - status, - orderStartDate, - orderEndDate, - estArrStartDate, - estArrEndDate - ); - - const extractedIds = batchReleaseData.map(item => item.id); - const extractedIdsCount = batchReleaseData.map(item => item.id).length; - const extractedItemsCount = batchReleaseData.flatMap(item => item.deliveryOrderLines).length; - - console.log("Batch Release Data:", batchReleaseData); - console.log("Query:", query); - console.log("IDs: " + extractedIds); - console.log("Total Shops: " + extractedIdsCount); - console.log("Total Items: " + extractedItemsCount); - - const result = await Swal.fire( - { - icon: "question", - title: t("Batch Release"), - html: t("Selected Shop(s): ") + extractedIdsCount.toString() + `

`+ - t("Selected Item(s): ") + extractedItemsCount.toString() + `

`, - showCancelButton: true, - confirmButtonText: t("Confirm"), - cancelButtonText: t("Cancel"), - confirmButtonColor: "#8dba00", - cancelButtonColor: "#F04438" - }); - if (result.isConfirmed) { - Swal.fire({ - title: t("Releasing"), - text: t("Please wait"), - allowOutsideClick: false, - allowEscapeKey: false, - showConfirmButton: false, - didOpen: () => { - Swal.showLoading(); - } - }); - - await Promise.all(extractedIds.map((id) => releaseDo({ id: id }))); - - Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Batch release completed successfully."), - showConfirmButton: false, - timer: 1500 - }); + + try { + const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); + const jobId = startRes?.entity?.jobId; + + if (!jobId) { + await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") }); + return; } - - - }, [currentSearchParams]); + + await Swal.fire({ + title: t("Releasing"), + html: ` +
+
${t("Total")}: 0
+
${t("Finished")}: 0
+
+
+
+
+ `, + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + didOpen: async () => { + const update = (total:number, finished:number, success:number, failed:number) => { + const bar = document.getElementById("br-bar") as HTMLElement; + const pct = total > 0 ? Math.floor((finished / total) * 100) : 0; + (document.getElementById("br-total") as HTMLElement).innerText = `${t("Total")}: ${total}`; + (document.getElementById("br-finished") as HTMLElement).innerText = `${t("Finished")}: ${finished}`; + + if (bar) bar.style.width = `${pct}%`; + }; + + const timer = setInterval(async () => { + try { + const p = await getBatchReleaseProgress(jobId); + const e = p?.entity || {}; + update(e.total ?? 0, e.finished ?? 0, e.success ?? 0, e.failedCount ?? 0); + + if (p.code === "FINISHED" || e.running === false) { + clearInterval(timer); + Swal.close(); + + // 简化完成提示 - 只显示完成,不显示成功/失败统计 + await Swal.fire({ + icon: "success", + title: t("Completed"), + text: t("Batch release completed"), + confirmButtonText: t("OK") + }); + + if (currentSearchParams && Object.keys(currentSearchParams).length > 0) { + await handleSearch(currentSearchParams); + } + setRowSelectionModel([]); + } + } catch (err) { + console.error("progress poll error:", err); + } + }, 800); + } + }); + } catch (error) { + console.error("Batch release error:", error); + await Swal.fire({ + icon: "error", + title: t("Error"), + text: t("An error occurred during batch release"), + confirmButtonText: t("OK") + }); + } + }, [rowSelectionModel, t, currentUserId, searchAllDos, currentSearchParams, handleSearch]); return ( diff --git a/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx b/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx index 9f9c4e2..b7f38aa 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodSearch.tsx @@ -34,6 +34,10 @@ import Swal from "sweetalert2"; import { printDN, printDNLabels } from "@/app/api/do/actions"; import { FGPickOrderResponse, fetchStoreLaneSummary, assignByLane,type StoreLaneSummary } from "@/app/api/pickOrder/actions"; import FGPickOrderCard from "./FGPickOrderCard"; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import dayjs, { Dayjs } from 'dayjs'; interface Props { pickOrders: PickOrderResult[]; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index aa5b1df..04e099b 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -79,6 +79,11 @@ const NavigationContent: React.FC = () => { label: "View item In-out And inventory Ledger", path: "/inventory", }, + { + icon: , + label: "Stock Take Management", + path: "/stocktakemanagement", + }, { icon: , label: "Put Away Scan", diff --git a/src/components/StockTakeManagement/InventoryAdjustmentsTab.tsx b/src/components/StockTakeManagement/InventoryAdjustmentsTab.tsx new file mode 100644 index 0000000..42ae4cb --- /dev/null +++ b/src/components/StockTakeManagement/InventoryAdjustmentsTab.tsx @@ -0,0 +1,339 @@ +"use client"; + +import { + Box, + Card, + CardContent, + Chip, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, +} from "@mui/material"; +import { useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; + +// Fake data types +interface InventoryAdjustment { + id: number; + adjustmentNo: string; + type: "stock_take" | "manual" | "damage" | "return"; + itemCode: string; + itemName: string; + lotNo: string; + location: string; + beforeQty: number; + afterQty: number; + adjustmentQty: number; + reason: string; + adjustedBy: string; + adjustedDate: string; + status: "approved" | "pending" | "rejected"; +} + +// Fake data +const fakeAdjustments: InventoryAdjustment[] = [ + { + id: 1, + adjustmentNo: "ADJ-2024-001", + type: "stock_take", + itemCode: "M001", + itemName: "Material A", + lotNo: "LOT-2024-001", + location: "A-01-01", + beforeQty: 100, + afterQty: 98, + adjustmentQty: -2, + reason: "Stock take variance", + adjustedBy: "John Doe", + adjustedDate: "2024-10-10T10:30:00", + status: "approved", + }, + { + id: 2, + adjustmentNo: "ADJ-2024-002", + type: "damage", + itemCode: "M003", + itemName: "Material C", + lotNo: "LOT-2024-003", + location: "A-01-03", + beforeQty: 75, + afterQty: 70, + adjustmentQty: -5, + reason: "Damaged during handling", + adjustedBy: "Jane Smith", + adjustedDate: "2024-10-11T14:20:00", + status: "approved", + }, + { + id: 3, + adjustmentNo: "ADJ-2024-003", + type: "manual", + itemCode: "M002", + itemName: "Material B", + lotNo: "LOT-2024-002", + location: "A-01-02", + beforeQty: 50, + afterQty: 55, + adjustmentQty: 5, + reason: "Found extra stock", + adjustedBy: "John Doe", + adjustedDate: "2024-10-12T09:15:00", + status: "pending", + }, + { + id: 4, + adjustmentNo: "ADJ-2024-004", + type: "return", + itemCode: "M001", + itemName: "Material A", + lotNo: "LOT-2024-004", + location: "A-02-01", + beforeQty: 30, + afterQty: 35, + adjustmentQty: 5, + reason: "Return from production", + adjustedBy: "Mike Johnson", + adjustedDate: "2024-10-13T11:45:00", + status: "approved", + }, +]; + +const InventoryAdjustmentsTab: React.FC = () => { + const { t } = useTranslation(["inventory"]); + + // States + const [adjustments] = useState(fakeAdjustments); + const [typeFilter, setTypeFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + const [searchText, setSearchText] = useState(""); + + // Pagination + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(10); + + // Filter adjustments + const filteredAdjustments = useMemo(() => { + return adjustments.filter((adj) => { + // Type filter + if (typeFilter !== "all" && adj.type !== typeFilter) { + return false; + } + + // Status filter + if (statusFilter !== "all" && adj.status !== statusFilter) { + return false; + } + + // Search filter + if (searchText) { + const search = searchText.toLowerCase(); + return ( + adj.adjustmentNo.toLowerCase().includes(search) || + adj.itemCode.toLowerCase().includes(search) || + adj.itemName.toLowerCase().includes(search) || + adj.lotNo.toLowerCase().includes(search) + ); + } + + return true; + }); + }, [adjustments, typeFilter, statusFilter, searchText]); + + // Pagination + const paginatedAdjustments = useMemo(() => { + const startIndex = page * rowsPerPage; + return filteredAdjustments.slice(startIndex, startIndex + rowsPerPage); + }, [filteredAdjustments, page, rowsPerPage]); + + const handlePageChange = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleRowsPerPageChange = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const getAdjustmentTypeColor = (type: string) => { + switch (type) { + case "stock_take": + return "primary"; + case "manual": + return "info"; + case "damage": + return "error"; + case "return": + return "success"; + default: + return "default"; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case "approved": + return "success"; + case "pending": + return "warning"; + case "rejected": + return "error"; + default: + return "default"; + } + }; + + return ( + + + {t("This is a demo with fake data. API integration pending.")} + + + {/* Filters */} + + + + + {t("Type")} + + + + + {t("Status")} + + + + setSearchText(e.target.value)} + placeholder={t("Adjustment No, Item, Lot...")} + sx={{ flexGrow: 1 }} + /> + + + + {t("Total Adjustments")}: {filteredAdjustments.length} + + + + + {/* Table */} + + + + + {t("Adjustment No")} + {t("Type")} + {t("Item")} + {t("Lot No")} + {t("Location")} + {t("Before Qty")} + {t("After Qty")} + {t("Adjustment")} + {t("Reason")} + {t("Adjusted By")} + {t("Date")} + {t("Status")} + + + + {paginatedAdjustments.length === 0 ? ( + + + + {t("No adjustments found")} + + + + ) : ( + paginatedAdjustments.map((adj) => ( + + {adj.adjustmentNo} + + + + + {adj.itemCode} + + {adj.itemName} + + + {adj.lotNo} + {adj.location} + {adj.beforeQty} + {adj.afterQty} + = 0 ? "success.main" : "error.main", + fontWeight: "bold", + }} + > + {adj.adjustmentQty > 0 ? `+${adj.adjustmentQty}` : adj.adjustmentQty} + + {adj.reason} + {adj.adjustedBy} + {dayjs(adj.adjustedDate).format(OUTPUT_DATE_FORMAT)} + + + + + )) + )} + +
+
+ + +
+ ); +}; + +export default InventoryAdjustmentsTab; \ No newline at end of file diff --git a/src/components/StockTakeManagement/PickExecutionIssuesTab.tsx b/src/components/StockTakeManagement/PickExecutionIssuesTab.tsx new file mode 100644 index 0000000..d98efba --- /dev/null +++ b/src/components/StockTakeManagement/PickExecutionIssuesTab.tsx @@ -0,0 +1,457 @@ +"use client"; + +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useCallback, useEffect, useState, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import SearchBox, { Criterion } from "../SearchBox"; +import SearchResults, { Column } from "../SearchResults"; +import { defaultPagingController } from "../SearchResults/SearchResults"; +import { + fetchAllPickExecutionIssues, + PickExecutionIssue, + updatePickExecutionIssueStatus, + FetchPickExecutionIssuesParams, +} from "@/app/api/pickOrder/actions"; +import dayjs from "dayjs"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; + +type SearchQuery = { + issueNo: string; + pickOrderCode: string; + itemCode: string; + lotNo: string; + type: string; + status: string; +}; + +type SearchParamNames = keyof SearchQuery; + +const PickExecutionIssuesTab: React.FC = () => { + const { t } = useTranslation(["inventory", "pickOrder"]); + + // States + const [issues, setIssues] = useState([]); + const [loading, setLoading] = useState(false); + const [pagingController, setPagingController] = useState(defaultPagingController); + const [totalCount, setTotalCount] = useState(0); + + // Search inputs + const defaultInputs = useMemo(() => ({ + issueNo: "", + pickOrderCode: "", + itemCode: "", + lotNo: "", + type: "", + status: "", + }), []); + const [inputs, setInputs] = useState>(defaultInputs); + + // Handle dialog + const [selectedIssue, setSelectedIssue] = useState(null); + const [handleDialogOpen, setHandleDialogOpen] = useState(false); + const [handleRemark, setHandleRemark] = useState(""); + + // Search criteria + const searchCriteria: Criterion[] = useMemo( + () => [ + { label: t("Issue No"), paramName: "issueNo", type: "text" }, + { label: t("Pick Order Code"), paramName: "pickOrderCode", type: "text" }, + { label: t("Item Code"), paramName: "itemCode", type: "text" }, + { label: t("Lot No"), paramName: "lotNo", type: "text" }, + { + label: t("Type"), + paramName: "type", + type: "select", + options: ["all", "jo", "do", "material"], + }, + { + label: t("Status"), + paramName: "status", + type: "select", + options: ["all", "pending", "resolved"], + }, + ], + [t], + ); + + // Fetch issues + const refetchIssuesData = useCallback(async ( + query: Record, + actionType: "reset" | "search" | "paging" | "init", + pagingController: typeof defaultPagingController, + ) => { + console.log("%c Action Type.", "color:red", actionType); + + setLoading(true); + try { + // Build API parameters with proper type casting + let apiParams: FetchPickExecutionIssuesParams | undefined = undefined; + + if (query.type && query.type !== "all") { + // Type assertion to ensure the string is one of the valid types + const validType = query.type as "jo" | "do" | "material"; + apiParams = { type: validType }; + } + + // Call API with type parameter if specified + const result = await fetchAllPickExecutionIssues(apiParams); + + // Apply client-side filtering for other parameters + let filteredResult = result; + + if (query.issueNo) { + filteredResult = filteredResult.filter(issue => + issue.issueNo.toLowerCase().includes(query.issueNo.toLowerCase()) + ); + } + + if (query.pickOrderCode) { + filteredResult = filteredResult.filter(issue => + issue.pickOrderCode.toLowerCase().includes(query.pickOrderCode.toLowerCase()) + ); + } + + if (query.itemCode) { + filteredResult = filteredResult.filter(issue => + issue.itemCode.toLowerCase().includes(query.itemCode.toLowerCase()) + ); + } + + if (query.lotNo) { + filteredResult = filteredResult.filter(issue => + issue.lotNo && issue.lotNo.toLowerCase().includes(query.lotNo.toLowerCase()) + ); + } + + if (query.status && query.status !== "all") { + filteredResult = filteredResult.filter(issue => { + if (query.status === "pending") { + return issue.handleStatus.toLowerCase() === "pending"; + } + if (query.status === "resolved") { + return issue.handleStatus.toLowerCase() !== "pending"; + } + return true; + }); + } + + setIssues(filteredResult); + setTotalCount(filteredResult.length); + + } catch (error) { + console.error("Error fetching pick execution issues:", error); + setIssues([]); + setTotalCount(0); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refetchIssuesData(defaultInputs, "init", defaultPagingController); + }, [refetchIssuesData, defaultInputs]); + + useEffect(() => { + refetchIssuesData(inputs, "paging", pagingController); + }, [pagingController, refetchIssuesData, inputs]); + + // Handlers + const onReset = useCallback(() => { + refetchIssuesData(defaultInputs, "reset", defaultPagingController); + setInputs(() => defaultInputs); + setPagingController(() => defaultPagingController); + }, [refetchIssuesData, defaultInputs]); + + const onSearch = useCallback((query: Record) => { + refetchIssuesData(query, "search", defaultPagingController); + setInputs(() => query); + setPagingController(() => defaultPagingController); + }, [refetchIssuesData]); + + const handleViewDetail = (issue: PickExecutionIssue) => { + setSelectedIssue(issue); + setHandleDialogOpen(true); + setHandleRemark(""); + }; + + const handleCloseDialog = () => { + setHandleDialogOpen(false); + setSelectedIssue(null); + setHandleRemark(""); + }; + + const handleMarkAsResolved = async () => { + if (!selectedIssue) return; + + try { + await updatePickExecutionIssueStatus({ + issueId: selectedIssue.id, + handleStatus: "resolved", + handleDate: new Date().toISOString(), + handleRemark: handleRemark, + }); + + // Refresh data + await refetchIssuesData(inputs, "search", pagingController); + handleCloseDialog(); + } catch (error) { + console.error("Error updating issue status:", error); + } + }; + + const getIssueCategoryColor = (category: string) => { + switch (category.toLowerCase()) { + case "lot_issue": + return "error"; + case "quality_issue": + return "warning"; + case "quantity_mismatch": + return "info"; + default: + return "default"; + } + }; + + // Table columns + const columns: Column[] = useMemo( + () => [ + { name: "issueNo", label: t("Issue No") }, + { name: "pickOrderCode", label: t("Pick Order") }, + { + name: "itemCode", + label: t("Item"), + renderCell: (params) => ( + + {params.itemCode} + + {params.itemDescription} + + + ), + }, + { + name: "lotNo", + label: t("Lot No"), + renderCell: (params) => params.lotNo || "-" + }, + { name: "requiredQty", label: t("Required Qty"), align: "right", type: "integer" }, + { + name: "actualPickQty", + label: t("Actual Qty"), + align: "right", + type: "integer", + renderCell: (params) => params.actualPickQty || 0 + }, + { + name: "missQty", + label: t("Miss Qty"), + align: "right", + type: "integer", + renderCell: (params) => params.missQty || 0 + }, + { + name: "issueCategory", + label: t("Category"), + renderCell: (params) => ( + + ), + }, + { + name: "handleStatus", + label: t("Status"), + renderCell: (params) => ( + + ), + }, + { + name: "pickExecutionDate", + label: t("Date"), + renderCell: (params) => dayjs(params.pickExecutionDate).format(OUTPUT_DATE_FORMAT), + }, + { + name: "id", + label: t("Action"), + renderCell: (params) => ( + + ), + }, + ], + [t, handleViewDetail], + ); + + return ( + + + + + items={issues} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + totalCount={totalCount} + /> + + {/* Handle Issue Dialog */} + + {t("Issue Detail")} + + {selectedIssue && ( + + + + {t("Issue No")} + + {selectedIssue.issueNo} + + + + + {t("Pick Order")} + + {selectedIssue.pickOrderCode} + + + + + {t("Item")} + + + {selectedIssue.itemCode} - {selectedIssue.itemDescription} + + + + {selectedIssue.lotNo && ( + + + {t("Lot No")} + + {selectedIssue.lotNo} + + )} + + + + {t("Location")} + + {selectedIssue.storeLocation || "-"} + + + + + + {t("Required Qty")} + + {selectedIssue.requiredQty} + + + + {t("Actual Qty")} + + {selectedIssue.actualPickQty || 0} + + + + {t("Miss Qty")} + + + {selectedIssue.missQty || 0} + + + + + {selectedIssue.issueRemark && ( + + + {t("Issue Remark")} + + {selectedIssue.issueRemark} + + )} + + + + {t("Picker")} + + {selectedIssue.pickerName || "-"} + + + + + {t("Status")} + + + + + {selectedIssue.handleStatus.toLowerCase() === "pending" && ( + setHandleRemark(e.target.value)} + fullWidth + /> + )} + + )} + + + + {selectedIssue && selectedIssue.handleStatus.toLowerCase() === "pending" && ( + + )} + + + + ); +}; + +export default PickExecutionIssuesTab; \ No newline at end of file diff --git a/src/components/StockTakeManagement/StockTakeManagement.tsx b/src/components/StockTakeManagement/StockTakeManagement.tsx new file mode 100644 index 0000000..20f59e9 --- /dev/null +++ b/src/components/StockTakeManagement/StockTakeManagement.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { Box, Tabs, Tab, Typography } from "@mui/material"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import PickExecutionIssuesTab from "./PickExecutionIssuesTab"; +import StockTakeTab from "./StockTakeTab"; +import InventoryAdjustmentsTab from "./InventoryAdjustmentsTab"; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +const StockTakeManagement: React.FC = () => { + const { t } = useTranslation(["inventory", "common"]); + const [currentTab, setCurrentTab] = useState(0); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setCurrentTab(newValue); + }; + + return ( + + + {t("Inventory Exception Management")} + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default StockTakeManagement; \ No newline at end of file diff --git a/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx b/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx new file mode 100644 index 0000000..d306255 --- /dev/null +++ b/src/components/StockTakeManagement/StockTakeManagementWrapper.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import GeneralLoading from "../General/GeneralLoading"; +import StockTakeManagement from "./StockTakeManagement"; + +interface SubComponents { + Loading: typeof GeneralLoading; +} + +const StockTakeManagementWrapper: React.FC & SubComponents = async () => { + return ; +}; + +StockTakeManagementWrapper.Loading = GeneralLoading; + +export default StockTakeManagementWrapper; \ No newline at end of file diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx new file mode 100644 index 0000000..55ec814 --- /dev/null +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -0,0 +1,432 @@ +"use client"; + +import { + Box, + Button, + Card, + CardContent, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, + Typography, + Paper, + Alert, + Dialog, + DialogTitle, + DialogContent, + DialogActions, +} from "@mui/material"; +import { useState, useMemo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import SearchBox, { Criterion } from "../SearchBox"; +import SearchResults, { Column } from "../SearchResults"; +import { defaultPagingController } from "../SearchResults/SearchResults"; + +// Fake data types +interface Floor { + id: number; + code: string; + name: string; + warehouseCode: string; + warehouseName: string; +} + +interface Zone { + id: number; + floorId: number; + code: string; + name: string; + description: string; +} + +interface InventoryLotLineForStockTake { + id: number; + zoneId: number; + itemCode: string; + itemName: string; + lotNo: string; + location: string; + systemQty: number; + countedQty?: number; + variance?: number; + uom: string; +} + +// Fake data +const fakeFloors: Floor[] = [ + { id: 1, code: "F1", name: "1st Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" }, + { id: 2, code: "F2", name: "2nd Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" }, + { id: 3, code: "F3", name: "3rd Floor", warehouseCode: "WH001", warehouseName: "Main Warehouse" }, +]; + +const fakeZones: Zone[] = [ + { id: 1, floorId: 1, code: "Z-A", name: "Zone A", description: "Row 1-5" }, + { id: 2, floorId: 1, code: "Z-B", name: "Zone B", description: "Row 6-10" }, + { id: 3, floorId: 2, code: "Z-C", name: "Zone C", description: "Row 1-5" }, + { id: 4, floorId: 2, code: "Z-D", name: "Zone D", description: "Row 6-10" }, + { id: 5, floorId: 3, code: "Z-E", name: "Zone E", description: "Row 1-5" }, +]; + +const fakeLots: InventoryLotLineForStockTake[] = [ + { id: 1, zoneId: 1, itemCode: "M001", itemName: "Material A", lotNo: "LOT-2024-001", location: "A-01-01", systemQty: 100, uom: "PCS" }, + { id: 2, zoneId: 1, itemCode: "M002", itemName: "Material B", lotNo: "LOT-2024-002", location: "A-01-02", systemQty: 50, uom: "PCS" }, + { id: 3, zoneId: 1, itemCode: "M003", itemName: "Material C", lotNo: "LOT-2024-003", location: "A-01-03", systemQty: 75, uom: "KG" }, + { id: 4, zoneId: 2, itemCode: "M004", itemName: "Material D", lotNo: "LOT-2024-004", location: "B-01-01", systemQty: 200, uom: "PCS" }, + { id: 5, zoneId: 2, itemCode: "M005", itemName: "Material E", lotNo: "LOT-2024-005", location: "B-01-02", systemQty: 150, uom: "KG" }, +]; + +type FloorSearchQuery = { + floorCode: string; + floorName: string; + warehouseCode: string; +}; + +type FloorSearchParamNames = keyof FloorSearchQuery; + +const StockTakeTab: React.FC = () => { + const { t } = useTranslation(["inventory"]); + + // Search states for floors + const defaultFloorInputs = useMemo(() => ({ + floorCode: "", + floorName: "", + warehouseCode: "", + }), []); + const [floorInputs, setFloorInputs] = useState>(defaultFloorInputs); + + // Selection states + const [selectedFloor, setSelectedFloor] = useState(null); + const [selectedZone, setSelectedZone] = useState(null); + + // Paging controllers + const [floorsPagingController, setFloorsPagingController] = useState(defaultPagingController); + const [zonesPagingController, setZonesPagingController] = useState(defaultPagingController); + const [lotsPagingController, setLotsPagingController] = useState(defaultPagingController); + + // Stock take dialog + const [stockTakeDialogOpen, setStockTakeDialogOpen] = useState(false); + const [selectedLot, setSelectedLot] = useState(null); + const [countedQty, setCountedQty] = useState(0); + const [remark, setRemark] = useState(""); + + // Filtered data + const filteredFloors = useMemo(() => { + return fakeFloors.filter(floor => { + if (floorInputs.floorCode && !floor.code.toLowerCase().includes(floorInputs.floorCode.toLowerCase())) { + return false; + } + if (floorInputs.floorName && !floor.name.toLowerCase().includes(floorInputs.floorName.toLowerCase())) { + return false; + } + if (floorInputs.warehouseCode && !floor.warehouseCode.toLowerCase().includes(floorInputs.warehouseCode.toLowerCase())) { + return false; + } + return true; + }); + }, [floorInputs]); + + const filteredZones = useMemo(() => { + if (!selectedFloor) return []; + return fakeZones.filter(zone => zone.floorId === selectedFloor.id); + }, [selectedFloor]); + + const filteredLots = useMemo(() => { + if (!selectedZone) return []; + return fakeLots.filter(lot => lot.zoneId === selectedZone.id); + }, [selectedZone]); + + // Search criteria + const floorSearchCriteria: Criterion[] = useMemo( + () => [ + { label: t("Floor Code"), paramName: "floorCode", type: "text" }, + { label: t("Floor Name"), paramName: "floorName", type: "text" }, + { label: t("Warehouse Code"), paramName: "warehouseCode", type: "text" }, + ], + [t], + ); + + // Handlers + const handleFloorSearch = useCallback((query: Record) => { + setFloorInputs(() => query); + setFloorsPagingController(() => defaultPagingController); + }, []); + + const handleFloorReset = useCallback(() => { + setFloorInputs(() => defaultFloorInputs); + setFloorsPagingController(() => defaultPagingController); + setSelectedFloor(null); + setSelectedZone(null); + }, [defaultFloorInputs]); + + const handleFloorClick = useCallback((floor: Floor) => { + setSelectedFloor(floor); + setSelectedZone(null); + setZonesPagingController(() => defaultPagingController); + setLotsPagingController(() => defaultPagingController); + }, []); + + const handleZoneClick = useCallback((zone: Zone) => { + setSelectedZone(zone); + setLotsPagingController(() => defaultPagingController); + }, []); + + const handleStockTakeClick = useCallback((lot: InventoryLotLineForStockTake) => { + setSelectedLot(lot); + setCountedQty(lot.countedQty || lot.systemQty); + setRemark(""); + setStockTakeDialogOpen(true); + }, []); + + const handleStockTakeSubmit = useCallback(() => { + if (!selectedLot) return; + + const variance = countedQty - selectedLot.systemQty; + + // Here you would call the API to submit stock take + console.log("Stock Take Submitted:", { + lotId: selectedLot.id, + systemQty: selectedLot.systemQty, + countedQty: countedQty, + variance: variance, + remark: remark, + }); + + alert(`${t("Stock take recorded successfully!")}\n${t("Variance")}: ${variance > 0 ? '+' : ''}${variance}`); + + // Update the lot with counted qty (in real app, this would come from backend) + selectedLot.countedQty = countedQty; + selectedLot.variance = variance; + + setStockTakeDialogOpen(false); + setSelectedLot(null); + }, [selectedLot, countedQty, remark, t]); + + const handleDialogClose = useCallback(() => { + setStockTakeDialogOpen(false); + setSelectedLot(null); + }, []); + + // Floor columns + const floorColumns: Column[] = useMemo( + () => [ + { name: "code", label: t("Floor Code") }, + { name: "name", label: t("Floor Name") }, + { + name: "warehouseCode", + label: t("Warehouse"), + renderCell: (params) => `${params.warehouseCode} - ${params.warehouseName}`, + }, + ], + [t], + ); + + // Zone columns + const zoneColumns: Column[] = useMemo( + () => [ + { name: "code", label: t("Zone Code") }, + { name: "name", label: t("Zone Name") }, + { name: "description", label: t("Description") }, + ], + [t], + ); + + // Lot columns + const lotColumns: Column[] = useMemo( + () => [ + { name: "itemCode", label: t("Item Code") }, + { name: "itemName", label: t("Item Name") }, + { name: "lotNo", label: t("Lot No") }, + { name: "location", label: t("Location") }, + { name: "systemQty", label: t("System Qty"), align: "right", type: "integer" }, + { + name: "countedQty", + label: t("Counted Qty"), + align: "right", + renderCell: (params) => params.countedQty || "-", + }, + { + name: "variance", + label: t("Variance"), + align: "right", + renderCell: (params) => { + if (params.variance === undefined) return "-"; + const variance = params.variance; + return ( + 0 ? "success.main" : "error.main", + fontWeight: variance !== 0 ? "bold" : "normal", + }} + > + {variance > 0 ? `+${variance}` : variance} + + ); + }, + }, + { name: "uom", label: t("UOM") }, + { + name: "id", + label: t("Action"), + renderCell: (params) => ( + + ), + }, + ], + [t, handleStockTakeClick], + ); + + return ( + + + {t("This is a demo with fake data. API integration pending.")} + + + {/* Step 1: Select Floor */} + + {t("Step 1: Select Floor")} + + + + items={filteredFloors} + columns={floorColumns} + pagingController={floorsPagingController} + setPagingController={setFloorsPagingController} + totalCount={filteredFloors.length} + onRowClick={handleFloorClick} + /> + + {/* Step 2: Select Zone */} + {selectedFloor && ( + <> + + {t("Step 2: Select Zone")} - {selectedFloor.name} + + + items={filteredZones} + columns={zoneColumns} + pagingController={zonesPagingController} + setPagingController={setZonesPagingController} + totalCount={filteredZones.length} + onRowClick={handleZoneClick} + /> + + )} + + {/* Step 3: Stock Take */} + {selectedZone && ( + <> + + {t("Step 3: Perform Stock Take")} - {selectedZone.name} + + + items={filteredLots} + columns={lotColumns} + pagingController={lotsPagingController} + setPagingController={setLotsPagingController} + totalCount={filteredLots.length} + /> + + )} + + {/* Stock Take Dialog */} + + {t("Stock Take")} + + {selectedLot && ( + + + + {t("Item")} + + + {selectedLot.itemCode} - {selectedLot.itemName} + + + + + + {t("Lot No")} + + {selectedLot.lotNo} + + + + + {t("Location")} + + {selectedLot.location} + + + + + {t("System Qty")} + + + {selectedLot.systemQty} {selectedLot.uom} + + + + setCountedQty(parseInt(e.target.value) || 0)} + fullWidth + autoFocus + /> + + + + {t("Variance")} + + 0 + ? "success.main" + : "error.main", + }} + > + {countedQty - selectedLot.systemQty > 0 ? "+" : ""} + {countedQty - selectedLot.systemQty} + + + + setRemark(e.target.value)} + fullWidth + /> + + )} + + + + + + + + ); +}; + +export default StockTakeTab; \ No newline at end of file diff --git a/src/components/StockTakeManagement/index.ts b/src/components/StockTakeManagement/index.ts new file mode 100644 index 0000000..bcdb90f --- /dev/null +++ b/src/components/StockTakeManagement/index.ts @@ -0,0 +1 @@ +export { default } from "./StockTakeManagementWrapper";