| @@ -118,4 +118,27 @@ export const fetchBagLotLines = cache(async (bagId: number) => | |||
| export const fetchBagConsumptions = cache(async (bagLotLineId: number) => | |||
| serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" }) | |||
| ); | |||
| ); | |||
| export interface SoftDeleteBagResponse { | |||
| id: number | null; | |||
| code: string | null; | |||
| name: string | null; | |||
| type: string | null; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| entity: any | null; | |||
| } | |||
| export const softDeleteBagByItemId = async (itemId: number): Promise<SoftDeleteBagResponse> => { | |||
| const response = await serverFetchJson<SoftDeleteBagResponse>( | |||
| `${BASE_API_URL}/bag/by-item/${itemId}/soft-delete`, | |||
| { | |||
| method: "PUT", | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ); | |||
| revalidateTag("bagInfo"); | |||
| revalidateTag("bags"); | |||
| return response; | |||
| }; | |||
| @@ -197,9 +197,12 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate: | |||
| ); | |||
| }); | |||
| export const fetchTruckScheduleDashboard = cache(async () => { | |||
| export const fetchTruckScheduleDashboard = cache(async (date?: string) => { | |||
| const url = date | |||
| ? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}` | |||
| : `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`; | |||
| return await serverFetchJson<TruckScheduleDashboardItem[]>( | |||
| `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`, | |||
| url, | |||
| { | |||
| method: "GET", | |||
| } | |||
| @@ -5,8 +5,8 @@ import { | |||
| type TruckScheduleDashboardItem | |||
| } from "./actions"; | |||
| export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => { | |||
| return await fetchTruckScheduleDashboard(); | |||
| export const fetchTruckScheduleDashboardClient = async (date?: string): Promise<TruckScheduleDashboardItem[]> => { | |||
| return await fetchTruckScheduleDashboard(date); | |||
| }; | |||
| export type { TruckScheduleDashboardItem }; | |||
| @@ -45,6 +45,7 @@ export type CreateItemInputs = { | |||
| isEgg?: boolean | undefined; | |||
| isFee?: boolean | undefined; | |||
| isBag?: boolean | undefined; | |||
| qcType?: string | undefined; | |||
| }; | |||
| export const saveItem = async (data: CreateItemInputs) => { | |||
| @@ -0,0 +1,28 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { QcItemInfo } from "./index"; | |||
| export const fetchQcItemsByCategoryId = async (categoryId: number): Promise<QcItemInfo[]> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/qcCategories/${categoryId}/items`, { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| }); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error(`Failed to fetch QC items: ${response.status} ${response.statusText}`); | |||
| } | |||
| return response.json(); | |||
| }; | |||
| @@ -17,6 +17,15 @@ export interface QcCategoryCombo { | |||
| label: string; | |||
| } | |||
| export interface QcItemInfo { | |||
| id: number; | |||
| qcItemId: number; | |||
| code: string; | |||
| name?: string; | |||
| order: number; | |||
| description?: string; | |||
| } | |||
| export const preloadQcCategory = () => { | |||
| fetchQcCategories(); | |||
| }; | |||
| @@ -31,6 +31,7 @@ import { saveItemQcChecks } from "@/app/api/settings/qcCheck/actions"; | |||
| import { useGridApiRef } from "@mui/x-data-grid"; | |||
| import { QcCategoryCombo } from "@/app/api/settings/qcCategory"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { softDeleteBagByItemId } from "@/app/api/bag/action"; | |||
| type Props = { | |||
| isEditMode: boolean; | |||
| @@ -173,6 +174,16 @@ const CreateItem: React.FC<Props> = ({ | |||
| ); | |||
| } else if (!Boolean(responseQ.id)) { | |||
| } else if (Boolean(responseI.id) && Boolean(responseQ.id)) { | |||
| // If special type is not "isBag", soft-delete the bag record if it exists | |||
| if (data.isBag !== true && data.id) { | |||
| try { | |||
| const itemId = typeof data.id === "string" ? parseInt(data.id) : data.id; | |||
| await softDeleteBagByItemId(itemId); | |||
| } catch (bagError) { | |||
| // Log error but don't block the save operation | |||
| console.log("Error soft-deleting bag:", bagError); | |||
| } | |||
| } | |||
| router.replace(redirPath); | |||
| } | |||
| } | |||
| @@ -29,8 +29,10 @@ import { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid"; | |||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||
| import { CreateItemInputs } from "@/app/api/settings/item/actions"; | |||
| import { ItemQc } from "@/app/api/settings/item"; | |||
| import { QcCategoryCombo } from "@/app/api/settings/qcCategory"; | |||
| import { QcCategoryCombo, QcItemInfo } from "@/app/api/settings/qcCategory"; | |||
| import { fetchQcItemsByCategoryId } from "@/app/api/settings/qcCategory/client"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import QcItemsList from "./QcItemsList"; | |||
| type Props = { | |||
| // isEditMode: boolean; | |||
| // type: TypeEnum; | |||
| @@ -43,11 +45,13 @@ type Props = { | |||
| }; | |||
| const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => { | |||
| const [qcItems, setQcItems] = useState<QcItemInfo[]>([]); | |||
| const [qcItemsLoading, setQcItemsLoading] = useState(false); | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation(); | |||
| } = useTranslation("items"); | |||
| const { | |||
| register, | |||
| @@ -121,6 +125,30 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||
| } | |||
| }, [initialDefaultValues, setValue, getValues]); | |||
| // Watch qcCategoryId and fetch QC items when it changes | |||
| const qcCategoryId = watch("qcCategoryId"); | |||
| useEffect(() => { | |||
| const fetchItems = async () => { | |||
| if (qcCategoryId) { | |||
| setQcItemsLoading(true); | |||
| try { | |||
| const items = await fetchQcItemsByCategoryId(qcCategoryId); | |||
| setQcItems(items); | |||
| } catch (error) { | |||
| console.error("Failed to fetch QC items:", error); | |||
| setQcItems([]); | |||
| } finally { | |||
| setQcItemsLoading(false); | |||
| } | |||
| } else { | |||
| setQcItems([]); | |||
| } | |||
| }; | |||
| fetchItems(); | |||
| }, [qcCategoryId]); | |||
| return ( | |||
| <Card sx={{ display: "block" }}> | |||
| <CardContent component={Stack} spacing={4}> | |||
| @@ -216,6 +244,26 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| name="qcType" | |||
| render={({ field }) => ( | |||
| <FormControl fullWidth> | |||
| <InputLabel>{t("QC Type")}</InputLabel> | |||
| <Select | |||
| value={field.value || ""} | |||
| label={t("QC Type")} | |||
| onChange={field.onChange} | |||
| onBlur={field.onBlur} | |||
| > | |||
| <MenuItem value="IPQC">{t("IPQC")}</MenuItem> | |||
| <MenuItem value="EPQC">{t("EPQC")}</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| )} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Controller | |||
| control={control} | |||
| @@ -292,6 +340,13 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous | |||
| </RadioGroup> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <QcItemsList | |||
| qcItems={qcItems} | |||
| loading={qcItemsLoading} | |||
| categorySelected={!!qcCategoryId} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Stack | |||
| direction="row" | |||
| @@ -0,0 +1,200 @@ | |||
| "use client"; | |||
| import { QcItemInfo } from "@/app/api/settings/qcCategory"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CircularProgress, | |||
| Divider, | |||
| List, | |||
| ListItem, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { CheckCircleOutline, FormatListNumbered } from "@mui/icons-material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| type Props = { | |||
| qcItems: QcItemInfo[]; | |||
| loading?: boolean; | |||
| categorySelected?: boolean; | |||
| }; | |||
| const QcItemsList: React.FC<Props> = ({ | |||
| qcItems, | |||
| loading = false, | |||
| categorySelected = false, | |||
| }) => { | |||
| const { t } = useTranslation("items"); | |||
| // Sort items by order | |||
| const sortedItems = [...qcItems].sort((a, b) => a.order - b.order); | |||
| if (loading) { | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| py={4} | |||
| sx={{ | |||
| backgroundColor: "grey.50", | |||
| borderRadius: 2, | |||
| border: "1px dashed", | |||
| borderColor: "grey.300", | |||
| }} | |||
| > | |||
| <CircularProgress size={24} sx={{ mr: 1.5 }} /> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Loading QC items...")} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| if (!categorySelected) { | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| flexDirection="column" | |||
| alignItems="center" | |||
| py={4} | |||
| sx={{ | |||
| backgroundColor: "grey.50", | |||
| borderRadius: 2, | |||
| border: "1px dashed", | |||
| borderColor: "grey.300", | |||
| }} | |||
| > | |||
| <FormatListNumbered | |||
| sx={{ fontSize: 40, color: "grey.400", mb: 1 }} | |||
| /> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Select a QC template to view items")} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| if (sortedItems.length === 0) { | |||
| return ( | |||
| <Box | |||
| display="flex" | |||
| flexDirection="column" | |||
| alignItems="center" | |||
| py={4} | |||
| sx={{ | |||
| backgroundColor: "grey.50", | |||
| borderRadius: 2, | |||
| border: "1px dashed", | |||
| borderColor: "grey.300", | |||
| }} | |||
| > | |||
| <CheckCircleOutline | |||
| sx={{ fontSize: 40, color: "grey.400", mb: 1 }} | |||
| /> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No QC items in this template")} | |||
| </Typography> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Card | |||
| variant="outlined" | |||
| sx={{ | |||
| borderRadius: 2, | |||
| backgroundColor: "background.paper", | |||
| overflow: "hidden", | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| px: 2, | |||
| py: 1.5, | |||
| backgroundColor: "primary.main", | |||
| color: "primary.contrastText", | |||
| }} | |||
| > | |||
| <Stack direction="row" alignItems="center" spacing={1}> | |||
| <FormatListNumbered fontSize="small" /> | |||
| <Typography variant="subtitle2" fontWeight={600}> | |||
| {t("QC Checklist")} ({sortedItems.length}) | |||
| </Typography> | |||
| </Stack> | |||
| </Box> | |||
| <List disablePadding> | |||
| {sortedItems.map((item, index) => ( | |||
| <Box key={item.id}> | |||
| {index > 0 && <Divider />} | |||
| <ListItem | |||
| sx={{ | |||
| py: 1.5, | |||
| px: 2, | |||
| "&:hover": { | |||
| backgroundColor: "action.hover", | |||
| }, | |||
| }} | |||
| > | |||
| <Stack | |||
| direction="row" | |||
| spacing={2} | |||
| alignItems="flex-start" | |||
| width="100%" | |||
| > | |||
| {/* Order Number */} | |||
| <Typography | |||
| variant="body1" | |||
| fontWeight={600} | |||
| color="text.secondary" | |||
| sx={{ minWidth: 24 }} | |||
| > | |||
| {item.order}. | |||
| </Typography> | |||
| {/* Content */} | |||
| <Stack | |||
| direction="row" | |||
| alignItems="center" | |||
| spacing={2} | |||
| flex={1} | |||
| minWidth={0} | |||
| > | |||
| <Typography | |||
| variant="body1" | |||
| fontWeight={500} | |||
| sx={{ | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| flexShrink: 0, | |||
| }} | |||
| > | |||
| {item.name || item.code} | |||
| </Typography> | |||
| {item.description && ( | |||
| <Typography | |||
| variant="body2" | |||
| color="text.secondary" | |||
| sx={{ | |||
| overflow: "hidden", | |||
| textOverflow: "ellipsis", | |||
| whiteSpace: "nowrap", | |||
| }} | |||
| > | |||
| {item.description} | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| </Stack> | |||
| </ListItem> | |||
| </Box> | |||
| ))} | |||
| </List> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default QcItemsList; | |||
| @@ -35,6 +35,7 @@ interface CompletedTracker { | |||
| const TruckScheduleDashboard: React.FC = () => { | |||
| const { t } = useTranslation("dashboard"); | |||
| const [selectedStore, setSelectedStore] = useState<string>(""); | |||
| const [selectedDate, setSelectedDate] = useState<string>("today"); | |||
| const [data, setData] = useState<TruckScheduleDashboardItem[]>([]); | |||
| const [loading, setLoading] = useState<boolean>(true); | |||
| // Initialize as null to avoid SSR/client hydration mismatch | |||
| @@ -43,6 +44,23 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| const completedTrackerRef = useRef<Map<string, CompletedTracker>>(new Map()); | |||
| const refreshCountRef = useRef<number>(0); | |||
| // Get date label for display (e.g., "2026-01-17") | |||
| const getDateLabel = (offset: number): string => { | |||
| return dayjs().add(offset, 'day').format('YYYY-MM-DD'); | |||
| }; | |||
| // Convert date option to YYYY-MM-DD format for API | |||
| const getDateParam = (dateOption: string): string => { | |||
| if (dateOption === "today") { | |||
| return dayjs().format('YYYY-MM-DD'); | |||
| } else if (dateOption === "tomorrow") { | |||
| return dayjs().add(1, 'day').format('YYYY-MM-DD'); | |||
| } else if (dateOption === "dayAfterTomorrow") { | |||
| return dayjs().add(2, 'day').format('YYYY-MM-DD'); | |||
| } | |||
| return dayjs().add(1, 'day').format('YYYY-MM-DD'); | |||
| }; | |||
| // Set client flag and time on mount | |||
| useEffect(() => { | |||
| setIsClient(true); | |||
| @@ -136,7 +154,8 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| // Load data from API | |||
| const loadData = useCallback(async () => { | |||
| try { | |||
| const result = await fetchTruckScheduleDashboardClient(); | |||
| const dateParam = getDateParam(selectedDate); | |||
| const result = await fetchTruckScheduleDashboardClient(dateParam); | |||
| // Update completed tracker | |||
| refreshCountRef.current += 1; | |||
| @@ -175,7 +194,7 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| }, [selectedDate]); | |||
| // Initial load and auto-refresh every 5 minutes | |||
| useEffect(() => { | |||
| @@ -183,7 +202,7 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| const refreshInterval = setInterval(() => { | |||
| loadData(); | |||
| }, 5 * 60 * 1000); // 5 minutes | |||
| }, 0.1 * 60 * 1000); // 5 minutes | |||
| return () => clearInterval(refreshInterval); | |||
| }, [loadData]); | |||
| @@ -256,6 +275,23 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| <MenuItem value="4/F">4/F</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <FormControl sx={{ minWidth: 200 }} size="small"> | |||
| <InputLabel id="date-select-label" shrink={true}> | |||
| {t("Select Date")} | |||
| </InputLabel> | |||
| <Select | |||
| labelId="date-select-label" | |||
| id="date-select" | |||
| value={selectedDate} | |||
| label={t("Select Date")} | |||
| onChange={(e) => setSelectedDate(e.target.value)} | |||
| > | |||
| <MenuItem value="today">{t("Today")} ({getDateLabel(0)})</MenuItem> | |||
| <MenuItem value="tomorrow">{t("Tomorrow")} ({getDateLabel(1)})</MenuItem> | |||
| <MenuItem value="dayAfterTomorrow">{t("Day After Tomorrow")} ({getDateLabel(2)})</MenuItem> | |||
| </Select> | |||
| </FormControl> | |||
| <Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}> | |||
| {t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'} | |||
| @@ -290,7 +326,7 @@ const TruckScheduleDashboard: React.FC = () => { | |||
| <TableRow> | |||
| <TableCell colSpan={10} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No truck schedules available for today")} | |||
| {t("No truck schedules available")} ({getDateParam(selectedDate)}) | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| @@ -73,6 +73,11 @@ | |||
| "Last Ticket End": "Last Ticket End", | |||
| "Pick Time (min)": "Pick Time (min)", | |||
| "No truck schedules available for today": "No truck schedules available for today", | |||
| "No truck schedules available": "No truck schedules available", | |||
| "Select Date": "Select Date", | |||
| "Today": "Today", | |||
| "Tomorrow": "Tomorrow", | |||
| "Day After Tomorrow": "Day After Tomorrow", | |||
| "Goods Receipt Status": "Goods Receipt Status", | |||
| "Filter": "Filter", | |||
| "All": "All", | |||
| @@ -9,5 +9,12 @@ | |||
| "Back": "Back", | |||
| "Status": "Status", | |||
| "Complete": "Complete", | |||
| "Missing Data": "Missing Data" | |||
| "Missing Data": "Missing Data", | |||
| "Loading QC items...": "Loading QC items...", | |||
| "Select a QC template to view items": "Select a QC template to view items", | |||
| "No QC items in this template": "No QC items in this template", | |||
| "QC Checklist": "QC Checklist", | |||
| "QC Type": "QC Type", | |||
| "IPQC": "IPQC", | |||
| "EPQC": "EPQC" | |||
| } | |||
| @@ -73,6 +73,11 @@ | |||
| "Last Ticket End": "末單結束時間", | |||
| "Pick Time (min)": "揀貨時間(分鐘)", | |||
| "No truck schedules available for today": "今日無車輛調度計劃", | |||
| "No truck schedules available": "無車輛調度計劃", | |||
| "Select Date": "請選擇日期", | |||
| "Today": "是日", | |||
| "Tomorrow": "翌日", | |||
| "Day After Tomorrow": "後日", | |||
| "Goods Receipt Status": "貨物接收狀態", | |||
| "Filter": "篩選", | |||
| "All": "全部", | |||
| @@ -43,5 +43,12 @@ | |||
| "Back": "返回", | |||
| "Status": "狀態", | |||
| "Complete": "完成", | |||
| "Missing Data": "缺少資料" | |||
| "Missing Data": "缺少資料", | |||
| "Loading QC items...": "正在加載質檢項目...", | |||
| "Select a QC template to view items": "選擇質檢模板以查看項目", | |||
| "No QC items in this template": "此模板無質檢項目", | |||
| "QC Checklist": "質檢項目", | |||
| "QC Type": "質檢種類", | |||
| "IPQC": "IPQC", | |||
| "EPQC": "EPQC" | |||
| } | |||