| @@ -34,12 +34,24 @@ export interface InventoryResultByPage { | |||
| total: number; | |||
| records: InventoryResult[]; | |||
| } | |||
| export interface UpdateInventoryLotLineStatusRequest { | |||
| inventoryLotLineId: number; | |||
| status: string; | |||
| } | |||
| export interface InventoryLotLineResultByPage { | |||
| total: number; | |||
| records: InventoryLotLineResult[]; | |||
| } | |||
| export interface PostInventoryLotLineResponse<T = null> { | |||
| id: number | null; | |||
| name: string; | |||
| code: string; | |||
| type?: string; | |||
| message: string | null; | |||
| errorPosition: string | |||
| entity?: T | T[]; | |||
| consoCode?: string; | |||
| } | |||
| export const fetchLotDetail = cache(async (stockInLineId: number) => { | |||
| return serverFetchJson<LotLineInfo>( | |||
| `${BASE_API_URL}/inventoryLotLine/lot-detail/${stockInLineId}`, | |||
| @@ -49,6 +61,19 @@ export const fetchLotDetail = cache(async (stockInLineId: number) => { | |||
| }, | |||
| ); | |||
| }); | |||
| export const updateInventoryLotLineStatus = async (data: UpdateInventoryLotLineStatusRequest) => { | |||
| console.log("Updating inventory lot line status:", data); | |||
| const result = await serverFetchJson<PostInventoryLotLineResponse>( | |||
| `${BASE_API_URL}/inventoryLotLine/updateStatus`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("inventory"); | |||
| return result; | |||
| }; | |||
| export const fetchInventories = cache(async (data: SearchInventory) => { | |||
| const queryStr = convertObjToURLSearchParams(data) | |||
| @@ -12,6 +12,7 @@ import { | |||
| PickOrderResult, | |||
| PreReleasePickOrderSummary, | |||
| StockOutLine, | |||
| } from "."; | |||
| import { PurchaseQcResult } from "../po/actions"; | |||
| // import { BASE_API_URL } from "@/config/api"; | |||
| @@ -35,6 +36,7 @@ export interface PostPickOrderResponse<T = null> { | |||
| message: string | null; | |||
| errorPosition: string | |||
| entity?: T | T[]; | |||
| consoCode?: string; | |||
| } | |||
| export interface PostStockOutLiineResponse<T> { | |||
| id: number | null; | |||
| @@ -84,6 +86,7 @@ export interface PickOrderApprovalInput { | |||
| export interface GetPickOrderInfoResponse { | |||
| consoCode: string | null; | |||
| pickOrders: GetPickOrderInfo[]; | |||
| items: CurrentInventoryItemInfo[]; | |||
| } | |||
| @@ -108,6 +111,7 @@ export interface GetPickOrderLineInfo { | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| suggestedList: any[]; | |||
| pickedQty: number; | |||
| } | |||
| export interface CurrentInventoryItemInfo { | |||
| @@ -137,7 +141,56 @@ export interface AssignPickOrderInputs { | |||
| pickOrderIds: number[]; | |||
| assignTo: number; | |||
| } | |||
| export interface LotDetailWithStockOutLine { | |||
| lotId: number; | |||
| lotNo: string; | |||
| expiryDate: string; | |||
| location: string; | |||
| stockUnit: string; | |||
| availableQty: number; | |||
| requiredQty: number; | |||
| actualPickQty: number; | |||
| suggestedPickLotId: number; | |||
| lotStatus: string; | |||
| lotAvailability: string; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| } | |||
| export const resuggestPickOrder = async (pickOrderId: number) => { | |||
| console.log("Resuggesting pick order:", pickOrderId); | |||
| const result = await serverFetchJson<PostPickOrderResponse>( | |||
| `${BASE_API_URL}/suggestedPickLot/resuggest/${pickOrderId}`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("pickorder"); | |||
| return result; | |||
| }; | |||
| export const updateStockOutLineStatus = async (data: { | |||
| id: number; | |||
| status: string; | |||
| qty?: number; | |||
| remarks?: string; | |||
| }) => { | |||
| console.log("Updating stock out line status:", data); | |||
| const result = await serverFetchJson<PostStockOutLiineResponse<StockOutLine>>( | |||
| `${BASE_API_URL}/stockOutLine/updateStatus`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("pickorder"); | |||
| return result; | |||
| }; | |||
| // Missing function 1: newassignPickOrder | |||
| export const newassignPickOrder = async (data: AssignPickOrderInputs) => { | |||
| const response = await serverFetchJson<PostPickOrderResponse>( | |||
| @@ -18,15 +18,12 @@ import { | |||
| Paper, | |||
| Checkbox, | |||
| TablePagination, | |||
| Alert, | |||
| AlertTitle, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| newassignPickOrder, | |||
| AssignPickOrderInputs, | |||
| fetchPickOrderWithStockClient, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions"; | |||
| import { FormProvider, useForm } from "react-hook-form"; | |||
| @@ -72,28 +69,6 @@ interface GroupedItemRow { | |||
| items: ItemRow[]; | |||
| } | |||
| // 新增的 PickOrderRow 和 PickOrderLineRow 接口 | |||
| interface PickOrderRow { | |||
| id: string; // Change from number to string to match API response | |||
| code: string; | |||
| targetDate: string; | |||
| type: string; | |||
| status: string; | |||
| assignTo: number; | |||
| groupName: string; | |||
| consoCode?: string; | |||
| pickOrderLines: PickOrderLineRow[]; | |||
| } | |||
| interface PickOrderLineRow { | |||
| id: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| availableQty: number; | |||
| uomDesc: string; | |||
| } | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| @@ -110,9 +85,9 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| const { setIsUploading } = useUploadContext(); | |||
| // Update state to use pick order data directly | |||
| const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<string[]>([]); // Change from number[] to string[] | |||
| const [filteredPickOrders, setFilteredPickOrders] = useState<PickOrderRow[]>([]); | |||
| // 修复:选择状态改为按 pick order ID 存储 | |||
| const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<number[]>([]); | |||
| const [filteredItems, setFilteredItems] = useState<ItemRow[]>([]); | |||
| const [isLoadingItems, setIsLoadingItems] = useState(false); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| @@ -122,52 +97,96 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const [modalOpen, setModalOpen] = useState(false); | |||
| const [usernameList, setUsernameList] = useState<NewNameList[]>([]); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]); | |||
| const [originalItemData, setOriginalItemData] = useState<ItemRow[]>([]); | |||
| const formProps = useForm<AssignPickOrderInputs>(); | |||
| const errors = formProps.formState.errors; | |||
| // Update the fetch function to process pick order data correctly | |||
| // 将项目按 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, | |||
| pageNum: (pagingController.pageNum || 1) - 1, | |||
| pageSize: pagingController.pageSize || 10, | |||
| // 新增:排除状态为 "assigned" 的提料单 | |||
| //status: "pending,released,completed,cancelled" // 或者使用其他方式过滤 | |||
| }; | |||
| console.log("Final params:", params); | |||
| const res = await fetchPickOrderWithStockClient(params); | |||
| const res = await fetchPickOrderItemsByPageClient(params); | |||
| console.log("API Response:", res); | |||
| if (res && res.records) { | |||
| // Filter out assigned status if needed | |||
| const filteredRecords = res.records.filter((pickOrder: any) => pickOrder.status !== "assigned"); | |||
| console.log("Records received:", res.records.length); | |||
| console.log("First record:", res.records[0]); | |||
| // Convert pick order data to the expected format | |||
| const pickOrderRows: PickOrderRow[] = filteredRecords.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 || [] | |||
| // 新增:在前端也过滤掉 "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, | |||
| })); | |||
| setOriginalPickOrderData(pickOrderRows); | |||
| setFilteredPickOrders(pickOrderRows); | |||
| setTotalCountItems(res.total); | |||
| setOriginalItemData(itemRows); | |||
| setFilteredItems(itemRows); | |||
| setTotalCountItems(filteredRecords.length); // 使用过滤后的数量 | |||
| } else { | |||
| setFilteredPickOrders([]); | |||
| console.log("No records in response"); | |||
| setFilteredItems([]); | |||
| setTotalCountItems(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching pick orders:", error); | |||
| setFilteredPickOrders([]); | |||
| console.error("Error fetching items:", error); | |||
| setFilteredItems([]); | |||
| setTotalCountItems(0); | |||
| } finally { | |||
| setIsLoadingItems(false); | |||
| @@ -176,34 +195,44 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| [], | |||
| ); | |||
| // Update search criteria to match the new data structure | |||
| const searchCriteria: Criterion<any>[] = useMemo( | |||
| () => [ | |||
| { | |||
| label: t("Pick Order Code"), | |||
| paramName: "code", | |||
| paramName: "pickOrderCode", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item Code"), | |||
| paramName: "itemCode", | |||
| type: "text" | |||
| }, | |||
| { | |||
| label: t("Group Name"), | |||
| 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( | |||
| originalPickOrderData.map((pickOrder) => ({ | |||
| value: pickOrder.status, | |||
| label: t(upperFirst(pickOrder.status)), | |||
| originalItemData.map((item) => ({ | |||
| value: item.status, | |||
| label: t(upperFirst(item.status)), | |||
| })), | |||
| "value", | |||
| ), | |||
| @@ -211,41 +240,45 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| ), | |||
| }, | |||
| ], | |||
| [originalPickOrderData, t], | |||
| [originalItemData, t], | |||
| ); | |||
| // Update search function to work with pick order data | |||
| const handleSearch = useCallback((query: Record<string, any>) => { | |||
| setSearchQuery({ ...query }); | |||
| console.log("Search query:", query); | |||
| const filtered = originalPickOrderData.filter((pickOrder) => { | |||
| const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate); | |||
| const filtered = originalItemData.filter((item) => { | |||
| const itemTargetDateStr = arrayToDayjs(item.targetDate); | |||
| const codeMatch = !query.code || | |||
| pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase()); | |||
| const itemCodeMatch = !query.itemCode || | |||
| item.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | |||
| const groupNameMatch = !query.groupName || | |||
| pickOrder.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase()); | |||
| const itemNameMatch = !query.itemName || | |||
| item.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); | |||
| // Date range search | |||
| 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 = pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day'); | |||
| dateMatch = itemTargetDateStr.isSame(fromDate, 'day') || | |||
| itemTargetDateStr.isAfter(fromDate, 'day'); | |||
| } else if (!query.targetDate && query.targetDateTo) { | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day'); | |||
| 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 = (pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day')) && | |||
| (pickOrderTargetDateStr.isSame(toDate, 'day') || | |||
| pickOrderTargetDateStr.isBefore(toDate, 'day')); | |||
| 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); | |||
| @@ -255,27 +288,28 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const statusMatch = !query.status || | |||
| query.status.toLowerCase() === "all" || | |||
| pickOrder.status?.toLowerCase().includes((query.status || "").toLowerCase()); | |||
| item.status?.toLowerCase().includes((query.status || "").toLowerCase()); | |||
| return codeMatch && groupNameMatch && dateMatch && statusMatch; | |||
| return itemCodeMatch && itemNameMatch && groupNameMatch && pickOrderCodeMatch && dateMatch && statusMatch; | |||
| }); | |||
| setFilteredPickOrders(filtered); | |||
| }, [originalPickOrderData]); | |||
| console.log("Filtered items count:", filtered.length); | |||
| setFilteredItems(filtered); | |||
| }, [originalItemData]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| setFilteredPickOrders(originalPickOrderData); | |||
| setFilteredItems(originalItemData); | |||
| setTimeout(() => { | |||
| setSearchQuery({}); | |||
| }, 0); | |||
| }, [originalPickOrderData]); | |||
| }, [originalItemData]); | |||
| // Fix the pagination handlers | |||
| // 修复:处理分页变化 | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, | |||
| pageNum: newPage + 1, // API 使用 1-based 分页 | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, [pagingController]); | |||
| @@ -283,43 +317,27 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, | |||
| pageNum: 1, // 重置到第一页 | |||
| pageSize: newPageSize, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, []); | |||
| // 修复:处理 pick order 选择 | |||
| const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => { | |||
| if (checked) { | |||
| setSelectedPickOrderIds(prev => [...prev, pickOrderId]); | |||
| } else { | |||
| setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId)); | |||
| } | |||
| }, []); | |||
| // 修复:检查 pick order 是否被选中 | |||
| const isPickOrderSelected = useCallback((pickOrderId: string) => { | |||
| return selectedPickOrderIds.includes(pickOrderId); | |||
| }, [selectedPickOrderIds]); | |||
| const handleAssignAndRelease = useCallback(async (data: AssignPickOrderInputs) => { | |||
| if (selectedPickOrderIds.length === 0) return; | |||
| setIsUploading(true); | |||
| try { | |||
| // Convert string IDs to numbers for the API | |||
| const numericIds = selectedPickOrderIds.map(id => parseInt(id, 10)); | |||
| // 修复:直接使用选中的 pick order IDs | |||
| const assignRes = await newassignPickOrder({ | |||
| pickOrderIds: numericIds, | |||
| pickOrderIds: selectedPickOrderIds, | |||
| assignTo: data.assignTo, | |||
| }); | |||
| if (assignRes && assignRes.code === "SUCCESS") { | |||
| console.log("Assign successful:", assignRes); | |||
| setModalOpen(false); | |||
| setSelectedPickOrderIds([]); // Clear selection | |||
| setSelectedPickOrderIds([]); // 清空选择 | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Assign failed:", assignRes); | |||
| @@ -336,13 +354,15 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| formProps.reset(); | |||
| }, [formProps]); | |||
| // Component mount effect | |||
| // 组件挂载时加载数据 | |||
| useEffect(() => { | |||
| console.log("=== Component mounted ==="); | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| }, []); | |||
| }, []); // 只在组件挂载时执行一次 | |||
| // Dependencies change effect | |||
| // 当 pagingController 或 filterArgs 变化时重新调用 API | |||
| useEffect(() => { | |||
| console.log("=== Dependencies changed ==="); | |||
| if (pagingController && (filterArgs || {})) { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| } | |||
| @@ -362,8 +382,8 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| loadUsernameList(); | |||
| }, []); | |||
| // Update the table component to work with pick order data directly | |||
| const CustomPickOrderTable = () => { | |||
| // 自定义分组表格组件 | |||
| const CustomGroupedTable = () => { | |||
| return ( | |||
| <> | |||
| <TableContainer component={Paper}> | |||
| @@ -372,7 +392,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Group Name")}</TableCell> | |||
| <TableCell>{t("Group Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||
| @@ -383,70 +403,72 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {filteredPickOrders.length === 0 ? ( | |||
| {groupedItems.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={10} align="center"> | |||
| <TableCell colSpan={9} 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 */} | |||
| groupedItems.map((group) => ( | |||
| group.items.map((item, index) => ( | |||
| <TableRow key={item.id}> | |||
| {/* Checkbox - 只在第一个项目显示,按 pick order 选择 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Checkbox | |||
| checked={isPickOrderSelected(pickOrder.id)} | |||
| onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)} | |||
| disabled={!isEmpty(pickOrder.consoCode)} | |||
| checked={isPickOrderSelected(group.pickOrderId)} | |||
| onChange={(e) => handlePickOrderSelect(group.pickOrderId, e.target.checked)} | |||
| disabled={!isEmpty(item.consoCode)} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Code - only show for first line */} | |||
| {/* Pick Order Code - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.code : null} | |||
| {index === 0 ? item.pickOrderCode : null} | |||
| </TableCell> | |||
| {/* Group Name - only show for first line */} | |||
| {/* Group Name */} | |||
| <TableCell> | |||
| {index === 0 ? pickOrder.groupName : null} | |||
| {index === 0 ? (item.groupName || "No Group") : null} | |||
| </TableCell> | |||
| {/* Item Code */} | |||
| <TableCell>{line.itemCode}</TableCell> | |||
| <TableCell>{item.itemCode}</TableCell> | |||
| {/* Item Name */} | |||
| <TableCell>{line.itemName}</TableCell> | |||
| <TableCell>{item.itemName}</TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right">{line.requiredQty}</TableCell> | |||
| <TableCell align="right">{item.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' }} | |||
| color={item.currentStock > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: item.currentStock > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {(line.availableQty || 0).toLocaleString()} | |||
| {item.currentStock.toLocaleString()} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Unit */} | |||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||
| <TableCell align="right">{item.unit}</TableCell> | |||
| {/* Target Date - only show for first line */} | |||
| {/* Target Date - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(pickOrder.targetDate) | |||
| arrayToDayjs(item.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Status - only show for first line */} | |||
| {/* Pick Order Status - 只在第一个项目显示 */} | |||
| <TableCell> | |||
| {index === 0 ? upperFirst(pickOrder.status) : null} | |||
| {index === 0 ? upperFirst(item.status) : null} | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| @@ -456,14 +478,15 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| </Table> | |||
| </TableContainer> | |||
| {/* 修复:添加分页组件 */} | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCountItems || 0} | |||
| page={(pagingController.pageNum - 1)} | |||
| page={(pagingController.pageNum - 1)} // 转换为 0-based | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50, 100]} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| @@ -481,7 +504,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| {isLoadingItems ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <CustomPickOrderTable /> | |||
| <CustomGroupedTable /> | |||
| )} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| @@ -556,7 +579,7 @@ const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => { | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Typography variant="body2" color="warning.main"> | |||
| {t("This action will assign the selected pick orders.")} | |||
| {t("This action will assign the selected pick orders to picker.")} | |||
| </Typography> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| @@ -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,327 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Checkbox, | |||
| Paper, | |||
| Stack, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| TextField, | |||
| Typography, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import QrCodeIcon from '@mui/icons-material/QrCode'; | |||
| import { GetPickOrderLineInfo } 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; | |||
| } | |||
| const LotTable: React.FC<LotTableProps> = ({ | |||
| lotData, | |||
| selectedRowId, | |||
| selectedRow, | |||
| pickQtyData, | |||
| selectedLotRowId, | |||
| selectedLotId, | |||
| onLotSelection, | |||
| onPickQtyChange, | |||
| onSubmitPickQty, | |||
| onCreateStockOutLine, | |||
| onQcCheck, | |||
| onLotSelectForInput, | |||
| showInputBody, | |||
| setShowInputBody, | |||
| selectedLotForInput, | |||
| generateInputBody, | |||
| }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| // 分页控制器 | |||
| const [lotTablePagingController, setLotTablePagingController] = useState({ | |||
| pageNum: 0, | |||
| pageSize: 10, | |||
| }); | |||
| // ✅ 添加状态消息生成函数 | |||
| const getStatusMessage = useCallback((lot: LotPickData) => { | |||
| if (!lot.stockOutLineId) { | |||
| return "Please finish QR code scan, QC check and pick order."; | |||
| } | |||
| switch (lot.stockOutLineStatus?.toUpperCase()) { | |||
| case 'PENDING': | |||
| return "Please finish QC check and pick order."; | |||
| case 'COMPLETE': | |||
| return "Please submit the pick order."; | |||
| case 'unavailable': | |||
| return "This order is insufficient, please pick another lot."; | |||
| default: | |||
| return "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, | |||
| }); | |||
| }, []); | |||
| 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"> | |||
| <Button | |||
| variant="outlined" | |||
| size="small" | |||
| onClick={() => { | |||
| onCreateStockOutLine(lot.lotId); | |||
| onLotSelectForInput(lot); // Show input body when button is clicked | |||
| }} | |||
| // ✅ Allow creation for available AND insufficient_stock lots | |||
| disabled={(lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || Boolean(lot.stockOutLineId)} | |||
| sx={{ | |||
| fontSize: '0.7rem', | |||
| py: 0.5, | |||
| minHeight: '28px', | |||
| whiteSpace: 'nowrap', | |||
| minWidth: '40px' | |||
| }} | |||
| startIcon={<QrCodeIcon />} // ✅ Add QR code icon | |||
| > | |||
| {lot.stockOutLineId ? t("Scanned") : t("Scan")} | |||
| </Button> | |||
| </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> | |||
| </TableCell> | |||
| {/* Lot Actual Pick Qty */} | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| value={selectedRowId ? (pickQtyData[selectedRowId]?.[lot.lotId] || 0) : 0} | |||
| onChange={(e) => { | |||
| if (selectedRowId) { | |||
| onPickQtyChange( | |||
| selectedRowId, | |||
| lot.lotId, // This should be unique (ill.id) | |||
| parseInt(e.target.value) || 0 | |||
| ); | |||
| } | |||
| }} | |||
| inputProps={{ min: 0, max: lot.availableQty }} | |||
| // ✅ Allow input for available AND insufficient_stock lots | |||
| disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'} | |||
| sx={{ width: '80px' }} | |||
| /> | |||
| </TableCell> | |||
| {/* Submit Button */} | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => { | |||
| if (selectedRowId) { | |||
| onSubmitPickQty(selectedRowId, lot.lotId); | |||
| } | |||
| }} | |||
| // ✅ Allow submission for available AND insufficient_stock lots | |||
| disabled={(lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') || !pickQtyData[selectedRowId!]?.[lot.lotId]} | |||
| 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}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| export default LotTable; | |||
| @@ -43,6 +43,9 @@ import { | |||
| fetchAllPickOrderDetails, | |||
| GetPickOrderInfoResponse, | |||
| GetPickOrderLineInfo, | |||
| createStockOutLine, | |||
| updateStockOutLineStatus, | |||
| resuggestPickOrder, | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { EditNote } from "@mui/icons-material"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| @@ -64,6 +67,8 @@ import { defaultPagingController } from "../SearchResults/SearchResults"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import dayjs from "dayjs"; | |||
| import { dummyQCData } from "../PoDetail/dummyQcTemplate"; | |||
| import { CreateStockOutLine } from "@/app/api/pickOrder/actions"; | |||
| import LotTable from './LotTable'; | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| @@ -81,6 +86,9 @@ interface LotPickData { | |||
| actualPickQty: number; | |||
| lotStatus: string; | |||
| lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'; | |||
| stockOutLineId?: number; | |||
| stockOutLineStatus?: string; | |||
| stockOutLineQty?: number; | |||
| } | |||
| interface PickQtyData { | |||
| @@ -122,6 +130,11 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| pickOrderCode: string; | |||
| qcResult?: PurchaseQcResult[]; | |||
| } | null>(null); | |||
| const [selectedLotForQc, setSelectedLotForQc] = useState<LotPickData | null>(null); | |||
| // ✅ Add lot selection state variables | |||
| const [selectedLotRowId, setSelectedLotRowId] = useState<string | null>(null); | |||
| const [selectedLotId, setSelectedLotId] = useState<number | null>(null); | |||
| // 新增:分页控制器 | |||
| const [mainTablePagingController, setMainTablePagingController] = useState({ | |||
| @@ -177,7 +190,34 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| useEffect(() => { | |||
| fetchNewPageConsoPickOrder({ limit: 10, offset: 0 }, filterArgs); | |||
| }, [fetchNewPageConsoPickOrder, filterArgs]); | |||
| const handleUpdateStockOutLineStatus = useCallback(async ( | |||
| stockOutLineId: number, | |||
| status: string, | |||
| qty?: number | |||
| ) => { | |||
| try { | |||
| const updateData = { | |||
| id: stockOutLineId, | |||
| status: status, | |||
| qty: qty | |||
| }; | |||
| console.log("Updating stock out line status:", updateData); | |||
| const result = await updateStockOutLineStatus(updateData); | |||
| if (result) { | |||
| console.log("Stock out line status updated successfully:", result); | |||
| // Refresh lot data to show updated status | |||
| if (selectedRowId) { | |||
| handleRowSelect(selectedRowId); | |||
| } | |||
| } | |||
| } catch (error) { | |||
| console.error("Error updating stock out line status:", error); | |||
| } | |||
| }, [selectedRowId]); | |||
| const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => { | |||
| let isReleasable = true; | |||
| for (const item of itemList) { | |||
| @@ -293,10 +333,41 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }); | |||
| }, []); | |||
| const handleSubmitPickQty = useCallback((lineId: number, lotId: number) => { | |||
| const handleSubmitPickQty = useCallback(async (lineId: number, lotId: number) => { | |||
| const qty = pickQtyData[lineId]?.[lotId] || 0; | |||
| console.log(`提交拣货数量: Line ${lineId}, Lot ${lotId}, Qty ${qty}`); | |||
| }, [pickQtyData]); | |||
| // ✅ Find the stock out line for this lot | |||
| const selectedLot = lotData.find(lot => lot.lotId === lotId); | |||
| if (!selectedLot?.stockOutLineId) { | |||
| return; | |||
| } | |||
| try { | |||
| // ✅ Update the stock out line quantity | |||
| const updateData = { | |||
| id: selectedLot.stockOutLineId, | |||
| status: selectedLot.stockOutLineStatus || 'PENDING', // Keep current status | |||
| qty: qty // Update with the submitted quantity | |||
| }; | |||
| console.log("Updating stock out line quantity:", updateData); | |||
| const result = await updateStockOutLineStatus(updateData); | |||
| if (result) { | |||
| console.log("Stock out line quantity updated successfully:", result); | |||
| // ✅ Refresh lot data to show updated "Qty Already Picked" | |||
| if (selectedRowId) { | |||
| handleRowSelect(selectedRowId); | |||
| } | |||
| } | |||
| } catch (error) { | |||
| console.error("Error updating stock out line quantity:", error); | |||
| } | |||
| }, [pickQtyData, lotData, selectedRowId]); | |||
| const getTotalPickedQty = useCallback((lineId: number) => { | |||
| const lineData = pickQtyData[lineId]; | |||
| @@ -304,80 +375,65 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return Object.values(lineData).reduce((sum, qty) => sum + qty, 0); | |||
| }, [pickQtyData]); | |||
| const handleInsufficientStock = useCallback(() => { | |||
| console.log("Insufficient stock - need to pick another lot"); | |||
| alert("Insufficient stock - need to pick another lot"); | |||
| }, []); | |||
| const handleQcCheck = useCallback(async (line: GetPickOrderLineInfo, pickOrderCode: string) => { | |||
| console.log("QC Check clicked for:", line, pickOrderCode); | |||
| // ✅ Get the selected lot for QC | |||
| if (!selectedLotId) { | |||
| return; | |||
| } | |||
| const selectedLot = lotData.find(lot => lot.lotId === selectedLotId); | |||
| if (!selectedLot) { | |||
| //alert("Selected lot not found in lot data"); | |||
| return; | |||
| } | |||
| // ✅ Check if stock out line exists | |||
| if (!selectedLot.stockOutLineId) { | |||
| //alert("Please create a stock out line first before performing QC check"); | |||
| return; | |||
| } | |||
| setSelectedLotForQc(selectedLot); | |||
| // ✅ ALWAYS use dummy data for consistent behavior | |||
| const transformedDummyData = dummyQCData.map(item => ({ | |||
| id: item.id, | |||
| code: item.code, | |||
| name: item.name, | |||
| itemId: line.itemId, | |||
| lowerLimit: undefined, | |||
| upperLimit: undefined, | |||
| description: item.qcDescription, | |||
| // ✅ Always reset QC result properties to undefined for fresh start | |||
| qcPassed: undefined, | |||
| failQty: undefined, | |||
| remarks: undefined | |||
| })); | |||
| setQcItems(transformedDummyData as QcItemWithChecks[]); | |||
| // ✅ Get existing QC results if any (for display purposes only) | |||
| let qcResult: any[] = []; | |||
| try { | |||
| // Try to get real data first | |||
| const qcItemsData = await fetchQcItemCheck(line.itemId); | |||
| console.log("QC Items from API:", qcItemsData); | |||
| // If no data in DB, use dummy data for testing | |||
| if (!qcItemsData || qcItemsData.length === 0) { | |||
| console.log("No QC items in DB, using dummy data for testing"); | |||
| // Transform dummy data to match QcItemWithChecks structure | |||
| const transformedDummyData = dummyQCData.map(item => ({ | |||
| id: item.id, | |||
| code: item.code, | |||
| name: item.name, | |||
| itemId: line.itemId, // Use the current item's ID | |||
| lowerLimit: undefined, | |||
| upperLimit: undefined, | |||
| description: item.qcDescription, | |||
| // Add the QC result properties | |||
| qcPassed: item.qcPassed, | |||
| failQty: item.failQty, | |||
| remarks: item.remarks | |||
| })); | |||
| setQcItems(transformedDummyData); | |||
| } else { | |||
| setQcItems(qcItemsData); | |||
| } | |||
| // 修复:处理类型不匹配问题 | |||
| let qcResult: any[] = []; | |||
| try { | |||
| const rawQcResult = await fetchPickOrderQcResult(line.id); | |||
| // 转换数据类型以匹配 PurchaseQcResult | |||
| qcResult = rawQcResult.map((result: any) => ({ | |||
| ...result, | |||
| isPassed: result.isPassed || false // 添加缺失的 isPassed 属性 | |||
| })); | |||
| console.log("QC Result:", qcResult); | |||
| } catch (error) { | |||
| console.log("No existing QC result found"); | |||
| } | |||
| setSelectedItemForQc({ | |||
| ...line, | |||
| pickOrderCode, | |||
| qcResult | |||
| }); | |||
| setQcModalOpen(true); | |||
| console.log("QC Modal should open now"); | |||
| } catch (error) { | |||
| console.error("Error fetching QC data:", error); | |||
| // Fallback to dummy data - transform it | |||
| const transformedDummyData = dummyQCData.map(item => ({ | |||
| id: item.id, | |||
| code: item.code, | |||
| name: item.name, | |||
| itemId: line.itemId, | |||
| lowerLimit: undefined, | |||
| upperLimit: undefined, | |||
| description: item.qcDescription, | |||
| qcPassed: item.qcPassed, | |||
| failQty: item.failQty, | |||
| remarks: item.remarks | |||
| const rawQcResult = await fetchPickOrderQcResult(line.id); | |||
| qcResult = rawQcResult.map((result: any) => ({ | |||
| ...result, | |||
| isPassed: result.isPassed || false | |||
| })); | |||
| setQcItems(transformedDummyData); | |||
| } catch (error) { | |||
| // No existing QC result found - this is normal | |||
| } | |||
| }, []); | |||
| setSelectedItemForQc({ | |||
| ...line, | |||
| pickOrderCode, | |||
| qcResult | |||
| }); | |||
| setQcModalOpen(true); | |||
| }, [lotData, selectedLotId, setQcItems]); | |||
| const handleCloseQcModal = useCallback(() => { | |||
| console.log("Closing QC modal"); | |||
| @@ -420,10 +476,27 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }); | |||
| }, []); | |||
| // 新增:处理行选择 | |||
| // ✅ Fix lot selection logic | |||
| const handleLotSelection = useCallback((uniqueLotId: string, lotId: number) => { | |||
| // If clicking the same lot, unselect it | |||
| if (selectedLotRowId === uniqueLotId) { | |||
| setSelectedLotRowId(null); | |||
| setSelectedLotId(null); | |||
| } else { | |||
| // Select the new lot | |||
| setSelectedLotRowId(uniqueLotId); | |||
| setSelectedLotId(lotId); | |||
| } | |||
| }, [selectedLotRowId]); | |||
| // ✅ Add function to handle row selection that resets lot selection | |||
| const handleRowSelect = useCallback(async (lineId: number) => { | |||
| setSelectedRowId(lineId); | |||
| // ✅ Reset lot selection when changing pick order line | |||
| setSelectedLotRowId(null); | |||
| setSelectedLotId(null); | |||
| try { | |||
| const lotDetails = await fetchPickOrderLineLotDetails(lineId); | |||
| console.log("Lot details from API:", lotDetails); | |||
| @@ -439,7 +512,11 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| requiredQty: lot.requiredQty, | |||
| actualPickQty: lot.actualPickQty || 0, | |||
| lotStatus: lot.lotStatus, | |||
| lotAvailability: lot.lotAvailability | |||
| lotAvailability: lot.lotAvailability, | |||
| // ✅ Add StockOutLine fields | |||
| stockOutLineId: lot.stockOutLineId, | |||
| stockOutLineStatus: lot.stockOutLineStatus, | |||
| stockOutLineQty: lot.stockOutLineQty | |||
| })); | |||
| setLotData(realLotData); | |||
| @@ -450,25 +527,30 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| }, []); | |||
| const prepareMainTableData = useMemo(() => { | |||
| if (!pickOrderDetails) return []; | |||
| return pickOrderDetails.pickOrders.flatMap((pickOrder) => | |||
| pickOrder.pickOrderLines.map((line) => { | |||
| // 修复:处理 availableQty 可能为 null 的情况 | |||
| const availableQty = line.availableQty ?? 0; | |||
| const balanceToPick = availableQty - line.requiredQty; | |||
| return { | |||
| ...line, | |||
| pickOrderCode: pickOrder.code, | |||
| targetDate: pickOrder.targetDate, | |||
| balanceToPick: balanceToPick, | |||
| // 确保 availableQty 不为 null | |||
| availableQty: availableQty, | |||
| }; | |||
| }) | |||
| ); | |||
| }, [pickOrderDetails]); | |||
| if (!pickOrderDetails) return []; | |||
| return pickOrderDetails.pickOrders.flatMap((pickOrder) => | |||
| pickOrder.pickOrderLines.map((line) => { | |||
| // 修复:处理 availableQty 可能为 null 的情况 | |||
| const availableQty = line.availableQty ?? 0; | |||
| const balanceToPick = availableQty - line.requiredQty; | |||
| // ✅ 使用 dayjs 进行一致的日期格式化 | |||
| const formattedTargetDate = pickOrder.targetDate | |||
| ? dayjs(pickOrder.targetDate).format('YYYY-MM-DD') | |||
| : 'N/A'; | |||
| return { | |||
| ...line, | |||
| pickOrderCode: pickOrder.code, | |||
| targetDate: formattedTargetDate, // ✅ 使用 dayjs 格式化的日期 | |||
| balanceToPick: balanceToPick, | |||
| // 确保 availableQty 不为 null | |||
| availableQty: availableQty, | |||
| }; | |||
| }) | |||
| ); | |||
| }, [pickOrderDetails]); | |||
| const prepareLotTableData = useMemo(() => { | |||
| return lotData.map((lot) => ({ | |||
| @@ -502,18 +584,127 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| return null; | |||
| }, [selectedRowId, pickOrderDetails]); | |||
| // Add these state variables (around line 110) | |||
| const [selectedLotId, setSelectedLotId] = useState<string | null>(null); | |||
| const handleInsufficientStock = useCallback(async () => { | |||
| console.log("Insufficient stock - testing resuggest API"); | |||
| if (!selectedRowId || !pickOrderDetails) { | |||
| // alert("Please select a pick order line first"); | |||
| return; | |||
| } | |||
| // Find the pick order ID from the selected row | |||
| let pickOrderId: number | null = null; | |||
| for (const pickOrder of pickOrderDetails.pickOrders) { | |||
| const foundLine = pickOrder.pickOrderLines.find(line => line.id === selectedRowId); | |||
| if (foundLine) { | |||
| pickOrderId = pickOrder.id; | |||
| break; | |||
| } | |||
| } | |||
| if (!pickOrderId) { | |||
| // alert("Could not find pick order ID for selected line"); | |||
| return; | |||
| } | |||
| try { | |||
| console.log(`Calling resuggest API for pick order ID: ${pickOrderId}`); | |||
| // Call the resuggest API | |||
| const result = await resuggestPickOrder(pickOrderId); | |||
| console.log("Resuggest API result:", result); | |||
| if (result.code === "SUCCESS") { | |||
| //alert(`✅ Resuggest successful!\n\nMessage: ${result.message}\n\nRemoved: ${result.message?.includes('Removed') ? 'Yes' : 'No'}\nCreated: ${result.message?.includes('created') ? 'Yes' : 'No'}`); | |||
| // Refresh the lot data to show the new suggestions | |||
| if (selectedRowId) { | |||
| await handleRowSelect(selectedRowId); | |||
| } | |||
| // Also refresh the main pick order details | |||
| await handleFetchAllPickOrderDetails(); | |||
| } else { | |||
| //alert(`❌ Resuggest failed!\n\nError: ${result.message}`); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error calling resuggest API:", error); | |||
| //alert(`❌ Error calling resuggest API:\n\n${error instanceof Error ? error.message : 'Unknown error'}`); | |||
| } | |||
| }, [selectedRowId, pickOrderDetails, handleRowSelect, handleFetchAllPickOrderDetails]); | |||
| // Add this function (around line 350) | |||
| const handleLotSelection = useCallback((uniqueLotId: string) => { | |||
| setSelectedLotId(uniqueLotId); | |||
| const hasSelectedLots = useCallback((lineId: number) => { | |||
| return selectedLotRowId !== null; | |||
| }, [selectedLotRowId]); | |||
| // Add state for showing input body | |||
| const [showInputBody, setShowInputBody] = useState(false); | |||
| const [selectedLotForInput, setSelectedLotForInput] = useState<LotPickData | null>(null); | |||
| // Add function to handle lot selection for input body display | |||
| const handleLotSelectForInput = useCallback((lot: LotPickData) => { | |||
| setSelectedLotForInput(lot); | |||
| setShowInputBody(true); | |||
| }, []); | |||
| // Add this function (around line 480) | |||
| const hasSelectedLots = useCallback((lineId: number) => { | |||
| return selectedLotId !== null; | |||
| }, [selectedLotId]); | |||
| // Add function to generate input body | |||
| const generateInputBody = useCallback((): CreateStockOutLine | null => { | |||
| if (!selectedLotForInput || !selectedRowId || !selectedRow || !pickOrderDetails?.consoCode) { | |||
| return null; | |||
| } | |||
| return { | |||
| consoCode: pickOrderDetails.consoCode, | |||
| pickOrderLineId: selectedRowId, | |||
| inventoryLotLineId: selectedLotForInput.lotId, | |||
| qty: 0.0 | |||
| }; | |||
| }, [selectedLotForInput, selectedRowId, selectedRow, pickOrderDetails?.consoCode]); | |||
| // Add function to handle create stock out line | |||
| const handleCreateStockOutLine = useCallback(async (inventoryLotLineId: number) => { | |||
| if (!selectedRowId || !pickOrderDetails?.consoCode) { | |||
| console.error("Missing required data for creating stock out line."); | |||
| return; | |||
| } | |||
| try { | |||
| const stockOutLineData: CreateStockOutLine = { | |||
| consoCode: pickOrderDetails.consoCode, | |||
| pickOrderLineId: selectedRowId, | |||
| inventoryLotLineId: inventoryLotLineId, | |||
| qty: 0.0 | |||
| }; | |||
| console.log("=== STOCK OUT LINE CREATION DEBUG ==="); | |||
| console.log("Input Body:", JSON.stringify(stockOutLineData, null, 2)); | |||
| // ✅ Use the correct API function | |||
| const result = await createStockOutLine(stockOutLineData); | |||
| console.log("Stock Out Line created:", result); | |||
| if (result) { | |||
| console.log("Stock out line created successfully:", result); | |||
| //alert(`Stock out line created successfully! ID: ${result.id}`); | |||
| // ✅ Don't refresh immediately - let user see the result first | |||
| setShowInputBody(false); // Hide preview after successful creation | |||
| } else { | |||
| console.error("Failed to create stock out line: No response"); | |||
| //alert("Failed to create stock out line: No response"); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error creating stock out line:", error); | |||
| //alert("Error creating stock out line. Please try again."); | |||
| } | |||
| }, [selectedRowId, pickOrderDetails?.consoCode]); | |||
| // 自定义主表格组件 | |||
| const CustomMainTable = () => { | |||
| @@ -614,125 +805,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| ); | |||
| }; | |||
| // 自定义批次表格组件 | |||
| const CustomLotTable = () => { | |||
| 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 align="right">{t("Lot Actual Pick Qty")}</TableCell> | |||
| <TableCell>{t("Stock Unit")}</TableCell> | |||
| <TableCell>{t("Submit")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {paginatedLotTableData.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={9} 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={selectedLotId === `row_${index}`} | |||
| onChange={() => handleLotSelection(`row_${index}`)} | |||
| disabled={lot.lotAvailability !== 'available'} | |||
| 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 align="right"> | |||
| <TextField | |||
| type="number" | |||
| value={selectedRowId ? (pickQtyData[selectedRowId]?.[lot.lotId] || 0) : 0} | |||
| onChange={(e) => { | |||
| if (selectedRowId) { | |||
| handlePickQtyChange( | |||
| selectedRowId, | |||
| lot.lotId, // This should be unique (ill.id) | |||
| parseInt(e.target.value) || 0 | |||
| ); | |||
| } | |||
| }} | |||
| inputProps={{ min: 0, max: lot.availableQty }} | |||
| disabled={lot.lotAvailability !== 'available'} | |||
| sx={{ width: '80px' }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell>{lot.stockUnit}</TableCell> | |||
| <TableCell align="center"> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => { | |||
| if (selectedRowId) { | |||
| handleSubmitPickQty(selectedRowId, lot.lotId); | |||
| } | |||
| }} | |||
| disabled={lot.lotAvailability !== 'available' || !pickQtyData[selectedRowId!]?.[lot.lotId]} | |||
| sx={{ | |||
| fontSize: '0.75rem', | |||
| py: 0.5, | |||
| minHeight: '28px' | |||
| }} | |||
| > | |||
| {t("Submit")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <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}`}` | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| }; | |||
| // Add search criteria | |||
| const searchCriteria: Criterion<any>[] = useMemo( | |||
| () => [ | |||
| @@ -850,7 +922,24 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| {/* 检查是否有可用的批次数据 */} | |||
| {lotData.length > 0 ? ( | |||
| <CustomLotTable /> | |||
| <LotTable | |||
| lotData={lotData} | |||
| selectedRowId={selectedRowId} | |||
| selectedRow={selectedRow} | |||
| pickQtyData={pickQtyData} | |||
| selectedLotRowId={selectedLotRowId} | |||
| selectedLotId={selectedLotId} | |||
| onLotSelection={handleLotSelection} | |||
| onPickQtyChange={handlePickQtyChange} | |||
| onSubmitPickQty={handleSubmitPickQty} | |||
| onCreateStockOutLine={handleCreateStockOutLine} | |||
| onQcCheck={handleQcCheck} | |||
| onLotSelectForInput={handleLotSelectForInput} | |||
| showInputBody={showInputBody} | |||
| setShowInputBody={setShowInputBody} | |||
| selectedLotForInput={selectedLotForInput} | |||
| generateInputBody={generateInputBody} | |||
| /> | |||
| ) : ( | |||
| <Box | |||
| sx={{ | |||
| @@ -880,18 +969,6 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| {/* Action buttons below the lot table */} | |||
| <Box sx={{ mt: 2 }}> | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => { | |||
| if (selectedRowId && selectedRow) { | |||
| handleQcCheck(selectedRow, selectedRow.pickOrderCode); | |||
| } | |||
| }} | |||
| disabled={!hasSelectedLots(selectedRowId!)} | |||
| sx={{ whiteSpace: 'nowrap' }} | |||
| > | |||
| {t("Qc Check")} {selectedLotId ? '(1 selected)' : '(none selected)'} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => handleInsufficientStock()} | |||
| @@ -938,6 +1015,13 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => { | |||
| warehouse={[]} | |||
| qcItems={qcItems} | |||
| setQcItems={setQcItems} | |||
| selectedLotId={selectedLotForQc?.stockOutLineId} | |||
| onStockOutLineUpdate={() => { | |||
| if (selectedRowId) { | |||
| handleRowSelect(selectedRowId); | |||
| } | |||
| }} | |||
| lotData={lotData} | |||
| /> | |||
| )} | |||
| </Stack> | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | |||
| import { GetPickOrderLineInfo, updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | |||
| import { QcItemWithChecks } from "@/app/api/qc"; | |||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | |||
| import { | |||
| @@ -31,6 +31,9 @@ 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 | |||
| // Define QcData interface locally | |||
| interface ExtendedQcItem extends QcItemWithChecks { | |||
| @@ -79,8 +82,27 @@ interface Props extends CommonProps { | |||
| }; | |||
| 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, | |||
| @@ -90,6 +112,9 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| warehouse, | |||
| qcItems, | |||
| setQcItems, | |||
| selectedLotId, | |||
| onStockOutLineUpdate, | |||
| lotData, | |||
| }) => { | |||
| const { | |||
| t, | |||
| @@ -108,7 +133,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| const formProps = useForm<any>({ | |||
| defaultValues: { | |||
| qcAccept: true, | |||
| acceptQty: itemDetail.requiredQty ?? 0, | |||
| acceptQty: null, | |||
| qcDecision: "1", // Default to accept | |||
| ...itemDetail, | |||
| }, | |||
| @@ -168,78 +193,145 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| } | |||
| }; | |||
| // Submit with QcComponent-style decision handling | |||
| // ✅ 修改:在组件开始时自动设置失败数量 | |||
| useEffect(() => { | |||
| if (itemDetail && qcItems.length > 0) { | |||
| // ✅ 自动将 Lot Required Pick Qty 设置为所有失败项目的 failQty | |||
| const updatedQcItems = qcItems.map(item => ({ | |||
| ...item, | |||
| failQty: itemDetail.requiredQty || 0 // 使用 Lot Required Pick Qty | |||
| })); | |||
| setQcItems(updatedQcItems); | |||
| } | |||
| }, [itemDetail, qcItems.length]); | |||
| // ✅ 修改:移除 alert 弹窗,改为控制台日志 | |||
| const onSubmitQc = useCallback<SubmitHandler<any>>( | |||
| async (data, event) => { | |||
| setIsSubmitting(true); | |||
| try { | |||
| const qcAccept = qcDecision === "1"; | |||
| const acceptQty = Number(accQty) || itemDetail.requiredQty; | |||
| const acceptQty = Number(accQty) || null; | |||
| const validationErrors : string[] = []; | |||
| 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(", ")}`); | |||
| } | |||
| const failedItemsWithoutQty = qcItems.filter(item => | |||
| item.qcPassed === false && (!item.failQty || item.failQty <= 0) | |||
| ); | |||
| if (failedItemsWithoutQty.length > 0) { | |||
| validationErrors.push(`${t("Failed items must have failed quantity")}: ${failedItemsWithoutQty.map(item => item.code).join(", ")}`); | |||
| } | |||
| if (qcDecision === "1" && (acceptQty === undefined || acceptQty <= 0)) { | |||
| validationErrors.push("Accept quantity must be greater than 0"); | |||
| } | |||
| if (validationErrors.length > 0) { | |||
| alert(`QC failed: ${validationErrors.join(", ")}`); | |||
| console.error(`QC validation failed: ${validationErrors.join(", ")}`); | |||
| return; | |||
| } | |||
| const qcData = { | |||
| qcAccept, | |||
| acceptQty, | |||
| qcItems: qcItems.map(item => ({ | |||
| id: item.id, | |||
| qcItem: item.code, // Use code instead of qcItem | |||
| qcDescription: item.description || "", // Use description instead of qcDescription | |||
| qcItem: item.code, | |||
| qcDescription: item.description || "", | |||
| isPassed: item.qcPassed, | |||
| failQty: item.qcPassed ? 0 : (item.failQty ?? 0), | |||
| failQty: item.qcPassed ? 0 : (itemDetail?.requiredQty || 0), | |||
| remarks: item.remarks || "", | |||
| })), | |||
| }; | |||
| console.log("Submitting QC data:", qcData); | |||
| const saveSuccess = await saveQcResults(qcData); | |||
| if (!saveSuccess) { | |||
| alert("Failed to save QC results"); | |||
| console.error("Failed to save QC results"); | |||
| return; | |||
| } | |||
| // Show success message | |||
| alert("QC results saved successfully!"); | |||
| // ✅ Fix: Update stock out line status based on QC decision | |||
| if (selectedLotId && qcData.qcAccept) { | |||
| try { | |||
| const allPassed = qcData.qcItems.every(item => item.isPassed); | |||
| // ✅ Fix: Use correct backend enum values | |||
| const newStockOutLineStatus = allPassed ? 'completed' : 'rejected'; | |||
| console.log("Updating stock out line status after QC:", { | |||
| stockOutLineId: selectedLotId, | |||
| newStatus: newStockOutLineStatus | |||
| }); | |||
| // ✅ Fix: 1. Update stock out line status with required qty field | |||
| await updateStockOutLineStatus({ | |||
| id: selectedLotId, | |||
| status: newStockOutLineStatus, | |||
| qty: itemDetail?.requiredQty || 0 // ✅ Add required qty field | |||
| }); | |||
| // ✅ Fix: 2. If QC failed, also update inventory lot line status | |||
| if (!allPassed) { | |||
| try { | |||
| // ✅ Fix: Get the correct lot data | |||
| const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId); | |||
| if (selectedLot) { | |||
| console.log("Updating inventory lot line status for failed QC:", { | |||
| inventoryLotLineId: selectedLot.lotId, | |||
| status: 'unavailable' | |||
| }); | |||
| await updateInventoryLotLineStatus({ | |||
| inventoryLotLineId: selectedLot.lotId, | |||
| status: 'unavailable' // ✅ Use correct backend enum value | |||
| }); | |||
| console.log("Inventory lot line status updated to unavailable"); | |||
| } else { | |||
| console.warn("Selected lot not found for inventory lot line status update"); | |||
| } | |||
| } catch (error) { | |||
| console.error("Failed to update inventory lot line status:", error); | |||
| // ✅ Don't fail the entire operation, just log the error | |||
| } | |||
| } | |||
| 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); | |||
| // ✅ Log detailed error information | |||
| if (error instanceof Error) { | |||
| console.error("Error details:", error.message); | |||
| console.error("Error stack:", error.stack); | |||
| } | |||
| // ✅ Don't fail the entire QC submission, just log the error | |||
| } | |||
| } | |||
| console.log("QC results saved successfully!"); | |||
| // ✅ Show warning dialog for failed QC items | |||
| if (!qcData.qcItems.every((q) => q.isPassed) && qcData.qcAccept) { | |||
| submitDialogWithWarning(() => { | |||
| closeHandler?.({}, 'escapeKeyDown'); | |||
| }, t, {title:"有不合格檢查項目,確認接受出庫?", confirmButtonText: "Confirm", html: ""}); | |||
| return; | |||
| } | |||
| closeHandler?.({}, 'escapeKeyDown'); | |||
| } catch (error) { | |||
| console.error("Error in QC submission:", error); | |||
| alert("Error saving QC results: " + (error as Error).message); | |||
| // ✅ Enhanced error logging | |||
| 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], | |||
| [qcItems, closeHandler, t, itemDetail, qcDecision, accQty, selectedLotId, onStockOutLineUpdate, lotData], | |||
| ); | |||
| // DataGrid columns (QcComponent style) | |||
| @@ -307,20 +399,22 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| <TextField | |||
| type="number" | |||
| size="small" | |||
| value={!params.row.qcPassed ? (params.value ?? "") : "0"} | |||
| // ✅ 修改:失败项目自动显示 Lot Required Pick Qty | |||
| value={!params.row.qcPassed ? (itemDetail?.requiredQty || 0) : 0} | |||
| disabled={params.row.qcPassed} | |||
| 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)) | |||
| ); | |||
| }} | |||
| // ✅ 移除 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 }} | |||
| inputProps={{ min: 0, max: itemDetail?.requiredQty || 0 }} | |||
| sx={{ width: "100%" }} | |||
| /> | |||
| ), | |||
| @@ -374,6 +468,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| <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> | |||
| @@ -381,6 +476,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| 記錄探測溫度的時間,請在1小時内完成出庫盤點,以保障食品安全<br/> | |||
| 監察方法:目視檢查、嗅覺檢查和使用適當的食物溫度計,檢查食物溫度是否符合指標 | |||
| </Typography> | |||
| </Box> | |||
| <StyledDataGrid | |||
| @@ -434,7 +530,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| value={(qcDecision == 1)? accQty : 0 } | |||
| disabled={qcDecision != 1} | |||
| {...register("acceptQty", { | |||
| required: "acceptQty required!", | |||
| //required: "acceptQty required!", | |||
| })} | |||
| error={Boolean(errors.acceptQty)} | |||
| helperText={errors.acceptQty?.message?.toString() || ""} | |||
| @@ -466,6 +562,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||
| forSupervisor={false} | |||
| isCollapsed={isCollapsed} | |||
| setIsCollapsed={setIsCollapsed} | |||
| //escalationCombo={[]} // ✅ Add missing prop | |||
| /> | |||
| </Grid> | |||
| )} | |||
| @@ -0,0 +1,242 @@ | |||
| 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 || 0} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Stock Unit */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.uomDesc || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* 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)} | |||
| /> | |||
| {/* 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,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; | |||
| @@ -25,6 +25,7 @@ import { | |||
| newassignPickOrder, | |||
| AssignPickOrderInputs, | |||
| releaseAssignedPickOrders, | |||
| fetchPickOrderWithStockClient, // Add this import | |||
| } from "@/app/api/pickOrder/actions"; | |||
| import { fetchNameList, NameList } from "@/app/api/user/actions"; | |||
| import { | |||
| @@ -38,40 +39,36 @@ import dayjs from "dayjs"; | |||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import { sortBy, uniqBy } from "lodash"; | |||
| import { fetchPickOrderItemsByPageClient } from "@/app/api/settings/item/actions"; | |||
| import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions"; | |||
| dayjs.extend(arraySupport); | |||
| interface Props { | |||
| filterArgs: Record<string, any>; | |||
| } | |||
| // 使用 fetchPickOrderItemsByPageClient 返回的数据结构 | |||
| interface ItemRow { | |||
| // Update the interface to match the new API response structure | |||
| interface PickOrderRow { | |||
| id: string; | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| requiredQty: number; | |||
| currentStock: number; | |||
| unit: string; | |||
| targetDate: any; | |||
| code: string; | |||
| targetDate: string; | |||
| type: string; | |||
| status: string; | |||
| assignTo: number; | |||
| groupName: string; | |||
| consoCode?: string; | |||
| assignTo?: number; | |||
| groupName?: string; | |||
| pickOrderLines: PickOrderLineRow[]; | |||
| } | |||
| // 分组后的数据结构 | |||
| interface GroupedItemRow { | |||
| pickOrderId: number; | |||
| pickOrderCode: string; | |||
| targetDate: any; | |||
| status: string; | |||
| consoCode?: string; | |||
| items: ItemRow[]; | |||
| interface PickOrderLineRow { | |||
| id: number; | |||
| itemId: number; | |||
| itemCode: string; | |||
| itemName: string; | |||
| availableQty: number | null; | |||
| requiredQty: number; | |||
| uomCode: string; | |||
| uomDesc: string; | |||
| suggestedList: any[]; | |||
| } | |||
| const style = { | |||
| @@ -89,10 +86,10 @@ const style = { | |||
| const AssignTo: 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 [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, | |||
| @@ -102,30 +99,13 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| const [modalOpen, setModalOpen] = useState(false); | |||
| const [usernameList, setUsernameList] = useState<NameList[]>([]); | |||
| const [searchQuery, setSearchQuery] = useState<Record<string, any>>({}); | |||
| const [originalItemData, setOriginalItemData] = useState<ItemRow[]>([]); | |||
| const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]); | |||
| 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, | |||
| groupName: firstItem.groupName, | |||
| } as GroupedItemRow; | |||
| }); | |||
| }, [filteredItems]); | |||
| // 修复:处理 pick order 选择 | |||
| const handlePickOrderSelect = useCallback((pickOrderId: number, checked: boolean) => { | |||
| // Update the handler functions to work with string IDs | |||
| const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => { | |||
| if (checked) { | |||
| setSelectedPickOrderIds(prev => [...prev, pickOrderId]); | |||
| } else { | |||
| @@ -133,62 +113,50 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| } | |||
| }, []); | |||
| // 修复:检查 pick order 是否被选中 | |||
| const isPickOrderSelected = useCallback((pickOrderId: number) => { | |||
| const isPickOrderSelected = useCallback((pickOrderId: string) => { | |||
| return selectedPickOrderIds.includes(pickOrderId); | |||
| }, [selectedPickOrderIds]); | |||
| // 使用 fetchPickOrderItemsByPageClient 获取数据 | |||
| // Update the fetch function to use the correct endpoint | |||
| 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" 的提料单 | |||
| pageNum: (pagingController.pageNum || 1) - 1, | |||
| pageSize: pagingController.pageSize || 10, | |||
| // Filter for assigned status only | |||
| status: "assigned" | |||
| }; | |||
| console.log("Final params:", params); | |||
| const res = await fetchPickOrderItemsByPageClient(params); | |||
| console.log("API Response:", res); | |||
| const res = await fetchPickOrderWithStockClient(params); | |||
| if (res && res.records) { | |||
| console.log("Records received:", res.records.length); | |||
| console.log("First record:", res.records[0]); | |||
| const itemRows: ItemRow[] = res.records.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, | |||
| // 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 || [] | |||
| })); | |||
| setOriginalItemData(itemRows); | |||
| setFilteredItems(itemRows); | |||
| setOriginalPickOrderData(pickOrderRows); | |||
| setFilteredPickOrders(pickOrderRows); | |||
| setTotalCountItems(res.total); | |||
| } else { | |||
| console.log("No records in response"); | |||
| setFilteredItems([]); | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Error fetching items:", error); | |||
| setFilteredItems([]); | |||
| console.error("Error fetching pick orders:", error); | |||
| setFilteredPickOrders([]); | |||
| setTotalCountItems(0); | |||
| } finally { | |||
| setIsLoadingItems(false); | |||
| @@ -197,47 +165,91 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| [], | |||
| ); | |||
| // 新增:处理 Release 操作(包含完整的库存管理) | |||
| const handleRelease = useCallback(async () => { | |||
| if (selectedPickOrderIds.length === 0) return; | |||
| setIsUploading(true); | |||
| try { | |||
| // 调用新的 release API,包含完整的库存管理功能 | |||
| const releaseRes = await releaseAssignedPickOrders({ | |||
| pickOrderIds: selectedPickOrderIds, | |||
| assignTo: 0, // 这个参数在 release 时不会被使用 | |||
| }); | |||
| if (releaseRes && releaseRes.code === "SUCCESS") { | |||
| console.log("Release successful with inventory management:", releaseRes); | |||
| setSelectedPickOrderIds([]); // 清空选择 | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Release failed:", releaseRes); | |||
| // 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); | |||
| } | |||
| } | |||
| } | |||
| } | |||
| } catch (error) { | |||
| console.error("Error in release:", error); | |||
| } finally { | |||
| setIsUploading(false); | |||
| fetchNewPageItems(pagingController, filterArgs); | |||
| } else { | |||
| console.error("Release failed:", releaseRes.message); | |||
| } | |||
| }, [selectedPickOrderIds, setIsUploading, fetchNewPageItems, pagingController, filterArgs]); | |||
| } 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: "pickOrderCode", | |||
| paramName: "code", | |||
| type: "text", | |||
| }, | |||
| { | |||
| label: t("Item Code"), | |||
| paramName: "itemCode", | |||
| type: "text" | |||
| }, | |||
| { | |||
| label: t("Item Name"), | |||
| paramName: "itemName", | |||
| label: t("Group Code"), | |||
| paramName: "groupName", | |||
| type: "text", | |||
| }, | |||
| { | |||
| @@ -250,72 +262,64 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| [t], | |||
| ); | |||
| // Update search function to work with pick order data | |||
| 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 filtered = originalPickOrderData.filter((pickOrder) => { | |||
| const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate); | |||
| const itemCodeMatch = !query.itemCode || | |||
| item.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase()); | |||
| const itemNameMatch = !query.itemName || | |||
| item.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase()); | |||
| const codeMatch = !query.code || | |||
| pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase()); | |||
| const pickOrderCodeMatch = !query.pickOrderCode || | |||
| item.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").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 = itemTargetDateStr.isSame(fromDate, 'day') || | |||
| itemTargetDateStr.isAfter(fromDate, 'day'); | |||
| dateMatch = pickOrderTargetDateStr.isSame(fromDate, 'day') || | |||
| pickOrderTargetDateStr.isAfter(fromDate, 'day'); | |||
| } else if (!query.targetDate && query.targetDateTo) { | |||
| const toDate = dayjs(query.targetDateTo); | |||
| dateMatch = itemTargetDateStr.isSame(toDate, 'day') || | |||
| itemTargetDateStr.isBefore(toDate, 'day'); | |||
| 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 = (itemTargetDateStr.isSame(fromDate, 'day') || | |||
| itemTargetDateStr.isAfter(fromDate, 'day')) && | |||
| (itemTargetDateStr.isSame(toDate, 'day') || | |||
| itemTargetDateStr.isBefore(toDate, 'day')); | |||
| 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; | |||
| } | |||
| } | |||
| const statusMatch = !query.status || | |||
| query.status.toLowerCase() === "all" || | |||
| item.status?.toLowerCase().includes((query.status || "").toLowerCase()); | |||
| return itemCodeMatch && itemNameMatch && pickOrderCodeMatch && dateMatch && statusMatch; | |||
| return codeMatch && groupNameMatch && dateMatch; | |||
| }); | |||
| console.log("Filtered items count:", filtered.length); | |||
| setFilteredItems(filtered); | |||
| }, [originalItemData]); | |||
| setFilteredPickOrders(filtered); | |||
| }, [originalPickOrderData]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchQuery({}); | |||
| setFilteredItems(originalItemData); | |||
| setFilteredPickOrders(originalPickOrderData); | |||
| setTimeout(() => { | |||
| setSearchQuery({}); | |||
| }, 0); | |||
| }, [originalItemData]); | |||
| }, [originalPickOrderData]); | |||
| // 修复:处理分页变化 | |||
| // Pagination handlers | |||
| const handlePageChange = useCallback((event: unknown, newPage: number) => { | |||
| const newPagingController = { | |||
| ...pagingController, | |||
| pageNum: newPage + 1, // API 使用 1-based 分页 | |||
| pageNum: newPage + 1, | |||
| }; | |||
| setPagingController(newPagingController); | |||
| }, [pagingController]); | |||
| @@ -323,52 +327,19 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newPageSize = parseInt(event.target.value, 10); | |||
| const newPagingController = { | |||
| pageNum: 1, // 重置到第一页 | |||
| 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]); | |||
| // 组件挂载时加载数据 | |||
| // Component mount effect | |||
| useEffect(() => { | |||
| console.log("=== Component mounted ==="); | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| }, []); // 只在组件挂载时执行一次 | |||
| }, []); | |||
| // 当 pagingController 或 filterArgs 变化时重新调用 API | |||
| // Dependencies change effect | |||
| useEffect(() => { | |||
| console.log("=== Dependencies changed ==="); | |||
| if (pagingController && (filterArgs || {})) { | |||
| fetchNewPageItems(pagingController, filterArgs || {}); | |||
| } | |||
| @@ -388,9 +359,9 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| loadUsernameList(); | |||
| }, []); | |||
| // 自定义分组表格组件 | |||
| const CustomGroupedTable = () => { | |||
| // 获取用户名的辅助函数 | |||
| // 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); | |||
| @@ -405,7 +376,7 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| <TableRow> | |||
| <TableCell>{t("Selected")}</TableCell> | |||
| <TableCell>{t("Pick Order Code")}</TableCell> | |||
| <TableCell>{t("Group Name")}</TableCell> | |||
| <TableCell>{t("Group Code")}</TableCell> | |||
| <TableCell>{t("Item Code")}</TableCell> | |||
| <TableCell>{t("Item Name")}</TableCell> | |||
| <TableCell align="right">{t("Order Quantity")}</TableCell> | |||
| @@ -416,75 +387,72 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {groupedItems.length === 0 ? ( | |||
| {filteredPickOrders.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={9} align="center"> | |||
| <TableCell colSpan={10} 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 选择 */} | |||
| 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(group.pickOrderId)} | |||
| onChange={(e) => handlePickOrderSelect(group.pickOrderId, e.target.checked)} | |||
| disabled={!isEmpty(item.consoCode)} | |||
| checked={isPickOrderSelected(pickOrder.id)} | |||
| onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)} | |||
| disabled={!isEmpty(pickOrder.consoCode)} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Pick Order Code - 只在第一个项目显示 */} | |||
| {/* Pick Order Code - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? item.pickOrderCode : null} | |||
| {index === 0 ? pickOrder.code : null} | |||
| </TableCell> | |||
| {/* Group Name */} | |||
| {/* Group Name - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? (item.groupName || "No Group") : null} | |||
| {index === 0 ? pickOrder.groupName : null} | |||
| </TableCell> | |||
| {/* Item Code */} | |||
| <TableCell>{item.itemCode}</TableCell> | |||
| <TableCell>{line.itemCode}</TableCell> | |||
| {/* Item Name */} | |||
| <TableCell>{item.itemName}</TableCell> | |||
| <TableCell>{line.itemName}</TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right">{item.requiredQty}</TableCell> | |||
| <TableCell align="right">{line.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' }} | |||
| color={line.availableQty && line.availableQty > 0 ? "success.main" : "error.main"} | |||
| sx={{ fontWeight: line.availableQty && line.availableQty > 0 ? 'bold' : 'normal' }} | |||
| > | |||
| {item.currentStock.toLocaleString()} | |||
| {(line.availableQty || 0).toLocaleString()} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Unit */} | |||
| <TableCell align="right">{item.unit}</TableCell> | |||
| <TableCell align="right">{line.uomDesc}</TableCell> | |||
| {/* Target Date - 只在第一个项目显示 */} | |||
| {/* Target Date - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| arrayToDayjs(item.targetDate) | |||
| arrayToDayjs(pickOrder.targetDate) | |||
| .add(-1, "month") | |||
| .format(OUTPUT_DATE_FORMAT) | |||
| ) : null} | |||
| </TableCell> | |||
| {/* Assigned To - 只在第一个项目显示,显示用户名 */} | |||
| {/* Assigned To - only show for first line */} | |||
| <TableCell> | |||
| {index === 0 ? ( | |||
| <Typography variant="body2"> | |||
| {getUserName(item.assignTo)} | |||
| {getUserName(pickOrder.assignTo)} | |||
| </Typography> | |||
| ) : null} | |||
| </TableCell> | |||
| @@ -496,15 +464,14 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| </Table> | |||
| </TableContainer> | |||
| {/* 修复:添加分页组件 */} | |||
| <TablePagination | |||
| component="div" | |||
| count={totalCountItems || 0} | |||
| page={(pagingController.pageNum - 1)} // 转换为 0-based | |||
| page={(pagingController.pageNum - 1)} | |||
| rowsPerPage={pagingController.pageSize} | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[10, 25, 50]} | |||
| rowsPerPageOptions={[10, 25, 50, 100]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| labelDisplayedRows={({ from, to, count }) => | |||
| `${from}-${to} of ${count !== -1 ? count : `more than ${to}`}` | |||
| @@ -522,7 +489,7 @@ const AssignTo: React.FC<Props> = ({ filterArgs }) => { | |||
| {isLoadingItems ? ( | |||
| <CircularProgress size={40} /> | |||
| ) : ( | |||
| <CustomGroupedTable /> | |||
| <CustomPickOrderTable /> | |||
| )} | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| @@ -37,7 +37,9 @@ import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||
| import { fetchJobOrderDetailByCode } from "@/app/api/jo/actions"; | |||
| import SearchBox, { Criterion } from "../SearchBox"; | |||
| import VerticalSearchBox from "./VerticalSearchBox"; | |||
| import SearchResultsTable from './SearchResultsTable'; | |||
| import CreatedItemsTable from './CreatedItemsTable'; | |||
| type Props = { | |||
| filterArgs?: Record<string, any>; | |||
| searchQuery?: Record<string, any>; | |||
| @@ -88,8 +90,11 @@ interface JobOrderDetailPickLine { | |||
| interface Group { | |||
| id: number; | |||
| name: string; | |||
| targetDate: string; | |||
| targetDate: string ; | |||
| } | |||
| // Move the counter outside the component to persist across re-renders | |||
| let checkboxChangeCallCount = 0; | |||
| let processingItems = new Set<number>(); | |||
| const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCreated }) => { | |||
| const { t } = useTranslation("pickOrder"); | |||
| @@ -217,11 +222,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| ); | |||
| const handleSearch = useCallback(() => { | |||
| if (!type) { | |||
| alert(t("Please select type")); | |||
| return; | |||
| } | |||
| if (!searchCode && !searchName) { | |||
| alert(t("Please enter at least code or name")); | |||
| return; | |||
| @@ -368,7 +369,12 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| // Update the handleGroupTargetDateChange function to update selected items that belong to that group | |||
| const handleGroupTargetDateChange = useCallback((groupId: number, newTargetDate: string) => { | |||
| setGroups(prev => prev.map(g => (g.id === groupId ? { ...g, targetDate: newTargetDate } : g))); | |||
| setSelectedGroup(prev => { | |||
| if (prev && prev.id === groupId) { | |||
| return { ...prev, targetDate: newTargetDate }; | |||
| } | |||
| return prev; | |||
| }); | |||
| // Update selected items that belong to this group | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.groupId === groupId | |||
| @@ -378,6 +384,14 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| } | |||
| : item | |||
| )); | |||
| setCreatedItems(prev => prev.map(item => | |||
| item.groupId === groupId | |||
| ? { | |||
| ...item, | |||
| targetDate: newTargetDate | |||
| } | |||
| : item | |||
| )); | |||
| }, []); | |||
| // Fix the handleCreateGroup function to use the API properly | |||
| @@ -390,7 +404,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| const newGroup: Group = { | |||
| id: response.id, | |||
| name: response.name, | |||
| targetDate: dayjs().format(INPUT_DATE_FORMAT) | |||
| targetDate: "" | |||
| }; | |||
| setGroups(prev => [...prev, newGroup]); | |||
| @@ -405,7 +419,94 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| alert(t('Failed to create group')); | |||
| } | |||
| }, [t]); | |||
| const checkAndAutoAddItem = useCallback((itemId: number) => { | |||
| const item = secondSearchResults.find(i => i.id === itemId); | |||
| if (!item) return; | |||
| // Check if item has ALL 3 conditions: | |||
| // 1. Item is selected (checkbox checked) | |||
| const isSelected = selectedSecondSearchItemIds.includes(itemId); | |||
| // 2. Group is assigned | |||
| const hasGroup = item.groupId !== undefined && item.groupId !== null; | |||
| // 3. Quantity is entered | |||
| const hasQty = item.qty !== null && item.qty !== undefined && item.qty > 0; | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(item.id)) { | |||
| // Auto-add to created items | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: item.id, | |||
| itemName: item.label, | |||
| itemCode: item.label, | |||
| qty: item.qty || 1, | |||
| uom: item.uom || "", | |||
| uomId: item.uomId || 0, | |||
| uomDesc: item.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: item.currentStockBalance, | |||
| targetDate: item.targetDate || targetDate, | |||
| groupId: item.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| // Remove from search results since it's now in created items | |||
| setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| } | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| // Add this function after checkAndAutoAddItem | |||
| // Add this function after checkAndAutoAddItem | |||
| const handleQtyBlur = useCallback((itemId: number) => { | |||
| // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) | |||
| setTimeout(() => { | |||
| const currentItem = secondSearchResults.find(i => i.id === itemId); | |||
| if (!currentItem) return; | |||
| const isSelected = selectedSecondSearchItemIds.includes(itemId); | |||
| const hasGroup = currentItem.groupId !== undefined && currentItem.groupId !== null; | |||
| const hasQty = currentItem.qty !== null && currentItem.qty !== undefined && currentItem.qty > 0; | |||
| // Only auto-add if item is already selected (scenario 1: select first, then enter quantity) | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(currentItem.id)) { | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: currentItem.id, | |||
| itemName: currentItem.label, | |||
| itemCode: currentItem.label, | |||
| qty: currentItem.qty || 1, | |||
| uom: currentItem.uom || "", | |||
| uomId: currentItem.uomId || 0, | |||
| uomDesc: currentItem.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: currentItem.currentStockBalance, | |||
| targetDate: currentItem.targetDate || targetDate, | |||
| groupId: currentItem.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| setSecondSearchResults(prev => prev.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| } | |||
| }, 0); | |||
| }, [secondSearchResults, selectedSecondSearchItemIds, isItemInCreated, targetDate]); | |||
| const handleSearchItemGroupChange = useCallback((itemId: number, groupId: string) => { | |||
| const gid = groupId ? Number(groupId) : undefined; | |||
| const group = groups.find(g => g.id === gid); | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| ? { | |||
| ...item, | |||
| groupId: gid, | |||
| targetDate: group?.targetDate || undefined | |||
| } | |||
| : item | |||
| )); | |||
| // Check auto-add after group assignment | |||
| setTimeout(() => { | |||
| checkAndAutoAddItem(itemId); | |||
| }, 0); | |||
| }, [groups, checkAndAutoAddItem]); | |||
| // 5) 选中新增的待选项:依然按“当前 Group”赋 groupId + targetDate(新加入的应随 Group) | |||
| const handleSecondSearchItemSelect = useCallback((itemId: number, isSelected: boolean) => { | |||
| if (!isSelected) return; | |||
| @@ -444,18 +545,13 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| alert(t("Please select at least one item to submit")); | |||
| return; | |||
| } | |||
| if (!data.type) { | |||
| alert(t("Please select product type")); | |||
| return; | |||
| } | |||
| // Remove the data.targetDate check since we'll use group target dates | |||
| // if (!data.targetDate) { | |||
| // alert(t("Please select target date")); | |||
| // ✅ 修复:自动填充 type 为 "Consumable",不再强制用户选择 | |||
| // if (!data.type) { | |||
| // alert(t("Please select product type")); | |||
| // return; | |||
| // } | |||
| // 按组分组选中的项目 | |||
| const itemsByGroup = selectedCreatedItems.reduce((acc, item) => { | |||
| const groupId = item.groupId || 'no-group'; | |||
| @@ -465,13 +561,13 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| acc[groupId].push(item); | |||
| return acc; | |||
| }, {} as Record<string | number, typeof selectedCreatedItems>); | |||
| console.log("Items grouped by group:", itemsByGroup); | |||
| let successCount = 0; | |||
| const totalGroups = Object.keys(itemsByGroup).length; | |||
| const groupUpdates: Array<{groupId: number, pickOrderId: number}> = []; | |||
| // 为每个组创建提料单 | |||
| for (const [groupId, items] of Object.entries(itemsByGroup)) { | |||
| try { | |||
| @@ -492,9 +588,9 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| if (!groupTargetDate) { | |||
| groupTargetDate = dayjs().format(INPUT_DATE_FORMAT); | |||
| } | |||
| console.log(`Creating pick order for group: ${groupName} with ${items.length} items, target date: ${groupTargetDate}`); | |||
| let formattedTargetDate = groupTargetDate; | |||
| if (groupTargetDate && typeof groupTargetDate === 'string') { | |||
| try { | |||
| @@ -506,9 +602,10 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| return; | |||
| } | |||
| } | |||
| // ✅ 修复:自动使用 "Consumable" 作为默认 type | |||
| const pickOrderData: SavePickOrderRequest = { | |||
| type: data.type || "Consumable", | |||
| type: data.type || "Consumable", // 如果用户选择了 type 就用用户的,否则默认 "Consumable" | |||
| targetDate: formattedTargetDate, | |||
| pickOrderLine: items.map(item => ({ | |||
| itemId: item.itemId, | |||
| @@ -516,7 +613,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| uomId: item.uomId | |||
| } as SavePickOrderLineRequest)) | |||
| }; | |||
| console.log(`Submitting pick order for group ${groupName}:`, pickOrderData); | |||
| const res = await createPickOrder(pickOrderData); | |||
| @@ -835,7 +932,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| </TableCell> | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"} | |||
| {item.targetDate&& item.targetDate !== "" ? new Date(item.targetDate).toLocaleDateString() : "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| @@ -1001,12 +1098,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| ) | |||
| ); | |||
| // Auto-update created items if this item exists there | |||
| setCreatedItems(prev => | |||
| prev.map(item => | |||
| item.itemId === itemId ? { ...item, qty: newQty || 1 } : item | |||
| ) | |||
| ); | |||
| // Don't auto-add here - only on blur event | |||
| }, []); | |||
| // Add checkbox change handler for second search | |||
| @@ -1020,7 +1112,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| // 全选:将所有搜索结果添加到创建项目 | |||
| secondSearchResults.forEach(item => { | |||
| if (!isItemInCreated(item.id)) { | |||
| handleSecondSearchItemSelect(item.id, true); | |||
| handleSearchItemSelect(item.id, true); | |||
| } | |||
| }); | |||
| } else { | |||
| @@ -1030,7 +1122,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| const isCurrentlyInCreated = isItemInCreated(item.id); | |||
| if (isSelected && !isCurrentlyInCreated) { | |||
| handleSecondSearchItemSelect(item.id, true); | |||
| handleSearchItemSelect(item.id, true); | |||
| } else if (!isSelected && isCurrentlyInCreated) { | |||
| setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== item.id)); | |||
| } | |||
| @@ -1045,7 +1137,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| newlySelected.forEach(id => { | |||
| if (!isItemInCreated(id as number)) { | |||
| handleSecondSearchItemSelect(id as number, true); | |||
| handleSearchItemSelect(id as number, true); | |||
| } | |||
| }); | |||
| @@ -1053,7 +1145,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| setCreatedItems(prev => prev.filter(createdItem => createdItem.itemId !== id)); | |||
| }); | |||
| } | |||
| }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSecondSearchItemSelect]); | |||
| }, [selectedSecondSearchItemIds, secondSearchResults, isItemInCreated, handleSearchItemSelect]); | |||
| // Update the secondSearchItemColumns to add right alignment for Current Stock and Order Quantity | |||
| const secondSearchItemColumns: Column<SearchItemWithQty>[] = useMemo(() => [ | |||
| @@ -1211,7 +1303,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| setIsLoadingSecondSearch(false); | |||
| }, 500); | |||
| }, [items, formProps]); | |||
| /* | |||
| // Create a custom search box component that displays fields vertically | |||
| const VerticalSearchBox = ({ criteria, onSearch, onReset }: { | |||
| criteria: Criterion<any>[]; | |||
| @@ -1255,6 +1347,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| 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} | |||
| @@ -1288,7 +1381,7 @@ const NewCreateItem: React.FC<Props> = ({ filterArgs, searchQuery, onPickOrderCr | |||
| </Card> | |||
| ); | |||
| }; | |||
| */ | |||
| // Add pagination state for search results | |||
| const [searchResultsPagingController, setSearchResultsPagingController] = useState({ | |||
| pageNum: 1, | |||
| @@ -1386,39 +1479,103 @@ const getValidationMessage = useCallback(() => { | |||
| }, [secondSearchResults, selectedSecondSearchItemIds]); | |||
| // Move these handlers to the component level (outside of CustomSearchResultsTable) | |||
| // Handle individual checkbox change - ONLY select, don't add to created items | |||
| const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => { | |||
| if (checked) { | |||
| // Just add to selected IDs, don't auto-add to created items | |||
| setSelectedSecondSearchItemIds(prev => [...prev, itemId]); | |||
| // Handle individual checkbox change - ONLY select, don't add to created items | |||
| const handleIndividualCheckboxChange = useCallback((itemId: number, checked: boolean) => { | |||
| checkboxChangeCallCount++; | |||
| // Set the item's group and targetDate to current group when selected | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| targetDate: selectedGroup?.targetDate || undefined | |||
| if (checked) { | |||
| // Add to selected IDs | |||
| setSelectedSecondSearchItemIds(prev => [...prev, itemId]); | |||
| // Set the item's group and targetDate to current group when selected | |||
| setSecondSearchResults(prev => { | |||
| const updatedResults = prev.map(item => | |||
| item.id === itemId | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| targetDate: selectedGroup?.targetDate !== undefined && selectedGroup?.targetDate !== "" ? selectedGroup.targetDate : undefined | |||
| } | |||
| : item | |||
| ); | |||
| // Check if should auto-add after state update | |||
| setTimeout(() => { | |||
| // Check if we're already processing this item | |||
| if (processingItems.has(itemId)) { | |||
| //alert(`Item ${itemId} is already being processed, skipping duplicate auto-add`); | |||
| return; | |||
| } | |||
| : item | |||
| )); | |||
| } else { | |||
| // Just remove from selected IDs, don't remove from created items | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| // Clear the item's group and targetDate when deselected | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| ? { | |||
| ...item, | |||
| groupId: undefined, | |||
| targetDate: undefined | |||
| const updatedItem = updatedResults.find(i => i.id === itemId); | |||
| if (updatedItem) { | |||
| const isSelected = true; // We just selected it | |||
| const hasGroup = updatedItem.groupId !== undefined && updatedItem.groupId !== null; | |||
| const hasQty = updatedItem.qty !== null && updatedItem.qty !== undefined && updatedItem.qty > 0; | |||
| // Only auto-add if item has quantity (scenario 2: enter quantity first, then select) | |||
| if (isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)) { | |||
| // Mark this item as being processed | |||
| processingItems.add(itemId); | |||
| const newCreatedItem: CreatedItem = { | |||
| itemId: updatedItem.id, | |||
| itemName: updatedItem.label, | |||
| itemCode: updatedItem.label, | |||
| qty: updatedItem.qty || 1, | |||
| uom: updatedItem.uom || "", | |||
| uomId: updatedItem.uomId || 0, | |||
| uomDesc: updatedItem.uomDesc || "", | |||
| isSelected: true, | |||
| currentStockBalance: updatedItem.currentStockBalance, | |||
| targetDate: updatedItem.targetDate || targetDate, | |||
| groupId: updatedItem.groupId || undefined, | |||
| }; | |||
| setCreatedItems(prev => [...prev, newCreatedItem]); | |||
| setSecondSearchResults(current => current.filter(searchItem => searchItem.id !== itemId)); | |||
| setSelectedSecondSearchItemIds(current => current.filter(id => id !== itemId)); | |||
| // Remove from processing set after a short delay | |||
| setTimeout(() => { | |||
| processingItems.delete(itemId); | |||
| }, 100); | |||
| } | |||
| // Show final debug info in one alert | |||
| /* | |||
| alert(`FINAL DEBUG INFO for item ${itemId}: | |||
| Function called ${checkboxChangeCallCount} times | |||
| Is Selected: ${isSelected} | |||
| Has Group: ${hasGroup} | |||
| Has Quantity: ${hasQty} | |||
| Quantity: ${updatedItem.qty} | |||
| Group ID: ${updatedItem.groupId} | |||
| Is Item In Created: ${isItemInCreated(updatedItem.id)} | |||
| Auto-add triggered: ${isSelected && hasGroup && hasQty && !isItemInCreated(updatedItem.id)} | |||
| Processing items: ${Array.from(processingItems).join(', ')}`); | |||
| */ | |||
| } | |||
| : item | |||
| )); | |||
| } | |||
| }, [selectedGroup]); | |||
| }, 0); | |||
| return updatedResults; | |||
| }); | |||
| } else { | |||
| // Remove from selected IDs | |||
| setSelectedSecondSearchItemIds(prev => prev.filter(id => id !== itemId)); | |||
| // Clear the item's group and targetDate when deselected | |||
| setSecondSearchResults(prev => prev.map(item => | |||
| item.id === itemId | |||
| ? { | |||
| ...item, | |||
| groupId: undefined, | |||
| targetDate: undefined | |||
| } | |||
| : item | |||
| )); | |||
| } | |||
| }, [selectedGroup, isItemInCreated, targetDate]); | |||
| // Handle select all checkbox for current page | |||
| const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: SearchItemWithQty[]) => { | |||
| @@ -1439,7 +1596,7 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||
| ? { | |||
| ...item, | |||
| groupId: selectedGroup?.id || undefined, | |||
| targetDate: selectedGroup?.targetDate || undefined | |||
| targetDate: selectedGroup?.targetDate !== undefined && selectedGroup.targetDate !== "" ? selectedGroup.targetDate : undefined | |||
| } | |||
| : item | |||
| )); | |||
| @@ -1462,6 +1619,7 @@ const handleSelectAllOnPage = useCallback((checked: boolean, paginatedResults: S | |||
| }, [selectedGroup, isItemInCreated]); | |||
| // Update the CustomSearchResultsTable to use the handlers from component level | |||
| /* | |||
| const CustomSearchResultsTable = () => { | |||
| // Calculate pagination | |||
| const startIndex = (searchResultsPagingController.pageNum - 1) * searchResultsPagingController.pageSize; | |||
| @@ -1524,7 +1682,7 @@ const CustomSearchResultsTable = () => { | |||
| /> | |||
| </TableCell> | |||
| {/* Item */} | |||
| <TableCell> | |||
| <Box> | |||
| <Typography variant="body2"> | |||
| @@ -1536,7 +1694,7 @@ const CustomSearchResultsTable = () => { | |||
| </Box> | |||
| </TableCell> | |||
| {/* Group - Show the item's own group (or "-" if not selected) */} | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {(() => { | |||
| @@ -1549,7 +1707,7 @@ const CustomSearchResultsTable = () => { | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Current Stock */} | |||
| <TableCell align="right"> | |||
| <Typography | |||
| variant="body2" | |||
| @@ -1560,14 +1718,13 @@ const CustomSearchResultsTable = () => { | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Stock Unit */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.uomDesc || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| {/* Order Quantity */} | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="number" | |||
| @@ -1581,6 +1738,10 @@ const CustomSearchResultsTable = () => { | |||
| handleSecondSearchQtyChange(item.id, numValue); | |||
| } | |||
| }} | |||
| onBlur={() => { | |||
| // Trigger auto-add check when user finishes input | |||
| handleQtyBlur(item.id); | |||
| }} | |||
| inputProps={{ | |||
| style: { textAlign: 'center' } | |||
| }} | |||
| @@ -1594,7 +1755,7 @@ const CustomSearchResultsTable = () => { | |||
| /> | |||
| </TableCell> | |||
| {/* Target Date - Show the item's own target date (or "-" if not selected) */} | |||
| <TableCell align="right"> | |||
| <Typography variant="body2"> | |||
| {item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"} | |||
| @@ -1607,7 +1768,7 @@ const CustomSearchResultsTable = () => { | |||
| </Table> | |||
| </TableContainer> | |||
| {/* Add pagination for search results */} | |||
| <TablePagination | |||
| component="div" | |||
| count={secondSearchResults.length} | |||
| @@ -1624,6 +1785,7 @@ const CustomSearchResultsTable = () => { | |||
| </> | |||
| ); | |||
| }; | |||
| */ | |||
| // Add helper function to get group range text | |||
| const getGroupRangeText = useCallback(() => { | |||
| @@ -1694,10 +1856,11 @@ const CustomSearchResultsTable = () => { | |||
| <Grid item> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||
| <DatePicker | |||
| value={dayjs(selectedGroup.targetDate)} | |||
| value={selectedGroup.targetDate && selectedGroup.targetDate !== "" ? dayjs(selectedGroup.targetDate) : null} | |||
| onChange={(date) => { | |||
| if (date) { | |||
| const formattedDate = date.format(INPUT_DATE_FORMAT); | |||
| handleGroupTargetDateChange(selectedGroup.id, formattedDate); | |||
| } | |||
| }} | |||
| @@ -1728,29 +1891,41 @@ const CustomSearchResultsTable = () => { | |||
| {/* Second Search Results - Use custom table like AssignAndRelease */} | |||
| {hasSearchedSecond && ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Typography variant="h6" marginBlockEnd={2}> | |||
| {t("Search Results")} ({secondSearchResults.length}) | |||
| </Typography> | |||
| {/* Add selected items info text */} | |||
| {selectedSecondSearchItemIds.length > 0 && ( | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}> | |||
| {t("Selected items will join above created group")} | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| {isLoadingSecondSearch ? ( | |||
| <Typography>{t("Loading...")}</Typography> | |||
| ) : secondSearchResults.length === 0 ? ( | |||
| <Typography color="textSecondary">{t("No results found")}</Typography> | |||
| ) : ( | |||
| <CustomSearchResultsTable /> | |||
| )} | |||
| </Box> | |||
| )} | |||
| <Box sx={{ mt: 3 }}> | |||
| <Typography variant="h6" marginBlockEnd={2}> | |||
| {t("Search Results")} ({secondSearchResults.length}) | |||
| </Typography> | |||
| {selectedSecondSearchItemIds.length > 0 && ( | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}> | |||
| {t("Selected items will join above created group")} | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| {isLoadingSecondSearch ? ( | |||
| <Typography>{t("Loading...")}</Typography> | |||
| ) : secondSearchResults.length === 0 ? ( | |||
| <Typography color="textSecondary">{t("No results found")}</Typography> | |||
| ) : ( | |||
| <SearchResultsTable | |||
| items={secondSearchResults} | |||
| selectedItemIds={selectedSecondSearchItemIds} | |||
| groups={groups} | |||
| onItemSelect={handleIndividualCheckboxChange} | |||
| onQtyChange={handleSecondSearchQtyChange} | |||
| onGroupChange={handleCreatedItemGroupChange} | |||
| onQtyBlur={handleQtyBlur} | |||
| isItemInCreated={isItemInCreated} | |||
| pageNum={searchResultsPagingController.pageNum} | |||
| pageSize={searchResultsPagingController.pageSize} | |||
| onPageChange={handleSearchResultsPageChange} | |||
| onPageSizeChange={handleSearchResultsPageSizeChange} | |||
| /> | |||
| )} | |||
| </Box> | |||
| )} | |||
| {/* Add Submit Button between tables */} | |||
| @@ -1784,14 +1959,24 @@ const CustomSearchResultsTable = () => { | |||
| {/* 创建项目区域 - 修改Group列为可选择的 */} | |||
| {createdItems.length > 0 && ( | |||
| <Box sx={{ mt: 3 }}> | |||
| <Typography variant="h6" marginBlockEnd={2}> | |||
| {t("Created Items")} ({createdItems.length}) | |||
| </Typography> | |||
| <CustomCreatedItemsTable /> | |||
| </Box> | |||
| )} | |||
| <Box sx={{ mt: 3 }}> | |||
| <Typography variant="h6" marginBlockEnd={2}> | |||
| {t("Created Items")} ({createdItems.length}) | |||
| </Typography> | |||
| <CreatedItemsTable | |||
| items={createdItems} | |||
| groups={groups} | |||
| onItemSelect={handleCreatedItemSelect} | |||
| onQtyChange={handleQtyChange} | |||
| onGroupChange={handleCreatedItemGroupChange} | |||
| pageNum={createdItemsPagingController.pageNum} | |||
| pageSize={createdItemsPagingController.pageSize} | |||
| onPageChange={handleCreatedItemsPageChange} | |||
| onPageSizeChange={handleCreatedItemsPageSizeChange} | |||
| /> | |||
| </Box> | |||
| )} | |||
| {/* 操作按钮 */} | |||
| <Stack direction="row" justifyContent="flex-start" gap={1} sx={{ mt: 3 }}> | |||
| @@ -25,15 +25,15 @@ | |||
| "Bind Storage": "綁定倉位", | |||
| "itemNo": "貨品編號", | |||
| "itemName": "貨品名稱", | |||
| "qty": "訂單數量", | |||
| "Require Qty": "需求數量", | |||
| "qty": "訂單數", | |||
| "Require Qty": "需求數", | |||
| "uom": "計量單位", | |||
| "total weight": "總重量", | |||
| "weight unit": "重量單位", | |||
| "price": "訂單貨值", | |||
| "processed": "已入倉", | |||
| "expiryDate": "到期日", | |||
| "acceptedQty": "是次訂單/來貨/巳來貨數量", | |||
| "acceptedQty": "是次訂單/來貨/巳來貨數", | |||
| "weight": "重量", | |||
| "start": "開始", | |||
| "qc": "質量控制", | |||
| @@ -41,7 +41,7 @@ | |||
| "stock in": "入庫", | |||
| "putaway": "上架", | |||
| "delete": "刪除", | |||
| "qty cannot be greater than remaining qty": "數量不能大於剩餘數量", | |||
| "qty cannot be greater than remaining qty": "數量不能大於剩餘數", | |||
| "Record pol": "記錄採購訂單", | |||
| "Add some entries!": "添加條目!", | |||
| "draft": "草稿", | |||
| @@ -59,9 +59,9 @@ | |||
| "value must be a number": "值必須是數字", | |||
| "qc Check": "質量控制檢查", | |||
| "Please select QC": "請選擇質量控制", | |||
| "failQty": "失敗數量", | |||
| "failQty": "失敗數", | |||
| "select qc": "選擇質量控制", | |||
| "enter a failQty": "請輸入失敗數量", | |||
| "enter a failQty": "請輸入失敗數", | |||
| "qty too big": "數量過大", | |||
| "sampleRate": "抽樣率", | |||
| "sampleWeight": "樣本重量", | |||
| @@ -76,7 +76,7 @@ | |||
| "acceptedWeight": "接受重量", | |||
| "productionDate": "生產日期", | |||
| "reportQty": "上報數量", | |||
| "reportQty": "上報數", | |||
| "Default Warehouse": "預設倉庫", | |||
| "Select warehouse": "選擇倉庫", | |||
| @@ -136,9 +136,9 @@ | |||
| "Second Search Items": "第二搜尋項目", | |||
| "Second Search": "第二搜尋", | |||
| "Item": "貨品", | |||
| "Order Quantity": "貨品需求數量", | |||
| "Order Quantity": "貨品需求數", | |||
| "Current Stock": "現時可用庫存", | |||
| "Selected": "已選擇", | |||
| "Selected": "已選", | |||
| "Select Items": "選擇貨品", | |||
| "Assign": "分派提料單", | |||
| "Release": "放單", | |||
| @@ -150,25 +150,27 @@ | |||
| "End Product": "成品", | |||
| "Lot Expiry Date": "批號到期日", | |||
| "Lot Location": "批號位置", | |||
| "Available Lot": "批號可用提料數量", | |||
| "Lot Required Pick Qty": "批號所需提料數量", | |||
| "Lot Actual Pick Qty": "批號實際提料數量", | |||
| "Available Lot": "批號可用提料數", | |||
| "Lot Required Pick Qty": "批號所需提料數", | |||
| "Lot Actual Pick Qty": "批號實際提料數", | |||
| "Lot#": "批號", | |||
| "Submit": "提交", | |||
| "Created Items": "已建立貨品", | |||
| "Create New Group": "建立新分組", | |||
| "Create New Group": "建立新提料分組", | |||
| "Group": "分組", | |||
| "Qty Already Picked": "已提料數量", | |||
| "Qty Already Picked": "已提料數", | |||
| "Select Job Order Items": "選擇工單貨品", | |||
| "failedQty": "不合格項目數量", | |||
| "failedQty": "不合格項目數", | |||
| "remarks": "備註", | |||
| "Qc items": "QC 項目", | |||
| "qcItem": "QC 項目", | |||
| "QC Info": "QC 資訊", | |||
| "qcResult": "QC 結果", | |||
| "acceptQty": "接受數量", | |||
| "acceptQty": "接受數", | |||
| "Escalation History": "上報歷史", | |||
| "Group Name": "分組名稱", | |||
| "Job Order Code": "工單編號" | |||
| "Group Code": "分組編號", | |||
| "Job Order Code": "工單編號", | |||
| "QC Check": "QC 檢查", | |||
| "QR Code Scan": "QR Code掃描" | |||
| } | |||