| @@ -0,0 +1,30 @@ | |||
| import { PreloadPickOrder } from "@/app/api/pickOrder"; | |||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||
| import FinishedGoodSearchWrapper from "@/components/FinishedGoodSearch"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Finished Good Detail", | |||
| }; | |||
| type Props = {} & SearchParams; | |||
| const PickOrder: React.FC<Props> = async ({ searchParams }) => { | |||
| const { t } = await getServerI18n("pickOrder"); | |||
| PreloadPickOrder(); | |||
| return ( | |||
| <> | |||
| <I18nProvider namespaces={["pickOrder", "common"]}> | |||
| <Suspense fallback={<FinishedGoodSearchWrapper.Loading />}> | |||
| <FinishedGoodSearchWrapper /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PickOrder; | |||
| @@ -0,0 +1,29 @@ | |||
| import { PreloadPickOrder } from "@/app/api/pickOrder"; | |||
| import FinishedGoodSearch from "@/components/FinishedGoodSearch/"; | |||
| import { getServerI18n } from "@/i18n"; | |||
| import { I18nProvider } from "@/i18n"; | |||
| import { Stack, Typography } from "@mui/material"; | |||
| import { Metadata } from "next"; | |||
| import { Suspense } from "react"; | |||
| export const metadata: Metadata = { | |||
| title: "Pick Order", | |||
| }; | |||
| const PickOrder: React.FC = async () => { | |||
| const { t } = await getServerI18n("pickOrder"); | |||
| PreloadPickOrder(); | |||
| return ( | |||
| <> | |||
| <I18nProvider namespaces={["pickOrder", "common"]}> | |||
| <Suspense fallback={<FinishedGoodSearch.Loading />}> | |||
| <FinishedGoodSearch /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PickOrder; | |||
| @@ -287,11 +287,65 @@ export interface PickOrderLotDetailResponse { | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| } | |||
| export const fetchAllPickOrderDetails = cache(async () => { | |||
| interface ALLPickOrderLotDetailResponse { | |||
| // Pick Order Information | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| pickOrderTargetDate: string; | |||
| pickOrderType: string; | |||
| pickOrderStatus: string; | |||
| pickOrderAssignTo: number; | |||
| groupName: string; | |||
| // Pick Order Line Information | |||
| pickOrderLineId: number; | |||
| pickOrderLineRequiredQty: number; | |||
| pickOrderLineStatus: string; | |||
| // Item Information | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| // Lot Information | |||
| lotId: number; | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| location: string; | |||
| stockUnit: string; | |||
| availableQty: number; | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| suggestedPickLotId: number; | |||
| lotStatus: string; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| processingStatus: string; | |||
| } | |||
| export const fetchALLPickOrderLineLotDetails = cache(async (userId?: number) => { | |||
| const url = userId | |||
| ? `${BASE_API_URL}/pickOrder/all-lots-with-details?userId=${userId}` | |||
| : `${BASE_API_URL}/pickOrder/all-lots-with-details`; | |||
| return serverFetchJson<ALLPickOrderLotDetailResponse[]>( | |||
| url, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["pickorder"] }, | |||
| }, | |||
| ); | |||
| }); | |||
| export const fetchAllPickOrderDetails = cache(async (userId?: number) => { | |||
| const url = userId | |||
| ? `${BASE_API_URL}/pickOrder/detail?userId=${userId}` | |||
| : `${BASE_API_URL}/pickOrder/detail`; | |||
| return serverFetchJson<GetPickOrderInfoResponse>( | |||
| `${BASE_API_URL}/pickOrder/detail`, | |||
| url, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["pickorder"] }, | |||
| @@ -340,7 +394,17 @@ export const assignPickOrder = async (ids: number[]) => { | |||
| // revalidateTag("po"); | |||
| return pickOrder; | |||
| }; | |||
| export const consolidatePickOrder = async (ids: number[]) => { | |||
| const pickOrder = await serverFetchJson<any>( | |||
| `${BASE_API_URL}/pickOrder/conso`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify({ ids: ids }), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| return pickOrder; | |||
| }; | |||
| export const consolidatePickOrder_revert = async (ids: number[]) => { | |||
| const pickOrder = await serverFetchJson<any>( | |||
| `${BASE_API_URL}/pickOrder/deconso`, | |||
| @@ -0,0 +1,607 @@ | |||
| "use client"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| FormControl, | |||
| Grid, | |||
| Modal, | |||
| TextField, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Checkbox, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| newassignPickOrder, | |||
| AssignPickOrderInputs, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; | |||
| import { FormProvider, useForm } from "react-hook-form"; | |||
| import { isEmpty, sortBy, uniqBy, upperFirst, groupBy } from "lodash"; | |||
| import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { fetchPickOrderItemsByPageClient } from "@/app/api/settings/item/actions"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| // 使用 fetchPickOrderItemsByPageClient 返回的数据结构 | |||
| interface ItemRow { | |||
| id: string; | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| currentStock: number; | |||
| unit: string; | |||
| targetDate: any; | |||
| status: string; | |||
| consoCode?: string; | |||
| assignTo?: number; | |||
| groupName?: string; | |||
| } | |||
| // 分组后的数据结构 | |||
| interface GroupedItemRow { | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| targetDate: any; | |||
| status: string; | |||
| consoCode?: string; | |||
| items: ItemRow[]; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| // 修复:选择状态改为按 pick order ID 存储 | |||
| const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<number[]>([]); | |||
| const [filteredItems, setFilteredItems] = useState<ItemRow[]>([]); | |||
| const [isLoadingItems, setIsLoadingItems] = useState(false); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const [totalCountItems, setTotalCountItems] = useState<number>(); | |||
| const [modalOpen, setModalOpen] = useState(false); | |||
| const [usernameList, setUsernameList] = useState<NewNameList[]>([]); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalItemData, setOriginalItemData] = useState<ItemRow[]>([]); | |||
| const formProps = useForm<AssignPickOrderInputs>(); | |||
| const errors = formProps.formState.errors; | |||
| // 将项目按 pick order 分组 | |||
| const groupedItems = useMemo(() => { | |||
| const grouped = groupBy(filteredItems, 'pickOrderId'); | |||
| return Object.entries(grouped).map(([pickOrderId, items]) => { | |||
| const firstItem = items[0]; | |||
| return { | |||
| pickOrderId: parseInt(pickOrderId), | |||
| pickOrderCode: firstItem.pickOrderCode, | |||
| targetDate: firstItem.targetDate, | |||
| status: firstItem.status, | |||
| consoCode: firstItem.consoCode, | |||
| items: items | |||
| } as GroupedItemRow; | |||
| }); | |||
| }, [filteredItems]); | |||
| // 修复:处理 pick order 选择 | |||
| const handlePickOrderSelect = useCallback((pickOrderId: number, checked: boolean) => { | |||
| if (checked) { | |||
| setSelectedPickOrderIds(prev => [...prev, pickOrderId]); | |||
| } else { | |||
| setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId)); | |||
| } | |||
| }, []); | |||
| // 修复:检查 pick order 是否被选中 | |||
| const isPickOrderSelected = useCallback((pickOrderId: number) => { | |||
| return selectedPickOrderIds.includes(pickOrderId); | |||
| }, [selectedPickOrderIds]); | |||
| // 使用 fetchPickOrderItemsByPageClient 获取数据 | |||
| const fetchNewPageItems = useCallback( | |||
| async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => { | |||
| console.log("=== fetchNewPageItems called ==="); | |||
| console.log("pagingController:", pagingController); | |||
| console.log("filterArgs:", filterArgs); | |||
| setIsLoadingItems(true); | |||
| try { | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| // 新增:排除状态为 "assigned" 的提料单 | |||
| //status: "pending,released,completed,cancelled" // 或者使用其他方式过滤 | |||
| }; | |||
| console.log("Final params:", params); | |||
| const res = await fetchPickOrderItemsByPageClient(params); | |||
| console.log("API Response:", res); | |||
| if (res && res.records) { | |||
| console.log("Records received:", res.records.length); | |||
| console.log("First record:", res.records[0]); | |||
| // 新增:在前端也过滤掉 "assigned" 状态的项目 | |||
| const filteredRecords = res.records.filter((item: any) => item.status !== "assigned"); | |||
| const itemRows: ItemRow[] = filteredRecords.map((item: any) => ({ | |||
| id: item.id, | |||
| pickOrderId: item.pickOrderId, | |||
| pickOrderCode: item.pickOrderCode, | |||
| itemId: item.itemId, | |||
| itemCode: item.itemCode, | |||
| itemName: item.itemName, | |||
| requiredQty: item.requiredQty, | |||
| currentStock: item.currentStock ?? 0, | |||
| unit: item.unit, | |||
| targetDate: item.targetDate, | |||
| status: item.status, | |||
| consoCode: item.consoCode, | |||
| assignTo: item.assignTo, | |||
| groupName: item.groupName, | |||
| })); | |||
| setOriginalItemData(itemRows); | |||
| setFilteredItems(itemRows); | |||
| setTotalCountItems(filteredRecords.length); // 使用过滤后的数量 | |||
| } else { | |||
| console.log("No records in response"); | |||
| setFilteredItems([]); | |||
| setTotalCountItems(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching items:", error); | |||
| setFilteredItems([]); | |||
| setTotalCountItems(0); | |||
| } finally { | |||
| setIsLoadingItems(false); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| const searchCriteria: Criterion<any>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Pick Order Code"), | |||
| paramName: "pickOrderCode", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item Code"), | |||
| paramName: "itemCode", | |||
| type: "text" | |||
| }, | |||
| { | |||
| label: t("Group Code"), | |||
| paramName: "groupName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item Name"), | |||
| paramName: "itemName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Target Date From"), | |||
| label2: t("Target Date To"), | |||
| paramName: "targetDate", | |||
| type: "dateRange", | |||
| }, | |||
| { | |||
| label: t("Pick Order Status"), | |||
| paramName: "status", | |||
| type: "autocomplete", | |||
| options: sortBy( | |||
| uniqBy( | |||
| originalItemData.map((item) => ({ | |||
| value: item.status, | |||
| label: t(upperFirst(item.status)), | |||
| })), | |||
| "value", | |||
| ), | |||
| "label", | |||
| ), | |||
| }, | |||
| ], | |||
| [originalItemData, t], | |||
| ); | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| console.log("Search query:", query); | |||
| const filtered = originalItemData.filter((item) => { | |||
| const itemTargetDateStr = arrayToDayjs(item.targetDate); | |||
| const itemCodeMatch = !query.itemCode || | |||
| item.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | |||
| const itemNameMatch = !query.itemName || | |||
| item.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); | |||
| const pickOrderCodeMatch = !query.pickOrderCode || | |||
| item.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); | |||
| const groupNameMatch = !query.groupName || | |||
| item.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase()); | |||
| // 日期范围搜索 | |||
| let dateMatch = true; | |||
| if (query.targetDate || query.targetDateTo) { | |||
| try { | |||
| if (query.targetDate && !query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| dateMatch = itemTargetDateStr.isSame(fromDate, 'day') || | |||
| itemTargetDateStr.isAfter(fromDate, 'day'); | |||
| } else if (!query.targetDate && query.targetDateTo) { | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = itemTargetDateStr.isSame(toDate, 'day') || | |||
| itemTargetDateStr.isBefore(toDate, 'day'); | |||
| } else if (query.targetDate && query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = (itemTargetDateStr.isSame(fromDate, 'day') || | |||
| itemTargetDateStr.isAfter(fromDate, 'day')) && | |||
| (itemTargetDateStr.isSame(toDate, 'day') || | |||
| itemTargetDateStr.isBefore(toDate, 'day')); | |||
| } | |||
| } catch (error) { | |||
| console.error("Date parsing error:", error); | |||
| dateMatch = true; | |||
| } | |||
| } | |||
| const statusMatch = !query.status || | |||
| query.status.toLowerCase() === "all" || | |||
| item.status?.toLowerCase().includes((query.status || "").toLowerCase()); | |||
| return itemCodeMatch && itemNameMatch && groupNameMatch && pickOrderCodeMatch && dateMatch && statusMatch; | |||
| }); | |||
| console.log("Filtered items count:", filtered.length); | |||
| setFilteredItems(filtered); | |||
| }, [originalItemData]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| setFilteredItems(originalItemData); | |||
| setTimeout(() => { | |||
| setSearchQuery({}); | |||
| }, 0); | |||
| }, [originalItemData]); | |||
| // 修复:处理分页变化 | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, // API 使用 1-based 分页 | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, [pagingController]); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, // 重置到第一页 | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, []); | |||
| const handleAssignAndRelease = useCallback(async (data: AssignPickOrderInputs) => { | |||
| if (selectedPickOrderIds.length === 0) return; | |||
| setIsUploading(true); | |||
| try { | |||
| // 修复:直接使用选中的 pick order IDs | |||
| const assignRes = await newassignPickOrder({ | |||
| pickOrderIds: selectedPickOrderIds, | |||
| assignTo: data.assignTo, | |||
| }); | |||
| if (assignRes && assignRes.code === "SUCCESS") { | |||
| console.log("Assign successful:", assignRes); | |||
| setModalOpen(false); | |||
| setSelectedPickOrderIds([]); // 清空选择 | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Assign failed:", assignRes); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error in assign:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [selectedPickOrderIds, setIsUploading, fetchNewPageItems, pagingController, filterArgs]); | |||
| const openAssignModal = useCallback(() => { | |||
| setModalOpen(true); | |||
| formProps.reset(); | |||
| }, [formProps]); | |||
| // 组件挂载时加载数据 | |||
| useEffect(() => { | |||
| console.log("=== Component mounted ==="); | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| }, []); // 只在组件挂载时执行一次 | |||
| // 当 pagingController 或 filterArgs 变化时重新调用 API | |||
| useEffect(() => { | |||
| console.log("=== Dependencies changed ==="); | |||
| if (pagingController && (filterArgs || {})) { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| } | |||
| }, [pagingController, filterArgs, fetchNewPageItems]); | |||
| useEffect(() => { | |||
| const loadUsernameList = async () => { | |||
| try { | |||
| const res = await fetchNewNameList(); | |||
| if (res) { | |||
| setUsernameList(res); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error loading username list:", error); | |||
| } | |||
| }; | |||
| loadUsernameList(); | |||
| }, []); | |||
| // 自定义分组表格组件 | |||
| const CustomGroupedTable = () => { | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Group Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||
| <TableCell align="right">{t("Stock Unit")}</TableCell> | |||
| <TableCell>{t("Target Date")}</TableCell> | |||
| <TableCell>{t("Pick Order Status")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {groupedItems.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={9} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| groupedItems.map((group) => ( | |||
| group.items.map((item, index) => ( | |||
| <TableRow key={item.id}> | |||
| {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Checkbox | |||
| checked={isPickOrderSelected(group.pickOrderId)} | |||
| onChange={(e) => handlePickOrderSelect(group.pickOrderId, e.target.checked)} | |||
| disabled={!isEmpty(item.consoCode)} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Code - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? item.pickOrderCode : null} | |||
| </TableCell> | |||
| {/* Group Name */} | |||
| <TableCell> | |||
| {index === 0 ? (item.groupName || "No Group") : null} | |||
| </TableCell> | |||
| {/* Item Code */} | |||
| <TableCell>{item.itemCode}</TableCell> | |||
| {/* Item Name */} | |||
| <TableCell>{item.itemName}</TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right">{item.requiredQty}</TableCell> | |||
| {/* Current Stock */} | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| color={item.currentStock > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: item.currentStock > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {item.currentStock.toLocaleString()} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Unit */} | |||
| <TableCell align="right">{item.unit}</TableCell> | |||
| {/* Target Date - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(item.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Status - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? upperFirst(item.status) : null} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| {/* 修复:添加分页组件 */} | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCountItems || 0} | |||
| page={(pagingController.pageNum - 1)} // 转换为 0-based | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| return ( | |||
| <> | |||
| <SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} /> | |||
| <Grid container rowGap={1}> | |||
| <Grid item xs={12}> | |||
| {isLoadingItems ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <CustomGroupedTable /> | |||
| )} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}> | |||
| <Button | |||
| disabled={selectedPickOrderIds.length < 1} | |||
| variant="outlined" | |||
| onClick={openAssignModal} | |||
| > | |||
| {t("Assign")} | |||
| </Button> | |||
| </Box> | |||
| </Grid> | |||
| </Grid> | |||
| {modalOpen ? ( | |||
| <Modal | |||
| open={modalOpen} | |||
| onClose={() => setModalOpen(false)} | |||
| aria-labelledby="modal-modal-title" | |||
| aria-describedby="modal-modal-description" | |||
| > | |||
| <Box sx={style}> | |||
| <Grid container rowGap={2}> | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" component="h2"> | |||
| {t("Assign Pick Orders")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Typography variant="body1" color="text.secondary"> | |||
| {t("Selected Pick Orders")}: {selectedPickOrderIds.length} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <FormProvider {...formProps}> | |||
| <form onSubmit={formProps.handleSubmit(handleAssignAndRelease)}> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <FormControl fullWidth> | |||
| <Autocomplete | |||
| options={usernameList} | |||
| getOptionLabel={(option) => { | |||
| // 修改:显示更详细的用户信息 | |||
| const title = option.title ? ` (${option.title})` : ''; | |||
| const department = option.department ? ` - ${option.department}` : ''; | |||
| return `${option.name}${title}${department}`; | |||
| }} | |||
| renderOption={(props, option) => ( | |||
| <Box component="li" {...props}> | |||
| <Typography variant="body1"> | |||
| {option.name} | |||
| {option.title && ` (${option.title})`} | |||
| {option.department && ` - ${option.department}`} | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| onChange={(_, value) => { | |||
| formProps.setValue("assignTo", value?.id || 0); | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t("Assign To")} | |||
| error={!!errors.assignTo} | |||
| helperText={errors.assignTo?.message} | |||
| required | |||
| /> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Typography variant="body2" color="warning.main"> | |||
| {t("This action will assign the selected pick orders to picker.")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ display: "flex", gap: 2, justifyContent: "flex-end" }}> | |||
| <Button variant="outlined" onClick={() => setModalOpen(false)}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button type="submit" variant="contained" color="primary"> | |||
| {t("Assign")} | |||
| </Button> | |||
| </Box> | |||
| </Grid> | |||
| </Grid> | |||
| </form> | |||
| </FormProvider> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </Modal> | |||
| ) : undefined} | |||
| </> | |||
| ); | |||
| }; | |||
| export default AssignAndRelease; | |||
| @@ -0,0 +1,228 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| Paper, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| TextField, | |||
| Typography, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface CombinedLotTableProps { | |||
| combinedLotData: any[]; | |||
| combinedDataLoading: boolean; | |||
| pickQtyData: Record<string, number>; | |||
| paginationController: { | |||
| pageNum: number; | |||
| pageSize: number; | |||
| }; | |||
| onPickQtyChange: (lotKey: string, value: number | string) => void; | |||
| onSubmitPickQty: (lot: any) => void; | |||
| onRejectLot: (lot: any) => void; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| // ✅ Simple helper function to check if item is completed | |||
| const isItemCompleted = (lot: any) => { | |||
| const actualPickQty = Number(lot.actualPickQty) || 0; | |||
| const requiredQty = Number(lot.requiredQty) || 0; | |||
| return lot.stockOutLineStatus === 'completed' || | |||
| (actualPickQty > 0 && requiredQty > 0 && actualPickQty >= requiredQty); | |||
| }; | |||
| const isItemRejected = (lot: any) => { | |||
| return lot.stockOutLineStatus === 'rejected'; | |||
| }; | |||
| const CombinedLotTable: React.FC<CombinedLotTableProps> = ({ | |||
| combinedLotData, | |||
| combinedDataLoading, | |||
| pickQtyData, | |||
| paginationController, | |||
| onPickQtyChange, | |||
| onSubmitPickQty, | |||
| onRejectLot, | |||
| onPageChange, | |||
| onPageSizeChange, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // ✅ Paginated data | |||
| const paginatedLotData = useMemo(() => { | |||
| const startIndex = paginationController.pageNum * paginationController.pageSize; | |||
| const endIndex = startIndex + paginationController.pageSize; | |||
| return combinedLotData.slice(startIndex, endIndex); | |||
| }, [combinedLotData, paginationController]); | |||
| if (combinedDataLoading) { | |||
| return ( | |||
| <Box display="flex" justifyContent="center" alignItems="center" minHeight="200px"> | |||
| <CircularProgress size={40} /> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell>{t("Lot No")}</TableCell> | |||
| <TableCell>{t("Expiry Date")}</TableCell> | |||
| <TableCell>{t("Location")}</TableCell> | |||
| <TableCell>{t("Stock Unit")}</TableCell> | |||
| <TableCell align="right">{t("Available Qty")}</TableCell> | |||
| <TableCell align="right">{t("Required Qty")}</TableCell> | |||
| <TableCell align="right">{t("Actual Pick Qty")}</TableCell> | |||
| <TableCell align="right">{t("Pick Qty")}</TableCell> | |||
| <TableCell align="center">{t("Submit")}</TableCell> | |||
| <TableCell align="center">{t("Reject")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedLotData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={13} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedLotData.map((lot: any) => { | |||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | |||
| const currentPickQty = pickQtyData[lotKey] ?? ''; | |||
| const isCompleted = isItemCompleted(lot); | |||
| const isRejected = isItemRejected(lot); | |||
| // ✅ Green text color for completed items | |||
| const textColor = isCompleted ? 'success.main' : isRejected ? 'error.main' : 'inherit'; | |||
| return ( | |||
| <TableRow | |||
| key={lotKey} | |||
| sx={{ | |||
| '&:hover': { | |||
| backgroundColor: 'action.hover', | |||
| }, | |||
| }} | |||
| > | |||
| <TableCell sx={{ color: textColor }}>{lot.pickOrderCode}</TableCell> | |||
| <TableCell sx={{ color: textColor }}>{lot.itemCode}</TableCell> | |||
| <TableCell sx={{ color: textColor }}>{lot.itemName}</TableCell> | |||
| <TableCell sx={{ color: textColor }}>{lot.lotNo}</TableCell> | |||
| <TableCell sx={{ color: textColor }}> | |||
| {lot.expiryDate ? new Date(lot.expiryDate).toLocaleDateString() : 'N/A'} | |||
| </TableCell> | |||
| <TableCell sx={{ color: textColor }}>{lot.location}</TableCell> | |||
| <TableCell sx={{ color: textColor }}>{lot.stockUnit}</TableCell> | |||
| <TableCell align="right" sx={{ color: textColor }}>{lot.availableQty}</TableCell> | |||
| <TableCell align="right" sx={{ color: textColor }}>{lot.requiredQty}</TableCell> | |||
| <TableCell align="right" sx={{ color: textColor }}>{lot.actualPickQty || 0}</TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| value={currentPickQty} | |||
| onChange={(e) => { | |||
| onPickQtyChange(lotKey, e.target.value); | |||
| }} | |||
| onFocus={(e) => { | |||
| e.target.select(); | |||
| }} | |||
| inputProps={{ | |||
| min: 0, | |||
| max: lot.availableQty, | |||
| step: 0.01 | |||
| }} | |||
| disabled={ | |||
| isCompleted || | |||
| isRejected || | |||
| lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' | |||
| } | |||
| sx={{ | |||
| width: '80px', | |||
| '& .MuiInputBase-input': { | |||
| textAlign: 'right', | |||
| } | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| size="small" | |||
| disabled={isCompleted || isRejected || !currentPickQty || currentPickQty <= 0} | |||
| onClick={() => onSubmitPickQty(lot)} | |||
| sx={{ | |||
| backgroundColor: isCompleted ? 'success.main' : 'primary.main', | |||
| color: 'white', | |||
| '&:disabled': { | |||
| backgroundColor: 'grey.300', | |||
| color: 'grey.500', | |||
| }, | |||
| }} | |||
| > | |||
| {isCompleted ? t("Completed") : t("Submit")} | |||
| </Button> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| color="error" | |||
| disabled={isCompleted || isRejected} | |||
| onClick={() => onRejectLot(lot)} | |||
| sx={{ | |||
| '&:disabled': { | |||
| borderColor: 'grey.300', | |||
| color: 'grey.500', | |||
| }, | |||
| }} | |||
| > | |||
| {t("Reject")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={combinedLotData.length} | |||
| page={paginationController.pageNum} | |||
| rowsPerPage={paginationController.pageSize} | |||
| onPageChange={onPageChange} | |||
| onRowsPerPageChange={onPageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CombinedLotTable; | |||
| @@ -0,0 +1,91 @@ | |||
| "use client"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { | |||
| Dispatch, | |||
| SetStateAction, | |||
| useCallback, | |||
| useEffect, | |||
| useMemo, | |||
| useState, | |||
| } from "react"; | |||
| import { GridColDef } from "@mui/x-data-grid"; | |||
| import { CircularProgress, Grid, Typography } from "@mui/material"; | |||
| import { ByItemsSummary } from "@/app/api/pickOrder"; | |||
| import { useTranslation } from "react-i18next"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| rows: ByItemsSummary[] | undefined; | |||
| setRows: Dispatch<SetStateAction<ByItemsSummary[] | undefined>>; | |||
| } | |||
| const ConsolidatePickOrderItemSum: React.FC<Props> = ({ rows, setRows }) => { | |||
| console.log(rows); | |||
| const { t } = useTranslation("pickOrder"); | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: "name", | |||
| headerName: "name", | |||
| flex: 1, | |||
| renderCell: (params) => { | |||
| console.log(params.row.name); | |||
| return params.row.name; | |||
| }, | |||
| }, | |||
| { | |||
| field: "requiredQty", | |||
| headerName: "requiredQty", | |||
| flex: 1, | |||
| renderCell: (params) => { | |||
| console.log(params.row.requiredQty); | |||
| const requiredQty = params.row.requiredQty ?? 0; | |||
| return `${requiredQty} ${params.row.uomDesc}`; | |||
| }, | |||
| }, | |||
| { | |||
| field: "availableQty", | |||
| headerName: "availableQty", | |||
| flex: 1, | |||
| renderCell: (params) => { | |||
| console.log(params.row.availableQty); | |||
| const availableQty = params.row.availableQty ?? 0; | |||
| return `${availableQty} ${params.row.uomDesc}`; | |||
| }, | |||
| }, | |||
| ], | |||
| [], | |||
| ); | |||
| return ( | |||
| <Grid | |||
| container | |||
| rowGap={1} | |||
| // direction="column" | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| > | |||
| <Grid item xs={12}> | |||
| <Typography variant="h5" marginInlineEnd={2}> | |||
| {t("Items Included")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| {!rows ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <StyledDataGrid | |||
| sx={{ maxHeight: 450 }} | |||
| rows={rows} | |||
| columns={columns} | |||
| /> | |||
| )} | |||
| </Grid> | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default ConsolidatePickOrderItemSum; | |||
| @@ -0,0 +1,116 @@ | |||
| "use client"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { | |||
| Dispatch, | |||
| SetStateAction, | |||
| useCallback, | |||
| useEffect, | |||
| useMemo, | |||
| useState, | |||
| } from "react"; | |||
| import { GridColDef, GridInputRowSelectionModel } from "@mui/x-data-grid"; | |||
| import { Box, CircularProgress, Grid, Typography } from "@mui/material"; | |||
| import { PickOrderResult } from "@/app/api/pickOrder"; | |||
| import { useTranslation } from "react-i18next"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| consoCode: string; | |||
| rows: Omit<PickOrderResult, "items">[] | undefined; | |||
| setRows: Dispatch< | |||
| SetStateAction<Omit<PickOrderResult, "items">[] | undefined> | |||
| >; | |||
| revertIds: GridInputRowSelectionModel; | |||
| setRevertIds: Dispatch<SetStateAction<GridInputRowSelectionModel>>; | |||
| } | |||
| const ConsolidatePickOrderSum: React.FC<Props> = ({ | |||
| consoCode, | |||
| rows, | |||
| setRows, | |||
| revertIds, | |||
| setRevertIds, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: "code", | |||
| headerName: "code", | |||
| flex: 0.6, | |||
| }, | |||
| { | |||
| field: "pickOrderLines", | |||
| headerName: "items", | |||
| flex: 1, | |||
| renderCell: (params) => { | |||
| console.log(params); | |||
| const pickOrderLine = params.row.pickOrderLines as any[]; | |||
| return ( | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| maxHeight: 100, | |||
| overflowY: "scroll", | |||
| scrollbarWidth: "none", // For Firefox | |||
| "&::-webkit-scrollbar": { | |||
| display: "none", // For Chrome, Safari, and Opera | |||
| }, | |||
| }} | |||
| > | |||
| {pickOrderLine.map((item, index) => ( | |||
| <Grid | |||
| sx={{ mt: 1 }} | |||
| key={index} | |||
| >{`${item.itemName} x ${item.requiredQty} ${item.uomDesc}`}</Grid> // Render each name in a span | |||
| ))} | |||
| </Box> | |||
| ); | |||
| }, | |||
| }, | |||
| ], | |||
| [], | |||
| ); | |||
| return ( | |||
| <Grid | |||
| container | |||
| rowGap={1} | |||
| // direction="column" | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| > | |||
| <Grid item xs={12}> | |||
| <Typography variant="h5" marginInlineEnd={2}> | |||
| {t("Pick Order Included")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| {!rows ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <StyledDataGrid | |||
| sx={{ maxHeight: 450 }} | |||
| checkboxSelection | |||
| rowSelectionModel={revertIds} | |||
| onRowSelectionModelChange={(newRowSelectionModel) => { | |||
| setRevertIds(newRowSelectionModel); | |||
| }} | |||
| getRowHeight={(params) => { | |||
| return 100; | |||
| }} | |||
| rows={rows} | |||
| columns={columns} | |||
| /> | |||
| )} | |||
| </Grid> | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default ConsolidatePickOrderSum; | |||
| @@ -0,0 +1,370 @@ | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| FormControl, | |||
| Grid, | |||
| Modal, | |||
| ModalProps, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { GridToolbarContainer } from "@mui/x-data-grid"; | |||
| import { | |||
| FooterPropsOverrides, | |||
| GridColDef, | |||
| GridRowSelectionModel, | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import SearchResults, { | |||
| Column, | |||
| defaultPagingController, | |||
| } from "../SearchResults/SearchResults"; | |||
| import { | |||
| ByItemsSummary, | |||
| ConsoPickOrderResult, | |||
| PickOrderLine, | |||
| PickOrderResult, | |||
| } from "@/app/api/pickOrder"; | |||
| import { useRouter, useSearchParams } from "next/navigation"; | |||
| import ConsolidatePickOrderItemSum from "./ConsolidatePickOrderItemSum"; | |||
| import ConsolidatePickOrderSum from "./ConsolidatePickOrderSum"; | |||
| import { GridInputRowSelectionModel } from "@mui/x-data-grid"; | |||
| import { | |||
| fetchConsoDetail, | |||
| fetchConsoPickOrderClient, | |||
| releasePickOrder, | |||
| ReleasePickOrderInputs, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import { useField } from "@mui/x-date-pickers/internals"; | |||
| import { | |||
| FormProvider, | |||
| SubmitErrorHandler, | |||
| SubmitHandler, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import { pickOrderStatusMap } from "@/app/utils/formatUtil"; | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| // width: 1500, | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| interface DisableButton { | |||
| releaseBtn: boolean; | |||
| removeBtn: boolean; | |||
| } | |||
| const ConsolidatedPickOrders: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const router = useRouter(); | |||
| const apiRef = useGridApiRef(); | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState( | |||
| [] as ConsoPickOrderResult[], | |||
| ); | |||
| const [isLoading, setIsLoading] = useState(false); | |||
| const [modalOpen, setModalOpen] = useState(false); //change back to false | |||
| const [consoCode, setConsoCode] = useState<string | undefined>(); ///change back to undefined | |||
| const [revertIds, setRevertIds] = useState<GridInputRowSelectionModel>([]); | |||
| const [totalCount, setTotalCount] = useState<number>(); | |||
| const [usernameList, setUsernameList] = useState<NameList[]>([]); | |||
| const [byPickOrderRows, setByPickOrderRows] = useState< | |||
| Omit<PickOrderResult, "items">[] | undefined | |||
| >(undefined); | |||
| const [byItemsRows, setByItemsRows] = useState<ByItemsSummary[] | undefined>( | |||
| undefined, | |||
| ); | |||
| const [disableRelease, setDisableRelease] = useState<boolean>(true); | |||
| const formProps = useForm<ReleasePickOrderInputs>(); | |||
| const errors = formProps.formState.errors; | |||
| const openDetailModal = useCallback((consoCode: string) => { | |||
| setConsoCode(consoCode); | |||
| setModalOpen(true); | |||
| }, []); | |||
| const closeDetailModal = useCallback(() => { | |||
| setModalOpen(false); | |||
| setConsoCode(undefined); | |||
| }, []); | |||
| const onDetailClick = useCallback( | |||
| (pickOrder: any) => { | |||
| console.log(pickOrder); | |||
| const status = pickOrder.status; | |||
| if (pickOrderStatusMap[status] >= 3) { | |||
| router.push(`/pickOrder/detail?consoCode=${pickOrder.consoCode}`); | |||
| } else { | |||
| openDetailModal(pickOrder.consoCode); | |||
| } | |||
| }, | |||
| [router, openDetailModal], | |||
| ); | |||
| const columns = useMemo<Column<ConsoPickOrderResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: t("Detail"), | |||
| onClick: onDetailClick, | |||
| buttonIcon: <EditNote />, | |||
| }, | |||
| { | |||
| name: "consoCode", | |||
| label: t("consoCode"), | |||
| }, | |||
| { | |||
| name: "status", | |||
| label: t("status"), | |||
| }, | |||
| ], | |||
| [onDetailClick, t], | |||
| ); | |||
| const [pagingController, setPagingController] = useState( | |||
| defaultPagingController, | |||
| ); | |||
| // pass conso code back to assign | |||
| // pass user back to assign | |||
| const fetchNewPageConsoPickOrder = useCallback( | |||
| async ( | |||
| pagingController: Record<string, number>, | |||
| filterArgs: Record<string, number>, | |||
| ) => { | |||
| setIsLoading(true); | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| }; | |||
| const res = await fetchConsoPickOrderClient(params); | |||
| if (res) { | |||
| console.log(res); | |||
| setFilteredPickOrders(res.records); | |||
| setTotalCount(res.total); | |||
| } | |||
| setIsLoading(false); | |||
| }, | |||
| [], | |||
| ); | |||
| useEffect(() => { | |||
| fetchNewPageConsoPickOrder(pagingController, filterArgs); | |||
| }, [fetchNewPageConsoPickOrder, pagingController, filterArgs]); | |||
| const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => { | |||
| let isReleasable = true; | |||
| for (const item of itemList) { | |||
| isReleasable = item.requiredQty >= item.availableQty; | |||
| if (!isReleasable) return isReleasable; | |||
| } | |||
| return isReleasable; | |||
| }, []); | |||
| const fetchConso = useCallback( | |||
| async (consoCode: string) => { | |||
| const res = await fetchConsoDetail(consoCode); | |||
| const nameListRes = await fetchNameList(); | |||
| if (res) { | |||
| console.log(res); | |||
| setByPickOrderRows(res.pickOrders); | |||
| // for testing | |||
| // for (const item of res.items) { | |||
| // item.availableQty = 1000; | |||
| // } | |||
| setByItemsRows(res.items); | |||
| setDisableRelease(isReleasable(res.items)); | |||
| } else { | |||
| console.log("error"); | |||
| console.log(res); | |||
| } | |||
| if (nameListRes) { | |||
| console.log(nameListRes); | |||
| setUsernameList(nameListRes); | |||
| } | |||
| }, | |||
| [isReleasable], | |||
| ); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| closeDetailModal(); | |||
| // reset(); | |||
| }, | |||
| [closeDetailModal], | |||
| ); | |||
| const onChange = useCallback( | |||
| (event: React.SyntheticEvent, newValue: NameList) => { | |||
| console.log(newValue); | |||
| formProps.setValue("assignTo", newValue.id); | |||
| }, | |||
| [formProps], | |||
| ); | |||
| const onSubmit = useCallback<SubmitHandler<ReleasePickOrderInputs>>( | |||
| async (data, event) => { | |||
| console.log(data); | |||
| try { | |||
| const res = await releasePickOrder(data); | |||
| console.log(res); | |||
| if (res.consoCode.length > 0) { | |||
| console.log(res); | |||
| router.push(`/pickOrder/detail?consoCode=${res.consoCode}`); | |||
| } else { | |||
| console.log(res); | |||
| } | |||
| } catch (error) { | |||
| console.log(error); | |||
| } | |||
| }, | |||
| [router], | |||
| ); | |||
| const onSubmitError = useCallback<SubmitErrorHandler<ReleasePickOrderInputs>>( | |||
| (errors) => {}, | |||
| [], | |||
| ); | |||
| const handleConsolidate_revert = useCallback(() => { | |||
| console.log(revertIds); | |||
| }, [revertIds]); | |||
| useEffect(() => { | |||
| if (consoCode) { | |||
| fetchConso(consoCode); | |||
| formProps.setValue("consoCode", consoCode); | |||
| } | |||
| }, [consoCode, fetchConso, formProps]); | |||
| return ( | |||
| <> | |||
| <Grid | |||
| container | |||
| rowGap={1} | |||
| // direction="column" | |||
| alignItems="center" | |||
| justifyContent="center" | |||
| > | |||
| <Grid item xs={12}> | |||
| {isLoading ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <SearchResults<ConsoPickOrderResult> | |||
| items={filteredPickOrders} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| /> | |||
| )} | |||
| </Grid> | |||
| </Grid> | |||
| {consoCode != undefined ? ( | |||
| <Modal open={modalOpen} onClose={closeHandler}> | |||
| <FormProvider {...formProps}> | |||
| <Box | |||
| sx={{ ...style, maxHeight: 800 }} | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | |||
| > | |||
| <Grid container> | |||
| <Grid item xs={8}> | |||
| <Typography mb={2} variant="h4"> | |||
| {consoCode} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid | |||
| item | |||
| xs={4} | |||
| display="flex" | |||
| justifyContent="end" | |||
| alignItems="end" | |||
| > | |||
| <FormControl fullWidth> | |||
| <Autocomplete | |||
| disableClearable | |||
| fullWidth | |||
| getOptionLabel={(option) => option.name} | |||
| options={usernameList} | |||
| onChange={onChange} | |||
| renderInput={(params) => <TextField {...params} />} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| </Grid> | |||
| <Box | |||
| sx={{ | |||
| height: 400, | |||
| overflowY: "auto", | |||
| }} | |||
| > | |||
| <Grid container> | |||
| <Grid item xs={12} sx={{ mt: 2 }}> | |||
| <ConsolidatePickOrderSum | |||
| rows={byPickOrderRows} | |||
| setRows={setByPickOrderRows} | |||
| consoCode={consoCode} | |||
| revertIds={revertIds} | |||
| setRevertIds={setRevertIds} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <ConsolidatePickOrderItemSum | |||
| rows={byItemsRows} | |||
| setRows={setByItemsRows} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| <Grid container> | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| display="flex" | |||
| justifyContent="end" | |||
| alignItems="end" | |||
| > | |||
| <Button | |||
| disabled={(revertIds as number[]).length < 1} | |||
| variant="outlined" | |||
| onClick={handleConsolidate_revert} | |||
| sx={{ mr: 1 }} | |||
| > | |||
| {t("remove")} | |||
| </Button> | |||
| <Button | |||
| disabled={disableRelease} | |||
| variant="outlined" | |||
| // onClick={handleRelease} | |||
| type="submit" | |||
| > | |||
| {t("release")} | |||
| </Button> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </FormProvider> | |||
| </Modal> | |||
| ) : undefined} | |||
| </> | |||
| ); | |||
| }; | |||
| export default ConsolidatedPickOrders; | |||
| @@ -0,0 +1,321 @@ | |||
| "use client"; | |||
| import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| FormControl, | |||
| Grid, | |||
| Stack, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| GridColDef, | |||
| GridRowIdGetter, | |||
| GridRowModel, | |||
| useGridApiContext, | |||
| GridRenderCellParams, | |||
| GridRenderEditCellParams, | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import InputDataGrid from "../InputDataGrid"; | |||
| import { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import { GridEditInputCell } from "@mui/x-data-grid"; | |||
| import { StockInLine } from "@/app/api/po"; | |||
| import { INPUT_DATE_FORMAT, stockInLineStatusMap } from "@/app/utils/formatUtil"; | |||
| import { fetchQcItemCheck, fetchQcResult } from "@/app/api/qc/actions"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import axios from "@/app/(main)/axios/axiosInstance"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import { SavePickOrderLineRequest, SavePickOrderRequest } from "@/app/api/pickOrder/actions"; | |||
| import TwoLineCell from "../PoDetail/TwoLineCell"; | |||
| import ItemSelect from "./ItemSelect"; | |||
| import { ItemCombo } from "@/app/api/settings/item/actions"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| interface Props { | |||
| items: ItemCombo[]; | |||
| // disabled: boolean; | |||
| } | |||
| type EntryError = | |||
| | { | |||
| [field in keyof SavePickOrderLineRequest]?: string; | |||
| } | |||
| | undefined; | |||
| type PolRow = TableRow<Partial<SavePickOrderLineRequest>, EntryError>; | |||
| // fetchQcItemCheck | |||
| const CreateForm: React.FC<Props> = ({ items }) => { | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("pickOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| const { | |||
| formState: { errors, defaultValues, touchedFields }, | |||
| watch, | |||
| control, | |||
| setValue, | |||
| } = useFormContext<SavePickOrderRequest>(); | |||
| console.log(defaultValues); | |||
| const targetDate = watch("targetDate"); | |||
| //// validate form | |||
| // const accQty = watch("acceptedQty"); | |||
| // const validateForm = useCallback(() => { | |||
| // console.log(accQty); | |||
| // if (accQty > itemDetail.acceptedQty) { | |||
| // setError("acceptedQty", { | |||
| // message: `${t("acceptedQty must not greater than")} ${ | |||
| // itemDetail.acceptedQty | |||
| // }`, | |||
| // type: "required", | |||
| // }); | |||
| // } | |||
| // if (accQty < 1) { | |||
| // setError("acceptedQty", { | |||
| // message: t("minimal value is 1"), | |||
| // type: "required", | |||
| // }); | |||
| // } | |||
| // if (isNaN(accQty)) { | |||
| // setError("acceptedQty", { | |||
| // message: t("value must be a number"), | |||
| // type: "required", | |||
| // }); | |||
| // } | |||
| // }, [accQty]); | |||
| // useEffect(() => { | |||
| // clearErrors(); | |||
| // validateForm(); | |||
| // }, [clearErrors, validateForm]); | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: "itemId", | |||
| headerName: t("Item"), | |||
| // width: 100, | |||
| flex: 1, | |||
| editable: true, | |||
| valueFormatter(params) { | |||
| const row = params.id ? params.api.getRow<PolRow>(params.id) : null; | |||
| if (!row) { | |||
| return null; | |||
| } | |||
| const Item = items.find((q) => q.id === row.itemId); | |||
| return Item ? Item.label : t("Please select item"); | |||
| }, | |||
| renderCell(params: GridRenderCellParams<PolRow, number>) { | |||
| console.log(params.value); | |||
| return <TwoLineCell>{params.formattedValue}</TwoLineCell>; | |||
| }, | |||
| renderEditCell(params: GridRenderEditCellParams<PolRow, number>) { | |||
| const errorMessage = | |||
| params.row._error?.[params.field as keyof SavePickOrderLineRequest]; | |||
| console.log(errorMessage); | |||
| const content = ( | |||
| // <></> | |||
| <ItemSelect | |||
| allItems={items} | |||
| value={params.row.itemId} | |||
| onItemSelect={async (itemId, uom, uomId) => { | |||
| console.log(uom) | |||
| await params.api.setEditCellValue({ | |||
| id: params.id, | |||
| field: "itemId", | |||
| value: itemId, | |||
| }); | |||
| await params.api.setEditCellValue({ | |||
| id: params.id, | |||
| field: "uom", | |||
| value: uom | |||
| }) | |||
| await params.api.setEditCellValue({ | |||
| id: params.id, | |||
| field: "uomId", | |||
| value: uomId | |||
| }) | |||
| }} | |||
| /> | |||
| ); | |||
| return errorMessage ? ( | |||
| <Tooltip title={errorMessage}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| field: "qty", | |||
| headerName: t("qty"), | |||
| // width: 100, | |||
| flex: 1, | |||
| type: "number", | |||
| editable: true, | |||
| renderEditCell(params: GridRenderEditCellParams<PolRow>) { | |||
| const errorMessage = | |||
| params.row._error?.[params.field as keyof SavePickOrderLineRequest]; | |||
| const content = <GridEditInputCell {...params} />; | |||
| return errorMessage ? ( | |||
| <Tooltip title={t(errorMessage)}> | |||
| <Box width="100%">{content}</Box> | |||
| </Tooltip> | |||
| ) : ( | |||
| content | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| field: "uom", | |||
| headerName: t("uom"), | |||
| // width: 100, | |||
| flex: 1, | |||
| editable: true, | |||
| // renderEditCell(params: GridRenderEditCellParams<PolRow>) { | |||
| // console.log(params.row) | |||
| // const errorMessage = | |||
| // params.row._error?.[params.field as keyof SavePickOrderLineRequest]; | |||
| // const content = <GridEditInputCell {...params} />; | |||
| // return errorMessage ? ( | |||
| // <Tooltip title={t(errorMessage)}> | |||
| // <Box width="100%">{content}</Box> | |||
| // </Tooltip> | |||
| // ) : ( | |||
| // content | |||
| // ); | |||
| // } | |||
| } | |||
| ], | |||
| [items, t], | |||
| ); | |||
| /// validate datagrid | |||
| const validation = useCallback( | |||
| (newRow: GridRowModel<PolRow>): EntryError => { | |||
| const error: EntryError = {}; | |||
| const { itemId, qty } = newRow; | |||
| if (!itemId || itemId <= 0) { | |||
| error["itemId"] = t("select qc"); | |||
| } | |||
| if (!qty || qty <= 0) { | |||
| error["qty"] = t("enter a qty"); | |||
| } | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| }, | |||
| [], | |||
| ); | |||
| const typeList = [ | |||
| { | |||
| type: "Consumable" | |||
| } | |||
| ] | |||
| const onChange = useCallback( | |||
| (event: React.SyntheticEvent, newValue: {type: string}) => { | |||
| console.log(newValue); | |||
| setValue("type", newValue.type); | |||
| }, | |||
| [setValue], | |||
| ); | |||
| return ( | |||
| <Grid container justifyContent="flex-start" alignItems="flex-start"> | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("Pick Order Detail")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| <Grid item xs={6} lg={6}> | |||
| <FormControl fullWidth> | |||
| <Autocomplete | |||
| disableClearable | |||
| fullWidth | |||
| getOptionLabel={(option) => option.type} | |||
| options={typeList} | |||
| onChange={onChange} | |||
| renderInput={(params) => <TextField {...params} label={t("type")}/>} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| name="targetDate" | |||
| // rules={{ required: !Boolean(productionDate) }} | |||
| render={({ field }) => { | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DatePicker | |||
| {...field} | |||
| sx={{ width: "100%" }} | |||
| label={t("targetDate")} | |||
| value={targetDate ? dayjs(targetDate) : undefined} | |||
| onChange={(date) => { | |||
| console.log(date); | |||
| if (!date) return; | |||
| console.log(date.format(INPUT_DATE_FORMAT)); | |||
| setValue("targetDate", date.format(INPUT_DATE_FORMAT)); | |||
| // field.onChange(date); | |||
| }} | |||
| inputRef={field.ref} | |||
| slotProps={{ | |||
| textField: { | |||
| // required: true, | |||
| error: Boolean(errors.targetDate?.message), | |||
| helperText: errors.targetDate?.message, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| <Grid item xs={12}> | |||
| <InputDataGrid<SavePickOrderRequest, SavePickOrderLineRequest, EntryError> | |||
| apiRef={apiRef} | |||
| checkboxSelection={false} | |||
| _formKey={"pickOrderLine"} | |||
| columns={columns} | |||
| validateRow={validation} | |||
| needAdd={true} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default CreateForm; | |||
| @@ -0,0 +1,98 @@ | |||
| import { createPickOrder, SavePickOrderRequest } from "@/app/api/pickOrder/actions"; | |||
| import { Box, Button, Modal, ModalProps, Stack } from "@mui/material"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import { useCallback } from "react"; | |||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import CreateForm from "./CreateForm"; | |||
| import { ItemCombo } from "@/app/api/settings/item/actions"; | |||
| import { Check } from "@mui/icons-material"; | |||
| dayjs.extend(arraySupport); | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| overflow: "scroll", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| display: "block", | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| interface Props extends Omit<ModalProps, "children"> { | |||
| items: ItemCombo[] | |||
| } | |||
| const CreatePickOrderModal: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| items | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const formProps = useForm<SavePickOrderRequest>(); | |||
| const errors = formProps.formState.errors; | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| onClose?.(...args); | |||
| // reset(); | |||
| }, | |||
| [onClose] | |||
| ); | |||
| const onSubmit = useCallback<SubmitHandler<SavePickOrderRequest>>( | |||
| async (data, event) => { | |||
| console.log(data) | |||
| try { | |||
| const res = await createPickOrder(data) | |||
| if (res.id) { | |||
| closeHandler({}, "backdropClick"); | |||
| } | |||
| } catch (error) { | |||
| console.log(error) | |||
| throw error | |||
| } | |||
| // formProps.reset() | |||
| }, | |||
| [closeHandler] | |||
| ); | |||
| return ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| <Modal open={open} onClose={closeHandler}> | |||
| <Box | |||
| sx={style} | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmit)} | |||
| > | |||
| <CreateForm | |||
| items={items} | |||
| /> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| name="submit" | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| type="submit" | |||
| > | |||
| {t("submit")} | |||
| </Button> | |||
| <Button | |||
| name="reset" | |||
| variant="contained" | |||
| startIcon={<Check />} | |||
| onClick={() => formProps.reset()} | |||
| > | |||
| {t("reset")} | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| </Modal> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreatePickOrderModal; | |||
| @@ -0,0 +1,209 @@ | |||
| import React, { useCallback } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Checkbox, | |||
| TextField, | |||
| TablePagination, | |||
| FormControl, | |||
| Select, | |||
| MenuItem, | |||
| } from '@mui/material'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| interface CreatedItem { | |||
| itemId: number; | |||
| itemName: string; | |||
| itemCode: string; | |||
| qty: number; | |||
| uom: string; | |||
| uomId: number; | |||
| uomDesc: string; | |||
| isSelected: boolean; | |||
| currentStockBalance?: number; | |||
| targetDate?: string | null; | |||
| groupId?: number | null; | |||
| } | |||
| interface Group { | |||
| id: number; | |||
| name: string; | |||
| targetDate: string; | |||
| } | |||
| interface CreatedItemsTableProps { | |||
| items: CreatedItem[]; | |||
| groups: Group[]; | |||
| onItemSelect: (itemId: number, checked: boolean) => void; | |||
| onQtyChange: (itemId: number, qty: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string) => void; | |||
| pageNum: number; | |||
| pageSize: number; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| const CreatedItemsTable: React.FC<CreatedItemsTableProps> = ({ | |||
| items, | |||
| groups, | |||
| onItemSelect, | |||
| onQtyChange, | |||
| onGroupChange, | |||
| pageNum, | |||
| pageSize, | |||
| onPageChange, | |||
| onPageSizeChange, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // Calculate pagination | |||
| const startIndex = (pageNum - 1) * pageSize; | |||
| const endIndex = startIndex + pageSize; | |||
| const paginatedItems = items.slice(startIndex, endIndex); | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| const numValue = Number(value); | |||
| if (!isNaN(numValue) && numValue >= 1) { | |||
| onQtyChange(itemId, numValue); | |||
| } | |||
| }, [onQtyChange]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell padding="checkbox" sx={{ width: '80px', minWidth: '80px' }}> | |||
| {t("Selected")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {t("Item")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {t("Group")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Current Stock")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Stock Unit")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Order Quantity")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Target Date")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedItems.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={12} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No created items")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedItems.map((item) => ( | |||
| <TableRow key={item.itemId}> | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| checked={item.isSelected} | |||
| onChange={(e) => onItemSelect(item.itemId, e.target.checked)} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2">{item.itemName}</Typography> | |||
| <Typography variant="caption" color="textSecondary"> | |||
| {item.itemCode} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <Select | |||
| value={item.groupId?.toString() || ""} | |||
| onChange={(e) => onGroupChange(item.itemId, e.target.value)} | |||
| displayEmpty | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("No Group")}</em> | |||
| </MenuItem> | |||
| {groups.map((group) => ( | |||
| <MenuItem key={group.id} value={group.id.toString()}> | |||
| {group.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| color={item.currentStockBalance && item.currentStockBalance > 0 ? "success.main" : "error.main"} | |||
| > | |||
| {item.currentStockBalance?.toLocaleString() || 0} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2">{item.uomDesc}</Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => handleQtyChange(item.itemId, e.target.value)} | |||
| inputProps={{ | |||
| min: 1, | |||
| step: 1, | |||
| style: { textAlign: 'center' } | |||
| }} | |||
| sx={{ | |||
| width: '80px', | |||
| '& .MuiInputBase-input': { | |||
| textAlign: 'center', | |||
| cursor: 'text' | |||
| } | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={items.length} | |||
| page={(pageNum - 1)} | |||
| rowsPerPage={pageSize} | |||
| onPageChange={onPageChange} | |||
| onRowsPerPageChange={onPageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default CreatedItemsTable; | |||
| @@ -0,0 +1,179 @@ | |||
| import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react'; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Collapse, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| TextField, | |||
| Checkbox, | |||
| FormControlLabel, | |||
| Paper, | |||
| Typography, | |||
| RadioGroup, | |||
| Radio, | |||
| Stack, | |||
| Autocomplete, | |||
| } from '@mui/material'; | |||
| import { SelectChangeEvent } from '@mui/material/Select'; | |||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | |||
| import ExpandLessIcon from '@mui/icons-material/ExpandLess'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| interface NameOption { | |||
| value: string; | |||
| label: string; | |||
| } | |||
| interface FormData { | |||
| name: string; | |||
| quantity: string; | |||
| message: string; | |||
| } | |||
| interface Props { | |||
| forSupervisor: boolean | |||
| isCollapsed: boolean | |||
| setIsCollapsed: Dispatch<React.SetStateAction<boolean>> | |||
| } | |||
| const EscalationComponent: React.FC<Props> = ({ | |||
| forSupervisor, | |||
| isCollapsed, | |||
| setIsCollapsed | |||
| }) => { | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const [formData, setFormData] = useState<FormData>({ | |||
| name: '', | |||
| quantity: '', | |||
| message: '', | |||
| }); | |||
| const nameOptions: NameOption[] = [ | |||
| { value: '', label: '請選擇姓名...' }, | |||
| { value: 'john', label: '張大明' }, | |||
| { value: 'jane', label: '李小美' }, | |||
| { value: 'mike', label: '王志強' }, | |||
| { value: 'sarah', label: '陳淑華' }, | |||
| { value: 'david', label: '林建國' }, | |||
| ]; | |||
| const handleInputChange = ( | |||
| event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent<string> | |||
| ): void => { | |||
| const { name, value } = event.target; | |||
| setFormData((prev) => ({ | |||
| ...prev, | |||
| [name]: value, | |||
| })); | |||
| }; | |||
| const handleSubmit = (e: FormEvent<HTMLFormElement>): void => { | |||
| e.preventDefault(); | |||
| console.log('表單已提交:', formData); | |||
| // 處理表單提交 | |||
| }; | |||
| const handleCollapseToggle = (e: ChangeEvent<HTMLInputElement>): void => { | |||
| setIsCollapsed(e.target.checked); | |||
| }; | |||
| return ( | |||
| // <Paper elevation={3} sx={{ maxWidth: 400, mx: 'auto', p: 3 }}> | |||
| <> | |||
| <Paper> | |||
| {/* <Paper elevation={3} sx={{ mx: 'auto', p: 3 }}> */} | |||
| <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={isCollapsed} | |||
| onChange={handleCollapseToggle} | |||
| color="primary" | |||
| /> | |||
| } | |||
| label={ | |||
| <Box sx={{ display: 'flex', alignItems: 'center' }}> | |||
| <Typography variant="body1">上報結果</Typography> | |||
| {isCollapsed ? ( | |||
| <ExpandLessIcon sx={{ ml: 1 }} /> | |||
| ) : ( | |||
| <ExpandMoreIcon sx={{ ml: 1 }} /> | |||
| )} | |||
| </Box> | |||
| } | |||
| /> | |||
| </Box> | |||
| <Collapse in={isCollapsed}> | |||
| <Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> | |||
| {forSupervisor ? ( | |||
| <FormControl> | |||
| <RadioGroup | |||
| row | |||
| aria-labelledby="demo-radio-buttons-group-label" | |||
| defaultValue="pass" | |||
| name="radio-buttons-group" | |||
| > | |||
| <FormControlLabel value="pass" control={<Radio />} label="合格" /> | |||
| <FormControlLabel value="fail" control={<Radio />} label="不合格" /> | |||
| </RadioGroup> | |||
| </FormControl> | |||
| ): undefined} | |||
| <FormControl fullWidth> | |||
| <select | |||
| id="name" | |||
| name="name" | |||
| value={formData.name} | |||
| onChange={handleInputChange} | |||
| className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white" | |||
| > | |||
| {nameOptions.map((option: NameOption) => ( | |||
| <option key={option.value} value={option.value}> | |||
| {option.label} | |||
| </option> | |||
| ))} | |||
| </select> | |||
| </FormControl> | |||
| <TextField | |||
| fullWidth | |||
| id="quantity" | |||
| name="quantity" | |||
| label="數量" | |||
| type="number" | |||
| value={formData.quantity} | |||
| onChange={handleInputChange} | |||
| InputProps={{ inputProps: { min: 1 } }} | |||
| placeholder="請輸入數量" | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| id="message" | |||
| name="message" | |||
| label="備註" | |||
| multiline | |||
| rows={4} | |||
| value={formData.message} | |||
| onChange={handleInputChange} | |||
| placeholder="請輸入您的備註" | |||
| /> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| type="submit" | |||
| variant="contained" | |||
| color="primary" | |||
| > | |||
| {t("update qc info")} | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| </Collapse> | |||
| </Paper> | |||
| </> | |||
| ); | |||
| } | |||
| export default EscalationComponent; | |||
| @@ -0,0 +1,167 @@ | |||
| import { Button, CircularProgress, Grid } from "@mui/material"; | |||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||
| import { PickOrderResult } from "@/app/api/pickOrder"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { isEmpty, upperCase, upperFirst } from "lodash"; | |||
| import { arrayToDateString, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { | |||
| consolidatePickOrder, | |||
| fetchPickOrderClient, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| filteredPickOrders: PickOrderResult[]; | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| const PickOrders: React.FC<Props> = ({ filteredPickOrders, filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const [selectedRows, setSelectedRows] = useState<(string | number)[]>([]); | |||
| const [filteredPickOrder, setFilteredPickOrder] = useState( | |||
| [] as PickOrderResult[], | |||
| ); | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [isLoading, setIsLoading] = useState(false); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 0, | |||
| pageSize: 10, | |||
| }); | |||
| const [totalCount, setTotalCount] = useState<number>(); | |||
| const fetchNewPagePickOrder = useCallback( | |||
| async ( | |||
| pagingController: Record<string, number>, | |||
| filterArgs: Record<string, number>, | |||
| ) => { | |||
| setIsLoading(true); | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| }; | |||
| const res = await fetchPickOrderClient(params); | |||
| if (res) { | |||
| console.log(res); | |||
| setFilteredPickOrder(res.records); | |||
| setTotalCount(res.total); | |||
| } | |||
| setIsLoading(false); | |||
| }, | |||
| [], | |||
| ); | |||
| const handleConsolidatedRows = useCallback(async () => { | |||
| console.log(selectedRows); | |||
| setIsUploading(true); | |||
| try { | |||
| const res = await consolidatePickOrder(selectedRows as number[]); | |||
| if (res) { | |||
| console.log(res); | |||
| } | |||
| } catch { | |||
| setIsUploading(false); | |||
| } | |||
| fetchNewPagePickOrder(pagingController, filterArgs); | |||
| setIsUploading(false); | |||
| }, [selectedRows, setIsUploading, fetchNewPagePickOrder, pagingController, filterArgs]); | |||
| useEffect(() => { | |||
| fetchNewPagePickOrder(pagingController, filterArgs); | |||
| }, [fetchNewPagePickOrder, pagingController, filterArgs]); | |||
| const columns = useMemo<Column<PickOrderResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "id", | |||
| label: "", | |||
| type: "checkbox", | |||
| disabled: (params) => { | |||
| return !isEmpty(params.consoCode); | |||
| }, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("Code"), | |||
| }, | |||
| { | |||
| name: "consoCode", | |||
| label: t("Consolidated Code"), | |||
| renderCell: (params) => { | |||
| return params.consoCode ?? ""; | |||
| }, | |||
| }, | |||
| { | |||
| name: "type", | |||
| label: t("type"), | |||
| renderCell: (params) => { | |||
| return upperCase(params.type); | |||
| }, | |||
| }, | |||
| { | |||
| name: "items", | |||
| label: t("Items"), | |||
| renderCell: (params) => { | |||
| return params.items?.map((i) => i.name).join(", "); | |||
| }, | |||
| }, | |||
| { | |||
| name: "targetDate", | |||
| label: t("Target Date"), | |||
| renderCell: (params) => { | |||
| return ( | |||
| dayjs(params.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| name: "releasedBy", | |||
| label: t("Released By"), | |||
| }, | |||
| { | |||
| name: "status", | |||
| label: t("Status"), | |||
| renderCell: (params) => { | |||
| return upperFirst(params.status); | |||
| }, | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| return ( | |||
| <Grid container rowGap={1}> | |||
| <Grid item xs={3}> | |||
| <Button | |||
| disabled={selectedRows.length < 1} | |||
| variant="outlined" | |||
| onClick={handleConsolidatedRows} | |||
| > | |||
| {t("Consolidate")} | |||
| </Button> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| {isLoading ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <SearchResults<PickOrderResult> | |||
| items={filteredPickOrder} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| totalCount={totalCount} | |||
| checkboxIds={selectedRows!} | |||
| setCheckboxIds={setSelectedRows} | |||
| /> | |||
| )} | |||
| </Grid> | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default PickOrders; | |||
| @@ -0,0 +1,292 @@ | |||
| "use client"; | |||
| import { PickOrderResult } from "@/app/api/pickOrder"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { | |||
| flatten, | |||
| intersectionWith, | |||
| isEmpty, | |||
| sortBy, | |||
| uniqBy, | |||
| upperCase, | |||
| upperFirst, | |||
| } from "lodash"; | |||
| import { | |||
| arrayToDayjs, | |||
| } from "@/app/utils/formatUtil"; | |||
| import { Button, Grid, Stack, Tab, Tabs, TabsProps, Typography, Box } from "@mui/material"; | |||
| import PickOrders from "./FinishedGood"; | |||
| import ConsolidatedPickOrders from "./ConsolidatedPickOrders"; | |||
| import PickExecution from "./GoodPickExecution"; | |||
| import CreatePickOrderModal from "./CreatePickOrderModal"; | |||
| import NewCreateItem from "./newcreatitem"; | |||
| import AssignAndRelease from "./AssignAndRelease"; | |||
| import AssignTo from "./assignTo"; | |||
| import { fetchAllItemsInClient, ItemCombo } from "@/app/api/settings/item/actions"; | |||
| import { fetchPickOrderClient } from "@/app/api/pickOrder/actions"; | |||
| import Jobcreatitem from "./Jobcreatitem"; | |||
| interface Props { | |||
| pickOrders: PickOrderResult[]; | |||
| } | |||
| type SearchQuery = Partial< | |||
| Omit<PickOrderResult, "id" | "consoCode" | "completeDate"> | |||
| >; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const PickOrderSearch: React.FC<Props> = ({ pickOrders }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const [isOpenCreateModal, setIsOpenCreateModal] = useState(false) | |||
| const [items, setItems] = useState<ItemCombo[]>([]) | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders); | |||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({}); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [totalCount, setTotalCount] = useState<number>(); | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| }, | |||
| [], | |||
| ); | |||
| const openCreateModal = useCallback(async () => { | |||
| console.log("testing") | |||
| const res = await fetchAllItemsInClient() | |||
| console.log(res) | |||
| setItems(res) | |||
| setIsOpenCreateModal(true) | |||
| }, []) | |||
| const closeCreateModal = useCallback(() => { | |||
| setIsOpenCreateModal(false) | |||
| }, []) | |||
| useEffect(() => { | |||
| if (tabIndex === 3) { | |||
| const loadItems = async () => { | |||
| try { | |||
| const itemsData = await fetchAllItemsInClient(); | |||
| console.log("PickOrderSearch loaded items:", itemsData.length); | |||
| setItems(itemsData); | |||
| } catch (error) { | |||
| console.error("Error loading items in PickOrderSearch:", error); | |||
| } | |||
| }; | |||
| // 如果还没有数据,则加载 | |||
| if (items.length === 0) { | |||
| loadItems(); | |||
| } | |||
| } | |||
| }, [tabIndex, items.length]); | |||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||
| () => { | |||
| const baseCriteria: Criterion<SearchParamNames>[] = [ | |||
| { | |||
| label: tabIndex === 3 ? t("Item Code") : t("Code"), | |||
| paramName: "code", | |||
| type: "text" | |||
| }, | |||
| { | |||
| label: t("Type"), | |||
| paramName: "type", | |||
| type: "autocomplete", | |||
| options: tabIndex === 3 | |||
| ? | |||
| [ | |||
| { value: "Consumable", label: t("Consumable") }, | |||
| { value: "Material", label: t("Material") }, | |||
| { value: "Product", label: t("Product") } | |||
| ] | |||
| : | |||
| sortBy( | |||
| uniqBy( | |||
| pickOrders.map((po) => ({ | |||
| value: po.type, | |||
| label: t(upperCase(po.type)), | |||
| })), | |||
| "value", | |||
| ), | |||
| "label", | |||
| ), | |||
| }, | |||
| ]; | |||
| // Add Job Order search for Create Item tab (tabIndex === 3) | |||
| if (tabIndex === 3) { | |||
| baseCriteria.splice(1, 0, { | |||
| label: t("Job Order"), | |||
| paramName: "jobOrderCode" as any, // Type assertion for now | |||
| type: "text", | |||
| }); | |||
| baseCriteria.splice(2, 0, { | |||
| label: t("Target Date"), | |||
| paramName: "targetDate", | |||
| type: "date", | |||
| }); | |||
| } else { | |||
| baseCriteria.splice(1, 0, { | |||
| label: t("Target Date From"), | |||
| label2: t("Target Date To"), | |||
| paramName: "targetDate", | |||
| type: "dateRange", | |||
| }); | |||
| } | |||
| // Add Items/Item Name criteria | |||
| baseCriteria.push({ | |||
| label: tabIndex === 3 ? t("Item Name") : t("Items"), | |||
| paramName: "items", | |||
| type: tabIndex === 3 ? "text" : "autocomplete", | |||
| options: tabIndex === 3 | |||
| ? [] | |||
| : | |||
| uniqBy( | |||
| flatten( | |||
| sortBy( | |||
| pickOrders.map((po) => | |||
| po.items | |||
| ? po.items.map((item) => ({ | |||
| value: item.name, | |||
| label: item.name, | |||
| })) | |||
| : [], | |||
| ), | |||
| "label", | |||
| ), | |||
| ), | |||
| "value", | |||
| ), | |||
| }); | |||
| // Add Status criteria for non-Create Item tabs | |||
| if (tabIndex !== 3) { | |||
| baseCriteria.push({ | |||
| label: t("Status"), | |||
| paramName: "status", | |||
| type: "autocomplete", | |||
| options: sortBy( | |||
| uniqBy( | |||
| pickOrders.map((po) => ({ | |||
| value: po.status, | |||
| label: t(upperFirst(po.status)), | |||
| })), | |||
| "value", | |||
| ), | |||
| "label", | |||
| ), | |||
| }); | |||
| } | |||
| return baseCriteria; | |||
| }, | |||
| [pickOrders, t, tabIndex, items], | |||
| ); | |||
| const fetchNewPagePickOrder = useCallback( | |||
| async ( | |||
| pagingController: Record<string, number>, | |||
| filterArgs: Record<string, number>, | |||
| ) => { | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| }; | |||
| const res = await fetchPickOrderClient(params); | |||
| if (res) { | |||
| console.log(res); | |||
| setFilteredPickOrders(res.records); | |||
| setTotalCount(res.total); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| const onReset = useCallback(() => { | |||
| setFilteredPickOrders(pickOrders); | |||
| }, [pickOrders]); | |||
| useEffect(() => { | |||
| if (!isOpenCreateModal) { | |||
| setTabIndex(1) | |||
| setTimeout(async () => { | |||
| setTabIndex(0) | |||
| }, 200) | |||
| } | |||
| }, [isOpenCreateModal]) | |||
| // 添加处理提料单创建成功的函数 | |||
| const handlePickOrderCreated = useCallback(() => { | |||
| // 切换到 Assign & Release 标签页 (tabIndex = 1) | |||
| setTabIndex(2); | |||
| }, []); | |||
| return ( | |||
| <Box sx={{ | |||
| height: '100vh', // Full viewport height | |||
| overflow: 'auto' // Single scrollbar for the whole page | |||
| }}> | |||
| {/* Header section */} | |||
| <Box sx={{ | |||
| p: 2, | |||
| borderBottom: '1px solid #e0e0e0' | |||
| }}> | |||
| <Stack rowGap={2}> | |||
| <Grid container> | |||
| <Grid item xs={8}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Pick Order")} | |||
| </Typography> | |||
| </Grid> | |||
| {/* | |||
| <Grid item xs={4} display="flex" justifyContent="end" alignItems="end"> | |||
| <Button onClick={openCreateModal}> | |||
| {t("create")} | |||
| </Button> | |||
| {isOpenCreateModal && | |||
| <CreatePickOrderModal | |||
| open={isOpenCreateModal} | |||
| onClose={closeCreateModal} | |||
| items={items} | |||
| /> | |||
| } | |||
| </Grid> | |||
| */} | |||
| </Grid> | |||
| </Stack> | |||
| </Box> | |||
| {/* Tabs section */} | |||
| <Box sx={{ | |||
| borderBottom: '1px solid #e0e0e0' | |||
| }}> | |||
| <Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable"> | |||
| <Tab label={t("Assign")} iconPosition="end" /> | |||
| <Tab label={t("Release")} iconPosition="end" /> | |||
| <Tab label={t("Pick Execution")} iconPosition="end" /> | |||
| </Tabs> | |||
| </Box> | |||
| {/* Content section - NO overflow: 'auto' here */} | |||
| <Box sx={{ | |||
| p: 2 | |||
| }}> | |||
| {tabIndex === 2 && <PickExecution filterArgs={filterArgs} />} | |||
| {tabIndex === 0 && <AssignAndRelease filterArgs={filterArgs} />} | |||
| {tabIndex === 1 && <AssignTo filterArgs={filterArgs} />} | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default PickOrderSearch; | |||
| @@ -0,0 +1,26 @@ | |||
| import { fetchPickOrders } from "@/app/api/pickOrder"; | |||
| import GeneralLoading from "../General/GeneralLoading"; | |||
| import PickOrderSearch from "./FinishedGoodSearch"; | |||
| interface SubComponents { | |||
| Loading: typeof GeneralLoading; | |||
| } | |||
| const FinishedGoodSearchWrapper: React.FC & SubComponents = async () => { | |||
| const [pickOrders] = await Promise.all([ | |||
| fetchPickOrders({ | |||
| code: undefined, | |||
| targetDateFrom: undefined, | |||
| targetDateTo: undefined, | |||
| type: undefined, | |||
| status: undefined, | |||
| itemName: undefined, | |||
| }), | |||
| ]); | |||
| return <PickOrderSearch pickOrders={pickOrders} />; | |||
| }; | |||
| FinishedGoodSearchWrapper.Loading = GeneralLoading; | |||
| export default FinishedGoodSearchWrapper; | |||
| @@ -0,0 +1,475 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { | |||
| fetchALLPickOrderLineLotDetails, | |||
| updateStockOutLineStatus, | |||
| createStockOutLine, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import { | |||
| FormProvider, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | |||
| import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; | |||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | |||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | |||
| import CombinedLotTable from './CombinedLotTable'; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; // ✅ Import the custom session type | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const router = useRouter(); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Cast to custom type | |||
| // ✅ Get current user ID from session with proper typing | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| // ✅ Combined approach states | |||
| const [combinedLotData, setCombinedLotData] = useState<any[]>([]); | |||
| const [combinedDataLoading, setCombinedDataLoading] = useState(false); | |||
| const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]); | |||
| // ✅ QR Scanner context | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| // ✅ QR scan input states | |||
| const [qrScanInput, setQrScanInput] = useState<string>(''); | |||
| const [qrScanError, setQrScanError] = useState<boolean>(false); | |||
| const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false); | |||
| // ✅ Pick quantity states | |||
| const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({}); | |||
| // ✅ Search states | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| // ✅ Add pagination state | |||
| const [paginationController, setPaginationController] = useState({ | |||
| pageNum: 0, | |||
| pageSize: 10, | |||
| }); | |||
| // ✅ Keep only essential states | |||
| const [usernameList, setUsernameList] = useState<NameList[]>([]); | |||
| const formProps = useForm(); | |||
| const errors = formProps.formState.errors; | |||
| // ✅ Start QR scanning on component mount | |||
| useEffect(() => { | |||
| startScan(); | |||
| return () => { | |||
| stopScan(); | |||
| resetScan(); | |||
| }; | |||
| }, [startScan, stopScan, resetScan]); | |||
| // ✅ Fetch all combined lot data - Updated to use current user ID | |||
| const fetchAllCombinedLotData = useCallback(async (userId?: number) => { | |||
| setCombinedDataLoading(true); | |||
| try { | |||
| // ✅ Use passed userId or current user ID | |||
| const userIdToUse = userId || currentUserId; | |||
| const allLotDetails = await fetchALLPickOrderLineLotDetails(userIdToUse); | |||
| console.log("All combined lot details:", allLotDetails); | |||
| setCombinedLotData(allLotDetails); | |||
| setOriginalCombinedData(allLotDetails); // Store original for filtering | |||
| } catch (error) { | |||
| console.error("Error fetching combined lot data:", error); | |||
| setCombinedLotData([]); | |||
| setOriginalCombinedData([]); | |||
| } finally { | |||
| setCombinedDataLoading(false); | |||
| } | |||
| }, [currentUserId]); // ✅ Add currentUserId as dependency | |||
| // ✅ Load data on component mount - Now uses current user ID | |||
| useEffect(() => { | |||
| fetchAllCombinedLotData(); // This will now use currentUserId | |||
| }, [fetchAllCombinedLotData]); | |||
| // ✅ Handle QR code submission for matched lot - FIXED: Handle multiple pick order lines | |||
| const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | |||
| console.log(`✅ Processing QR Code for lot: ${lotNo}`); | |||
| console.log(`🔍 Available lots:`, combinedLotData.map(lot => lot.lotNo)); | |||
| // Find ALL matching lots (same lot number can be used by multiple pick order lines) | |||
| const matchingLots = combinedLotData.filter(lot => | |||
| lot.lotNo === lotNo || | |||
| lot.lotNo?.toLowerCase() === lotNo.toLowerCase() | |||
| ); | |||
| if (matchingLots.length === 0) { | |||
| console.error(`❌ Lot not found: ${lotNo}`); | |||
| console.error(`❌ Available lots:`, combinedLotData.map(lot => lot.lotNo)); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| return; | |||
| } | |||
| console.log(`✅ Found ${matchingLots.length} matching lots:`, matchingLots); | |||
| setQrScanError(false); | |||
| try { | |||
| let successCount = 0; | |||
| let existsCount = 0; | |||
| let errorCount = 0; | |||
| // ✅ Process each matching lot (each pick order line that uses this lot) | |||
| for (const matchingLot of matchingLots) { | |||
| console.log(`🔄 Processing pick order line ${matchingLot.pickOrderLineId} for lot ${lotNo}`); | |||
| // ✅ Check if stockOutLineId is null before creating | |||
| if (matchingLot.stockOutLineId) { | |||
| console.log(`✅ Stock out line already exists for line ${matchingLot.pickOrderLineId}`); | |||
| existsCount++; | |||
| } else { | |||
| // Create stock out line for this specific pick order line | |||
| const stockOutLineData: CreateStockOutLine = { | |||
| consoCode: matchingLot.pickOrderCode, // Use pick order code as conso code | |||
| pickOrderLineId: matchingLot.pickOrderLineId, | |||
| inventoryLotLineId: matchingLot.lotId, | |||
| qty: 0.0 | |||
| }; | |||
| console.log(`Creating stock out line for pick order line ${matchingLot.pickOrderLineId}:`, stockOutLineData); | |||
| const result = await createStockOutLine(stockOutLineData); | |||
| console.log(`Create stock out line result for line ${matchingLot.pickOrderLineId}:`, result); | |||
| // ✅ Handle different response codes | |||
| if (result && result.code === "EXISTS") { | |||
| console.log(`✅ Stock out line already exists for line ${matchingLot.pickOrderLineId}`); | |||
| existsCount++; | |||
| } else if (result && result.code === "SUCCESS") { | |||
| console.log(`✅ Stock out line created successfully for line ${matchingLot.pickOrderLineId}`); | |||
| successCount++; | |||
| } else { | |||
| console.error(`❌ Unexpected response for line ${matchingLot.pickOrderLineId}:`, result); | |||
| errorCount++; | |||
| } | |||
| } | |||
| // Auto-set pick quantity to required quantity for this specific line | |||
| const lotKey = `${matchingLot.pickOrderLineId}-${matchingLot.lotId}`; | |||
| setPickQtyData(prev => ({ | |||
| ...prev, | |||
| [lotKey]: matchingLot.requiredQty | |||
| })); | |||
| } | |||
| // ✅ Set success state if at least one operation succeeded | |||
| if (successCount > 0 || existsCount > 0) { | |||
| setQrScanSuccess(true); | |||
| setQrScanError(false); | |||
| console.log(`✅ QR Code processing completed: ${successCount} created, ${existsCount} already existed, ${errorCount} errors`); | |||
| } else { | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| console.error(`❌ All operations failed for lot ${lotNo}`); | |||
| return; | |||
| } | |||
| // Refresh data | |||
| await fetchAllCombinedLotData(); | |||
| // Clear input after successful match | |||
| setQrScanInput(''); | |||
| console.log("Stock out line process completed successfully!"); | |||
| } catch (error) { | |||
| console.error("Error creating stock out line:", error); | |||
| setQrScanError(true); | |||
| setQrScanSuccess(false); | |||
| } | |||
| }, [combinedLotData, fetchAllCombinedLotData]); | |||
| // ✅ Process scanned QR codes automatically - FIXED: Only process when data is loaded | |||
| useEffect(() => { | |||
| if (qrValues.length > 0 && combinedLotData.length > 0) { | |||
| const latestQr = qrValues[qrValues.length - 1]; | |||
| const qrContent = latestQr.replace(/[{}]/g, ''); | |||
| setQrScanInput(qrContent); | |||
| // Auto-process the QR code | |||
| handleQrCodeSubmit(qrContent); | |||
| } | |||
| }, [qrValues, combinedLotData, handleQrCodeSubmit]); | |||
| // ✅ Handle manual input submission | |||
| const handleManualInputSubmit = useCallback(() => { | |||
| if (qrScanInput.trim() !== '') { | |||
| handleQrCodeSubmit(qrScanInput.trim()); | |||
| } | |||
| }, [qrScanInput, handleQrCodeSubmit]); | |||
| // ✅ Handle pick quantity change - FIXED: Better input handling | |||
| const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => { | |||
| // ✅ Handle empty string as 0 | |||
| if (value === '' || value === null || value === undefined) { | |||
| setPickQtyData(prev => ({ | |||
| ...prev, | |||
| [lotKey]: 0 | |||
| })); | |||
| return; | |||
| } | |||
| // ✅ Convert to number properly | |||
| const numericValue = typeof value === 'string' ? parseFloat(value) : value; | |||
| // ✅ Handle NaN case | |||
| if (isNaN(numericValue)) { | |||
| setPickQtyData(prev => ({ | |||
| ...prev, | |||
| [lotKey]: 0 | |||
| })); | |||
| return; | |||
| } | |||
| setPickQtyData(prev => ({ | |||
| ...prev, | |||
| [lotKey]: numericValue | |||
| })); | |||
| }, []); | |||
| // ✅ Handle submit pick quantity | |||
| const handleSubmitPickQty = useCallback(async (lot: any) => { | |||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | |||
| const newQty = pickQtyData[lotKey] || 0; | |||
| if (!lot.stockOutLineId) { | |||
| console.error("No stock out line found for this lot"); | |||
| return; | |||
| } | |||
| try { | |||
| // ✅ FIXED: Calculate cumulative quantity | |||
| const currentActualPickQty = lot.actualPickQty || 0; | |||
| const cumulativeQty = currentActualPickQty + newQty; | |||
| // ✅ FIXED: Check cumulative quantity against required quantity | |||
| let newStatus = 'partially_completed'; | |||
| if (cumulativeQty >= lot.requiredQty) { | |||
| newStatus = 'completed'; | |||
| } | |||
| console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`); | |||
| console.log(`Lot: ${lot.lotNo}`); | |||
| console.log(`Required Qty: ${lot.requiredQty}`); | |||
| console.log(`Current Actual Pick Qty: ${currentActualPickQty}`); | |||
| console.log(`New Submitted Qty: ${newQty}`); | |||
| console.log(`Cumulative Qty: ${cumulativeQty}`); | |||
| console.log(`New Status: ${newStatus}`); | |||
| console.log(`=====================================`); | |||
| await updateStockOutLineStatus({ | |||
| id: lot.stockOutLineId, | |||
| status: newStatus, | |||
| qty: cumulativeQty // ✅ Submit the cumulative quantity | |||
| }); | |||
| // Update inventory | |||
| if (newQty > 0) { | |||
| await updateInventoryLotLineQuantities({ | |||
| inventoryLotLineId: lot.lotId, | |||
| qty: newQty, // ✅ Only update inventory with the new quantity | |||
| status: 'available', | |||
| operation: 'pick' | |||
| }); | |||
| } | |||
| // Refresh data | |||
| await fetchAllCombinedLotData(); | |||
| console.log("Pick quantity submitted successfully!"); | |||
| } catch (error) { | |||
| console.error("Error submitting pick quantity:", error); | |||
| } | |||
| }, [pickQtyData, fetchAllCombinedLotData]); | |||
| // ✅ Handle reject lot | |||
| const handleRejectLot = useCallback(async (lot: any) => { | |||
| if (!lot.stockOutLineId) { | |||
| console.error("No stock out line found for this lot"); | |||
| return; | |||
| } | |||
| try { | |||
| await updateStockOutLineStatus({ | |||
| id: lot.stockOutLineId, | |||
| status: 'rejected', | |||
| qty: 0 | |||
| }); | |||
| // Refresh data | |||
| await fetchAllCombinedLotData(); | |||
| console.log("Lot rejected successfully!"); | |||
| } catch (error) { | |||
| console.error("Error rejecting lot:", error); | |||
| } | |||
| }, [fetchAllCombinedLotData]); | |||
| // ✅ Search criteria | |||
| const searchCriteria: Criterion<any>[] = [ | |||
| { | |||
| label: t("Pick Order Code"), | |||
| paramName: "pickOrderCode", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item Code"), | |||
| paramName: "itemCode", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item Name"), | |||
| paramName: "itemName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Lot No"), | |||
| paramName: "lotNo", | |||
| type: "text", | |||
| }, | |||
| ]; | |||
| // ✅ Search handler | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| console.log("Search query:", query); | |||
| if (!originalCombinedData) return; | |||
| const filtered = originalCombinedData.filter((lot: any) => { | |||
| const pickOrderCodeMatch = !query.pickOrderCode || | |||
| lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase()); | |||
| const itemCodeMatch = !query.itemCode || | |||
| lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | |||
| const itemNameMatch = !query.itemName || | |||
| lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); | |||
| const lotNoMatch = !query.lotNo || | |||
| lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase()); | |||
| return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch; | |||
| }); | |||
| setCombinedLotData(filtered); | |||
| console.log("Filtered lots count:", filtered.length); | |||
| }, [originalCombinedData]); | |||
| // ✅ Reset handler | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| if (originalCombinedData) { | |||
| setCombinedLotData(originalCombinedData); | |||
| } | |||
| }, [originalCombinedData]); | |||
| // ✅ Pagination handlers | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| setPaginationController(prev => ({ | |||
| ...prev, | |||
| pageNum: newPage, | |||
| })); | |||
| }, []); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| setPaginationController({ | |||
| pageNum: 0, | |||
| pageSize: newPageSize, | |||
| }); | |||
| }, []); | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| <Stack spacing={2}> | |||
| {/* Search Box */} | |||
| <Box> | |||
| <SearchBox | |||
| criteria={searchCriteria} | |||
| onSearch={handleSearch} | |||
| onReset={handleReset} | |||
| /> | |||
| </Box> | |||
| {/* Combined Lot Table with QR Scan Input */} | |||
| <Box> | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||
| <Typography variant="h6" gutterBottom sx={{ mb: 0 }}> | |||
| {t("All Pick Order Lots")} | |||
| </Typography> | |||
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> | |||
| <TextField | |||
| size="small" | |||
| value={qrScanInput} | |||
| onChange={(e) => setQrScanInput(e.target.value)} | |||
| onKeyPress={(e) => { | |||
| if (e.key === 'Enter') { | |||
| handleManualInputSubmit(); | |||
| } | |||
| }} | |||
| error={qrScanError} | |||
| color={qrScanSuccess ? 'success' : undefined} | |||
| helperText={ | |||
| qrScanError | |||
| ? t("Lot number not found") | |||
| : qrScanSuccess | |||
| ? t("Lot processed successfully") | |||
| : t("Enter lot number or scan QR code") | |||
| } | |||
| placeholder={t("Enter lot number...")} | |||
| sx={{ minWidth: '250px' }} | |||
| InputProps={{ | |||
| startAdornment: <QrCodeIcon sx={{ mr: 1, color: isScanning ? 'primary.main' : 'text.secondary' }} />, | |||
| }} | |||
| /> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={handleManualInputSubmit} | |||
| disabled={!qrScanInput.trim()} | |||
| size="small" | |||
| > | |||
| {t("Submit")} | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| <CombinedLotTable | |||
| combinedLotData={combinedLotData} | |||
| combinedDataLoading={combinedDataLoading} | |||
| pickQtyData={pickQtyData} | |||
| paginationController={paginationController} | |||
| onPickQtyChange={handlePickQtyChange} | |||
| onSubmitPickQty={handleSubmitPickQty} | |||
| onRejectLot={handleRejectLot} | |||
| onPageChange={handlePageChange} | |||
| onPageSizeChange={handlePageSizeChange} | |||
| /> | |||
| </Box> | |||
| </Stack> | |||
| </FormProvider> | |||
| ); | |||
| }; | |||
| export default PickExecution; | |||
| @@ -0,0 +1,79 @@ | |||
| import { ItemCombo } from "@/app/api/settings/item/actions"; | |||
| import { Autocomplete, TextField } from "@mui/material"; | |||
| import { useCallback, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface CommonProps { | |||
| allItems: ItemCombo[]; | |||
| error?: boolean; | |||
| } | |||
| interface SingleAutocompleteProps extends CommonProps { | |||
| value: number | string | undefined; | |||
| onItemSelect: (itemId: number, uom: string, uomId: number) => void | Promise<void>; | |||
| // multiple: false; | |||
| } | |||
| type Props = SingleAutocompleteProps; | |||
| const ItemSelect: React.FC<Props> = ({ | |||
| allItems, | |||
| value, | |||
| error, | |||
| onItemSelect | |||
| }) => { | |||
| const { t } = useTranslation("item"); | |||
| const filteredItems = useMemo(() => { | |||
| return allItems | |||
| }, [allItems]) | |||
| const options = useMemo(() => { | |||
| return [ | |||
| { | |||
| value: -1, // think think sin | |||
| label: t("None"), | |||
| uom: "", | |||
| uomId: -1, | |||
| group: "default", | |||
| }, | |||
| ...filteredItems.map((i) => ({ | |||
| value: i.id as number, | |||
| label: i.label, | |||
| uom: i.uom, | |||
| uomId: i.uomId, | |||
| group: "existing", | |||
| })), | |||
| ]; | |||
| }, [t, filteredItems]); | |||
| const currentValue = options.find((o) => o.value === value) || options[0]; | |||
| const onChange = useCallback( | |||
| ( | |||
| event: React.SyntheticEvent, | |||
| newValue: { value: number; uom: string; uomId: number; group: string } | { uom: string; uomId: number; value: number }[], | |||
| ) => { | |||
| const singleNewVal = newValue as { | |||
| value: number; | |||
| uom: string; | |||
| uomId: number; | |||
| group: string; | |||
| }; | |||
| onItemSelect(singleNewVal.value, singleNewVal.uom, singleNewVal.uomId) | |||
| } | |||
| , [onItemSelect]) | |||
| return ( | |||
| <Autocomplete | |||
| noOptionsText={t("No Item")} | |||
| disableClearable | |||
| fullWidth | |||
| value={currentValue} | |||
| onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderInput={(params) => <TextField {...params} error={error} />} | |||
| /> | |||
| ); | |||
| } | |||
| export default ItemSelect | |||
| @@ -0,0 +1,737 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Checkbox, | |||
| Paper, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| TextField, | |||
| Typography, | |||
| TablePagination, | |||
| Modal, | |||
| } from "@mui/material"; | |||
| import { useCallback, useMemo, useState, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | |||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||
| import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider'; | |||
| import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions"; | |||
| import { updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | |||
| interface LotPickData { | |||
| id: number; | |||
| lotId: number; | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| location: string; | |||
| stockUnit: string; | |||
| availableQty: number; | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| } | |||
| interface PickQtyData { | |||
| [lineId: number]: { | |||
| [lotId: number]: number; | |||
| }; | |||
| } | |||
| interface LotTableProps { | |||
| lotData: LotPickData[]; | |||
| selectedRowId: number | null; | |||
| selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string }) | null; | |||
| pickQtyData: PickQtyData; | |||
| selectedLotRowId: string | null; | |||
| selectedLotId: number | null; | |||
| onLotSelection: (uniqueLotId: string, lotId: number) => void; | |||
| onPickQtyChange: (lineId: number, lotId: number, value: number) => void; | |||
| onSubmitPickQty: (lineId: number, lotId: number) => void; | |||
| onCreateStockOutLine: (inventoryLotLineId: number) => void; | |||
| onQcCheck: (line: GetPickOrderLineInfo, pickOrderCode: string) => void; | |||
| onLotSelectForInput: (lot: LotPickData) => void; | |||
| showInputBody: boolean; | |||
| setShowInputBody: (show: boolean) => void; | |||
| selectedLotForInput: LotPickData | null; | |||
| generateInputBody: () => any; | |||
| onDataRefresh: () => Promise<void>; | |||
| } | |||
| // ✅ QR Code Modal Component | |||
| const QrCodeModal: React.FC<{ | |||
| open: boolean; | |||
| onClose: () => void; | |||
| lot: LotPickData | null; | |||
| onQrCodeSubmit: (lotNo: string) => void; | |||
| }> = ({ open, onClose, lot, onQrCodeSubmit }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| const [manualInput, setManualInput] = useState<string>(''); | |||
| // ✅ Add state to track manual input submission | |||
| const [manualInputSubmitted, setManualInputSubmitted] = useState<boolean>(false); | |||
| const [manualInputError, setManualInputError] = useState<boolean>(false); | |||
| // ✅ Process scanned QR codes | |||
| useEffect(() => { | |||
| if (qrValues.length > 0 && lot) { | |||
| const latestQr = qrValues[qrValues.length - 1]; | |||
| const qrContent = latestQr.replace(/[{}]/g, ''); | |||
| if (qrContent === lot.lotNo) { | |||
| onQrCodeSubmit(lot.lotNo); | |||
| onClose(); | |||
| resetScan(); | |||
| } else { | |||
| // ✅ Set error state for helper text | |||
| setManualInputError(true); | |||
| setManualInputSubmitted(true); | |||
| } | |||
| } | |||
| }, [qrValues, lot, onQrCodeSubmit, onClose, resetScan]); | |||
| // ✅ Clear states when modal opens or lot changes | |||
| useEffect(() => { | |||
| if (open) { | |||
| setManualInput(''); | |||
| setManualInputSubmitted(false); | |||
| setManualInputError(false); | |||
| } | |||
| }, [open]); | |||
| useEffect(() => { | |||
| if (lot) { | |||
| setManualInput(''); | |||
| setManualInputSubmitted(false); | |||
| setManualInputError(false); | |||
| } | |||
| }, [lot]); | |||
| {/* | |||
| const handleManualSubmit = () => { | |||
| if (manualInput.trim() === lot?.lotNo) { | |||
| // ✅ Success - no error helper text needed | |||
| onQrCodeSubmit(lot.lotNo); | |||
| onClose(); | |||
| setManualInput(''); | |||
| } else { | |||
| // ✅ Show error helper text after submit | |||
| setManualInputError(true); | |||
| setManualInputSubmitted(true); | |||
| // Don't clear input - let user see what they typed | |||
| } | |||
| }; | |||
| return ( | |||
| <Modal open={open} onClose={onClose}> | |||
| <Box sx={{ | |||
| position: 'absolute', | |||
| top: '50%', | |||
| left: '50%', | |||
| transform: 'translate(-50%, -50%)', | |||
| bgcolor: 'background.paper', | |||
| p: 3, | |||
| borderRadius: 2, | |||
| minWidth: 400, | |||
| }}> | |||
| <Typography variant="h6" gutterBottom> | |||
| {t("QR Code Scan for Lot")}: {lot?.lotNo} | |||
| </Typography> | |||
| <Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}> | |||
| <Typography variant="body2" gutterBottom> | |||
| <strong>Scanner Status:</strong> {isScanning ? 'Scanning...' : 'Ready'} | |||
| </Typography> | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| variant="contained" | |||
| onClick={isScanning ? stopScan : startScan} | |||
| size="small" | |||
| > | |||
| {isScanning ? 'Stop Scan' : 'Start Scan'} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={resetScan} | |||
| size="small" | |||
| > | |||
| Reset | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="body2" gutterBottom> | |||
| <strong>Manual Input:</strong> | |||
| </Typography> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| value={manualInput} | |||
| onChange={(e) => setManualInput(e.target.value)} | |||
| sx={{ mb: 1 }} | |||
| // ✅ Only show error after submit button is clicked | |||
| error={manualInputSubmitted && manualInputError} | |||
| helperText={ | |||
| // ✅ Show helper text only after submit with error | |||
| manualInputSubmitted && manualInputError | |||
| ? `The input is not the same as the expected lot number. Expected: ${lot?.lotNo}` | |||
| : '' | |||
| } | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleManualSubmit} | |||
| disabled={!manualInput.trim()} | |||
| size="small" | |||
| color="primary" | |||
| > | |||
| Submit Manual Input | |||
| </Button> | |||
| </Box> | |||
| {qrValues.length > 0 && ( | |||
| <Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}> | |||
| <Typography variant="body2" color={manualInputError ? 'error' : 'success'}> | |||
| <strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]} | |||
| </Typography> | |||
| {manualInputError && ( | |||
| <Typography variant="caption" color="error" display="block"> | |||
| ❌ Mismatch! Expected: {lot?.lotNo} | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| )} | |||
| <Box sx={{ mt: 2, textAlign: 'right' }}> | |||
| <Button onClick={onClose} variant="outlined"> | |||
| Cancel | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| */} | |||
| useEffect(() => { | |||
| if (manualInput.trim() === lot?.lotNo && manualInput.trim() !== '') { | |||
| // Auto-submit when manual input matches the expected lot number | |||
| console.log('🔄 Auto-submitting manual input:', manualInput.trim()); | |||
| // Add a small delay to ensure proper execution order | |||
| const timer = setTimeout(() => { | |||
| onQrCodeSubmit(lot.lotNo); | |||
| onClose(); | |||
| setManualInput(''); | |||
| setManualInputError(false); | |||
| setManualInputSubmitted(false); | |||
| }, 200); // 200ms delay | |||
| return () => clearTimeout(timer); | |||
| } | |||
| }, [manualInput, lot, onQrCodeSubmit, onClose]); | |||
| const handleManualSubmit = () => { | |||
| if (manualInput.trim() === lot?.lotNo) { | |||
| // ✅ Success - no error helper text needed | |||
| onQrCodeSubmit(lot.lotNo); | |||
| onClose(); | |||
| setManualInput(''); | |||
| } else { | |||
| // ✅ Show error helper text after submit | |||
| setManualInputError(true); | |||
| setManualInputSubmitted(true); | |||
| // Don't clear input - let user see what they typed | |||
| } | |||
| }; | |||
| useEffect(() => { | |||
| if (open) { | |||
| startScan(); | |||
| } | |||
| }, [open, startScan]); | |||
| return ( | |||
| <Modal open={open} onClose={onClose}> | |||
| <Box sx={{ | |||
| position: 'absolute', | |||
| top: '50%', | |||
| left: '50%', | |||
| transform: 'translate(-50%, -50%)', | |||
| bgcolor: 'background.paper', | |||
| p: 3, | |||
| borderRadius: 2, | |||
| minWidth: 400, | |||
| }}> | |||
| <Typography variant="h6" gutterBottom> | |||
| QR Code Scan for Lot: {lot?.lotNo} | |||
| </Typography> | |||
| {/* Manual Input with Submit-Triggered Helper Text */} | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="body2" gutterBottom> | |||
| <strong>Manual Input:</strong> | |||
| </Typography> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| value={manualInput} | |||
| onChange={(e) => setManualInput(e.target.value)} | |||
| sx={{ mb: 1 }} | |||
| error={manualInputSubmitted && manualInputError} | |||
| helperText={ | |||
| manualInputSubmitted && manualInputError | |||
| ? `The input is not the same as the expected lot number.` | |||
| : '' | |||
| } | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| onClick={handleManualSubmit} | |||
| disabled={!manualInput.trim()} | |||
| size="small" | |||
| color="primary" | |||
| > | |||
| Submit Manual Input | |||
| </Button> | |||
| </Box> | |||
| {/* Show QR Scan Status */} | |||
| {qrValues.length > 0 && ( | |||
| <Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}> | |||
| <Typography variant="body2" color={manualInputError ? 'error' : 'success'}> | |||
| <strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]} | |||
| </Typography> | |||
| {manualInputError && ( | |||
| <Typography variant="caption" color="error" display="block"> | |||
| ❌ Mismatch! Expected! | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| )} | |||
| <Box sx={{ mt: 2, textAlign: 'right' }}> | |||
| <Button onClick={onClose} variant="outlined"> | |||
| Cancel | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| </Modal> | |||
| ); | |||
| }; | |||
| const LotTable: React.FC<LotTableProps> = ({ | |||
| lotData, | |||
| selectedRowId, | |||
| selectedRow, | |||
| pickQtyData, | |||
| selectedLotRowId, | |||
| selectedLotId, | |||
| onLotSelection, | |||
| onPickQtyChange, | |||
| onSubmitPickQty, | |||
| onCreateStockOutLine, | |||
| onQcCheck, | |||
| onLotSelectForInput, | |||
| showInputBody, | |||
| setShowInputBody, | |||
| selectedLotForInput, | |||
| generateInputBody, | |||
| onDataRefresh, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // ✅ Add QR scanner context | |||
| const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext(); | |||
| // ✅ Add state for QR input modal | |||
| const [qrModalOpen, setQrModalOpen] = useState(false); | |||
| const [selectedLotForQr, setSelectedLotForQr] = useState<LotPickData | null>(null); | |||
| const [manualQrInput, setManualQrInput] = useState<string>(''); | |||
| // 分页控制器 | |||
| const [lotTablePagingController, setLotTablePagingController] = useState({ | |||
| pageNum: 0, | |||
| pageSize: 10, | |||
| }); | |||
| // ✅ 添加状态消息生成函数 | |||
| const getStatusMessage = useCallback((lot: LotPickData) => { | |||
| if (!lot.stockOutLineId) { | |||
| return t("Please finish QR code scan, QC check and pick order."); | |||
| } | |||
| switch (lot.stockOutLineStatus?.toLowerCase()) { | |||
| case 'pending': | |||
| return t("Please finish QC check and pick order."); | |||
| case 'checked': | |||
| return t("Please submit the pick order."); | |||
| case 'partially_completed': | |||
| return t("Partial quantity submitted. Please submit more or complete the order.") ; | |||
| case 'completed': | |||
| return t("Pick order completed successfully!"); | |||
| case 'rejected': | |||
| return t("QC check failed. Lot has been rejected and marked as unavailable."); | |||
| case 'unavailable': | |||
| return t("This order is insufficient, please pick another lot."); | |||
| default: | |||
| return t("Please finish QR code scan, QC check and pick order."); | |||
| } | |||
| }, []); | |||
| const prepareLotTableData = useMemo(() => { | |||
| return lotData.map((lot) => ({ | |||
| ...lot, | |||
| id: lot.lotId, | |||
| })); | |||
| }, [lotData]); | |||
| // 分页数据 | |||
| const paginatedLotTableData = useMemo(() => { | |||
| const startIndex = lotTablePagingController.pageNum * lotTablePagingController.pageSize; | |||
| const endIndex = startIndex + lotTablePagingController.pageSize; | |||
| return prepareLotTableData.slice(startIndex, endIndex); | |||
| }, [prepareLotTableData, lotTablePagingController]); | |||
| // 分页处理函数 | |||
| const handleLotTablePageChange = useCallback((event: unknown, newPage: number) => { | |||
| setLotTablePagingController(prev => ({ | |||
| ...prev, | |||
| pageNum: newPage, | |||
| })); | |||
| }, []); | |||
| const handleLotTablePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| setLotTablePagingController({ | |||
| pageNum: 0, | |||
| pageSize: newPageSize, | |||
| }); | |||
| }, []); | |||
| // ✅ Handle QR code submission | |||
| const handleQrCodeSubmit = useCallback(async (lotNo: string) => { | |||
| if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) { | |||
| console.log(`✅ QR Code verified for lot: ${lotNo}`); | |||
| // ✅ Store the required quantity before creating stock out line | |||
| const requiredQty = selectedLotForQr.requiredQty; | |||
| const lotId = selectedLotForQr.lotId; | |||
| // ✅ Create stock out line and wait for it to complete | |||
| await onCreateStockOutLine(selectedLotForQr.lotId); | |||
| // ✅ Close modal | |||
| setQrModalOpen(false); | |||
| setSelectedLotForQr(null); | |||
| // ✅ Set pick quantity AFTER stock out line creation and refresh is complete | |||
| if (selectedRowId) { | |||
| // Add a small delay to ensure the data refresh from onCreateStockOutLine is complete | |||
| setTimeout(() => { | |||
| onPickQtyChange(selectedRowId, lotId, requiredQty); | |||
| console.log(`✅ Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`); | |||
| }, 500); // 500ms delay to ensure refresh is complete | |||
| } | |||
| // ✅ Show success message | |||
| console.log("Stock out line created successfully!"); | |||
| } | |||
| }, [selectedLotForQr, onCreateStockOutLine, selectedRowId, onPickQtyChange]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Lot#")}</TableCell> | |||
| <TableCell>{t("Lot Expiry Date")}</TableCell> | |||
| <TableCell>{t("Lot Location")}</TableCell> | |||
| <TableCell align="right">{t("Available Lot")}</TableCell> | |||
| <TableCell align="right">{t("Lot Required Pick Qty")}</TableCell> | |||
| <TableCell>{t("Stock Unit")}</TableCell> | |||
| <TableCell align="center">{t("QR Code Scan")}</TableCell> | |||
| <TableCell align="center">{t("QC Check")}</TableCell> | |||
| <TableCell align="right">{t("Lot Actual Pick Qty")}</TableCell> | |||
| <TableCell align="center">{t("Submit")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedLotTableData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={11} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedLotTableData.map((lot, index) => ( | |||
| <TableRow key={lot.id}> | |||
| <TableCell> | |||
| <Checkbox | |||
| checked={selectedLotRowId === `row_${index}`} | |||
| onChange={() => onLotSelection(`row_${index}`, lot.lotId)} | |||
| // ✅ Allow selection of available AND insufficient_stock lots | |||
| //disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||
| value={`row_${index}`} | |||
| name="lot-selection" | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Box> | |||
| <Typography>{lot.lotNo}</Typography> | |||
| {lot.lotAvailability !== 'available' && ( | |||
| <Typography variant="caption" color="error" display="block"> | |||
| ({lot.lotAvailability === 'expired' ? 'Expired' : | |||
| lot.lotAvailability === 'insufficient_stock' ? 'Insufficient' : | |||
| 'Unavailable'}) | |||
| </Typography> | |||
| )} | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell>{lot.expiryDate}</TableCell> | |||
| <TableCell>{lot.location}</TableCell> | |||
| <TableCell align="right">{lot.availableQty.toLocaleString()}</TableCell> | |||
| <TableCell align="right">{lot.requiredQty.toLocaleString()}</TableCell> | |||
| <TableCell>{lot.stockUnit}</TableCell> | |||
| {/* QR Code Scan Button */} | |||
| <TableCell align="center"> | |||
| <Box sx={{ textAlign: 'center' }}> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => { | |||
| setSelectedLotForQr(lot); | |||
| setQrModalOpen(true); | |||
| resetScan(); | |||
| }} | |||
| // ✅ Disable when: | |||
| // 1. Lot is expired or unavailable | |||
| // 2. Already scanned (has stockOutLineId) | |||
| // 3. Not selected (selectedLotRowId doesn't match) | |||
| disabled={ | |||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||
| Boolean(lot.stockOutLineId) || | |||
| selectedLotRowId !== `row_${index}` | |||
| } | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| whiteSpace: 'nowrap', | |||
| minWidth: '40px', | |||
| // ✅ Visual feedback | |||
| opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5 | |||
| }} | |||
| startIcon={<QrCodeIcon />} | |||
| title={ | |||
| selectedLotRowId !== `row_${index}` | |||
| ? "Please select this lot first to enable QR scanning" | |||
| : lot.stockOutLineId | |||
| ? "Already scanned" | |||
| : "Click to scan QR code" | |||
| } | |||
| > | |||
| {lot.stockOutLineId ? t("Scanned") : t("Scan")} | |||
| </Button> | |||
| </Box> | |||
| </TableCell> | |||
| {/* QC Check Button */} | |||
| {/* | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => { | |||
| if (selectedRowId && selectedRow) { | |||
| onQcCheck(selectedRow, selectedRow.pickOrderCode); | |||
| } | |||
| }} | |||
| // ✅ Enable QC check only when stock out line exists | |||
| disabled={!lot.stockOutLineId || selectedLotRowId !== `row_${index}`} | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| whiteSpace: 'nowrap', | |||
| minWidth: '40px' | |||
| }} | |||
| > | |||
| {t("QC")} | |||
| </Button> | |||
| */} | |||
| {/* Lot Actual Pick Qty */} | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| value={selectedRowId ? (pickQtyData[selectedRowId]?.[lot.lotId] || '') : ''} // ✅ Fixed: Use empty string instead of 0 | |||
| onChange={(e) => { | |||
| if (selectedRowId) { | |||
| const inputValue = e.target.value; | |||
| // ✅ Fixed: Handle empty string and prevent leading zeros | |||
| if (inputValue === '') { | |||
| // Allow empty input (user can backspace to clear) | |||
| onPickQtyChange(selectedRowId, lot.lotId, 0); | |||
| } else { | |||
| // Parse the number and prevent leading zeros | |||
| const numValue = parseInt(inputValue, 10); | |||
| if (!isNaN(numValue)) { | |||
| onPickQtyChange(selectedRowId, lot.lotId, numValue); | |||
| } | |||
| } | |||
| } | |||
| }} | |||
| onBlur={(e) => { | |||
| // ✅ Fixed: When input loses focus, ensure we have a valid number | |||
| if (selectedRowId) { | |||
| const currentValue = pickQtyData[selectedRowId]?.[lot.lotId]; | |||
| if (currentValue === undefined || currentValue === null) { | |||
| // Set to 0 if no value | |||
| onPickQtyChange(selectedRowId, lot.lotId, 0); | |||
| } | |||
| } | |||
| }} | |||
| inputProps={{ | |||
| min: 0, | |||
| max: lot.availableQty, | |||
| step: 1 // Allow only whole numbers | |||
| }} | |||
| // ✅ Allow input for available AND insufficient_stock lots | |||
| disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||
| sx={{ width: '80px' }} | |||
| placeholder="0" // Show placeholder instead of default value | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={async () => { | |||
| if (selectedRowId && selectedRow && lot.stockOutLineId) { | |||
| try { | |||
| // ✅ Call updateStockOutLineStatus to reject the stock out line | |||
| await updateStockOutLineStatus({ | |||
| id: lot.stockOutLineId, | |||
| status: 'rejected', | |||
| qty: 0 | |||
| }); | |||
| // ✅ Refresh data after rejection | |||
| if (onDataRefresh) { | |||
| await onDataRefresh(); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error rejecting lot:", error); | |||
| } | |||
| } | |||
| }} | |||
| // ✅ Only enable if stock out line exists | |||
| disabled={!lot.stockOutLineId} | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| whiteSpace: 'nowrap', | |||
| minWidth: '40px' | |||
| }} | |||
| > | |||
| {t("Reject")} | |||
| </Button> | |||
| </TableCell> | |||
| {/* Submit Button */} | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => { | |||
| if (selectedRowId) { | |||
| onSubmitPickQty(selectedRowId, lot.lotId); | |||
| } | |||
| }} | |||
| disabled={ | |||
| (lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || | |||
| !pickQtyData[selectedRowId!]?.[lot.lotId] || | |||
| !lot.stockOutLineStatus || // Must have stock out line | |||
| !['pending','checked', 'partially_completed'].includes(lot.stockOutLineStatus.toLowerCase()) // Only these statuses | |||
| } | |||
| // ✅ Allow submission for available AND insufficient_stock lots | |||
| sx={{ | |||
| fontSize: '0.75rem', | |||
| py: 0.5, | |||
| minHeight: '28px' | |||
| }} | |||
| > | |||
| {t("Submit")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| {/* ✅ Status Messages Display */} | |||
| {paginatedLotTableData.length > 0 && ( | |||
| <Box sx={{ mt: 2, p: 2, backgroundColor: 'grey.50', borderRadius: 1 }}> | |||
| {paginatedLotTableData.map((lot, index) => ( | |||
| <Box key={lot.id} sx={{ mb: 1 }}> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| <strong>Lot {lot.lotNo}:</strong> {getStatusMessage(lot)} | |||
| </Typography> | |||
| </Box> | |||
| ))} | |||
| </Box> | |||
| )} | |||
| <TablePagination | |||
| component="div" | |||
| count={prepareLotTableData.length} | |||
| page={lotTablePagingController.pageNum} | |||
| rowsPerPage={lotTablePagingController.pageSize} | |||
| onPageChange={handleLotTablePageChange} | |||
| onRowsPerPageChange={handleLotTablePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| {/* ✅ QR Code Modal */} | |||
| <QrCodeModal | |||
| open={qrModalOpen} | |||
| onClose={() => { | |||
| setQrModalOpen(false); | |||
| setSelectedLotForQr(null); | |||
| stopScan(); | |||
| resetScan(); | |||
| }} | |||
| lot={selectedLotForQr} | |||
| onQrCodeSubmit={handleQrCodeSubmit} | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default LotTable; | |||
| @@ -0,0 +1,288 @@ | |||
| "use client"; | |||
| // 修改为 PickOrder 相关的导入 | |||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Grid, | |||
| Modal, | |||
| ModalProps, | |||
| Stack, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| } from "@mui/material"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; | |||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { dummyQCData, QcData } from "../PoDetail/dummyQcTemplate"; | |||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| display: "block", | |||
| width: { xs: "60%", sm: "60%", md: "60%" }, | |||
| }; | |||
| // 修改接口定义 | |||
| interface CommonProps extends Omit<ModalProps, "children"> { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| setItemDetail: Dispatch< | |||
| SetStateAction< | |||
| | (GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| warehouseId?: number; | |||
| }) | |||
| | undefined | |||
| > | |||
| >; | |||
| qc?: QcItemWithChecks[]; | |||
| warehouse?: any[]; | |||
| } | |||
| interface Props extends CommonProps { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| } | |||
| // 修改组件名称 | |||
| const PickQcStockInModalVer2: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| itemDetail, | |||
| setItemDetail, | |||
| qc, | |||
| warehouse, | |||
| }) => { | |||
| console.log(warehouse); | |||
| // 修改翻译键 | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("pickOrder"); | |||
| const [qcItems, setQcItems] = useState(dummyQCData) | |||
| const formProps = useForm<any>({ | |||
| defaultValues: { | |||
| ...itemDetail, | |||
| }, | |||
| }); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| onClose?.(...args); | |||
| }, | |||
| [onClose], | |||
| ); | |||
| // QC submission handler | |||
| const onSubmitQc = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| console.log("QC Submission:", event!.nativeEvent); | |||
| // Get QC data from the shared form context | |||
| const qcAccept = data.qcAccept; | |||
| const acceptQty = data.acceptQty; | |||
| // Validate QC data | |||
| const validationErrors : string[] = []; | |||
| // Check if all QC items have results | |||
| const itemsWithoutResult = qcItems.filter(item => item.isPassed === undefined); | |||
| if (itemsWithoutResult.length > 0) { | |||
| validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.qcItem).join(', ')}`); | |||
| } | |||
| // Check if failed items have failed quantity | |||
| const failedItemsWithoutQty = qcItems.filter(item => | |||
| item.isPassed === false && (!item.failedQty || item.failedQty <= 0) | |||
| ); | |||
| if (failedItemsWithoutQty.length > 0) { | |||
| validationErrors.push(`${t("Failed items must have failed quantity")}: ${failedItemsWithoutQty.map(item => item.qcItem).join(', ')}`); | |||
| } | |||
| // Check if accept quantity is valid | |||
| if (acceptQty === undefined || acceptQty <= 0) { | |||
| validationErrors.push("Accept quantity must be greater than 0"); | |||
| } | |||
| if (validationErrors.length > 0) { | |||
| console.error("QC Validation failed:", validationErrors); | |||
| alert(`未完成品檢: ${validationErrors}`); | |||
| return; | |||
| } | |||
| const qcData = { | |||
| qcAccept: qcAccept, | |||
| acceptQty: acceptQty, | |||
| qcItems: qcItems.map(item => ({ | |||
| id: item.id, | |||
| qcItem: item.qcItem, | |||
| qcDescription: item.qcDescription, | |||
| isPassed: item.isPassed, | |||
| failedQty: (item.failedQty && !item.isPassed) || 0, | |||
| remarks: item.remarks || '' | |||
| })) | |||
| }; | |||
| console.log("QC Data for submission:", qcData); | |||
| // await submitQcData(qcData); | |||
| if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) { | |||
| submitDialogWithWarning(() => { | |||
| console.log("QC accepted with failed items"); | |||
| onClose(); | |||
| }, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""}); | |||
| return; | |||
| } | |||
| if (qcData.qcAccept) { | |||
| console.log("QC accepted"); | |||
| onClose(); | |||
| } else { | |||
| console.log("QC rejected"); | |||
| onClose(); | |||
| } | |||
| }, | |||
| [qcItems, onClose, t], | |||
| ); | |||
| const handleQcItemChange = useCallback((index: number, field: keyof QcData, value: any) => { | |||
| setQcItems(prev => prev.map((item, i) => | |||
| i === index ? { ...item, [field]: value } : item | |||
| )); | |||
| }, []); | |||
| return ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| <Modal open={open} onClose={closeHandler}> | |||
| <Box | |||
| sx={{ | |||
| ...style, | |||
| padding: 2, | |||
| maxHeight: "90vh", | |||
| overflowY: "auto", | |||
| marginLeft: 3, | |||
| marginRight: 3, | |||
| }} | |||
| > | |||
| <Grid container justifyContent="flex-start" alignItems="flex-start"> | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| GroupA - {itemDetail.pickOrderCode} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary" marginBlockEnd={2}> | |||
| 記錄探測溫度的時間,請在1小時內完成出庫,以保障食品安全 監察方法、日闸檢查、嗅覺檢查和使用適當的食物温度計椒鱼食物溫度是否符合指標 | |||
| </Typography> | |||
| </Grid> | |||
| {/* QC 表格 */} | |||
| <Grid item xs={12}> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>QC模板代號</TableCell> | |||
| <TableCell>檢查項目</TableCell> | |||
| <TableCell>QC Result</TableCell> | |||
| <TableCell>Failed Qty</TableCell> | |||
| <TableCell>Remarks</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {qcItems.map((item, index) => ( | |||
| <TableRow key={item.id}> | |||
| <TableCell>{item.id}</TableCell> | |||
| <TableCell>{item.qcDescription}</TableCell> | |||
| <TableCell> | |||
| <select | |||
| value={item.isPassed === undefined ? '' : item.isPassed ? 'pass' : 'fail'} | |||
| onChange={(e) => handleQcItemChange(index, 'isPassed', e.target.value === 'pass')} | |||
| > | |||
| <option value="">Select</option> | |||
| <option value="pass">Pass</option> | |||
| <option value="fail">Fail</option> | |||
| </select> | |||
| </TableCell> | |||
| <TableCell> | |||
| <input | |||
| type="number" | |||
| value={item.failedQty || 0} | |||
| onChange={(e) => handleQcItemChange(index, 'failedQty', parseInt(e.target.value) || 0)} | |||
| disabled={item.isPassed !== false} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <input | |||
| type="text" | |||
| value={item.remarks || ''} | |||
| onChange={(e) => handleQcItemChange(index, 'remarks', e.target.value)} | |||
| /> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Grid> | |||
| {/* 按钮 */} | |||
| <Grid item xs={12} sx={{ mt: 2 }}> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| variant="contained" | |||
| color="success" | |||
| onClick={formProps.handleSubmit(onSubmitQc)} | |||
| > | |||
| QC Accept | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="warning" | |||
| onClick={() => { | |||
| console.log("Sort to accept"); | |||
| onClose(); | |||
| }} | |||
| > | |||
| Sort to Accept | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| onClick={() => { | |||
| console.log("Reject and pick another lot"); | |||
| onClose(); | |||
| }} | |||
| > | |||
| Reject and Pick Another Lot | |||
| </Button> | |||
| </Stack> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </Modal> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PickQcStockInModalVer2; | |||
| @@ -0,0 +1,683 @@ | |||
| "use client"; | |||
| import { GetPickOrderLineInfo, updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Grid, | |||
| Modal, | |||
| ModalProps, | |||
| Stack, | |||
| Typography, | |||
| TextField, | |||
| Radio, | |||
| RadioGroup, | |||
| FormControlLabel, | |||
| FormControl, | |||
| Tab, | |||
| Tabs, | |||
| TabsProps, | |||
| Paper, | |||
| } from "@mui/material"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { dummyQCData } from "../PoDetail/dummyQcTemplate"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { GridColDef } from "@mui/x-data-grid"; | |||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||
| import EscalationLogTable from "../DashboardPage/escalation/EscalationLogTable"; | |||
| import EscalationComponent from "../PoDetail/EscalationComponent"; | |||
| import { fetchPickOrderQcResult, savePickOrderQcResult } from "@/app/api/qc/actions"; | |||
| import { | |||
| updateInventoryLotLineStatus | |||
| } from "@/app/api/inventory/actions"; // ✅ 导入新的 API | |||
| import { dayjsToInputDateString } from "@/app/utils/formatUtil"; | |||
| import dayjs from "dayjs"; | |||
| // Define QcData interface locally | |||
| interface ExtendedQcItem extends QcItemWithChecks { | |||
| qcPassed?: boolean; | |||
| failQty?: number; | |||
| remarks?: string; | |||
| order?: number; // ✅ Add order property | |||
| stableId?: string; // ✅ Also add stableId for better row identification | |||
| } | |||
| interface Props extends CommonProps { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| qcItems: ExtendedQcItem[]; | |||
| setQcItems: Dispatch<SetStateAction<ExtendedQcItem[]>>; | |||
| selectedLotId?: number; | |||
| onStockOutLineUpdate?: () => void; | |||
| lotData: LotPickData[]; | |||
| // ✅ Add missing props | |||
| pickQtyData?: PickQtyData; | |||
| selectedRowId?: number; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| display: "block", | |||
| width: { xs: "80%", sm: "80%", md: "80%" }, | |||
| maxHeight: "90vh", | |||
| overflowY: "auto", | |||
| }; | |||
| interface PickQtyData { | |||
| [lineId: number]: { | |||
| [lotId: number]: number; | |||
| }; | |||
| } | |||
| interface CommonProps extends Omit<ModalProps, "children"> { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| setItemDetail: Dispatch< | |||
| SetStateAction< | |||
| | (GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| warehouseId?: number; | |||
| }) | |||
| | undefined | |||
| > | |||
| >; | |||
| qc?: QcItemWithChecks[]; | |||
| warehouse?: any[]; | |||
| } | |||
| interface Props extends CommonProps { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| qcItems: ExtendedQcItem[]; // Change to ExtendedQcItem | |||
| setQcItems: Dispatch<SetStateAction<ExtendedQcItem[]>>; // Change to ExtendedQcItem | |||
| // ✅ Add props for stock out line update | |||
| selectedLotId?: number; | |||
| onStockOutLineUpdate?: () => void; | |||
| lotData: LotPickData[]; | |||
| } | |||
| interface LotPickData { | |||
| id: number; | |||
| lotId: number; | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| location: string; | |||
| stockUnit: string; | |||
| availableQty: number; | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| } | |||
| const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| itemDetail, | |||
| setItemDetail, | |||
| qc, | |||
| warehouse, | |||
| qcItems, | |||
| setQcItems, | |||
| selectedLotId, | |||
| onStockOutLineUpdate, | |||
| lotData, | |||
| pickQtyData, | |||
| selectedRowId, | |||
| }) => { | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("pickOrder"); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| //const [qcItems, setQcItems] = useState<QcData[]>(dummyQCData); | |||
| const [isCollapsed, setIsCollapsed] = useState<boolean>(true); | |||
| const [isSubmitting, setIsSubmitting] = useState(false); | |||
| const [feedbackMessage, setFeedbackMessage] = useState<string>(""); | |||
| // Add state to store submitted data | |||
| const [submittedData, setSubmittedData] = useState<any[]>([]); | |||
| const formProps = useForm<any>({ | |||
| defaultValues: { | |||
| qcAccept: true, | |||
| acceptQty: null, | |||
| qcDecision: "1", // Default to accept | |||
| ...itemDetail, | |||
| }, | |||
| }); | |||
| const { control, register, formState: { errors }, watch, setValue } = formProps; | |||
| const qcDecision = watch("qcDecision"); | |||
| const accQty = watch("acceptQty"); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| onClose?.(...args); | |||
| }, | |||
| [onClose], | |||
| ); | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| }, | |||
| [], | |||
| ); | |||
| // Save failed QC results only | |||
| const saveQcResults = async (qcData: any) => { | |||
| try { | |||
| const qcResults = qcData.qcItems | |||
| .map((item: any) => ({ | |||
| qcItemId: item.id, | |||
| itemId: itemDetail.itemId, | |||
| stockInLineId: null, | |||
| stockOutLineId: 1, // Fixed to 1 as requested | |||
| failQty: item.isPassed ? 0 : (item.failQty || 0), // 0 for passed, actual qty for failed | |||
| type: "pick_order_qc", | |||
| remarks: item.remarks || "", | |||
| qcPassed: item.isPassed, // ✅ This will now be included | |||
| })); | |||
| // Store the submitted data for debug display | |||
| setSubmittedData(qcResults); | |||
| console.log("Saving QC results:", qcResults); | |||
| // Use the corrected API function instead of manual fetch | |||
| for (const qcResult of qcResults) { | |||
| const response = await savePickOrderQcResult(qcResult); | |||
| console.log("QC Result save success:", response); | |||
| // Check if the response indicates success | |||
| if (!response.id) { | |||
| throw new Error(`Failed to save QC result: ${response.message || 'Unknown error'}`); | |||
| } | |||
| } | |||
| return true; | |||
| } catch (error) { | |||
| console.error("Error saving QC results:", error); | |||
| return false; | |||
| } | |||
| }; | |||
| // ✅ 修改:在组件开始时自动设置失败数量 | |||
| useEffect(() => { | |||
| if (itemDetail && qcItems.length > 0 && selectedLotId) { | |||
| // ✅ 获取选中的批次数据 | |||
| const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId); | |||
| if (selectedLot) { | |||
| // ✅ 自动将 Lot Required Pick Qty 设置为所有失败项目的 failQty | |||
| const updatedQcItems = qcItems.map((item, index) => ({ | |||
| ...item, | |||
| failQty: selectedLot.requiredQty || 0, // 使用 Lot Required Pick Qty | |||
| // ✅ Add stable order and ID fields | |||
| order: index, | |||
| stableId: `qc-${item.id}-${index}` | |||
| })); | |||
| setQcItems(updatedQcItems); | |||
| } | |||
| } | |||
| }, [itemDetail, qcItems.length, selectedLotId, lotData]); | |||
| // ✅ Add this helper function at the top of the component | |||
| const safeClose = useCallback(() => { | |||
| if (onClose) { | |||
| // Create a mock event object that satisfies the Modal onClose signature | |||
| const mockEvent = { | |||
| target: null, | |||
| currentTarget: null, | |||
| type: 'close', | |||
| preventDefault: () => {}, | |||
| stopPropagation: () => {}, | |||
| bubbles: false, | |||
| cancelable: false, | |||
| defaultPrevented: false, | |||
| isTrusted: false, | |||
| timeStamp: Date.now(), | |||
| nativeEvent: null, | |||
| isDefaultPrevented: () => false, | |||
| isPropagationStopped: () => false, | |||
| persist: () => {}, | |||
| eventPhase: 0, | |||
| isPersistent: () => false | |||
| } as any; | |||
| // ✅ Fixed: Pass both event and reason parameters | |||
| onClose(mockEvent, 'escapeKeyDown'); // 'escapeKeyDown' is a valid reason | |||
| } | |||
| }, [onClose]); | |||
| // ✅ 修改:移除 alert 弹窗,改为控制台日志 | |||
| const onSubmitQc = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| setIsSubmitting(true); | |||
| try { | |||
| const qcAccept = qcDecision === "1"; | |||
| const acceptQty = Number(accQty) || null; | |||
| const validationErrors : string[] = []; | |||
| const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId); | |||
| // ✅ Add safety check for selectedLot | |||
| if (!selectedLot) { | |||
| console.error("Selected lot not found"); | |||
| return; | |||
| } | |||
| const itemsWithoutResult = qcItems.filter(item => item.qcPassed === undefined); | |||
| if (itemsWithoutResult.length > 0) { | |||
| validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.code).join(", ")}`); | |||
| } | |||
| if (validationErrors.length > 0) { | |||
| console.error(`QC validation failed: ${validationErrors.join(", ")}`); | |||
| return; | |||
| } | |||
| const qcData = { | |||
| qcAccept, | |||
| acceptQty, | |||
| qcItems: qcItems.map(item => ({ | |||
| id: item.id, | |||
| qcItem: item.code, | |||
| qcDescription: item.description || "", | |||
| isPassed: item.qcPassed, | |||
| failQty: item.qcPassed ? 0 : (selectedLot.requiredQty || 0), | |||
| remarks: item.remarks || "", | |||
| })), | |||
| }; | |||
| console.log("Submitting QC data:", qcData); | |||
| const saveSuccess = await saveQcResults(qcData); | |||
| if (!saveSuccess) { | |||
| console.error("Failed to save QC results"); | |||
| return; | |||
| } | |||
| // ✅ Handle different QC decisions | |||
| if (selectedLotId) { | |||
| try { | |||
| const allPassed = qcData.qcItems.every(item => item.isPassed); | |||
| if (qcDecision === "2") { | |||
| // ✅ QC Decision 2: Report and Re-pick | |||
| console.log("QC Decision 2 - Report and Re-pick: Rejecting lot and marking as unavailable"); | |||
| // ✅ Inventory lot line status: unavailable | |||
| if (selectedLot) { | |||
| try { | |||
| console.log("=== DEBUG: Updating inventory lot line status ==="); | |||
| console.log("Selected lot:", selectedLot); | |||
| console.log("Selected lot ID:", selectedLotId); | |||
| // ✅ FIX: Only send the fields that the backend expects | |||
| const updateData = { | |||
| inventoryLotLineId: selectedLot.lotId, | |||
| status: 'unavailable' | |||
| // ❌ Remove qty and operation - backend doesn't expect these | |||
| }; | |||
| console.log("Update data:", updateData); | |||
| const result = await updateInventoryLotLineStatus(updateData); | |||
| console.log("✅ Inventory lot line status updated successfully:", result); | |||
| } catch (error) { | |||
| console.error("❌ Error updating inventory lot line status:", error); | |||
| console.error("Error details:", { | |||
| selectedLot, | |||
| selectedLotId, | |||
| acceptQty | |||
| }); | |||
| // Show user-friendly error message | |||
| return; // Stop execution if this fails | |||
| } | |||
| } else { | |||
| console.error("❌ Selected lot not found for inventory update"); | |||
| alert("Selected lot not found. Cannot update inventory status."); | |||
| return; | |||
| } | |||
| // ✅ Close modal and refresh data | |||
| safeClose(); // ✅ Fixed: Use safe close function with both parameters | |||
| if (onStockOutLineUpdate) { | |||
| onStockOutLineUpdate(); | |||
| } | |||
| } else if (qcDecision === "1") { | |||
| // ✅ QC Decision 1: Accept | |||
| console.log("QC Decision 1 - Accept: QC passed"); | |||
| // ✅ Stock out line status: checked (QC completed) | |||
| await updateStockOutLineStatus({ | |||
| id: selectedLotId, | |||
| status: 'checked', | |||
| qty: acceptQty || 0 | |||
| }); | |||
| // ✅ Inventory lot line status: NO CHANGE needed | |||
| // Keep the existing status from handleSubmitPickQty | |||
| // ✅ Close modal and refresh data | |||
| safeClose(); // ✅ Fixed: Use safe close function with both parameters | |||
| if (onStockOutLineUpdate) { | |||
| onStockOutLineUpdate(); | |||
| } | |||
| } | |||
| console.log("Stock out line status updated successfully after QC"); | |||
| // Call callback to refresh data | |||
| if (onStockOutLineUpdate) { | |||
| onStockOutLineUpdate(); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error updating stock out line status after QC:", error); | |||
| } | |||
| } | |||
| console.log("QC results saved successfully!"); | |||
| // ✅ Show warning dialog for failed QC items when accepting | |||
| if (qcDecision === "1" && !qcData.qcItems.every((q) => q.isPassed)) { | |||
| submitDialogWithWarning(() => { | |||
| closeHandler?.({}, 'escapeKeyDown'); | |||
| }, t, {title:"有不合格檢查項目,確認接受出庫?", confirmButtonText: "Confirm", html: ""}); | |||
| return; | |||
| } | |||
| closeHandler?.({}, 'escapeKeyDown'); | |||
| } catch (error) { | |||
| console.error("Error in QC submission:", error); | |||
| if (error instanceof Error) { | |||
| console.error("Error details:", error.message); | |||
| console.error("Error stack:", error.stack); | |||
| } | |||
| } finally { | |||
| setIsSubmitting(false); | |||
| } | |||
| }, | |||
| [qcItems, closeHandler, t, itemDetail, qcDecision, accQty, selectedLotId, onStockOutLineUpdate, lotData, pickQtyData, selectedRowId], | |||
| ); | |||
| // DataGrid columns (QcComponent style) | |||
| const qcColumns: GridColDef[] = useMemo( | |||
| () => [ | |||
| { | |||
| field: "code", | |||
| headerName: t("qcItem"), | |||
| flex: 2, | |||
| renderCell: (params) => ( | |||
| <Box> | |||
| <b>{`${params.api.getRowIndexRelativeToVisibleRows(params.id) + 1}. ${params.value}`}</b><br/> | |||
| {params.row.name}<br/> | |||
| </Box> | |||
| ), | |||
| }, | |||
| { | |||
| field: "qcPassed", | |||
| headerName: t("qcResult"), | |||
| flex: 1.5, | |||
| renderCell: (params) => { | |||
| const current = params.row; | |||
| return ( | |||
| <FormControl> | |||
| <RadioGroup | |||
| row | |||
| aria-labelledby="qc-result" | |||
| value={current.qcPassed === undefined ? "" : (current.qcPassed ? "true" : "false")} | |||
| onChange={(e) => { | |||
| const value = e.target.value === "true"; | |||
| // ✅ Simple state update | |||
| setQcItems(prev => | |||
| prev.map(item => | |||
| item.id === params.id | |||
| ? { ...item, qcPassed: value } | |||
| : item | |||
| ) | |||
| ); | |||
| }} | |||
| name={`qcPassed-${params.id}`} | |||
| > | |||
| <FormControlLabel | |||
| value="true" | |||
| control={<Radio />} | |||
| label="合格" | |||
| sx={{ | |||
| color: current.qcPassed === true ? "green" : "inherit", | |||
| "& .Mui-checked": {color: "green"} | |||
| }} | |||
| /> | |||
| <FormControlLabel | |||
| value="false" | |||
| control={<Radio />} | |||
| label="不合格" | |||
| sx={{ | |||
| color: current.qcPassed === false ? "red" : "inherit", | |||
| "& .Mui-checked": {color: "red"} | |||
| }} | |||
| /> | |||
| </RadioGroup> | |||
| </FormControl> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| field: "failQty", | |||
| headerName: t("failedQty"), | |||
| flex: 1, | |||
| renderCell: (params) => ( | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| // ✅ 修改:失败项目自动显示 Lot Required Pick Qty | |||
| value={!params.row.qcPassed ? (0) : 0} | |||
| disabled={params.row.qcPassed} | |||
| // ✅ 移除 onChange,因为数量是固定的 | |||
| // onChange={(e) => { | |||
| // const v = e.target.value; | |||
| // const next = v === "" ? undefined : Number(v); | |||
| // if (Number.isNaN(next)) return; | |||
| // setQcItems((prev) => | |||
| // prev.map((r) => (r.id === params.id ? { ...r, failQty: next } : r)) | |||
| // ); | |||
| // }} | |||
| onClick={(e) => e.stopPropagation()} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| onKeyDown={(e) => e.stopPropagation()} | |||
| inputProps={{ min: 0, max: itemDetail?.requiredQty || 0 }} | |||
| sx={{ width: "100%" }} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| field: "remarks", | |||
| headerName: t("remarks"), | |||
| flex: 2, | |||
| renderCell: (params) => ( | |||
| <TextField | |||
| size="small" | |||
| value={params.value ?? ""} | |||
| onChange={(e) => { | |||
| const remarks = e.target.value; | |||
| setQcItems((prev) => | |||
| prev.map((r) => (r.id === params.id ? { ...r, remarks } : r)) | |||
| ); | |||
| }} | |||
| onClick={(e) => e.stopPropagation()} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| onKeyDown={(e) => e.stopPropagation()} | |||
| sx={{ width: "100%" }} | |||
| /> | |||
| ), | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| // ✅ Add stable update function | |||
| const handleQcResultChange = useCallback((itemId: number, qcPassed: boolean) => { | |||
| setQcItems(prevItems => | |||
| prevItems.map(item => | |||
| item.id === itemId | |||
| ? { ...item, qcPassed } | |||
| : item | |||
| ) | |||
| ); | |||
| }, []); | |||
| // ✅ Remove duplicate functions | |||
| const getRowId = useCallback((row: any) => { | |||
| return row.id; // Just use the original ID | |||
| }, []); | |||
| // ✅ Remove complex sorting logic | |||
| // const stableQcItems = useMemo(() => { ... }); // Remove | |||
| // const sortedQcItems = useMemo(() => { ... }); // Remove | |||
| // ✅ Use qcItems directly in DataGrid | |||
| return ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| <Modal open={open} onClose={closeHandler}> | |||
| <Box sx={style}> | |||
| <Grid container justifyContent="flex-start" alignItems="flex-start" spacing={2}> | |||
| <Grid item xs={12}> | |||
| <Tabs | |||
| value={tabIndex} | |||
| onChange={handleTabChange} | |||
| variant="scrollable" | |||
| > | |||
| <Tab label={t("QC Info")} iconPosition="end" /> | |||
| <Tab label={t("Escalation History")} iconPosition="end" /> | |||
| </Tabs> | |||
| </Grid> | |||
| {tabIndex == 0 && ( | |||
| <> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}> | |||
| <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold', color: '#333' }}> | |||
| Group A - 急凍貨類 (QCA1-MEAT01) | |||
| </Typography> | |||
| <Typography variant="subtitle1" sx={{ color: '#666' }}> | |||
| <b>品檢類型</b>:OQC | |||
| </Typography> | |||
| <Typography variant="subtitle2" sx={{ color: '#666' }}> | |||
| 記錄探測溫度的時間,請在1小時内完成出庫盤點,以保障食品安全<br/> | |||
| 監察方法:目視檢查、嗅覺檢查和使用適當的食物溫度計,檢查食物溫度是否符合指標 | |||
| </Typography> | |||
| </Box> | |||
| <StyledDataGrid | |||
| columns={qcColumns} | |||
| rows={qcItems} // ✅ Use qcItems directly | |||
| autoHeight | |||
| getRowId={getRowId} // ✅ Simple row ID function | |||
| /> | |||
| </Grid> | |||
| </> | |||
| )} | |||
| {tabIndex == 1 && ( | |||
| <> | |||
| <Grid item xs={12}> | |||
| <EscalationLogTable items={[]}/> | |||
| </Grid> | |||
| </> | |||
| )} | |||
| <Grid item xs={12}> | |||
| <FormControl> | |||
| <Controller | |||
| name="qcDecision" | |||
| control={control} | |||
| defaultValue="1" | |||
| render={({ field }) => ( | |||
| <RadioGroup | |||
| row | |||
| aria-labelledby="demo-radio-buttons-group-label" | |||
| {...field} | |||
| value={field.value} | |||
| onChange={(e) => { | |||
| const value = e.target.value.toString(); | |||
| if (value != "1" && Boolean(errors.acceptQty)) { | |||
| setValue("acceptQty", itemDetail.requiredQty ?? 0); | |||
| } | |||
| field.onChange(value); | |||
| }} | |||
| > | |||
| <FormControlLabel | |||
| value="1" | |||
| control={<Radio />} | |||
| label={t("Accept Stock Out")} | |||
| /> | |||
| {/* ✅ Combirne options 2 & 3 into one */} | |||
| <FormControlLabel | |||
| value="2" | |||
| control={<Radio />} | |||
| sx={{"& .Mui-checked": {color: "blue"}}} | |||
| label={t("Report and Pick another lot")} | |||
| /> | |||
| </RadioGroup> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| {/* ✅ Show escalation component when QC Decision = 2 (Report and Re-pick) */} | |||
| <Grid item xs={12} sx={{ mt: 2 }}> | |||
| <Stack direction="row" justifyContent="flex-start" gap={1}> | |||
| <Button | |||
| variant="contained" | |||
| onClick={formProps.handleSubmit(onSubmitQc)} | |||
| disabled={isSubmitting} | |||
| sx={{ whiteSpace: 'nowrap' }} | |||
| > | |||
| {isSubmitting ? "Submitting..." : "Submit QC"} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={() => { | |||
| closeHandler?.({}, 'escapeKeyDown'); | |||
| }} | |||
| > | |||
| Cancel | |||
| </Button> | |||
| </Stack> | |||
| </Grid> | |||
| </Grid> | |||
| </Box> | |||
| </Modal> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PickQcStockInModalVer3; | |||
| @@ -0,0 +1,527 @@ | |||
| "use client"; | |||
| import { PurchaseQcResult, PutAwayInput, PutAwayLine } from "@/app/api/po/actions"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| Card, | |||
| CardContent, | |||
| FormControl, | |||
| Grid, | |||
| Modal, | |||
| ModalProps, | |||
| Stack, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| GridColDef, | |||
| GridRowIdGetter, | |||
| GridRowModel, | |||
| useGridApiContext, | |||
| GridRenderCellParams, | |||
| GridRenderEditCellParams, | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import InputDataGrid from "../InputDataGrid"; | |||
| import { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import TwoLineCell from "./TwoLineCell"; | |||
| import QcSelect from "./QcSelect"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { GridEditInputCell } from "@mui/x-data-grid"; | |||
| import { StockInLine } from "@/app/api/po"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { | |||
| OUTPUT_DATE_FORMAT, | |||
| stockInLineStatusMap, | |||
| } from "@/app/utils/formatUtil"; | |||
| import { QRCodeSVG } from "qrcode.react"; | |||
| import { QrCode } from "../QrCode"; | |||
| import ReactQrCodeScanner, { | |||
| ScannerConfig, | |||
| } from "../ReactQrCodeScanner/ReactQrCodeScanner"; | |||
| import { QrCodeInfo } from "@/app/api/qrcode"; | |||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import { dummyPutawayLine } from "./dummyQcTemplate"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| itemDetail: StockInLine; | |||
| warehouse: WarehouseResult[]; | |||
| disabled: boolean; | |||
| // qc: QcItemWithChecks[]; | |||
| } | |||
| type EntryError = | |||
| | { | |||
| [field in keyof PutAwayLine]?: string; | |||
| } | |||
| | undefined; | |||
| type PutawayRow = TableRow<Partial<PutAwayLine>, EntryError>; | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| width: "auto", | |||
| }; | |||
| const PutawayForm: React.FC<Props> = ({ itemDetail, warehouse, disabled }) => { | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| const { | |||
| register, | |||
| formState: { errors, defaultValues, touchedFields }, | |||
| watch, | |||
| control, | |||
| setValue, | |||
| getValues, | |||
| reset, | |||
| resetField, | |||
| setError, | |||
| clearErrors, | |||
| } = useFormContext<PutAwayInput>(); | |||
| console.log(itemDetail); | |||
| // const [recordQty, setRecordQty] = useState(0); | |||
| const [warehouseId, setWarehouseId] = useState(itemDetail.defaultWarehouseId); | |||
| const filteredWarehouse = useMemo(() => { | |||
| // do filtering here if any | |||
| return warehouse; | |||
| }, []); | |||
| const defaultOption = { | |||
| value: 0, // think think sin | |||
| label: t("Select warehouse"), | |||
| group: "default", | |||
| }; | |||
| const options = useMemo(() => { | |||
| return [ | |||
| // { | |||
| // value: 0, // think think sin | |||
| // label: t("Select warehouse"), | |||
| // group: "default", | |||
| // }, | |||
| ...filteredWarehouse.map((w) => ({ | |||
| value: w.id, | |||
| label: `${w.code} - ${w.name}`, | |||
| group: "existing", | |||
| })), | |||
| ]; | |||
| }, [filteredWarehouse]); | |||
| const currentValue = | |||
| warehouseId > 0 | |||
| ? options.find((o) => o.value === warehouseId) | |||
| : options.find((o) => o.value === getValues("warehouseId")) || | |||
| defaultOption; | |||
| const onChange = useCallback( | |||
| ( | |||
| event: React.SyntheticEvent, | |||
| newValue: { value: number; group: string } | { value: number }[], | |||
| ) => { | |||
| const singleNewVal = newValue as { | |||
| value: number; | |||
| group: string; | |||
| }; | |||
| console.log(singleNewVal); | |||
| console.log("onChange"); | |||
| // setValue("warehouseId", singleNewVal.value); | |||
| setWarehouseId(singleNewVal.value); | |||
| }, | |||
| [], | |||
| ); | |||
| console.log(watch("putAwayLines")) | |||
| // const accQty = watch("acceptedQty"); | |||
| // const validateForm = useCallback(() => { | |||
| // console.log(accQty); | |||
| // if (accQty > itemDetail.acceptedQty) { | |||
| // setError("acceptedQty", { | |||
| // message: `acceptedQty must not greater than ${itemDetail.acceptedQty}`, | |||
| // type: "required", | |||
| // }); | |||
| // } | |||
| // if (accQty < 1) { | |||
| // setError("acceptedQty", { | |||
| // message: `minimal value is 1`, | |||
| // type: "required", | |||
| // }); | |||
| // } | |||
| // if (isNaN(accQty)) { | |||
| // setError("acceptedQty", { | |||
| // message: `value must be a number`, | |||
| // type: "required", | |||
| // }); | |||
| // } | |||
| // }, [accQty]); | |||
| // useEffect(() => { | |||
| // clearErrors(); | |||
| // validateForm(); | |||
| // }, [validateForm]); | |||
| const qrContent = useMemo( | |||
| () => ({ | |||
| stockInLineId: itemDetail.id, | |||
| itemId: itemDetail.itemId, | |||
| lotNo: itemDetail.lotNo, | |||
| // warehouseId: 2 // for testing | |||
| // expiryDate: itemDetail.expiryDate, | |||
| // productionDate: itemDetail.productionDate, | |||
| // supplier: itemDetail.supplier, | |||
| // poCode: itemDetail.poCode, | |||
| }), | |||
| [itemDetail], | |||
| ); | |||
| const [isOpenScanner, setOpenScanner] = useState(false); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| setOpenScanner(false); | |||
| }, | |||
| [], | |||
| ); | |||
| const onOpenScanner = useCallback(() => { | |||
| setOpenScanner(true); | |||
| }, []); | |||
| const onCloseScanner = useCallback(() => { | |||
| setOpenScanner(false); | |||
| }, []); | |||
| const scannerConfig = useMemo<ScannerConfig>( | |||
| () => ({ | |||
| onUpdate: (err, result) => { | |||
| console.log(result); | |||
| console.log(Boolean(result)); | |||
| if (result) { | |||
| const data: QrCodeInfo = JSON.parse(result.getText()); | |||
| console.log(data); | |||
| if (data.warehouseId) { | |||
| console.log(data.warehouseId); | |||
| setWarehouseId(data.warehouseId); | |||
| onCloseScanner(); | |||
| } | |||
| } else return; | |||
| }, | |||
| }), | |||
| [onCloseScanner], | |||
| ); | |||
| // QR Code Scanner | |||
| const scanner = useQrCodeScannerContext(); | |||
| useEffect(() => { | |||
| if (isOpenScanner) { | |||
| scanner.startScan(); | |||
| } else if (!isOpenScanner) { | |||
| scanner.stopScan(); | |||
| } | |||
| }, [isOpenScanner]); | |||
| useEffect(() => { | |||
| if (scanner.values.length > 0) { | |||
| console.log(scanner.values[0]); | |||
| const data: QrCodeInfo = JSON.parse(scanner.values[0]); | |||
| console.log(data); | |||
| if (data.warehouseId) { | |||
| console.log(data.warehouseId); | |||
| setWarehouseId(data.warehouseId); | |||
| onCloseScanner(); | |||
| } | |||
| scanner.resetScan(); | |||
| } | |||
| }, [scanner.values]); | |||
| useEffect(() => { | |||
| setValue("status", "completed"); | |||
| setValue("warehouseId", options[0].value); | |||
| }, []); | |||
| useEffect(() => { | |||
| if (warehouseId > 0) { | |||
| setValue("warehouseId", warehouseId); | |||
| clearErrors("warehouseId"); | |||
| } | |||
| }, [warehouseId]); | |||
| const getWarningTextHardcode = useCallback((): string | undefined => { | |||
| console.log(options) | |||
| if (options.length === 0) return undefined | |||
| const defaultWarehouseId = options[0].value; | |||
| const currWarehouseId = watch("warehouseId"); | |||
| if (defaultWarehouseId !== currWarehouseId) { | |||
| return t("not default warehosue"); | |||
| } | |||
| return undefined; | |||
| }, [options]); | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: "qty", | |||
| headerName: t("qty"), | |||
| flex: 1, | |||
| // renderCell(params) { | |||
| // return <>100</> | |||
| // }, | |||
| }, | |||
| { | |||
| field: "warehouse", | |||
| headerName: t("warehouse"), | |||
| flex: 1, | |||
| // renderCell(params) { | |||
| // return <>{filteredWarehouse[0].name}</> | |||
| // }, | |||
| }, | |||
| { | |||
| field: "printQty", | |||
| headerName: t("printQty"), | |||
| flex: 1, | |||
| // renderCell(params) { | |||
| // return <>100</> | |||
| // }, | |||
| }, | |||
| ], []) | |||
| const validation = useCallback( | |||
| (newRow: GridRowModel<PutawayRow>): EntryError => { | |||
| const error: EntryError = {}; | |||
| const { qty, warehouseId, printQty } = newRow; | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| }, | |||
| [], | |||
| ); | |||
| return ( | |||
| <Grid container justifyContent="flex-start" alignItems="flex-start"> | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("Putaway Detail")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label={t("LotNo")} | |||
| fullWidth | |||
| value={itemDetail.lotNo} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Supplier")} | |||
| fullWidth | |||
| value={itemDetail.supplier} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("Po Code")} | |||
| fullWidth | |||
| value={itemDetail.poCode} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("itemName")} | |||
| fullWidth | |||
| value={itemDetail.itemName} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("itemNo")} | |||
| fullWidth | |||
| value={itemDetail.itemNo} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("qty")} | |||
| fullWidth | |||
| value={itemDetail.acceptedQty} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("productionDate")} | |||
| fullWidth | |||
| value={ | |||
| // dayjs(itemDetail.productionDate) | |||
| dayjs() | |||
| // .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT)} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("expiryDate")} | |||
| fullWidth | |||
| value={ | |||
| // dayjs(itemDetail.expiryDate) | |||
| dayjs() | |||
| .add(20, "day") | |||
| .format(OUTPUT_DATE_FORMAT)} | |||
| disabled | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <FormControl fullWidth> | |||
| <Autocomplete | |||
| noOptionsText={t("No Warehouse")} | |||
| disableClearable | |||
| disabled | |||
| fullWidth | |||
| defaultValue={options[0]} /// modify this later | |||
| // onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderInput={(params) => ( | |||
| <TextField {...params} label={t("Default Warehouse")} /> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| {/* <Grid item xs={5.5}> | |||
| <TextField | |||
| label={t("acceptedQty")} | |||
| fullWidth | |||
| {...register("acceptedQty", { | |||
| required: "acceptedQty required!", | |||
| min: 1, | |||
| max: itemDetail.acceptedQty, | |||
| valueAsNumber: true, | |||
| })} | |||
| // defaultValue={itemDetail.acceptedQty} | |||
| disabled={disabled} | |||
| error={Boolean(errors.acceptedQty)} | |||
| helperText={errors.acceptedQty?.message} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={1}> | |||
| <Button disabled={disabled} onClick={onOpenScanner}> | |||
| {t("bind")} | |||
| </Button> | |||
| </Grid> */} | |||
| {/* <Grid item xs={5.5}> | |||
| <Controller | |||
| control={control} | |||
| name="warehouseId" | |||
| render={({ field }) => { | |||
| console.log(field); | |||
| return ( | |||
| <Autocomplete | |||
| noOptionsText={t("No Warehouse")} | |||
| disableClearable | |||
| fullWidth | |||
| value={options.find((o) => o.value == field.value)} | |||
| onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={"Select warehouse"} | |||
| error={Boolean(errors.warehouseId?.message)} | |||
| helperText={warehouseHelperText} | |||
| // helperText={errors.warehouseId?.message} | |||
| /> | |||
| )} | |||
| /> | |||
| ); | |||
| }} | |||
| /> | |||
| <FormControl fullWidth> | |||
| <Autocomplete | |||
| noOptionsText={t("No Warehouse")} | |||
| disableClearable | |||
| fullWidth | |||
| // value={warehouseId > 0 | |||
| // ? options.find((o) => o.value === warehouseId) | |||
| // : undefined} | |||
| defaultValue={options[0]} | |||
| // defaultValue={options.find((o) => o.value === 1)} | |||
| value={currentValue} | |||
| onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| // label={"Select warehouse"} | |||
| disabled={disabled} | |||
| error={Boolean(errors.warehouseId?.message)} | |||
| helperText={ | |||
| errors.warehouseId?.message ?? getWarningTextHardcode() | |||
| } | |||
| // helperText={warehouseHelperText} | |||
| /> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> */} | |||
| <Grid | |||
| item | |||
| xs={12} | |||
| style={{ display: "flex", justifyContent: "center" }} | |||
| > | |||
| {/* <QrCode content={qrContent} sx={{ width: 200, height: 200 }} /> */} | |||
| <InputDataGrid<PutAwayInput, PutAwayLine, EntryError> | |||
| apiRef={apiRef} | |||
| checkboxSelection={false} | |||
| _formKey={"putAwayLines"} | |||
| columns={columns} | |||
| validateRow={validation} | |||
| needAdd={true} | |||
| showRemoveBtn={false} | |||
| /> | |||
| </Grid> | |||
| </Grid> | |||
| {/* <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| <Button onClick={onOpenScanner}>bind</Button> | |||
| </Grid> */} | |||
| <Modal open={isOpenScanner} onClose={closeHandler}> | |||
| <Box sx={style}> | |||
| <Typography variant="h4"> | |||
| {t("Please scan warehouse qr code.")} | |||
| </Typography> | |||
| {/* <ReactQrCodeScanner scannerConfig={scannerConfig} /> */} | |||
| </Box> | |||
| </Modal> | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default PutawayForm; | |||
| @@ -0,0 +1,395 @@ | |||
| "use client"; | |||
| import { | |||
| Dispatch, | |||
| MutableRefObject, | |||
| SetStateAction, | |||
| useCallback, | |||
| useEffect, | |||
| useMemo, | |||
| useState, | |||
| } from "react"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { | |||
| FooterPropsOverrides, | |||
| GridActionsCellItem, | |||
| GridCellParams, | |||
| GridColDef, | |||
| GridEventListener, | |||
| GridRowEditStopReasons, | |||
| GridRowId, | |||
| GridRowIdGetter, | |||
| GridRowModel, | |||
| GridRowModes, | |||
| GridRowModesModel, | |||
| GridRowSelectionModel, | |||
| GridToolbarContainer, | |||
| GridValidRowModel, | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import { set, useFormContext } from "react-hook-form"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import CancelIcon from "@mui/icons-material/Cancel"; | |||
| import { Add } from "@mui/icons-material"; | |||
| import { Box, Button, Typography } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| GridApiCommunity, | |||
| GridSlotsComponentsProps, | |||
| } from "@mui/x-data-grid/internals"; | |||
| import { dummyQCData } from "./dummyQcTemplate"; | |||
| // T == CreatexxxInputs map of the form's fields | |||
| // V == target field input inside CreatexxxInputs, e.g. qcChecks: ItemQc[], V = ItemQc | |||
| // E == error | |||
| interface ResultWithId { | |||
| id: string | number; | |||
| } | |||
| // export type InputGridProps = { | |||
| // [key: string]: any | |||
| // } | |||
| interface DefaultResult<E> { | |||
| _isNew: boolean; | |||
| _error: E; | |||
| } | |||
| interface SelectionResult<E> { | |||
| active: boolean; | |||
| _isNew: boolean; | |||
| _error: E; | |||
| } | |||
| type Result<E> = DefaultResult<E> | SelectionResult<E>; | |||
| export type TableRow<V, E> = Partial< | |||
| V & { | |||
| isActive: boolean | undefined; | |||
| _isNew: boolean; | |||
| _error: E; | |||
| } & ResultWithId | |||
| >; | |||
| export interface InputDataGridProps<T, V, E> { | |||
| apiRef: MutableRefObject<GridApiCommunity>; | |||
| // checkboxSelection: false | undefined; | |||
| _formKey: keyof T; | |||
| columns: GridColDef[]; | |||
| validateRow: (newRow: GridRowModel<TableRow<V, E>>) => E; | |||
| needAdd?: boolean; | |||
| } | |||
| export interface SelectionInputDataGridProps<T, V, E> { | |||
| // thinking how do | |||
| apiRef: MutableRefObject<GridApiCommunity>; | |||
| // checkboxSelection: true; | |||
| _formKey: keyof T; | |||
| columns: GridColDef[]; | |||
| validateRow: (newRow: GridRowModel<TableRow<V, E>>) => E; | |||
| } | |||
| export type Props<T, V, E> = | |||
| | InputDataGridProps<T, V, E> | |||
| | SelectionInputDataGridProps<T, V, E>; | |||
| export class ProcessRowUpdateError<T, E> extends Error { | |||
| public readonly row: T; | |||
| public readonly errors: E | undefined; | |||
| constructor(row: T, message?: string, errors?: E) { | |||
| super(message); | |||
| this.row = row; | |||
| this.errors = errors; | |||
| Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); | |||
| } | |||
| } | |||
| // T == CreatexxxInputs map of the form's fields | |||
| // V == target field input inside CreatexxxInputs, e.g. qcChecks: ItemQc[], V = ItemQc | |||
| // E == error | |||
| function InputDataGrid<T, V, E>({ | |||
| apiRef, | |||
| // checkboxSelection = false, | |||
| _formKey, | |||
| columns, | |||
| validateRow, | |||
| }: Props<T, V, E>) { | |||
| const { | |||
| t, | |||
| // i18n: { language }, | |||
| } = useTranslation("purchaseOrder"); | |||
| const formKey = _formKey.toString(); | |||
| const { setValue, getValues } = useFormContext(); | |||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | |||
| // const apiRef = useGridApiRef(); | |||
| const getRowId = useCallback<GridRowIdGetter<TableRow<V, E>>>( | |||
| (row) => row.id! as number, | |||
| [], | |||
| ); | |||
| const formValue = getValues(formKey) | |||
| const list: TableRow<V, E>[] = !formValue || formValue.length == 0 ? dummyQCData : getValues(formKey); | |||
| console.log(list) | |||
| const [rows, setRows] = useState<TableRow<V, E>[]>(() => { | |||
| // const list: TableRow<V, E>[] = getValues(formKey); | |||
| console.log(list) | |||
| return list && list.length > 0 ? list : []; | |||
| }); | |||
| console.log(rows) | |||
| // const originalRows = list && list.length > 0 ? list : []; | |||
| const originalRows = useMemo(() => ( | |||
| list && list.length > 0 ? list : [] | |||
| ), [list]) | |||
| // const originalRowModel = originalRows.filter((li) => li.isActive).map(i => i.id) as GridRowSelectionModel | |||
| const [rowSelectionModel, setRowSelectionModel] = | |||
| useState<GridRowSelectionModel>(() => { | |||
| // const rowModel = list.filter((li) => li.isActive).map(i => i.id) as GridRowSelectionModel | |||
| const rowModel: GridRowSelectionModel = getValues( | |||
| `${formKey}_active`, | |||
| ) as GridRowSelectionModel; | |||
| console.log(rowModel); | |||
| return rowModel; | |||
| }); | |||
| useEffect(() => { | |||
| for (let i = 0; i < rows.length; i++) { | |||
| const currRow = rows[i] | |||
| setRowModesModel((prevRowModesModel) => ({ | |||
| ...prevRowModesModel, | |||
| [currRow.id as number]: { mode: GridRowModes.View }, | |||
| })); | |||
| } | |||
| }, [rows]) | |||
| const handleSave = useCallback( | |||
| (id: GridRowId) => () => { | |||
| setRowModesModel((prevRowModesModel) => ({ | |||
| ...prevRowModesModel, | |||
| [id]: { mode: GridRowModes.View }, | |||
| })); | |||
| }, | |||
| [], | |||
| ); | |||
| const onProcessRowUpdateError = useCallback( | |||
| (updateError: ProcessRowUpdateError<T, E>) => { | |||
| const errors = updateError.errors; | |||
| const row = updateError.row; | |||
| console.log(errors); | |||
| apiRef.current.updateRows([{ ...row, _error: errors }]); | |||
| }, | |||
| [apiRef], | |||
| ); | |||
| const processRowUpdate = useCallback( | |||
| ( | |||
| newRow: GridRowModel<TableRow<V, E>>, | |||
| originalRow: GridRowModel<TableRow<V, E>>, | |||
| ) => { | |||
| ///////////////// | |||
| // validation here | |||
| const errors = validateRow(newRow); | |||
| console.log(newRow); | |||
| if (errors) { | |||
| throw new ProcessRowUpdateError( | |||
| originalRow, | |||
| "validation error", | |||
| errors, | |||
| ); | |||
| } | |||
| ///////////////// | |||
| const { _isNew, _error, ...updatedRow } = newRow; | |||
| const rowToSave = { | |||
| ...updatedRow, | |||
| } as TableRow<V, E>; /// test | |||
| console.log(rowToSave); | |||
| setRows((rw) => | |||
| rw.map((r) => (getRowId(r) === getRowId(originalRow) ? rowToSave : r)), | |||
| ); | |||
| return rowToSave; | |||
| }, | |||
| [validateRow, getRowId], | |||
| ); | |||
| const addRow = useCallback(() => { | |||
| const newEntry = { id: Date.now(), _isNew: true } as TableRow<V, E>; | |||
| setRows((prev) => [...prev, newEntry]); | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [getRowId(newEntry)]: { | |||
| mode: GridRowModes.Edit, | |||
| // fieldToFocus: "team", /// test | |||
| }, | |||
| })); | |||
| }, [getRowId]); | |||
| const reset = useCallback(() => { | |||
| setRowModesModel({}); | |||
| setRows(originalRows); | |||
| }, [originalRows]); | |||
| const handleCancel = useCallback( | |||
| (id: GridRowId) => () => { | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| [id]: { mode: GridRowModes.View, ignoreModifications: true }, | |||
| })); | |||
| const editedRow = rows.find((row) => getRowId(row) === id); | |||
| if (editedRow?._isNew) { | |||
| setRows((rw) => rw.filter((r) => getRowId(r) !== id)); | |||
| } else { | |||
| setRows((rw) => | |||
| rw.map((r) => (getRowId(r) === id ? { ...r, _error: undefined } : r)), | |||
| ); | |||
| } | |||
| }, | |||
| [rows, getRowId], | |||
| ); | |||
| const handleDelete = useCallback( | |||
| (id: GridRowId) => () => { | |||
| setRows((prevRows) => prevRows.filter((row) => getRowId(row) !== id)); | |||
| }, | |||
| [getRowId], | |||
| ); | |||
| const _columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| ...columns, | |||
| { | |||
| field: "actions", | |||
| type: "actions", | |||
| headerName: "", | |||
| flex: 0.5, | |||
| cellClassName: "actions", | |||
| getActions: ({ id }: { id: GridRowId }) => { | |||
| const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; | |||
| if (isInEditMode) { | |||
| return [ | |||
| <GridActionsCellItem | |||
| icon={<SaveIcon />} | |||
| label="Save" | |||
| key="edit" | |||
| sx={{ | |||
| color: "primary.main", | |||
| }} | |||
| onClick={handleSave(id)} | |||
| />, | |||
| <GridActionsCellItem | |||
| icon={<CancelIcon />} | |||
| label="Cancel" | |||
| key="edit" | |||
| onClick={handleCancel(id)} | |||
| />, | |||
| ]; | |||
| } | |||
| return [ | |||
| <GridActionsCellItem | |||
| icon={<DeleteIcon />} | |||
| label="Delete" | |||
| sx={{ | |||
| color: "error.main", | |||
| }} | |||
| onClick={handleDelete(id)} | |||
| color="inherit" | |||
| key="edit" | |||
| />, | |||
| ]; | |||
| }, | |||
| }, | |||
| ], | |||
| [columns, rowModesModel, handleSave, handleCancel, handleDelete], | |||
| ); | |||
| // sync useForm | |||
| useEffect(() => { | |||
| // console.log(formKey) | |||
| // console.log(rows) | |||
| setValue(formKey, rows); | |||
| }, [formKey, rows, setValue]); | |||
| const footer = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={addRow} | |||
| size="small" | |||
| > | |||
| 新增 | |||
| {/* {t("Add Record")} */} | |||
| </Button> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={reset} | |||
| size="small" | |||
| > | |||
| {/* {t("Clean Record")} */} | |||
| 清除 | |||
| </Button> | |||
| </Box> | |||
| ); | |||
| // const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { | |||
| // if (params.reason === GridRowEditStopReasons.rowFocusOut) { | |||
| // event.defaultMuiPrevented = true; | |||
| // } | |||
| // }; | |||
| return ( | |||
| <StyledDataGrid | |||
| // {...props} | |||
| // getRowId={getRowId as GridRowIdGetter<GridValidRowModel>} | |||
| rowSelectionModel={rowSelectionModel} | |||
| apiRef={apiRef} | |||
| rows={rows} | |||
| columns={columns} | |||
| editMode="row" | |||
| autoHeight | |||
| sx={{ | |||
| "--DataGrid-overlayHeight": "100px", | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||
| border: "1px solid", | |||
| borderColor: "error.main", | |||
| }, | |||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||
| border: "1px solid", | |||
| borderColor: "warning.main", | |||
| }, | |||
| }} | |||
| disableColumnMenu | |||
| processRowUpdate={processRowUpdate as any} | |||
| // onRowEditStop={handleRowEditStop} | |||
| rowModesModel={rowModesModel} | |||
| onRowModesModelChange={setRowModesModel} | |||
| onProcessRowUpdateError={onProcessRowUpdateError} | |||
| getCellClassName={(params: GridCellParams<TableRow<T, E>>) => { | |||
| let classname = ""; | |||
| if (params.row._error) { | |||
| classname = "hasError"; | |||
| } | |||
| return classname; | |||
| }} | |||
| slots={{ | |||
| // footer: FooterToolbar, | |||
| noRowsOverlay: NoRowsOverlay, | |||
| }} | |||
| // slotProps={{ | |||
| // footer: { child: footer }, | |||
| // } | |||
| // } | |||
| /> | |||
| ); | |||
| } | |||
| const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => { | |||
| return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>; | |||
| }; | |||
| const NoRowsOverlay: React.FC = () => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| height="100%" | |||
| > | |||
| <Typography variant="caption">{t("Add some entries!")}</Typography> | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default InputDataGrid; | |||
| @@ -0,0 +1,460 @@ | |||
| "use client"; | |||
| import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Checkbox, | |||
| FormControl, | |||
| FormControlLabel, | |||
| Grid, | |||
| Radio, | |||
| RadioGroup, | |||
| Stack, | |||
| Tab, | |||
| Tabs, | |||
| TabsProps, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { useFormContext, Controller } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| GridColDef, | |||
| GridRowIdGetter, | |||
| GridRowModel, | |||
| useGridApiContext, | |||
| GridRenderCellParams, | |||
| GridRenderEditCellParams, | |||
| useGridApiRef, | |||
| GridRowSelectionModel, | |||
| } from "@mui/x-data-grid"; | |||
| import InputDataGrid from "../InputDataGrid"; | |||
| import { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import TwoLineCell from "./TwoLineCell"; | |||
| import QcSelect from "./QcSelect"; | |||
| import { GridEditInputCell } from "@mui/x-data-grid"; | |||
| import { StockInLine } from "@/app/api/po"; | |||
| import { stockInLineStatusMap } from "@/app/utils/formatUtil"; | |||
| import { fetchQcItemCheck, fetchQcResult } from "@/app/api/qc/actions"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import axios from "@/app/(main)/axios/axiosInstance"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import axiosInstance from "@/app/(main)/axios/axiosInstance"; | |||
| import EscalationComponent from "./EscalationComponent"; | |||
| import QcDataGrid from "./QCDatagrid"; | |||
| import StockInFormVer2 from "./StockInFormVer2"; | |||
| import { dummyEscalationHistory, dummyQCData, QcData } from "./dummyQcTemplate"; | |||
| import { ModalFormInput } from "@/app/api/po/actions"; | |||
| import { escape } from "lodash"; | |||
| interface Props { | |||
| itemDetail: StockInLine; | |||
| qc: QcItemWithChecks[]; | |||
| disabled: boolean; | |||
| qcItems: QcData[] | |||
| setQcItems: Dispatch<SetStateAction<QcData[]>> | |||
| } | |||
| type EntryError = | |||
| | { | |||
| [field in keyof QcData]?: string; | |||
| } | |||
| | undefined; | |||
| type QcRow = TableRow<Partial<QcData>, EntryError>; | |||
| // fetchQcItemCheck | |||
| const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => { | |||
| const { t } = useTranslation("purchaseOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| const { | |||
| register, | |||
| formState: { errors, defaultValues, touchedFields }, | |||
| watch, | |||
| control, | |||
| setValue, | |||
| getValues, | |||
| reset, | |||
| resetField, | |||
| setError, | |||
| clearErrors, | |||
| } = useFormContext<PurchaseQCInput>(); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>(); | |||
| const [escalationHistory, setEscalationHistory] = useState(dummyEscalationHistory); | |||
| const [qcResult, setQcResult] = useState(); | |||
| const qcAccept = watch("qcAccept"); | |||
| // const [qcAccept, setQcAccept] = useState(true); | |||
| // const [qcItems, setQcItems] = useState(dummyQCData) | |||
| const column = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: "escalation", | |||
| headerName: t("escalation"), | |||
| flex: 1, | |||
| }, | |||
| { | |||
| field: "supervisor", | |||
| headerName: t("supervisor"), | |||
| flex: 1, | |||
| }, | |||
| ], [] | |||
| ) | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| setTabIndex(newValue); | |||
| }, | |||
| [], | |||
| ); | |||
| //// validate form | |||
| const accQty = watch("acceptQty"); | |||
| const validateForm = useCallback(() => { | |||
| console.log(accQty); | |||
| if (accQty > itemDetail.acceptedQty) { | |||
| setError("acceptQty", { | |||
| message: `${t("acceptQty must not greater than")} ${ | |||
| itemDetail.acceptedQty | |||
| }`, | |||
| type: "required", | |||
| }); | |||
| } | |||
| if (accQty < 1) { | |||
| setError("acceptQty", { | |||
| message: t("minimal value is 1"), | |||
| type: "required", | |||
| }); | |||
| } | |||
| if (isNaN(accQty)) { | |||
| setError("acceptQty", { | |||
| message: t("value must be a number"), | |||
| type: "required", | |||
| }); | |||
| } | |||
| }, [accQty]); | |||
| useEffect(() => { | |||
| clearErrors(); | |||
| validateForm(); | |||
| }, [clearErrors, validateForm]); | |||
| const columns = useMemo<GridColDef[]>( | |||
| () => [ | |||
| { | |||
| field: "escalation", | |||
| headerName: t("escalation"), | |||
| flex: 1, | |||
| }, | |||
| { | |||
| field: "supervisor", | |||
| headerName: t("supervisor"), | |||
| flex: 1, | |||
| }, | |||
| ], | |||
| [], | |||
| ); | |||
| /// validate datagrid | |||
| const validation = useCallback( | |||
| (newRow: GridRowModel<QcRow>): EntryError => { | |||
| const error: EntryError = {}; | |||
| // const { qcItemId, failQty } = newRow; | |||
| return Object.keys(error).length > 0 ? error : undefined; | |||
| }, | |||
| [], | |||
| ); | |||
| function BooleanEditCell(params: GridRenderEditCellParams) { | |||
| const apiRef = useGridApiContext(); | |||
| const { id, field, value } = params; | |||
| const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| apiRef.current.setEditCellValue({ id, field, value: e.target.checked }); | |||
| apiRef.current.stopCellEditMode({ id, field }); // commit immediately | |||
| }; | |||
| return <Checkbox checked={!!value} onChange={handleChange} sx={{ p: 0 }} />; | |||
| } | |||
| const qcColumns: GridColDef[] = [ | |||
| { | |||
| field: "qcItem", | |||
| headerName: t("qcItem"), | |||
| flex: 2, | |||
| renderCell: (params) => ( | |||
| <Box> | |||
| <b>{params.value}</b><br/> | |||
| {params.row.qcDescription}<br/> | |||
| </Box> | |||
| ), | |||
| }, | |||
| { | |||
| field: 'isPassed', | |||
| headerName: t("qcResult"), | |||
| flex: 1.5, | |||
| renderCell: (params) => { | |||
| const currentValue = params.value; | |||
| return ( | |||
| <FormControl> | |||
| <RadioGroup | |||
| row | |||
| aria-labelledby="demo-radio-buttons-group-label" | |||
| value={currentValue === undefined ? "" : (currentValue ? "true" : "false")} | |||
| onChange={(e) => { | |||
| const value = e.target.value; | |||
| setQcItems((prev) => | |||
| prev.map((r): QcData => (r.id === params.id ? { ...r, isPassed: value === "true" } : r)) | |||
| ); | |||
| }} | |||
| name={`isPassed-${params.id}`} | |||
| > | |||
| <FormControlLabel | |||
| value="true" | |||
| control={<Radio />} | |||
| label="合格" | |||
| sx={{ | |||
| color: currentValue === true ? "green" : "inherit", | |||
| "& .Mui-checked": {color: "green"} | |||
| }} | |||
| /> | |||
| <FormControlLabel | |||
| value="false" | |||
| control={<Radio />} | |||
| label="不合格" | |||
| sx={{ | |||
| color: currentValue === false ? "red" : "inherit", | |||
| "& .Mui-checked": {color: "red"} | |||
| }} | |||
| /> | |||
| </RadioGroup> | |||
| </FormControl> | |||
| ); | |||
| }, | |||
| }, | |||
| { | |||
| field: "failedQty", | |||
| headerName: t("failedQty"), | |||
| flex: 1, | |||
| // editable: true, | |||
| renderCell: (params) => ( | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={!params.row.isPassed? (params.value ?? '') : '0'} | |||
| disabled={params.row.isPassed} | |||
| onChange={(e) => { | |||
| const v = e.target.value; | |||
| const next = v === '' ? undefined : Number(v); | |||
| if (Number.isNaN(next)) return; | |||
| setQcItems((prev) => | |||
| prev.map((r) => (r.id === params.id ? { ...r, failedQty: next } : r)) | |||
| ); | |||
| }} | |||
| onClick={(e) => e.stopPropagation()} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| onKeyDown={(e) => e.stopPropagation()} | |||
| inputProps={{ min: 0 }} | |||
| sx={{ width: '100%' }} | |||
| /> | |||
| ), | |||
| }, | |||
| { | |||
| field: "remarks", | |||
| headerName: t("remarks"), | |||
| flex: 2, | |||
| renderCell: (params) => ( | |||
| <TextField | |||
| size="small" | |||
| value={params.value ?? ''} | |||
| onChange={(e) => { | |||
| const remarks = e.target.value; | |||
| // const next = v === '' ? undefined : Number(v); | |||
| // if (Number.isNaN(next)) return; | |||
| setQcItems((prev) => | |||
| prev.map((r) => (r.id === params.id ? { ...r, remarks: remarks } : r)) | |||
| ); | |||
| }} | |||
| onClick={(e) => e.stopPropagation()} | |||
| onMouseDown={(e) => e.stopPropagation()} | |||
| onKeyDown={(e) => e.stopPropagation()} | |||
| inputProps={{ min: 0 }} | |||
| sx={{ width: '100%' }} | |||
| /> | |||
| ), | |||
| }, | |||
| ] | |||
| useEffect(() => { | |||
| console.log(itemDetail); | |||
| }, [itemDetail]); | |||
| // Set initial value for acceptQty | |||
| useEffect(() => { | |||
| if (itemDetail?.acceptedQty !== undefined) { | |||
| setValue("acceptQty", itemDetail.acceptedQty); | |||
| } | |||
| }, [itemDetail?.acceptedQty, setValue]); | |||
| // const [openCollapse, setOpenCollapse] = useState(false) | |||
| const [isCollapsed, setIsCollapsed] = useState<boolean>(false); | |||
| const onFailedOpenCollapse = useCallback((qcItems: QcData[]) => { | |||
| const isFailed = qcItems.some((qc) => !qc.isPassed) | |||
| console.log(isFailed) | |||
| if (isFailed) { | |||
| setIsCollapsed(true) | |||
| } else { | |||
| setIsCollapsed(false) | |||
| } | |||
| }, []) | |||
| // const handleRadioChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| // const value = event.target.value === 'true'; | |||
| // setValue("qcAccept", value); | |||
| // }, [setValue]); | |||
| useEffect(() => { | |||
| console.log(itemDetail); | |||
| }, [itemDetail]); | |||
| useEffect(() => { | |||
| // onFailedOpenCollapse(qcItems) // This function is no longer needed | |||
| }, [qcItems]); // Removed onFailedOpenCollapse from dependency array | |||
| return ( | |||
| <> | |||
| <Grid container justifyContent="flex-start" alignItems="flex-start"> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| spacing={2} | |||
| sx={{ mt: 0.5 }} | |||
| > | |||
| <Grid item xs={12}> | |||
| <Tabs | |||
| value={tabIndex} | |||
| onChange={handleTabChange} | |||
| variant="scrollable" | |||
| > | |||
| <Tab label={t("QC Info")} iconPosition="end" /> | |||
| <Tab label={t("Escalation History")} iconPosition="end" /> | |||
| </Tabs> | |||
| </Grid> | |||
| {tabIndex == 0 && ( | |||
| <> | |||
| <Grid item xs={12}> | |||
| {/* <QcDataGrid<ModalFormInput, QcData, EntryError> | |||
| apiRef={apiRef} | |||
| columns={qcColumns} | |||
| _formKey="qcResult" | |||
| validateRow={validation} | |||
| /> */} | |||
| <StyledDataGrid | |||
| columns={qcColumns} | |||
| rows={qcItems} | |||
| autoHeight | |||
| /> | |||
| </Grid> | |||
| {/* <Grid item xs={12}> | |||
| <EscalationComponent | |||
| forSupervisor={false} | |||
| isCollapsed={isCollapsed} | |||
| setIsCollapsed={setIsCollapsed} | |||
| /> | |||
| </Grid> */} | |||
| </> | |||
| )} | |||
| {tabIndex == 1 && ( | |||
| <> | |||
| {/* <Grid item xs={12}> | |||
| <StockInFormVer2 | |||
| itemDetail={itemDetail} | |||
| disabled={false} | |||
| /> | |||
| </Grid> */} | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("Escalation Info")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <StyledDataGrid | |||
| rows={escalationHistory} | |||
| columns={columns} | |||
| onRowSelectionModelChange={(newRowSelectionModel) => { | |||
| setRowSelectionModel(newRowSelectionModel); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| </> | |||
| )} | |||
| <Grid item xs={12}> | |||
| <FormControl> | |||
| <Controller | |||
| name="qcAccept" | |||
| control={control} | |||
| defaultValue={true} | |||
| render={({ field }) => ( | |||
| <RadioGroup | |||
| row | |||
| aria-labelledby="demo-radio-buttons-group-label" | |||
| {...field} | |||
| value={field.value?.toString() || "true"} | |||
| onChange={(e) => { | |||
| const value = e.target.value === 'true'; | |||
| if (!value && Boolean(errors.acceptQty)) { | |||
| setValue("acceptQty", itemDetail.acceptedQty); | |||
| } | |||
| field.onChange(value); | |||
| }} | |||
| > | |||
| <FormControlLabel value="true" control={<Radio />} label="接受" /> | |||
| <Box sx={{mr:2}}> | |||
| <TextField | |||
| type="number" | |||
| label={t("acceptQty")} | |||
| sx={{ width: '150px' }} | |||
| defaultValue={accQty} | |||
| disabled={!qcAccept} | |||
| {...register("acceptQty", { | |||
| required: "acceptQty required!", | |||
| })} | |||
| error={Boolean(errors.acceptQty)} | |||
| helperText={errors.acceptQty?.message} | |||
| /> | |||
| </Box> | |||
| <FormControlLabel value="false" control={<Radio />} label="不接受及上報" /> | |||
| </RadioGroup> | |||
| )} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| {/* <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("Escalation Result")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <EscalationComponent | |||
| forSupervisor={true} | |||
| isCollapsed={isCollapsed} | |||
| setIsCollapsed={setIsCollapsed} | |||
| /> | |||
| </Grid> */} | |||
| </Grid> | |||
| </Grid> | |||
| </> | |||
| ); | |||
| }; | |||
| export default QcFormVer2; | |||
| @@ -0,0 +1,78 @@ | |||
| import React, { useCallback, useMemo } from "react"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Checkbox, | |||
| Chip, | |||
| ListSubheader, | |||
| MenuItem, | |||
| TextField, | |||
| Tooltip, | |||
| } from "@mui/material"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface CommonProps { | |||
| allQcs: QcItemWithChecks[]; | |||
| error?: boolean; | |||
| } | |||
| interface SingleAutocompleteProps extends CommonProps { | |||
| value: number | string | undefined; | |||
| onQcSelect: (qcItemId: number) => void | Promise<void>; | |||
| // multiple: false; | |||
| } | |||
| type Props = SingleAutocompleteProps; | |||
| const QcSelect: React.FC<Props> = ({ allQcs, value, error, onQcSelect }) => { | |||
| const { t } = useTranslation("home"); | |||
| const filteredQc = useMemo(() => { | |||
| // do filtering here if any | |||
| return allQcs; | |||
| }, [allQcs]); | |||
| const options = useMemo(() => { | |||
| return [ | |||
| { | |||
| value: -1, // think think sin | |||
| label: t("None"), | |||
| group: "default", | |||
| }, | |||
| ...filteredQc.map((q) => ({ | |||
| value: q.id, | |||
| label: `${q.code} - ${q.name}`, | |||
| group: "existing", | |||
| })), | |||
| ]; | |||
| }, [t, filteredQc]); | |||
| const currentValue = options.find((o) => o.value === value) || options[0]; | |||
| const onChange = useCallback( | |||
| ( | |||
| event: React.SyntheticEvent, | |||
| newValue: { value: number; group: string } | { value: number }[], | |||
| ) => { | |||
| const singleNewVal = newValue as { | |||
| value: number; | |||
| group: string; | |||
| }; | |||
| onQcSelect(singleNewVal.value); | |||
| }, | |||
| [onQcSelect], | |||
| ); | |||
| return ( | |||
| <Autocomplete | |||
| noOptionsText={t("No Qc")} | |||
| disableClearable | |||
| fullWidth | |||
| value={currentValue} | |||
| onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderInput={(params) => <TextField {...params} error={error} />} | |||
| /> | |||
| ); | |||
| }; | |||
| export default QcSelect; | |||
| @@ -0,0 +1,243 @@ | |||
| import React, { useCallback } from 'react'; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Checkbox, | |||
| TextField, | |||
| TablePagination, | |||
| FormControl, | |||
| Select, | |||
| MenuItem, | |||
| } from '@mui/material'; | |||
| import { useTranslation } from 'react-i18next'; | |||
| interface SearchItemWithQty { | |||
| id: number; | |||
| label: string; | |||
| qty: number | null; | |||
| currentStockBalance?: number; | |||
| uomDesc?: string; | |||
| targetDate?: string | null; | |||
| groupId?: number | null; | |||
| } | |||
| interface Group { | |||
| id: number; | |||
| name: string; | |||
| targetDate: string; | |||
| } | |||
| interface SearchResultsTableProps { | |||
| items: SearchItemWithQty[]; | |||
| selectedItemIds: (string | number)[]; | |||
| groups: Group[]; | |||
| onItemSelect: (itemId: number, checked: boolean) => void; | |||
| onQtyChange: (itemId: number, qty: number | null) => void; | |||
| onQtyBlur: (itemId: number) => void; | |||
| onGroupChange: (itemId: number, groupId: string) => void; | |||
| isItemInCreated: (itemId: number) => boolean; | |||
| pageNum: number; | |||
| pageSize: number; | |||
| onPageChange: (event: unknown, newPage: number) => void; | |||
| onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| } | |||
| const SearchResultsTable: React.FC<SearchResultsTableProps> = ({ | |||
| items, | |||
| selectedItemIds, | |||
| groups, | |||
| onItemSelect, | |||
| onQtyChange, | |||
| onGroupChange, | |||
| onQtyBlur, | |||
| isItemInCreated, | |||
| pageNum, | |||
| pageSize, | |||
| onPageChange, | |||
| onPageSizeChange, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // Calculate pagination | |||
| const startIndex = (pageNum - 1) * pageSize; | |||
| const endIndex = startIndex + pageSize; | |||
| const paginatedResults = items.slice(startIndex, endIndex); | |||
| const handleQtyChange = useCallback((itemId: number, value: string) => { | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(itemId, numValue); | |||
| } | |||
| }, [onQtyChange]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell padding="checkbox" sx={{ width: '80px', minWidth: '80px' }}> | |||
| {t("Selected")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {t("Item")} | |||
| </TableCell> | |||
| <TableCell> | |||
| {t("Group")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Current Stock")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Stock Unit")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Order Quantity")} | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {t("Target Date")} | |||
| </TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedResults.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={12} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| paginatedResults.map((item) => ( | |||
| <TableRow key={item.id}> | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| checked={selectedItemIds.includes(item.id)} | |||
| onChange={(e) => onItemSelect(item.id, e.target.checked)} | |||
| disabled={isItemInCreated(item.id)} | |||
| /> | |||
| </TableCell> | |||
| {/* Item */} | |||
| <TableCell> | |||
| <Box> | |||
| <Typography variant="body2"> | |||
| {item.label.split(' - ')[1] || item.label} | |||
| </Typography> | |||
| <Typography variant="caption" color="textSecondary"> | |||
| {item.label.split(' - ')[0] || ''} | |||
| </Typography> | |||
| </Box> | |||
| </TableCell> | |||
| {/* Group */} | |||
| <TableCell> | |||
| <FormControl size="small" sx={{ minWidth: 120 }}> | |||
| <Select | |||
| value={item.groupId?.toString() || ""} | |||
| onChange={(e) => onGroupChange(item.id, e.target.value)} | |||
| displayEmpty | |||
| disabled={isItemInCreated(item.id)} | |||
| > | |||
| <MenuItem value=""> | |||
| <em>{t("No Group")}</em> | |||
| </MenuItem> | |||
| {groups.map((group) => ( | |||
| <MenuItem key={group.id} value={group.id.toString()}> | |||
| {group.name} | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| </TableCell> | |||
| {/* Current Stock */} | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| color={item.currentStockBalance && item.currentStockBalance > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: item.currentStockBalance && item.currentStockBalance > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {item.currentStockBalance?.toLocaleString()||0} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Stock Unit */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.uomDesc || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| {/* Order Quantity */} | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={item.qty || ""} | |||
| onChange={(e) => { | |||
| const value = e.target.value; | |||
| // Only allow numbers | |||
| if (value === "" || /^\d+$/.test(value)) { | |||
| const numValue = value === "" ? null : Number(value); | |||
| onQtyChange(item.id, numValue); | |||
| } | |||
| }} | |||
| onBlur={() => { | |||
| // Trigger auto-add check when user finishes input (clicks elsewhere) | |||
| onQtyBlur(item.id); // ← Change this to call onQtyBlur instead! | |||
| }} | |||
| inputProps={{ | |||
| style: { textAlign: 'center' } | |||
| }} | |||
| sx={{ | |||
| width: '80px', | |||
| '& .MuiInputBase-input': { | |||
| textAlign: 'center', | |||
| cursor: 'text' | |||
| } | |||
| }} | |||
| disabled={isItemInCreated(item.id)} | |||
| /> | |||
| </TableCell> | |||
| {/* Target Date */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={items.length} | |||
| page={(pageNum - 1)} | |||
| rowsPerPage={pageSize} | |||
| onPageChange={onPageChange} | |||
| onRowsPerPageChange={onPageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default SearchResultsTable; | |||
| @@ -0,0 +1,321 @@ | |||
| "use client"; | |||
| import { | |||
| PurchaseQcResult, | |||
| PurchaseQCInput, | |||
| StockInInput, | |||
| } from "@/app/api/po/actions"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| Grid, | |||
| Stack, | |||
| TextField, | |||
| Tooltip, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { useCallback, useEffect, useMemo } from "react"; | |||
| import { | |||
| GridColDef, | |||
| GridRowIdGetter, | |||
| GridRowModel, | |||
| useGridApiContext, | |||
| GridRenderCellParams, | |||
| GridRenderEditCellParams, | |||
| useGridApiRef, | |||
| } from "@mui/x-data-grid"; | |||
| import InputDataGrid from "../InputDataGrid"; | |||
| import { TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import TwoLineCell from "./TwoLineCell"; | |||
| import QcSelect from "./QcSelect"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { GridEditInputCell } from "@mui/x-data-grid"; | |||
| import { StockInLine } from "@/app/api/po"; | |||
| import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import dayjs from "dayjs"; | |||
| // 修改接口以支持 PickOrder 数据 | |||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||
| // change PurchaseQcResult to stock in entry props | |||
| interface Props { | |||
| itemDetail: StockInLine | (GetPickOrderLineInfo & { pickOrderCode: string }); | |||
| // qc: QcItemWithChecks[]; | |||
| disabled: boolean; | |||
| } | |||
| type EntryError = | |||
| | { | |||
| [field in keyof StockInInput]?: string; | |||
| } | |||
| | undefined; | |||
| // type PoQcRow = TableRow<Partial<PurchaseQcResult>, EntryError>; | |||
| const StockInFormVer2: React.FC<Props> = ({ | |||
| // qc, | |||
| itemDetail, | |||
| disabled, | |||
| }) => { | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("purchaseOrder"); | |||
| const apiRef = useGridApiRef(); | |||
| const { | |||
| register, | |||
| formState: { errors, defaultValues, touchedFields }, | |||
| watch, | |||
| control, | |||
| setValue, | |||
| getValues, | |||
| reset, | |||
| resetField, | |||
| setError, | |||
| clearErrors, | |||
| } = useFormContext<StockInInput>(); | |||
| // console.log(itemDetail); | |||
| useEffect(() => { | |||
| console.log("triggered"); | |||
| // receiptDate default tdy | |||
| setValue("receiptDate", dayjs().add(0, "month").format(INPUT_DATE_FORMAT)); | |||
| setValue("status", "received"); | |||
| }, [setValue]); | |||
| useEffect(() => { | |||
| console.log(errors); | |||
| }, [errors]); | |||
| const productionDate = watch("productionDate"); | |||
| const expiryDate = watch("expiryDate"); | |||
| const uom = watch("uom"); | |||
| useEffect(() => { | |||
| console.log(uom); | |||
| console.log(productionDate); | |||
| console.log(expiryDate); | |||
| if (expiryDate) clearErrors(); | |||
| if (productionDate) clearErrors(); | |||
| }, [expiryDate, productionDate, clearErrors]); | |||
| // 检查是否为 PickOrder 数据 | |||
| const isPickOrderData = 'pickOrderCode' in itemDetail; | |||
| // 获取 UOM 显示值 | |||
| const getUomDisplayValue = () => { | |||
| if (isPickOrderData) { | |||
| // PickOrder 数据 | |||
| const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string }; | |||
| return pickOrderItem.uomDesc || pickOrderItem.uomCode || ''; | |||
| } else { | |||
| // StockIn 数据 | |||
| const stockInItem = itemDetail as StockInLine; | |||
| return uom?.code || stockInItem.uom?.code || ''; | |||
| } | |||
| }; | |||
| // 获取 Item 显示值 | |||
| const getItemDisplayValue = () => { | |||
| if (isPickOrderData) { | |||
| // PickOrder 数据 | |||
| const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string }; | |||
| return pickOrderItem.itemCode || ''; | |||
| } else { | |||
| // StockIn 数据 | |||
| const stockInItem = itemDetail as StockInLine; | |||
| return stockInItem.itemNo || ''; | |||
| } | |||
| }; | |||
| // 获取 Item Name 显示值 | |||
| const getItemNameDisplayValue = () => { | |||
| if (isPickOrderData) { | |||
| // PickOrder 数据 | |||
| const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string }; | |||
| return pickOrderItem.itemName || ''; | |||
| } else { | |||
| // StockIn 数据 | |||
| const stockInItem = itemDetail as StockInLine; | |||
| return stockInItem.itemName || ''; | |||
| } | |||
| }; | |||
| // 获取 Quantity 显示值 | |||
| const getQuantityDisplayValue = () => { | |||
| if (isPickOrderData) { | |||
| // PickOrder 数据 | |||
| const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string }; | |||
| return pickOrderItem.requiredQty || 0; | |||
| } else { | |||
| // StockIn 数据 | |||
| const stockInItem = itemDetail as StockInLine; | |||
| return stockInItem.acceptedQty || 0; | |||
| } | |||
| }; | |||
| return ( | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("stock in information")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("itemNo")} | |||
| fullWidth | |||
| {...register("itemNo", { | |||
| required: "itemNo required!", | |||
| })} | |||
| value={getItemDisplayValue()} | |||
| disabled={true} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("itemName")} | |||
| fullWidth | |||
| {...register("itemName", { | |||
| required: "itemName required!", | |||
| })} | |||
| value={getItemNameDisplayValue()} | |||
| disabled={true} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| name="productionDate" | |||
| control={control} | |||
| rules={{ | |||
| required: "productionDate required!", | |||
| }} | |||
| render={({ field }) => { | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DatePicker | |||
| {...field} | |||
| sx={{ width: "100%" }} | |||
| label={t("productionDate")} | |||
| value={productionDate ? dayjs(productionDate) : undefined} | |||
| disabled={disabled} | |||
| onChange={(date) => { | |||
| console.log(date); | |||
| if (!date) return; | |||
| console.log(date.format(INPUT_DATE_FORMAT)); | |||
| setValue("productionDate", date.format(INPUT_DATE_FORMAT)); | |||
| // field.onChange(date); | |||
| }} | |||
| inputRef={field.ref} | |||
| slotProps={{ | |||
| textField: { | |||
| // required: true, | |||
| error: Boolean(errors.productionDate?.message), | |||
| helperText: errors.productionDate?.message, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| name="expiryDate" | |||
| control={control} | |||
| rules={{ | |||
| required: "expiryDate required!", | |||
| }} | |||
| render={({ field }) => { | |||
| return ( | |||
| <LocalizationProvider | |||
| dateAdapter={AdapterDayjs} | |||
| adapterLocale={`${language}-hk`} | |||
| > | |||
| <DatePicker | |||
| {...field} | |||
| sx={{ width: "100%" }} | |||
| label={t("expiryDate")} | |||
| value={expiryDate ? dayjs(expiryDate) : undefined} | |||
| disabled={disabled} | |||
| onChange={(date) => { | |||
| console.log(date); | |||
| if (!date) return; | |||
| console.log(date.format(INPUT_DATE_FORMAT)); | |||
| setValue("expiryDate", date.format(INPUT_DATE_FORMAT)); | |||
| // field.onChange(date); | |||
| }} | |||
| inputRef={field.ref} | |||
| slotProps={{ | |||
| textField: { | |||
| // required: true, | |||
| error: Boolean(errors.expiryDate?.message), | |||
| helperText: errors.expiryDate?.message, | |||
| }, | |||
| }} | |||
| /> | |||
| </LocalizationProvider> | |||
| ); | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("receivedQty")} | |||
| fullWidth | |||
| {...register("receivedQty", { | |||
| required: "receivedQty required!", | |||
| })} | |||
| value={getQuantityDisplayValue()} | |||
| disabled={true} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("uom")} | |||
| fullWidth | |||
| {...register("uom", { | |||
| required: "uom required!", | |||
| })} | |||
| value={getUomDisplayValue()} | |||
| disabled={true} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <TextField | |||
| label={t("acceptedQty")} | |||
| fullWidth | |||
| {...register("acceptedQty", { | |||
| required: "acceptedQty required!", | |||
| })} | |||
| value={getQuantityDisplayValue()} | |||
| disabled={true} | |||
| // disabled={disabled} | |||
| // error={Boolean(errors.acceptedQty)} | |||
| // helperText={errors.acceptedQty?.message} | |||
| /> | |||
| </Grid> | |||
| {/* <Grid item xs={4}> | |||
| <TextField | |||
| label={t("acceptedWeight")} | |||
| fullWidth | |||
| // {...register("acceptedWeight", { | |||
| // required: "acceptedWeight required!", | |||
| // })} | |||
| disabled={disabled} | |||
| error={Boolean(errors.acceptedWeight)} | |||
| helperText={errors.acceptedWeight?.message} | |||
| /> | |||
| </Grid> */} | |||
| </Grid> | |||
| ); | |||
| }; | |||
| export default StockInFormVer2; | |||
| @@ -0,0 +1,24 @@ | |||
| import { Box, Tooltip } from "@mui/material"; | |||
| import React from "react"; | |||
| const TwoLineCell: React.FC<{ children: React.ReactNode }> = ({ children }) => { | |||
| return ( | |||
| <Tooltip title={children}> | |||
| <Box | |||
| sx={{ | |||
| whiteSpace: "normal", | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| display: "-webkit-box", | |||
| WebkitLineClamp: 2, | |||
| WebkitBoxOrient: "vertical", | |||
| lineHeight: "22px", | |||
| }} | |||
| > | |||
| {children} | |||
| </Box> | |||
| </Tooltip> | |||
| ); | |||
| }; | |||
| export default TwoLineCell; | |||
| @@ -0,0 +1,73 @@ | |||
| import { ItemCombo } from "@/app/api/settings/item/actions"; | |||
| import { Autocomplete, TextField } from "@mui/material"; | |||
| import { useCallback, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface CommonProps { | |||
| allUom: ItemCombo[]; | |||
| error?: boolean; | |||
| } | |||
| interface SingleAutocompleteProps extends CommonProps { | |||
| value: number | string | undefined; | |||
| onUomSelect: (itemId: number) => void | Promise<void>; | |||
| // multiple: false; | |||
| } | |||
| type Props = SingleAutocompleteProps; | |||
| const UomSelect: React.FC<Props> = ({ | |||
| allUom, | |||
| value, | |||
| error, | |||
| onUomSelect | |||
| }) => { | |||
| const { t } = useTranslation("item"); | |||
| const filteredUom = useMemo(() => { | |||
| return allUom | |||
| }, [allUom]) | |||
| const options = useMemo(() => { | |||
| return [ | |||
| { | |||
| value: -1, // think think sin | |||
| label: t("None"), | |||
| group: "default", | |||
| }, | |||
| ...filteredUom.map((i) => ({ | |||
| value: i.id as number, | |||
| label: i.label, | |||
| group: "existing", | |||
| })), | |||
| ]; | |||
| }, [t, filteredUom]); | |||
| const currentValue = options.find((o) => o.value === value) || options[0]; | |||
| const onChange = useCallback( | |||
| ( | |||
| event: React.SyntheticEvent, | |||
| newValue: { value: number; group: string } | { value: number }[], | |||
| ) => { | |||
| const singleNewVal = newValue as { | |||
| value: number; | |||
| group: string; | |||
| }; | |||
| onUomSelect(singleNewVal.value) | |||
| } | |||
| , [onUomSelect]) | |||
| return ( | |||
| <Autocomplete | |||
| noOptionsText={t("No Uom")} | |||
| disableClearable | |||
| fullWidth | |||
| value={currentValue} | |||
| onChange={onChange} | |||
| getOptionLabel={(option) => option.label} | |||
| options={options} | |||
| renderInput={(params) => <TextField {...params} error={error} />} | |||
| /> | |||
| ); | |||
| } | |||
| export default UomSelect | |||
| @@ -0,0 +1,85 @@ | |||
| import { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useState } from "react"; | |||
| import { Card, CardContent, Typography, Grid, TextField, Button, Stack } from "@mui/material"; | |||
| import { RestartAlt, Search } from "@mui/icons-material"; | |||
| import { Autocomplete } from "@mui/material"; | |||
| const VerticalSearchBox = ({ criteria, onSearch, onReset }: { | |||
| criteria: Criterion<any>[]; | |||
| onSearch: (inputs: Record<string, any>) => void; | |||
| onReset?: () => void; | |||
| }) => { | |||
| const { t } = useTranslation("common"); | |||
| const [inputs, setInputs] = useState<Record<string, any>>({}); | |||
| const handleInputChange = (paramName: string, value: any) => { | |||
| setInputs(prev => ({ ...prev, [paramName]: value })); | |||
| }; | |||
| const handleSearch = () => { | |||
| onSearch(inputs); | |||
| }; | |||
| const handleReset = () => { | |||
| setInputs({}); | |||
| onReset?.(); | |||
| }; | |||
| return ( | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Search Criteria")}</Typography> | |||
| <Grid container spacing={2} columns={{ xs: 12, sm: 12 }}> | |||
| {criteria.map((c) => { | |||
| return ( | |||
| <Grid key={c.paramName} item xs={12}> | |||
| {c.type === "text" && ( | |||
| <TextField | |||
| label={t(c.label)} | |||
| fullWidth | |||
| onChange={(e) => handleInputChange(c.paramName, e.target.value)} | |||
| value={inputs[c.paramName] || ""} | |||
| /> | |||
| )} | |||
| {c.type === "autocomplete" && ( | |||
| <Autocomplete | |||
| options={c.options || []} | |||
| getOptionLabel={(option: any) => option.label} | |||
| onChange={(_, value: any) => handleInputChange(c.paramName, value?.value || "")} | |||
| value={c.options?.find(option => option.value === inputs[c.paramName]) || null} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| label={t(c.label)} | |||
| fullWidth | |||
| /> | |||
| )} | |||
| /> | |||
| )} | |||
| </Grid> | |||
| ); | |||
| })} | |||
| </Grid> | |||
| <Stack direction="row" spacing={2} sx={{ mt: 2 }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default VerticalSearchBox; | |||
| @@ -0,0 +1,511 @@ | |||
| "use client"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| FormControl, | |||
| Grid, | |||
| Modal, | |||
| TextField, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Checkbox, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| newassignPickOrder, | |||
| AssignPickOrderInputs, | |||
| releaseAssignedPickOrders, | |||
| fetchPickOrderWithStockClient, // Add this import | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import { | |||
| FormProvider, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import { isEmpty, upperFirst, groupBy } from "lodash"; | |||
| import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { sortBy, uniqBy } from "lodash"; | |||
| import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| // Update the interface to match the new API response structure | |||
| interface PickOrderRow { | |||
| id: string; | |||
| code: string; | |||
| targetDate: string; | |||
| type: string; | |||
| status: string; | |||
| assignTo: number; | |||
| groupName: string; | |||
| consoCode?: string; | |||
| pickOrderLines: PickOrderLineRow[]; | |||
| } | |||
| interface PickOrderLineRow { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| availableQty: number | null; | |||
| requiredQty: number; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| suggestedList: any[]; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [isUploading, setIsUploadingLocal] = useState(false); | |||
| // Update state to use pick order data directly | |||
| const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<string[]>([]); | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState<PickOrderRow[]>([]); | |||
| const [isLoadingItems, setIsLoadingItems] = useState(false); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const [totalCountItems, setTotalCountItems] = useState<number>(); | |||
| const [modalOpen, setModalOpen] = useState(false); | |||
| const [usernameList, setUsernameList] = useState<NameList[]>([]); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]); | |||
| const formProps = useForm<AssignPickOrderInputs>(); | |||
| const errors = formProps.formState.errors; | |||
| // Update the handler functions to work with string IDs | |||
| const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => { | |||
| if (checked) { | |||
| setSelectedPickOrderIds(prev => [...prev, pickOrderId]); | |||
| } else { | |||
| setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId)); | |||
| } | |||
| }, []); | |||
| const isPickOrderSelected = useCallback((pickOrderId: string) => { | |||
| return selectedPickOrderIds.includes(pickOrderId); | |||
| }, [selectedPickOrderIds]); | |||
| // Update the fetch function to use the correct endpoint | |||
| const fetchNewPageItems = useCallback( | |||
| async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => { | |||
| setIsLoadingItems(true); | |||
| try { | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| pageNum: (pagingController.pageNum || 1) - 1, | |||
| pageSize: pagingController.pageSize || 10, | |||
| // Filter for assigned status only | |||
| status: "assigned" | |||
| }; | |||
| const res = await fetchPickOrderWithStockClient(params); | |||
| if (res && res.records) { | |||
| // Convert pick order data to the expected format | |||
| const pickOrderRows: PickOrderRow[] = res.records.map((pickOrder: any) => ({ | |||
| id: pickOrder.id, | |||
| code: pickOrder.code, | |||
| targetDate: pickOrder.targetDate, | |||
| type: pickOrder.type, | |||
| status: pickOrder.status, | |||
| assignTo: pickOrder.assignTo, | |||
| groupName: pickOrder.groupName || "No Group", | |||
| consoCode: pickOrder.consoCode, | |||
| pickOrderLines: pickOrder.pickOrderLines || [] | |||
| })); | |||
| setOriginalPickOrderData(pickOrderRows); | |||
| setFilteredPickOrders(pickOrderRows); | |||
| setTotalCountItems(res.total); | |||
| } else { | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching pick orders:", error); | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } finally { | |||
| setIsLoadingItems(false); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| // Handle Release operation | |||
| // Handle Release operation | |||
| const handleRelease = useCallback(async () => { | |||
| if (selectedPickOrderIds.length === 0) return; | |||
| setIsUploading(true); | |||
| try { | |||
| // Get the assigned user from the selected pick orders | |||
| const selectedPickOrders = filteredPickOrders.filter(pickOrder => | |||
| selectedPickOrderIds.includes(pickOrder.id) | |||
| ); | |||
| // Check if all selected pick orders have the same assigned user | |||
| const assignedUsers = selectedPickOrders.map(po => po.assignTo).filter(Boolean); | |||
| if (assignedUsers.length === 0) { | |||
| alert("Selected pick orders are not assigned to any user."); | |||
| return; | |||
| } | |||
| const assignToValue = assignedUsers[0]; | |||
| // Validate that all pick orders are assigned to the same user | |||
| const allSameUser = assignedUsers.every(userId => userId === assignToValue); | |||
| if (!allSameUser) { | |||
| alert("All selected pick orders must be assigned to the same user."); | |||
| return; | |||
| } | |||
| console.log("Using assigned user:", assignToValue); | |||
| console.log("selectedPickOrderIds:", selectedPickOrderIds); | |||
| const releaseRes = await releaseAssignedPickOrders({ | |||
| pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)), | |||
| assignTo: assignToValue | |||
| }); | |||
| if (releaseRes.code === "SUCCESS") { | |||
| console.log("Pick orders released successfully"); | |||
| // Get the consoCode from the response | |||
| const consoCode = (releaseRes.entity as any)?.consoCode; | |||
| if (consoCode) { | |||
| // Create StockOutLine records for each pick order line | |||
| for (const pickOrder of selectedPickOrders) { | |||
| for (const line of pickOrder.pickOrderLines) { | |||
| try { | |||
| const stockOutLineData = { | |||
| consoCode: consoCode, | |||
| pickOrderLineId: line.id, | |||
| inventoryLotLineId: 0, // This will be set when user scans QR code | |||
| qty: line.requiredQty, | |||
| }; | |||
| console.log("Creating stock out line:", stockOutLineData); | |||
| await createStockOutLine(stockOutLineData); | |||
| } catch (error) { | |||
| console.error("Error creating stock out line for line", line.id, error); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Release failed:", releaseRes.message); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error releasing pick orders:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [selectedPickOrderIds, filteredPickOrders, setIsUploading, fetchNewPageItems, pagingController, filterArgs]); | |||
| // Update search criteria to match the new data structure | |||
| const searchCriteria: Criterion<any>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Pick Order Code"), | |||
| paramName: "code", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Group Code"), | |||
| paramName: "groupName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Target Date From"), | |||
| label2: t("Target Date To"), | |||
| paramName: "targetDate", | |||
| type: "dateRange", | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| // Update search function to work with pick order data | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| const filtered = originalPickOrderData.filter((pickOrder) => { | |||
| const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate); | |||
| const codeMatch = !query.code || | |||
| pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase()); | |||
| const groupNameMatch = !query.groupName || | |||
| pickOrder.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase()); | |||
| // Date range search | |||
| let dateMatch = true; | |||
| if (query.targetDate || query.targetDateTo) { | |||
| try { | |||
| if (query.targetDate && !query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| dateMatch = pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day'); | |||
| } else if (!query.targetDate && query.targetDateTo) { | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day'); | |||
| } else if (query.targetDate && query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = (pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day')) && | |||
| (pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day')); | |||
| } | |||
| } catch (error) { | |||
| console.error("Date parsing error:", error); | |||
| dateMatch = true; | |||
| } | |||
| } | |||
| return codeMatch && groupNameMatch && dateMatch; | |||
| }); | |||
| setFilteredPickOrders(filtered); | |||
| }, [originalPickOrderData]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| setFilteredPickOrders(originalPickOrderData); | |||
| setTimeout(() => { | |||
| setSearchQuery({}); | |||
| }, 0); | |||
| }, [originalPickOrderData]); | |||
| // Pagination handlers | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, [pagingController]); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, []); | |||
| // Component mount effect | |||
| useEffect(() => { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| }, []); | |||
| // Dependencies change effect | |||
| useEffect(() => { | |||
| if (pagingController && (filterArgs || {})) { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| } | |||
| }, [pagingController, filterArgs, fetchNewPageItems]); | |||
| useEffect(() => { | |||
| const loadUsernameList = async () => { | |||
| try { | |||
| const res = await fetchNameList(); | |||
| if (res) { | |||
| setUsernameList(res); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error loading username list:", error); | |||
| } | |||
| }; | |||
| loadUsernameList(); | |||
| }, []); | |||
| // Update the table component to work with pick order data directly | |||
| const CustomPickOrderTable = () => { | |||
| // Helper function to get user name | |||
| const getUserName = useCallback((assignToId: number | null | undefined) => { | |||
| if (!assignToId) return '-'; | |||
| const user = usernameList.find(u => u.id === assignToId); | |||
| return user ? user.name : `User ${assignToId}`; | |||
| }, [usernameList]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Group Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||
| <TableCell align="right">{t("Stock Unit")}</TableCell> | |||
| <TableCell>{t("Target Date")}</TableCell> | |||
| <TableCell>{t("Assigned To")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {filteredPickOrders.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={10} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| filteredPickOrders.map((pickOrder) => ( | |||
| pickOrder.pickOrderLines.map((line: PickOrderLineRow, index: number) => ( | |||
| <TableRow key={`${pickOrder.id}-${line.id}`}> | |||
| {/* Checkbox - only show for first line of each pick order */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Checkbox | |||
| checked={isPickOrderSelected(pickOrder.id)} | |||
| onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)} | |||
| disabled={!isEmpty(pickOrder.consoCode)} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Code - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.code : null} | |||
| </TableCell> | |||
| {/* Group Name - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.groupName : null} | |||
| </TableCell> | |||
| {/* Item Code */} | |||
| <TableCell>{line.itemCode}</TableCell> | |||
| {/* Item Name */} | |||
| <TableCell>{line.itemName}</TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right">{line.requiredQty}</TableCell> | |||
| {/* Current Stock */} | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| color={line.availableQty && line.availableQty > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: line.availableQty && line.availableQty > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {(line.availableQty || 0).toLocaleString()} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Unit */} | |||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||
| {/* Target Date - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(pickOrder.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Assigned To - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Typography variant="body2"> | |||
| {getUserName(pickOrder.assignTo)} | |||
| </Typography> | |||
| ) : null} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCountItems || 0} | |||
| page={(pagingController.pageNum - 1)} | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50, 100]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| return ( | |||
| <> | |||
| <SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} /> | |||
| <Grid container rowGap={1}> | |||
| <Grid item xs={12}> | |||
| {isLoadingItems ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <CustomPickOrderTable /> | |||
| )} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}> | |||
| <Button | |||
| disabled={selectedPickOrderIds.length < 1} | |||
| variant="outlined" | |||
| onClick={handleRelease} | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| </Box> | |||
| </Grid> | |||
| </Grid> | |||
| </> | |||
| ); | |||
| }; | |||
| export default AssignTo; | |||
| @@ -0,0 +1,511 @@ | |||
| "use client"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| CircularProgress, | |||
| FormControl, | |||
| Grid, | |||
| Modal, | |||
| TextField, | |||
| Typography, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| Checkbox, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| newassignPickOrder, | |||
| AssignPickOrderInputs, | |||
| releaseAssignedPickOrders, | |||
| fetchPickOrderWithStockClient, // Add this import | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import { | |||
| FormProvider, | |||
| useForm, | |||
| } from "react-hook-form"; | |||
| import { isEmpty, upperFirst, groupBy } from "lodash"; | |||
| import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { sortBy, uniqBy } from "lodash"; | |||
| import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| // Update the interface to match the new API response structure | |||
| interface PickOrderRow { | |||
| id: string; | |||
| code: string; | |||
| targetDate: string; | |||
| type: string; | |||
| status: string; | |||
| assignTo: number; | |||
| groupName: string; | |||
| consoCode?: string; | |||
| pickOrderLines: PickOrderLineRow[]; | |||
| } | |||
| interface PickOrderLineRow { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| availableQty: number | null; | |||
| requiredQty: number; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| suggestedList: any[]; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| width: { xs: "100%", sm: "100%", md: "100%" }, | |||
| }; | |||
| const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [isUploading, setIsUploadingLocal] = useState(false); | |||
| // Update state to use pick order data directly | |||
| const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<string[]>([]); | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState<PickOrderRow[]>([]); | |||
| const [isLoadingItems, setIsLoadingItems] = useState(false); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const [totalCountItems, setTotalCountItems] = useState<number>(); | |||
| const [modalOpen, setModalOpen] = useState(false); | |||
| const [usernameList, setUsernameList] = useState<NameList[]>([]); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]); | |||
| const formProps = useForm<AssignPickOrderInputs>(); | |||
| const errors = formProps.formState.errors; | |||
| // Update the handler functions to work with string IDs | |||
| const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => { | |||
| if (checked) { | |||
| setSelectedPickOrderIds(prev => [...prev, pickOrderId]); | |||
| } else { | |||
| setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId)); | |||
| } | |||
| }, []); | |||
| const isPickOrderSelected = useCallback((pickOrderId: string) => { | |||
| return selectedPickOrderIds.includes(pickOrderId); | |||
| }, [selectedPickOrderIds]); | |||
| // Update the fetch function to use the correct endpoint | |||
| const fetchNewPageItems = useCallback( | |||
| async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => { | |||
| setIsLoadingItems(true); | |||
| try { | |||
| const params = { | |||
| ...pagingController, | |||
| ...filterArgs, | |||
| pageNum: (pagingController.pageNum || 1) - 1, | |||
| pageSize: pagingController.pageSize || 10, | |||
| // Filter for assigned status only | |||
| status: "assigned" | |||
| }; | |||
| const res = await fetchPickOrderWithStockClient(params); | |||
| if (res && res.records) { | |||
| // Convert pick order data to the expected format | |||
| const pickOrderRows: PickOrderRow[] = res.records.map((pickOrder: any) => ({ | |||
| id: pickOrder.id, | |||
| code: pickOrder.code, | |||
| targetDate: pickOrder.targetDate, | |||
| type: pickOrder.type, | |||
| status: pickOrder.status, | |||
| assignTo: pickOrder.assignTo, | |||
| groupName: pickOrder.groupName || "No Group", | |||
| consoCode: pickOrder.consoCode, | |||
| pickOrderLines: pickOrder.pickOrderLines || [] | |||
| })); | |||
| setOriginalPickOrderData(pickOrderRows); | |||
| setFilteredPickOrders(pickOrderRows); | |||
| setTotalCountItems(res.total); | |||
| } else { | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching pick orders:", error); | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } finally { | |||
| setIsLoadingItems(false); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| // Handle Release operation | |||
| // Handle Release operation | |||
| const handleRelease = useCallback(async () => { | |||
| if (selectedPickOrderIds.length === 0) return; | |||
| setIsUploading(true); | |||
| try { | |||
| // Get the assigned user from the selected pick orders | |||
| const selectedPickOrders = filteredPickOrders.filter(pickOrder => | |||
| selectedPickOrderIds.includes(pickOrder.id) | |||
| ); | |||
| // Check if all selected pick orders have the same assigned user | |||
| const assignedUsers = selectedPickOrders.map(po => po.assignTo).filter(Boolean); | |||
| if (assignedUsers.length === 0) { | |||
| alert("Selected pick orders are not assigned to any user."); | |||
| return; | |||
| } | |||
| const assignToValue = assignedUsers[0]; | |||
| // Validate that all pick orders are assigned to the same user | |||
| const allSameUser = assignedUsers.every(userId => userId === assignToValue); | |||
| if (!allSameUser) { | |||
| alert("All selected pick orders must be assigned to the same user."); | |||
| return; | |||
| } | |||
| console.log("Using assigned user:", assignToValue); | |||
| console.log("selectedPickOrderIds:", selectedPickOrderIds); | |||
| const releaseRes = await releaseAssignedPickOrders({ | |||
| pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)), | |||
| assignTo: assignToValue | |||
| }); | |||
| if (releaseRes.code === "SUCCESS") { | |||
| console.log("Pick orders released successfully"); | |||
| // Get the consoCode from the response | |||
| const consoCode = (releaseRes.entity as any)?.consoCode; | |||
| if (consoCode) { | |||
| // Create StockOutLine records for each pick order line | |||
| for (const pickOrder of selectedPickOrders) { | |||
| for (const line of pickOrder.pickOrderLines) { | |||
| try { | |||
| const stockOutLineData = { | |||
| consoCode: consoCode, | |||
| pickOrderLineId: line.id, | |||
| inventoryLotLineId: 0, // This will be set when user scans QR code | |||
| qty: line.requiredQty, | |||
| }; | |||
| console.log("Creating stock out line:", stockOutLineData); | |||
| await createStockOutLine(stockOutLineData); | |||
| } catch (error) { | |||
| console.error("Error creating stock out line for line", line.id, error); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Release failed:", releaseRes.message); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error releasing pick orders:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [selectedPickOrderIds, filteredPickOrders, setIsUploading, fetchNewPageItems, pagingController, filterArgs]); | |||
| // Update search criteria to match the new data structure | |||
| const searchCriteria: Criterion<any>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Pick Order Code"), | |||
| paramName: "code", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Group Code"), | |||
| paramName: "groupName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Target Date From"), | |||
| label2: t("Target Date To"), | |||
| paramName: "targetDate", | |||
| type: "dateRange", | |||
| }, | |||
| ], | |||
| [t], | |||
| ); | |||
| // Update search function to work with pick order data | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| const filtered = originalPickOrderData.filter((pickOrder) => { | |||
| const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate); | |||
| const codeMatch = !query.code || | |||
| pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase()); | |||
| const groupNameMatch = !query.groupName || | |||
| pickOrder.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase()); | |||
| // Date range search | |||
| let dateMatch = true; | |||
| if (query.targetDate || query.targetDateTo) { | |||
| try { | |||
| if (query.targetDate && !query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| dateMatch = pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day'); | |||
| } else if (!query.targetDate && query.targetDateTo) { | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day'); | |||
| } else if (query.targetDate && query.targetDateTo) { | |||
| const fromDate = dayjs(query.targetDate); | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = (pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day')) && | |||
| (pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day')); | |||
| } | |||
| } catch (error) { | |||
| console.error("Date parsing error:", error); | |||
| dateMatch = true; | |||
| } | |||
| } | |||
| return codeMatch && groupNameMatch && dateMatch; | |||
| }); | |||
| setFilteredPickOrders(filtered); | |||
| }, [originalPickOrderData]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| setFilteredPickOrders(originalPickOrderData); | |||
| setTimeout(() => { | |||
| setSearchQuery({}); | |||
| }, 0); | |||
| }, [originalPickOrderData]); | |||
| // Pagination handlers | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, [pagingController]); | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, []); | |||
| // Component mount effect | |||
| useEffect(() => { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| }, []); | |||
| // Dependencies change effect | |||
| useEffect(() => { | |||
| if (pagingController && (filterArgs || {})) { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| } | |||
| }, [pagingController, filterArgs, fetchNewPageItems]); | |||
| useEffect(() => { | |||
| const loadUsernameList = async () => { | |||
| try { | |||
| const res = await fetchNameList(); | |||
| if (res) { | |||
| setUsernameList(res); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error loading username list:", error); | |||
| } | |||
| }; | |||
| loadUsernameList(); | |||
| }, []); | |||
| // Update the table component to work with pick order data directly | |||
| const CustomPickOrderTable = () => { | |||
| // Helper function to get user name | |||
| const getUserName = useCallback((assignToId: number | null | undefined) => { | |||
| if (!assignToId) return '-'; | |||
| const user = usernameList.find(u => u.id === assignToId); | |||
| return user ? user.name : `User ${assignToId}`; | |||
| }, [usernameList]); | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Group Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||
| <TableCell align="right">{t("Current Stock")}</TableCell> | |||
| <TableCell align="right">{t("Stock Unit")}</TableCell> | |||
| <TableCell>{t("Target Date")}</TableCell> | |||
| <TableCell>{t("Assigned To")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {filteredPickOrders.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={10} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data available")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| filteredPickOrders.map((pickOrder) => ( | |||
| pickOrder.pickOrderLines.map((line: PickOrderLineRow, index: number) => ( | |||
| <TableRow key={`${pickOrder.id}-${line.id}`}> | |||
| {/* Checkbox - only show for first line of each pick order */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Checkbox | |||
| checked={isPickOrderSelected(pickOrder.id)} | |||
| onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)} | |||
| disabled={!isEmpty(pickOrder.consoCode)} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Code - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.code : null} | |||
| </TableCell> | |||
| {/* Group Name - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.groupName : null} | |||
| </TableCell> | |||
| {/* Item Code */} | |||
| <TableCell>{line.itemCode}</TableCell> | |||
| {/* Item Name */} | |||
| <TableCell>{line.itemName}</TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right">{line.requiredQty}</TableCell> | |||
| {/* Current Stock */} | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| color={line.availableQty && line.availableQty > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: line.availableQty && line.availableQty > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {(line.availableQty || 0).toLocaleString()} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Unit */} | |||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||
| {/* Target Date - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(pickOrder.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Assigned To - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Typography variant="body2"> | |||
| {getUserName(pickOrder.assignTo)} | |||
| </Typography> | |||
| ) : null} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCountItems || 0} | |||
| page={(pagingController.pageNum - 1)} | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50, 100]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| return ( | |||
| <> | |||
| <SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} /> | |||
| <Grid container rowGap={1}> | |||
| <Grid item xs={12}> | |||
| {isLoadingItems ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <CustomPickOrderTable /> | |||
| )} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}> | |||
| <Button | |||
| disabled={selectedPickOrderIds.length < 1} | |||
| variant="outlined" | |||
| onClick={handleRelease} | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| </Box> | |||
| </Grid> | |||
| </Grid> | |||
| </> | |||
| ); | |||
| }; | |||
| export default AssignTo; | |||
| @@ -0,0 +1,78 @@ | |||
| import { PutAwayLine } from "@/app/api/po/actions" | |||
| export interface QcData { | |||
| id: number, | |||
| qcItem: string, | |||
| qcDescription: string, | |||
| isPassed: boolean | undefined | |||
| failedQty: number | undefined | |||
| remarks: string | undefined | |||
| } | |||
| export const dummyQCData: QcData[] = [ | |||
| { | |||
| id: 1, | |||
| qcItem: "包裝", | |||
| qcDescription: "有破爛、污糟、脹袋、積水、與實物不符等任何一種情況,則不合格", | |||
| isPassed: undefined, | |||
| failedQty: undefined, | |||
| remarks: undefined, | |||
| }, | |||
| { | |||
| id: 2, | |||
| qcItem: "肉質", | |||
| qcDescription: "肉質鬆散,則不合格", | |||
| isPassed: undefined, | |||
| failedQty: undefined, | |||
| remarks: undefined, | |||
| }, | |||
| { | |||
| id: 3, | |||
| qcItem: "顔色", | |||
| qcDescription: "不是食材應有的顔色、顔色不均匀、出現其他顔色、腌料/醬顔色不均匀,油脂部分變綠色、黃色,", | |||
| isPassed: undefined, | |||
| failedQty: undefined, | |||
| remarks: undefined, | |||
| }, | |||
| { | |||
| id: 4, | |||
| qcItem: "狀態", | |||
| qcDescription: "有結晶、結霜、解凍跡象、發霉、散發異味等任何一種情況,則不合格", | |||
| isPassed: undefined, | |||
| failedQty: undefined, | |||
| remarks: undefined, | |||
| }, | |||
| { | |||
| id: 5, | |||
| qcItem: "異物", | |||
| qcDescription: "有不屬於本食材的雜質,則不合格", | |||
| isPassed: undefined, | |||
| failedQty: undefined, | |||
| remarks: undefined, | |||
| }, | |||
| ] | |||
| export interface EscalationData { | |||
| id: number, | |||
| escalation: string, | |||
| supervisor: string, | |||
| } | |||
| export const dummyEscalationHistory: EscalationData[] = [ | |||
| { | |||
| id: 1, | |||
| escalation: "上報1", | |||
| supervisor: "陳大文" | |||
| }, | |||
| ] | |||
| export const dummyPutawayLine: PutAwayLine[] = [ | |||
| { | |||
| id: 1, | |||
| qty: 100, | |||
| warehouseId: 1, | |||
| warehouse: "W001 - 憶兆 3樓A倉", | |||
| printQty: 100 | |||
| } | |||
| ] | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./FinishedGoodSearchWrapper"; | |||
| @@ -0,0 +1,380 @@ | |||
| "use client"; | |||
| // 修改为 PickOrder 相关的导入 | |||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Grid, | |||
| Modal, | |||
| ModalProps, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; | |||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StockInFormVer2 from "./StockInFormVer2"; | |||
| import QcFormVer2 from "./QcFormVer2"; | |||
| import PutawayForm from "./PutawayForm"; | |||
| import { dummyPutawayLine, dummyQCData, QcData } from "./dummyQcTemplate"; | |||
| import { useGridApiRef } from "@mui/x-data-grid"; | |||
| import {submitDialogWithWarning} from "../Swal/CustomAlerts"; | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: 5, | |||
| px: 5, | |||
| pb: 10, | |||
| display: "block", | |||
| width: { xs: "60%", sm: "60%", md: "60%" }, | |||
| // height: { xs: "60%", sm: "60%", md: "60%" }, | |||
| }; | |||
| // 修改接口定义 | |||
| interface CommonProps extends Omit<ModalProps, "children"> { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| setItemDetail: Dispatch< | |||
| SetStateAction< | |||
| | (GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| warehouseId?: number; | |||
| }) | |||
| | undefined | |||
| > | |||
| >; | |||
| qc?: QcItemWithChecks[]; | |||
| warehouse?: any[]; | |||
| } | |||
| interface Props extends CommonProps { | |||
| itemDetail: GetPickOrderLineInfo & { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[] | |||
| }; | |||
| } | |||
| // 修改组件名称 | |||
| const PickQcStockInModalVer2: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| itemDetail, | |||
| setItemDetail, | |||
| qc, | |||
| warehouse, | |||
| }) => { | |||
| console.log(warehouse); | |||
| // 修改翻译键 | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("pickOrder"); | |||
| const [qcItems, setQcItems] = useState(dummyQCData) | |||
| const formProps = useForm<any>({ | |||
| defaultValues: { | |||
| ...itemDetail, | |||
| putAwayLine: dummyPutawayLine, | |||
| // receiptDate: itemDetail.receiptDate || dayjs().add(-1, "month").format(INPUT_DATE_FORMAT), | |||
| // warehouseId: itemDetail.defaultWarehouseId || 0 | |||
| }, | |||
| }); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| onClose?.(...args); | |||
| // reset(); | |||
| }, | |||
| [onClose], | |||
| ); | |||
| const [openPutaway, setOpenPutaway] = useState(false); | |||
| const onOpenPutaway = useCallback(() => { | |||
| setOpenPutaway(true); | |||
| }, []); | |||
| const onClosePutaway = useCallback(() => { | |||
| setOpenPutaway(false); | |||
| }, []); | |||
| // Stock In submission handler | |||
| const onSubmitStockIn = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| console.log("Stock In Submission:", event!.nativeEvent); | |||
| // Extract only stock-in related fields | |||
| const stockInData = { | |||
| // quantity: data.quantity, | |||
| // receiptDate: data.receiptDate, | |||
| // batchNumber: data.batchNumber, | |||
| // expiryDate: data.expiryDate, | |||
| // warehouseId: data.warehouseId, | |||
| // location: data.location, | |||
| // unitCost: data.unitCost, | |||
| data: data, | |||
| // Add other stock-in specific fields from your form | |||
| }; | |||
| console.log("Stock In Data:", stockInData); | |||
| // Handle stock-in submission logic here | |||
| // e.g., call API, update state, etc. | |||
| }, | |||
| [], | |||
| ); | |||
| // QC submission handler | |||
| const onSubmitQc = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| console.log("QC Submission:", event!.nativeEvent); | |||
| // Get QC data from the shared form context | |||
| const qcAccept = data.qcAccept; | |||
| const acceptQty = data.acceptQty; | |||
| // Validate QC data | |||
| const validationErrors : string[] = []; | |||
| // Check if all QC items have results | |||
| const itemsWithoutResult = qcItems.filter(item => item.isPassed === undefined); | |||
| if (itemsWithoutResult.length > 0) { | |||
| validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.qcItem).join(', ')}`); | |||
| } | |||
| // Check if failed items have failed quantity | |||
| const failedItemsWithoutQty = qcItems.filter(item => | |||
| item.isPassed === false && (!item.failedQty || item.failedQty <= 0) | |||
| ); | |||
| if (failedItemsWithoutQty.length > 0) { | |||
| validationErrors.push(`${t("Failed items must have failed quantity")}: ${failedItemsWithoutQty.map(item => item.qcItem).join(', ')}`); | |||
| } | |||
| // Check if QC accept decision is made | |||
| // if (qcAccept === undefined) { | |||
| // validationErrors.push("QC accept/reject decision is required"); | |||
| // } | |||
| // Check if accept quantity is valid | |||
| if (acceptQty === undefined || acceptQty <= 0) { | |||
| validationErrors.push("Accept quantity must be greater than 0"); | |||
| } | |||
| if (validationErrors.length > 0) { | |||
| console.error("QC Validation failed:", validationErrors); | |||
| alert(`未完成品檢: ${validationErrors}`); | |||
| return; | |||
| } | |||
| const qcData = { | |||
| qcAccept: qcAccept, | |||
| acceptQty: acceptQty, | |||
| qcItems: qcItems.map(item => ({ | |||
| id: item.id, | |||
| qcItem: item.qcItem, | |||
| qcDescription: item.qcDescription, | |||
| isPassed: item.isPassed, | |||
| failedQty: (item.failedQty && !item.isPassed) || 0, | |||
| remarks: item.remarks || '' | |||
| })) | |||
| }; | |||
| // const qcData = data; | |||
| console.log("QC Data for submission:", qcData); | |||
| // await submitQcData(qcData); | |||
| if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) { | |||
| submitDialogWithWarning(onOpenPutaway, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""}); | |||
| return; | |||
| } | |||
| if (qcData.qcAccept) { | |||
| onOpenPutaway(); | |||
| } else { | |||
| onClose(); | |||
| } | |||
| }, | |||
| [onOpenPutaway, qcItems], | |||
| ); | |||
| // Email supplier handler | |||
| const onSubmitEmailSupplier = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| console.log("Email Supplier Submission:", event!.nativeEvent); | |||
| // Extract only email supplier related fields | |||
| const emailData = { | |||
| // supplierEmail: data.supplierEmail, | |||
| // issueDescription: data.issueDescription, | |||
| // qcComments: data.qcComments, | |||
| // defectNotes: data.defectNotes, | |||
| // attachments: data.attachments, | |||
| // escalationReason: data.escalationReason, | |||
| data: data, | |||
| // Add other email-specific fields | |||
| }; | |||
| console.log("Email Supplier Data:", emailData); | |||
| // Handle email supplier logic here | |||
| // e.g., send email to supplier, log escalation, etc. | |||
| }, | |||
| [], | |||
| ); | |||
| // Putaway submission handler | |||
| const onSubmitPutaway = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| console.log("Putaway Submission:", event!.nativeEvent); | |||
| // Extract only putaway related fields | |||
| const putawayData = { | |||
| // putawayLine: data.putawayLine, | |||
| // putawayLocation: data.putawayLocation, | |||
| // binLocation: data.binLocation, | |||
| // putawayQuantity: data.putawayQuantity, | |||
| // putawayNotes: data.putawayNotes, | |||
| data: data, | |||
| // Add other putaway specific fields | |||
| }; | |||
| console.log("Putaway Data:", putawayData); | |||
| // Handle putaway submission logic here | |||
| // Close modal after successful putaway | |||
| closeHandler({}, "backdropClick"); | |||
| }, | |||
| [closeHandler], | |||
| ); | |||
| // Print handler | |||
| const onPrint = useCallback(() => { | |||
| console.log("Print putaway documents"); | |||
| // Handle print logic here | |||
| window.print(); | |||
| }, []); | |||
| const acceptQty = formProps.watch("acceptedQty") | |||
| const checkQcIsPassed = useCallback((qcItems: QcData[]) => { | |||
| const isPassed = qcItems.every((qc) => qc.isPassed); | |||
| console.log(isPassed) | |||
| if (isPassed) { | |||
| formProps.setValue("passingQty", acceptQty) | |||
| } else { | |||
| formProps.setValue("passingQty", 0) | |||
| } | |||
| return isPassed | |||
| }, [acceptQty, formProps]) | |||
| useEffect(() => { | |||
| // maybe check if submitted before | |||
| console.log(qcItems) | |||
| checkQcIsPassed(qcItems) | |||
| }, [qcItems, checkQcIsPassed]) | |||
| return ( | |||
| <> | |||
| <FormProvider {...formProps}> | |||
| <Modal open={open} onClose={closeHandler}> | |||
| <Box | |||
| sx={{ | |||
| ...style, | |||
| padding: 2, | |||
| maxHeight: "90vh", | |||
| overflowY: "auto", | |||
| marginLeft: 3, | |||
| marginRight: 3, | |||
| }} | |||
| > | |||
| {openPutaway ? ( | |||
| <Box | |||
| component="form" | |||
| onSubmit={formProps.handleSubmit(onSubmitPutaway)} | |||
| > | |||
| <PutawayForm | |||
| itemDetail={itemDetail} | |||
| warehouse={warehouse!} | |||
| disabled={false} | |||
| /> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| id="printButton" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| onClick={onPrint} | |||
| > | |||
| {t("print")} | |||
| </Button> | |||
| <Button | |||
| id="putawaySubmit" | |||
| type="submit" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| > | |||
| {t("confirm putaway")} | |||
| </Button> | |||
| </Stack> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| > | |||
| <Grid item xs={12}> | |||
| <Typography variant="h6" display="block" marginBlockEnd={1}> | |||
| {t("qc processing")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <StockInFormVer2 itemDetail={itemDetail} disabled={false} /> | |||
| </Grid> | |||
| </Grid> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| id="stockInSubmit" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={formProps.handleSubmit(onSubmitStockIn)} | |||
| > | |||
| {t("submitStockIn")} | |||
| </Button> | |||
| </Stack> | |||
| <Grid | |||
| container | |||
| justifyContent="flex-start" | |||
| alignItems="flex-start" | |||
| > | |||
| <QcFormVer2 | |||
| qc={qc!} | |||
| itemDetail={itemDetail} | |||
| disabled={false} | |||
| qcItems={qcItems} | |||
| setQcItems={setQcItems} | |||
| /> | |||
| </Grid> | |||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | |||
| <Button | |||
| id="emailSupplier" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| onClick={formProps.handleSubmit(onSubmitEmailSupplier)} | |||
| > | |||
| {t("email supplier")} | |||
| </Button> | |||
| <Button | |||
| id="qcSubmit" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| onClick={formProps.handleSubmit(onSubmitQc)} | |||
| > | |||
| {t("confirm putaway")} | |||
| </Button> | |||
| </Stack> | |||
| </> | |||
| )} | |||
| </Box> | |||
| </Modal> | |||
| </FormProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default PickQcStockInModalVer2; | |||
| @@ -84,6 +84,11 @@ const NavigationContent: React.FC = () => { | |||
| label: "Put Away Scan", | |||
| path: "/putAway", | |||
| }, | |||
| { | |||
| icon: <RequestQuote />, | |||
| label: "Finished Good", | |||
| path: "/finishedGood", | |||
| }, | |||
| ], | |||
| }, | |||
| // { | |||
| @@ -70,6 +70,8 @@ import { dummyQCData } from "../PoDetail/dummyQcTemplate"; | |||
| import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | |||
| import LotTable from './LotTable'; | |||
| import { updateInventoryLotLineStatus, updateInventoryStatus, updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; | |||
| import { useSession } from "next-auth/react"; // ✅ Add session import | |||
| import { SessionWithTokens } from "@/config/authConfig"; // ✅ Add custom session type | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| @@ -101,6 +103,11 @@ interface PickQtyData { | |||
| const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const router = useRouter(); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Add session | |||
| // ✅ Get current user ID from session with proper typing | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState( | |||
| [] as ConsoPickOrderResult[], | |||
| ); | |||
| @@ -252,10 +259,11 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| const handleFetchAllPickOrderDetails = useCallback(async () => { | |||
| setDetailLoading(true); | |||
| try { | |||
| const data = await fetchAllPickOrderDetails(); | |||
| // ✅ Use current user ID for filtering | |||
| const data = await fetchAllPickOrderDetails(currentUserId); | |||
| setPickOrderDetails(data); | |||
| setOriginalPickOrderData(data); // Store original data for filtering | |||
| console.log("All Pick Order Details:", data); | |||
| console.log("All Pick Order Details for user:", currentUserId, data); | |||
| const initialPickQtyData: PickQtyData = {}; | |||
| data.pickOrders.forEach((pickOrder: any) => { | |||
| @@ -270,7 +278,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| } finally { | |||
| setDetailLoading(false); | |||
| } | |||
| }, []); | |||
| }, [currentUserId]); // ✅ Add currentUserId as dependency | |||
| useEffect(() => { | |||
| handleFetchAllPickOrderDetails(); | |||
| @@ -183,5 +183,9 @@ | |||
| "Item lot to be Pick:": "批次貨品提料:", | |||
| "Report and Pick another lot": "上報並需重新選擇批號", | |||
| "Accept Stock Out": "接受出庫", | |||
| "Pick Another Lot": "重新選擇批號" | |||
| "Pick Another Lot": "重新選擇批號", | |||
| "Lot No": "批號", | |||
| "Expiry Date": "到期日", | |||
| "Location": "位置" | |||
| } | |||