CANCERYS\kw093 3 месяцев назад
Родитель
Сommit
ff1325da4e
40 измененных файлов: 14914 добавлений и 9 удалений
  1. +30
    -0
      src/app/(main)/finishedGood/detail/page.tsx
  2. +29
    -0
      src/app/(main)/finishedGood/page.tsx
  3. +69
    -5
      src/app/api/pickOrder/actions.ts
  4. +607
    -0
      src/components/FinishedGoodSearch/AssignAndRelease.tsx
  5. +228
    -0
      src/components/FinishedGoodSearch/CombinedLotTable.tsx
  6. +91
    -0
      src/components/FinishedGoodSearch/ConsolidatePickOrderItemSum.tsx
  7. +116
    -0
      src/components/FinishedGoodSearch/ConsolidatePickOrderSum.tsx
  8. +370
    -0
      src/components/FinishedGoodSearch/ConsolidatedPickOrders.tsx
  9. +321
    -0
      src/components/FinishedGoodSearch/CreateForm.tsx
  10. +98
    -0
      src/components/FinishedGoodSearch/CreatePickOrderModal.tsx
  11. +209
    -0
      src/components/FinishedGoodSearch/CreatedItemsTable.tsx
  12. +179
    -0
      src/components/FinishedGoodSearch/EscalationComponent.tsx
  13. +167
    -0
      src/components/FinishedGoodSearch/FinishedGood.tsx
  14. +292
    -0
      src/components/FinishedGoodSearch/FinishedGoodSearch.tsx
  15. +26
    -0
      src/components/FinishedGoodSearch/FinishedGoodSearchWrapper.tsx
  16. +475
    -0
      src/components/FinishedGoodSearch/GoodPickExecution.tsx
  17. +79
    -0
      src/components/FinishedGoodSearch/ItemSelect.tsx
  18. +1824
    -0
      src/components/FinishedGoodSearch/Jobcreatitem.tsx
  19. +737
    -0
      src/components/FinishedGoodSearch/LotTable.tsx
  20. +288
    -0
      src/components/FinishedGoodSearch/PickQcStockInModalVer2.tsx
  21. +683
    -0
      src/components/FinishedGoodSearch/PickQcStockInModalVer3.tsx
  22. +527
    -0
      src/components/FinishedGoodSearch/PutawayForm.tsx
  23. +395
    -0
      src/components/FinishedGoodSearch/QCDatagrid.tsx
  24. +460
    -0
      src/components/FinishedGoodSearch/QcFormVer2.tsx
  25. +78
    -0
      src/components/FinishedGoodSearch/QcSelect.tsx
  26. +243
    -0
      src/components/FinishedGoodSearch/SearchResultsTable.tsx
  27. +321
    -0
      src/components/FinishedGoodSearch/StockInFormVer2.tsx
  28. +24
    -0
      src/components/FinishedGoodSearch/TwoLineCell.tsx
  29. +73
    -0
      src/components/FinishedGoodSearch/UomSelect.tsx
  30. +85
    -0
      src/components/FinishedGoodSearch/VerticalSearchBox.tsx
  31. +511
    -0
      src/components/FinishedGoodSearch/assignTo copy.tsx
  32. +511
    -0
      src/components/FinishedGoodSearch/assignTo.tsx
  33. +78
    -0
      src/components/FinishedGoodSearch/dummyQcTemplate.tsx
  34. +1
    -0
      src/components/FinishedGoodSearch/index.ts
  35. +2234
    -0
      src/components/FinishedGoodSearch/newcreatitem copy.tsx
  36. +2054
    -0
      src/components/FinishedGoodSearch/newcreatitem.tsx
  37. +380
    -0
      src/components/FinishedGoodSearch/pickorderModelVer2.tsx
  38. +5
    -0
      src/components/NavigationContent/NavigationContent.tsx
  39. +11
    -3
      src/components/PickOrderSearch/PickExecution.tsx
  40. +5
    -1
      src/i18n/zh/pickOrder.json

+ 30
- 0
src/app/(main)/finishedGood/detail/page.tsx Просмотреть файл

@@ -0,0 +1,30 @@
import { PreloadPickOrder } from "@/app/api/pickOrder";
import { SearchParams } from "@/app/utils/fetchUtil";
import FinishedGoodSearchWrapper from "@/components/FinishedGoodSearch";
import { getServerI18n, I18nProvider } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Finished Good Detail",
};
type Props = {} & SearchParams;

const PickOrder: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("pickOrder");

PreloadPickOrder();

return (
<>
<I18nProvider namespaces={["pickOrder", "common"]}>
<Suspense fallback={<FinishedGoodSearchWrapper.Loading />}>
<FinishedGoodSearchWrapper />
</Suspense>
</I18nProvider>
</>
);
};

export default PickOrder;

+ 29
- 0
src/app/(main)/finishedGood/page.tsx Просмотреть файл

@@ -0,0 +1,29 @@
import { PreloadPickOrder } from "@/app/api/pickOrder";
import FinishedGoodSearch from "@/components/FinishedGoodSearch/";
import { getServerI18n } from "@/i18n";
import { I18nProvider } from "@/i18n";
import { Stack, Typography } from "@mui/material";
import { Metadata } from "next";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Pick Order",
};

const PickOrder: React.FC = async () => {
const { t } = await getServerI18n("pickOrder");

PreloadPickOrder();

return (
<>
<I18nProvider namespaces={["pickOrder", "common"]}>
<Suspense fallback={<FinishedGoodSearch.Loading />}>
<FinishedGoodSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default PickOrder;

+ 69
- 5
src/app/api/pickOrder/actions.ts Просмотреть файл

@@ -287,11 +287,65 @@ export interface PickOrderLotDetailResponse {
lotStatus: string;
lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable';
}


export const fetchAllPickOrderDetails = cache(async () => {
interface ALLPickOrderLotDetailResponse {
// Pick Order Information
pickOrderId: number;
pickOrderCode: string;
pickOrderTargetDate: string;
pickOrderType: string;
pickOrderStatus: string;
pickOrderAssignTo: number;
groupName: string;
// Pick Order Line Information
pickOrderLineId: number;
pickOrderLineRequiredQty: number;
pickOrderLineStatus: string;
// Item Information
itemId: number;
itemCode: string;
itemName: string;
uomCode: string;
uomDesc: string;
// Lot Information
lotId: number;
lotNo: string;
expiryDate: string;
location: string;
stockUnit: string;
availableQty: number;
requiredQty: number;
actualPickQty: number;
suggestedPickLotId: number;
lotStatus: string;
stockOutLineId?: number;
stockOutLineStatus?: string;
stockOutLineQty?: number;
lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable';
processingStatus: string;
}
export const fetchALLPickOrderLineLotDetails = cache(async (userId?: number) => {
const url = userId
? `${BASE_API_URL}/pickOrder/all-lots-with-details?userId=${userId}`
: `${BASE_API_URL}/pickOrder/all-lots-with-details`;
return serverFetchJson<ALLPickOrderLotDetailResponse[]>(
url,
{
method: "GET",
next: { tags: ["pickorder"] },
},
);
});
export const fetchAllPickOrderDetails = cache(async (userId?: number) => {
const url = userId
? `${BASE_API_URL}/pickOrder/detail?userId=${userId}`
: `${BASE_API_URL}/pickOrder/detail`;
return serverFetchJson<GetPickOrderInfoResponse>(
`${BASE_API_URL}/pickOrder/detail`,
url,
{
method: "GET",
next: { tags: ["pickorder"] },
@@ -340,7 +394,17 @@ export const assignPickOrder = async (ids: number[]) => {
// revalidateTag("po");
return pickOrder;
};

export const consolidatePickOrder = async (ids: number[]) => {
const pickOrder = await serverFetchJson<any>(
`${BASE_API_URL}/pickOrder/conso`,
{
method: "POST",
body: JSON.stringify({ ids: ids }),
headers: { "Content-Type": "application/json" },
},
);
return pickOrder;
};
export const consolidatePickOrder_revert = async (ids: number[]) => {
const pickOrder = await serverFetchJson<any>(
`${BASE_API_URL}/pickOrder/deconso`,


+ 607
- 0
src/components/FinishedGoodSearch/AssignAndRelease.tsx Просмотреть файл

@@ -0,0 +1,607 @@
"use client";
import {
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
Grid,
Modal,
TextField,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Checkbox,
TablePagination,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
newassignPickOrder,
AssignPickOrderInputs,
} from "@/app/api/pickOrder/actions";
import { fetchNameList, NameList ,fetchNewNameList, NewNameList} from "@/app/api/user/actions";
import { FormProvider, useForm } from "react-hook-form";
import { isEmpty, sortBy, uniqBy, upperFirst, groupBy } from "lodash";
import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil";
import useUploadContext from "../UploadProvider/useUploadContext";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import SearchBox, { Criterion } from "../SearchBox";
import { fetchPickOrderItemsByPageClient } from "@/app/api/settings/item/actions";

dayjs.extend(arraySupport);

interface Props {
filterArgs: Record<string, any>;
}

// 使用 fetchPickOrderItemsByPageClient 返回的数据结构
interface ItemRow {
id: string;
pickOrderId: number;
pickOrderCode: string;
itemId: number;
itemCode: string;
itemName: string;
requiredQty: number;
currentStock: number;
unit: string;
targetDate: any;
status: string;
consoCode?: string;
assignTo?: number;
groupName?: string;
}

// 分组后的数据结构
interface GroupedItemRow {
pickOrderId: number;
pickOrderCode: string;
targetDate: any;
status: string;
consoCode?: string;
items: ItemRow[];
}

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
width: { xs: "100%", sm: "100%", md: "100%" },
};

const AssignAndRelease: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { setIsUploading } = useUploadContext();

// 修复:选择状态改为按 pick order ID 存储
const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<number[]>([]);
const [filteredItems, setFilteredItems] = useState<ItemRow[]>([]);
const [isLoadingItems, setIsLoadingItems] = useState(false);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const [totalCountItems, setTotalCountItems] = useState<number>();
const [modalOpen, setModalOpen] = useState(false);
const [usernameList, setUsernameList] = useState<NewNameList[]>([]);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
const [originalItemData, setOriginalItemData] = useState<ItemRow[]>([]);

const formProps = useForm<AssignPickOrderInputs>();
const errors = formProps.formState.errors;

// 将项目按 pick order 分组
const groupedItems = useMemo(() => {
const grouped = groupBy(filteredItems, 'pickOrderId');
return Object.entries(grouped).map(([pickOrderId, items]) => {
const firstItem = items[0];
return {
pickOrderId: parseInt(pickOrderId),
pickOrderCode: firstItem.pickOrderCode,
targetDate: firstItem.targetDate,
status: firstItem.status,
consoCode: firstItem.consoCode,
items: items
} as GroupedItemRow;
});
}, [filteredItems]);

// 修复:处理 pick order 选择
const handlePickOrderSelect = useCallback((pickOrderId: number, checked: boolean) => {
if (checked) {
setSelectedPickOrderIds(prev => [...prev, pickOrderId]);
} else {
setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId));
}
}, []);

// 修复:检查 pick order 是否被选中
const isPickOrderSelected = useCallback((pickOrderId: number) => {
return selectedPickOrderIds.includes(pickOrderId);
}, [selectedPickOrderIds]);

// 使用 fetchPickOrderItemsByPageClient 获取数据
const fetchNewPageItems = useCallback(
async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => {
console.log("=== fetchNewPageItems called ===");
console.log("pagingController:", pagingController);
console.log("filterArgs:", filterArgs);
setIsLoadingItems(true);
try {
const params = {
...pagingController,
...filterArgs,
// 新增:排除状态为 "assigned" 的提料单
//status: "pending,released,completed,cancelled" // 或者使用其他方式过滤
};
console.log("Final params:", params);

const res = await fetchPickOrderItemsByPageClient(params);
console.log("API Response:", res);

if (res && res.records) {
console.log("Records received:", res.records.length);
console.log("First record:", res.records[0]);
// 新增:在前端也过滤掉 "assigned" 状态的项目
const filteredRecords = res.records.filter((item: any) => item.status !== "assigned");
const itemRows: ItemRow[] = filteredRecords.map((item: any) => ({
id: item.id,
pickOrderId: item.pickOrderId,
pickOrderCode: item.pickOrderCode,
itemId: item.itemId,
itemCode: item.itemCode,
itemName: item.itemName,
requiredQty: item.requiredQty,
currentStock: item.currentStock ?? 0,
unit: item.unit,
targetDate: item.targetDate,
status: item.status,
consoCode: item.consoCode,
assignTo: item.assignTo,
groupName: item.groupName,
}));
setOriginalItemData(itemRows);
setFilteredItems(itemRows);
setTotalCountItems(filteredRecords.length); // 使用过滤后的数量
} else {
console.log("No records in response");
setFilteredItems([]);
setTotalCountItems(0);
}
} catch (error) {
console.error("Error fetching items:", error);
setFilteredItems([]);
setTotalCountItems(0);
} finally {
setIsLoadingItems(false);
}
},
[],
);

const searchCriteria: Criterion<any>[] = useMemo(
() => [
{
label: t("Pick Order Code"),
paramName: "pickOrderCode",
type: "text",
},
{
label: t("Item Code"),
paramName: "itemCode",
type: "text"
},
{
label: t("Group Code"),
paramName: "groupName",
type: "text",
},
{
label: t("Item Name"),
paramName: "itemName",
type: "text",
},
{
label: t("Target Date From"),
label2: t("Target Date To"),
paramName: "targetDate",
type: "dateRange",
},

{
label: t("Pick Order Status"),
paramName: "status",
type: "autocomplete",
options: sortBy(
uniqBy(
originalItemData.map((item) => ({
value: item.status,
label: t(upperFirst(item.status)),
})),
"value",
),
"label",
),
},
],
[originalItemData, t],
);

const handleSearch = useCallback((query: Record<string, any>) => {
setSearchQuery({ ...query });
console.log("Search query:", query);

const filtered = originalItemData.filter((item) => {
const itemTargetDateStr = arrayToDayjs(item.targetDate);

const itemCodeMatch = !query.itemCode ||
item.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase());
const itemNameMatch = !query.itemName ||
item.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase());
const pickOrderCodeMatch = !query.pickOrderCode ||
item.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase());
const groupNameMatch = !query.groupName ||
item.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase());
// 日期范围搜索
let dateMatch = true;
if (query.targetDate || query.targetDateTo) {
try {
if (query.targetDate && !query.targetDateTo) {
const fromDate = dayjs(query.targetDate);
dateMatch = itemTargetDateStr.isSame(fromDate, 'day') ||
itemTargetDateStr.isAfter(fromDate, 'day');
} else if (!query.targetDate && query.targetDateTo) {
const toDate = dayjs(query.targetDateTo);
dateMatch = itemTargetDateStr.isSame(toDate, 'day') ||
itemTargetDateStr.isBefore(toDate, 'day');
} else if (query.targetDate && query.targetDateTo) {
const fromDate = dayjs(query.targetDate);
const toDate = dayjs(query.targetDateTo);
dateMatch = (itemTargetDateStr.isSame(fromDate, 'day') ||
itemTargetDateStr.isAfter(fromDate, 'day')) &&
(itemTargetDateStr.isSame(toDate, 'day') ||
itemTargetDateStr.isBefore(toDate, 'day'));
}
} catch (error) {
console.error("Date parsing error:", error);
dateMatch = true;
}
}
const statusMatch = !query.status ||
query.status.toLowerCase() === "all" ||
item.status?.toLowerCase().includes((query.status || "").toLowerCase());

return itemCodeMatch && itemNameMatch && groupNameMatch && pickOrderCodeMatch && dateMatch && statusMatch;
});
console.log("Filtered items count:", filtered.length);
setFilteredItems(filtered);
}, [originalItemData]);

const handleReset = useCallback(() => {
setSearchQuery({});
setFilteredItems(originalItemData);
setTimeout(() => {
setSearchQuery({});
}, 0);
}, [originalItemData]);

// 修复:处理分页变化
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1, // API 使用 1-based 分页
};
setPagingController(newPagingController);
}, [pagingController]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1, // 重置到第一页
pageSize: newPageSize,
};
setPagingController(newPagingController);
}, []);

const handleAssignAndRelease = useCallback(async (data: AssignPickOrderInputs) => {
if (selectedPickOrderIds.length === 0) return;

setIsUploading(true);
try {
// 修复:直接使用选中的 pick order IDs
const assignRes = await newassignPickOrder({
pickOrderIds: selectedPickOrderIds,
assignTo: data.assignTo,
});

if (assignRes && assignRes.code === "SUCCESS") {
console.log("Assign successful:", assignRes);
setModalOpen(false);
setSelectedPickOrderIds([]); // 清空选择
fetchNewPageItems(pagingController, filterArgs);
} else {
console.error("Assign failed:", assignRes);
}
} catch (error) {
console.error("Error in assign:", error);
} finally {
setIsUploading(false);
}
}, [selectedPickOrderIds, setIsUploading, fetchNewPageItems, pagingController, filterArgs]);

const openAssignModal = useCallback(() => {
setModalOpen(true);
formProps.reset();
}, [formProps]);

// 组件挂载时加载数据
useEffect(() => {
console.log("=== Component mounted ===");
fetchNewPageItems(pagingController, filterArgs || {});
}, []); // 只在组件挂载时执行一次

// 当 pagingController 或 filterArgs 变化时重新调用 API
useEffect(() => {
console.log("=== Dependencies changed ===");
if (pagingController && (filterArgs || {})) {
fetchNewPageItems(pagingController, filterArgs || {});
}
}, [pagingController, filterArgs, fetchNewPageItems]);

useEffect(() => {
const loadUsernameList = async () => {
try {
const res = await fetchNewNameList();
if (res) {
setUsernameList(res);
}
} catch (error) {
console.error("Error loading username list:", error);
}
};
loadUsernameList();
}, []);

// 自定义分组表格组件
const CustomGroupedTable = () => {
return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Selected")}</TableCell>
<TableCell>{t("Pick Order Code")}</TableCell>
<TableCell>{t("Group Code")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell align="right">{t("Order Quantity")}</TableCell>
<TableCell align="right">{t("Current Stock")}</TableCell>
<TableCell align="right">{t("Stock Unit")}</TableCell>
<TableCell>{t("Target Date")}</TableCell>
<TableCell>{t("Pick Order Status")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{groupedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
groupedItems.map((group) => (
group.items.map((item, index) => (
<TableRow key={item.id}>
{/* Checkbox - 只在第一个项目显示,按 pick order 选择 */}
<TableCell>
{index === 0 ? (
<Checkbox
checked={isPickOrderSelected(group.pickOrderId)}
onChange={(e) => handlePickOrderSelect(group.pickOrderId, e.target.checked)}
disabled={!isEmpty(item.consoCode)}
/>
) : null}
</TableCell>
{/* Pick Order Code - 只在第一个项目显示 */}
<TableCell>
{index === 0 ? item.pickOrderCode : null}
</TableCell>
{/* Group Name */}
<TableCell>
{index === 0 ? (item.groupName || "No Group") : null}
</TableCell>
{/* Item Code */}
<TableCell>{item.itemCode}</TableCell>
{/* Item Name */}
<TableCell>{item.itemName}</TableCell>
{/* Order Quantity */}
<TableCell align="right">{item.requiredQty}</TableCell>
{/* Current Stock */}
<TableCell align="right">
<Typography
variant="body2"
color={item.currentStock > 0 ? "success.main" : "error.main"}
sx={{ fontWeight: item.currentStock > 0 ? 'bold' : 'normal' }}
>
{item.currentStock.toLocaleString()}
</Typography>
</TableCell>
{/* Unit */}
<TableCell align="right">{item.unit}</TableCell>
{/* Target Date - 只在第一个项目显示 */}
<TableCell>
{index === 0 ? (
arrayToDayjs(item.targetDate)
.add(-1, "month")
.format(OUTPUT_DATE_FORMAT)
) : null}
</TableCell>
{/* Pick Order Status - 只在第一个项目显示 */}
<TableCell>
{index === 0 ? upperFirst(item.status) : null}
</TableCell>
</TableRow>
))
))
)}
</TableBody>
</Table>
</TableContainer>
{/* 修复:添加分页组件 */}
<TablePagination
component="div"
count={totalCountItems || 0}
page={(pagingController.pageNum - 1)} // 转换为 0-based
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

return (
<>
<SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} />
<Grid container rowGap={1}>
<Grid item xs={12}>
{isLoadingItems ? (
<CircularProgress size={40} />
) : (
<CustomGroupedTable />
)}
</Grid>
<Grid item xs={12}>
<Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}>
<Button
disabled={selectedPickOrderIds.length < 1}
variant="outlined"
onClick={openAssignModal}
>
{t("Assign")}
</Button>
</Box>
</Grid>
</Grid>

{modalOpen ? (
<Modal
open={modalOpen}
onClose={() => setModalOpen(false)}
aria-labelledby="modal-modal-title"
aria-describedby="modal-modal-description"
>
<Box sx={style}>
<Grid container rowGap={2}>
<Grid item xs={12}>
<Typography variant="h6" component="h2">
{t("Assign Pick Orders")}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body1" color="text.secondary">
{t("Selected Pick Orders")}: {selectedPickOrderIds.length}
</Typography>
</Grid>
<Grid item xs={12}>
<FormProvider {...formProps}>
<form onSubmit={formProps.handleSubmit(handleAssignAndRelease)}>
<Grid container spacing={2}>
<Grid item xs={12}>
<FormControl fullWidth>
<Autocomplete
options={usernameList}
getOptionLabel={(option) => {
// 修改:显示更详细的用户信息
const title = option.title ? ` (${option.title})` : '';
const department = option.department ? ` - ${option.department}` : '';
return `${option.name}${title}${department}`;
}}
renderOption={(props, option) => (
<Box component="li" {...props}>
<Typography variant="body1">
{option.name}
{option.title && ` (${option.title})`}
{option.department && ` - ${option.department}`}
</Typography>
</Box>
)}
onChange={(_, value) => {
formProps.setValue("assignTo", value?.id || 0);
}}
renderInput={(params) => (
<TextField
{...params}
label={t("Assign To")}
error={!!errors.assignTo}
helperText={errors.assignTo?.message}
required
/>
)}
/>
</FormControl>
</Grid>
<Grid item xs={12}>
<Typography variant="body2" color="warning.main">
{t("This action will assign the selected pick orders to picker.")}
</Typography>
</Grid>
<Grid item xs={12}>
<Box sx={{ display: "flex", gap: 2, justifyContent: "flex-end" }}>
<Button variant="outlined" onClick={() => setModalOpen(false)}>
{t("Cancel")}
</Button>
<Button type="submit" variant="contained" color="primary">
{t("Assign")}
</Button>
</Box>
</Grid>
</Grid>
</form>
</FormProvider>
</Grid>
</Grid>
</Box>
</Modal>
) : undefined}
</>
);
};

export default AssignAndRelease;

+ 228
- 0
src/components/FinishedGoodSearch/CombinedLotTable.tsx Просмотреть файл

@@ -0,0 +1,228 @@
"use client";

import {
Box,
Button,
CircularProgress,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
TablePagination,
} from "@mui/material";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

interface CombinedLotTableProps {
combinedLotData: any[];
combinedDataLoading: boolean;
pickQtyData: Record<string, number>;
paginationController: {
pageNum: number;
pageSize: number;
};
onPickQtyChange: (lotKey: string, value: number | string) => void;
onSubmitPickQty: (lot: any) => void;
onRejectLot: (lot: any) => void;
onPageChange: (event: unknown, newPage: number) => void;
onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

// ✅ Simple helper function to check if item is completed
const isItemCompleted = (lot: any) => {
const actualPickQty = Number(lot.actualPickQty) || 0;
const requiredQty = Number(lot.requiredQty) || 0;
return lot.stockOutLineStatus === 'completed' ||
(actualPickQty > 0 && requiredQty > 0 && actualPickQty >= requiredQty);
};

const isItemRejected = (lot: any) => {
return lot.stockOutLineStatus === 'rejected';
};

const CombinedLotTable: React.FC<CombinedLotTableProps> = ({
combinedLotData,
combinedDataLoading,
pickQtyData,
paginationController,
onPickQtyChange,
onSubmitPickQty,
onRejectLot,
onPageChange,
onPageSizeChange,
}) => {
const { t } = useTranslation("pickOrder");

// ✅ Paginated data
const paginatedLotData = useMemo(() => {
const startIndex = paginationController.pageNum * paginationController.pageSize;
const endIndex = startIndex + paginationController.pageSize;
return combinedLotData.slice(startIndex, endIndex);
}, [combinedLotData, paginationController]);

if (combinedDataLoading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress size={40} />
</Box>
);
}

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Pick Order Code")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell>{t("Lot No")}</TableCell>
<TableCell>{t("Expiry Date")}</TableCell>
<TableCell>{t("Location")}</TableCell>
<TableCell>{t("Stock Unit")}</TableCell>
<TableCell align="right">{t("Available Qty")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell align="right">{t("Actual Pick Qty")}</TableCell>
<TableCell align="right">{t("Pick Qty")}</TableCell>
<TableCell align="center">{t("Submit")}</TableCell>
<TableCell align="center">{t("Reject")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedLotData.length === 0 ? (
<TableRow>
<TableCell colSpan={13} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
paginatedLotData.map((lot: any) => {
const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`;
const currentPickQty = pickQtyData[lotKey] ?? '';
const isCompleted = isItemCompleted(lot);
const isRejected = isItemRejected(lot);
// ✅ Green text color for completed items
const textColor = isCompleted ? 'success.main' : isRejected ? 'error.main' : 'inherit';
return (
<TableRow
key={lotKey}
sx={{
'&:hover': {
backgroundColor: 'action.hover',
},
}}
>
<TableCell sx={{ color: textColor }}>{lot.pickOrderCode}</TableCell>
<TableCell sx={{ color: textColor }}>{lot.itemCode}</TableCell>
<TableCell sx={{ color: textColor }}>{lot.itemName}</TableCell>
<TableCell sx={{ color: textColor }}>{lot.lotNo}</TableCell>
<TableCell sx={{ color: textColor }}>
{lot.expiryDate ? new Date(lot.expiryDate).toLocaleDateString() : 'N/A'}
</TableCell>
<TableCell sx={{ color: textColor }}>{lot.location}</TableCell>
<TableCell sx={{ color: textColor }}>{lot.stockUnit}</TableCell>
<TableCell align="right" sx={{ color: textColor }}>{lot.availableQty}</TableCell>
<TableCell align="right" sx={{ color: textColor }}>{lot.requiredQty}</TableCell>
<TableCell align="right" sx={{ color: textColor }}>{lot.actualPickQty || 0}</TableCell>
<TableCell align="right">
<TextField
type="number"
value={currentPickQty}
onChange={(e) => {
onPickQtyChange(lotKey, e.target.value);
}}
onFocus={(e) => {
e.target.select();
}}
inputProps={{
min: 0,
max: lot.availableQty,
step: 0.01
}}
disabled={
isCompleted ||
isRejected ||
lot.lotAvailability === 'expired' ||
lot.lotAvailability === 'status_unavailable'
}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'right',
}
}}
/>
</TableCell>
<TableCell align="center">
<Button
variant="contained"
size="small"
disabled={isCompleted || isRejected || !currentPickQty || currentPickQty <= 0}
onClick={() => onSubmitPickQty(lot)}
sx={{
backgroundColor: isCompleted ? 'success.main' : 'primary.main',
color: 'white',
'&:disabled': {
backgroundColor: 'grey.300',
color: 'grey.500',
},
}}
>
{isCompleted ? t("Completed") : t("Submit")}
</Button>
</TableCell>
<TableCell align="center">
<Button
variant="outlined"
size="small"
color="error"
disabled={isCompleted || isRejected}
onClick={() => onRejectLot(lot)}
sx={{
'&:disabled': {
borderColor: 'grey.300',
color: 'grey.500',
},
}}
>
{t("Reject")}
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={combinedLotData.length}
page={paginationController.pageNum}
rowsPerPage={paginationController.pageSize}
onPageChange={onPageChange}
onRowsPerPageChange={onPageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

export default CombinedLotTable;

+ 91
- 0
src/components/FinishedGoodSearch/ConsolidatePickOrderItemSum.tsx Просмотреть файл

@@ -0,0 +1,91 @@
"use client";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import StyledDataGrid from "../StyledDataGrid";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { GridColDef } from "@mui/x-data-grid";
import { CircularProgress, Grid, Typography } from "@mui/material";
import { ByItemsSummary } from "@/app/api/pickOrder";
import { useTranslation } from "react-i18next";

dayjs.extend(arraySupport);

interface Props {
rows: ByItemsSummary[] | undefined;
setRows: Dispatch<SetStateAction<ByItemsSummary[] | undefined>>;
}

const ConsolidatePickOrderItemSum: React.FC<Props> = ({ rows, setRows }) => {
console.log(rows);
const { t } = useTranslation("pickOrder");

const columns = useMemo<GridColDef[]>(
() => [
{
field: "name",
headerName: "name",
flex: 1,
renderCell: (params) => {
console.log(params.row.name);
return params.row.name;
},
},
{
field: "requiredQty",
headerName: "requiredQty",
flex: 1,
renderCell: (params) => {
console.log(params.row.requiredQty);
const requiredQty = params.row.requiredQty ?? 0;
return `${requiredQty} ${params.row.uomDesc}`;
},
},
{
field: "availableQty",
headerName: "availableQty",
flex: 1,
renderCell: (params) => {
console.log(params.row.availableQty);
const availableQty = params.row.availableQty ?? 0;
return `${availableQty} ${params.row.uomDesc}`;
},
},
],
[],
);
return (
<Grid
container
rowGap={1}
// direction="column"
alignItems="center"
justifyContent="center"
>
<Grid item xs={12}>
<Typography variant="h5" marginInlineEnd={2}>
{t("Items Included")}
</Typography>
</Grid>
<Grid item xs={12}>
{!rows ? (
<CircularProgress size={40} />
) : (
<StyledDataGrid
sx={{ maxHeight: 450 }}
rows={rows}
columns={columns}
/>
)}
</Grid>
</Grid>
);
};

export default ConsolidatePickOrderItemSum;

+ 116
- 0
src/components/FinishedGoodSearch/ConsolidatePickOrderSum.tsx Просмотреть файл

@@ -0,0 +1,116 @@
"use client";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import StyledDataGrid from "../StyledDataGrid";
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { GridColDef, GridInputRowSelectionModel } from "@mui/x-data-grid";
import { Box, CircularProgress, Grid, Typography } from "@mui/material";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useTranslation } from "react-i18next";

dayjs.extend(arraySupport);

interface Props {
consoCode: string;
rows: Omit<PickOrderResult, "items">[] | undefined;
setRows: Dispatch<
SetStateAction<Omit<PickOrderResult, "items">[] | undefined>
>;
revertIds: GridInputRowSelectionModel;
setRevertIds: Dispatch<SetStateAction<GridInputRowSelectionModel>>;
}

const ConsolidatePickOrderSum: React.FC<Props> = ({
consoCode,
rows,
setRows,
revertIds,
setRevertIds,
}) => {
const { t } = useTranslation("pickOrder");
const columns = useMemo<GridColDef[]>(
() => [
{
field: "code",
headerName: "code",
flex: 0.6,
},

{
field: "pickOrderLines",
headerName: "items",
flex: 1,
renderCell: (params) => {
console.log(params);
const pickOrderLine = params.row.pickOrderLines as any[];
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
maxHeight: 100,
overflowY: "scroll",
scrollbarWidth: "none", // For Firefox
"&::-webkit-scrollbar": {
display: "none", // For Chrome, Safari, and Opera
},
}}
>
{pickOrderLine.map((item, index) => (
<Grid
sx={{ mt: 1 }}
key={index}
>{`${item.itemName} x ${item.requiredQty} ${item.uomDesc}`}</Grid> // Render each name in a span
))}
</Box>
);
},
},
],
[],
);

return (
<Grid
container
rowGap={1}
// direction="column"
alignItems="center"
justifyContent="center"
>
<Grid item xs={12}>
<Typography variant="h5" marginInlineEnd={2}>
{t("Pick Order Included")}
</Typography>
</Grid>
<Grid item xs={12}>
{!rows ? (
<CircularProgress size={40} />
) : (
<StyledDataGrid
sx={{ maxHeight: 450 }}
checkboxSelection
rowSelectionModel={revertIds}
onRowSelectionModelChange={(newRowSelectionModel) => {
setRevertIds(newRowSelectionModel);
}}
getRowHeight={(params) => {
return 100;
}}
rows={rows}
columns={columns}
/>
)}
</Grid>
</Grid>
);
};

export default ConsolidatePickOrderSum;

+ 370
- 0
src/components/FinishedGoodSearch/ConsolidatedPickOrders.tsx Просмотреть файл

@@ -0,0 +1,370 @@
import {
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
Grid,
Modal,
ModalProps,
TextField,
Typography,
} from "@mui/material";
import { GridToolbarContainer } from "@mui/x-data-grid";
import {
FooterPropsOverrides,
GridColDef,
GridRowSelectionModel,
useGridApiRef,
} from "@mui/x-data-grid";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import SearchResults, {
Column,
defaultPagingController,
} from "../SearchResults/SearchResults";
import {
ByItemsSummary,
ConsoPickOrderResult,
PickOrderLine,
PickOrderResult,
} from "@/app/api/pickOrder";
import { useRouter, useSearchParams } from "next/navigation";
import ConsolidatePickOrderItemSum from "./ConsolidatePickOrderItemSum";
import ConsolidatePickOrderSum from "./ConsolidatePickOrderSum";
import { GridInputRowSelectionModel } from "@mui/x-data-grid";
import {
fetchConsoDetail,
fetchConsoPickOrderClient,
releasePickOrder,
ReleasePickOrderInputs,
} from "@/app/api/pickOrder/actions";
import { EditNote } from "@mui/icons-material";
import { fetchNameList, NameList } from "@/app/api/user/actions";
import { useField } from "@mui/x-date-pickers/internals";
import {
FormProvider,
SubmitErrorHandler,
SubmitHandler,
useForm,
} from "react-hook-form";
import { pickOrderStatusMap } from "@/app/utils/formatUtil";

interface Props {
filterArgs: Record<string, any>;
}

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
// width: 1500,
width: { xs: "100%", sm: "100%", md: "100%" },
};
interface DisableButton {
releaseBtn: boolean;
removeBtn: boolean;
}

const ConsolidatedPickOrders: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const router = useRouter();
const apiRef = useGridApiRef();
const [filteredPickOrders, setFilteredPickOrders] = useState(
[] as ConsoPickOrderResult[],
);
const [isLoading, setIsLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false); //change back to false
const [consoCode, setConsoCode] = useState<string | undefined>(); ///change back to undefined
const [revertIds, setRevertIds] = useState<GridInputRowSelectionModel>([]);
const [totalCount, setTotalCount] = useState<number>();
const [usernameList, setUsernameList] = useState<NameList[]>([]);

const [byPickOrderRows, setByPickOrderRows] = useState<
Omit<PickOrderResult, "items">[] | undefined
>(undefined);
const [byItemsRows, setByItemsRows] = useState<ByItemsSummary[] | undefined>(
undefined,
);
const [disableRelease, setDisableRelease] = useState<boolean>(true);

const formProps = useForm<ReleasePickOrderInputs>();
const errors = formProps.formState.errors;

const openDetailModal = useCallback((consoCode: string) => {
setConsoCode(consoCode);
setModalOpen(true);
}, []);

const closeDetailModal = useCallback(() => {
setModalOpen(false);
setConsoCode(undefined);
}, []);

const onDetailClick = useCallback(
(pickOrder: any) => {
console.log(pickOrder);
const status = pickOrder.status;
if (pickOrderStatusMap[status] >= 3) {
router.push(`/pickOrder/detail?consoCode=${pickOrder.consoCode}`);
} else {
openDetailModal(pickOrder.consoCode);
}
},
[router, openDetailModal],
);
const columns = useMemo<Column<ConsoPickOrderResult>[]>(
() => [
{
name: "id",
label: t("Detail"),
onClick: onDetailClick,
buttonIcon: <EditNote />,
},
{
name: "consoCode",
label: t("consoCode"),
},
{
name: "status",
label: t("status"),
},
],
[onDetailClick, t],
);
const [pagingController, setPagingController] = useState(
defaultPagingController,
);

// pass conso code back to assign
// pass user back to assign
const fetchNewPageConsoPickOrder = useCallback(
async (
pagingController: Record<string, number>,
filterArgs: Record<string, number>,
) => {
setIsLoading(true);
const params = {
...pagingController,
...filterArgs,
};
const res = await fetchConsoPickOrderClient(params);
if (res) {
console.log(res);
setFilteredPickOrders(res.records);
setTotalCount(res.total);
}
setIsLoading(false);
},
[],
);

useEffect(() => {
fetchNewPageConsoPickOrder(pagingController, filterArgs);
}, [fetchNewPageConsoPickOrder, pagingController, filterArgs]);

const isReleasable = useCallback((itemList: ByItemsSummary[]): boolean => {
let isReleasable = true;
for (const item of itemList) {
isReleasable = item.requiredQty >= item.availableQty;
if (!isReleasable) return isReleasable;
}
return isReleasable;
}, []);

const fetchConso = useCallback(
async (consoCode: string) => {
const res = await fetchConsoDetail(consoCode);
const nameListRes = await fetchNameList();
if (res) {
console.log(res);
setByPickOrderRows(res.pickOrders);
// for testing
// for (const item of res.items) {
// item.availableQty = 1000;
// }
setByItemsRows(res.items);
setDisableRelease(isReleasable(res.items));
} else {
console.log("error");
console.log(res);
}
if (nameListRes) {
console.log(nameListRes);
setUsernameList(nameListRes);
}
},
[isReleasable],
);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
closeDetailModal();
// reset();
},
[closeDetailModal],
);

const onChange = useCallback(
(event: React.SyntheticEvent, newValue: NameList) => {
console.log(newValue);
formProps.setValue("assignTo", newValue.id);
},
[formProps],
);

const onSubmit = useCallback<SubmitHandler<ReleasePickOrderInputs>>(
async (data, event) => {
console.log(data);
try {
const res = await releasePickOrder(data);
console.log(res);
if (res.consoCode.length > 0) {
console.log(res);
router.push(`/pickOrder/detail?consoCode=${res.consoCode}`);
} else {
console.log(res);
}
} catch (error) {
console.log(error);
}
},
[router],
);
const onSubmitError = useCallback<SubmitErrorHandler<ReleasePickOrderInputs>>(
(errors) => {},
[],
);

const handleConsolidate_revert = useCallback(() => {
console.log(revertIds);
}, [revertIds]);

useEffect(() => {
if (consoCode) {
fetchConso(consoCode);
formProps.setValue("consoCode", consoCode);
}
}, [consoCode, fetchConso, formProps]);

return (
<>
<Grid
container
rowGap={1}
// direction="column"
alignItems="center"
justifyContent="center"
>
<Grid item xs={12}>
{isLoading ? (
<CircularProgress size={40} />
) : (
<SearchResults<ConsoPickOrderResult>
items={filteredPickOrders}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
totalCount={totalCount}
/>
)}
</Grid>
</Grid>
{consoCode != undefined ? (
<Modal open={modalOpen} onClose={closeHandler}>
<FormProvider {...formProps}>
<Box
sx={{ ...style, maxHeight: 800 }}
component="form"
onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)}
>
<Grid container>
<Grid item xs={8}>
<Typography mb={2} variant="h4">
{consoCode}
</Typography>
</Grid>
<Grid
item
xs={4}
display="flex"
justifyContent="end"
alignItems="end"
>
<FormControl fullWidth>
<Autocomplete
disableClearable
fullWidth
getOptionLabel={(option) => option.name}
options={usernameList}
onChange={onChange}
renderInput={(params) => <TextField {...params} />}
/>
</FormControl>
</Grid>
</Grid>
<Box
sx={{
height: 400,
overflowY: "auto",
}}
>
<Grid container>
<Grid item xs={12} sx={{ mt: 2 }}>
<ConsolidatePickOrderSum
rows={byPickOrderRows}
setRows={setByPickOrderRows}
consoCode={consoCode}
revertIds={revertIds}
setRevertIds={setRevertIds}
/>
</Grid>
<Grid item xs={12}>
<ConsolidatePickOrderItemSum
rows={byItemsRows}
setRows={setByItemsRows}
/>
</Grid>
</Grid>
</Box>
<Grid container>
<Grid
item
xs={12}
display="flex"
justifyContent="end"
alignItems="end"
>
<Button
disabled={(revertIds as number[]).length < 1}
variant="outlined"
onClick={handleConsolidate_revert}
sx={{ mr: 1 }}
>
{t("remove")}
</Button>
<Button
disabled={disableRelease}
variant="outlined"
// onClick={handleRelease}
type="submit"
>
{t("release")}
</Button>
</Grid>
</Grid>
</Box>
</FormProvider>
</Modal>
) : undefined}
</>
);
};

export default ConsolidatedPickOrders;

+ 321
- 0
src/components/FinishedGoodSearch/CreateForm.tsx Просмотреть файл

@@ -0,0 +1,321 @@
"use client";

import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions";
import {
Autocomplete,
Box,
Card,
CardContent,
FormControl,
Grid,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
GridColDef,
GridRowIdGetter,
GridRowModel,
useGridApiContext,
GridRenderCellParams,
GridRenderEditCellParams,
useGridApiRef,
} from "@mui/x-data-grid";
import InputDataGrid from "../InputDataGrid";
import { TableRow } from "../InputDataGrid/InputDataGrid";
import { GridEditInputCell } from "@mui/x-data-grid";
import { StockInLine } from "@/app/api/po";
import { INPUT_DATE_FORMAT, stockInLineStatusMap } from "@/app/utils/formatUtil";
import { fetchQcItemCheck, fetchQcResult } from "@/app/api/qc/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import axios from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { SavePickOrderLineRequest, SavePickOrderRequest } from "@/app/api/pickOrder/actions";
import TwoLineCell from "../PoDetail/TwoLineCell";
import ItemSelect from "./ItemSelect";
import { ItemCombo } from "@/app/api/settings/item/actions";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs from "dayjs";

interface Props {
items: ItemCombo[];
// disabled: boolean;
}
type EntryError =
| {
[field in keyof SavePickOrderLineRequest]?: string;
}
| undefined;

type PolRow = TableRow<Partial<SavePickOrderLineRequest>, EntryError>;
// fetchQcItemCheck
const CreateForm: React.FC<Props> = ({ items }) => {
const {
t,
i18n: { language },
} = useTranslation("pickOrder");
const apiRef = useGridApiRef();
const {
formState: { errors, defaultValues, touchedFields },
watch,
control,
setValue,
} = useFormContext<SavePickOrderRequest>();
console.log(defaultValues);
const targetDate = watch("targetDate");
//// validate form
// const accQty = watch("acceptedQty");
// const validateForm = useCallback(() => {
// console.log(accQty);
// if (accQty > itemDetail.acceptedQty) {
// setError("acceptedQty", {
// message: `${t("acceptedQty must not greater than")} ${
// itemDetail.acceptedQty
// }`,
// type: "required",
// });
// }
// if (accQty < 1) {
// setError("acceptedQty", {
// message: t("minimal value is 1"),
// type: "required",
// });
// }
// if (isNaN(accQty)) {
// setError("acceptedQty", {
// message: t("value must be a number"),
// type: "required",
// });
// }
// }, [accQty]);

// useEffect(() => {
// clearErrors();
// validateForm();
// }, [clearErrors, validateForm]);

const columns = useMemo<GridColDef[]>(
() => [
{
field: "itemId",
headerName: t("Item"),
// width: 100,
flex: 1,
editable: true,
valueFormatter(params) {
const row = params.id ? params.api.getRow<PolRow>(params.id) : null;
if (!row) {
return null;
}
const Item = items.find((q) => q.id === row.itemId);
return Item ? Item.label : t("Please select item");
},
renderCell(params: GridRenderCellParams<PolRow, number>) {
console.log(params.value);
return <TwoLineCell>{params.formattedValue}</TwoLineCell>;
},
renderEditCell(params: GridRenderEditCellParams<PolRow, number>) {
const errorMessage =
params.row._error?.[params.field as keyof SavePickOrderLineRequest];
console.log(errorMessage);
const content = (
// <></>
<ItemSelect
allItems={items}
value={params.row.itemId}
onItemSelect={async (itemId, uom, uomId) => {
console.log(uom)
await params.api.setEditCellValue({
id: params.id,
field: "itemId",
value: itemId,
});
await params.api.setEditCellValue({
id: params.id,
field: "uom",
value: uom
})
await params.api.setEditCellValue({
id: params.id,
field: "uomId",
value: uomId
})
}}
/>
);
return errorMessage ? (
<Tooltip title={errorMessage}>
<Box width="100%">{content}</Box>
</Tooltip>
) : (
content
);
},
},
{
field: "qty",
headerName: t("qty"),
// width: 100,
flex: 1,
type: "number",
editable: true,
renderEditCell(params: GridRenderEditCellParams<PolRow>) {
const errorMessage =
params.row._error?.[params.field as keyof SavePickOrderLineRequest];
const content = <GridEditInputCell {...params} />;
return errorMessage ? (
<Tooltip title={t(errorMessage)}>
<Box width="100%">{content}</Box>
</Tooltip>
) : (
content
);
},
},
{
field: "uom",
headerName: t("uom"),
// width: 100,
flex: 1,
editable: true,
// renderEditCell(params: GridRenderEditCellParams<PolRow>) {
// console.log(params.row)
// const errorMessage =
// params.row._error?.[params.field as keyof SavePickOrderLineRequest];
// const content = <GridEditInputCell {...params} />;
// return errorMessage ? (
// <Tooltip title={t(errorMessage)}>
// <Box width="100%">{content}</Box>
// </Tooltip>
// ) : (
// content
// );
// }
}
],
[items, t],
);
/// validate datagrid
const validation = useCallback(
(newRow: GridRowModel<PolRow>): EntryError => {
const error: EntryError = {};
const { itemId, qty } = newRow;
if (!itemId || itemId <= 0) {
error["itemId"] = t("select qc");
}
if (!qty || qty <= 0) {
error["qty"] = t("enter a qty");
}
return Object.keys(error).length > 0 ? error : undefined;
},
[],
);

const typeList = [
{
type: "Consumable"
}
]

const onChange = useCallback(
(event: React.SyntheticEvent, newValue: {type: string}) => {
console.log(newValue);
setValue("type", newValue.type);
},
[setValue],
);

return (
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
{t("Pick Order Detail")}
</Typography>
</Grid>
<Grid
container
justifyContent="flex-start"
alignItems="flex-start"
spacing={2}
sx={{ mt: 0.5 }}
>
<Grid item xs={6} lg={6}>
<FormControl fullWidth>
<Autocomplete
disableClearable
fullWidth
getOptionLabel={(option) => option.type}
options={typeList}
onChange={onChange}
renderInput={(params) => <TextField {...params} label={t("type")}/>}
/>
</FormControl>
</Grid>
<Grid item xs={6}>
<Controller
control={control}
name="targetDate"
// rules={{ required: !Boolean(productionDate) }}
render={({ field }) => {
return (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={`${language}-hk`}
>
<DatePicker
{...field}
sx={{ width: "100%" }}
label={t("targetDate")}
value={targetDate ? dayjs(targetDate) : undefined}
onChange={(date) => {
console.log(date);
if (!date) return;
console.log(date.format(INPUT_DATE_FORMAT));
setValue("targetDate", date.format(INPUT_DATE_FORMAT));
// field.onChange(date);
}}
inputRef={field.ref}
slotProps={{
textField: {
// required: true,
error: Boolean(errors.targetDate?.message),
helperText: errors.targetDate?.message,
},
}}
/>
</LocalizationProvider>
);
}}
/>
</Grid>
</Grid>
<Grid
container
justifyContent="flex-start"
alignItems="flex-start"
spacing={2}
sx={{ mt: 0.5 }}
>
<Grid item xs={12}>
<InputDataGrid<SavePickOrderRequest, SavePickOrderLineRequest, EntryError>
apiRef={apiRef}
checkboxSelection={false}
_formKey={"pickOrderLine"}
columns={columns}
validateRow={validation}
needAdd={true}
/>
</Grid>
</Grid>
</Grid>
);
};
export default CreateForm;

+ 98
- 0
src/components/FinishedGoodSearch/CreatePickOrderModal.tsx Просмотреть файл

@@ -0,0 +1,98 @@
import { createPickOrder, SavePickOrderRequest } from "@/app/api/pickOrder/actions";
import { Box, Button, Modal, ModalProps, Stack } from "@mui/material";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import { useCallback } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import CreateForm from "./CreateForm";
import { ItemCombo } from "@/app/api/settings/item/actions";
import { Check } from "@mui/icons-material";
dayjs.extend(arraySupport);

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
overflow: "scroll",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
display: "block",
width: { xs: "100%", sm: "100%", md: "100%" },
};

interface Props extends Omit<ModalProps, "children"> {
items: ItemCombo[]
}

const CreatePickOrderModal: React.FC<Props> = ({
open,
onClose,
items
}) => {
const { t } = useTranslation("pickOrder");
const formProps = useForm<SavePickOrderRequest>();
const errors = formProps.formState.errors;
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
// reset();
},
[onClose]
);
const onSubmit = useCallback<SubmitHandler<SavePickOrderRequest>>(
async (data, event) => {
console.log(data)
try {
const res = await createPickOrder(data)
if (res.id) {
closeHandler({}, "backdropClick");
}
} catch (error) {
console.log(error)
throw error
}
// formProps.reset()
},
[closeHandler]
);
return (
<>
<FormProvider {...formProps}>
<Modal open={open} onClose={closeHandler}>
<Box
sx={style}
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<CreateForm
items={items}
/>
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
name="submit"
variant="contained"
startIcon={<Check />}
type="submit"
>
{t("submit")}
</Button>
<Button
name="reset"
variant="contained"
startIcon={<Check />}
onClick={() => formProps.reset()}
>
{t("reset")}
</Button>
</Stack>
</Box>
</Modal>
</FormProvider>
</>
);
};
export default CreatePickOrderModal;

+ 209
- 0
src/components/FinishedGoodSearch/CreatedItemsTable.tsx Просмотреть файл

@@ -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;

+ 179
- 0
src/components/FinishedGoodSearch/EscalationComponent.tsx Просмотреть файл

@@ -0,0 +1,179 @@
import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react';
import {
Box,
Button,
Collapse,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Checkbox,
FormControlLabel,
Paper,
Typography,
RadioGroup,
Radio,
Stack,
Autocomplete,
} from '@mui/material';
import { SelectChangeEvent } from '@mui/material/Select';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useTranslation } from 'react-i18next';

interface NameOption {
value: string;
label: string;
}

interface FormData {
name: string;
quantity: string;
message: string;
}

interface Props {
forSupervisor: boolean
isCollapsed: boolean
setIsCollapsed: Dispatch<React.SetStateAction<boolean>>
}
const EscalationComponent: React.FC<Props> = ({
forSupervisor,
isCollapsed,
setIsCollapsed
}) => {
const { t } = useTranslation("purchaseOrder");
const [formData, setFormData] = useState<FormData>({
name: '',
quantity: '',
message: '',
});

const nameOptions: NameOption[] = [
{ value: '', label: '請選擇姓名...' },
{ value: 'john', label: '張大明' },
{ value: 'jane', label: '李小美' },
{ value: 'mike', label: '王志強' },
{ value: 'sarah', label: '陳淑華' },
{ value: 'david', label: '林建國' },
];

const handleInputChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> | SelectChangeEvent<string>
): void => {
const { name, value } = event.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};

const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
console.log('表單已提交:', formData);
// 處理表單提交
};

const handleCollapseToggle = (e: ChangeEvent<HTMLInputElement>): void => {
setIsCollapsed(e.target.checked);
};

return (
// <Paper elevation={3} sx={{ maxWidth: 400, mx: 'auto', p: 3 }}>
<>
<Paper>
{/* <Paper elevation={3} sx={{ mx: 'auto', p: 3 }}> */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={isCollapsed}
onChange={handleCollapseToggle}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">上報結果</Typography>
{isCollapsed ? (
<ExpandLessIcon sx={{ ml: 1 }} />
) : (
<ExpandMoreIcon sx={{ ml: 1 }} />
)}
</Box>
}
/>
</Box>
<Collapse in={isCollapsed}>
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{forSupervisor ? (
<FormControl>
<RadioGroup
row
aria-labelledby="demo-radio-buttons-group-label"
defaultValue="pass"
name="radio-buttons-group"
>
<FormControlLabel value="pass" control={<Radio />} label="合格" />
<FormControlLabel value="fail" control={<Radio />} label="不合格" />
</RadioGroup>
</FormControl>
): undefined}
<FormControl fullWidth>
<select
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
>
{nameOptions.map((option: NameOption) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormControl>
<TextField
fullWidth
id="quantity"
name="quantity"
label="數量"
type="number"
value={formData.quantity}
onChange={handleInputChange}
InputProps={{ inputProps: { min: 1 } }}
placeholder="請輸入數量"
/>

<TextField
fullWidth
id="message"
name="message"
label="備註"
multiline
rows={4}
value={formData.message}
onChange={handleInputChange}
placeholder="請輸入您的備註"
/>

<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
type="submit"
variant="contained"
color="primary"
>
{t("update qc info")}
</Button>
</Stack>
</Box>
</Collapse>
</Paper>
</>
);
}

export default EscalationComponent;

+ 167
- 0
src/components/FinishedGoodSearch/FinishedGood.tsx Просмотреть файл

@@ -0,0 +1,167 @@
import { Button, CircularProgress, Grid } from "@mui/material";
import SearchResults, { Column } from "../SearchResults/SearchResults";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useTranslation } from "react-i18next";
import { useCallback, useEffect, useMemo, useState } from "react";
import { isEmpty, upperCase, upperFirst } from "lodash";
import { arrayToDateString, OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import {
consolidatePickOrder,
fetchPickOrderClient,
} from "@/app/api/pickOrder/actions";
import useUploadContext from "../UploadProvider/useUploadContext";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
dayjs.extend(arraySupport);
interface Props {
filteredPickOrders: PickOrderResult[];
filterArgs: Record<string, any>;
}

const PickOrders: React.FC<Props> = ({ filteredPickOrders, filterArgs }) => {
const { t } = useTranslation("pickOrder");
const [selectedRows, setSelectedRows] = useState<(string | number)[]>([]);
const [filteredPickOrder, setFilteredPickOrder] = useState(
[] as PickOrderResult[],
);
const { setIsUploading } = useUploadContext();
const [isLoading, setIsLoading] = useState(false);
const [pagingController, setPagingController] = useState({
pageNum: 0,
pageSize: 10,
});
const [totalCount, setTotalCount] = useState<number>();

const fetchNewPagePickOrder = useCallback(
async (
pagingController: Record<string, number>,
filterArgs: Record<string, number>,
) => {
setIsLoading(true);
const params = {
...pagingController,
...filterArgs,
};
const res = await fetchPickOrderClient(params);
if (res) {
console.log(res);
setFilteredPickOrder(res.records);
setTotalCount(res.total);
}
setIsLoading(false);
},
[],
);

const handleConsolidatedRows = useCallback(async () => {
console.log(selectedRows);
setIsUploading(true);
try {
const res = await consolidatePickOrder(selectedRows as number[]);
if (res) {
console.log(res);
}
} catch {
setIsUploading(false);
}
fetchNewPagePickOrder(pagingController, filterArgs);
setIsUploading(false);
}, [selectedRows, setIsUploading, fetchNewPagePickOrder, pagingController, filterArgs]);


useEffect(() => {
fetchNewPagePickOrder(pagingController, filterArgs);
}, [fetchNewPagePickOrder, pagingController, filterArgs]);

const columns = useMemo<Column<PickOrderResult>[]>(
() => [
{
name: "id",
label: "",
type: "checkbox",
disabled: (params) => {
return !isEmpty(params.consoCode);
},
},
{
name: "code",
label: t("Code"),
},
{
name: "consoCode",
label: t("Consolidated Code"),
renderCell: (params) => {
return params.consoCode ?? "";
},
},
{
name: "type",
label: t("type"),
renderCell: (params) => {
return upperCase(params.type);
},
},
{
name: "items",
label: t("Items"),
renderCell: (params) => {
return params.items?.map((i) => i.name).join(", ");
},
},
{
name: "targetDate",
label: t("Target Date"),
renderCell: (params) => {
return (
dayjs(params.targetDate)
.add(-1, "month")
.format(OUTPUT_DATE_FORMAT)
);
},
},
{
name: "releasedBy",
label: t("Released By"),
},
{
name: "status",
label: t("Status"),
renderCell: (params) => {
return upperFirst(params.status);
},
},
],
[t],
);

return (
<Grid container rowGap={1}>
<Grid item xs={3}>
<Button
disabled={selectedRows.length < 1}
variant="outlined"
onClick={handleConsolidatedRows}
>
{t("Consolidate")}
</Button>
</Grid>
<Grid item xs={12}>
{isLoading ? (
<CircularProgress size={40} />
) : (
<SearchResults<PickOrderResult>
items={filteredPickOrder}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
totalCount={totalCount}
checkboxIds={selectedRows!}
setCheckboxIds={setSelectedRows}
/>
)}
</Grid>
</Grid>
);
};

export default PickOrders;

+ 292
- 0
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx Просмотреть файл

@@ -0,0 +1,292 @@
"use client";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchBox, { Criterion } from "../SearchBox";
import {
flatten,
intersectionWith,
isEmpty,
sortBy,
uniqBy,
upperCase,
upperFirst,
} from "lodash";
import {
arrayToDayjs,
} from "@/app/utils/formatUtil";
import { Button, Grid, Stack, Tab, Tabs, TabsProps, Typography, Box } from "@mui/material";
import PickOrders from "./FinishedGood";
import ConsolidatedPickOrders from "./ConsolidatedPickOrders";
import PickExecution from "./GoodPickExecution";
import CreatePickOrderModal from "./CreatePickOrderModal";
import NewCreateItem from "./newcreatitem";
import AssignAndRelease from "./AssignAndRelease";
import AssignTo from "./assignTo";
import { fetchAllItemsInClient, ItemCombo } from "@/app/api/settings/item/actions";
import { fetchPickOrderClient } from "@/app/api/pickOrder/actions";
import Jobcreatitem from "./Jobcreatitem";

interface Props {
pickOrders: PickOrderResult[];
}

type SearchQuery = Partial<
Omit<PickOrderResult, "id" | "consoCode" | "completeDate">
>;

type SearchParamNames = keyof SearchQuery;

const PickOrderSearch: React.FC<Props> = ({ pickOrders }) => {
const { t } = useTranslation("pickOrder");

const [isOpenCreateModal, setIsOpenCreateModal] = useState(false)
const [items, setItems] = useState<ItemCombo[]>([])
const [filteredPickOrders, setFilteredPickOrders] = useState(pickOrders);
const [filterArgs, setFilterArgs] = useState<Record<string, any>>({});
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
const [tabIndex, setTabIndex] = useState(0);
const [totalCount, setTotalCount] = useState<number>();

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[],
);
const openCreateModal = useCallback(async () => {
console.log("testing")
const res = await fetchAllItemsInClient()
console.log(res)
setItems(res)
setIsOpenCreateModal(true)
}, [])

const closeCreateModal = useCallback(() => {
setIsOpenCreateModal(false)
}, [])

useEffect(() => {
if (tabIndex === 3) {
const loadItems = async () => {
try {
const itemsData = await fetchAllItemsInClient();
console.log("PickOrderSearch loaded items:", itemsData.length);
setItems(itemsData);
} catch (error) {
console.error("Error loading items in PickOrderSearch:", error);
}
};
// 如果还没有数据,则加载
if (items.length === 0) {
loadItems();
}
}
}, [tabIndex, items.length]);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => {
const baseCriteria: Criterion<SearchParamNames>[] = [
{
label: tabIndex === 3 ? t("Item Code") : t("Code"),
paramName: "code",
type: "text"
},
{
label: t("Type"),
paramName: "type",
type: "autocomplete",
options: tabIndex === 3
?
[
{ value: "Consumable", label: t("Consumable") },
{ value: "Material", label: t("Material") },
{ value: "Product", label: t("Product") }
]
:
sortBy(
uniqBy(
pickOrders.map((po) => ({
value: po.type,
label: t(upperCase(po.type)),
})),
"value",
),
"label",
),
},
];
// Add Job Order search for Create Item tab (tabIndex === 3)
if (tabIndex === 3) {
baseCriteria.splice(1, 0, {
label: t("Job Order"),
paramName: "jobOrderCode" as any, // Type assertion for now
type: "text",
});
baseCriteria.splice(2, 0, {
label: t("Target Date"),
paramName: "targetDate",
type: "date",
});
} else {
baseCriteria.splice(1, 0, {
label: t("Target Date From"),
label2: t("Target Date To"),
paramName: "targetDate",
type: "dateRange",
});
}
// Add Items/Item Name criteria
baseCriteria.push({
label: tabIndex === 3 ? t("Item Name") : t("Items"),
paramName: "items",
type: tabIndex === 3 ? "text" : "autocomplete",
options: tabIndex === 3
? []
:
uniqBy(
flatten(
sortBy(
pickOrders.map((po) =>
po.items
? po.items.map((item) => ({
value: item.name,
label: item.name,
}))
: [],
),
"label",
),
),
"value",
),
});
// Add Status criteria for non-Create Item tabs
if (tabIndex !== 3) {
baseCriteria.push({
label: t("Status"),
paramName: "status",
type: "autocomplete",
options: sortBy(
uniqBy(
pickOrders.map((po) => ({
value: po.status,
label: t(upperFirst(po.status)),
})),
"value",
),
"label",
),
});
}
return baseCriteria;
},
[pickOrders, t, tabIndex, items],
);

const fetchNewPagePickOrder = useCallback(
async (
pagingController: Record<string, number>,
filterArgs: Record<string, number>,
) => {
const params = {
...pagingController,
...filterArgs,
};
const res = await fetchPickOrderClient(params);
if (res) {
console.log(res);
setFilteredPickOrders(res.records);
setTotalCount(res.total);
}
},
[],
);

const onReset = useCallback(() => {
setFilteredPickOrders(pickOrders);
}, [pickOrders]);

useEffect(() => {
if (!isOpenCreateModal) {
setTabIndex(1)
setTimeout(async () => {
setTabIndex(0)
}, 200)
}
}, [isOpenCreateModal])
// 添加处理提料单创建成功的函数
const handlePickOrderCreated = useCallback(() => {
// 切换到 Assign & Release 标签页 (tabIndex = 1)
setTabIndex(2);
}, []);

return (
<Box sx={{
height: '100vh', // Full viewport height
overflow: 'auto' // Single scrollbar for the whole page
}}>
{/* Header section */}
<Box sx={{
p: 2,
borderBottom: '1px solid #e0e0e0'
}}>
<Stack rowGap={2}>
<Grid container>
<Grid item xs={8}>
<Typography variant="h4" marginInlineEnd={2}>
{t("Pick Order")}
</Typography>
</Grid>
{/*
<Grid item xs={4} display="flex" justifyContent="end" alignItems="end">
<Button onClick={openCreateModal}>
{t("create")}
</Button>
{isOpenCreateModal &&
<CreatePickOrderModal
open={isOpenCreateModal}
onClose={closeCreateModal}
items={items}
/>
}
</Grid>
*/}
</Grid>
</Stack>
</Box>

{/* Tabs section */}
<Box sx={{
borderBottom: '1px solid #e0e0e0'
}}>
<Tabs value={tabIndex} onChange={handleTabChange} variant="scrollable">
<Tab label={t("Assign")} iconPosition="end" />
<Tab label={t("Release")} iconPosition="end" />
<Tab label={t("Pick Execution")} iconPosition="end" />
</Tabs>
</Box>

{/* Content section - NO overflow: 'auto' here */}
<Box sx={{
p: 2
}}>
{tabIndex === 2 && <PickExecution filterArgs={filterArgs} />}
{tabIndex === 0 && <AssignAndRelease filterArgs={filterArgs} />}
{tabIndex === 1 && <AssignTo filterArgs={filterArgs} />}
</Box>
</Box>
);
};

export default PickOrderSearch;

+ 26
- 0
src/components/FinishedGoodSearch/FinishedGoodSearchWrapper.tsx Просмотреть файл

@@ -0,0 +1,26 @@
import { fetchPickOrders } from "@/app/api/pickOrder";
import GeneralLoading from "../General/GeneralLoading";
import PickOrderSearch from "./FinishedGoodSearch";

interface SubComponents {
Loading: typeof GeneralLoading;
}

const FinishedGoodSearchWrapper: React.FC & SubComponents = async () => {
const [pickOrders] = await Promise.all([
fetchPickOrders({
code: undefined,
targetDateFrom: undefined,
targetDateTo: undefined,
type: undefined,
status: undefined,
itemName: undefined,
}),
]);

return <PickOrderSearch pickOrders={pickOrders} />;
};

FinishedGoodSearchWrapper.Loading = GeneralLoading;

export default FinishedGoodSearchWrapper;

+ 475
- 0
src/components/FinishedGoodSearch/GoodPickExecution.tsx Просмотреть файл

@@ -0,0 +1,475 @@
"use client";

import {
Box,
Button,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/navigation";
import {
fetchALLPickOrderLineLotDetails,
updateStockOutLineStatus,
createStockOutLine,
} from "@/app/api/pickOrder/actions";
import { fetchNameList, NameList } from "@/app/api/user/actions";
import {
FormProvider,
useForm,
} from "react-hook-form";
import SearchBox, { Criterion } from "../SearchBox";
import { CreateStockOutLine } from "@/app/api/pickOrder/actions";
import { updateInventoryLotLineQuantities } from "@/app/api/inventory/actions";
import QrCodeIcon from '@mui/icons-material/QrCode';
import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider';
import CombinedLotTable from './CombinedLotTable';
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig"; // ✅ Import the custom session type

interface Props {
filterArgs: Record<string, any>;
}

const PickExecution: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Cast to custom type
// ✅ Get current user ID from session with proper typing
const currentUserId = session?.id ? parseInt(session.id) : undefined;
// ✅ Combined approach states
const [combinedLotData, setCombinedLotData] = useState<any[]>([]);
const [combinedDataLoading, setCombinedDataLoading] = useState(false);
const [originalCombinedData, setOriginalCombinedData] = useState<any[]>([]);
// ✅ QR Scanner context
const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext();
// ✅ QR scan input states
const [qrScanInput, setQrScanInput] = useState<string>('');
const [qrScanError, setQrScanError] = useState<boolean>(false);
const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false);
// ✅ Pick quantity states
const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({});
// ✅ Search states
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});

// ✅ Add pagination state
const [paginationController, setPaginationController] = useState({
pageNum: 0,
pageSize: 10,
});

// ✅ Keep only essential states
const [usernameList, setUsernameList] = useState<NameList[]>([]);

const formProps = useForm();
const errors = formProps.formState.errors;

// ✅ Start QR scanning on component mount
useEffect(() => {
startScan();
return () => {
stopScan();
resetScan();
};
}, [startScan, stopScan, resetScan]);

// ✅ Fetch all combined lot data - Updated to use current user ID
const fetchAllCombinedLotData = useCallback(async (userId?: number) => {
setCombinedDataLoading(true);
try {
// ✅ Use passed userId or current user ID
const userIdToUse = userId || currentUserId;
const allLotDetails = await fetchALLPickOrderLineLotDetails(userIdToUse);
console.log("All combined lot details:", allLotDetails);
setCombinedLotData(allLotDetails);
setOriginalCombinedData(allLotDetails); // Store original for filtering
} catch (error) {
console.error("Error fetching combined lot data:", error);
setCombinedLotData([]);
setOriginalCombinedData([]);
} finally {
setCombinedDataLoading(false);
}
}, [currentUserId]); // ✅ Add currentUserId as dependency


// ✅ Load data on component mount - Now uses current user ID
useEffect(() => {
fetchAllCombinedLotData(); // This will now use currentUserId
}, [fetchAllCombinedLotData]);

// ✅ Handle QR code submission for matched lot - FIXED: Handle multiple pick order lines
const handleQrCodeSubmit = useCallback(async (lotNo: string) => {
console.log(`✅ Processing QR Code for lot: ${lotNo}`);
console.log(`🔍 Available lots:`, combinedLotData.map(lot => lot.lotNo));
// Find ALL matching lots (same lot number can be used by multiple pick order lines)
const matchingLots = combinedLotData.filter(lot =>
lot.lotNo === lotNo ||
lot.lotNo?.toLowerCase() === lotNo.toLowerCase()
);
if (matchingLots.length === 0) {
console.error(`❌ Lot not found: ${lotNo}`);
console.error(`❌ Available lots:`, combinedLotData.map(lot => lot.lotNo));
setQrScanError(true);
setQrScanSuccess(false);
return;
}
console.log(`✅ Found ${matchingLots.length} matching lots:`, matchingLots);
setQrScanError(false);
try {
let successCount = 0;
let existsCount = 0;
let errorCount = 0;
// ✅ Process each matching lot (each pick order line that uses this lot)
for (const matchingLot of matchingLots) {
console.log(`🔄 Processing pick order line ${matchingLot.pickOrderLineId} for lot ${lotNo}`);
// ✅ Check if stockOutLineId is null before creating
if (matchingLot.stockOutLineId) {
console.log(`✅ Stock out line already exists for line ${matchingLot.pickOrderLineId}`);
existsCount++;
} else {
// Create stock out line for this specific pick order line
const stockOutLineData: CreateStockOutLine = {
consoCode: matchingLot.pickOrderCode, // Use pick order code as conso code
pickOrderLineId: matchingLot.pickOrderLineId,
inventoryLotLineId: matchingLot.lotId,
qty: 0.0
};
console.log(`Creating stock out line for pick order line ${matchingLot.pickOrderLineId}:`, stockOutLineData);
const result = await createStockOutLine(stockOutLineData);
console.log(`Create stock out line result for line ${matchingLot.pickOrderLineId}:`, result);
// ✅ Handle different response codes
if (result && result.code === "EXISTS") {
console.log(`✅ Stock out line already exists for line ${matchingLot.pickOrderLineId}`);
existsCount++;
} else if (result && result.code === "SUCCESS") {
console.log(`✅ Stock out line created successfully for line ${matchingLot.pickOrderLineId}`);
successCount++;
} else {
console.error(`❌ Unexpected response for line ${matchingLot.pickOrderLineId}:`, result);
errorCount++;
}
}
// Auto-set pick quantity to required quantity for this specific line
const lotKey = `${matchingLot.pickOrderLineId}-${matchingLot.lotId}`;
setPickQtyData(prev => ({
...prev,
[lotKey]: matchingLot.requiredQty
}));
}
// ✅ Set success state if at least one operation succeeded
if (successCount > 0 || existsCount > 0) {
setQrScanSuccess(true);
setQrScanError(false);
console.log(`✅ QR Code processing completed: ${successCount} created, ${existsCount} already existed, ${errorCount} errors`);
} else {
setQrScanError(true);
setQrScanSuccess(false);
console.error(`❌ All operations failed for lot ${lotNo}`);
return;
}
// Refresh data
await fetchAllCombinedLotData();
// Clear input after successful match
setQrScanInput('');
console.log("Stock out line process completed successfully!");
} catch (error) {
console.error("Error creating stock out line:", error);
setQrScanError(true);
setQrScanSuccess(false);
}
}, [combinedLotData, fetchAllCombinedLotData]);

// ✅ Process scanned QR codes automatically - FIXED: Only process when data is loaded
useEffect(() => {
if (qrValues.length > 0 && combinedLotData.length > 0) {
const latestQr = qrValues[qrValues.length - 1];
const qrContent = latestQr.replace(/[{}]/g, '');
setQrScanInput(qrContent);
// Auto-process the QR code
handleQrCodeSubmit(qrContent);
}
}, [qrValues, combinedLotData, handleQrCodeSubmit]);

// ✅ Handle manual input submission
const handleManualInputSubmit = useCallback(() => {
if (qrScanInput.trim() !== '') {
handleQrCodeSubmit(qrScanInput.trim());
}
}, [qrScanInput, handleQrCodeSubmit]);

// ✅ Handle pick quantity change - FIXED: Better input handling
const handlePickQtyChange = useCallback((lotKey: string, value: number | string) => {
// ✅ Handle empty string as 0
if (value === '' || value === null || value === undefined) {
setPickQtyData(prev => ({
...prev,
[lotKey]: 0
}));
return;
}
// ✅ Convert to number properly
const numericValue = typeof value === 'string' ? parseFloat(value) : value;
// ✅ Handle NaN case
if (isNaN(numericValue)) {
setPickQtyData(prev => ({
...prev,
[lotKey]: 0
}));
return;
}
setPickQtyData(prev => ({
...prev,
[lotKey]: numericValue
}));
}, []);

// ✅ Handle submit pick quantity
const handleSubmitPickQty = useCallback(async (lot: any) => {
const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`;
const newQty = pickQtyData[lotKey] || 0;
if (!lot.stockOutLineId) {
console.error("No stock out line found for this lot");
return;
}
try {
// ✅ FIXED: Calculate cumulative quantity
const currentActualPickQty = lot.actualPickQty || 0;
const cumulativeQty = currentActualPickQty + newQty;
// ✅ FIXED: Check cumulative quantity against required quantity
let newStatus = 'partially_completed';
if (cumulativeQty >= lot.requiredQty) {
newStatus = 'completed';
}
console.log(`=== PICK QUANTITY SUBMISSION DEBUG ===`);
console.log(`Lot: ${lot.lotNo}`);
console.log(`Required Qty: ${lot.requiredQty}`);
console.log(`Current Actual Pick Qty: ${currentActualPickQty}`);
console.log(`New Submitted Qty: ${newQty}`);
console.log(`Cumulative Qty: ${cumulativeQty}`);
console.log(`New Status: ${newStatus}`);
console.log(`=====================================`);
await updateStockOutLineStatus({
id: lot.stockOutLineId,
status: newStatus,
qty: cumulativeQty // ✅ Submit the cumulative quantity
});
// Update inventory
if (newQty > 0) {
await updateInventoryLotLineQuantities({
inventoryLotLineId: lot.lotId,
qty: newQty, // ✅ Only update inventory with the new quantity
status: 'available',
operation: 'pick'
});
}
// Refresh data
await fetchAllCombinedLotData();
console.log("Pick quantity submitted successfully!");
} catch (error) {
console.error("Error submitting pick quantity:", error);
}
}, [pickQtyData, fetchAllCombinedLotData]);

// ✅ Handle reject lot
const handleRejectLot = useCallback(async (lot: any) => {
if (!lot.stockOutLineId) {
console.error("No stock out line found for this lot");
return;
}
try {
await updateStockOutLineStatus({
id: lot.stockOutLineId,
status: 'rejected',
qty: 0
});
// Refresh data
await fetchAllCombinedLotData();
console.log("Lot rejected successfully!");
} catch (error) {
console.error("Error rejecting lot:", error);
}
}, [fetchAllCombinedLotData]);

// ✅ Search criteria
const searchCriteria: Criterion<any>[] = [
{
label: t("Pick Order Code"),
paramName: "pickOrderCode",
type: "text",
},
{
label: t("Item Code"),
paramName: "itemCode",
type: "text",
},
{
label: t("Item Name"),
paramName: "itemName",
type: "text",
},
{
label: t("Lot No"),
paramName: "lotNo",
type: "text",
},
];

// ✅ Search handler
const handleSearch = useCallback((query: Record<string, any>) => {
setSearchQuery({ ...query });
console.log("Search query:", query);

if (!originalCombinedData) return;

const filtered = originalCombinedData.filter((lot: any) => {
const pickOrderCodeMatch = !query.pickOrderCode ||
lot.pickOrderCode?.toLowerCase().includes((query.pickOrderCode || "").toLowerCase());
const itemCodeMatch = !query.itemCode ||
lot.itemCode?.toLowerCase().includes((query.itemCode || "").toLowerCase());
const itemNameMatch = !query.itemName ||
lot.itemName?.toLowerCase().includes((query.itemName || "").toLowerCase());
const lotNoMatch = !query.lotNo ||
lot.lotNo?.toLowerCase().includes((query.lotNo || "").toLowerCase());
return pickOrderCodeMatch && itemCodeMatch && itemNameMatch && lotNoMatch;
});
setCombinedLotData(filtered);
console.log("Filtered lots count:", filtered.length);
}, [originalCombinedData]);

// ✅ Reset handler
const handleReset = useCallback(() => {
setSearchQuery({});
if (originalCombinedData) {
setCombinedLotData(originalCombinedData);
}
}, [originalCombinedData]);

// ✅ Pagination handlers
const handlePageChange = useCallback((event: unknown, newPage: number) => {
setPaginationController(prev => ({
...prev,
pageNum: newPage,
}));
}, []);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
setPaginationController({
pageNum: 0,
pageSize: newPageSize,
});
}, []);

return (
<FormProvider {...formProps}>
<Stack spacing={2}>
{/* Search Box */}
<Box>
<SearchBox
criteria={searchCriteria}
onSearch={handleSearch}
onReset={handleReset}
/>
</Box>

{/* Combined Lot Table with QR Scan Input */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" gutterBottom sx={{ mb: 0 }}>
{t("All Pick Order Lots")}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
value={qrScanInput}
onChange={(e) => setQrScanInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleManualInputSubmit();
}
}}
error={qrScanError}
color={qrScanSuccess ? 'success' : undefined}
helperText={
qrScanError
? t("Lot number not found")
: qrScanSuccess
? t("Lot processed successfully")
: t("Enter lot number or scan QR code")
}
placeholder={t("Enter lot number...")}
sx={{ minWidth: '250px' }}
InputProps={{
startAdornment: <QrCodeIcon sx={{ mr: 1, color: isScanning ? 'primary.main' : 'text.secondary' }} />,
}}
/>
<Button
variant="outlined"
onClick={handleManualInputSubmit}
disabled={!qrScanInput.trim()}
size="small"
>
{t("Submit")}
</Button>
</Box>
</Box>
<CombinedLotTable
combinedLotData={combinedLotData}
combinedDataLoading={combinedDataLoading}
pickQtyData={pickQtyData}
paginationController={paginationController}
onPickQtyChange={handlePickQtyChange}
onSubmitPickQty={handleSubmitPickQty}
onRejectLot={handleRejectLot}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
</Box>
</Stack>
</FormProvider>
);
};

export default PickExecution;

+ 79
- 0
src/components/FinishedGoodSearch/ItemSelect.tsx Просмотреть файл

@@ -0,0 +1,79 @@

import { ItemCombo } from "@/app/api/settings/item/actions";
import { Autocomplete, TextField } from "@mui/material";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

interface CommonProps {
allItems: ItemCombo[];
error?: boolean;
}

interface SingleAutocompleteProps extends CommonProps {
value: number | string | undefined;
onItemSelect: (itemId: number, uom: string, uomId: number) => void | Promise<void>;
// multiple: false;
}

type Props = SingleAutocompleteProps;

const ItemSelect: React.FC<Props> = ({
allItems,
value,
error,
onItemSelect
}) => {
const { t } = useTranslation("item");
const filteredItems = useMemo(() => {
return allItems
}, [allItems])

const options = useMemo(() => {
return [
{
value: -1, // think think sin
label: t("None"),
uom: "",
uomId: -1,
group: "default",
},
...filteredItems.map((i) => ({
value: i.id as number,
label: i.label,
uom: i.uom,
uomId: i.uomId,
group: "existing",
})),
];
}, [t, filteredItems]);

const currentValue = options.find((o) => o.value === value) || options[0];

const onChange = useCallback(
(
event: React.SyntheticEvent,
newValue: { value: number; uom: string; uomId: number; group: string } | { uom: string; uomId: number; value: number }[],
) => {
const singleNewVal = newValue as {
value: number;
uom: string;
uomId: number;
group: string;
};
onItemSelect(singleNewVal.value, singleNewVal.uom, singleNewVal.uomId)
}
, [onItemSelect])
return (
<Autocomplete
noOptionsText={t("No Item")}
disableClearable
fullWidth
value={currentValue}
onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}
renderInput={(params) => <TextField {...params} error={error} />}
/>
);
}
export default ItemSelect

+ 1824
- 0
src/components/FinishedGoodSearch/Jobcreatitem.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 737
- 0
src/components/FinishedGoodSearch/LotTable.tsx Просмотреть файл

@@ -0,0 +1,737 @@
"use client";

import {
Box,
Button,
Checkbox,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
TablePagination,
Modal,
} from "@mui/material";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import QrCodeIcon from '@mui/icons-material/QrCode';
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { useQrCodeScannerContext } from '../QrCodeScannerProvider/QrCodeScannerProvider';
import { updateInventoryLotLineStatus } from "@/app/api/inventory/actions";
import { updateStockOutLineStatus } from "@/app/api/pickOrder/actions";
interface LotPickData {
id: number;
lotId: number;
lotNo: string;
expiryDate: string;
location: string;
stockUnit: string;
availableQty: number;
requiredQty: number;
actualPickQty: number;
lotStatus: string;
lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable';
stockOutLineId?: number;
stockOutLineStatus?: string;
stockOutLineQty?: number;
}

interface PickQtyData {
[lineId: number]: {
[lotId: number]: number;
};
}

interface LotTableProps {
lotData: LotPickData[];
selectedRowId: number | null;
selectedRow: (GetPickOrderLineInfo & { pickOrderCode: string }) | null;
pickQtyData: PickQtyData;
selectedLotRowId: string | null;
selectedLotId: number | null;
onLotSelection: (uniqueLotId: string, lotId: number) => void;
onPickQtyChange: (lineId: number, lotId: number, value: number) => void;
onSubmitPickQty: (lineId: number, lotId: number) => void;
onCreateStockOutLine: (inventoryLotLineId: number) => void;
onQcCheck: (line: GetPickOrderLineInfo, pickOrderCode: string) => void;
onLotSelectForInput: (lot: LotPickData) => void;
showInputBody: boolean;
setShowInputBody: (show: boolean) => void;
selectedLotForInput: LotPickData | null;
generateInputBody: () => any;
onDataRefresh: () => Promise<void>;
}

// ✅ QR Code Modal Component
const QrCodeModal: React.FC<{
open: boolean;
onClose: () => void;
lot: LotPickData | null;
onQrCodeSubmit: (lotNo: string) => void;
}> = ({ open, onClose, lot, onQrCodeSubmit }) => {
const { t } = useTranslation("pickOrder");
const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext();
const [manualInput, setManualInput] = useState<string>('');
// ✅ Add state to track manual input submission
const [manualInputSubmitted, setManualInputSubmitted] = useState<boolean>(false);
const [manualInputError, setManualInputError] = useState<boolean>(false);

// ✅ Process scanned QR codes
useEffect(() => {
if (qrValues.length > 0 && lot) {
const latestQr = qrValues[qrValues.length - 1];
const qrContent = latestQr.replace(/[{}]/g, '');
if (qrContent === lot.lotNo) {
onQrCodeSubmit(lot.lotNo);
onClose();
resetScan();
} else {
// ✅ Set error state for helper text
setManualInputError(true);
setManualInputSubmitted(true);
}
}
}, [qrValues, lot, onQrCodeSubmit, onClose, resetScan]);

// ✅ Clear states when modal opens or lot changes
useEffect(() => {
if (open) {
setManualInput('');
setManualInputSubmitted(false);
setManualInputError(false);
}
}, [open]);

useEffect(() => {
if (lot) {
setManualInput('');
setManualInputSubmitted(false);
setManualInputError(false);
}
}, [lot]);

{/*
const handleManualSubmit = () => {
if (manualInput.trim() === lot?.lotNo) {
// ✅ Success - no error helper text needed
onQrCodeSubmit(lot.lotNo);
onClose();
setManualInput('');
} else {
// ✅ Show error helper text after submit
setManualInputError(true);
setManualInputSubmitted(true);
// Don't clear input - let user see what they typed
}
};

return (
<Modal open={open} onClose={onClose}>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
p: 3,
borderRadius: 2,
minWidth: 400,
}}>
<Typography variant="h6" gutterBottom>
{t("QR Code Scan for Lot")}: {lot?.lotNo}
</Typography>
<Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="body2" gutterBottom>
<strong>Scanner Status:</strong> {isScanning ? 'Scanning...' : 'Ready'}
</Typography>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
onClick={isScanning ? stopScan : startScan}
size="small"
>
{isScanning ? 'Stop Scan' : 'Start Scan'}
</Button>
<Button
variant="outlined"
onClick={resetScan}
size="small"
>
Reset
</Button>
</Stack>
</Box>

<Box sx={{ mb: 2 }}>
<Typography variant="body2" gutterBottom>
<strong>Manual Input:</strong>
</Typography>
<TextField
fullWidth
size="small"
value={manualInput}
onChange={(e) => setManualInput(e.target.value)}
sx={{ mb: 1 }}
// ✅ Only show error after submit button is clicked
error={manualInputSubmitted && manualInputError}
helperText={
// ✅ Show helper text only after submit with error
manualInputSubmitted && manualInputError
? `The input is not the same as the expected lot number. Expected: ${lot?.lotNo}`
: ''
}
/>
<Button
variant="contained"
onClick={handleManualSubmit}
disabled={!manualInput.trim()}
size="small"
color="primary"
>
Submit Manual Input
</Button>
</Box>

{qrValues.length > 0 && (
<Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}>
<Typography variant="body2" color={manualInputError ? 'error' : 'success'}>
<strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]}
</Typography>
{manualInputError && (
<Typography variant="caption" color="error" display="block">
❌ Mismatch! Expected: {lot?.lotNo}
</Typography>
)}
</Box>
)}

<Box sx={{ mt: 2, textAlign: 'right' }}>
<Button onClick={onClose} variant="outlined">
Cancel
</Button>
</Box>
</Box>
</Modal>
);
};
*/}
useEffect(() => {
if (manualInput.trim() === lot?.lotNo && manualInput.trim() !== '') {
// Auto-submit when manual input matches the expected lot number
console.log('🔄 Auto-submitting manual input:', manualInput.trim());
// Add a small delay to ensure proper execution order
const timer = setTimeout(() => {
onQrCodeSubmit(lot.lotNo);
onClose();
setManualInput('');
setManualInputError(false);
setManualInputSubmitted(false);
}, 200); // 200ms delay
return () => clearTimeout(timer);
}
}, [manualInput, lot, onQrCodeSubmit, onClose]);
const handleManualSubmit = () => {
if (manualInput.trim() === lot?.lotNo) {
// ✅ Success - no error helper text needed
onQrCodeSubmit(lot.lotNo);
onClose();
setManualInput('');
} else {
// ✅ Show error helper text after submit
setManualInputError(true);
setManualInputSubmitted(true);
// Don't clear input - let user see what they typed
}
};
useEffect(() => {
if (open) {
startScan();
}
}, [open, startScan]);
return (
<Modal open={open} onClose={onClose}>
<Box sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
bgcolor: 'background.paper',
p: 3,
borderRadius: 2,
minWidth: 400,
}}>
<Typography variant="h6" gutterBottom>
QR Code Scan for Lot: {lot?.lotNo}
</Typography>
{/* Manual Input with Submit-Triggered Helper Text */}
<Box sx={{ mb: 2 }}>
<Typography variant="body2" gutterBottom>
<strong>Manual Input:</strong>
</Typography>
<TextField
fullWidth
size="small"
value={manualInput}
onChange={(e) => setManualInput(e.target.value)}
sx={{ mb: 1 }}
error={manualInputSubmitted && manualInputError}
helperText={
manualInputSubmitted && manualInputError
? `The input is not the same as the expected lot number.`
: ''
}
/>
<Button
variant="contained"
onClick={handleManualSubmit}
disabled={!manualInput.trim()}
size="small"
color="primary"
>
Submit Manual Input
</Button>
</Box>

{/* Show QR Scan Status */}
{qrValues.length > 0 && (
<Box sx={{ mb: 2, p: 2, backgroundColor: manualInputError ? '#ffebee' : '#e8f5e8', borderRadius: 1 }}>
<Typography variant="body2" color={manualInputError ? 'error' : 'success'}>
<strong>QR Scan Result:</strong> {qrValues[qrValues.length - 1]}
</Typography>
{manualInputError && (
<Typography variant="caption" color="error" display="block">
❌ Mismatch! Expected!
</Typography>
)}
</Box>
)}

<Box sx={{ mt: 2, textAlign: 'right' }}>
<Button onClick={onClose} variant="outlined">
Cancel
</Button>
</Box>
</Box>
</Modal>
);
};


const LotTable: React.FC<LotTableProps> = ({
lotData,
selectedRowId,
selectedRow,
pickQtyData,
selectedLotRowId,
selectedLotId,
onLotSelection,
onPickQtyChange,
onSubmitPickQty,
onCreateStockOutLine,
onQcCheck,
onLotSelectForInput,
showInputBody,
setShowInputBody,
selectedLotForInput,
generateInputBody,
onDataRefresh,
}) => {
const { t } = useTranslation("pickOrder");
// ✅ Add QR scanner context
const { values: qrValues, isScanning, startScan, stopScan, resetScan } = useQrCodeScannerContext();
// ✅ Add state for QR input modal
const [qrModalOpen, setQrModalOpen] = useState(false);
const [selectedLotForQr, setSelectedLotForQr] = useState<LotPickData | null>(null);
const [manualQrInput, setManualQrInput] = useState<string>('');
// 分页控制器
const [lotTablePagingController, setLotTablePagingController] = useState({
pageNum: 0,
pageSize: 10,
});

// ✅ 添加状态消息生成函数
const getStatusMessage = useCallback((lot: LotPickData) => {
if (!lot.stockOutLineId) {
return t("Please finish QR code scan, QC check and pick order.");
}
switch (lot.stockOutLineStatus?.toLowerCase()) {
case 'pending':
return t("Please finish QC check and pick order.");
case 'checked':
return t("Please submit the pick order.");
case 'partially_completed':
return t("Partial quantity submitted. Please submit more or complete the order.") ;
case 'completed':
return t("Pick order completed successfully!");
case 'rejected':
return t("QC check failed. Lot has been rejected and marked as unavailable.");
case 'unavailable':
return t("This order is insufficient, please pick another lot.");
default:
return t("Please finish QR code scan, QC check and pick order.");
}
}, []);

const prepareLotTableData = useMemo(() => {
return lotData.map((lot) => ({
...lot,
id: lot.lotId,
}));
}, [lotData]);

// 分页数据
const paginatedLotTableData = useMemo(() => {
const startIndex = lotTablePagingController.pageNum * lotTablePagingController.pageSize;
const endIndex = startIndex + lotTablePagingController.pageSize;
return prepareLotTableData.slice(startIndex, endIndex);
}, [prepareLotTableData, lotTablePagingController]);

// 分页处理函数
const handleLotTablePageChange = useCallback((event: unknown, newPage: number) => {
setLotTablePagingController(prev => ({
...prev,
pageNum: newPage,
}));
}, []);

const handleLotTablePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
setLotTablePagingController({
pageNum: 0,
pageSize: newPageSize,
});
}, []);

// ✅ Handle QR code submission
const handleQrCodeSubmit = useCallback(async (lotNo: string) => {
if (selectedLotForQr && selectedLotForQr.lotNo === lotNo) {
console.log(`✅ QR Code verified for lot: ${lotNo}`);
// ✅ Store the required quantity before creating stock out line
const requiredQty = selectedLotForQr.requiredQty;
const lotId = selectedLotForQr.lotId;
// ✅ Create stock out line and wait for it to complete
await onCreateStockOutLine(selectedLotForQr.lotId);
// ✅ Close modal
setQrModalOpen(false);
setSelectedLotForQr(null);
// ✅ Set pick quantity AFTER stock out line creation and refresh is complete
if (selectedRowId) {
// Add a small delay to ensure the data refresh from onCreateStockOutLine is complete
setTimeout(() => {
onPickQtyChange(selectedRowId, lotId, requiredQty);
console.log(`✅ Auto-set pick quantity to ${requiredQty} for lot ${lotNo}`);
}, 500); // 500ms delay to ensure refresh is complete
}
// ✅ Show success message
console.log("Stock out line created successfully!");
}
}, [selectedLotForQr, onCreateStockOutLine, selectedRowId, onPickQtyChange]);

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Selected")}</TableCell>
<TableCell>{t("Lot#")}</TableCell>
<TableCell>{t("Lot Expiry Date")}</TableCell>
<TableCell>{t("Lot Location")}</TableCell>
<TableCell align="right">{t("Available Lot")}</TableCell>
<TableCell align="right">{t("Lot Required Pick Qty")}</TableCell>
<TableCell>{t("Stock Unit")}</TableCell>
<TableCell align="center">{t("QR Code Scan")}</TableCell>
<TableCell align="center">{t("QC Check")}</TableCell>
<TableCell align="right">{t("Lot Actual Pick Qty")}</TableCell>
<TableCell align="center">{t("Submit")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedLotTableData.length === 0 ? (
<TableRow>
<TableCell colSpan={11} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
paginatedLotTableData.map((lot, index) => (
<TableRow key={lot.id}>
<TableCell>
<Checkbox
checked={selectedLotRowId === `row_${index}`}
onChange={() => onLotSelection(`row_${index}`, lot.lotId)}
// ✅ Allow selection of available AND insufficient_stock lots
//disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'}
value={`row_${index}`}
name="lot-selection"
/>
</TableCell>
<TableCell>
<Box>
<Typography>{lot.lotNo}</Typography>
{lot.lotAvailability !== 'available' && (
<Typography variant="caption" color="error" display="block">
({lot.lotAvailability === 'expired' ? 'Expired' :
lot.lotAvailability === 'insufficient_stock' ? 'Insufficient' :
'Unavailable'})
</Typography>
)}
</Box>
</TableCell>
<TableCell>{lot.expiryDate}</TableCell>
<TableCell>{lot.location}</TableCell>
<TableCell align="right">{lot.availableQty.toLocaleString()}</TableCell>
<TableCell align="right">{lot.requiredQty.toLocaleString()}</TableCell>
<TableCell>{lot.stockUnit}</TableCell>
{/* QR Code Scan Button */}
<TableCell align="center">
<Box sx={{ textAlign: 'center' }}>
<Button
variant="outlined"
size="small"
onClick={() => {
setSelectedLotForQr(lot);
setQrModalOpen(true);
resetScan();
}}
// ✅ Disable when:
// 1. Lot is expired or unavailable
// 2. Already scanned (has stockOutLineId)
// 3. Not selected (selectedLotRowId doesn't match)
disabled={
(lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') ||
Boolean(lot.stockOutLineId) ||
selectedLotRowId !== `row_${index}`
}
sx={{
fontSize: '0.7rem',
py: 0.5,
minHeight: '28px',
whiteSpace: 'nowrap',
minWidth: '40px',
// ✅ Visual feedback
opacity: selectedLotRowId === `row_${index}` ? 1 : 0.5
}}
startIcon={<QrCodeIcon />}
title={
selectedLotRowId !== `row_${index}`
? "Please select this lot first to enable QR scanning"
: lot.stockOutLineId
? "Already scanned"
: "Click to scan QR code"
}
>
{lot.stockOutLineId ? t("Scanned") : t("Scan")}
</Button>
</Box>
</TableCell>
{/* QC Check Button */}
{/*
<TableCell align="center">
<Button
variant="outlined"
size="small"
onClick={() => {
if (selectedRowId && selectedRow) {
onQcCheck(selectedRow, selectedRow.pickOrderCode);
}
}}
// ✅ Enable QC check only when stock out line exists
disabled={!lot.stockOutLineId || selectedLotRowId !== `row_${index}`}
sx={{
fontSize: '0.7rem',
py: 0.5,
minHeight: '28px',
whiteSpace: 'nowrap',
minWidth: '40px'
}}
>
{t("QC")}
</Button>
*/}
{/* Lot Actual Pick Qty */}
<TableCell align="right">
<TextField
type="number"
value={selectedRowId ? (pickQtyData[selectedRowId]?.[lot.lotId] || '') : ''} // ✅ Fixed: Use empty string instead of 0
onChange={(e) => {
if (selectedRowId) {
const inputValue = e.target.value;
// ✅ Fixed: Handle empty string and prevent leading zeros
if (inputValue === '') {
// Allow empty input (user can backspace to clear)
onPickQtyChange(selectedRowId, lot.lotId, 0);
} else {
// Parse the number and prevent leading zeros
const numValue = parseInt(inputValue, 10);
if (!isNaN(numValue)) {
onPickQtyChange(selectedRowId, lot.lotId, numValue);
}
}
}
}}
onBlur={(e) => {
// ✅ Fixed: When input loses focus, ensure we have a valid number
if (selectedRowId) {
const currentValue = pickQtyData[selectedRowId]?.[lot.lotId];
if (currentValue === undefined || currentValue === null) {
// Set to 0 if no value
onPickQtyChange(selectedRowId, lot.lotId, 0);
}
}
}}
inputProps={{
min: 0,
max: lot.availableQty,
step: 1 // Allow only whole numbers
}}
// ✅ Allow input for available AND insufficient_stock lots
disabled={lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable'}
sx={{ width: '80px' }}
placeholder="0" // Show placeholder instead of default value
/>
</TableCell>
<TableCell align="center">
<Button
variant="outlined"
size="small"
onClick={async () => {
if (selectedRowId && selectedRow && lot.stockOutLineId) {
try {
// ✅ Call updateStockOutLineStatus to reject the stock out line
await updateStockOutLineStatus({
id: lot.stockOutLineId,
status: 'rejected',
qty: 0
});
// ✅ Refresh data after rejection
if (onDataRefresh) {
await onDataRefresh();
}
} catch (error) {
console.error("Error rejecting lot:", error);
}
}
}}
// ✅ Only enable if stock out line exists
disabled={!lot.stockOutLineId}
sx={{
fontSize: '0.7rem',
py: 0.5,
minHeight: '28px',
whiteSpace: 'nowrap',
minWidth: '40px'
}}
>
{t("Reject")}
</Button>
</TableCell>
{/* Submit Button */}
<TableCell align="center">
<Button
variant="contained"
onClick={() => {
if (selectedRowId) {
onSubmitPickQty(selectedRowId, lot.lotId);
}
}}
disabled={
(lot.lotAvailability === 'expired' || lot.lotAvailability === 'status_unavailable') ||
!pickQtyData[selectedRowId!]?.[lot.lotId] ||
!lot.stockOutLineStatus || // Must have stock out line
!['pending','checked', 'partially_completed'].includes(lot.stockOutLineStatus.toLowerCase()) // Only these statuses
}
// ✅ Allow submission for available AND insufficient_stock lots
sx={{
fontSize: '0.75rem',
py: 0.5,
minHeight: '28px'
}}
>
{t("Submit")}
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{/* ✅ Status Messages Display */}
{paginatedLotTableData.length > 0 && (
<Box sx={{ mt: 2, p: 2, backgroundColor: 'grey.50', borderRadius: 1 }}>
{paginatedLotTableData.map((lot, index) => (
<Box key={lot.id} sx={{ mb: 1 }}>
<Typography variant="body2" color="text.secondary">
<strong>Lot {lot.lotNo}:</strong> {getStatusMessage(lot)}
</Typography>
</Box>
))}
</Box>
)}

<TablePagination
component="div"
count={prepareLotTableData.length}
page={lotTablePagingController.pageNum}
rowsPerPage={lotTablePagingController.pageSize}
onPageChange={handleLotTablePageChange}
onRowsPerPageChange={handleLotTablePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
{/* ✅ QR Code Modal */}
<QrCodeModal
open={qrModalOpen}
onClose={() => {
setQrModalOpen(false);
setSelectedLotForQr(null);
stopScan();
resetScan();
}}
lot={selectedLotForQr}
onQrCodeSubmit={handleQrCodeSubmit}
/>
</>
);
};

export default LotTable;

+ 288
- 0
src/components/FinishedGoodSearch/PickQcStockInModalVer2.tsx Просмотреть файл

@@ -0,0 +1,288 @@
"use client";
// 修改为 PickOrder 相关的导入
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
import {
Box,
Button,
Grid,
Modal,
ModalProps,
Stack,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
} from "@mui/material";
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { dummyQCData, QcData } from "../PoDetail/dummyQcTemplate";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
display: "block",
width: { xs: "60%", sm: "60%", md: "60%" },
};

// 修改接口定义
interface CommonProps extends Omit<ModalProps, "children"> {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
setItemDetail: Dispatch<
SetStateAction<
| (GetPickOrderLineInfo & {
pickOrderCode: string;
warehouseId?: number;
})
| undefined
>
>;
qc?: QcItemWithChecks[];
warehouse?: any[];
}

interface Props extends CommonProps {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
}

// 修改组件名称
const PickQcStockInModalVer2: React.FC<Props> = ({
open,
onClose,
itemDetail,
setItemDetail,
qc,
warehouse,
}) => {
console.log(warehouse);
// 修改翻译键
const {
t,
i18n: { language },
} = useTranslation("pickOrder");
const [qcItems, setQcItems] = useState(dummyQCData)
const formProps = useForm<any>({
defaultValues: {
...itemDetail,
},
});
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
},
[onClose],
);

// QC submission handler
const onSubmitQc = useCallback<SubmitHandler<any>>(
async (data, event) => {
console.log("QC Submission:", event!.nativeEvent);
// Get QC data from the shared form context
const qcAccept = data.qcAccept;
const acceptQty = data.acceptQty;
// Validate QC data
const validationErrors : string[] = [];
// Check if all QC items have results
const itemsWithoutResult = qcItems.filter(item => item.isPassed === undefined);
if (itemsWithoutResult.length > 0) {
validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.qcItem).join(', ')}`);
}

// Check if failed items have failed quantity
const failedItemsWithoutQty = qcItems.filter(item =>
item.isPassed === false && (!item.failedQty || item.failedQty <= 0)
);
if (failedItemsWithoutQty.length > 0) {
validationErrors.push(`${t("Failed items must have failed quantity")}: ${failedItemsWithoutQty.map(item => item.qcItem).join(', ')}`);
}

// Check if accept quantity is valid
if (acceptQty === undefined || acceptQty <= 0) {
validationErrors.push("Accept quantity must be greater than 0");
}

if (validationErrors.length > 0) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
return;
}

const qcData = {
qcAccept: qcAccept,
acceptQty: acceptQty,
qcItems: qcItems.map(item => ({
id: item.id,
qcItem: item.qcItem,
qcDescription: item.qcDescription,
isPassed: item.isPassed,
failedQty: (item.failedQty && !item.isPassed) || 0,
remarks: item.remarks || ''
}))
};

console.log("QC Data for submission:", qcData);
// await submitQcData(qcData);

if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) {
submitDialogWithWarning(() => {
console.log("QC accepted with failed items");
onClose();
}, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
return;
}

if (qcData.qcAccept) {
console.log("QC accepted");
onClose();
} else {
console.log("QC rejected");
onClose();
}
},
[qcItems, onClose, t],
);

const handleQcItemChange = useCallback((index: number, field: keyof QcData, value: any) => {
setQcItems(prev => prev.map((item, i) =>
i === index ? { ...item, [field]: value } : item
));
}, []);

return (
<>
<FormProvider {...formProps}>
<Modal open={open} onClose={closeHandler}>
<Box
sx={{
...style,
padding: 2,
maxHeight: "90vh",
overflowY: "auto",
marginLeft: 3,
marginRight: 3,
}}
>
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
GroupA - {itemDetail.pickOrderCode}
</Typography>
<Typography variant="body2" color="text.secondary" marginBlockEnd={2}>
記錄探測溫度的時間,請在1小時內完成出庫,以保障食品安全 監察方法、日闸檢查、嗅覺檢查和使用適當的食物温度計椒鱼食物溫度是否符合指標
</Typography>
</Grid>
{/* QC 表格 */}
<Grid item xs={12}>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>QC模板代號</TableCell>
<TableCell>檢查項目</TableCell>
<TableCell>QC Result</TableCell>
<TableCell>Failed Qty</TableCell>
<TableCell>Remarks</TableCell>
</TableRow>
</TableHead>
<TableBody>
{qcItems.map((item, index) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.qcDescription}</TableCell>
<TableCell>
<select
value={item.isPassed === undefined ? '' : item.isPassed ? 'pass' : 'fail'}
onChange={(e) => handleQcItemChange(index, 'isPassed', e.target.value === 'pass')}
>
<option value="">Select</option>
<option value="pass">Pass</option>
<option value="fail">Fail</option>
</select>
</TableCell>
<TableCell>
<input
type="number"
value={item.failedQty || 0}
onChange={(e) => handleQcItemChange(index, 'failedQty', parseInt(e.target.value) || 0)}
disabled={item.isPassed !== false}
/>
</TableCell>
<TableCell>
<input
type="text"
value={item.remarks || ''}
onChange={(e) => handleQcItemChange(index, 'remarks', e.target.value)}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Grid>
{/* 按钮 */}
<Grid item xs={12} sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
variant="contained"
color="success"
onClick={formProps.handleSubmit(onSubmitQc)}
>
QC Accept
</Button>
<Button
variant="contained"
color="warning"
onClick={() => {
console.log("Sort to accept");
onClose();
}}
>
Sort to Accept
</Button>
<Button
variant="contained"
color="error"
onClick={() => {
console.log("Reject and pick another lot");
onClose();
}}
>
Reject and Pick Another Lot
</Button>
</Stack>
</Grid>
</Grid>
</Box>
</Modal>
</FormProvider>
</>
);
};

export default PickQcStockInModalVer2;

+ 683
- 0
src/components/FinishedGoodSearch/PickQcStockInModalVer3.tsx Просмотреть файл

@@ -0,0 +1,683 @@
"use client";

import { GetPickOrderLineInfo, updateStockOutLineStatus } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
import {
Box,
Button,
Grid,
Modal,
ModalProps,
Stack,
Typography,
TextField,
Radio,
RadioGroup,
FormControlLabel,
FormControl,
Tab,
Tabs,
TabsProps,
Paper,
} from "@mui/material";
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { Controller, FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { dummyQCData } from "../PoDetail/dummyQcTemplate";
import StyledDataGrid from "../StyledDataGrid";
import { GridColDef } from "@mui/x-data-grid";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";
import EscalationLogTable from "../DashboardPage/escalation/EscalationLogTable";
import EscalationComponent from "../PoDetail/EscalationComponent";
import { fetchPickOrderQcResult, savePickOrderQcResult } from "@/app/api/qc/actions";
import {
updateInventoryLotLineStatus
} from "@/app/api/inventory/actions"; // ✅ 导入新的 API
import { dayjsToInputDateString } from "@/app/utils/formatUtil";
import dayjs from "dayjs";

// Define QcData interface locally
interface ExtendedQcItem extends QcItemWithChecks {
qcPassed?: boolean;
failQty?: number;
remarks?: string;
order?: number; // ✅ Add order property
stableId?: string; // ✅ Also add stableId for better row identification
}
interface Props extends CommonProps {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
qcItems: ExtendedQcItem[];
setQcItems: Dispatch<SetStateAction<ExtendedQcItem[]>>;
selectedLotId?: number;
onStockOutLineUpdate?: () => void;
lotData: LotPickData[];
// ✅ Add missing props
pickQtyData?: PickQtyData;
selectedRowId?: number;
}

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
display: "block",
width: { xs: "80%", sm: "80%", md: "80%" },
maxHeight: "90vh",
overflowY: "auto",
};
interface PickQtyData {
[lineId: number]: {
[lotId: number]: number;
};
}
interface CommonProps extends Omit<ModalProps, "children"> {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
setItemDetail: Dispatch<
SetStateAction<
| (GetPickOrderLineInfo & {
pickOrderCode: string;
warehouseId?: number;
})
| undefined
>
>;
qc?: QcItemWithChecks[];
warehouse?: any[];
}

interface Props extends CommonProps {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
qcItems: ExtendedQcItem[]; // Change to ExtendedQcItem
setQcItems: Dispatch<SetStateAction<ExtendedQcItem[]>>; // Change to ExtendedQcItem
// ✅ Add props for stock out line update
selectedLotId?: number;
onStockOutLineUpdate?: () => void;
lotData: LotPickData[];
}
interface LotPickData {
id: number;
lotId: number;
lotNo: string;
expiryDate: string;
location: string;
stockUnit: string;
availableQty: number;
requiredQty: number;
actualPickQty: number;
lotStatus: string;
lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable';
stockOutLineId?: number;
stockOutLineStatus?: string;
stockOutLineQty?: number;
}
const PickQcStockInModalVer3: React.FC<Props> = ({
open,
onClose,
itemDetail,
setItemDetail,
qc,
warehouse,
qcItems,
setQcItems,
selectedLotId,
onStockOutLineUpdate,
lotData,
pickQtyData,
selectedRowId,
}) => {
const {
t,
i18n: { language },
} = useTranslation("pickOrder");

const [tabIndex, setTabIndex] = useState(0);
//const [qcItems, setQcItems] = useState<QcData[]>(dummyQCData);
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [feedbackMessage, setFeedbackMessage] = useState<string>("");

// Add state to store submitted data
const [submittedData, setSubmittedData] = useState<any[]>([]);

const formProps = useForm<any>({
defaultValues: {
qcAccept: true,
acceptQty: null,
qcDecision: "1", // Default to accept
...itemDetail,
},
});
const { control, register, formState: { errors }, watch, setValue } = formProps;

const qcDecision = watch("qcDecision");
const accQty = watch("acceptQty");

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
},
[onClose],
);

const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[],
);

// Save failed QC results only
const saveQcResults = async (qcData: any) => {
try {
const qcResults = qcData.qcItems
.map((item: any) => ({
qcItemId: item.id,
itemId: itemDetail.itemId,
stockInLineId: null,
stockOutLineId: 1, // Fixed to 1 as requested
failQty: item.isPassed ? 0 : (item.failQty || 0), // 0 for passed, actual qty for failed
type: "pick_order_qc",
remarks: item.remarks || "",
qcPassed: item.isPassed, // ✅ This will now be included
}));

// Store the submitted data for debug display
setSubmittedData(qcResults);
console.log("Saving QC results:", qcResults);

// Use the corrected API function instead of manual fetch
for (const qcResult of qcResults) {
const response = await savePickOrderQcResult(qcResult);
console.log("QC Result save success:", response);
// Check if the response indicates success
if (!response.id) {
throw new Error(`Failed to save QC result: ${response.message || 'Unknown error'}`);
}
}
return true;
} catch (error) {
console.error("Error saving QC results:", error);
return false;
}
};

// ✅ 修改:在组件开始时自动设置失败数量
useEffect(() => {
if (itemDetail && qcItems.length > 0 && selectedLotId) {
// ✅ 获取选中的批次数据
const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId);
if (selectedLot) {
// ✅ 自动将 Lot Required Pick Qty 设置为所有失败项目的 failQty
const updatedQcItems = qcItems.map((item, index) => ({
...item,
failQty: selectedLot.requiredQty || 0, // 使用 Lot Required Pick Qty
// ✅ Add stable order and ID fields
order: index,
stableId: `qc-${item.id}-${index}`
}));
setQcItems(updatedQcItems);
}
}
}, [itemDetail, qcItems.length, selectedLotId, lotData]);

// ✅ Add this helper function at the top of the component
const safeClose = useCallback(() => {
if (onClose) {
// Create a mock event object that satisfies the Modal onClose signature
const mockEvent = {
target: null,
currentTarget: null,
type: 'close',
preventDefault: () => {},
stopPropagation: () => {},
bubbles: false,
cancelable: false,
defaultPrevented: false,
isTrusted: false,
timeStamp: Date.now(),
nativeEvent: null,
isDefaultPrevented: () => false,
isPropagationStopped: () => false,
persist: () => {},
eventPhase: 0,
isPersistent: () => false
} as any;
// ✅ Fixed: Pass both event and reason parameters
onClose(mockEvent, 'escapeKeyDown'); // 'escapeKeyDown' is a valid reason
}
}, [onClose]);

// ✅ 修改:移除 alert 弹窗,改为控制台日志
const onSubmitQc = useCallback<SubmitHandler<any>>(
async (data, event) => {
setIsSubmitting(true);
try {
const qcAccept = qcDecision === "1";
const acceptQty = Number(accQty) || null;
const validationErrors : string[] = [];
const selectedLot = lotData.find(lot => lot.stockOutLineId === selectedLotId);
// ✅ Add safety check for selectedLot
if (!selectedLot) {
console.error("Selected lot not found");
return;
}
const itemsWithoutResult = qcItems.filter(item => item.qcPassed === undefined);
if (itemsWithoutResult.length > 0) {
validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.code).join(", ")}`);
}
if (validationErrors.length > 0) {
console.error(`QC validation failed: ${validationErrors.join(", ")}`);
return;
}
const qcData = {
qcAccept,
acceptQty,
qcItems: qcItems.map(item => ({
id: item.id,
qcItem: item.code,
qcDescription: item.description || "",
isPassed: item.qcPassed,
failQty: item.qcPassed ? 0 : (selectedLot.requiredQty || 0),
remarks: item.remarks || "",
})),
};
console.log("Submitting QC data:", qcData);
const saveSuccess = await saveQcResults(qcData);
if (!saveSuccess) {
console.error("Failed to save QC results");
return;
}
// ✅ Handle different QC decisions
if (selectedLotId) {
try {
const allPassed = qcData.qcItems.every(item => item.isPassed);
if (qcDecision === "2") {
// ✅ QC Decision 2: Report and Re-pick
console.log("QC Decision 2 - Report and Re-pick: Rejecting lot and marking as unavailable");
// ✅ Inventory lot line status: unavailable
if (selectedLot) {
try {
console.log("=== DEBUG: Updating inventory lot line status ===");
console.log("Selected lot:", selectedLot);
console.log("Selected lot ID:", selectedLotId);
// ✅ FIX: Only send the fields that the backend expects
const updateData = {
inventoryLotLineId: selectedLot.lotId,
status: 'unavailable'
// ❌ Remove qty and operation - backend doesn't expect these
};
console.log("Update data:", updateData);
const result = await updateInventoryLotLineStatus(updateData);
console.log("✅ Inventory lot line status updated successfully:", result);
} catch (error) {
console.error("❌ Error updating inventory lot line status:", error);
console.error("Error details:", {
selectedLot,
selectedLotId,
acceptQty
});
// Show user-friendly error message

return; // Stop execution if this fails
}
} else {
console.error("❌ Selected lot not found for inventory update");
alert("Selected lot not found. Cannot update inventory status.");
return;
}
// ✅ Close modal and refresh data
safeClose(); // ✅ Fixed: Use safe close function with both parameters
if (onStockOutLineUpdate) {
onStockOutLineUpdate();
}
} else if (qcDecision === "1") {
// ✅ QC Decision 1: Accept
console.log("QC Decision 1 - Accept: QC passed");
// ✅ Stock out line status: checked (QC completed)
await updateStockOutLineStatus({
id: selectedLotId,
status: 'checked',
qty: acceptQty || 0
});
// ✅ Inventory lot line status: NO CHANGE needed
// Keep the existing status from handleSubmitPickQty
// ✅ Close modal and refresh data
safeClose(); // ✅ Fixed: Use safe close function with both parameters
if (onStockOutLineUpdate) {
onStockOutLineUpdate();
}
}
console.log("Stock out line status updated successfully after QC");
// Call callback to refresh data
if (onStockOutLineUpdate) {
onStockOutLineUpdate();
}
} catch (error) {
console.error("Error updating stock out line status after QC:", error);
}
}
console.log("QC results saved successfully!");
// ✅ Show warning dialog for failed QC items when accepting
if (qcDecision === "1" && !qcData.qcItems.every((q) => q.isPassed)) {
submitDialogWithWarning(() => {
closeHandler?.({}, 'escapeKeyDown');
}, t, {title:"有不合格檢查項目,確認接受出庫?", confirmButtonText: "Confirm", html: ""});
return;
}
closeHandler?.({}, 'escapeKeyDown');
} catch (error) {
console.error("Error in QC submission:", error);
if (error instanceof Error) {
console.error("Error details:", error.message);
console.error("Error stack:", error.stack);
}
} finally {
setIsSubmitting(false);
}
},
[qcItems, closeHandler, t, itemDetail, qcDecision, accQty, selectedLotId, onStockOutLineUpdate, lotData, pickQtyData, selectedRowId],
);
// DataGrid columns (QcComponent style)
const qcColumns: GridColDef[] = useMemo(
() => [
{
field: "code",
headerName: t("qcItem"),
flex: 2,
renderCell: (params) => (
<Box>
<b>{`${params.api.getRowIndexRelativeToVisibleRows(params.id) + 1}. ${params.value}`}</b><br/>
{params.row.name}<br/>
</Box>
),
},
{
field: "qcPassed",
headerName: t("qcResult"),
flex: 1.5,
renderCell: (params) => {
const current = params.row;
return (
<FormControl>
<RadioGroup
row
aria-labelledby="qc-result"
value={current.qcPassed === undefined ? "" : (current.qcPassed ? "true" : "false")}
onChange={(e) => {
const value = e.target.value === "true";
// ✅ Simple state update
setQcItems(prev =>
prev.map(item =>
item.id === params.id
? { ...item, qcPassed: value }
: item
)
);
}}
name={`qcPassed-${params.id}`}
>
<FormControlLabel
value="true"
control={<Radio />}
label="合格"
sx={{
color: current.qcPassed === true ? "green" : "inherit",
"& .Mui-checked": {color: "green"}
}}
/>
<FormControlLabel
value="false"
control={<Radio />}
label="不合格"
sx={{
color: current.qcPassed === false ? "red" : "inherit",
"& .Mui-checked": {color: "red"}
}}
/>
</RadioGroup>
</FormControl>
);
},
},
{
field: "failQty",
headerName: t("failedQty"),
flex: 1,
renderCell: (params) => (
<TextField
type="number"
size="small"
// ✅ 修改:失败项目自动显示 Lot Required Pick Qty
value={!params.row.qcPassed ? (0) : 0}
disabled={params.row.qcPassed}
// ✅ 移除 onChange,因为数量是固定的
// onChange={(e) => {
// const v = e.target.value;
// const next = v === "" ? undefined : Number(v);
// if (Number.isNaN(next)) return;
// setQcItems((prev) =>
// prev.map((r) => (r.id === params.id ? { ...r, failQty: next } : r))
// );
// }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
inputProps={{ min: 0, max: itemDetail?.requiredQty || 0 }}
sx={{ width: "100%" }}
/>
),
},
{
field: "remarks",
headerName: t("remarks"),
flex: 2,
renderCell: (params) => (
<TextField
size="small"
value={params.value ?? ""}
onChange={(e) => {
const remarks = e.target.value;
setQcItems((prev) =>
prev.map((r) => (r.id === params.id ? { ...r, remarks } : r))
);
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
sx={{ width: "100%" }}
/>
),
},
],
[t],
);

// ✅ Add stable update function
const handleQcResultChange = useCallback((itemId: number, qcPassed: boolean) => {
setQcItems(prevItems =>
prevItems.map(item =>
item.id === itemId
? { ...item, qcPassed }
: item
)
);
}, []);

// ✅ Remove duplicate functions
const getRowId = useCallback((row: any) => {
return row.id; // Just use the original ID
}, []);

// ✅ Remove complex sorting logic
// const stableQcItems = useMemo(() => { ... }); // Remove
// const sortedQcItems = useMemo(() => { ... }); // Remove

// ✅ Use qcItems directly in DataGrid
return (
<>
<FormProvider {...formProps}>
<Modal open={open} onClose={closeHandler}>
<Box sx={style}>
<Grid container justifyContent="flex-start" alignItems="flex-start" spacing={2}>
<Grid item xs={12}>
<Tabs
value={tabIndex}
onChange={handleTabChange}
variant="scrollable"
>
<Tab label={t("QC Info")} iconPosition="end" />
<Tab label={t("Escalation History")} iconPosition="end" />
</Tabs>
</Grid>
{tabIndex == 0 && (
<>
<Grid item xs={12}>
<Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold', color: '#333' }}>
Group A - 急凍貨類 (QCA1-MEAT01)
</Typography>
<Typography variant="subtitle1" sx={{ color: '#666' }}>
<b>品檢類型</b>:OQC
</Typography>
<Typography variant="subtitle2" sx={{ color: '#666' }}>
記錄探測溫度的時間,請在1小時内完成出庫盤點,以保障食品安全<br/>
監察方法:目視檢查、嗅覺檢查和使用適當的食物溫度計,檢查食物溫度是否符合指標
</Typography>

</Box>
<StyledDataGrid
columns={qcColumns}
rows={qcItems} // ✅ Use qcItems directly
autoHeight
getRowId={getRowId} // ✅ Simple row ID function
/>
</Grid>
</>
)}
{tabIndex == 1 && (
<>
<Grid item xs={12}>
<EscalationLogTable items={[]}/>
</Grid>
</>
)}
<Grid item xs={12}>
<FormControl>
<Controller
name="qcDecision"
control={control}
defaultValue="1"
render={({ field }) => (
<RadioGroup
row
aria-labelledby="demo-radio-buttons-group-label"
{...field}
value={field.value}
onChange={(e) => {
const value = e.target.value.toString();
if (value != "1" && Boolean(errors.acceptQty)) {
setValue("acceptQty", itemDetail.requiredQty ?? 0);
}
field.onChange(value);
}}
>
<FormControlLabel
value="1"
control={<Radio />}
label={t("Accept Stock Out")}
/>

{/* ✅ Combirne options 2 & 3 into one */}
<FormControlLabel
value="2"
control={<Radio />}
sx={{"& .Mui-checked": {color: "blue"}}}
label={t("Report and Pick another lot")}
/>
</RadioGroup>
)}
/>
</FormControl>
</Grid>

{/* ✅ Show escalation component when QC Decision = 2 (Report and Re-pick) */}

<Grid item xs={12} sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="flex-start" gap={1}>
<Button
variant="contained"
onClick={formProps.handleSubmit(onSubmitQc)}
disabled={isSubmitting}
sx={{ whiteSpace: 'nowrap' }}
>
{isSubmitting ? "Submitting..." : "Submit QC"}
</Button>
<Button
variant="outlined"
onClick={() => {
closeHandler?.({}, 'escapeKeyDown');
}}
>
Cancel
</Button>
</Stack>
</Grid>
</Grid>
</Box>
</Modal>
</FormProvider>
</>
);
};

export default PickQcStockInModalVer3;

+ 527
- 0
src/components/FinishedGoodSearch/PutawayForm.tsx Просмотреть файл

@@ -0,0 +1,527 @@
"use client";

import { PurchaseQcResult, PutAwayInput, PutAwayLine } from "@/app/api/po/actions";
import {
Autocomplete,
Box,
Button,
Card,
CardContent,
FormControl,
Grid,
Modal,
ModalProps,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
GridColDef,
GridRowIdGetter,
GridRowModel,
useGridApiContext,
GridRenderCellParams,
GridRenderEditCellParams,
useGridApiRef,
} from "@mui/x-data-grid";
import InputDataGrid from "../InputDataGrid";
import { TableRow } from "../InputDataGrid/InputDataGrid";
import TwoLineCell from "./TwoLineCell";
import QcSelect from "./QcSelect";
import { QcItemWithChecks } from "@/app/api/qc";
import { GridEditInputCell } from "@mui/x-data-grid";
import { StockInLine } from "@/app/api/po";
import { WarehouseResult } from "@/app/api/warehouse";
import {
OUTPUT_DATE_FORMAT,
stockInLineStatusMap,
} from "@/app/utils/formatUtil";
import { QRCodeSVG } from "qrcode.react";
import { QrCode } from "../QrCode";
import ReactQrCodeScanner, {
ScannerConfig,
} from "../ReactQrCodeScanner/ReactQrCodeScanner";
import { QrCodeInfo } from "@/app/api/qrcode";
import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import { dummyPutawayLine } from "./dummyQcTemplate";
dayjs.extend(arraySupport);

interface Props {
itemDetail: StockInLine;
warehouse: WarehouseResult[];
disabled: boolean;
// qc: QcItemWithChecks[];
}
type EntryError =
| {
[field in keyof PutAwayLine]?: string;
}
| undefined;

type PutawayRow = TableRow<Partial<PutAwayLine>, EntryError>;

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
width: "auto",
};

const PutawayForm: React.FC<Props> = ({ itemDetail, warehouse, disabled }) => {
const { t } = useTranslation("purchaseOrder");
const apiRef = useGridApiRef();
const {
register,
formState: { errors, defaultValues, touchedFields },
watch,
control,
setValue,
getValues,
reset,
resetField,
setError,
clearErrors,
} = useFormContext<PutAwayInput>();
console.log(itemDetail);
// const [recordQty, setRecordQty] = useState(0);
const [warehouseId, setWarehouseId] = useState(itemDetail.defaultWarehouseId);
const filteredWarehouse = useMemo(() => {
// do filtering here if any
return warehouse;
}, []);

const defaultOption = {
value: 0, // think think sin
label: t("Select warehouse"),
group: "default",
};
const options = useMemo(() => {
return [
// {
// value: 0, // think think sin
// label: t("Select warehouse"),
// group: "default",
// },
...filteredWarehouse.map((w) => ({
value: w.id,
label: `${w.code} - ${w.name}`,
group: "existing",
})),
];
}, [filteredWarehouse]);
const currentValue =
warehouseId > 0
? options.find((o) => o.value === warehouseId)
: options.find((o) => o.value === getValues("warehouseId")) ||
defaultOption;

const onChange = useCallback(
(
event: React.SyntheticEvent,
newValue: { value: number; group: string } | { value: number }[],
) => {
const singleNewVal = newValue as {
value: number;
group: string;
};
console.log(singleNewVal);
console.log("onChange");
// setValue("warehouseId", singleNewVal.value);
setWarehouseId(singleNewVal.value);
},
[],
);
console.log(watch("putAwayLines"))
// const accQty = watch("acceptedQty");
// const validateForm = useCallback(() => {
// console.log(accQty);
// if (accQty > itemDetail.acceptedQty) {
// setError("acceptedQty", {
// message: `acceptedQty must not greater than ${itemDetail.acceptedQty}`,
// type: "required",
// });
// }
// if (accQty < 1) {
// setError("acceptedQty", {
// message: `minimal value is 1`,
// type: "required",
// });
// }
// if (isNaN(accQty)) {
// setError("acceptedQty", {
// message: `value must be a number`,
// type: "required",
// });
// }
// }, [accQty]);

// useEffect(() => {
// clearErrors();
// validateForm();
// }, [validateForm]);

const qrContent = useMemo(
() => ({
stockInLineId: itemDetail.id,
itemId: itemDetail.itemId,
lotNo: itemDetail.lotNo,
// warehouseId: 2 // for testing
// expiryDate: itemDetail.expiryDate,
// productionDate: itemDetail.productionDate,
// supplier: itemDetail.supplier,
// poCode: itemDetail.poCode,
}),
[itemDetail],
);
const [isOpenScanner, setOpenScanner] = useState(false);

const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
setOpenScanner(false);
},
[],
);

const onOpenScanner = useCallback(() => {
setOpenScanner(true);
}, []);

const onCloseScanner = useCallback(() => {
setOpenScanner(false);
}, []);
const scannerConfig = useMemo<ScannerConfig>(
() => ({
onUpdate: (err, result) => {
console.log(result);
console.log(Boolean(result));
if (result) {
const data: QrCodeInfo = JSON.parse(result.getText());
console.log(data);
if (data.warehouseId) {
console.log(data.warehouseId);
setWarehouseId(data.warehouseId);
onCloseScanner();
}
} else return;
},
}),
[onCloseScanner],
);

// QR Code Scanner
const scanner = useQrCodeScannerContext();
useEffect(() => {
if (isOpenScanner) {
scanner.startScan();
} else if (!isOpenScanner) {
scanner.stopScan();
}
}, [isOpenScanner]);

useEffect(() => {
if (scanner.values.length > 0) {
console.log(scanner.values[0]);
const data: QrCodeInfo = JSON.parse(scanner.values[0]);
console.log(data);
if (data.warehouseId) {
console.log(data.warehouseId);
setWarehouseId(data.warehouseId);
onCloseScanner();
}
scanner.resetScan();
}
}, [scanner.values]);

useEffect(() => {
setValue("status", "completed");
setValue("warehouseId", options[0].value);
}, []);

useEffect(() => {
if (warehouseId > 0) {
setValue("warehouseId", warehouseId);
clearErrors("warehouseId");
}
}, [warehouseId]);

const getWarningTextHardcode = useCallback((): string | undefined => {
console.log(options)
if (options.length === 0) return undefined
const defaultWarehouseId = options[0].value;
const currWarehouseId = watch("warehouseId");
if (defaultWarehouseId !== currWarehouseId) {
return t("not default warehosue");
}
return undefined;
}, [options]);

const columns = useMemo<GridColDef[]>(
() => [
{
field: "qty",
headerName: t("qty"),
flex: 1,
// renderCell(params) {
// return <>100</>
// },
},
{
field: "warehouse",
headerName: t("warehouse"),
flex: 1,
// renderCell(params) {
// return <>{filteredWarehouse[0].name}</>
// },
},
{
field: "printQty",
headerName: t("printQty"),
flex: 1,
// renderCell(params) {
// return <>100</>
// },
},
], [])

const validation = useCallback(
(newRow: GridRowModel<PutawayRow>): EntryError => {
const error: EntryError = {};
const { qty, warehouseId, printQty } = newRow;

return Object.keys(error).length > 0 ? error : undefined;
},
[],
);

return (
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
{t("Putaway Detail")}
</Typography>
</Grid>
<Grid
container
justifyContent="flex-start"
alignItems="flex-start"
spacing={2}
sx={{ mt: 0.5 }}
>
<Grid item xs={12}>
<TextField
label={t("LotNo")}
fullWidth
value={itemDetail.lotNo}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Supplier")}
fullWidth
value={itemDetail.supplier}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Po Code")}
fullWidth
value={itemDetail.poCode}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("itemName")}
fullWidth
value={itemDetail.itemName}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("itemNo")}
fullWidth
value={itemDetail.itemNo}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("qty")}
fullWidth
value={itemDetail.acceptedQty}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("productionDate")}
fullWidth
value={
// dayjs(itemDetail.productionDate)
dayjs()
// .add(-1, "month")
.format(OUTPUT_DATE_FORMAT)}
disabled
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("expiryDate")}
fullWidth
value={
// dayjs(itemDetail.expiryDate)
dayjs()
.add(20, "day")
.format(OUTPUT_DATE_FORMAT)}
disabled
/>
</Grid>
<Grid item xs={6}>
<FormControl fullWidth>
<Autocomplete
noOptionsText={t("No Warehouse")}
disableClearable
disabled
fullWidth
defaultValue={options[0]} /// modify this later
// onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}
renderInput={(params) => (
<TextField {...params} label={t("Default Warehouse")} />
)}
/>
</FormControl>
</Grid>
{/* <Grid item xs={5.5}>
<TextField
label={t("acceptedQty")}
fullWidth
{...register("acceptedQty", {
required: "acceptedQty required!",
min: 1,
max: itemDetail.acceptedQty,
valueAsNumber: true,
})}
// defaultValue={itemDetail.acceptedQty}
disabled={disabled}
error={Boolean(errors.acceptedQty)}
helperText={errors.acceptedQty?.message}
/>
</Grid>
<Grid item xs={1}>
<Button disabled={disabled} onClick={onOpenScanner}>
{t("bind")}
</Button>
</Grid> */}
{/* <Grid item xs={5.5}>
<Controller
control={control}
name="warehouseId"
render={({ field }) => {
console.log(field);
return (
<Autocomplete
noOptionsText={t("No Warehouse")}
disableClearable
fullWidth
value={options.find((o) => o.value == field.value)}
onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}
renderInput={(params) => (
<TextField
{...params}
label={"Select warehouse"}
error={Boolean(errors.warehouseId?.message)}
helperText={warehouseHelperText}
// helperText={errors.warehouseId?.message}
/>
)}
/>
);
}}
/>
<FormControl fullWidth>
<Autocomplete
noOptionsText={t("No Warehouse")}
disableClearable
fullWidth
// value={warehouseId > 0
// ? options.find((o) => o.value === warehouseId)
// : undefined}
defaultValue={options[0]}
// defaultValue={options.find((o) => o.value === 1)}
value={currentValue}
onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}
renderInput={(params) => (
<TextField
{...params}
// label={"Select warehouse"}
disabled={disabled}
error={Boolean(errors.warehouseId?.message)}
helperText={
errors.warehouseId?.message ?? getWarningTextHardcode()
}
// helperText={warehouseHelperText}
/>
)}
/>
</FormControl>
</Grid> */}
<Grid
item
xs={12}
style={{ display: "flex", justifyContent: "center" }}
>
{/* <QrCode content={qrContent} sx={{ width: 200, height: 200 }} /> */}
<InputDataGrid<PutAwayInput, PutAwayLine, EntryError>
apiRef={apiRef}
checkboxSelection={false}
_formKey={"putAwayLines"}
columns={columns}
validateRow={validation}
needAdd={true}
showRemoveBtn={false}
/>
</Grid>
</Grid>
{/* <Grid
container
justifyContent="flex-start"
alignItems="flex-start"
spacing={2}
sx={{ mt: 0.5 }}
>
<Button onClick={onOpenScanner}>bind</Button>
</Grid> */}

<Modal open={isOpenScanner} onClose={closeHandler}>
<Box sx={style}>
<Typography variant="h4">
{t("Please scan warehouse qr code.")}
</Typography>
{/* <ReactQrCodeScanner scannerConfig={scannerConfig} /> */}
</Box>
</Modal>
</Grid>
);
};
export default PutawayForm;

+ 395
- 0
src/components/FinishedGoodSearch/QCDatagrid.tsx Просмотреть файл

@@ -0,0 +1,395 @@
"use client";
import {
Dispatch,
MutableRefObject,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import StyledDataGrid from "../StyledDataGrid";
import {
FooterPropsOverrides,
GridActionsCellItem,
GridCellParams,
GridColDef,
GridEventListener,
GridRowEditStopReasons,
GridRowId,
GridRowIdGetter,
GridRowModel,
GridRowModes,
GridRowModesModel,
GridRowSelectionModel,
GridToolbarContainer,
GridValidRowModel,
useGridApiRef,
} from "@mui/x-data-grid";
import { set, useFormContext } from "react-hook-form";
import SaveIcon from "@mui/icons-material/Save";
import DeleteIcon from "@mui/icons-material/Delete";
import CancelIcon from "@mui/icons-material/Cancel";
import { Add } from "@mui/icons-material";
import { Box, Button, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import {
GridApiCommunity,
GridSlotsComponentsProps,
} from "@mui/x-data-grid/internals";
import { dummyQCData } from "./dummyQcTemplate";
// T == CreatexxxInputs map of the form's fields
// V == target field input inside CreatexxxInputs, e.g. qcChecks: ItemQc[], V = ItemQc
// E == error
interface ResultWithId {
id: string | number;
}
// export type InputGridProps = {
// [key: string]: any
// }
interface DefaultResult<E> {
_isNew: boolean;
_error: E;
}

interface SelectionResult<E> {
active: boolean;
_isNew: boolean;
_error: E;
}
type Result<E> = DefaultResult<E> | SelectionResult<E>;

export type TableRow<V, E> = Partial<
V & {
isActive: boolean | undefined;
_isNew: boolean;
_error: E;
} & ResultWithId
>;

export interface InputDataGridProps<T, V, E> {
apiRef: MutableRefObject<GridApiCommunity>;
// checkboxSelection: false | undefined;
_formKey: keyof T;
columns: GridColDef[];
validateRow: (newRow: GridRowModel<TableRow<V, E>>) => E;
needAdd?: boolean;
}

export interface SelectionInputDataGridProps<T, V, E> {
// thinking how do
apiRef: MutableRefObject<GridApiCommunity>;
// checkboxSelection: true;
_formKey: keyof T;
columns: GridColDef[];
validateRow: (newRow: GridRowModel<TableRow<V, E>>) => E;
}

export type Props<T, V, E> =
| InputDataGridProps<T, V, E>
| SelectionInputDataGridProps<T, V, E>;
export class ProcessRowUpdateError<T, E> extends Error {
public readonly row: T;
public readonly errors: E | undefined;
constructor(row: T, message?: string, errors?: E) {
super(message);
this.row = row;
this.errors = errors;

Object.setPrototypeOf(this, ProcessRowUpdateError.prototype);
}
}
// T == CreatexxxInputs map of the form's fields
// V == target field input inside CreatexxxInputs, e.g. qcChecks: ItemQc[], V = ItemQc
// E == error
function InputDataGrid<T, V, E>({
apiRef,
// checkboxSelection = false,
_formKey,
columns,
validateRow,
}: Props<T, V, E>) {
const {
t,
// i18n: { language },
} = useTranslation("purchaseOrder");
const formKey = _formKey.toString();
const { setValue, getValues } = useFormContext();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
// const apiRef = useGridApiRef();
const getRowId = useCallback<GridRowIdGetter<TableRow<V, E>>>(
(row) => row.id! as number,
[],
);
const formValue = getValues(formKey)
const list: TableRow<V, E>[] = !formValue || formValue.length == 0 ? dummyQCData : getValues(formKey);
console.log(list)
const [rows, setRows] = useState<TableRow<V, E>[]>(() => {
// const list: TableRow<V, E>[] = getValues(formKey);
console.log(list)
return list && list.length > 0 ? list : [];
});
console.log(rows)
// const originalRows = list && list.length > 0 ? list : [];
const originalRows = useMemo(() => (
list && list.length > 0 ? list : []
), [list])
// const originalRowModel = originalRows.filter((li) => li.isActive).map(i => i.id) as GridRowSelectionModel
const [rowSelectionModel, setRowSelectionModel] =
useState<GridRowSelectionModel>(() => {
// const rowModel = list.filter((li) => li.isActive).map(i => i.id) as GridRowSelectionModel
const rowModel: GridRowSelectionModel = getValues(
`${formKey}_active`,
) as GridRowSelectionModel;
console.log(rowModel);
return rowModel;
});

useEffect(() => {
for (let i = 0; i < rows.length; i++) {
const currRow = rows[i]
setRowModesModel((prevRowModesModel) => ({
...prevRowModesModel,
[currRow.id as number]: { mode: GridRowModes.View },
}));
}
}, [rows])

const handleSave = useCallback(
(id: GridRowId) => () => {
setRowModesModel((prevRowModesModel) => ({
...prevRowModesModel,
[id]: { mode: GridRowModes.View },
}));
},
[],
);
const onProcessRowUpdateError = useCallback(
(updateError: ProcessRowUpdateError<T, E>) => {
const errors = updateError.errors;
const row = updateError.row;
console.log(errors);
apiRef.current.updateRows([{ ...row, _error: errors }]);
},
[apiRef],
);

const processRowUpdate = useCallback(
(
newRow: GridRowModel<TableRow<V, E>>,
originalRow: GridRowModel<TableRow<V, E>>,
) => {
/////////////////
// validation here
const errors = validateRow(newRow);
console.log(newRow);
if (errors) {
throw new ProcessRowUpdateError(
originalRow,
"validation error",
errors,
);
}
/////////////////
const { _isNew, _error, ...updatedRow } = newRow;
const rowToSave = {
...updatedRow,
} as TableRow<V, E>; /// test
console.log(rowToSave);
setRows((rw) =>
rw.map((r) => (getRowId(r) === getRowId(originalRow) ? rowToSave : r)),
);
return rowToSave;
},
[validateRow, getRowId],
);

const addRow = useCallback(() => {
const newEntry = { id: Date.now(), _isNew: true } as TableRow<V, E>;
setRows((prev) => [...prev, newEntry]);
setRowModesModel((model) => ({
...model,
[getRowId(newEntry)]: {
mode: GridRowModes.Edit,
// fieldToFocus: "team", /// test
},
}));
}, [getRowId]);

const reset = useCallback(() => {
setRowModesModel({});
setRows(originalRows);
}, [originalRows]);

const handleCancel = useCallback(
(id: GridRowId) => () => {
setRowModesModel((model) => ({
...model,
[id]: { mode: GridRowModes.View, ignoreModifications: true },
}));
const editedRow = rows.find((row) => getRowId(row) === id);
if (editedRow?._isNew) {
setRows((rw) => rw.filter((r) => getRowId(r) !== id));
} else {
setRows((rw) =>
rw.map((r) => (getRowId(r) === id ? { ...r, _error: undefined } : r)),
);
}
},
[rows, getRowId],
);

const handleDelete = useCallback(
(id: GridRowId) => () => {
setRows((prevRows) => prevRows.filter((row) => getRowId(row) !== id));
},
[getRowId],
);

const _columns = useMemo<GridColDef[]>(
() => [
...columns,
{
field: "actions",
type: "actions",
headerName: "",
flex: 0.5,
cellClassName: "actions",
getActions: ({ id }: { id: GridRowId }) => {
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;
if (isInEditMode) {
return [
<GridActionsCellItem
icon={<SaveIcon />}
label="Save"
key="edit"
sx={{
color: "primary.main",
}}
onClick={handleSave(id)}
/>,
<GridActionsCellItem
icon={<CancelIcon />}
label="Cancel"
key="edit"
onClick={handleCancel(id)}
/>,
];
}
return [
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
sx={{
color: "error.main",
}}
onClick={handleDelete(id)}
color="inherit"
key="edit"
/>,
];
},
},
],
[columns, rowModesModel, handleSave, handleCancel, handleDelete],
);
// sync useForm
useEffect(() => {
// console.log(formKey)
// console.log(rows)
setValue(formKey, rows);
}, [formKey, rows, setValue]);

const footer = (
<Box display="flex" gap={2} alignItems="center">
<Button
disableRipple
variant="outlined"
startIcon={<Add />}
onClick={addRow}
size="small"
>
新增
{/* {t("Add Record")} */}
</Button>
<Button
disableRipple
variant="outlined"
startIcon={<Add />}
onClick={reset}
size="small"
>
{/* {t("Clean Record")} */}
清除
</Button>
</Box>
);
// const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => {
// if (params.reason === GridRowEditStopReasons.rowFocusOut) {
// event.defaultMuiPrevented = true;
// }
// };

return (
<StyledDataGrid
// {...props}
// getRowId={getRowId as GridRowIdGetter<GridValidRowModel>}
rowSelectionModel={rowSelectionModel}
apiRef={apiRef}
rows={rows}
columns={columns}
editMode="row"
autoHeight
sx={{
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
borderColor: "error.main",
},
".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
border: "1px solid",
borderColor: "warning.main",
},
}}
disableColumnMenu
processRowUpdate={processRowUpdate as any}
// onRowEditStop={handleRowEditStop}
rowModesModel={rowModesModel}
onRowModesModelChange={setRowModesModel}
onProcessRowUpdateError={onProcessRowUpdateError}
getCellClassName={(params: GridCellParams<TableRow<T, E>>) => {
let classname = "";
if (params.row._error) {
classname = "hasError";
}
return classname;
}}
slots={{
// footer: FooterToolbar,
noRowsOverlay: NoRowsOverlay,
}}
// slotProps={{
// footer: { child: footer },
// }
// }
/>
);
}
const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => {
return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>;
};
const NoRowsOverlay: React.FC = () => {
const { t } = useTranslation("home");
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100%"
>
<Typography variant="caption">{t("Add some entries!")}</Typography>
</Box>
);
};
export default InputDataGrid;

+ 460
- 0
src/components/FinishedGoodSearch/QcFormVer2.tsx Просмотреть файл

@@ -0,0 +1,460 @@
"use client";

import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions";
import {
Box,
Card,
CardContent,
Checkbox,
FormControl,
FormControlLabel,
Grid,
Radio,
RadioGroup,
Stack,
Tab,
Tabs,
TabsProps,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { useFormContext, Controller } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import {
GridColDef,
GridRowIdGetter,
GridRowModel,
useGridApiContext,
GridRenderCellParams,
GridRenderEditCellParams,
useGridApiRef,
GridRowSelectionModel,
} from "@mui/x-data-grid";
import InputDataGrid from "../InputDataGrid";
import { TableRow } from "../InputDataGrid/InputDataGrid";
import TwoLineCell from "./TwoLineCell";
import QcSelect from "./QcSelect";
import { GridEditInputCell } from "@mui/x-data-grid";
import { StockInLine } from "@/app/api/po";
import { stockInLineStatusMap } from "@/app/utils/formatUtil";
import { fetchQcItemCheck, fetchQcResult } from "@/app/api/qc/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import axios from "@/app/(main)/axios/axiosInstance";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import EscalationComponent from "./EscalationComponent";
import QcDataGrid from "./QCDatagrid";
import StockInFormVer2 from "./StockInFormVer2";
import { dummyEscalationHistory, dummyQCData, QcData } from "./dummyQcTemplate";
import { ModalFormInput } from "@/app/api/po/actions";
import { escape } from "lodash";

interface Props {
itemDetail: StockInLine;
qc: QcItemWithChecks[];
disabled: boolean;
qcItems: QcData[]
setQcItems: Dispatch<SetStateAction<QcData[]>>
}

type EntryError =
| {
[field in keyof QcData]?: string;
}
| undefined;

type QcRow = TableRow<Partial<QcData>, EntryError>;
// fetchQcItemCheck
const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => {
const { t } = useTranslation("purchaseOrder");
const apiRef = useGridApiRef();
const {
register,
formState: { errors, defaultValues, touchedFields },
watch,
control,
setValue,
getValues,
reset,
resetField,
setError,
clearErrors,
} = useFormContext<PurchaseQCInput>();
const [tabIndex, setTabIndex] = useState(0);
const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>();
const [escalationHistory, setEscalationHistory] = useState(dummyEscalationHistory);
const [qcResult, setQcResult] = useState();
const qcAccept = watch("qcAccept");
// const [qcAccept, setQcAccept] = useState(true);
// const [qcItems, setQcItems] = useState(dummyQCData)

const column = useMemo<GridColDef[]>(
() => [
{
field: "escalation",
headerName: t("escalation"),
flex: 1,
},
{
field: "supervisor",
headerName: t("supervisor"),
flex: 1,
},
], []
)
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
},
[],
);

//// validate form
const accQty = watch("acceptQty");
const validateForm = useCallback(() => {
console.log(accQty);
if (accQty > itemDetail.acceptedQty) {
setError("acceptQty", {
message: `${t("acceptQty must not greater than")} ${
itemDetail.acceptedQty
}`,
type: "required",
});
}
if (accQty < 1) {
setError("acceptQty", {
message: t("minimal value is 1"),
type: "required",
});
}
if (isNaN(accQty)) {
setError("acceptQty", {
message: t("value must be a number"),
type: "required",
});
}
}, [accQty]);

useEffect(() => {
clearErrors();
validateForm();
}, [clearErrors, validateForm]);

const columns = useMemo<GridColDef[]>(
() => [
{
field: "escalation",
headerName: t("escalation"),
flex: 1,
},
{
field: "supervisor",
headerName: t("supervisor"),
flex: 1,
},
],
[],
);
/// validate datagrid
const validation = useCallback(
(newRow: GridRowModel<QcRow>): EntryError => {
const error: EntryError = {};
// const { qcItemId, failQty } = newRow;
return Object.keys(error).length > 0 ? error : undefined;
},
[],
);

function BooleanEditCell(params: GridRenderEditCellParams) {
const apiRef = useGridApiContext();
const { id, field, value } = params;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
apiRef.current.setEditCellValue({ id, field, value: e.target.checked });
apiRef.current.stopCellEditMode({ id, field }); // commit immediately
};

return <Checkbox checked={!!value} onChange={handleChange} sx={{ p: 0 }} />;
}

const qcColumns: GridColDef[] = [
{
field: "qcItem",
headerName: t("qcItem"),
flex: 2,
renderCell: (params) => (
<Box>
<b>{params.value}</b><br/>
{params.row.qcDescription}<br/>
</Box>
),
},
{
field: 'isPassed',
headerName: t("qcResult"),
flex: 1.5,
renderCell: (params) => {
const currentValue = params.value;
return (
<FormControl>
<RadioGroup
row
aria-labelledby="demo-radio-buttons-group-label"
value={currentValue === undefined ? "" : (currentValue ? "true" : "false")}
onChange={(e) => {
const value = e.target.value;
setQcItems((prev) =>
prev.map((r): QcData => (r.id === params.id ? { ...r, isPassed: value === "true" } : r))
);
}}
name={`isPassed-${params.id}`}
>
<FormControlLabel
value="true"
control={<Radio />}
label="合格"
sx={{
color: currentValue === true ? "green" : "inherit",
"& .Mui-checked": {color: "green"}
}}
/>
<FormControlLabel
value="false"
control={<Radio />}
label="不合格"
sx={{
color: currentValue === false ? "red" : "inherit",
"& .Mui-checked": {color: "red"}
}}
/>
</RadioGroup>
</FormControl>
);
},
},
{
field: "failedQty",
headerName: t("failedQty"),
flex: 1,
// editable: true,
renderCell: (params) => (
<TextField
type="number"
size="small"
value={!params.row.isPassed? (params.value ?? '') : '0'}
disabled={params.row.isPassed}
onChange={(e) => {
const v = e.target.value;
const next = v === '' ? undefined : Number(v);
if (Number.isNaN(next)) return;
setQcItems((prev) =>
prev.map((r) => (r.id === params.id ? { ...r, failedQty: next } : r))
);
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
inputProps={{ min: 0 }}
sx={{ width: '100%' }}
/>
),
},
{
field: "remarks",
headerName: t("remarks"),
flex: 2,
renderCell: (params) => (
<TextField
size="small"
value={params.value ?? ''}
onChange={(e) => {
const remarks = e.target.value;
// const next = v === '' ? undefined : Number(v);
// if (Number.isNaN(next)) return;
setQcItems((prev) =>
prev.map((r) => (r.id === params.id ? { ...r, remarks: remarks } : r))
);
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
inputProps={{ min: 0 }}
sx={{ width: '100%' }}
/>
),
},
]

useEffect(() => {
console.log(itemDetail);
}, [itemDetail]);

// Set initial value for acceptQty
useEffect(() => {
if (itemDetail?.acceptedQty !== undefined) {
setValue("acceptQty", itemDetail.acceptedQty);
}
}, [itemDetail?.acceptedQty, setValue]);

// const [openCollapse, setOpenCollapse] = useState(false)
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);

const onFailedOpenCollapse = useCallback((qcItems: QcData[]) => {
const isFailed = qcItems.some((qc) => !qc.isPassed)
console.log(isFailed)
if (isFailed) {
setIsCollapsed(true)
} else {
setIsCollapsed(false)
}
}, [])

// const handleRadioChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
// const value = event.target.value === 'true';
// setValue("qcAccept", value);
// }, [setValue]);


useEffect(() => {
console.log(itemDetail);
}, [itemDetail]);

useEffect(() => {
// onFailedOpenCollapse(qcItems) // This function is no longer needed
}, [qcItems]); // Removed onFailedOpenCollapse from dependency array

return (
<>
<Grid container justifyContent="flex-start" alignItems="flex-start">
<Grid
container
justifyContent="flex-start"
alignItems="flex-start"
spacing={2}
sx={{ mt: 0.5 }}
>
<Grid item xs={12}>
<Tabs
value={tabIndex}
onChange={handleTabChange}
variant="scrollable"
>
<Tab label={t("QC Info")} iconPosition="end" />
<Tab label={t("Escalation History")} iconPosition="end" />
</Tabs>
</Grid>
{tabIndex == 0 && (
<>
<Grid item xs={12}>
{/* <QcDataGrid<ModalFormInput, QcData, EntryError>
apiRef={apiRef}
columns={qcColumns}
_formKey="qcResult"
validateRow={validation}
/> */}
<StyledDataGrid
columns={qcColumns}
rows={qcItems}
autoHeight
/>
</Grid>
{/* <Grid item xs={12}>
<EscalationComponent
forSupervisor={false}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
/>
</Grid> */}
</>
)}
{tabIndex == 1 && (
<>
{/* <Grid item xs={12}>
<StockInFormVer2
itemDetail={itemDetail}
disabled={false}
/>
</Grid> */}
<Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
{t("Escalation Info")}
</Typography>
</Grid>
<Grid item xs={12}>
<StyledDataGrid
rows={escalationHistory}
columns={columns}
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
}}
/>
</Grid>
</>
)}
<Grid item xs={12}>
<FormControl>
<Controller
name="qcAccept"
control={control}
defaultValue={true}
render={({ field }) => (
<RadioGroup
row
aria-labelledby="demo-radio-buttons-group-label"
{...field}
value={field.value?.toString() || "true"}
onChange={(e) => {
const value = e.target.value === 'true';
if (!value && Boolean(errors.acceptQty)) {
setValue("acceptQty", itemDetail.acceptedQty);
}
field.onChange(value);
}}
>
<FormControlLabel value="true" control={<Radio />} label="接受" />
<Box sx={{mr:2}}>
<TextField
type="number"
label={t("acceptQty")}
sx={{ width: '150px' }}
defaultValue={accQty}
disabled={!qcAccept}
{...register("acceptQty", {
required: "acceptQty required!",
})}
error={Boolean(errors.acceptQty)}
helperText={errors.acceptQty?.message}
/>
</Box>
<FormControlLabel value="false" control={<Radio />} label="不接受及上報" />
</RadioGroup>
)}
/>
</FormControl>
</Grid>
{/* <Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
{t("Escalation Result")}
</Typography>
</Grid>
<Grid item xs={12}>
<EscalationComponent
forSupervisor={true}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
/>
</Grid> */}
</Grid>
</Grid>
</>
);
};
export default QcFormVer2;

+ 78
- 0
src/components/FinishedGoodSearch/QcSelect.tsx Просмотреть файл

@@ -0,0 +1,78 @@
import React, { useCallback, useMemo } from "react";
import {
Autocomplete,
Box,
Checkbox,
Chip,
ListSubheader,
MenuItem,
TextField,
Tooltip,
} from "@mui/material";
import { QcItemWithChecks } from "@/app/api/qc";
import { useTranslation } from "react-i18next";

interface CommonProps {
allQcs: QcItemWithChecks[];
error?: boolean;
}

interface SingleAutocompleteProps extends CommonProps {
value: number | string | undefined;
onQcSelect: (qcItemId: number) => void | Promise<void>;
// multiple: false;
}

type Props = SingleAutocompleteProps;

const QcSelect: React.FC<Props> = ({ allQcs, value, error, onQcSelect }) => {
const { t } = useTranslation("home");
const filteredQc = useMemo(() => {
// do filtering here if any
return allQcs;
}, [allQcs]);
const options = useMemo(() => {
return [
{
value: -1, // think think sin
label: t("None"),
group: "default",
},
...filteredQc.map((q) => ({
value: q.id,
label: `${q.code} - ${q.name}`,
group: "existing",
})),
];
}, [t, filteredQc]);

const currentValue = options.find((o) => o.value === value) || options[0];

const onChange = useCallback(
(
event: React.SyntheticEvent,
newValue: { value: number; group: string } | { value: number }[],
) => {
const singleNewVal = newValue as {
value: number;
group: string;
};
onQcSelect(singleNewVal.value);
},
[onQcSelect],
);

return (
<Autocomplete
noOptionsText={t("No Qc")}
disableClearable
fullWidth
value={currentValue}
onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}
renderInput={(params) => <TextField {...params} error={error} />}
/>
);
};
export default QcSelect;

+ 243
- 0
src/components/FinishedGoodSearch/SearchResultsTable.tsx Просмотреть файл

@@ -0,0 +1,243 @@
import React, { useCallback } from 'react';
import {
Box,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Checkbox,
TextField,
TablePagination,
FormControl,
Select,
MenuItem,
} from '@mui/material';
import { useTranslation } from 'react-i18next';

interface SearchItemWithQty {
id: number;
label: string;
qty: number | null;
currentStockBalance?: number;
uomDesc?: string;
targetDate?: string | null;
groupId?: number | null;
}

interface Group {
id: number;
name: string;
targetDate: string;
}

interface SearchResultsTableProps {
items: SearchItemWithQty[];
selectedItemIds: (string | number)[];
groups: Group[];
onItemSelect: (itemId: number, checked: boolean) => void;
onQtyChange: (itemId: number, qty: number | null) => void;
onQtyBlur: (itemId: number) => void;
onGroupChange: (itemId: number, groupId: string) => void;
isItemInCreated: (itemId: number) => boolean;
pageNum: number;
pageSize: number;
onPageChange: (event: unknown, newPage: number) => void;
onPageSizeChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}

const SearchResultsTable: React.FC<SearchResultsTableProps> = ({
items,
selectedItemIds,
groups,
onItemSelect,
onQtyChange,
onGroupChange,
onQtyBlur,
isItemInCreated,
pageNum,
pageSize,
onPageChange,
onPageSizeChange,
}) => {
const { t } = useTranslation("pickOrder");

// Calculate pagination
const startIndex = (pageNum - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedResults = items.slice(startIndex, endIndex);

const handleQtyChange = useCallback((itemId: number, value: string) => {
// Only allow numbers
if (value === "" || /^\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
onQtyChange(itemId, numValue);
}
}, [onQtyChange]);

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell padding="checkbox" sx={{ width: '80px', minWidth: '80px' }}>
{t("Selected")}
</TableCell>
<TableCell>
{t("Item")}
</TableCell>
<TableCell>
{t("Group")}
</TableCell>
<TableCell align="right">
{t("Current Stock")}
</TableCell>
<TableCell align="right">
{t("Stock Unit")}
</TableCell>
<TableCell align="right">
{t("Order Quantity")}
</TableCell>
<TableCell align="right">
{t("Target Date")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{paginatedResults.length === 0 ? (
<TableRow>
<TableCell colSpan={12} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
paginatedResults.map((item) => (
<TableRow key={item.id}>
<TableCell padding="checkbox">
<Checkbox
checked={selectedItemIds.includes(item.id)}
onChange={(e) => onItemSelect(item.id, e.target.checked)}
disabled={isItemInCreated(item.id)}
/>
</TableCell>
{/* Item */}
<TableCell>
<Box>
<Typography variant="body2">
{item.label.split(' - ')[1] || item.label}
</Typography>
<Typography variant="caption" color="textSecondary">
{item.label.split(' - ')[0] || ''}
</Typography>
</Box>
</TableCell>
{/* Group */}
<TableCell>
<FormControl size="small" sx={{ minWidth: 120 }}>
<Select
value={item.groupId?.toString() || ""}
onChange={(e) => onGroupChange(item.id, e.target.value)}
displayEmpty
disabled={isItemInCreated(item.id)}
>
<MenuItem value="">
<em>{t("No Group")}</em>
</MenuItem>
{groups.map((group) => (
<MenuItem key={group.id} value={group.id.toString()}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
</TableCell>
{/* Current Stock */}
<TableCell align="right">
<Typography
variant="body2"
color={item.currentStockBalance && item.currentStockBalance > 0 ? "success.main" : "error.main"}
sx={{ fontWeight: item.currentStockBalance && item.currentStockBalance > 0 ? 'bold' : 'normal' }}
>
{item.currentStockBalance?.toLocaleString()||0}
</Typography>
</TableCell>
{/* Stock Unit */}
<TableCell align="right">
<Typography variant="body2">
{item.uomDesc || "-"}
</Typography>
</TableCell>
<TableCell align="right">
{/* Order Quantity */}

<TextField
type="number"
size="small"
value={item.qty || ""}
onChange={(e) => {
const value = e.target.value;
// Only allow numbers
if (value === "" || /^\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
onQtyChange(item.id, numValue);
}
}}
onBlur={() => {
// Trigger auto-add check when user finishes input (clicks elsewhere)
onQtyBlur(item.id); // ← Change this to call onQtyBlur instead!
}}
inputProps={{
style: { textAlign: 'center' }
}}
sx={{
width: '80px',
'& .MuiInputBase-input': {
textAlign: 'center',
cursor: 'text'
}
}}
disabled={isItemInCreated(item.id)}
/>
</TableCell>
{/* Target Date */}
<TableCell align="right">
<Typography variant="body2">
{item.targetDate ? new Date(item.targetDate).toLocaleDateString() : "-"}
</Typography>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={items.length}
page={(pageNum - 1)}
rowsPerPage={pageSize}
onPageChange={onPageChange}
onRowsPerPageChange={onPageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

export default SearchResultsTable;

+ 321
- 0
src/components/FinishedGoodSearch/StockInFormVer2.tsx Просмотреть файл

@@ -0,0 +1,321 @@
"use client";

import {
PurchaseQcResult,
PurchaseQCInput,
StockInInput,
} from "@/app/api/po/actions";
import {
Box,
Card,
CardContent,
Grid,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { useCallback, useEffect, useMemo } from "react";
import {
GridColDef,
GridRowIdGetter,
GridRowModel,
useGridApiContext,
GridRenderCellParams,
GridRenderEditCellParams,
useGridApiRef,
} from "@mui/x-data-grid";
import InputDataGrid from "../InputDataGrid";
import { TableRow } from "../InputDataGrid/InputDataGrid";
import TwoLineCell from "./TwoLineCell";
import QcSelect from "./QcSelect";
import { QcItemWithChecks } from "@/app/api/qc";
import { GridEditInputCell } from "@mui/x-data-grid";
import { StockInLine } from "@/app/api/po";
import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import dayjs from "dayjs";
// 修改接口以支持 PickOrder 数据
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";

// change PurchaseQcResult to stock in entry props
interface Props {
itemDetail: StockInLine | (GetPickOrderLineInfo & { pickOrderCode: string });
// qc: QcItemWithChecks[];
disabled: boolean;
}
type EntryError =
| {
[field in keyof StockInInput]?: string;
}
| undefined;

// type PoQcRow = TableRow<Partial<PurchaseQcResult>, EntryError>;

const StockInFormVer2: React.FC<Props> = ({
// qc,
itemDetail,
disabled,
}) => {
const {
t,
i18n: { language },
} = useTranslation("purchaseOrder");
const apiRef = useGridApiRef();
const {
register,
formState: { errors, defaultValues, touchedFields },
watch,
control,
setValue,
getValues,
reset,
resetField,
setError,
clearErrors,
} = useFormContext<StockInInput>();
// console.log(itemDetail);

useEffect(() => {
console.log("triggered");
// receiptDate default tdy
setValue("receiptDate", dayjs().add(0, "month").format(INPUT_DATE_FORMAT));
setValue("status", "received");
}, [setValue]);

useEffect(() => {
console.log(errors);
}, [errors]);

const productionDate = watch("productionDate");
const expiryDate = watch("expiryDate");
const uom = watch("uom");

useEffect(() => {
console.log(uom);
console.log(productionDate);
console.log(expiryDate);
if (expiryDate) clearErrors();
if (productionDate) clearErrors();
}, [expiryDate, productionDate, clearErrors]);

// 检查是否为 PickOrder 数据
const isPickOrderData = 'pickOrderCode' in itemDetail;

// 获取 UOM 显示值
const getUomDisplayValue = () => {
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.uomDesc || pickOrderItem.uomCode || '';
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
return uom?.code || stockInItem.uom?.code || '';
}
};

// 获取 Item 显示值
const getItemDisplayValue = () => {
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.itemCode || '';
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
return stockInItem.itemNo || '';
}
};

// 获取 Item Name 显示值
const getItemNameDisplayValue = () => {
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.itemName || '';
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
return stockInItem.itemName || '';
}
};

// 获取 Quantity 显示值
const getQuantityDisplayValue = () => {
if (isPickOrderData) {
// PickOrder 数据
const pickOrderItem = itemDetail as GetPickOrderLineInfo & { pickOrderCode: string };
return pickOrderItem.requiredQty || 0;
} else {
// StockIn 数据
const stockInItem = itemDetail as StockInLine;
return stockInItem.acceptedQty || 0;
}
};

return (
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
{t("stock in information")}
</Typography>
</Grid>
<Grid item xs={6}>
<TextField
label={t("itemNo")}
fullWidth
{...register("itemNo", {
required: "itemNo required!",
})}
value={getItemDisplayValue()}
disabled={true}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("itemName")}
fullWidth
{...register("itemName", {
required: "itemName required!",
})}
value={getItemNameDisplayValue()}
disabled={true}
/>
</Grid>
<Grid item xs={6}>
<Controller
name="productionDate"
control={control}
rules={{
required: "productionDate required!",
}}
render={({ field }) => {
return (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={`${language}-hk`}
>
<DatePicker
{...field}
sx={{ width: "100%" }}
label={t("productionDate")}
value={productionDate ? dayjs(productionDate) : undefined}
disabled={disabled}
onChange={(date) => {
console.log(date);
if (!date) return;
console.log(date.format(INPUT_DATE_FORMAT));
setValue("productionDate", date.format(INPUT_DATE_FORMAT));
// field.onChange(date);
}}
inputRef={field.ref}
slotProps={{
textField: {
// required: true,
error: Boolean(errors.productionDate?.message),
helperText: errors.productionDate?.message,
},
}}
/>
</LocalizationProvider>
);
}}
/>
</Grid>
<Grid item xs={6}>
<Controller
name="expiryDate"
control={control}
rules={{
required: "expiryDate required!",
}}
render={({ field }) => {
return (
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale={`${language}-hk`}
>
<DatePicker
{...field}
sx={{ width: "100%" }}
label={t("expiryDate")}
value={expiryDate ? dayjs(expiryDate) : undefined}
disabled={disabled}
onChange={(date) => {
console.log(date);
if (!date) return;
console.log(date.format(INPUT_DATE_FORMAT));
setValue("expiryDate", date.format(INPUT_DATE_FORMAT));
// field.onChange(date);
}}
inputRef={field.ref}
slotProps={{
textField: {
// required: true,
error: Boolean(errors.expiryDate?.message),
helperText: errors.expiryDate?.message,
},
}}
/>
</LocalizationProvider>
);
}}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("receivedQty")}
fullWidth
{...register("receivedQty", {
required: "receivedQty required!",
})}
value={getQuantityDisplayValue()}
disabled={true}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("uom")}
fullWidth
{...register("uom", {
required: "uom required!",
})}
value={getUomDisplayValue()}
disabled={true}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("acceptedQty")}
fullWidth
{...register("acceptedQty", {
required: "acceptedQty required!",
})}
value={getQuantityDisplayValue()}
disabled={true}
// disabled={disabled}
// error={Boolean(errors.acceptedQty)}
// helperText={errors.acceptedQty?.message}
/>
</Grid>
{/* <Grid item xs={4}>
<TextField
label={t("acceptedWeight")}
fullWidth
// {...register("acceptedWeight", {
// required: "acceptedWeight required!",
// })}
disabled={disabled}
error={Boolean(errors.acceptedWeight)}
helperText={errors.acceptedWeight?.message}
/>
</Grid> */}
</Grid>
);
};
export default StockInFormVer2;

+ 24
- 0
src/components/FinishedGoodSearch/TwoLineCell.tsx Просмотреть файл

@@ -0,0 +1,24 @@
import { Box, Tooltip } from "@mui/material";
import React from "react";

const TwoLineCell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<Tooltip title={children}>
<Box
sx={{
whiteSpace: "normal",
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
lineHeight: "22px",
}}
>
{children}
</Box>
</Tooltip>
);
};

export default TwoLineCell;

+ 73
- 0
src/components/FinishedGoodSearch/UomSelect.tsx Просмотреть файл

@@ -0,0 +1,73 @@

import { ItemCombo } from "@/app/api/settings/item/actions";
import { Autocomplete, TextField } from "@mui/material";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";

interface CommonProps {
allUom: ItemCombo[];
error?: boolean;
}

interface SingleAutocompleteProps extends CommonProps {
value: number | string | undefined;
onUomSelect: (itemId: number) => void | Promise<void>;
// multiple: false;
}

type Props = SingleAutocompleteProps;

const UomSelect: React.FC<Props> = ({
allUom,
value,
error,
onUomSelect
}) => {
const { t } = useTranslation("item");
const filteredUom = useMemo(() => {
return allUom
}, [allUom])

const options = useMemo(() => {
return [
{
value: -1, // think think sin
label: t("None"),
group: "default",
},
...filteredUom.map((i) => ({
value: i.id as number,
label: i.label,
group: "existing",
})),
];
}, [t, filteredUom]);

const currentValue = options.find((o) => o.value === value) || options[0];

const onChange = useCallback(
(
event: React.SyntheticEvent,
newValue: { value: number; group: string } | { value: number }[],
) => {
const singleNewVal = newValue as {
value: number;
group: string;
};
onUomSelect(singleNewVal.value)
}
, [onUomSelect])
return (
<Autocomplete
noOptionsText={t("No Uom")}
disableClearable
fullWidth
value={currentValue}
onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}
renderInput={(params) => <TextField {...params} error={error} />}
/>
);
}
export default UomSelect

+ 85
- 0
src/components/FinishedGoodSearch/VerticalSearchBox.tsx Просмотреть файл

@@ -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;

+ 511
- 0
src/components/FinishedGoodSearch/assignTo copy.tsx Просмотреть файл

@@ -0,0 +1,511 @@
"use client";
import {
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
Grid,
Modal,
TextField,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Checkbox,
TablePagination,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
newassignPickOrder,
AssignPickOrderInputs,
releaseAssignedPickOrders,
fetchPickOrderWithStockClient, // Add this import
} from "@/app/api/pickOrder/actions";
import { fetchNameList, NameList } from "@/app/api/user/actions";
import {
FormProvider,
useForm,
} from "react-hook-form";
import { isEmpty, upperFirst, groupBy } from "lodash";
import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil";
import useUploadContext from "../UploadProvider/useUploadContext";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import SearchBox, { Criterion } from "../SearchBox";
import { sortBy, uniqBy } from "lodash";
import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions";
dayjs.extend(arraySupport);

interface Props {
filterArgs: Record<string, any>;
}

// Update the interface to match the new API response structure
interface PickOrderRow {
id: string;
code: string;
targetDate: string;
type: string;
status: string;
assignTo: number;
groupName: string;
consoCode?: string;
pickOrderLines: PickOrderLineRow[];
}

interface PickOrderLineRow {
id: number;
itemId: number;
itemCode: string;
itemName: string;
availableQty: number | null;
requiredQty: number;
uomCode: string;
uomDesc: string;
suggestedList: any[];
}

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
width: { xs: "100%", sm: "100%", md: "100%" },
};

const AssignTo: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { setIsUploading } = useUploadContext();
const [isUploading, setIsUploadingLocal] = useState(false);
// Update state to use pick order data directly
const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<string[]>([]);
const [filteredPickOrders, setFilteredPickOrders] = useState<PickOrderRow[]>([]);
const [isLoadingItems, setIsLoadingItems] = useState(false);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const [totalCountItems, setTotalCountItems] = useState<number>();
const [modalOpen, setModalOpen] = useState(false);
const [usernameList, setUsernameList] = useState<NameList[]>([]);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]);

const formProps = useForm<AssignPickOrderInputs>();
const errors = formProps.formState.errors;

// Update the handler functions to work with string IDs
const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => {
if (checked) {
setSelectedPickOrderIds(prev => [...prev, pickOrderId]);
} else {
setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId));
}
}, []);

const isPickOrderSelected = useCallback((pickOrderId: string) => {
return selectedPickOrderIds.includes(pickOrderId);
}, [selectedPickOrderIds]);

// Update the fetch function to use the correct endpoint
const fetchNewPageItems = useCallback(
async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => {
setIsLoadingItems(true);
try {
const params = {
...pagingController,
...filterArgs,
pageNum: (pagingController.pageNum || 1) - 1,
pageSize: pagingController.pageSize || 10,
// Filter for assigned status only
status: "assigned"
};

const res = await fetchPickOrderWithStockClient(params);

if (res && res.records) {
// Convert pick order data to the expected format
const pickOrderRows: PickOrderRow[] = res.records.map((pickOrder: any) => ({
id: pickOrder.id,
code: pickOrder.code,
targetDate: pickOrder.targetDate,
type: pickOrder.type,
status: pickOrder.status,
assignTo: pickOrder.assignTo,
groupName: pickOrder.groupName || "No Group",
consoCode: pickOrder.consoCode,
pickOrderLines: pickOrder.pickOrderLines || []
}));
setOriginalPickOrderData(pickOrderRows);
setFilteredPickOrders(pickOrderRows);
setTotalCountItems(res.total);
} else {
setFilteredPickOrders([]);
setTotalCountItems(0);
}
} catch (error) {
console.error("Error fetching pick orders:", error);
setFilteredPickOrders([]);
setTotalCountItems(0);
} finally {
setIsLoadingItems(false);
}
},
[],
);

// Handle Release operation
// Handle Release operation
const handleRelease = useCallback(async () => {
if (selectedPickOrderIds.length === 0) return;
setIsUploading(true);
try {
// Get the assigned user from the selected pick orders
const selectedPickOrders = filteredPickOrders.filter(pickOrder =>
selectedPickOrderIds.includes(pickOrder.id)
);
// Check if all selected pick orders have the same assigned user
const assignedUsers = selectedPickOrders.map(po => po.assignTo).filter(Boolean);
if (assignedUsers.length === 0) {
alert("Selected pick orders are not assigned to any user.");
return;
}
const assignToValue = assignedUsers[0];
// Validate that all pick orders are assigned to the same user
const allSameUser = assignedUsers.every(userId => userId === assignToValue);
if (!allSameUser) {
alert("All selected pick orders must be assigned to the same user.");
return;
}
console.log("Using assigned user:", assignToValue);
console.log("selectedPickOrderIds:", selectedPickOrderIds);
const releaseRes = await releaseAssignedPickOrders({
pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)),
assignTo: assignToValue
});
if (releaseRes.code === "SUCCESS") {
console.log("Pick orders released successfully");
// Get the consoCode from the response
const consoCode = (releaseRes.entity as any)?.consoCode;
if (consoCode) {
// Create StockOutLine records for each pick order line
for (const pickOrder of selectedPickOrders) {
for (const line of pickOrder.pickOrderLines) {
try {
const stockOutLineData = {
consoCode: consoCode,
pickOrderLineId: line.id,
inventoryLotLineId: 0, // This will be set when user scans QR code
qty: line.requiredQty,
};
console.log("Creating stock out line:", stockOutLineData);
await createStockOutLine(stockOutLineData);
} catch (error) {
console.error("Error creating stock out line for line", line.id, error);
}
}
}
}
fetchNewPageItems(pagingController, filterArgs);
} else {
console.error("Release failed:", releaseRes.message);
}
} catch (error) {
console.error("Error releasing pick orders:", error);
} finally {
setIsUploading(false);
}
}, [selectedPickOrderIds, filteredPickOrders, setIsUploading, fetchNewPageItems, pagingController, filterArgs]);

// Update search criteria to match the new data structure
const searchCriteria: Criterion<any>[] = useMemo(
() => [
{
label: t("Pick Order Code"),
paramName: "code",
type: "text",
},
{
label: t("Group Code"),
paramName: "groupName",
type: "text",
},
{
label: t("Target Date From"),
label2: t("Target Date To"),
paramName: "targetDate",
type: "dateRange",
},
],
[t],
);

// Update search function to work with pick order data
const handleSearch = useCallback((query: Record<string, any>) => {
setSearchQuery({ ...query });

const filtered = originalPickOrderData.filter((pickOrder) => {
const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate);

const codeMatch = !query.code ||
pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase());
const groupNameMatch = !query.groupName ||
pickOrder.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase());
// Date range search
let dateMatch = true;
if (query.targetDate || query.targetDateTo) {
try {
if (query.targetDate && !query.targetDateTo) {
const fromDate = dayjs(query.targetDate);
dateMatch = pickOrderTargetDateStr.isSame(fromDate, 'day') ||
pickOrderTargetDateStr.isAfter(fromDate, 'day');
} else if (!query.targetDate && query.targetDateTo) {
const toDate = dayjs(query.targetDateTo);
dateMatch = pickOrderTargetDateStr.isSame(toDate, 'day') ||
pickOrderTargetDateStr.isBefore(toDate, 'day');
} else if (query.targetDate && query.targetDateTo) {
const fromDate = dayjs(query.targetDate);
const toDate = dayjs(query.targetDateTo);
dateMatch = (pickOrderTargetDateStr.isSame(fromDate, 'day') ||
pickOrderTargetDateStr.isAfter(fromDate, 'day')) &&
(pickOrderTargetDateStr.isSame(toDate, 'day') ||
pickOrderTargetDateStr.isBefore(toDate, 'day'));
}
} catch (error) {
console.error("Date parsing error:", error);
dateMatch = true;
}
}

return codeMatch && groupNameMatch && dateMatch;
});
setFilteredPickOrders(filtered);
}, [originalPickOrderData]);

const handleReset = useCallback(() => {
setSearchQuery({});
setFilteredPickOrders(originalPickOrderData);
setTimeout(() => {
setSearchQuery({});
}, 0);
}, [originalPickOrderData]);

// Pagination handlers
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
setPagingController(newPagingController);
}, [pagingController]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1,
pageSize: newPageSize,
};
setPagingController(newPagingController);
}, []);

// Component mount effect
useEffect(() => {
fetchNewPageItems(pagingController, filterArgs || {});
}, []);

// Dependencies change effect
useEffect(() => {
if (pagingController && (filterArgs || {})) {
fetchNewPageItems(pagingController, filterArgs || {});
}
}, [pagingController, filterArgs, fetchNewPageItems]);

useEffect(() => {
const loadUsernameList = async () => {
try {
const res = await fetchNameList();
if (res) {
setUsernameList(res);
}
} catch (error) {
console.error("Error loading username list:", error);
}
};
loadUsernameList();
}, []);

// Update the table component to work with pick order data directly
const CustomPickOrderTable = () => {
// Helper function to get user name
const getUserName = useCallback((assignToId: number | null | undefined) => {
if (!assignToId) return '-';
const user = usernameList.find(u => u.id === assignToId);
return user ? user.name : `User ${assignToId}`;
}, [usernameList]);

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Selected")}</TableCell>
<TableCell>{t("Pick Order Code")}</TableCell>
<TableCell>{t("Group Code")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell align="right">{t("Order Quantity")}</TableCell>
<TableCell align="right">{t("Current Stock")}</TableCell>
<TableCell align="right">{t("Stock Unit")}</TableCell>
<TableCell>{t("Target Date")}</TableCell>
<TableCell>{t("Assigned To")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPickOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
filteredPickOrders.map((pickOrder) => (
pickOrder.pickOrderLines.map((line: PickOrderLineRow, index: number) => (
<TableRow key={`${pickOrder.id}-${line.id}`}>
{/* Checkbox - only show for first line of each pick order */}
<TableCell>
{index === 0 ? (
<Checkbox
checked={isPickOrderSelected(pickOrder.id)}
onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)}
disabled={!isEmpty(pickOrder.consoCode)}
/>
) : null}
</TableCell>
{/* Pick Order Code - only show for first line */}
<TableCell>
{index === 0 ? pickOrder.code : null}
</TableCell>
{/* Group Name - only show for first line */}
<TableCell>
{index === 0 ? pickOrder.groupName : null}
</TableCell>
{/* Item Code */}
<TableCell>{line.itemCode}</TableCell>
{/* Item Name */}
<TableCell>{line.itemName}</TableCell>
{/* Order Quantity */}
<TableCell align="right">{line.requiredQty}</TableCell>
{/* Current Stock */}
<TableCell align="right">
<Typography
variant="body2"
color={line.availableQty && line.availableQty > 0 ? "success.main" : "error.main"}
sx={{ fontWeight: line.availableQty && line.availableQty > 0 ? 'bold' : 'normal' }}
>
{(line.availableQty || 0).toLocaleString()}
</Typography>
</TableCell>
{/* Unit */}
<TableCell align="right">{line.uomDesc}</TableCell>
{/* Target Date - only show for first line */}
<TableCell>
{index === 0 ? (
arrayToDayjs(pickOrder.targetDate)
.add(-1, "month")
.format(OUTPUT_DATE_FORMAT)
) : null}
</TableCell>
{/* Assigned To - only show for first line */}
<TableCell>
{index === 0 ? (
<Typography variant="body2">
{getUserName(pickOrder.assignTo)}
</Typography>
) : null}
</TableCell>
</TableRow>
))
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCountItems || 0}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

return (
<>
<SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} />
<Grid container rowGap={1}>
<Grid item xs={12}>
{isLoadingItems ? (
<CircularProgress size={40} />
) : (
<CustomPickOrderTable />
)}
</Grid>
<Grid item xs={12}>
<Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}>
<Button
disabled={selectedPickOrderIds.length < 1}
variant="outlined"
onClick={handleRelease}
>
{t("Release")}
</Button>
</Box>
</Grid>
</Grid>
</>
);
};

export default AssignTo;

+ 511
- 0
src/components/FinishedGoodSearch/assignTo.tsx Просмотреть файл

@@ -0,0 +1,511 @@
"use client";
import {
Autocomplete,
Box,
Button,
CircularProgress,
FormControl,
Grid,
Modal,
TextField,
Typography,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Checkbox,
TablePagination,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
newassignPickOrder,
AssignPickOrderInputs,
releaseAssignedPickOrders,
fetchPickOrderWithStockClient, // Add this import
} from "@/app/api/pickOrder/actions";
import { fetchNameList, NameList } from "@/app/api/user/actions";
import {
FormProvider,
useForm,
} from "react-hook-form";
import { isEmpty, upperFirst, groupBy } from "lodash";
import { OUTPUT_DATE_FORMAT, arrayToDayjs } from "@/app/utils/formatUtil";
import useUploadContext from "../UploadProvider/useUploadContext";
import dayjs from "dayjs";
import arraySupport from "dayjs/plugin/arraySupport";
import SearchBox, { Criterion } from "../SearchBox";
import { sortBy, uniqBy } from "lodash";
import { createStockOutLine, CreateStockOutLine, fetchPickOrderDetails } from "@/app/api/pickOrder/actions";
dayjs.extend(arraySupport);

interface Props {
filterArgs: Record<string, any>;
}

// Update the interface to match the new API response structure
interface PickOrderRow {
id: string;
code: string;
targetDate: string;
type: string;
status: string;
assignTo: number;
groupName: string;
consoCode?: string;
pickOrderLines: PickOrderLineRow[];
}

interface PickOrderLineRow {
id: number;
itemId: number;
itemCode: string;
itemName: string;
availableQty: number | null;
requiredQty: number;
uomCode: string;
uomDesc: string;
suggestedList: any[];
}

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
width: { xs: "100%", sm: "100%", md: "100%" },
};

const AssignTo: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const { setIsUploading } = useUploadContext();
const [isUploading, setIsUploadingLocal] = useState(false);
// Update state to use pick order data directly
const [selectedPickOrderIds, setSelectedPickOrderIds] = useState<string[]>([]);
const [filteredPickOrders, setFilteredPickOrders] = useState<PickOrderRow[]>([]);
const [isLoadingItems, setIsLoadingItems] = useState(false);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const [totalCountItems, setTotalCountItems] = useState<number>();
const [modalOpen, setModalOpen] = useState(false);
const [usernameList, setUsernameList] = useState<NameList[]>([]);
const [searchQuery, setSearchQuery] = useState<Record<string, any>>({});
const [originalPickOrderData, setOriginalPickOrderData] = useState<PickOrderRow[]>([]);

const formProps = useForm<AssignPickOrderInputs>();
const errors = formProps.formState.errors;

// Update the handler functions to work with string IDs
const handlePickOrderSelect = useCallback((pickOrderId: string, checked: boolean) => {
if (checked) {
setSelectedPickOrderIds(prev => [...prev, pickOrderId]);
} else {
setSelectedPickOrderIds(prev => prev.filter(id => id !== pickOrderId));
}
}, []);

const isPickOrderSelected = useCallback((pickOrderId: string) => {
return selectedPickOrderIds.includes(pickOrderId);
}, [selectedPickOrderIds]);

// Update the fetch function to use the correct endpoint
const fetchNewPageItems = useCallback(
async (pagingController: Record<string, number>, filterArgs: Record<string, any>) => {
setIsLoadingItems(true);
try {
const params = {
...pagingController,
...filterArgs,
pageNum: (pagingController.pageNum || 1) - 1,
pageSize: pagingController.pageSize || 10,
// Filter for assigned status only
status: "assigned"
};

const res = await fetchPickOrderWithStockClient(params);

if (res && res.records) {
// Convert pick order data to the expected format
const pickOrderRows: PickOrderRow[] = res.records.map((pickOrder: any) => ({
id: pickOrder.id,
code: pickOrder.code,
targetDate: pickOrder.targetDate,
type: pickOrder.type,
status: pickOrder.status,
assignTo: pickOrder.assignTo,
groupName: pickOrder.groupName || "No Group",
consoCode: pickOrder.consoCode,
pickOrderLines: pickOrder.pickOrderLines || []
}));
setOriginalPickOrderData(pickOrderRows);
setFilteredPickOrders(pickOrderRows);
setTotalCountItems(res.total);
} else {
setFilteredPickOrders([]);
setTotalCountItems(0);
}
} catch (error) {
console.error("Error fetching pick orders:", error);
setFilteredPickOrders([]);
setTotalCountItems(0);
} finally {
setIsLoadingItems(false);
}
},
[],
);

// Handle Release operation
// Handle Release operation
const handleRelease = useCallback(async () => {
if (selectedPickOrderIds.length === 0) return;
setIsUploading(true);
try {
// Get the assigned user from the selected pick orders
const selectedPickOrders = filteredPickOrders.filter(pickOrder =>
selectedPickOrderIds.includes(pickOrder.id)
);
// Check if all selected pick orders have the same assigned user
const assignedUsers = selectedPickOrders.map(po => po.assignTo).filter(Boolean);
if (assignedUsers.length === 0) {
alert("Selected pick orders are not assigned to any user.");
return;
}
const assignToValue = assignedUsers[0];
// Validate that all pick orders are assigned to the same user
const allSameUser = assignedUsers.every(userId => userId === assignToValue);
if (!allSameUser) {
alert("All selected pick orders must be assigned to the same user.");
return;
}
console.log("Using assigned user:", assignToValue);
console.log("selectedPickOrderIds:", selectedPickOrderIds);
const releaseRes = await releaseAssignedPickOrders({
pickOrderIds: selectedPickOrderIds.map(id => parseInt(id)),
assignTo: assignToValue
});
if (releaseRes.code === "SUCCESS") {
console.log("Pick orders released successfully");
// Get the consoCode from the response
const consoCode = (releaseRes.entity as any)?.consoCode;
if (consoCode) {
// Create StockOutLine records for each pick order line
for (const pickOrder of selectedPickOrders) {
for (const line of pickOrder.pickOrderLines) {
try {
const stockOutLineData = {
consoCode: consoCode,
pickOrderLineId: line.id,
inventoryLotLineId: 0, // This will be set when user scans QR code
qty: line.requiredQty,
};
console.log("Creating stock out line:", stockOutLineData);
await createStockOutLine(stockOutLineData);
} catch (error) {
console.error("Error creating stock out line for line", line.id, error);
}
}
}
}
fetchNewPageItems(pagingController, filterArgs);
} else {
console.error("Release failed:", releaseRes.message);
}
} catch (error) {
console.error("Error releasing pick orders:", error);
} finally {
setIsUploading(false);
}
}, [selectedPickOrderIds, filteredPickOrders, setIsUploading, fetchNewPageItems, pagingController, filterArgs]);

// Update search criteria to match the new data structure
const searchCriteria: Criterion<any>[] = useMemo(
() => [
{
label: t("Pick Order Code"),
paramName: "code",
type: "text",
},
{
label: t("Group Code"),
paramName: "groupName",
type: "text",
},
{
label: t("Target Date From"),
label2: t("Target Date To"),
paramName: "targetDate",
type: "dateRange",
},
],
[t],
);

// Update search function to work with pick order data
const handleSearch = useCallback((query: Record<string, any>) => {
setSearchQuery({ ...query });

const filtered = originalPickOrderData.filter((pickOrder) => {
const pickOrderTargetDateStr = arrayToDayjs(pickOrder.targetDate);

const codeMatch = !query.code ||
pickOrder.code?.toLowerCase().includes((query.code || "").toLowerCase());
const groupNameMatch = !query.groupName ||
pickOrder.groupName?.toLowerCase().includes((query.groupName || "").toLowerCase());
// Date range search
let dateMatch = true;
if (query.targetDate || query.targetDateTo) {
try {
if (query.targetDate && !query.targetDateTo) {
const fromDate = dayjs(query.targetDate);
dateMatch = pickOrderTargetDateStr.isSame(fromDate, 'day') ||
pickOrderTargetDateStr.isAfter(fromDate, 'day');
} else if (!query.targetDate && query.targetDateTo) {
const toDate = dayjs(query.targetDateTo);
dateMatch = pickOrderTargetDateStr.isSame(toDate, 'day') ||
pickOrderTargetDateStr.isBefore(toDate, 'day');
} else if (query.targetDate && query.targetDateTo) {
const fromDate = dayjs(query.targetDate);
const toDate = dayjs(query.targetDateTo);
dateMatch = (pickOrderTargetDateStr.isSame(fromDate, 'day') ||
pickOrderTargetDateStr.isAfter(fromDate, 'day')) &&
(pickOrderTargetDateStr.isSame(toDate, 'day') ||
pickOrderTargetDateStr.isBefore(toDate, 'day'));
}
} catch (error) {
console.error("Date parsing error:", error);
dateMatch = true;
}
}

return codeMatch && groupNameMatch && dateMatch;
});
setFilteredPickOrders(filtered);
}, [originalPickOrderData]);

const handleReset = useCallback(() => {
setSearchQuery({});
setFilteredPickOrders(originalPickOrderData);
setTimeout(() => {
setSearchQuery({});
}, 0);
}, [originalPickOrderData]);

// Pagination handlers
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
setPagingController(newPagingController);
}, [pagingController]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1,
pageSize: newPageSize,
};
setPagingController(newPagingController);
}, []);

// Component mount effect
useEffect(() => {
fetchNewPageItems(pagingController, filterArgs || {});
}, []);

// Dependencies change effect
useEffect(() => {
if (pagingController && (filterArgs || {})) {
fetchNewPageItems(pagingController, filterArgs || {});
}
}, [pagingController, filterArgs, fetchNewPageItems]);

useEffect(() => {
const loadUsernameList = async () => {
try {
const res = await fetchNameList();
if (res) {
setUsernameList(res);
}
} catch (error) {
console.error("Error loading username list:", error);
}
};
loadUsernameList();
}, []);

// Update the table component to work with pick order data directly
const CustomPickOrderTable = () => {
// Helper function to get user name
const getUserName = useCallback((assignToId: number | null | undefined) => {
if (!assignToId) return '-';
const user = usernameList.find(u => u.id === assignToId);
return user ? user.name : `User ${assignToId}`;
}, [usernameList]);

return (
<>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Selected")}</TableCell>
<TableCell>{t("Pick Order Code")}</TableCell>
<TableCell>{t("Group Code")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell align="right">{t("Order Quantity")}</TableCell>
<TableCell align="right">{t("Current Stock")}</TableCell>
<TableCell align="right">{t("Stock Unit")}</TableCell>
<TableCell>{t("Target Date")}</TableCell>
<TableCell>{t("Assigned To")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredPickOrders.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")}
</Typography>
</TableCell>
</TableRow>
) : (
filteredPickOrders.map((pickOrder) => (
pickOrder.pickOrderLines.map((line: PickOrderLineRow, index: number) => (
<TableRow key={`${pickOrder.id}-${line.id}`}>
{/* Checkbox - only show for first line of each pick order */}
<TableCell>
{index === 0 ? (
<Checkbox
checked={isPickOrderSelected(pickOrder.id)}
onChange={(e) => handlePickOrderSelect(pickOrder.id, e.target.checked)}
disabled={!isEmpty(pickOrder.consoCode)}
/>
) : null}
</TableCell>
{/* Pick Order Code - only show for first line */}
<TableCell>
{index === 0 ? pickOrder.code : null}
</TableCell>
{/* Group Name - only show for first line */}
<TableCell>
{index === 0 ? pickOrder.groupName : null}
</TableCell>
{/* Item Code */}
<TableCell>{line.itemCode}</TableCell>
{/* Item Name */}
<TableCell>{line.itemName}</TableCell>
{/* Order Quantity */}
<TableCell align="right">{line.requiredQty}</TableCell>
{/* Current Stock */}
<TableCell align="right">
<Typography
variant="body2"
color={line.availableQty && line.availableQty > 0 ? "success.main" : "error.main"}
sx={{ fontWeight: line.availableQty && line.availableQty > 0 ? 'bold' : 'normal' }}
>
{(line.availableQty || 0).toLocaleString()}
</Typography>
</TableCell>
{/* Unit */}
<TableCell align="right">{line.uomDesc}</TableCell>
{/* Target Date - only show for first line */}
<TableCell>
{index === 0 ? (
arrayToDayjs(pickOrder.targetDate)
.add(-1, "month")
.format(OUTPUT_DATE_FORMAT)
) : null}
</TableCell>
{/* Assigned To - only show for first line */}
<TableCell>
{index === 0 ? (
<Typography variant="body2">
{getUserName(pickOrder.assignTo)}
</Typography>
) : null}
</TableCell>
</TableRow>
))
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCountItems || 0}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50, 100]}
labelRowsPerPage={t("Rows per page")}
labelDisplayedRows={({ from, to, count }) =>
`${from}-${to} of ${count !== -1 ? count : `more than ${to}`}`
}
/>
</>
);
};

return (
<>
<SearchBox criteria={searchCriteria} onSearch={handleSearch} onReset={handleReset} />
<Grid container rowGap={1}>
<Grid item xs={12}>
{isLoadingItems ? (
<CircularProgress size={40} />
) : (
<CustomPickOrderTable />
)}
</Grid>
<Grid item xs={12}>
<Box sx={{ display: "flex", justifyContent: "flex-start", mt: 2 }}>
<Button
disabled={selectedPickOrderIds.length < 1}
variant="outlined"
onClick={handleRelease}
>
{t("Release")}
</Button>
</Box>
</Grid>
</Grid>
</>
);
};

export default AssignTo;

+ 78
- 0
src/components/FinishedGoodSearch/dummyQcTemplate.tsx Просмотреть файл

@@ -0,0 +1,78 @@
import { PutAwayLine } from "@/app/api/po/actions"

export interface QcData {
id: number,
qcItem: string,
qcDescription: string,
isPassed: boolean | undefined
failedQty: number | undefined
remarks: string | undefined
}

export const dummyQCData: QcData[] = [
{
id: 1,
qcItem: "包裝",
qcDescription: "有破爛、污糟、脹袋、積水、與實物不符等任何一種情況,則不合格",
isPassed: undefined,
failedQty: undefined,
remarks: undefined,
},
{
id: 2,
qcItem: "肉質",
qcDescription: "肉質鬆散,則不合格",
isPassed: undefined,
failedQty: undefined,
remarks: undefined,
},
{
id: 3,
qcItem: "顔色",
qcDescription: "不是食材應有的顔色、顔色不均匀、出現其他顔色、腌料/醬顔色不均匀,油脂部分變綠色、黃色,",
isPassed: undefined,
failedQty: undefined,
remarks: undefined,
},
{
id: 4,
qcItem: "狀態",
qcDescription: "有結晶、結霜、解凍跡象、發霉、散發異味等任何一種情況,則不合格",
isPassed: undefined,
failedQty: undefined,
remarks: undefined,
},
{
id: 5,
qcItem: "異物",
qcDescription: "有不屬於本食材的雜質,則不合格",
isPassed: undefined,
failedQty: undefined,
remarks: undefined,
},
]

export interface EscalationData {
id: number,
escalation: string,
supervisor: string,
}


export const dummyEscalationHistory: EscalationData[] = [
{
id: 1,
escalation: "上報1",
supervisor: "陳大文"
},
]

export const dummyPutawayLine: PutAwayLine[] = [
{
id: 1,
qty: 100,
warehouseId: 1,
warehouse: "W001 - 憶兆 3樓A倉",
printQty: 100
}
]

+ 1
- 0
src/components/FinishedGoodSearch/index.ts Просмотреть файл

@@ -0,0 +1 @@
export { default } from "./FinishedGoodSearchWrapper";

+ 2234
- 0
src/components/FinishedGoodSearch/newcreatitem copy.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 2054
- 0
src/components/FinishedGoodSearch/newcreatitem.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 380
- 0
src/components/FinishedGoodSearch/pickorderModelVer2.tsx Просмотреть файл

@@ -0,0 +1,380 @@
"use client";
// 修改为 PickOrder 相关的导入
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
import {
Box,
Button,
Grid,
Modal,
ModalProps,
Stack,
Typography,
} from "@mui/material";
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StockInFormVer2 from "./StockInFormVer2";
import QcFormVer2 from "./QcFormVer2";
import PutawayForm from "./PutawayForm";
import { dummyPutawayLine, dummyQCData, QcData } from "./dummyQcTemplate";
import { useGridApiRef } from "@mui/x-data-grid";
import {submitDialogWithWarning} from "../Swal/CustomAlerts";

const style = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
bgcolor: "background.paper",
pt: 5,
px: 5,
pb: 10,
display: "block",
width: { xs: "60%", sm: "60%", md: "60%" },
// height: { xs: "60%", sm: "60%", md: "60%" },
};
// 修改接口定义
interface CommonProps extends Omit<ModalProps, "children"> {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
setItemDetail: Dispatch<
SetStateAction<
| (GetPickOrderLineInfo & {
pickOrderCode: string;
warehouseId?: number;
})
| undefined
>
>;
qc?: QcItemWithChecks[];
warehouse?: any[];
}

interface Props extends CommonProps {
itemDetail: GetPickOrderLineInfo & {
pickOrderCode: string;
qcResult?: PurchaseQcResult[]
};
}

// 修改组件名称
const PickQcStockInModalVer2: React.FC<Props> = ({
open,
onClose,
itemDetail,
setItemDetail,
qc,
warehouse,
}) => {
console.log(warehouse);
// 修改翻译键
const {
t,
i18n: { language },
} = useTranslation("pickOrder");
const [qcItems, setQcItems] = useState(dummyQCData)
const formProps = useForm<any>({
defaultValues: {
...itemDetail,
putAwayLine: dummyPutawayLine,
// receiptDate: itemDetail.receiptDate || dayjs().add(-1, "month").format(INPUT_DATE_FORMAT),
// warehouseId: itemDetail.defaultWarehouseId || 0
},
});
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
(...args) => {
onClose?.(...args);
// reset();
},
[onClose],
);
const [openPutaway, setOpenPutaway] = useState(false);
const onOpenPutaway = useCallback(() => {
setOpenPutaway(true);
}, []);
const onClosePutaway = useCallback(() => {
setOpenPutaway(false);
}, []);
// Stock In submission handler
const onSubmitStockIn = useCallback<SubmitHandler<any>>(
async (data, event) => {
console.log("Stock In Submission:", event!.nativeEvent);
// Extract only stock-in related fields
const stockInData = {
// quantity: data.quantity,
// receiptDate: data.receiptDate,
// batchNumber: data.batchNumber,
// expiryDate: data.expiryDate,
// warehouseId: data.warehouseId,
// location: data.location,
// unitCost: data.unitCost,
data: data,
// Add other stock-in specific fields from your form
};
console.log("Stock In Data:", stockInData);
// Handle stock-in submission logic here
// e.g., call API, update state, etc.
},
[],
);
// QC submission handler
const onSubmitQc = useCallback<SubmitHandler<any>>(
async (data, event) => {
console.log("QC Submission:", event!.nativeEvent);
// Get QC data from the shared form context
const qcAccept = data.qcAccept;
const acceptQty = data.acceptQty;
// Validate QC data
const validationErrors : string[] = [];
// Check if all QC items have results
const itemsWithoutResult = qcItems.filter(item => item.isPassed === undefined);
if (itemsWithoutResult.length > 0) {
validationErrors.push(`${t("QC items without result")}: ${itemsWithoutResult.map(item => item.qcItem).join(', ')}`);
}

// Check if failed items have failed quantity
const failedItemsWithoutQty = qcItems.filter(item =>
item.isPassed === false && (!item.failedQty || item.failedQty <= 0)
);
if (failedItemsWithoutQty.length > 0) {
validationErrors.push(`${t("Failed items must have failed quantity")}: ${failedItemsWithoutQty.map(item => item.qcItem).join(', ')}`);
}

// Check if QC accept decision is made
// if (qcAccept === undefined) {
// validationErrors.push("QC accept/reject decision is required");
// }

// Check if accept quantity is valid
if (acceptQty === undefined || acceptQty <= 0) {
validationErrors.push("Accept quantity must be greater than 0");
}

if (validationErrors.length > 0) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
return;
}

const qcData = {
qcAccept: qcAccept,
acceptQty: acceptQty,
qcItems: qcItems.map(item => ({
id: item.id,
qcItem: item.qcItem,
qcDescription: item.qcDescription,
isPassed: item.isPassed,
failedQty: (item.failedQty && !item.isPassed) || 0,
remarks: item.remarks || ''
}))
};
// const qcData = data;

console.log("QC Data for submission:", qcData);
// await submitQcData(qcData);

if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) {
submitDialogWithWarning(onOpenPutaway, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
return;
}

if (qcData.qcAccept) {
onOpenPutaway();
} else {
onClose();
}
},
[onOpenPutaway, qcItems],
);
// Email supplier handler
const onSubmitEmailSupplier = useCallback<SubmitHandler<any>>(
async (data, event) => {
console.log("Email Supplier Submission:", event!.nativeEvent);
// Extract only email supplier related fields
const emailData = {
// supplierEmail: data.supplierEmail,
// issueDescription: data.issueDescription,
// qcComments: data.qcComments,
// defectNotes: data.defectNotes,
// attachments: data.attachments,
// escalationReason: data.escalationReason,
data: data,

// Add other email-specific fields
};
console.log("Email Supplier Data:", emailData);
// Handle email supplier logic here
// e.g., send email to supplier, log escalation, etc.
},
[],
);
// Putaway submission handler
const onSubmitPutaway = useCallback<SubmitHandler<any>>(
async (data, event) => {
console.log("Putaway Submission:", event!.nativeEvent);
// Extract only putaway related fields
const putawayData = {
// putawayLine: data.putawayLine,
// putawayLocation: data.putawayLocation,
// binLocation: data.binLocation,
// putawayQuantity: data.putawayQuantity,
// putawayNotes: data.putawayNotes,
data: data,

// Add other putaway specific fields
};
console.log("Putaway Data:", putawayData);
// Handle putaway submission logic here
// Close modal after successful putaway
closeHandler({}, "backdropClick");
},
[closeHandler],
);
// Print handler
const onPrint = useCallback(() => {
console.log("Print putaway documents");
// Handle print logic here
window.print();
}, []);
const acceptQty = formProps.watch("acceptedQty")

const checkQcIsPassed = useCallback((qcItems: QcData[]) => {
const isPassed = qcItems.every((qc) => qc.isPassed);
console.log(isPassed)
if (isPassed) {
formProps.setValue("passingQty", acceptQty)
} else {
formProps.setValue("passingQty", 0)
}
return isPassed
}, [acceptQty, formProps])

useEffect(() => {
// maybe check if submitted before
console.log(qcItems)
checkQcIsPassed(qcItems)
}, [qcItems, checkQcIsPassed])

return (
<>
<FormProvider {...formProps}>
<Modal open={open} onClose={closeHandler}>
<Box
sx={{
...style,
padding: 2,
maxHeight: "90vh",
overflowY: "auto",
marginLeft: 3,
marginRight: 3,
}}
>
{openPutaway ? (
<Box
component="form"
onSubmit={formProps.handleSubmit(onSubmitPutaway)}
>
<PutawayForm
itemDetail={itemDetail}
warehouse={warehouse!}
disabled={false}
/>
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
id="printButton"
type="button"
variant="contained"
color="primary"
sx={{ mt: 1 }}
onClick={onPrint}
>
{t("print")}
</Button>
<Button
id="putawaySubmit"
type="submit"
variant="contained"
color="primary"
sx={{ mt: 1 }}
>
{t("confirm putaway")}
</Button>
</Stack>
</Box>
) : (
<>
<Grid
container
justifyContent="flex-start"
alignItems="flex-start"
>
<Grid item xs={12}>
<Typography variant="h6" display="block" marginBlockEnd={1}>
{t("qc processing")}
</Typography>
</Grid>
<Grid item xs={12}>
<StockInFormVer2 itemDetail={itemDetail} disabled={false} />
</Grid>
</Grid>
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
id="stockInSubmit"
type="button"
variant="contained"
color="primary"
onClick={formProps.handleSubmit(onSubmitStockIn)}
>
{t("submitStockIn")}
</Button>
</Stack>
<Grid
container
justifyContent="flex-start"
alignItems="flex-start"
>
<QcFormVer2
qc={qc!}
itemDetail={itemDetail}
disabled={false}
qcItems={qcItems}
setQcItems={setQcItems}
/>
</Grid>
<Stack direction="row" justifyContent="flex-end" gap={1}>
<Button
id="emailSupplier"
type="button"
variant="contained"
color="primary"
sx={{ mt: 1 }}
onClick={formProps.handleSubmit(onSubmitEmailSupplier)}
>
{t("email supplier")}
</Button>
<Button
id="qcSubmit"
type="button"
variant="contained"
color="primary"
sx={{ mt: 1 }}
onClick={formProps.handleSubmit(onSubmitQc)}
>
{t("confirm putaway")}
</Button>
</Stack>
</>
)}
</Box>
</Modal>
</FormProvider>
</>
);
};
export default PickQcStockInModalVer2;

+ 5
- 0
src/components/NavigationContent/NavigationContent.tsx Просмотреть файл

@@ -84,6 +84,11 @@ const NavigationContent: React.FC = () => {
label: "Put Away Scan",
path: "/putAway",
},
{
icon: <RequestQuote />,
label: "Finished Good",
path: "/finishedGood",
},
],
},
// {


+ 11
- 3
src/components/PickOrderSearch/PickExecution.tsx Просмотреть файл

@@ -70,6 +70,8 @@ import { dummyQCData } from "../PoDetail/dummyQcTemplate";
import { CreateStockOutLine } from "@/app/api/pickOrder/actions";
import LotTable from './LotTable';
import { updateInventoryLotLineStatus, updateInventoryStatus, updateInventoryLotLineQuantities } from "@/app/api/inventory/actions";
import { useSession } from "next-auth/react"; // ✅ Add session import
import { SessionWithTokens } from "@/config/authConfig"; // ✅ Add custom session type

interface Props {
filterArgs: Record<string, any>;
@@ -101,6 +103,11 @@ interface PickQtyData {
const PickExecution: React.FC<Props> = ({ filterArgs }) => {
const { t } = useTranslation("pickOrder");
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null }; // ✅ Add session
// ✅ Get current user ID from session with proper typing
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const [filteredPickOrders, setFilteredPickOrders] = useState(
[] as ConsoPickOrderResult[],
);
@@ -252,10 +259,11 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => {
const handleFetchAllPickOrderDetails = useCallback(async () => {
setDetailLoading(true);
try {
const data = await fetchAllPickOrderDetails();
// ✅ Use current user ID for filtering
const data = await fetchAllPickOrderDetails(currentUserId);
setPickOrderDetails(data);
setOriginalPickOrderData(data); // Store original data for filtering
console.log("All Pick Order Details:", data);
console.log("All Pick Order Details for user:", currentUserId, data);
const initialPickQtyData: PickQtyData = {};
data.pickOrders.forEach((pickOrder: any) => {
@@ -270,7 +278,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => {
} finally {
setDetailLoading(false);
}
}, []);
}, [currentUserId]); // ✅ Add currentUserId as dependency

useEffect(() => {
handleFetchAllPickOrderDetails();


+ 5
- 1
src/i18n/zh/pickOrder.json Просмотреть файл

@@ -183,5 +183,9 @@
"Item lot to be Pick:": "批次貨品提料:",
"Report and Pick another lot": "上報並需重新選擇批號",
"Accept Stock Out": "接受出庫",
"Pick Another Lot": "重新選擇批號"
"Pick Another Lot": "重新選擇批號",
"Lot No": "批號",
"Expiry Date": "到期日",
"Location": "位置"
}

Загрузка…
Отмена
Сохранить