mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
enhance phase countdown and booking window descriptions for clarity
This commit is contained in:
@@ -83,6 +83,8 @@ import UsersPage from "./pages/dashboard/user-management/UsersPage";
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage";
|
||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
|
||||
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
|
||||
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
|
||||
@@ -926,6 +928,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="vehicles/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<VehicleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="drivers"
|
||||
element={
|
||||
@@ -934,6 +944,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="drivers/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<DriverDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface FleetRecordActionsProps {
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
onAssignDriver?: (record: FleetRecord) => void;
|
||||
onHistory?: (record: FleetRecord) => void;
|
||||
onViewDetail?: (record: FleetRecord) => void;
|
||||
layout?: "row" | "compact";
|
||||
}
|
||||
|
||||
@@ -22,11 +23,13 @@ const FleetRecordActions = ({
|
||||
onRemove,
|
||||
onAssignDriver,
|
||||
onHistory,
|
||||
onViewDetail,
|
||||
layout = "row",
|
||||
}: FleetRecordActionsProps) => {
|
||||
const navigate = useNavigate();
|
||||
const removeLabel = config.removeActionLabel ?? "Delete";
|
||||
const showDetail = Boolean(config.detailPath && "id" in record);
|
||||
const showViewDetail = Boolean(onViewDetail);
|
||||
const isVehicle = config.slug === "vehicles";
|
||||
const showHistory =
|
||||
Boolean(onHistory) &&
|
||||
@@ -62,6 +65,14 @@ const FleetRecordActions = ({
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{showViewDetail ? (
|
||||
<MenuItem
|
||||
onClick={() => onViewDetail?.(record)}
|
||||
leftSection={<Eye size={14} strokeWidth={2} />}
|
||||
>
|
||||
View detail
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{showHistory ? (
|
||||
<MenuItem
|
||||
onClick={() => onHistory?.(record)}
|
||||
@@ -114,6 +125,14 @@ const FleetRecordActions = ({
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{showViewDetail ? (
|
||||
<MenuItem
|
||||
onClick={() => onViewDetail?.(record)}
|
||||
leftSection={<Eye size={14} strokeWidth={2} />}
|
||||
>
|
||||
View detail
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{showDetail ? (
|
||||
<MenuItem
|
||||
onClick={handleDetail}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Timeline,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeft,
|
||||
History,
|
||||
Route,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
|
||||
|
||||
const fmtDate = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||
};
|
||||
const fmtDateTime = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||
};
|
||||
const meta = (e: FleetHistoryEvent, k: string) => {
|
||||
const v = e.metadata?.[k];
|
||||
return typeof v === "string" && v ? v : null;
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">{label}</Text>
|
||||
<Text size="sm" fw={500} ta="right">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
const Loading = () => (
|
||||
<Center py="xl"><Loader size="sm" /></Center>
|
||||
);
|
||||
|
||||
const DriverDetailPage = () => {
|
||||
const { id = "" } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: driver, isLoading } = useQuery({
|
||||
queryKey: ["driver", id],
|
||||
queryFn: () => driversService.getById(id).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const name = driver ? `${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() : "";
|
||||
const licenseExpired =
|
||||
driver?.licenseExpiryDate && new Date(driver.licenseExpiryDate) < new Date();
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Group gap="sm" mb="md" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" onClick={() => navigate("/dashboard/drivers")} aria-label="Back">
|
||||
<ArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<User size={22} />
|
||||
<Stack gap={0}>
|
||||
<Title order={4}>{name || "Driver"}</Title>
|
||||
{driver && (
|
||||
<Group gap="xs">
|
||||
<Badge size="sm" variant="light">{driver.status}</Badge>
|
||||
{driver.faydaVerified && (
|
||||
<Badge size="sm" variant="light" color="green" leftSection={<ShieldCheck size={12} />}>
|
||||
Fayda verified
|
||||
</Badge>
|
||||
)}
|
||||
{licenseExpired && (
|
||||
<Badge size="sm" variant="light" color="red">License expired</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">{driver.licenseNumber}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : !driver ? (
|
||||
<Text c="dimmed">Driver not found.</Text>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview" leftSection={<User size={14} />}>Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="vehicles" leftSection={<Truck size={14} />}>Vehicles</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||
<Tabs.Tab value="trips" leftSection={<Route size={14} />}>Trips</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Name" value={name || "—"} />
|
||||
<InfoRow label="Phone" value={driver.phoneNumber ?? "—"} />
|
||||
<InfoRow label="Email" value={driver.email ?? "—"} />
|
||||
<InfoRow label="License no." value={driver.licenseNumber ?? "—"} />
|
||||
<InfoRow
|
||||
label="License expiry"
|
||||
value={
|
||||
<Text size="sm" fw={500} c={licenseExpired ? "red" : undefined}>
|
||||
{fmtDate(driver.licenseExpiryDate)}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Date of birth" value={fmtDate(driver.dateOfBirth)} />
|
||||
<InfoRow label="Status" value={driver.status ?? "—"} />
|
||||
<InfoRow label="Fayda verified" value={driver.faydaVerified ? "Yes" : "No"} />
|
||||
<InfoRow
|
||||
label="Authorized vehicles"
|
||||
value={driver.vehicleTypesAuthorized?.join(", ") || "—"}
|
||||
/>
|
||||
<InfoRow label="Total trips" value={driver.totalTrips ?? "—"} />
|
||||
<InfoRow label="Rating" value={driver.rating ?? "—"} />
|
||||
<InfoRow label="Address" value={driver.address ?? "—"} />
|
||||
<InfoRow label="Emergency contact" value={driver.emergencyContact ?? "—"} />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="vehicles" pt="lg">
|
||||
<VehiclesTab driverId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<HistoryTab driverId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="trips" pt="lg">
|
||||
<TripsTab driverId={id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const useDriverHistory = (driverId: string) =>
|
||||
useQuery({
|
||||
queryKey: ["driver-history", driverId],
|
||||
queryFn: () => fleetHistoryService.driver(driverId),
|
||||
});
|
||||
|
||||
const VehiclesTab = ({ driverId }: { driverId: string }) => {
|
||||
const { data = [], isLoading } = useDriverHistory(driverId);
|
||||
const rows = data
|
||||
.filter((e) => e.eventType === "DRIVER_ASSIGNED")
|
||||
.map((e) => ({ id: e.id, plate: meta(e, "vehiclePlate") || e.vehicleId || "Vehicle", at: e.createdAt }));
|
||||
if (isLoading) return <Loading />;
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>Vehicles driven ({rows.length})</Text>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">No vehicle assignments recorded.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr><Table.Th>Vehicle</Table.Th><Table.Th>Assigned</Table.Th></Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.plate}</Table.Td>
|
||||
<Table.Td>{fmtDateTime(r.at)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const HistoryTab = ({ driverId }: { driverId: string }) => {
|
||||
const { data = [], isLoading } = useDriverHistory(driverId);
|
||||
if (isLoading) return <Loading />;
|
||||
if (!data.length) return <Text c="dimmed">No activity recorded yet.</Text>;
|
||||
return (
|
||||
<Timeline active={data.length} bulletSize={18} lineWidth={2}>
|
||||
{data.map((e) => (
|
||||
<Timeline.Item key={e.id} title={<Text size="sm" fw={500}>{e.eventType.replaceAll("_", " ")}</Text>}>
|
||||
{(meta(e, "vehiclePlate") || meta(e, "bookingRef") || e.label) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{[meta(e, "vehiclePlate"), meta(e, "bookingRef") && `Booking ${meta(e, "bookingRef")}`, e.label]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
};
|
||||
|
||||
const TripsTab = ({ driverId }: { driverId: string }) => {
|
||||
const { data = [], isLoading } = useDriverHistory(driverId);
|
||||
const trips = useMemo(
|
||||
() =>
|
||||
data
|
||||
.filter((e) => e.eventType === "MILE_VEHICLE_ASSIGNED")
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
mile: meta(e, "mile") === "LAST" ? "Last-mile" : "First-mile",
|
||||
booking: meta(e, "bookingRef") ?? "—",
|
||||
vehicle: meta(e, "vehiclePlate") ?? "—",
|
||||
status: e.label ?? "—",
|
||||
at: e.createdAt,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
if (isLoading) return <Loading />;
|
||||
return (
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>Trips assigned ({trips.length})</Text>
|
||||
{trips.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">No trips recorded.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Mile</Table.Th><Table.Th>Booking</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th><Table.Th>Status</Table.Th><Table.Th>When</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{trips.map((t) => (
|
||||
<Table.Tr key={t.id}>
|
||||
<Table.Td><Badge size="sm" variant="light">{t.mile}</Badge></Table.Td>
|
||||
<Table.Td>{t.booking}</Table.Td>
|
||||
<Table.Td>{t.vehicle}</Table.Td>
|
||||
<Table.Td>{t.status}</Table.Td>
|
||||
<Table.Td>{fmtDateTime(t.at)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DriverDetailPage;
|
||||
@@ -0,0 +1,453 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Timeline,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Fuel,
|
||||
History,
|
||||
Route,
|
||||
Truck,
|
||||
User,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService } from "@/services/drivers.service";
|
||||
import { fleetHistoryService } from "@/services/fleet-history.service";
|
||||
|
||||
interface MaintenanceCost {
|
||||
id: string;
|
||||
incurredDate: string;
|
||||
costAmount: number;
|
||||
costType: string;
|
||||
description?: string | null;
|
||||
serviceProvider?: string | null;
|
||||
invoiceNumber?: string | null;
|
||||
}
|
||||
interface FuelPurchase {
|
||||
id: string;
|
||||
purchaseDate: string;
|
||||
liters: number;
|
||||
costPerLiter: number;
|
||||
totalCost: number;
|
||||
fuelStation?: string | null;
|
||||
odometerReading?: number | null;
|
||||
}
|
||||
interface MileRecord {
|
||||
id: string;
|
||||
status: string;
|
||||
exactKm?: number | null;
|
||||
estimatedKm?: number | null;
|
||||
remainingPayment?: number | null;
|
||||
advancedPayment?: number | null;
|
||||
booking?: { reference?: string } | null;
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
const fmtDate = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||
};
|
||||
const fmtDateTime = (iso?: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||
};
|
||||
const money = (n?: number | null) =>
|
||||
n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: React.ReactNode }) => (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">{label}</Text>
|
||||
<Text size="sm" fw={500} ta="right">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
|
||||
const Loading = () => (
|
||||
<Center py="xl"><Loader size="sm" /></Center>
|
||||
);
|
||||
|
||||
const VehicleDetailPage = () => {
|
||||
const { id = "" } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: vehicle, isLoading } = useQuery({
|
||||
queryKey: ["vehicle", id],
|
||||
queryFn: () => vehiclesService.getById(id).then((r) => r.data),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const plate = vehicle
|
||||
? [vehicle.code, vehicle.plateNumber].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Group gap="sm" mb="md" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" onClick={() => navigate("/dashboard/vehicles")} aria-label="Back">
|
||||
<ArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<Truck size={22} />
|
||||
<Stack gap={0}>
|
||||
<Title order={4}>{plate || "Vehicle"}</Title>
|
||||
{vehicle && (
|
||||
<Group gap="xs">
|
||||
<Badge size="sm" variant="light">{vehicle.status}</Badge>
|
||||
<Badge size="sm" variant="light" color={vehicle.availability === "FREE" ? "green" : "orange"}>
|
||||
{vehicle.availability}
|
||||
</Badge>
|
||||
{vehicle.vehicleType && <Text size="xs" c="dimmed">{vehicle.vehicleType}</Text>}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : !vehicle ? (
|
||||
<Text c="dimmed">Vehicle not found.</Text>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="overview" leftSection={<Truck size={14} />}>Overview</Tabs.Tab>
|
||||
<Tabs.Tab value="driver" leftSection={<User size={14} />}>Driver</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<History size={14} />}>History</Tabs.Tab>
|
||||
<Tabs.Tab value="maintenance" leftSection={<Wrench size={14} />}>Maintenance</Tabs.Tab>
|
||||
<Tabs.Tab value="fuel" leftSection={<Fuel size={14} />}>Fuel</Tabs.Tab>
|
||||
<Tabs.Tab value="mile" leftSection={<Route size={14} />}>First/Last mile</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Plate" value={vehicle.plateNumber ?? "—"} />
|
||||
<InfoRow label="Code" value={vehicle.code ?? "—"} />
|
||||
<InfoRow label="Registration" value={vehicle.registrationNumber ?? "—"} />
|
||||
<InfoRow label="Type" value={vehicle.vehicleType ?? "—"} />
|
||||
<InfoRow label="Manufacturer" value={vehicle.manufacturer ?? "—"} />
|
||||
<InfoRow label="Model" value={vehicle.model ?? "—"} />
|
||||
<InfoRow label="Year" value={vehicle.year ?? "—"} />
|
||||
<InfoRow label="Fuel type" value={vehicle.fuelType ?? "—"} />
|
||||
<InfoRow label="Capacity" value={vehicle.capacity ?? "—"} />
|
||||
<InfoRow label="Power plate" value={vehicle.powerPlateNo ?? "—"} />
|
||||
<InfoRow label="Trailer plate" value={vehicle.trailerPlateNo ?? "—"} />
|
||||
<InfoRow label="Status" value={vehicle.status ?? "—"} />
|
||||
<InfoRow label="Availability" value={vehicle.availability ?? "—"} />
|
||||
<InfoRow label="Assigned driver" value={vehicle.assignedDriverName ?? "—"} />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="driver" pt="lg">
|
||||
<DriverTab vehicleId={id} driverId={vehicle.assignedDriverId} fallbackName={vehicle.assignedDriverName} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="lg">
|
||||
<HistoryTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="maintenance" pt="lg">
|
||||
<MaintenanceTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="fuel" pt="lg">
|
||||
<FuelTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="mile" pt="lg">
|
||||
<MileTab vehicleId={id} />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const DriverTab = ({
|
||||
vehicleId,
|
||||
driverId,
|
||||
fallbackName,
|
||||
}: {
|
||||
vehicleId: string;
|
||||
driverId?: string | null;
|
||||
fallbackName?: string | null;
|
||||
}) => {
|
||||
const { data: driver } = useQuery({
|
||||
queryKey: ["driver", driverId],
|
||||
queryFn: () => driversService.getById(driverId!).then((r) => r.data),
|
||||
enabled: Boolean(driverId),
|
||||
});
|
||||
// All drivers that have driven this vehicle, from the assignment history.
|
||||
const { data: history = [], isLoading } = useQuery({
|
||||
queryKey: ["vehicle-history", vehicleId],
|
||||
queryFn: () => fleetHistoryService.vehicle(vehicleId),
|
||||
});
|
||||
const drivers = history
|
||||
.filter((e) => e.eventType === "DRIVER_ASSIGNED")
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
driverId: e.driverId,
|
||||
name: (typeof e.metadata?.driverName === "string" && e.metadata.driverName) || e.label || "Driver",
|
||||
at: e.createdAt,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{driverId && driver ? (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Text size="sm" fw={600} mb="sm">Current driver</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<InfoRow label="Name" value={`${driver.firstName ?? ""} ${driver.lastName ?? ""}`.trim() || "—"} />
|
||||
<InfoRow label="Phone" value={driver.phoneNumber ?? "—"} />
|
||||
<InfoRow label="Email" value={driver.email ?? "—"} />
|
||||
<InfoRow label="License no." value={driver.licenseNumber ?? "—"} />
|
||||
<InfoRow label="License expiry" value={fmtDate(driver.licenseExpiryDate)} />
|
||||
<InfoRow label="Status" value={driver.status ?? "—"} />
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
) : (
|
||||
<Text c="dimmed">{fallbackName ? `Assigned: ${fallbackName}` : "No driver currently assigned."}</Text>
|
||||
)}
|
||||
|
||||
<Stack gap={4}>
|
||||
<Text size="sm" fw={600}>Driver history ({drivers.length})</Text>
|
||||
{isLoading ? (
|
||||
<Loading />
|
||||
) : drivers.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">No driver assignments recorded.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Driver</Table.Th><Table.Th>Assigned</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{drivers.map((d) => (
|
||||
<Table.Tr key={d.id}>
|
||||
<Table.Td>{d.name}</Table.Td>
|
||||
<Table.Td>{fmtDateTime(d.at)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const HistoryTab = ({ vehicleId }: { vehicleId: string }) => {
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ["vehicle-history", vehicleId],
|
||||
queryFn: () => fleetHistoryService.vehicle(vehicleId),
|
||||
});
|
||||
if (isLoading) return <Loading />;
|
||||
if (!data.length) return <Text c="dimmed">No activity recorded yet.</Text>;
|
||||
return (
|
||||
<Timeline active={data.length} bulletSize={18} lineWidth={2}>
|
||||
{data.map((e) => (
|
||||
<Timeline.Item key={e.id} title={<Text size="sm" fw={500}>{e.eventType.replaceAll("_", " ")}</Text>}>
|
||||
{(e.label || e.fromValue || e.toValue) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{[e.label, e.fromValue && e.toValue ? `${e.fromValue} → ${e.toValue}` : e.toValue]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed" mt={2}>{fmtDateTime(e.createdAt)}</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
);
|
||||
};
|
||||
|
||||
const MaintenanceTab = ({ vehicleId }: { vehicleId: string }) => {
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ["vehicle-maintenance", vehicleId],
|
||||
queryFn: () => api.get<MaintenanceCost[]>(`/maintenance/history/${vehicleId}`).then((r) => r.data),
|
||||
});
|
||||
const total = useMemo(() => data.reduce((s, m) => s + (Number(m.costAmount) || 0), 0), [data]);
|
||||
if (isLoading) return <Loading />;
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Last 12 months</Text>
|
||||
<Text size="sm" fw={600}>Total: {money(total)}</Text>
|
||||
</Group>
|
||||
{data.length === 0 ? (
|
||||
<Text c="dimmed">No maintenance records.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Date</Table.Th><Table.Th>Type</Table.Th><Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Description</Table.Th><Table.Th>Provider</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{fmtDate(m.incurredDate)}</Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light">{m.costType}</Badge></Table.Td>
|
||||
<Table.Td>{money(m.costAmount)}</Table.Td>
|
||||
<Table.Td>{m.description ?? "—"}</Table.Td>
|
||||
<Table.Td>{m.serviceProvider ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const FuelTab = ({ vehicleId }: { vehicleId: string }) => {
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ["vehicle-fuel", vehicleId],
|
||||
queryFn: () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
start.setMonth(start.getMonth() - 12);
|
||||
const qs = `startDate=${start.toISOString()}&endDate=${end.toISOString()}`;
|
||||
return api.get<FuelPurchase[]>(`/fuel/purchases/${vehicleId}?${qs}`).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
const totals = useMemo(
|
||||
() => ({
|
||||
liters: data.reduce((s, f) => s + (Number(f.liters) || 0), 0),
|
||||
cost: data.reduce((s, f) => s + (Number(f.totalCost) || 0), 0),
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
if (isLoading) return <Loading />;
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={{ base: 2, sm: 3 }} spacing="sm">
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Text size="xs" c="dimmed">Total litres</Text>
|
||||
<Text fw={700}>{totals.liters.toLocaleString(undefined, { maximumFractionDigits: 1 })} L</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Text size="xs" c="dimmed">Total fuel cost</Text>
|
||||
<Text fw={700}>{money(totals.cost)}</Text>
|
||||
</Card>
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Text size="xs" c="dimmed">Purchases</Text>
|
||||
<Text fw={700}>{data.length}</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
{data.length === 0 ? (
|
||||
<Text c="dimmed">No fuel purchases in the last 12 months.</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Date</Table.Th><Table.Th>Litres</Table.Th><Table.Th>Cost/L</Table.Th>
|
||||
<Table.Th>Total</Table.Th><Table.Th>Odometer</Table.Th><Table.Th>Station</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.map((f) => (
|
||||
<Table.Tr key={f.id}>
|
||||
<Table.Td>{fmtDate(f.purchaseDate)}</Table.Td>
|
||||
<Table.Td>{f.liters} L</Table.Td>
|
||||
<Table.Td>{money(f.costPerLiter)}</Table.Td>
|
||||
<Table.Td>{money(f.totalCost)}</Table.Td>
|
||||
<Table.Td>{f.odometerReading ?? "—"}</Table.Td>
|
||||
<Table.Td>{f.fuelStation ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const mileTotal = (rows: MileRecord[]) =>
|
||||
rows.reduce((s, r) => s + (Number(r.remainingPayment) || 0), 0);
|
||||
|
||||
const MileTable = ({ title, rows }: { title: string; rows: MileRecord[] }) => (
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>{title} ({rows.length})</Text>
|
||||
<Text size="sm" fw={600}>Total: {money(mileTotal(rows))}</Text>
|
||||
</Group>
|
||||
{rows.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">None.</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Booking</Table.Th><Table.Th>Status</Table.Th>
|
||||
<Table.Th>Distance (km)</Table.Th><Table.Th>Cost</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>{r.booking?.reference ?? r.bookingId}</Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light">{r.status}</Badge></Table.Td>
|
||||
<Table.Td>{r.exactKm ?? r.estimatedKm ?? "—"}</Table.Td>
|
||||
<Table.Td>{money(r.remainingPayment)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const MileTab = ({ vehicleId }: { vehicleId: string }) => {
|
||||
const first = useQuery({
|
||||
queryKey: ["vehicle-first-mile", vehicleId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: MileRecord[] }>(`/first-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []),
|
||||
});
|
||||
const last = useQuery({
|
||||
queryKey: ["vehicle-last-mile", vehicleId],
|
||||
queryFn: () =>
|
||||
api.get<{ data: MileRecord[] }>(`/last-mile?vehicleId=${vehicleId}&pageSize=1000`).then((r) => r.data.data ?? []),
|
||||
});
|
||||
if (first.isLoading || last.isLoading) return <Loading />;
|
||||
const firstRows = first.data ?? [];
|
||||
const lastRows = last.data ?? [];
|
||||
const grandTotal = mileTotal(firstRows) + mileTotal(lastRows);
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Card withBorder radius="md" padding="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Total first + last mile revenue for this vehicle</Text>
|
||||
<Text fw={700}>{money(grandTotal)}</Text>
|
||||
</Group>
|
||||
</Card>
|
||||
<MileTable title="First-mile" rows={firstRows} />
|
||||
<MileTable title="Last-mile" rows={lastRows} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default VehicleDetailPage;
|
||||
@@ -19,6 +19,7 @@ export const driversConfig: FleetResourceConfig = {
|
||||
label: "Drivers",
|
||||
subtitle: "Manage driver records and licenses",
|
||||
basePath: "/dashboard/drivers",
|
||||
detailPath: "/dashboard/drivers/:id",
|
||||
addLabel: "Add Driver",
|
||||
entityLabel: "Driver",
|
||||
searchPlaceholder: "Search drivers…",
|
||||
|
||||
@@ -34,6 +34,7 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
label: "Vehicles",
|
||||
subtitle: "Manage vehicle master data for fleet operations",
|
||||
basePath: "/dashboard/vehicles",
|
||||
detailPath: "/dashboard/vehicles/:id",
|
||||
addLabel: "Add Vehicle",
|
||||
entityLabel: "Vehicle",
|
||||
searchPlaceholder: "Search vehicles…",
|
||||
|
||||
Reference in New Issue
Block a user