| @@ -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; | |||
| 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<AssignByStoreResponse>(`${BASE_API_URL}/doPickOrder/assign-by-store`, | |||
| { | |||
| @@ -400,8 +400,11 @@ export const updatePickExecutionIssueStatus = async ( | |||
| return result; | |||
| }; | |||
| export async function fetchStoreLaneSummary(storeId: string): Promise<StoreLaneSummary> { | |||
| // ✅ 硬编码测试日期 - 改成你想测试的日期 | |||
| const testDate = "2025-10-16"; // 或者 "2025-10-16", "2025-10-17" 等 | |||
| 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", | |||
| } | |||
| @@ -33,7 +33,8 @@ const DoDetail: React.FC<Props> = ({ | |||
| 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<DoDetailType>({ | |||
| defaultValues: defaultValues | |||
| }) | |||
| @@ -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<string, any>; | |||
| searchQuery?: Record<string, any>; | |||
| @@ -60,6 +61,11 @@ const DoSearch: React.FC<Props> = ({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<NodeJS.Timeout | null>(null); | |||
| const [rowSelectionModel, setRowSelectionModel] = | |||
| useState<GridRowSelectionModel>([]); | |||
| @@ -293,15 +299,14 @@ const DoSearch: React.FC<Props> = ({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<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear | |||
| estArrStartDate, | |||
| estArrEndDate | |||
| ); | |||
| setSearchAllDos(data); | |||
| setHasSearched(true); | |||
| setHasResults(data.length > 0); | |||
| @@ -340,94 +345,126 @@ const DoSearch: React.FC<Props> = ({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: ` | |||
| <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 ( | |||
| @@ -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[]; | |||
| @@ -79,6 +79,11 @@ const NavigationContent: React.FC = () => { | |||
| label: "View item In-out And inventory Ledger", | |||
| path: "/inventory", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Stock Take Management", | |||
| path: "/stocktakemanagement", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| 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"; | |||