Ver código fonte

Supporting Function FG Pick Status Dashboard

master
Tommy\2Fi-Staff 3 semanas atrás
pai
commit
1a329b1797
7 arquivos alterados com 540 adições e 2 exclusões
  1. +24
    -0
      src/app/api/do/actions.tsx
  2. +16
    -0
      src/app/api/do/client.ts
  3. +8
    -0
      src/components/DashboardPage/DashboardPage.tsx
  4. +397
    -0
      src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx
  5. +3
    -0
      src/components/DashboardPage/truckSchedule/index.ts
  6. +76
    -1
      src/i18n/en/dashboard.json
  7. +16
    -1
      src/i18n/zh/dashboard.json

+ 24
- 0
src/app/api/do/actions.tsx Ver arquivo

@@ -131,6 +131,21 @@ export interface getTicketReleaseTable {
handlerName: string | null;
numberOfFGItems: number;
}

export interface TruckScheduleDashboardItem {
storeId: string | null;
truckId: number | null;
truckLanceCode: string | null;
truckDepartureTime: string | number[] | null;
numberOfShopsToServe: number;
numberOfPickTickets: number;
totalItemsToPick: number;
numberOfTicketsReleased: number;
firstTicketStartTime: string | number[] | null;
numberOfTicketsCompleted: number;
lastTicketEndTime: string | number[] | null;
pickTimeTakenMinutes: number | null;
}
export interface SearchDeliveryOrderInfoRequest {
code: string;
shopName: string;
@@ -181,6 +196,15 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate:
}
);
});

export const fetchTruckScheduleDashboard = cache(async () => {
return await serverFetchJson<TruckScheduleDashboardItem[]>(
`${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`,
{
method: "GET",
}
);
});
export const startBatchReleaseAsyncSingle = cache(async (data: { doId: number; userId: number }) => {
const { doId, userId } = data;
return await serverFetchJson<{ id: number|null; code: string; entity?: any }>(


+ 16
- 0
src/app/api/do/client.ts Ver arquivo

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

import {
fetchTruckScheduleDashboard,
type TruckScheduleDashboardItem
} from "./actions";

export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => {
return await fetchTruckScheduleDashboard();
};

export type { TruckScheduleDashboardItem };

export default fetchTruckScheduleDashboardClient;



+ 8
- 0
src/components/DashboardPage/DashboardPage.tsx Ver arquivo

@@ -17,6 +17,7 @@ import CollapsibleCard from "../CollapsibleCard";
// import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval";
import { EscalationResult } from "@/app/api/escalation";
import EscalationLogTable from "./escalation/EscalationLogTable";
import { TruckScheduleDashboard } from "./truckSchedule";
type Props = {
// iqc: IQCItems[] | undefined
escalationLogs: EscalationResult[]
@@ -42,6 +43,13 @@ const DashboardPage: React.FC<Props> = ({
return (
<ThemeProvider theme={theme}>
<Grid container spacing={2}>
<Grid item xs={12}>
<CollapsibleCard title={t("Truck Schedule Dashboard")} defaultOpen={true}>
<CardContent>
<TruckScheduleDashboard />
</CardContent>
</CollapsibleCard>
</Grid>
<Grid item xs={12}>
<CollapsibleCard
title={`${t("Responsible Escalation List")} (${t("pending")} : ${


+ 397
- 0
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Ver arquivo

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

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Box,
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
Card,
CardContent,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Chip
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { fetchTruckScheduleDashboardClient, type TruckScheduleDashboardItem } from '@/app/api/do/client';
import { formatDepartureTime, arrayToDayjs } from '@/app/utils/formatUtil';

// Track completed items for hiding after 2 refresh cycles
interface CompletedTracker {
key: string;
refreshCount: number;
}

const TruckScheduleDashboard: React.FC = () => {
const { t } = useTranslation("dashboard");
const [selectedStore, setSelectedStore] = useState<string>("");
const [data, setData] = useState<TruckScheduleDashboardItem[]>([]);
const [loading, setLoading] = useState<boolean>(true);
// Initialize as null to avoid SSR/client hydration mismatch
const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null);
const [isClient, setIsClient] = useState<boolean>(false);
const completedTrackerRef = useRef<Map<string, CompletedTracker>>(new Map());
const refreshCountRef = useRef<number>(0);
// Set client flag and time on mount
useEffect(() => {
setIsClient(true);
setCurrentTime(dayjs());
}, []);

// Format time from array or string to HH:mm
const formatTime = (timeData: string | number[] | null): string => {
if (!timeData) return '-';
if (Array.isArray(timeData)) {
if (timeData.length >= 2) {
const hour = timeData[0] || 0;
const minute = timeData[1] || 0;
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
}
return '-';
}
if (typeof timeData === 'string') {
const parts = timeData.split(':');
if (parts.length >= 2) {
const hour = parseInt(parts[0], 10);
const minute = parseInt(parts[1], 10);
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
}
}
return '-';
};

// Format datetime from array or string
const formatDateTime = (dateTimeData: string | number[] | null): string => {
if (!dateTimeData) return '-';
if (Array.isArray(dateTimeData)) {
return arrayToDayjs(dateTimeData, true).format('HH:mm');
}
const parsed = dayjs(dateTimeData);
if (parsed.isValid()) {
return parsed.format('HH:mm');
}
return '-';
};

// Calculate time remaining for truck departure
const calculateTimeRemaining = useCallback((departureTime: string | number[] | null): string => {
if (!departureTime || !currentTime) return '-';
const now = currentTime;
let departureHour: number;
let departureMinute: number;
if (Array.isArray(departureTime)) {
if (departureTime.length < 2) return '-';
departureHour = departureTime[0] || 0;
departureMinute = departureTime[1] || 0;
} else if (typeof departureTime === 'string') {
const parts = departureTime.split(':');
if (parts.length < 2) return '-';
departureHour = parseInt(parts[0], 10);
departureMinute = parseInt(parts[1], 10);
} else {
return '-';
}
// Create departure datetime for today
const departure = now.clone().hour(departureHour).minute(departureMinute).second(0);
const diffMinutes = departure.diff(now, 'minute');
if (diffMinutes < 0) {
// Past departure time
const absDiff = Math.abs(diffMinutes);
const hours = Math.floor(absDiff / 60);
const minutes = absDiff % 60;
return `-${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
} else {
const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}
}, [currentTime]);

// Generate unique key for tracking completed items
const getItemKey = (item: TruckScheduleDashboardItem): string => {
return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`;
};

// Load data from API
const loadData = useCallback(async () => {
try {
const result = await fetchTruckScheduleDashboardClient();
// Update completed tracker
refreshCountRef.current += 1;
const currentRefresh = refreshCountRef.current;
result.forEach(item => {
const key = getItemKey(item);
// If all tickets are completed, track it
if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) {
const existing = completedTrackerRef.current.get(key);
if (!existing) {
completedTrackerRef.current.set(key, { key, refreshCount: currentRefresh });
}
} else {
// Remove from tracker if no longer completed
completedTrackerRef.current.delete(key);
}
});
// Filter out items that have been completed for 2+ refresh cycles
const filteredResult = result.filter(item => {
const key = getItemKey(item);
const tracker = completedTrackerRef.current.get(key);
if (tracker) {
// Hide if completed for 2 or more refresh cycles
if (currentRefresh - tracker.refreshCount >= 2) {
return false;
}
}
return true;
});
setData(filteredResult);
} catch (error) {
console.error('Error fetching truck schedule dashboard:', error);
} finally {
setLoading(false);
}
}, []);

// Initial load and auto-refresh every 5 minutes
useEffect(() => {
loadData();
const refreshInterval = setInterval(() => {
loadData();
}, 5 * 60 * 1000); // 5 minutes
return () => clearInterval(refreshInterval);
}, [loadData]);

// Update current time every 1 minute for time remaining calculation
useEffect(() => {
if (!isClient) return;
const timeInterval = setInterval(() => {
setCurrentTime(dayjs());
}, 60 * 1000); // 1 minute
return () => clearInterval(timeInterval);
}, [isClient]);

// Filter data by selected store
const filteredData = useMemo(() => {
if (!selectedStore) return data;
return data.filter(item => item.storeId === selectedStore);
}, [data, selectedStore]);

// Get chip color based on time remaining
const getTimeChipColor = (departureTime: string | number[] | null): "success" | "warning" | "error" | "default" => {
if (!departureTime || !currentTime) return "default";
const now = currentTime;
let departureHour: number;
let departureMinute: number;
if (Array.isArray(departureTime)) {
if (departureTime.length < 2) return "default";
departureHour = departureTime[0] || 0;
departureMinute = departureTime[1] || 0;
} else if (typeof departureTime === 'string') {
const parts = departureTime.split(':');
if (parts.length < 2) return "default";
departureHour = parseInt(parts[0], 10);
departureMinute = parseInt(parts[1], 10);
} else {
return "default";
}
const departure = now.clone().hour(departureHour).minute(departureMinute).second(0);
const diffMinutes = departure.diff(now, 'minute');
if (diffMinutes < 0) return "error"; // Past due
if (diffMinutes <= 30) return "warning"; // Within 30 minutes
return "success"; // More than 30 minutes
};

return (
<Card sx={{ mb: 2 }}>
<CardContent>
{/* Title */}
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
{t("Truck Schedule Dashboard")}
</Typography>

{/* Filter */}
<Stack direction="row" spacing={2} sx={{ mb: 3 }}>
<FormControl sx={{ minWidth: 150 }} size="small">
<InputLabel id="store-select-label" shrink={true}>
{t("Store ID")}
</InputLabel>
<Select
labelId="store-select-label"
id="store-select"
value={selectedStore}
label={t("Store ID")}
onChange={(e) => setSelectedStore(e.target.value)}
displayEmpty
>
<MenuItem value="">{t("All Stores")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="4/F">4/F</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') : '--:--:--'}
</Typography>
</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: 1200 }}>
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell sx={{ fontWeight: 600 }}>{t("Store ID")}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{t("Truck Schedule")}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{t("Time Remaining")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Shops")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Total Items")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Released")}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{t("First Ticket Start")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Tickets Completed")}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{t("Last Ticket End")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Pick Time (min)")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredData.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center">
<Typography variant="body2" color="text.secondary">
{t("No truck schedules available for today")}
</Typography>
</TableCell>
</TableRow>
) : (
filteredData.map((row, index) => {
const timeRemaining = calculateTimeRemaining(row.truckDepartureTime);
const chipColor = getTimeChipColor(row.truckDepartureTime);
return (
<TableRow
key={`${row.storeId}-${row.truckLanceCode}-${index}`}
sx={{
'&:hover': { backgroundColor: 'grey.50' },
backgroundColor: row.numberOfPickTickets > 0 && row.numberOfTicketsCompleted >= row.numberOfPickTickets
? 'success.light'
: 'inherit'
}}
>
<TableCell>
<Chip
label={row.storeId || '-'}
size="small"
color={row.storeId === '2/F' ? 'primary' : 'secondary'}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{row.truckLanceCode || '-'}
</Typography>
<Typography variant="caption" color="text.secondary">
ETD: {formatTime(row.truckDepartureTime)}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={timeRemaining}
size="small"
color={chipColor}
sx={{ fontWeight: 600 }}
/>
</TableCell>
<TableCell align="center">
<Typography variant="body2">
{row.numberOfShopsToServe} [{row.numberOfPickTickets}]
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{row.totalItemsToPick}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={row.numberOfTicketsReleased}
size="small"
color={row.numberOfTicketsReleased > 0 ? 'info' : 'default'}
/>
</TableCell>
<TableCell>
{formatDateTime(row.firstTicketStartTime)}
</TableCell>
<TableCell align="center">
<Chip
label={row.numberOfTicketsCompleted}
size="small"
color={row.numberOfTicketsCompleted > 0 ? 'success' : 'default'}
/>
</TableCell>
<TableCell>
{formatDateTime(row.lastTicketEndTime)}
</TableCell>
<TableCell align="center">
<Typography
variant="body2"
sx={{
fontWeight: 500,
color: row.pickTimeTakenMinutes !== null ? 'text.primary' : 'text.secondary'
}}
>
{row.pickTimeTakenMinutes !== null ? row.pickTimeTakenMinutes : '-'}
</Typography>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</CardContent>
</Card>
);
};

export default TruckScheduleDashboard;

+ 3
- 0
src/components/DashboardPage/truckSchedule/index.ts Ver arquivo

@@ -0,0 +1,3 @@
export { default as TruckScheduleDashboard } from './TruckScheduleDashboard';



+ 76
- 1
src/i18n/en/dashboard.json Ver arquivo

@@ -1 +1,76 @@
{}
{
"Dashboard": "Dashboard",
"Order status": "Order status",
"pending": "pending",
"receiving": "receiving",
"total": "total",
"Warehouse temperature record": "Warehouse temperature record",
"Warehouse type": "Warehouse type",
"Last 6 hours": "Last 6 hours",
"Add some entries!": "Add some entries!",
"Last 24 hours": "Last 24 hours",
"Cold storage": "Cold storage",
"Normal temperature storage": "Normal temperature storage",
"Temperature status": "Temperature status",
"Humidity status": "Humidity status",
"Warehouse status": "Warehouse status",
"Progress chart": "Progress chart",
"Purchase Order Code": "Purchase Order Code",
"Item Name": "Item Name",
"Escalation Level": "Escalation Level",
"Reason": "Reason",
"escalated date": "escalated date",
"Order completion": "Order completion",
"Store Management": "Store Management",
"Consumable": "Consumable",
"Shipment": "Shipment",
"Extracted order": "Extracted order",
"Pending order": "Pending order",
"Temperature": "Temperature",
"Humidity": "Humidity",
"Pending storage": "Pending storage",
"Total storage": "Total storage",
"Application completion": "Application completion",
"Processed application": "Processed application",
"Pending application": "Pending application",
"pending inspection material": "pending inspection material",
"rejected": "rejected",
"accepted": "accepted",
"escalated": "escalated",
"inspected material": "inspected material",
"total material": "total material",
"stock in escalation list": "stock in escalation list",
"Responsible for handling colleagues": "Responsible for handling colleagues",
"Completed QC Total": "Completed QC Total",
"QC Fail Count": "QC Fail Count",
"DN Date": "DN Date",
"Received Qty": "Received Qty",
"Po Code": "Po Code",
"My Escalation List": "My Escalation List",
"Escalation List": "Escalation List",
"Purchase UoM": "Purchase UoM",
"QC Completed Count": "QC Completed Count",
"QC Fail-Total Count": "QC Fail-Total Count",
"escalationStatus": "escalationStatus",
"escalated datetime": "escalated datetime",
"escalateFrom": "escalateFrom",
"No": "No",
"Responsible Escalation List": "Responsible Escalation List",
"show completed logs": "show completed logs",
"Rows per page": "Rows per page",
"Truck Schedule Dashboard": "Truck Schedule Dashboard",
"Store ID": "Store ID",
"All Stores": "All Stores",
"Auto-refresh every 5 minutes": "Auto-refresh every 5 minutes",
"Last updated": "Last updated",
"Truck Schedule": "Truck Schedule",
"Time Remaining": "Time Remaining",
"No. of Shops": "No. of Shops",
"Total Items": "Total Items",
"Tickets Released": "Tickets Released",
"First Ticket Start": "First Ticket Start",
"Tickets Completed": "Tickets Completed",
"Last Ticket End": "Last Ticket End",
"Pick Time (min)": "Pick Time (min)",
"No truck schedules available for today": "No truck schedules available for today"
}

+ 16
- 1
src/i18n/zh/dashboard.json Ver arquivo

@@ -57,5 +57,20 @@
"No": "無",
"Responsible Escalation List": "負責的上報列表",
"show completed logs": "顯示已完成上報",
"Rows per page": "每頁行數"
"Rows per page": "每頁行數",
"Truck Schedule Dashboard": "車輛調度儀表板",
"Store ID": "樓層",
"All Stores": "所有樓層",
"Auto-refresh every 5 minutes": "每5分鐘自動刷新",
"Last updated": "最後更新",
"Truck Schedule": "車輛班次",
"Time Remaining": "剩餘時間",
"No. of Shops": "門店數量",
"Total Items": "總貨品數",
"Tickets Released": "已發放成品出倉單",
"First Ticket Start": "首單開始時間",
"Tickets Completed": "已完成成品出倉單",
"Last Ticket End": "末單結束時間",
"Pick Time (min)": "揀貨時間(分鐘)",
"No truck schedules available for today": "今日無車輛調度計劃"
}

Carregando…
Cancelar
Salvar