Untitled
Yash
plain_text
a year ago
24 kB
14
Indexable
import { toaster } from '@/components/ui/toaster';
import { useGetAllAssignedVehicle, useGetRoutes } from '@/modules/routes/hooks';
import { useAuthContext } from '@/providers/authProvider';
import { SCHOOLLOG_NODE_API_URL } from '@/services/requestUris';
import {
Badge,
Box,
Button,
Card,
Flex,
Grid,
GridItem,
HStack,
Icon,
Input,
Menu,
Spinner,
Text,
VStack,
} from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { find } from 'lodash';
import type { LucideIcon } from 'lucide-react';
import {
AlertCircle,
Bus,
CheckCircle,
ChevronDownIcon,
Clock,
Eye,
Home,
Play,
School,
XCircle,
} from 'lucide-react';
import type { Dispatch, JSX, SetStateAction } from 'react';
import React, { useMemo, useState } from 'react';
import {
useCreateTrip,
useGetTripRouteStudentCount,
useGetTrips,
} from '../hooks';
// API Response types
type ApiTrip = {
id: string;
t_route_id: string;
school_id: string;
start_time: string;
end_time: string;
date: string;
type: string;
created_by_user_id: string | null;
pickup_completed: boolean;
drop_completed: boolean;
pickedup_count?: number;
dropped_count?: number;
pickup_attendance?: boolean;
drop_attendance?: boolean;
};
type VehicleRouteData = {
id: string;
vehicle_id: string;
route_id: string;
school_id: string;
order: string | number;
type: string;
active: boolean;
vehicle: {
id: string;
name: string;
number: string;
capacity: number;
type: string;
active: boolean;
school_id: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
};
routes: {
id: string;
name: string;
active: boolean;
school_id: string;
created_at: string | null;
updated_at: string | null;
deleted_at: string | null;
};
};
type TripStatus = 'not_started' | 'ongoing' | 'completed';
type TripDirection = 'to_school' | 'to_home';
type StudentCounts = {
total: number;
picked: number;
dropped: number;
available?: number;
};
type TripCardData = {
id: string | null; // null when no trip data exists
routeId: string;
routeName: string;
vehicleName: string;
vehicleNumber: string;
direction: TripDirection;
status: TripStatus | null;
startTime?: string;
endTime?: string;
totalStudents: number;
students: StudentCounts;
schoolAttendance?: number;
gatePassed?: number;
pickup_attendance?: boolean;
drop_attendance?: boolean;
progressSteps?: {
current: number;
total: number;
};
hasData: boolean;
};
type RouteRowData = {
routeId: string;
routeName: string;
vehicleName: string;
vehicleNumber: string;
totalStudents: number;
toSchoolTrip: TripCardData;
toHomeTrip: TripCardData;
};
const getStatusColor = (status: TripStatus | null): string => {
if (!status) return 'gray';
switch (status) {
case 'not_started':
return 'orange';
case 'ongoing':
return 'blue';
case 'completed':
return 'green';
default:
return 'gray';
}
};
const getStatusIcon = (status: TripStatus | null): LucideIcon => {
if (!status) return XCircle;
switch (status) {
case 'not_started':
return AlertCircle;
case 'ongoing':
return Clock;
case 'completed':
return CheckCircle;
default:
return XCircle;
}
};
const getDirectionText = (direction: TripDirection): string => {
return direction === 'to_school' ? 'To School' : 'To Home';
};
const getDirectionIcon = (direction: TripDirection): LucideIcon => {
return direction === 'to_school' ? School : Home;
};
const getTripStatus = (trip: ApiTrip): TripStatus => {
if (trip.pickup_attendance && trip.drop_attendance) {
return 'completed';
}
if (trip.start_time && !trip.end_time) {
return 'ongoing';
}
if (!trip) {
return 'not_started';
}
return 'completed';
};
const formatTime = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
};
const convertDirection = (apiType: string): TripDirection => {
if (apiType === 'to_school') return 'to_school';
if (apiType === 'to_home') return 'to_home';
return 'to_school';
};
const getVehicleInfoByRouteId = (
routeId: string,
vehicleRoutesData: VehicleRouteData[],
): { vehicleName: string; vehicleNumber: string } => {
const vehicleRoute = vehicleRoutesData?.find(
(vr) => vr.route_id === routeId && vr.active,
);
if (vehicleRoute && vehicleRoute.vehicle) {
return {
vehicleName: vehicleRoute.vehicle.name,
vehicleNumber: vehicleRoute.vehicle.number,
};
}
return {
vehicleName: 'N/A',
vehicleNumber: 'N/A',
};
};
const TripCard = ({
trip,
selectedDate,
}: {
trip: TripCardData;
selectedDate: string;
}): JSX.Element => {
const statusToShow = trip.hasData ? trip.status : 'not_started';
const StatusIconComponent = getStatusIcon(statusToShow);
const DirectionIconComponent = getDirectionIcon(trip.direction);
const client = useQueryClient();
const { mutate: createTrip, isPending: isTripCreating } = useCreateTrip({
onSuccess: () => {
toaster.create({
title: `Trip Started Successfully`,
type: 'success',
});
client.invalidateQueries({
queryKey: [SCHOOLLOG_NODE_API_URL.getAllTripRouteStudentCount],
});
client.invalidateQueries({
queryKey: [SCHOOLLOG_NODE_API_URL.getAllTrips],
});
},
onError: (e) => {
toaster.create({
type: 'error',
title: e.message ?? 'Something went wrong',
});
},
});
const handleViewDetailsClick = (): void => {
if (trip.hasData && trip.id) {
window.location.href = `/v2/transport-attendance/${trip.routeId}?date=${selectedDate}`;
}
};
const handleStartTripClick = (): void => {
createTrip({
t_route_id: trip.routeId,
type: trip.direction,
start_time: dayjs(selectedDate)
.hour(dayjs(trip.startTime).hour())
.minute(dayjs(trip.startTime).minute())
.second(0)
.millisecond(0)
.toDate(),
date: selectedDate,
});
};
const getProgressPercentage = (): number => {
if (!trip.progressSteps) return 0;
return (trip.progressSteps.current / trip.progressSteps.total) * 100;
};
return (
<Card.Root variant="elevated" size="md" bg="white" shadow="sm">
<Card.Body p={4}>
<VStack align="stretch" gap={3}>
<Flex justify="space-between" align="center">
<Text fontSize="lg" fontWeight="bold" color="gray.800">
{trip.routeName}
</Text>
<HStack gap={2}>
<Badge colorPalette={getStatusColor(statusToShow)} size="sm">
<HStack gap={1}>
<Icon>
<StatusIconComponent size={12} />
</Icon>
<Text>
{trip.hasData && trip.status === 'completed'
? 'Completed'
: trip.hasData && trip.status === 'ongoing'
? 'In Progress'
: 'Not Started'}
</Text>
</HStack>
</Badge>
</HStack>
</Flex>
{/* Direction */}
<HStack gap={2}>
<Icon color="gray.600">
<DirectionIconComponent size={16} />
</Icon>
<Text fontSize="md" color="gray.700" fontWeight="medium">
{getDirectionText(trip.direction)}
</Text>
</HStack>
{/* Vehicle Info */}
<HStack gap={4}>
<HStack gap={2}>
<Icon color="gray.500">
<Bus size={16} />
</Icon>
<Text fontSize="sm" color="gray.800" fontWeight="bold">
{trip.vehicleName}
</Text>
</HStack>
</HStack>
{/* Student Stats */}
{trip.hasData ? (
<Grid templateColumns="repeat(4, 1fr)" gap={4} py={2}>
{trip.direction === 'to_school' ? (
<>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{trip.totalStudents}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
Assigned
</Text>
</VStack>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{trip.students.picked}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
Picked Up
</Text>
</VStack>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
{trip.students.dropped}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
Dropped Off
</Text>
</VStack>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{trip.gatePassed || 0}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
At School
</Text>
</VStack>
</>
) : (
<>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{trip.students.available || 0}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
Available
</Text>
</VStack>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{trip.students.picked}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
Onboard
</Text>
</VStack>
<VStack gap={1}>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{trip.students.dropped}
</Text>
<Text fontSize="xs" color="gray.600" textAlign="center">
Dropped Off
</Text>
</VStack>
</>
)}
</Grid>
) : (
<Box py={4} textAlign="center">
<VStack gap={2}>
<Icon color="orange.500" size="lg">
<AlertCircle size={24} />
</Icon>
<Text fontSize="sm" color="gray.500" fontWeight="medium">
Trip not started yet
</Text>
<Text fontSize="xs" color="gray.400">
Click "Start Trip" to begin
</Text>
</VStack>
</Box>
)}
{/* Progress Bar */}
{trip.hasData && trip.progressSteps && (
<VStack gap={2}>
<Box
w="100%"
bg="gray.200"
borderRadius="md"
overflow="hidden"
h="2"
>
<Box
h="100%"
bg="green.500"
transition="width 0.3s ease"
w={`${getProgressPercentage()}%`}
/>
</Box>
<Text fontSize="sm" color="gray.600">
Trip Progress ({trip.progressSteps.current} /{' '}
{trip.progressSteps.total} stops)
</Text>
</VStack>
)}
{/* Action Button */}
{trip.hasData ? (
<Button
variant="solid"
size="sm"
onClick={handleViewDetailsClick}
w="100%"
>
<Eye size={14} />
View Details
</Button>
) : (
<Button
variant="solid"
colorPalette="blue"
size="sm"
onClick={handleStartTripClick}
w="100%"
loading={isTripCreating}
>
<Play size={14} />
Start Trip
</Button>
)}
</VStack>
</Card.Body>
</Card.Root>
);
};
const RouteRow = ({
routeRow,
selectedDate,
}: {
routeRow: RouteRowData;
selectedDate: string;
}): JSX.Element => {
return (
<Grid templateColumns="repeat(2, 1fr)" gap={4} mb={6}>
<GridItem>
<TripCard trip={routeRow.toSchoolTrip} selectedDate={selectedDate} />
</GridItem>
<GridItem>
<TripCard trip={routeRow.toHomeTrip} selectedDate={selectedDate} />
</GridItem>
</Grid>
);
};
const RouteFilter = ({
selectedRoute,
setSelectedRoute,
filterOptions,
}: {
selectedRoute: string;
setSelectedRoute: Dispatch<SetStateAction<string>>;
filterOptions: { value: string; label: string }[];
}): JSX.Element => {
const currentSelection = filterOptions.find(
(option) => option.value === selectedRoute,
) || { value: 'all', label: 'All Routes' };
const handleSelectFilter = (newRouteId: string): void => {
setSelectedRoute(newRouteId);
};
const allOptions = [{ value: 'all', label: 'All Routes' }, ...filterOptions];
return (
<Menu.Root>
<Menu.Trigger>
<Button
minW={150}
justifyContent="space-between"
variant="outline"
size="md"
colorPalette="gray"
bg="white"
borderColor="gray.300"
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)',
}}
>
<Text color="gray.700">{currentSelection.label}</Text>
<ChevronDownIcon size={16} />
</Button>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
{allOptions.map((option) => (
<Menu.Item
onClick={() => handleSelectFilter(option.value)}
key={option.value}
value={option.value}
>
{option.label}
</Menu.Item>
))}
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
);
};
export default function TripDashboard(): JSX.Element {
const [selectedDate, setSelectedDate] = useState<string>(() => {
const today = new Date();
return today.toISOString().split('T')[0];
});
const [selectedRoute, setSelectedRoute] = useState<string>('all');
const [appliedDate, setAppliedDate] = useState<Date>(() => new Date());
const { school } = useAuthContext();
const { data: tripRouteStudentCountData, isLoading: isLoadingStudentCount } =
useGetTripRouteStudentCount({
date: appliedDate,
});
const { data: routesData, isLoading: isLoadingRoutes } = useGetRoutes({
status: 'active',
...(selectedRoute && selectedRoute !== 'all'
? { routeId: selectedRoute }
: {}),
});
const { data: allRoutes, isLoading: isLoadingAllRoutes } = useGetRoutes({
status: 'active',
});
const { data: vehicleRoutesResponse, isLoading: isLoadingVehicleRoutes } =
useGetAllAssignedVehicle({
schoolId: school.id,
status: 'active',
});
const { data: tripsData, isLoading: isLoadingTrips } = useGetTrips({
date: appliedDate,
});
const isLoading =
isLoadingRoutes ||
isLoadingVehicleRoutes ||
isLoadingTrips ||
isLoadingStudentCount ||
isLoadingAllRoutes;
const routeOptions =
allRoutes?.data?.map((rd) => ({
value: rd.id,
label: rd.name,
})) || [];
const routeRows = useMemo(() => {
if (!routesData?.data || !vehicleRoutesResponse) {
return [];
}
const vehicleRoutesData: VehicleRouteData[] = Array.isArray(
vehicleRoutesResponse,
)
? vehicleRoutesResponse
: vehicleRoutesResponse || [];
const routeRowsData: RouteRowData[] = routesData.data.map((route) => {
const vehicleInfo = getVehicleInfoByRouteId(route.id, vehicleRoutesData);
const studentCountData = find(
tripRouteStudentCountData,
(d) => d.route_id === route.id,
);
const totalStudents = studentCountData?.total_students || 0;
// Find trips for this route
const routeTrips =
tripsData?.filter((trip: ApiTrip) => trip.t_route_id === route.id) ||
[];
const toSchoolTrip = routeTrips.find(
(trip: ApiTrip) => trip.type === 'to_school',
);
const toHomeTrip = routeTrips.find(
(trip: ApiTrip) => trip.type === 'to_home',
);
const attendance = studentCountData?.attendance || 0;
const gatepassCount = studentCountData?.gatepass_count || 0;
const availableCount = attendance - gatepassCount;
// Create to_school trip card data
const toSchoolTripData: TripCardData = toSchoolTrip
? {
id: toSchoolTrip.id,
routeId: route.id,
routeName: route.name,
vehicleName: vehicleInfo.vehicleName,
vehicleNumber: vehicleInfo.vehicleNumber,
direction: 'to_school',
status: getTripStatus(toSchoolTrip),
startTime: formatTime(toSchoolTrip.start_time),
endTime: formatTime(toSchoolTrip.end_time),
totalStudents,
students: {
total: totalStudents,
picked: toSchoolTrip.pickedup_count || 0,
dropped: toSchoolTrip.dropped_count || 0,
},
schoolAttendance: attendance,
gatePassed: gatepassCount,
pickup_attendance: toSchoolTrip.pickup_attendance,
drop_attendance: toSchoolTrip.drop_attendance,
progressSteps: {
current: toSchoolTrip.pickedup_count || 0,
total: 6,
},
hasData: true,
}
: {
id: null,
routeId: route.id,
routeName: route.name,
vehicleName: vehicleInfo.vehicleName,
vehicleNumber: vehicleInfo.vehicleNumber,
direction: 'to_school',
status: null,
totalStudents,
students: {
total: 0,
picked: 0,
dropped: 0,
},
hasData: false,
};
// Create to_home trip card data
const toHomeTripData: TripCardData = toHomeTrip
? {
id: toHomeTrip.id,
routeId: route.id,
routeName: route.name,
vehicleName: vehicleInfo.vehicleName,
vehicleNumber: vehicleInfo.vehicleNumber,
direction: 'to_home',
status: getTripStatus(toHomeTrip),
startTime: formatTime(toHomeTrip.start_time),
endTime: formatTime(toHomeTrip.end_time),
totalStudents,
students: {
total: totalStudents,
picked: toHomeTrip.pickedup_count || 0,
dropped: toHomeTrip.dropped_count || 0,
available: availableCount >= 0 ? availableCount : 0,
},
pickup_attendance: toHomeTrip.pickup_attendance,
drop_attendance: toHomeTrip.drop_attendance,
progressSteps: {
current: toHomeTrip.dropped_count || 0,
total: 6,
},
hasData: true,
}
: {
id: null,
routeId: route.id,
routeName: route.name,
vehicleName: vehicleInfo.vehicleName,
vehicleNumber: vehicleInfo.vehicleNumber,
direction: 'to_home',
status: null,
totalStudents,
students: {
total: 0,
picked: 0,
dropped: 0,
available: 0,
},
hasData: false,
};
return {
routeId: route.id,
routeName: route.name,
vehicleName: vehicleInfo.vehicleName,
vehicleNumber: vehicleInfo.vehicleNumber,
totalStudents,
toSchoolTrip: toSchoolTripData,
toHomeTrip: toHomeTripData,
};
});
return routeRowsData;
}, [routesData, tripsData, vehicleRoutesResponse, tripRouteStudentCountData]);
const handleDateChange = (
event: React.ChangeEvent<HTMLInputElement>,
): void => {
setSelectedDate(event.target.value);
};
const handleGetData = (): void => {
if (selectedDate) {
const newDate = new Date(selectedDate);
setAppliedDate(newDate);
}
};
const hasRoutesData = !!(routesData?.data && vehicleRoutesResponse);
return (
<Box p={4} bg="gray.50" minH="100vh">
<VStack gap={4} align="stretch">
{/* Date Filter Section */}
<VStack gap={3} align="start">
<HStack gap={3} align="end">
<VStack align="flex-start" gap={2} flex={1}>
<Text fontSize="sm" color="gray.600">
Select Route
</Text>
<RouteFilter
selectedRoute={selectedRoute}
setSelectedRoute={setSelectedRoute}
filterOptions={routeOptions}
/>
</VStack>
<VStack align="flex-start" gap={2} flex={1}>
<Text fontSize="sm" color="gray.600">
Select Date
</Text>
<Input
type="date"
value={selectedDate}
onChange={handleDateChange}
size="md"
bg="white"
borderColor="gray.300"
_focus={{
borderColor: 'blue.500',
boxShadow: '0 0 0 1px var(--chakra-colors-blue-500)',
}}
/>
</VStack>
<Button
colorPalette="blue"
size="md"
onClick={handleGetData}
px={6}
variant="outline"
loading={isLoading}
loadingText="Getting..."
>
Get
</Button>
</HStack>
</VStack>
{isLoading && (
<Text textAlign="center" color="gray.500" py={8}>
<Spinner mr={2} />
Loading trips...
</Text>
)}
{!isLoading && !hasRoutesData && (
<Text textAlign="center" color="gray.500" py={8}>
Unable to load route data. Please try again.
</Text>
)}
{!isLoading && hasRoutesData && (
<>
{routeRows.length > 0 ? (
<VStack gap={0} align="stretch">
{routeRows.map((routeRow) => (
<RouteRow
key={routeRow.routeId}
routeRow={routeRow}
selectedDate={selectedDate}
/>
))}
</VStack>
) : (
<Text textAlign="center" color="gray.500" py={8}>
No routes found for the selected filter.
</Text>
)}
</>
)}
</VStack>
</Box>
);
}
Editor is loading...
Leave a Comment