| @@ -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; | lotStatus: string; | ||||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | 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>( | return serverFetchJson<GetPickOrderInfoResponse>( | ||||
| `${BASE_API_URL}/pickOrder/detail`, | |||||
| url, | |||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| next: { tags: ["pickorder"] }, | next: { tags: ["pickorder"] }, | ||||
| @@ -340,7 +394,17 @@ export const assignPickOrder = async (ids: number[]) => { | |||||
| // revalidateTag("po"); | // revalidateTag("po"); | ||||
| return pickOrder; | 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[]) => { | export const consolidatePickOrder_revert = async (ids: number[]) => { | ||||
| const pickOrder = await serverFetchJson<any>( | const pickOrder = await serverFetchJson<any>( | ||||
| `${BASE_API_URL}/pickOrder/deconso`, | `${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", | label: "Put Away Scan", | ||||
| path: "/putAway", | 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 { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | ||||
| import LotTable from './LotTable'; | import LotTable from './LotTable'; | ||||
| import { updateInventoryLotLineStatus, updateInventoryStatus, updateInventoryLotLineQuantities } from "@/app/api/inventory/actions"; | 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 { | interface Props { | ||||
| filterArgs: Record<string, any>; | filterArgs: Record<string, any>; | ||||
| @@ -101,6 +103,11 @@ interface PickQtyData { | |||||
| const PickExecution: React.FC<Props> = ({ filterArgs }) => { | const PickExecution: React.FC<Props> = ({ filterArgs }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const router = useRouter(); | 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( | const [filteredPickOrders, setFilteredPickOrders] = useState( | ||||
| [] as ConsoPickOrderResult[], | [] as ConsoPickOrderResult[], | ||||
| ); | ); | ||||
| @@ -252,10 +259,11 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| const handleFetchAllPickOrderDetails = useCallback(async () => { | const handleFetchAllPickOrderDetails = useCallback(async () => { | ||||
| setDetailLoading(true); | setDetailLoading(true); | ||||
| try { | try { | ||||
| const data = await fetchAllPickOrderDetails(); | |||||
| // ✅ Use current user ID for filtering | |||||
| const data = await fetchAllPickOrderDetails(currentUserId); | |||||
| setPickOrderDetails(data); | setPickOrderDetails(data); | ||||
| setOriginalPickOrderData(data); // Store original data for filtering | 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 = {}; | const initialPickQtyData: PickQtyData = {}; | ||||
| data.pickOrders.forEach((pickOrder: any) => { | data.pickOrders.forEach((pickOrder: any) => { | ||||
| @@ -270,7 +278,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||||
| } finally { | } finally { | ||||
| setDetailLoading(false); | setDetailLoading(false); | ||||
| } | } | ||||
| }, []); | |||||
| }, [currentUserId]); // ✅ Add currentUserId as dependency | |||||
| useEffect(() => { | useEffect(() => { | ||||
| handleFetchAllPickOrderDetails(); | handleFetchAllPickOrderDetails(); | ||||
| @@ -183,5 +183,9 @@ | |||||
| "Item lot to be Pick:": "批次貨品提料:", | "Item lot to be Pick:": "批次貨品提料:", | ||||
| "Report and Pick another lot": "上報並需重新選擇批號", | "Report and Pick another lot": "上報並需重新選擇批號", | ||||
| "Accept Stock Out": "接受出庫", | "Accept Stock Out": "接受出庫", | ||||
| "Pick Another Lot": "重新選擇批號" | |||||
| "Pick Another Lot": "重新選擇批號", | |||||
| "Lot No": "批號", | |||||
| "Expiry Date": "到期日", | |||||
| "Location": "位置" | |||||
| } | } | ||||