| @@ -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 ( | |||||
| <I18nProvider namespaces={["inventory"]}> | |||||
| <Suspense fallback={<StockTakeManagementWrapper.Loading />}> | |||||
| <StockTakeManagementWrapper /> | |||||
| </Suspense> | |||||
| </I18nProvider> | |||||
| ); | |||||
| } | |||||
| @@ -101,7 +101,31 @@ export interface PrintDNLabelsRespone{ | |||||
| success: boolean; | success: boolean; | ||||
| message?: string | 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) => { | export const assignPickOrderByStore = cache(async (data: AssignByStoreRequest) => { | ||||
| return await serverFetchJson<AssignByStoreResponse>(`${BASE_API_URL}/doPickOrder/assign-by-store`, | return await serverFetchJson<AssignByStoreResponse>(`${BASE_API_URL}/doPickOrder/assign-by-store`, | ||||
| { | { | ||||
| @@ -400,8 +400,11 @@ export const updatePickExecutionIssueStatus = async ( | |||||
| return result; | return result; | ||||
| }; | }; | ||||
| export async function fetchStoreLaneSummary(storeId: string): Promise<StoreLaneSummary> { | export async function fetchStoreLaneSummary(storeId: string): Promise<StoreLaneSummary> { | ||||
| // ✅ 硬编码测试日期 - 改成你想测试的日期 | |||||
| const testDate = "2025-10-16"; // 或者 "2025-10-16", "2025-10-17" 等 | |||||
| const response = await serverFetchJson<StoreLaneSummary>( | const response = await serverFetchJson<StoreLaneSummary>( | ||||
| `${BASE_API_URL}/doPickOrder/summary-by-store?storeId=${encodeURIComponent(storeId)}`, | |||||
| `${BASE_API_URL}/doPickOrder/summary-by-store?storeId=${encodeURIComponent(storeId)}&requiredDate=${testDate}`, | |||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| } | } | ||||
| @@ -33,7 +33,8 @@ const DoDetail: React.FC<Props> = ({ | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Use correct session type | 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 | 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<DoDetailType>({ | const formProps = useForm<DoDetailType>({ | ||||
| defaultValues: defaultValues | defaultValues: defaultValues | ||||
| }) | }) | ||||
| @@ -1,7 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import { DoResult } from "@/app/api/do"; | 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 { useRouter } from "next/navigation"; | ||||
| import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; | import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -30,7 +30,8 @@ import { Box, Button, Grid, Stack, Typography, TablePagination } from "@mui/mate | |||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import { GridRowSelectionModel } from "@mui/x-data-grid"; | import { GridRowSelectionModel } from "@mui/x-data-grid"; | ||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| import { useSession } from "next-auth/react"; | |||||
| import { SessionWithTokens } from "@/config/authConfig"; | |||||
| type Props = { | type Props = { | ||||
| filterArgs?: Record<string, any>; | filterArgs?: Record<string, any>; | ||||
| searchQuery?: Record<string, any>; | searchQuery?: Record<string, any>; | ||||
| @@ -60,6 +61,11 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| const { t } = useTranslation("do"); | const { t } = useTranslation("do"); | ||||
| const router = useRouter(); | 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<NodeJS.Timeout | null>(null); | |||||
| const [rowSelectionModel, setRowSelectionModel] = | const [rowSelectionModel, setRowSelectionModel] = | ||||
| useState<GridRowSelectionModel>([]); | useState<GridRowSelectionModel>([]); | ||||
| @@ -293,15 +299,14 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| //SEARCH FUNCTION | //SEARCH FUNCTION | ||||
| const handleSearch = useCallback(async (query: SearchBoxInputs) => { | const handleSearch = useCallback(async (query: SearchBoxInputs) => { | ||||
| try { | try { | ||||
| setCurrentSearchParams(query); | setCurrentSearchParams(query); | ||||
| let orderStartDate = query.orderDate; | let orderStartDate = query.orderDate; | ||||
| let orderEndDate = query.orderDateTo; | let orderEndDate = query.orderDateTo; | ||||
| let estArrStartDate = query.estimatedArrivalDate; | let estArrStartDate = query.estimatedArrivalDate; | ||||
| let estArrEndDate = query.estimatedArrivalDateTo; | let estArrEndDate = query.estimatedArrivalDateTo; | ||||
| const time = "T00:00:00"; | const time = "T00:00:00"; | ||||
| if(orderStartDate != ""){ | if(orderStartDate != ""){ | ||||
| orderStartDate = query.orderDate + time; | orderStartDate = query.orderDate + time; | ||||
| } | } | ||||
| @@ -332,7 +337,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| estArrStartDate, | estArrStartDate, | ||||
| estArrEndDate | estArrEndDate | ||||
| ); | ); | ||||
| setSearchAllDos(data); | setSearchAllDos(data); | ||||
| setHasSearched(true); | setHasSearched(true); | ||||
| setHasResults(data.length > 0); | setHasResults(data.length > 0); | ||||
| @@ -340,94 +345,126 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||||
| console.error("Error: ", error); | 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: ` | |||||
| <div> | |||||
| <p>${t("Selected items on current page")}: ${selectedIds.length}</p> | |||||
| <p>${t("Total search results")}: ${searchAllDos.length}</p> | |||||
| <hr> | |||||
| <p><strong>${t("Choose release option")}:</strong></p> | |||||
| </div> | |||||
| `, | |||||
| 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() + `</p>`+ | |||||
| t("Selected Item(s): ") + extractedItemsCount.toString() + `</p>`, | |||||
| 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: ` | |||||
| <div style="text-align:left"> | |||||
| <div id="br-total">${t("Total")}: 0</div> | |||||
| <div id="br-finished">${t("Finished")}: 0</div> | |||||
| <div style="margin-top:8px;height:8px;background:#eee;border-radius:4px;"> | |||||
| <div id="br-bar" style="height:8px;width:0%;background:#8dba00;border-radius:4px;"></div> | |||||
| </div> | |||||
| </div> | |||||
| `, | |||||
| 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 ( | return ( | ||||
| @@ -34,6 +34,10 @@ import Swal from "sweetalert2"; | |||||
| import { printDN, printDNLabels } from "@/app/api/do/actions"; | import { printDN, printDNLabels } from "@/app/api/do/actions"; | ||||
| import { FGPickOrderResponse, fetchStoreLaneSummary, assignByLane,type StoreLaneSummary } from "@/app/api/pickOrder/actions"; | import { FGPickOrderResponse, fetchStoreLaneSummary, assignByLane,type StoreLaneSummary } from "@/app/api/pickOrder/actions"; | ||||
| import FGPickOrderCard from "./FGPickOrderCard"; | 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 { | interface Props { | ||||
| pickOrders: PickOrderResult[]; | pickOrders: PickOrderResult[]; | ||||
| @@ -79,6 +79,11 @@ const NavigationContent: React.FC = () => { | |||||
| label: "View item In-out And inventory Ledger", | label: "View item In-out And inventory Ledger", | ||||
| path: "/inventory", | path: "/inventory", | ||||
| }, | }, | ||||
| { | |||||
| icon: <RequestQuote />, | |||||
| label: "Stock Take Management", | |||||
| path: "/stocktakemanagement", | |||||
| }, | |||||
| { | { | ||||
| icon: <RequestQuote />, | icon: <RequestQuote />, | ||||
| label: "Put Away Scan", | label: "Put Away Scan", | ||||
| @@ -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<InventoryAdjustment[]>(fakeAdjustments); | |||||
| const [typeFilter, setTypeFilter] = useState<string>("all"); | |||||
| const [statusFilter, setStatusFilter] = useState<string>("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<HTMLInputElement>) => { | |||||
| 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 ( | |||||
| <Box> | |||||
| <Alert severity="info" sx={{ mb: 3 }}> | |||||
| {t("This is a demo with fake data. API integration pending.")} | |||||
| </Alert> | |||||
| {/* Filters */} | |||||
| <Card sx={{ mb: 3 }}> | |||||
| <CardContent> | |||||
| <Stack direction="row" spacing={2} alignItems="center"> | |||||
| <FormControl sx={{ minWidth: 150 }}> | |||||
| <InputLabel>{t("Type")}</InputLabel> | |||||
| <Select | |||||
| value={typeFilter} | |||||
| label={t("Type")} | |||||
| onChange={(e) => setTypeFilter(e.target.value)} | |||||
| > | |||||
| <MenuItem value="all">{t("All")}</MenuItem> | |||||
| <MenuItem value="stock_take">{t("Stock Take")}</MenuItem> | |||||
| <MenuItem value="manual">{t("Manual")}</MenuItem> | |||||
| <MenuItem value="damage">{t("Damage")}</MenuItem> | |||||
| <MenuItem value="return">{t("Return")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| <FormControl sx={{ minWidth: 150 }}> | |||||
| <InputLabel>{t("Status")}</InputLabel> | |||||
| <Select | |||||
| value={statusFilter} | |||||
| label={t("Status")} | |||||
| onChange={(e) => setStatusFilter(e.target.value)} | |||||
| > | |||||
| <MenuItem value="all">{t("All")}</MenuItem> | |||||
| <MenuItem value="approved">{t("Approved")}</MenuItem> | |||||
| <MenuItem value="pending">{t("Pending")}</MenuItem> | |||||
| <MenuItem value="rejected">{t("Rejected")}</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| <TextField | |||||
| label={t("Search")} | |||||
| variant="outlined" | |||||
| value={searchText} | |||||
| onChange={(e) => setSearchText(e.target.value)} | |||||
| placeholder={t("Adjustment No, Item, Lot...")} | |||||
| sx={{ flexGrow: 1 }} | |||||
| /> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}> | |||||
| {t("Total Adjustments")}: {filteredAdjustments.length} | |||||
| </Typography> | |||||
| </CardContent> | |||||
| </Card> | |||||
| {/* Table */} | |||||
| <TableContainer component={Paper}> | |||||
| <Table> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Adjustment No")}</TableCell> | |||||
| <TableCell>{t("Type")}</TableCell> | |||||
| <TableCell>{t("Item")}</TableCell> | |||||
| <TableCell>{t("Lot No")}</TableCell> | |||||
| <TableCell>{t("Location")}</TableCell> | |||||
| <TableCell align="right">{t("Before Qty")}</TableCell> | |||||
| <TableCell align="right">{t("After Qty")}</TableCell> | |||||
| <TableCell align="right">{t("Adjustment")}</TableCell> | |||||
| <TableCell>{t("Reason")}</TableCell> | |||||
| <TableCell>{t("Adjusted By")}</TableCell> | |||||
| <TableCell>{t("Date")}</TableCell> | |||||
| <TableCell>{t("Status")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paginatedAdjustments.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={12} align="center"> | |||||
| <Typography variant="body2" color="text.secondary" sx={{ py: 3 }}> | |||||
| {t("No adjustments found")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paginatedAdjustments.map((adj) => ( | |||||
| <TableRow key={adj.id} hover> | |||||
| <TableCell>{adj.adjustmentNo}</TableCell> | |||||
| <TableCell> | |||||
| <Chip | |||||
| label={t(adj.type)} | |||||
| size="small" | |||||
| color={getAdjustmentTypeColor(adj.type)} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell> | |||||
| <Typography variant="body2">{adj.itemCode}</Typography> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {adj.itemName} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell>{adj.lotNo}</TableCell> | |||||
| <TableCell>{adj.location}</TableCell> | |||||
| <TableCell align="right">{adj.beforeQty}</TableCell> | |||||
| <TableCell align="right">{adj.afterQty}</TableCell> | |||||
| <TableCell | |||||
| align="right" | |||||
| sx={{ | |||||
| color: adj.adjustmentQty >= 0 ? "success.main" : "error.main", | |||||
| fontWeight: "bold", | |||||
| }} | |||||
| > | |||||
| {adj.adjustmentQty > 0 ? `+${adj.adjustmentQty}` : adj.adjustmentQty} | |||||
| </TableCell> | |||||
| <TableCell>{adj.reason}</TableCell> | |||||
| <TableCell>{adj.adjustedBy}</TableCell> | |||||
| <TableCell>{dayjs(adj.adjustedDate).format(OUTPUT_DATE_FORMAT)}</TableCell> | |||||
| <TableCell> | |||||
| <Chip label={t(adj.status)} size="small" color={getStatusColor(adj.status)} /> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={filteredAdjustments.length} | |||||
| page={page} | |||||
| rowsPerPage={rowsPerPage} | |||||
| onPageChange={handlePageChange} | |||||
| onRowsPerPageChange={handleRowsPerPageChange} | |||||
| rowsPerPageOptions={[5, 10, 25, 50]} | |||||
| /> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default InventoryAdjustmentsTab; | |||||
| @@ -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<PickExecutionIssue[]>([]); | |||||
| 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<Record<SearchParamNames, string>>(defaultInputs); | |||||
| // Handle dialog | |||||
| const [selectedIssue, setSelectedIssue] = useState<PickExecutionIssue | null>(null); | |||||
| const [handleDialogOpen, setHandleDialogOpen] = useState(false); | |||||
| const [handleRemark, setHandleRemark] = useState(""); | |||||
| // Search criteria | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = 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<SearchParamNames, string>, | |||||
| 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<SearchParamNames, string>) => { | |||||
| 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<PickExecutionIssue>[] = useMemo( | |||||
| () => [ | |||||
| { name: "issueNo", label: t("Issue No") }, | |||||
| { name: "pickOrderCode", label: t("Pick Order") }, | |||||
| { | |||||
| name: "itemCode", | |||||
| label: t("Item"), | |||||
| renderCell: (params) => ( | |||||
| <Box> | |||||
| <Typography variant="body2">{params.itemCode}</Typography> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {params.itemDescription} | |||||
| </Typography> | |||||
| </Box> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| 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) => ( | |||||
| <Chip | |||||
| label={t(params.issueCategory)} | |||||
| size="small" | |||||
| color={getIssueCategoryColor(params.issueCategory)} | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "handleStatus", | |||||
| label: t("Status"), | |||||
| renderCell: (params) => ( | |||||
| <Chip | |||||
| label={t(params.handleStatus)} | |||||
| size="small" | |||||
| color={ | |||||
| params.handleStatus.toLowerCase() === "pending" | |||||
| ? "warning" | |||||
| : "success" | |||||
| } | |||||
| /> | |||||
| ), | |||||
| }, | |||||
| { | |||||
| name: "pickExecutionDate", | |||||
| label: t("Date"), | |||||
| renderCell: (params) => dayjs(params.pickExecutionDate).format(OUTPUT_DATE_FORMAT), | |||||
| }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Action"), | |||||
| renderCell: (params) => ( | |||||
| <Button size="small" onClick={() => handleViewDetail(params)}> | |||||
| {t("Detail")} | |||||
| </Button> | |||||
| ), | |||||
| }, | |||||
| ], | |||||
| [t, handleViewDetail], | |||||
| ); | |||||
| return ( | |||||
| <Box> | |||||
| <SearchBox | |||||
| criteria={searchCriteria} | |||||
| onSearch={onSearch} | |||||
| onReset={onReset} | |||||
| /> | |||||
| <SearchResults<PickExecutionIssue> | |||||
| items={issues} | |||||
| columns={columns} | |||||
| pagingController={pagingController} | |||||
| setPagingController={setPagingController} | |||||
| totalCount={totalCount} | |||||
| /> | |||||
| {/* Handle Issue Dialog */} | |||||
| <Dialog open={handleDialogOpen} onClose={handleCloseDialog} maxWidth="md" fullWidth> | |||||
| <DialogTitle>{t("Issue Detail")}</DialogTitle> | |||||
| <DialogContent> | |||||
| {selectedIssue && ( | |||||
| <Stack spacing={2} sx={{ mt: 2 }}> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Issue No")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.issueNo}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Pick Order")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.pickOrderCode}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Item")} | |||||
| </Typography> | |||||
| <Typography variant="body1"> | |||||
| {selectedIssue.itemCode} - {selectedIssue.itemDescription} | |||||
| </Typography> | |||||
| </Box> | |||||
| {selectedIssue.lotNo && ( | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Lot No")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.lotNo}</Typography> | |||||
| </Box> | |||||
| )} | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Location")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.storeLocation || "-"}</Typography> | |||||
| </Box> | |||||
| <Stack direction="row" spacing={3}> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Required Qty")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.requiredQty}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Actual Qty")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.actualPickQty || 0}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Miss Qty")} | |||||
| </Typography> | |||||
| <Typography variant="body1" color="error"> | |||||
| {selectedIssue.missQty || 0} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Stack> | |||||
| {selectedIssue.issueRemark && ( | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Issue Remark")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.issueRemark}</Typography> | |||||
| </Box> | |||||
| )} | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Picker")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedIssue.pickerName || "-"}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Status")} | |||||
| </Typography> | |||||
| <Chip | |||||
| label={t(selectedIssue.handleStatus)} | |||||
| size="small" | |||||
| color={ | |||||
| selectedIssue.handleStatus.toLowerCase() === "pending" | |||||
| ? "warning" | |||||
| : "success" | |||||
| } | |||||
| /> | |||||
| </Box> | |||||
| {selectedIssue.handleStatus.toLowerCase() === "pending" && ( | |||||
| <TextField | |||||
| label={t("Handle Remark")} | |||||
| multiline | |||||
| rows={3} | |||||
| value={handleRemark} | |||||
| onChange={(e) => setHandleRemark(e.target.value)} | |||||
| fullWidth | |||||
| /> | |||||
| )} | |||||
| </Stack> | |||||
| )} | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleCloseDialog}>{t("Close")}</Button> | |||||
| {selectedIssue && selectedIssue.handleStatus.toLowerCase() === "pending" && ( | |||||
| <Button onClick={handleMarkAsResolved} variant="contained" color="primary"> | |||||
| {t("Mark as Resolved")} | |||||
| </Button> | |||||
| )} | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default PickExecutionIssuesTab; | |||||
| @@ -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 ( | |||||
| <div | |||||
| role="tabpanel" | |||||
| hidden={value !== index} | |||||
| id={`inventory-management-tabpanel-${index}`} | |||||
| aria-labelledby={`inventory-management-tab-${index}`} | |||||
| {...other} | |||||
| > | |||||
| {value === index && <Box sx={{ py: 3 }}>{children}</Box>} | |||||
| </div> | |||||
| ); | |||||
| } | |||||
| const StockTakeManagement: React.FC = () => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | |||||
| const [currentTab, setCurrentTab] = useState(0); | |||||
| const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { | |||||
| setCurrentTab(newValue); | |||||
| }; | |||||
| return ( | |||||
| <Box sx={{ width: "100%" }}> | |||||
| <Typography variant="h4" sx={{ mb: 3 }}> | |||||
| {t("Inventory Exception Management")} | |||||
| </Typography> | |||||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | |||||
| <Tabs value={currentTab} onChange={handleTabChange}> | |||||
| <Tab label={t("Pick Execution Issues")} /> | |||||
| <Tab label={t("Stock Take")} /> | |||||
| <Tab label={t("Inventory Adjustments")} /> | |||||
| </Tabs> | |||||
| </Box> | |||||
| <TabPanel value={currentTab} index={0}> | |||||
| <PickExecutionIssuesTab /> | |||||
| </TabPanel> | |||||
| <TabPanel value={currentTab} index={1}> | |||||
| <StockTakeTab /> | |||||
| </TabPanel> | |||||
| <TabPanel value={currentTab} index={2}> | |||||
| <InventoryAdjustmentsTab /> | |||||
| </TabPanel> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default StockTakeManagement; | |||||
| @@ -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 <StockTakeManagement />; | |||||
| }; | |||||
| StockTakeManagementWrapper.Loading = GeneralLoading; | |||||
| export default StockTakeManagementWrapper; | |||||
| @@ -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<Record<FloorSearchParamNames, string>>(defaultFloorInputs); | |||||
| // Selection states | |||||
| const [selectedFloor, setSelectedFloor] = useState<Floor | null>(null); | |||||
| const [selectedZone, setSelectedZone] = useState<Zone | null>(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<InventoryLotLineForStockTake | null>(null); | |||||
| const [countedQty, setCountedQty] = useState<number>(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<FloorSearchParamNames>[] = 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<FloorSearchParamNames, string>) => { | |||||
| 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<Floor>[] = 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<Zone>[] = useMemo( | |||||
| () => [ | |||||
| { name: "code", label: t("Zone Code") }, | |||||
| { name: "name", label: t("Zone Name") }, | |||||
| { name: "description", label: t("Description") }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| // Lot columns | |||||
| const lotColumns: Column<InventoryLotLineForStockTake>[] = 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 ( | |||||
| <Typography | |||||
| variant="body2" | |||||
| sx={{ | |||||
| color: variance === 0 ? "inherit" : variance > 0 ? "success.main" : "error.main", | |||||
| fontWeight: variance !== 0 ? "bold" : "normal", | |||||
| }} | |||||
| > | |||||
| {variance > 0 ? `+${variance}` : variance} | |||||
| </Typography> | |||||
| ); | |||||
| }, | |||||
| }, | |||||
| { name: "uom", label: t("UOM") }, | |||||
| { | |||||
| name: "id", | |||||
| label: t("Action"), | |||||
| renderCell: (params) => ( | |||||
| <Button size="small" variant="outlined" onClick={() => handleStockTakeClick(params)}> | |||||
| {t("Stock Take")} | |||||
| </Button> | |||||
| ), | |||||
| }, | |||||
| ], | |||||
| [t, handleStockTakeClick], | |||||
| ); | |||||
| return ( | |||||
| <Box> | |||||
| <Alert severity="info" sx={{ mb: 3 }}> | |||||
| {t("This is a demo with fake data. API integration pending.")} | |||||
| </Alert> | |||||
| {/* Step 1: Select Floor */} | |||||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||||
| {t("Step 1: Select Floor")} | |||||
| </Typography> | |||||
| <SearchBox | |||||
| criteria={floorSearchCriteria} | |||||
| onSearch={handleFloorSearch} | |||||
| onReset={handleFloorReset} | |||||
| /> | |||||
| <SearchResults<Floor> | |||||
| items={filteredFloors} | |||||
| columns={floorColumns} | |||||
| pagingController={floorsPagingController} | |||||
| setPagingController={setFloorsPagingController} | |||||
| totalCount={filteredFloors.length} | |||||
| onRowClick={handleFloorClick} | |||||
| /> | |||||
| {/* Step 2: Select Zone */} | |||||
| {selectedFloor && ( | |||||
| <> | |||||
| <Typography variant="h6" sx={{ mt: 4, mb: 2 }}> | |||||
| {t("Step 2: Select Zone")} - {selectedFloor.name} | |||||
| </Typography> | |||||
| <SearchResults<Zone> | |||||
| items={filteredZones} | |||||
| columns={zoneColumns} | |||||
| pagingController={zonesPagingController} | |||||
| setPagingController={setZonesPagingController} | |||||
| totalCount={filteredZones.length} | |||||
| onRowClick={handleZoneClick} | |||||
| /> | |||||
| </> | |||||
| )} | |||||
| {/* Step 3: Stock Take */} | |||||
| {selectedZone && ( | |||||
| <> | |||||
| <Typography variant="h6" sx={{ mt: 4, mb: 2 }}> | |||||
| {t("Step 3: Perform Stock Take")} - {selectedZone.name} | |||||
| </Typography> | |||||
| <SearchResults<InventoryLotLineForStockTake> | |||||
| items={filteredLots} | |||||
| columns={lotColumns} | |||||
| pagingController={lotsPagingController} | |||||
| setPagingController={setLotsPagingController} | |||||
| totalCount={filteredLots.length} | |||||
| /> | |||||
| </> | |||||
| )} | |||||
| {/* Stock Take Dialog */} | |||||
| <Dialog open={stockTakeDialogOpen} onClose={handleDialogClose} maxWidth="sm" fullWidth> | |||||
| <DialogTitle>{t("Stock Take")}</DialogTitle> | |||||
| <DialogContent> | |||||
| {selectedLot && ( | |||||
| <Stack spacing={3} sx={{ mt: 2 }}> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Item")} | |||||
| </Typography> | |||||
| <Typography variant="body1"> | |||||
| {selectedLot.itemCode} - {selectedLot.itemName} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Lot No")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedLot.lotNo}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Location")} | |||||
| </Typography> | |||||
| <Typography variant="body1">{selectedLot.location}</Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("System Qty")} | |||||
| </Typography> | |||||
| <Typography variant="body1"> | |||||
| {selectedLot.systemQty} {selectedLot.uom} | |||||
| </Typography> | |||||
| </Box> | |||||
| <TextField | |||||
| label={t("Counted Qty")} | |||||
| type="number" | |||||
| value={countedQty} | |||||
| onChange={(e) => setCountedQty(parseInt(e.target.value) || 0)} | |||||
| fullWidth | |||||
| autoFocus | |||||
| /> | |||||
| <Box> | |||||
| <Typography variant="subtitle2" color="text.secondary"> | |||||
| {t("Variance")} | |||||
| </Typography> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ | |||||
| color: | |||||
| countedQty - selectedLot.systemQty === 0 | |||||
| ? "inherit" | |||||
| : countedQty - selectedLot.systemQty > 0 | |||||
| ? "success.main" | |||||
| : "error.main", | |||||
| }} | |||||
| > | |||||
| {countedQty - selectedLot.systemQty > 0 ? "+" : ""} | |||||
| {countedQty - selectedLot.systemQty} | |||||
| </Typography> | |||||
| </Box> | |||||
| <TextField | |||||
| label={t("Remark")} | |||||
| multiline | |||||
| rows={3} | |||||
| value={remark} | |||||
| onChange={(e) => setRemark(e.target.value)} | |||||
| fullWidth | |||||
| /> | |||||
| </Stack> | |||||
| )} | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={handleDialogClose}>{t("Cancel")}</Button> | |||||
| <Button onClick={handleStockTakeSubmit} variant="contained" color="primary"> | |||||
| {t("Submit Stock Take")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default StockTakeTab; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./StockTakeManagementWrapper"; | |||||