|
|
@@ -0,0 +1,287 @@ |
|
|
|
|
|
"use client"; |
|
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'; |
|
|
|
|
|
import { |
|
|
|
|
|
Box, |
|
|
|
|
|
Typography, |
|
|
|
|
|
Card, |
|
|
|
|
|
CardContent, |
|
|
|
|
|
Stack, |
|
|
|
|
|
Table, |
|
|
|
|
|
TableBody, |
|
|
|
|
|
TableCell, |
|
|
|
|
|
TableContainer, |
|
|
|
|
|
TableHead, |
|
|
|
|
|
TableRow, |
|
|
|
|
|
Paper, |
|
|
|
|
|
CircularProgress, |
|
|
|
|
|
Button, |
|
|
|
|
|
Chip |
|
|
|
|
|
} from '@mui/material'; |
|
|
|
|
|
import { useTranslation } from 'react-i18next'; |
|
|
|
|
|
import dayjs from 'dayjs'; |
|
|
|
|
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; |
|
|
|
|
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; |
|
|
|
|
|
import { DatePicker } from '@mui/x-date-pickers/DatePicker'; |
|
|
|
|
|
import { fetchGoodsReceiptStatusClient, type GoodsReceiptStatusRow } from '@/app/api/dashboard/client'; |
|
|
|
|
|
|
|
|
|
|
|
const REFRESH_MS = 15 * 60 * 1000; |
|
|
|
|
|
|
|
|
|
|
|
const GoodsReceiptStatusNew: React.FC = () => { |
|
|
|
|
|
const { t } = useTranslation("dashboard"); |
|
|
|
|
|
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs()); |
|
|
|
|
|
const [data, setData] = useState<GoodsReceiptStatusRow[]>([]); |
|
|
|
|
|
const [loading, setLoading] = useState<boolean>(true); |
|
|
|
|
|
const [lastUpdated, setLastUpdated] = useState<dayjs.Dayjs | null>(null); |
|
|
|
|
|
const [screenCleared, setScreenCleared] = useState<boolean>(false); |
|
|
|
|
|
|
|
|
|
|
|
const loadData = useCallback(async () => { |
|
|
|
|
|
if (screenCleared) return; |
|
|
|
|
|
try { |
|
|
|
|
|
setLoading(true); |
|
|
|
|
|
const dateParam = selectedDate.format('YYYY-MM-DD'); |
|
|
|
|
|
const result = await fetchGoodsReceiptStatusClient(dateParam); |
|
|
|
|
|
setData(result ?? []); |
|
|
|
|
|
setLastUpdated(dayjs()); |
|
|
|
|
|
} catch (error) { |
|
|
|
|
|
console.error('Error fetching goods receipt status:', error); |
|
|
|
|
|
setData([]); |
|
|
|
|
|
} finally { |
|
|
|
|
|
setLoading(false); |
|
|
|
|
|
} |
|
|
|
|
|
}, [selectedDate, screenCleared]); |
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
|
if (screenCleared) return; |
|
|
|
|
|
loadData(); |
|
|
|
|
|
|
|
|
|
|
|
const refreshInterval = setInterval(() => { |
|
|
|
|
|
loadData(); |
|
|
|
|
|
}, REFRESH_MS); |
|
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(refreshInterval); |
|
|
|
|
|
}, [loadData, screenCleared]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const selectedDateLabel = useMemo(() => { |
|
|
|
|
|
return selectedDate.format('YYYY-MM-DD'); |
|
|
|
|
|
}, [selectedDate]); |
|
|
|
|
|
|
|
|
|
|
|
// Sort rows by supplier code alphabetically (A -> Z) |
|
|
|
|
|
const sortedData = useMemo(() => { |
|
|
|
|
|
return [...data].sort((a, b) => { |
|
|
|
|
|
const codeA = (a.supplierCode || '').toUpperCase(); |
|
|
|
|
|
const codeB = (b.supplierCode || '').toUpperCase(); |
|
|
|
|
|
if (codeA < codeB) return -1; |
|
|
|
|
|
if (codeA > codeB) return 1; |
|
|
|
|
|
return 0; |
|
|
|
|
|
}); |
|
|
|
|
|
}, [data]); |
|
|
|
|
|
|
|
|
|
|
|
const totalStatistics = useMemo(() => { |
|
|
|
|
|
// Overall statistics should count ALL POs, including those hidden from the table |
|
|
|
|
|
const totalReceived = sortedData.reduce((sum, row) => sum + (row.noOfOrdersReceivedAtDock || 0), 0); |
|
|
|
|
|
const totalExpected = sortedData.reduce((sum, row) => sum + (row.expectedNoOfDelivery || 0), 0); |
|
|
|
|
|
return { received: totalReceived, expected: totalExpected }; |
|
|
|
|
|
}, [sortedData]); |
|
|
|
|
|
|
|
|
|
|
|
type StatusKey = 'pending' | 'receiving' | 'accepted'; |
|
|
|
|
|
|
|
|
|
|
|
const getStatusKey = useCallback((row: GoodsReceiptStatusRow): StatusKey => { |
|
|
|
|
|
// Only when the whole PO is processed (all items finished IQC and PO completed) |
|
|
|
|
|
// should we treat it as "accepted" (已收貨). |
|
|
|
|
|
if (row.noOfOrdersReceivedAtDock === 1) { |
|
|
|
|
|
return 'accepted'; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// If some items have been inspected or put away but the order is not fully processed, |
|
|
|
|
|
// treat as "receiving" / "processing". |
|
|
|
|
|
if ((row.noOfItemsInspected ?? 0) > 0 || (row.noOfItemsCompletedPutAwayAtStore ?? 0) > 0) { |
|
|
|
|
|
return 'receiving'; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Otherwise, nothing has started yet -> "pending". |
|
|
|
|
|
return 'pending'; |
|
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
const renderStatusChip = useCallback((row: GoodsReceiptStatusRow) => { |
|
|
|
|
|
const statusKey = getStatusKey(row); |
|
|
|
|
|
const label = t(statusKey); |
|
|
|
|
|
|
|
|
|
|
|
// Color mapping: pending -> red, receiving -> yellow, accepted -> default/green-ish |
|
|
|
|
|
const color = |
|
|
|
|
|
statusKey === 'pending' |
|
|
|
|
|
? 'error' |
|
|
|
|
|
: statusKey === 'receiving' |
|
|
|
|
|
? 'warning' |
|
|
|
|
|
: 'success'; |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<Chip |
|
|
|
|
|
label={label} |
|
|
|
|
|
color={color} |
|
|
|
|
|
size="small" |
|
|
|
|
|
sx={{ |
|
|
|
|
|
minWidth: 64, |
|
|
|
|
|
fontWeight: 500, |
|
|
|
|
|
...(statusKey === 'pending' |
|
|
|
|
|
? { |
|
|
|
|
|
bgcolor: 'error.light', |
|
|
|
|
|
color: 'common.white', |
|
|
|
|
|
} |
|
|
|
|
|
: {}), |
|
|
|
|
|
}} |
|
|
|
|
|
/> |
|
|
|
|
|
); |
|
|
|
|
|
}, [getStatusKey, t]); |
|
|
|
|
|
|
|
|
|
|
|
if (screenCleared) { |
|
|
|
|
|
return ( |
|
|
|
|
|
<Card sx={{ mb: 2 }}> |
|
|
|
|
|
<CardContent> |
|
|
|
|
|
<Stack direction="row" spacing={2} justifyContent="space-between" alignItems="center"> |
|
|
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
|
|
{t("Screen cleared")} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
<Button variant="contained" onClick={() => setScreenCleared(false)}> |
|
|
|
|
|
{t("Restore Screen")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
</CardContent> |
|
|
|
|
|
</Card> |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<Card sx={{ mb: 2 }}> |
|
|
|
|
|
<CardContent> |
|
|
|
|
|
{/* Header */} |
|
|
|
|
|
<Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center" flexWrap="wrap"> |
|
|
|
|
|
<Stack direction="row" spacing={1} alignItems="center"> |
|
|
|
|
|
<Typography variant="body2" sx={{ fontWeight: 600 }}> |
|
|
|
|
|
{t("Date")}: |
|
|
|
|
|
</Typography> |
|
|
|
|
|
<LocalizationProvider dateAdapter={AdapterDayjs}> |
|
|
|
|
|
<DatePicker |
|
|
|
|
|
value={selectedDate} |
|
|
|
|
|
onChange={(value) => { |
|
|
|
|
|
if (!value) return; |
|
|
|
|
|
setSelectedDate(value); |
|
|
|
|
|
}} |
|
|
|
|
|
slotProps={{ |
|
|
|
|
|
textField: { |
|
|
|
|
|
size: "small", |
|
|
|
|
|
sx: { minWidth: 160 } |
|
|
|
|
|
} |
|
|
|
|
|
}} |
|
|
|
|
|
/> |
|
|
|
|
|
</LocalizationProvider> |
|
|
|
|
|
<Typography variant="body2" sx={{ ml: 1 }}> |
|
|
|
|
|
訂單已處理: {totalStatistics.received}/{totalStatistics.expected} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
|
|
|
|
|
|
<Box sx={{ flexGrow: 1 }} /> |
|
|
|
|
|
|
|
|
|
|
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}> |
|
|
|
|
|
{t("Auto-refresh every 15 minutes")} | {t("Last updated")}: {lastUpdated ? lastUpdated.format('HH:mm:ss') : '--:--:--'} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
|
|
|
|
|
|
<Button variant="outlined" color="inherit" onClick={() => setScreenCleared(true)}> |
|
|
|
|
|
{t("Exit Screen")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
|
|
|
|
|
|
{/* Table */} |
|
|
|
|
|
<Box sx={{ mt: 2 }}> |
|
|
|
|
|
{loading ? ( |
|
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}> |
|
|
|
|
|
<CircularProgress /> |
|
|
|
|
|
</Box> |
|
|
|
|
|
) : ( |
|
|
|
|
|
<TableContainer component={Paper}> |
|
|
|
|
|
<Table |
|
|
|
|
|
size="small" |
|
|
|
|
|
sx={{ |
|
|
|
|
|
minWidth: 560, |
|
|
|
|
|
tableLayout: 'fixed', |
|
|
|
|
|
}} |
|
|
|
|
|
> |
|
|
|
|
|
<TableHead> |
|
|
|
|
|
<TableRow sx={{ backgroundColor: 'grey.100' }}> |
|
|
|
|
|
<TableCell sx={{ fontWeight: 600, width: '32%', padding: '4px 6px' }}>{t("Supplier")}</TableCell> |
|
|
|
|
|
<TableCell sx={{ fontWeight: 600, width: '30%', padding: '4px 6px' }}>{t("Purchase Order Code")}</TableCell> |
|
|
|
|
|
<TableCell sx={{ fontWeight: 600, width: '14%', padding: '4px 4px' }}>{t("Status")}</TableCell> |
|
|
|
|
|
<TableCell sx={{ fontWeight: 600, width: '24%', padding: '4px 4px' }} align="right">{t("No. of Items with IQC Issue")}</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
</TableHead> |
|
|
|
|
|
<TableBody> |
|
|
|
|
|
{sortedData.length === 0 ? ( |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell colSpan={4} align="center"> |
|
|
|
|
|
<Typography variant="body2" color="text.secondary"> |
|
|
|
|
|
{t("No data available")} ({selectedDateLabel}) |
|
|
|
|
|
</Typography> |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
) : ( |
|
|
|
|
|
sortedData |
|
|
|
|
|
.filter((row) => !row.hideFromDashboard) // hide completed/rejected POs from table only |
|
|
|
|
|
.map((row, index) => ( |
|
|
|
|
|
<TableRow |
|
|
|
|
|
key={`${row.supplierId ?? 'na'}-${index}`} |
|
|
|
|
|
sx={{ |
|
|
|
|
|
'&:hover': { backgroundColor: 'grey.50' } |
|
|
|
|
|
}} |
|
|
|
|
|
> |
|
|
|
|
|
<TableCell sx={{ padding: '4px 6px' }}> |
|
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}> |
|
|
|
|
|
<Typography |
|
|
|
|
|
component="span" |
|
|
|
|
|
variant="body2" |
|
|
|
|
|
sx={{ |
|
|
|
|
|
fontWeight: 500, |
|
|
|
|
|
minWidth: '60px' |
|
|
|
|
|
}} |
|
|
|
|
|
> |
|
|
|
|
|
{row.supplierCode || '-'} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
<Typography |
|
|
|
|
|
component="span" |
|
|
|
|
|
variant="body2" |
|
|
|
|
|
sx={{ color: 'text.secondary',ml: 0.5, mr: 1 }} |
|
|
|
|
|
> |
|
|
|
|
|
- |
|
|
|
|
|
</Typography> |
|
|
|
|
|
<Typography |
|
|
|
|
|
component="span" |
|
|
|
|
|
variant="body2" |
|
|
|
|
|
> |
|
|
|
|
|
{row.supplierName || '-'} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
</Box> |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
<TableCell sx={{ padding: '4px 6px' }}> |
|
|
|
|
|
{row.purchaseOrderCode || '-'} |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
<TableCell sx={{ padding: '4px 4px' }}> |
|
|
|
|
|
{renderStatusChip(row)} |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
<TableCell sx={{ padding: '4px 6px' }} align="right"> |
|
|
|
|
|
{row.noOfItemsWithIqcIssue ?? 0} |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
)) |
|
|
|
|
|
)} |
|
|
|
|
|
</TableBody> |
|
|
|
|
|
</Table> |
|
|
|
|
|
</TableContainer> |
|
|
|
|
|
)} |
|
|
|
|
|
</Box> |
|
|
|
|
|
</CardContent> |
|
|
|
|
|
</Card> |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
export default GoodsReceiptStatusNew; |
|
|
|
|
|
4 |