Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { render, screen, fireEvent, waitFor, renderHook } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PerformanceMetricsDashboard } from '@/components/dashboard/PerformanceMetricsDashboard';
import { usePerformanceMetrics } from '@/hooks/usePerformanceMetrics';
import api from '@/lib/api';

jest.mock('@/lib/api', () => ({ __esModule: true, default: { get: jest.fn() } }));

const mockApi = api as jest.Mocked<typeof api>;
const metrics = {
totalDeliveries: 1250,
completionRate: 96.4,
averageDeliveryMinutes: 42,
deliveryTimes: [{ date: '2026-01', averageMinutes: 42 }],
completionRates: [{ date: '2026-01', completed: 1205, cancelled: 45, rate: 96.4 }],
regionalVolume: [{ region: 'Lagos', volume: 700, percentage: 56 }],
driverRankings: [{ driverId: 'd1', driverName: 'Bola', deliveries: 120, completionRate: 98, averageDeliveryMinutes: 38, score: 97.5 }],
};

function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{children}</QueryClientProvider>;
}

describe('usePerformanceMetrics and PerformanceMetricsDashboard', () => {
beforeEach(() => {
jest.clearAllMocks();
mockApi.get.mockResolvedValue({ data: metrics } as never);
});

it('fetches performance metrics with filter parameters', async () => {
const { result } = renderHook(() => usePerformanceMetrics({ region: 'Lagos', limit: 10 }), { wrapper });
await waitFor(() => expect(result.current.metrics).toEqual(metrics));
expect(mockApi.get).toHaveBeenCalledWith('/analytics/performance', { params: { region: 'Lagos', limit: 10 } });
});

it('renders KPI cards and all dashboard widget sections', async () => {
render(<PerformanceMetricsDashboard />, { wrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'Performance metrics' })).toBeInTheDocument();
expect(screen.getByText('Total deliveries')).toBeInTheDocument();
expect(screen.getByText('Delivery times')).toBeInTheDocument();
expect(screen.getByText('Completion rates')).toBeInTheDocument();
expect(screen.getByText('Regional volume')).toBeInTheDocument();
expect(screen.getByText('Driver rankings')).toBeInTheDocument();
expect(screen.getByText(/Bola/)).toBeInTheDocument();
});

it('shows an error and supports retrying the dashboard request', async () => {
mockApi.get
.mockRejectedValueOnce(new Error('Metrics unavailable'))
.mockRejectedValueOnce(new Error('Metrics unavailable'))
.mockResolvedValueOnce({ data: metrics } as never);
render(<PerformanceMetricsDashboard />, { wrapper });
expect(await screen.findByRole('alert', {}, { timeout: 4_000 })).toHaveTextContent('Metrics unavailable');
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByText(/Bola/)).toBeInTheDocument();
});
});
64 changes: 64 additions & 0 deletions __tests__/components/dashboard/RoiDashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RoiDashboard } from '@/components/dashboard/RoiDashboard';
import { useRoiMetrics } from '@/hooks/useRoiMetrics';
import api from '@/lib/api';

jest.mock('@/lib/api', () => ({ __esModule: true, default: { get: jest.fn() } }));

const mockApi = api as jest.Mocked<typeof api>;
const metrics = {
totalRevenue: 12000,
totalCost: 8000,
totalProfit: 4000,
roi: 50,
returnTrends: [{ date: '2026-01', revenue: 12000, cost: 8000, roi: 50 }],
costBreakdown: [{ category: 'Fuel', amount: 3000, percentage: 37.5 }],
driverProfitability: [{ driverId: 'd1', driverName: 'Ada', revenue: 5000, cost: 3000, profit: 2000, roi: 66.7 }],
};

function wrapper({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>{children}</QueryClientProvider>;
}

describe('useRoiMetrics and RoiDashboard', () => {
beforeEach(() => {
jest.clearAllMocks();
mockApi.get.mockResolvedValue({ data: metrics } as never);
});

it('fetches ROI data from the analytics API and unwraps the response', async () => {
const { result } = renderHookWithRoi();
await waitFor(() => expect(result.current.metrics).toEqual(metrics));
expect(mockApi.get).toHaveBeenCalledWith('/analytics/roi', { params: {} });
expect(result.current.isError).toBe(false);
});

it('renders KPI, trend, cost, and driver widgets', async () => {
render(<RoiDashboard />, { wrapper });
expect(screen.getByRole('status')).toHaveTextContent('Loading ROI metrics');
expect(await screen.findByRole('heading', { name: 'ROI analytics' })).toBeInTheDocument();
expect(screen.getByText('Total revenue')).toBeInTheDocument();
expect(screen.getByText('Return trends')).toBeInTheDocument();
expect(screen.getByText('Cost breakdown')).toBeInTheDocument();
expect(screen.getByText('Driver profitability')).toBeInTheDocument();
expect(screen.getByText(/Ada/)).toBeInTheDocument();
});

it('shows an error and retries the request', async () => {
mockApi.get
.mockRejectedValueOnce(new Error('Analytics unavailable'))
.mockRejectedValueOnce(new Error('Analytics unavailable'))
.mockResolvedValueOnce({ data: metrics } as never);
render(<RoiDashboard />, { wrapper });
expect(await screen.findByRole('alert', {}, { timeout: 4_000 })).toHaveTextContent('Analytics unavailable');
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByText(/Ada/)).toBeInTheDocument();
});
});

function renderHookWithRoi() {
// Kept local to avoid a second test dependency while retaining QueryClient isolation.
const { renderHook } = require('@testing-library/react') as typeof import('@testing-library/react');
return renderHook(() => useRoiMetrics(), { wrapper });
}
89 changes: 89 additions & 0 deletions components/dashboard/RoiDashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client';

import { useRoiMetrics, type RoiMetricsParams } from '@/hooks/useRoiMetrics';

export interface RoiDashboardProps {
params?: RoiMetricsParams;
}

function LoadingState() {
return <p role="status" aria-live="polite">Loading ROI metrics…</p>;
}

export function RoiDashboard({ params }: RoiDashboardProps) {
const { metrics, isLoading, isError, error, refetch } = useRoiMetrics(params);

if (isLoading) return <LoadingState />;
if (isError) {
return (
<div role="alert" className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
<p>Unable to load ROI metrics{error?.message ? `: ${error.message}` : '.'}</p>
<button type="button" onClick={() => void refetch()} className="mt-3 underline">
Retry
</button>
</div>
);
}
if (!metrics) return <p>No ROI metrics available.</p>;

return (
<section aria-labelledby="roi-dashboard-title" className="space-y-6">
<header>
<h2 id="roi-dashboard-title" className="text-2xl font-semibold">ROI analytics</h2>
<p className="text-sm text-slate-600">Returns, costs, and profitability across your delivery network.</p>
</header>

<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" aria-label="ROI summary">
<MetricCard label="Total revenue" value={metrics.totalRevenue} />
<MetricCard label="Total cost" value={metrics.totalCost} />
<MetricCard label="Total profit" value={metrics.totalProfit} />
<MetricCard label="ROI" value={`${metrics.roi.toFixed(1)}%`} />
</div>

<div className="grid gap-6 lg:grid-cols-2">
<Widget title="Return trends" id="roi-return-trends">
<ul aria-label="Return trends" className="space-y-2">
{metrics.returnTrends.map((point) => (
<li key={point.date} className="flex justify-between">
<span>{point.date}</span><strong>{point.roi.toFixed(1)}%</strong>
</li>
))}
</ul>
</Widget>
<Widget title="Cost breakdown" id="roi-cost-breakdown">
<ul aria-label="Cost breakdown" className="space-y-2">
{metrics.costBreakdown.map((item) => (
<li key={item.category} className="flex justify-between">
<span>{item.category}</span><span>{item.percentage.toFixed(1)}%</span>
</li>
))}
</ul>
</Widget>
</div>

<Widget title="Driver profitability" id="roi-driver-profitability">
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<caption className="sr-only">Profitability by driver</caption>
<thead><tr><th scope="col" className="pr-4">Driver</th><th scope="col" className="pr-4">Profit</th><th scope="col">ROI</th></tr></thead>
<tbody>{metrics.driverProfitability.map((driver) => (
<tr key={driver.driverId} className="border-t border-slate-100">
<th scope="row" className="py-2 pr-4 font-medium">{driver.driverName}</th>
<td className="py-2 pr-4">{driver.profit.toFixed(2)}</td>
<td className="py-2">{driver.roi.toFixed(1)}%</td>
</tr>
))}</tbody>
</table>
</div>
</Widget>
</section>
);
}

function MetricCard({ label, value }: { label: string; value: number | string }) {
return <div className="rounded-lg border border-slate-200 bg-white p-4"><dt className="text-sm text-slate-600">{label}</dt><dd className="mt-1 text-xl font-semibold">{typeof value === 'number' ? value.toFixed(2) : value}</dd></div>;
}

function Widget({ title, id, children }: { title: string; id: string; children: React.ReactNode }) {
return <section aria-labelledby={id} className="rounded-lg border border-slate-200 bg-white p-5"><h3 id={id} className="mb-4 text-lg font-medium">{title}</h3>{children}</section>;
}
82 changes: 82 additions & 0 deletions hooks/usePerformanceMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client';

import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import api from '@/lib/api';

export interface DeliveryTimeMetric {
date: string;
averageMinutes: number;
targetMinutes?: number;
}

export interface CompletionRateMetric {
date: string;
completed: number;
cancelled: number;
rate: number;
}

export interface RegionalVolumeMetric {
region: string;
volume: number;
percentage: number;
}

export interface DriverPerformanceRanking {
driverId: string;
driverName: string;
deliveries: number;
completionRate: number;
averageDeliveryMinutes: number;
score: number;
}

export interface PerformanceMetrics {
totalDeliveries: number;
completionRate: number;
averageDeliveryMinutes: number;
deliveryTimes: DeliveryTimeMetric[];
completionRates: CompletionRateMetric[];
regionalVolume: RegionalVolumeMetric[];
driverRankings: DriverPerformanceRanking[];
}

export interface PerformanceMetricsParams {
startDate?: string;
endDate?: string;
region?: string;
limit?: number;
}

export const PERFORMANCE_METRICS_QUERY_KEY = ['analytics', 'performance'] as const;

function unwrapResponse<T>(payload: T | { data?: T }): T {
if (payload && typeof payload === 'object' && 'data' in payload && payload.data !== undefined) {
return payload.data as T;
}
return payload as T;
}

export interface UsePerformanceMetricsResult
extends Omit<UseQueryResult<PerformanceMetrics, Error>, 'data'> {
metrics: PerformanceMetrics | null;
}

export function usePerformanceMetrics(
params: PerformanceMetricsParams = {},
): UsePerformanceMetricsResult {
const query = useQuery<PerformanceMetrics, Error>({
queryKey: [...PERFORMANCE_METRICS_QUERY_KEY, params],
queryFn: async () => {
const response = await api.get<PerformanceMetrics | { data: PerformanceMetrics }>(
'/analytics/performance',
{ params },
);
return unwrapResponse<PerformanceMetrics>(response.data);
},
staleTime: 60_000,
retry: 1,
});

return { ...query, metrics: query.data ?? null };
}
72 changes: 72 additions & 0 deletions hooks/useRoiMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client';

import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import api from '@/lib/api';

export interface RoiTrendPoint {
date: string;
revenue: number;
cost: number;
roi: number;
}

export interface RoiCostBreakdownItem {
category: string;
amount: number;
percentage: number;
}

export interface DriverProfitability {
driverId: string;
driverName: string;
revenue: number;
cost: number;
profit: number;
roi: number;
}

export interface RoiMetrics {
totalRevenue: number;
totalCost: number;
totalProfit: number;
roi: number;
returnTrends: RoiTrendPoint[];
costBreakdown: RoiCostBreakdownItem[];
driverProfitability: DriverProfitability[];
}

export interface RoiMetricsParams {
startDate?: string;
endDate?: string;
region?: string;
driverId?: string;
}

export const ROI_METRICS_QUERY_KEY = ['analytics', 'roi'] as const;

function unwrapResponse<T>(payload: T | { data?: T }): T {
if (payload && typeof payload === 'object' && 'data' in payload && payload.data !== undefined) {
return payload.data as T;
}
return payload as T;
}

export interface UseRoiMetricsResult extends Omit<UseQueryResult<RoiMetrics, Error>, 'data'> {
metrics: RoiMetrics | null;
}

export function useRoiMetrics(params: RoiMetricsParams = {}): UseRoiMetricsResult {
const query = useQuery<RoiMetrics, Error>({
queryKey: [...ROI_METRICS_QUERY_KEY, params],
queryFn: async () => {
const response = await api.get<RoiMetrics | { data: RoiMetrics }>('/analytics/roi', {
params,
});
return unwrapResponse<RoiMetrics>(response.data);
},
staleTime: 60_000,
retry: 1,
});

return { ...query, metrics: query.data ?? null };
}