diff --git a/components/__tests__/DashboardOverview.test.tsx b/components/__tests__/DashboardOverview.test.tsx index a7a82daa..cb7e7eea 100644 --- a/components/__tests__/DashboardOverview.test.tsx +++ b/components/__tests__/DashboardOverview.test.tsx @@ -1,20 +1,16 @@ -// __tests__/components/DashboardOverview.test.tsx - import React from 'react'; import { render, screen } from '@testing-library/react'; -import DashboardOverview from '@/components/dashboard/DashboardOverview'; // Adjust path as needed -import { useDashboardData } from '@/hooks/useDashboardData'; // Adjust path to your data hook +import DashboardOverview from '@/components/dashboard/DashboardOverview'; +import { useDashboardData } from '@/hooks/useDashboardData'; -// Mock the data fetching hook to control loading states jest.mock('@/hooks/useDashboardData'); -describe('Component: Skeleton Loading States for Dashboard', () => { +describe('DashboardOverview', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('renders skeleton UI placeholders when data is loading', () => { - // Force the loading state to true + it('renders skeleton placeholders while loading', () => { (useDashboardData as jest.Mock).mockReturnValue({ data: null, isLoading: true, @@ -23,20 +19,28 @@ describe('Component: Skeleton Loading States for Dashboard', () => { render(); - // Assert that skeleton elements are rendered - const skeletons = screen.getAllByTestId('skeleton-loader'); - expect(skeletons.length).toBeGreaterThan(0); - - // Assert that actual data content is not present - expect(screen.queryByText(/total deliveries/i)).not.toBeInTheDocument(); + expect(screen.getAllByTestId('skeleton-loader').length).toBeGreaterThan(0); + expect(screen.queryByText(/dashboard overview/i)).not.toBeInTheDocument(); }); - it('removes skeleton loaders and renders actual data when loading completes', () => { - // Simulate a successful data fetch + it('renders metrics, active shipments, and quick actions when data loads', () => { (useDashboardData as jest.Mock).mockReturnValue({ data: { totalDeliveries: 1450, activeDrivers: 32, + metrics: [ + { label: 'Completed', value: '1284' }, + { label: 'Escrowed', value: '$245k' }, + ], + shipments: [ + { + id: 'SC-1048', + route: 'Lagos → Abuja', + status: 'In transit', + eta: '18 min', + }, + ], + quickActions: ['Create delivery', 'Assign driver'], }, isLoading: false, error: null, @@ -44,15 +48,18 @@ describe('Component: Skeleton Loading States for Dashboard', () => { render(); - // Assert that skeletons are removed - expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument(); - - // Assert that real data is rendered + expect( + screen.getByRole('heading', { name: /dashboard overview/i }) + ).toBeInTheDocument(); expect(screen.getByText('1450')).toBeInTheDocument(); expect(screen.getByText('32')).toBeInTheDocument(); + expect(screen.getByText(/active shipments/i)).toBeInTheDocument(); + expect(screen.getByText(/quick actions/i)).toBeInTheDocument(); + expect(screen.getByText('SC-1048')).toBeInTheDocument(); + expect(screen.getByText('Create delivery')).toBeInTheDocument(); }); - it('renders an error boundary or state if data fetching fails', () => { + it('renders an error state if data fetching fails', () => { (useDashboardData as jest.Mock).mockReturnValue({ data: null, isLoading: false, @@ -62,6 +69,8 @@ describe('Component: Skeleton Loading States for Dashboard', () => { render(); expect(screen.queryByTestId('skeleton-loader')).not.toBeInTheDocument(); - expect(screen.getByText(/failed to fetch dashboard metrics/i)).toBeInTheDocument(); + expect( + screen.getByText(/failed to fetch dashboard metrics/i) + ).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/components/dashboard/DashboardOverview.tsx b/components/dashboard/DashboardOverview.tsx new file mode 100644 index 00000000..c92bee95 --- /dev/null +++ b/components/dashboard/DashboardOverview.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useDashboardData } from '@/hooks/useDashboardData'; + +export default function DashboardOverview() { + const { data, isLoading, error } = useDashboardData(); + + if (isLoading) { + return ( +
+
+
+
+
+
+
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+
+ Failed to fetch dashboard metrics +
+
+ ); + } + + if (!data) { + return ( +
+

No dashboard data available.

+
+ ); + } + + return ( +
+
+

+ Dashboard Overview +

+

+ Live logistics performance and shipment activity +

+
+ +
+ {data.metrics.map((metric) => ( +
+

+ {metric.label} +

+

+ {metric.value} +

+
+ ))} +
+

+ Total Deliveries +

+

+ {data.totalDeliveries} +

+
+
+

+ Active Drivers +

+

+ {data.activeDrivers} +

+
+
+ +
+
+

+ Active shipments +

+
    + {data.shipments.map((shipment) => ( +
  • +
    +

    + {shipment.id} +

    +

    + {shipment.route} +

    +
    +
    +

    + {shipment.status} +

    +

    + {shipment.eta} +

    +
    +
  • + ))} +
+
+ +
+

+ Quick actions +

+
    + {data.quickActions.map((action) => ( +
  • + {action} +
  • + ))} +
+
+
+
+ ); +} diff --git a/components/layout/NotificationCenter.tsx b/components/layout/NotificationCenter.tsx index d2586d12..77d646e7 100644 --- a/components/layout/NotificationCenter.tsx +++ b/components/layout/NotificationCenter.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo } from 'react'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { useNotifications } from '@/hooks/useNotifications'; @@ -57,7 +58,7 @@ function NotificationItem({ width: '8px', height: '8px', borderRadius: '50%', - background: read ? 'transparent' : typeColors[type] ?? '#6b7280', + background: read ? 'transparent' : (typeColors[type] ?? '#6b7280'), marginTop: '6px', flexShrink: 0, }} @@ -102,6 +103,21 @@ export function NotificationCenter() { const { notifications, unreadCount, isLoading, markAllAsRead } = useNotifications(); + const orderedNotifications = useMemo( + () => + [...notifications].sort((a, b) => { + const timeA = new Date(a.createdAt).getTime(); + const timeB = new Date(b.createdAt).getTime(); + + if (Number.isNaN(timeA) && Number.isNaN(timeB)) return 0; + if (Number.isNaN(timeA)) return 1; + if (Number.isNaN(timeB)) return -1; + + return timeB - timeA; + }), + [notifications] + ); + return ( {/* Bell trigger button */} @@ -171,7 +187,9 @@ export function NotificationCenter() { borderBottom: '1px solid #e5e7eb', }} > -

+

Notifications {unreadCount > 0 && ( )} - {notifications.map((n) => ( + {orderedNotifications.map((n) => ( ); -} \ No newline at end of file +} diff --git a/components/layout/__tests__/NotificationCenter.test.tsx b/components/layout/__tests__/NotificationCenter.test.tsx index fa1304ff..e0226723 100644 --- a/components/layout/__tests__/NotificationCenter.test.tsx +++ b/components/layout/__tests__/NotificationCenter.test.tsx @@ -42,7 +42,7 @@ describe('NotificationCenter', () => { it('should render the bell icon button', () => { render(); expect( - screen.getByRole('button', { name: /notifications/i }), + screen.getByRole('button', { name: /notifications/i }) ).toBeInTheDocument(); }); @@ -88,15 +88,51 @@ describe('NotificationCenter', () => { render(); expect(screen.getByText('Delivery Update')).toBeInTheDocument(); expect( - screen.getByText('Your package is out for delivery.'), + screen.getByText('Your package is out for delivery.') ).toBeInTheDocument(); }); + it('prepends newer shipment status updates before older notifications', () => { + mockUseNotifications.mockReturnValue({ + ...baseReturn, + notifications: [ + { + id: 'older', + title: 'Shipment picked up', + message: 'Driver has collected your package.', + type: 'delivery', + read: false, + createdAt: '2026-04-28T09:30:00Z', + }, + { + id: 'newer', + title: 'Shipment in transit', + message: 'Package is now moving to the destination.', + type: 'delivery', + read: false, + createdAt: '2026-04-28T10:15:00Z', + }, + ], + unreadCount: 2, + }); + + const { container } = render(); + + const older = screen.getByText('Shipment picked up'); + const newer = screen.getByText('Shipment in transit'); + + expect( + newer.compareDocumentPosition(older) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(container.textContent).toContain('Shipment in transit'); + expect(container.textContent).toContain('Shipment picked up'); + }); + it('should show "Mark all as read" button when unread count > 0', () => { mockUseNotifications.mockReturnValue({ ...baseReturn, unreadCount: 3 }); render(); expect( - screen.getByRole('button', { name: /mark all as read/i }), + screen.getByRole('button', { name: /mark all as read/i }) ).toBeInTheDocument(); }); @@ -112,4 +148,4 @@ describe('NotificationCenter', () => { render(); expect(screen.getByText(/loading notifications/i)).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/context/NotificationContext.tsx b/context/NotificationContext.tsx index 58a5eefa..245218b9 100644 --- a/context/NotificationContext.tsx +++ b/context/NotificationContext.tsx @@ -25,7 +25,21 @@ interface NotificationContextValue { markAllAsRead: () => void; } -const NotificationContext = createContext(null); +const NotificationContext = createContext( + null +); + +const sortNotificationsByNewestFirst = (items: Notification[]) => + [...items].sort((a, b) => { + const timeA = new Date(a.createdAt).getTime(); + const timeB = new Date(b.createdAt).getTime(); + + if (Number.isNaN(timeA) && Number.isNaN(timeB)) return 0; + if (Number.isNaN(timeA)) return 1; + if (Number.isNaN(timeB)) return -1; + + return timeB - timeA; + }); export function NotificationProvider({ children }: { children: ReactNode }) { const [notifications, setNotificationsState] = useState([]); @@ -33,18 +47,18 @@ export function NotificationProvider({ children }: { children: ReactNode }) { const unreadCount = notifications.filter((n) => !n.read).length; const setNotifications = useCallback((items: Notification[]) => { - setNotificationsState(items); + setNotificationsState(sortNotificationsByNewestFirst(items)); }, []); // Called directly by the WebSocket handler — updates badge immediately const addNotification = useCallback((notification: Notification) => { - setNotificationsState((prev) => [notification, ...prev]); + setNotificationsState((prev) => + sortNotificationsByNewestFirst([notification, ...prev]) + ); }, []); const markAllAsRead = useCallback(() => { - setNotificationsState((prev) => - prev.map((n) => ({ ...n, read: true })), - ); + setNotificationsState((prev) => prev.map((n) => ({ ...n, read: true }))); }, []); return ( @@ -66,8 +80,8 @@ export function useNotificationContext(): NotificationContextValue { const ctx = useContext(NotificationContext); if (!ctx) { throw new Error( - 'useNotificationContext must be used within NotificationProvider', + 'useNotificationContext must be used within NotificationProvider' ); } return ctx; -} \ No newline at end of file +} diff --git a/features/deliveries/components/__tests__/ProofUpload.test.tsx b/features/deliveries/components/__tests__/ProofUpload.test.tsx new file mode 100644 index 00000000..47c630c2 --- /dev/null +++ b/features/deliveries/components/__tests__/ProofUpload.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ProofUpload } from '@/features/deliveries/components/ProofUpload'; +import { useProofUpload } from '@/hooks/useProofUpload'; + +jest.mock('@/hooks/useProofUpload'); + +const mockedUseProofUpload = useProofUpload as jest.MockedFunction< + typeof useProofUpload +>; + +const makeHookState = ( + overrides: Partial> = {} +) => ({ + uploadProgress: 0, + compressionProgress: 0, + isUploading: false, + isCompressing: false, + uploadedProofs: [], + errors: [], + handleFileCapture: jest.fn(), + handleCameraCapture: jest.fn(), + clearUploadedProofs: jest.fn(), + removeProof: jest.fn(), + ...overrides, +}); + +describe('ProofUpload', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the empty upload state for a delivery receipt', () => { + mockedUseProofUpload.mockReturnValue(makeHookState()); + + render(); + + expect( + screen.getByText(/drag and drop an image or click to select/i) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /start camera/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /select file/i }) + ).toBeInTheDocument(); + }); + + it('displays validation errors returned by the proof upload API flow', () => { + mockedUseProofUpload.mockReturnValue( + makeHookState({ + errors: ['Please select an image file'], + }) + ); + + render(); + + expect(screen.getByText('Please select an image file')).toBeInTheDocument(); + }); + + it('shows submission progress while the upload is in flight', () => { + mockedUseProofUpload.mockReturnValue( + makeHookState({ + isUploading: true, + uploadProgress: 68, + }) + ); + + render(); + + expect(screen.getByText(/uploading/i)).toBeInTheDocument(); + expect(screen.getByText('68%')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /start camera/i }) + ).toBeDisabled(); + expect(screen.getByRole('button', { name: /select file/i })).toBeDisabled(); + }); + + it('renders a completed proof record from the successful upload state', () => { + mockedUseProofUpload.mockReturnValue( + makeHookState({ + uploadedProofs: [ + { + filename: 'proof-receipt.jpg', + fileSize: 2048, + originalSize: 4096, + isCompressed: true, + status: 'completed', + uploadedAt: '2026-10-18T00:00:00.000Z', + compressionProgress: 100, + }, + ], + }) + ); + + render(); + + expect(screen.getByText('Uploaded Proofs (1)')).toBeInTheDocument(); + expect(screen.getByText('proof-receipt.jpg')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument(); + }); + + it('passes the selected file through the validation/upload handler', async () => { + const user = userEvent.setup(); + const handleFileCapture = jest.fn(); + mockedUseProofUpload.mockReturnValue( + makeHookState({ + handleFileCapture, + }) + ); + + render(); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const file = new File(['receipt-data'], 'delivery-receipt.png', { + type: 'image/png', + }); + + await user.upload(fileInput, file); + + expect(handleFileCapture).toHaveBeenCalledWith(file); + }); +}); diff --git a/hooks/useDashboardData.ts b/hooks/useDashboardData.ts new file mode 100644 index 00000000..1a6f84e9 --- /dev/null +++ b/hooks/useDashboardData.ts @@ -0,0 +1,58 @@ +export interface DashboardShipment { + id: string; + route: string; + status: string; + eta: string; +} + +export interface DashboardOverviewData { + totalDeliveries: number; + activeDrivers: number; + metrics: Array<{ label: string; value: string | number }>; + shipments: DashboardShipment[]; + quickActions: string[]; +} + +export function useDashboardData() { + const data: DashboardOverviewData = { + totalDeliveries: 1450, + activeDrivers: 32, + metrics: [ + { label: 'Completed', value: '1284' }, + { label: 'Pending', value: '96' }, + { label: 'Escrowed', value: '$245k' }, + ], + shipments: [ + { + id: 'SC-1048', + route: 'Lagos → Abuja', + status: 'In transit', + eta: '18 min', + }, + { + id: 'SC-1021', + route: 'Accra → Kumasi', + status: 'Assigned', + eta: '32 min', + }, + { + id: 'SC-1007', + route: 'Nairobi → Mombasa', + status: 'Delayed', + eta: 'Requires review', + }, + ], + quickActions: [ + 'Create delivery', + 'Assign driver', + 'View analytics', + 'Review escrow', + ], + }; + + return { + data, + isLoading: false, + error: null, + }; +}