mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
Center,
|
|
Container,
|
|
Group,
|
|
Loader,
|
|
SimpleGrid,
|
|
Stack,
|
|
Table,
|
|
Tabs,
|
|
Text,
|
|
Timeline,
|
|
Title,
|
|
} from "@mantine/core";
|
|
import { SmartFileInput } from "@edr/ui-common";
|
|
import type { IFileUploadSetting } from "@edr/types/freight";
|
|
import {
|
|
ArrowLeft,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
History,
|
|
Route,
|
|
ShieldCheck,
|
|
Trash2,
|
|
Upload,
|
|
Truck,
|
|
User,
|
|
} from "lucide-react";
|
|
|
|
import { driversService } from "@/services/drivers.service";
|
|
import { vehiclesService } from "@/services/vehicles.service";
|
|
import { fleetHistoryService, type FleetHistoryEvent } from "@/services/fleet-history.service";
|
|
import { fileUploadSettingsService } from "@/services/fileUploadSettings.service";
|
|
import {
|
|
downloadBookingFile,
|
|
fetchViewableFile,
|
|
} from "@/services/files.service";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
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 fmtSize = (bytes: number) => {
|
|
if (!bytes) return "—";
|
|
const kb = bytes / 1024;
|
|
return kb < 1024 ? `${kb.toFixed(0)} KB` : `${(kb / 1024).toFixed(1)} MB`;
|
|
};
|
|
|
|
/** Upload-area setting code configured on the File Settings page. */
|
|
const DRIVER_DOCS_CODE = "driver_docs";
|
|
// Field key the FALLBACK setting is keyed on (used only when the "driver_docs"
|
|
// upload area hasn't been configured in File Settings yet).
|
|
const DRIVER_DOCS_KEY = "driver_docs";
|
|
/** Fallback single-field setting so the dropzone still works before an admin
|
|
* configures the "driver_docs" area in File Settings. */
|
|
const DRIVER_DOCS_FALLBACK: IFileUploadSetting = {
|
|
id: "driver-docs-setting",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
deletedAt: null,
|
|
code: "driver_docs",
|
|
label: "Driver documents",
|
|
description: null,
|
|
entity: "other",
|
|
fields: [
|
|
{
|
|
id: "driver-docs-field",
|
|
createdAt: "",
|
|
updatedAt: "",
|
|
deletedAt: null,
|
|
settingId: "driver-docs-setting",
|
|
fileKey: DRIVER_DOCS_KEY,
|
|
fileLabel: "Upload driver document(s)",
|
|
helpText: "License, national ID, contracts, training certificates, etc.",
|
|
isRequired: false,
|
|
isMultiple: true,
|
|
maxFiles: 20,
|
|
allowedExtensions: ["pdf", "png", "jpg", "jpeg", "doc", "docx"],
|
|
maxSizeMb: 10,
|
|
order: 1,
|
|
},
|
|
],
|
|
};
|
|
|
|
/** Driver documents upload + view area (files stored under code "driver_docs"). */
|
|
const DriverDocuments = ({ driverId }: { driverId: string }) => {
|
|
const { toast } = useToast();
|
|
const qc = useQueryClient();
|
|
// Files selected per configured field key (SmartFileInput is multi-field).
|
|
const [selectedMap, setSelectedMap] = useState<Record<string, File | File[] | null>>({});
|
|
const selectedFiles = Object.values(selectedMap).flatMap((v) =>
|
|
Array.isArray(v) ? v : v ? [v] : [],
|
|
);
|
|
|
|
// Upload-area configuration from the File Settings page (code "driver_docs").
|
|
// Falls back to a default field until an admin configures it there.
|
|
const { data: setting } = useQuery({
|
|
queryKey: ["file-upload-setting", DRIVER_DOCS_CODE],
|
|
queryFn: () => fileUploadSettingsService.getByCode(DRIVER_DOCS_CODE),
|
|
retry: false,
|
|
});
|
|
const activeSetting = setting ?? DRIVER_DOCS_FALLBACK;
|
|
|
|
const { data: docs = [], isLoading } = useQuery({
|
|
queryKey: ["driver", driverId, "documents"],
|
|
queryFn: () => driversService.listDocuments(driverId).then((r) => r.data ?? []),
|
|
enabled: Boolean(driverId),
|
|
});
|
|
|
|
const uploadMutation = useMutation({
|
|
mutationFn: (files: File[]) => driversService.uploadDocuments(driverId, files),
|
|
onSuccess: () => {
|
|
toast({ title: "Documents uploaded" });
|
|
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
|
|
},
|
|
onError: (err: unknown) => {
|
|
const description =
|
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
|
"Upload failed";
|
|
toast({ title: "Upload failed", description, variant: "destructive" });
|
|
},
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (fileId: string) => driversService.removeDocument(driverId, fileId),
|
|
onSuccess: () => {
|
|
toast({ title: "Document deleted" });
|
|
void qc.invalidateQueries({ queryKey: ["driver", driverId, "documents"] });
|
|
},
|
|
onError: () => toast({ title: "Delete failed", variant: "destructive" }),
|
|
});
|
|
|
|
return (
|
|
<Card withBorder padding="lg" radius="md">
|
|
<Stack gap="md" mb="lg">
|
|
<Text fw={600} size="sm">{activeSetting.label ?? "Upload documents"}</Text>
|
|
<SmartFileInput
|
|
file={activeSetting}
|
|
value={selectedMap}
|
|
onChange={setSelectedMap}
|
|
/>
|
|
<Group justify="flex-end">
|
|
<Button
|
|
size="xs"
|
|
leftSection={<Upload size={14} />}
|
|
loading={uploadMutation.isPending}
|
|
disabled={selectedFiles.length === 0}
|
|
onClick={() =>
|
|
uploadMutation.mutate(selectedFiles, { onSuccess: () => setSelectedMap({}) })
|
|
}
|
|
>
|
|
Upload {selectedFiles.length > 0 ? `(${selectedFiles.length})` : ""}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
|
|
<Text fw={600} size="sm" mb="sm">Uploaded documents ({docs.length})</Text>
|
|
{isLoading ? (
|
|
<Loading />
|
|
) : docs.length === 0 ? (
|
|
<Text c="dimmed" size="sm" ta="center" py="md">No documents uploaded yet.</Text>
|
|
) : (
|
|
<Table highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Name</Table.Th>
|
|
<Table.Th>Size</Table.Th>
|
|
<Table.Th>Uploaded</Table.Th>
|
|
<Table.Th ta="right">Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{docs.map((doc) => (
|
|
<Table.Tr key={doc.id}>
|
|
<Table.Td>
|
|
<Group gap="xs" wrap="nowrap">
|
|
<FileText size={15} />
|
|
<Text size="sm" truncate>{doc.name}</Text>
|
|
</Group>
|
|
</Table.Td>
|
|
<Table.Td>{fmtSize(doc.size)}</Table.Td>
|
|
<Table.Td>{fmtDate(doc.createdAt)}</Table.Td>
|
|
<Table.Td>
|
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
aria-label="View"
|
|
onClick={() =>
|
|
void fetchViewableFile(doc.id, doc.name).then((f) =>
|
|
window.open(f.url, "_blank"),
|
|
)
|
|
}
|
|
>
|
|
<Eye size={16} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
aria-label="Download"
|
|
onClick={() => void downloadBookingFile(doc.id, doc.name)}
|
|
>
|
|
<Download size={16} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="red"
|
|
aria-label="Delete"
|
|
loading={removeMutation.isPending && removeMutation.variables === doc.id}
|
|
onClick={() => removeMutation.mutate(doc.id)}
|
|
>
|
|
<Trash2 size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
)}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
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="xl" py="xl" px="lg">
|
|
<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.Tab value="documents" leftSection={<FileText size={14} />}>Documents</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.Panel value="documents" pt="lg">
|
|
<DriverDocuments driverId={id} />
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
)}
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
const useDriverHistory = (driverId: string) =>
|
|
useQuery({
|
|
queryKey: ["driver-history", driverId],
|
|
queryFn: () => fleetHistoryService.driver(driverId),
|
|
});
|
|
|
|
/** id → "code · plate" map so events without a stored plate still show a name. */
|
|
const useVehicleMap = () => {
|
|
const { data } = useQuery({
|
|
queryKey: ["vehicles-all"],
|
|
queryFn: () => vehiclesService.getAll({}).then((r) => r.data),
|
|
});
|
|
return useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
for (const v of data ?? []) {
|
|
m.set(v.id, [v.code, v.plateNumber].filter(Boolean).join(" · ") || v.id);
|
|
}
|
|
return m;
|
|
}, [data]);
|
|
};
|
|
|
|
const VehiclesTab = ({ driverId }: { driverId: string }) => {
|
|
const { data = [], isLoading } = useDriverHistory(driverId);
|
|
const vmap = useVehicleMap();
|
|
const rows = data
|
|
.filter((e) => e.eventType === "DRIVER_ASSIGNED")
|
|
.map((e) => ({
|
|
id: e.id,
|
|
plate: meta(e, "vehiclePlate") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "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 vmap = useVehicleMap();
|
|
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") || (e.vehicleId ? vmap.get(e.vehicleId) : null) || "—",
|
|
status: e.label ?? "—",
|
|
at: e.createdAt,
|
|
})),
|
|
[data, vmap],
|
|
);
|
|
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;
|