This commit is contained in:
natib21
2026-07-03 15:21:23 +00:00
parent e881e8de84
commit 0e6ebda6f6
15 changed files with 661 additions and 7 deletions

View File

@@ -0,0 +1,176 @@
import { Center, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Activity,
CircleDot,
Route,
Truck,
UserCheck,
UserMinus,
UserPlus,
} from "lucide-react";
import {
fleetHistoryService,
type FleetHistoryEvent,
} from "@/services/fleet-history.service";
import type { FleetRecord } from "@/services/fleet/fleet.service";
export interface FleetHistoryModalProps {
opened: boolean;
onClose: () => void;
entity: "driver" | "vehicle";
record: FleetRecord | null;
}
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
const titleFor = (entity: "driver" | "vehicle", record: FleetRecord | null) => {
const r = asObj(record);
if (entity === "vehicle") {
return `Vehicle history — ${r.plateNumber ?? r.code ?? ""}`.trim();
}
return `Driver history — ${[r.firstName, r.lastName]
.filter(Boolean)
.join(" ")}`.trim();
};
const mileLabel = (e: FleetHistoryEvent) =>
e.metadata?.mile === "LAST" ? "Last-mile" : "First-mile";
const arrow = (from?: string | null, to?: string | null) =>
`${from ?? "—"}${to ?? "—"}`;
function describe(e: FleetHistoryEvent, entity: "driver" | "vehicle") {
switch (e.eventType) {
case "DRIVER_REGISTERED":
return {
icon: <UserCheck size={14} />,
title: "Driver registered",
text: e.toValue ? `Status: ${e.toValue}` : "",
};
case "VEHICLE_REGISTERED":
return {
icon: <Truck size={14} />,
title: "Vehicle registered",
text: e.toValue ? `Availability: ${e.toValue}` : "",
};
case "DRIVER_ASSIGNED":
return {
icon: <UserPlus size={14} />,
title:
entity === "vehicle"
? `Driver assigned${e.label ? `: ${e.label}` : ""}`
: "Assigned to a vehicle",
text: "",
};
case "DRIVER_UNASSIGNED":
return {
icon: <UserMinus size={14} />,
title:
entity === "vehicle"
? `Driver unassigned${e.label ? `: ${e.label}` : ""}`
: "Unassigned from a vehicle",
text: "",
};
case "VEHICLE_STATUS_CHANGED":
return {
icon: <CircleDot size={14} />,
title: "Status changed",
text: arrow(e.fromValue, e.toValue),
};
case "VEHICLE_AVAILABILITY_CHANGED":
return {
icon: <Activity size={14} />,
title: `Marked ${e.toValue ?? ""}`.trim(),
text: e.fromValue ? arrow(e.fromValue, e.toValue) : "",
};
case "MILE_VEHICLE_ASSIGNED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle assigned`,
text: e.label ? `Status: ${e.label}` : "",
};
case "MILE_VEHICLE_RELEASED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)}: vehicle released`,
text: "",
};
case "MILE_STATUS_CHANGED":
return {
icon: <Route size={14} />,
title: `${mileLabel(e)} status`,
text: arrow(e.fromValue, e.toValue),
};
default:
return { icon: <CircleDot size={14} />, title: e.eventType, text: "" };
}
}
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const FleetHistoryModal = ({
opened,
onClose,
entity,
record,
}: FleetHistoryModalProps) => {
const id = asObj(record).id ? String(asObj(record).id) : "";
const { data, isLoading } = useQuery({
queryKey: ["fleet-history", entity, id],
queryFn: () =>
entity === "vehicle"
? fleetHistoryService.vehicle(id)
: fleetHistoryService.driver(id),
enabled: opened && Boolean(id),
});
const events = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{titleFor(entity, record)}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : events.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No history recorded yet. Activity appears here as this{" "}
{entity} is assigned, reassigned, or its status changes.
</Text>
) : (
<Timeline active={events.length} bulletSize={24} lineWidth={2}>
{events.map((e) => {
const d = describe(e, entity);
return (
<Timeline.Item key={e.id} bullet={d.icon} title={d.title}>
{d.text && (
<Text size="sm" c="dimmed">
{d.text}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(e.createdAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default FleetHistoryModal;

View File

@@ -1,4 +1,4 @@
import { Edit2, Trash2, Eye, Users, MoreVertical } from "lucide-react";
import { Edit2, Trash2, Eye, Users, MoreVertical, History } from "lucide-react";
import { ActionIcon, Menu, MenuItem, Tooltip } from "@mantine/core";
import { useNavigate } from "react-router-dom";
@@ -11,6 +11,7 @@ export interface FleetRecordActionsProps {
onEdit: (record: FleetRecord) => void;
onRemove: (record: FleetRecord) => void;
onAssignDriver?: (record: FleetRecord) => void;
onHistory?: (record: FleetRecord) => void;
layout?: "row" | "compact";
}
@@ -20,12 +21,16 @@ const FleetRecordActions = ({
onEdit,
onRemove,
onAssignDriver,
onHistory,
layout = "row",
}: FleetRecordActionsProps) => {
const navigate = useNavigate();
const removeLabel = config.removeActionLabel ?? "Delete";
const showDetail = Boolean(config.detailPath && "id" in record);
const isVehicle = config.slug === "vehicles";
const showHistory =
Boolean(onHistory) &&
(config.slug === "drivers" || config.slug === "vehicles");
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;
@@ -57,6 +62,14 @@ const FleetRecordActions = ({
>
Edit
</MenuItem>
{showHistory ? (
<MenuItem
onClick={() => onHistory?.(record)}
leftSection={<History size={14} strokeWidth={2} />}
>
History
</MenuItem>
) : null}
{showDetail ? (
<MenuItem
onClick={handleDetail}

View File

@@ -10,6 +10,7 @@ import { Navigate, useLocation } from "react-router-dom";
import FleetCardGrid from "@/components/fleet/FleetCardGrid";
import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
@@ -42,6 +43,7 @@ const FleetResourcePage = () => {
const [editing, setEditing] = useState<FleetRecord | null>(null);
const [removeTarget, setRemoveTarget] = useState<FleetRecord | null>(null);
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
const [selectedDriver, setSelectedDriver] = useState<string>("");
const { viewMode, setViewMode } = useFleetViewMode(slug);
@@ -273,6 +275,7 @@ const FleetResourcePage = () => {
}}
onRemove={setRemoveTarget}
onAssignDriver={setAssigningDriver}
onHistory={setHistoryTarget}
/>
</div>
),
@@ -581,6 +584,13 @@ const FleetResourcePage = () => {
</Group>
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
</Container>
);
};

View File

@@ -0,0 +1,38 @@
import { api as apiClient } from "../auth/http";
export type FleetEventType =
| "DRIVER_REGISTERED"
| "VEHICLE_REGISTERED"
| "DRIVER_ASSIGNED"
| "DRIVER_UNASSIGNED"
| "VEHICLE_STATUS_CHANGED"
| "VEHICLE_AVAILABILITY_CHANGED"
| "MILE_VEHICLE_ASSIGNED"
| "MILE_VEHICLE_RELEASED"
| "MILE_STATUS_CHANGED";
export interface FleetHistoryEvent {
id: string;
eventType: FleetEventType;
vehicleId?: string | null;
driverId?: string | null;
firstMileId?: string | null;
lastMileId?: string | null;
fromValue?: string | null;
toValue?: string | null;
label?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
/** Timeline of fleet events for a driver or a vehicle (newest first). */
export const fleetHistoryService = {
driver: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/drivers/${id}/history`)
.then((r) => r.data),
vehicle: (id: string) =>
apiClient
.get<FleetHistoryEvent[]>(`/vehicles/${id}/history`)
.then((r) => r.data),
};