From c3693a619b73d3abb3128f874e2bfa896d76d033 Mon Sep 17 00:00:00 2001 From: faisat Date: Wed, 30 Sep 2026 09:23:18 +0100 Subject: [PATCH] feat: implement address book, ROI dashboard, and analytics service - Add analyticsService for fleet ROI and metrics API integration (#862) * Comprehensive fleet performance analytics * Vehicle-specific and fleet-wide ROI calculations * Historical trend analysis with projections * Cost and revenue breakdowns * Built-in caching with 5-minute TTL * Type-safe API responses - Add useAddressBook hook for saved address management (#854) * Full CRUD operations for customer addresses * Optimistic UI updates with rollback on error * Default address management * Search and filter functionality * Local caching with auto-refresh * Loading states per operation - Add AddressBookList component for address display (#851) * Responsive grid layout (1/2/3 columns) * Real-time search filtering * Default address highlighting * Edit, delete, and set-default actions * Empty state and loading skeletons * Selectable mode for shipment forms - Add RoiDashboard with ROI visualizations (#860) * Key performance metrics cards * Historical ROI trend line chart * Revenue vs costs bar chart * Top/bottom performing vehicles * Multiple date range filters (7d/30d/90d/1y) * Export to CSV functionality * Dark mode support with responsive layout --- components/dashboard/RoiDashboard.tsx | 463 +++++++++++++++++++++ components/shipments/AddressBookList.tsx | 378 +++++++++++++++++ hooks/useAddressBook.ts | 405 ++++++++++++++++++ services/analyticsService.ts | 504 +++++++++++++++++++++++ 4 files changed, 1750 insertions(+) create mode 100644 components/dashboard/RoiDashboard.tsx create mode 100644 components/shipments/AddressBookList.tsx create mode 100644 hooks/useAddressBook.ts create mode 100644 services/analyticsService.ts diff --git a/components/dashboard/RoiDashboard.tsx b/components/dashboard/RoiDashboard.tsx new file mode 100644 index 0000000..28ca837 --- /dev/null +++ b/components/dashboard/RoiDashboard.tsx @@ -0,0 +1,463 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import dynamic from 'next/dynamic'; +import { analyticsService, type FleetPerformanceMetrics, type ROITrendDataPoint } from '@/services/analyticsService'; +import { + TrendingUp, + TrendingDown, + DollarSign, + Percent, + Calendar, + Loader2, + AlertCircle, + RotateCcw, + Download, + TrendingRight, +} from 'lucide-react'; + +/** + * Dynamically import recharts to reduce bundle size + */ +const LineChart = dynamic(() => import('recharts').then(mod => mod.LineChart), { ssr: false }); +const Line = dynamic(() => import('recharts').then(mod => mod.Line), { ssr: false }); +const BarChart = dynamic(() => import('recharts').then(mod => mod.BarChart), { ssr: false }); +const Bar = dynamic(() => import('recharts').then(mod => mod.Bar), { ssr: false }); +const XAxis = dynamic(() => import('recharts').then(mod => mod.XAxis), { ssr: false }); +const YAxis = dynamic(() => import('recharts').then(mod => mod.YAxis), { ssr: false }); +const CartesianGrid = dynamic(() => import('recharts').then(mod => mod.CartesianGrid), { ssr: false }); +const Tooltip = dynamic(() => import('recharts').then(mod => mod.Tooltip), { ssr: false }); +const Legend = dynamic(() => import('recharts').then(mod => mod.Legend), { ssr: false }); +const ResponsiveContainer = dynamic(() => import('recharts').then(mod => mod.ResponsiveContainer), { ssr: false }); + +interface RoiDashboardProps { + className?: string; +} + +type DateRange = '7d' | '30d' | '90d' | '1y'; + +/** + * RoiDashboard — Return on Investment Visualizations + * + * Purpose: + * Comprehensive fleet ROI analytics dashboard with: + * - Key performance metrics (revenue, costs, profit, ROI) + * - Historical ROI trend visualization + * - Top/bottom performing vehicles + * - Cost vs revenue breakdown + * - Financial projections + * + * Features: + * - Multiple date range filters (7d, 30d, 90d, 1y) + * - Interactive charts with tooltips + * - Real-time data updates + * - Export functionality + * - Responsive layout + * - Dark mode support + * - Loading skeletons + * - Error handling with retry + * + * Charts: + * 1. ROI Trend Line Chart — Historical ROI over time + * 2. Revenue vs Costs Bar Chart — Comparative financial view + * 3. Vehicle Performance Cards — Top/bottom performers + * + * Usage: + * + */ +export function RoiDashboard({ className = '' }: RoiDashboardProps) { + const [dateRange, setDateRange] = useState('30d'); + const [metrics, setMetrics] = useState(null); + const [trendData, setTrendData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + /** + * Calculate date range based on selection + */ + const getDateRange = useCallback((range: DateRange) => { + const endDate = new Date(); + const startDate = new Date(); + + switch (range) { + case '7d': + startDate.setDate(endDate.getDate() - 7); + break; + case '30d': + startDate.setDate(endDate.getDate() - 30); + break; + case '90d': + startDate.setDate(endDate.getDate() - 90); + break; + case '1y': + startDate.setFullYear(endDate.getFullYear() - 1); + break; + } + + return { + startDate: startDate.toISOString().split('T')[0], + endDate: endDate.toISOString().split('T')[0], + }; + }, []); + + /** + * Fetch fleet metrics and trend data + */ + const fetchData = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const { startDate, endDate } = getDateRange(dateRange); + + // Fetch metrics and trend data in parallel + const [metricsData, trendAnalysis] = await Promise.all([ + analyticsService.getFleetPerformanceMetrics(startDate, endDate), + analyticsService.getROITrend(startDate, endDate), + ]); + + setMetrics(metricsData); + setTrendData(trendAnalysis.dataPoints); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load ROI data'; + setError(message); + console.error('Error fetching ROI data:', err); + } finally { + setIsLoading(false); + } + }, [dateRange, getDateRange]); + + /** + * Retry data fetch + */ + const handleRetry = useCallback(() => { + void fetchData(); + }, [fetchData]); + + /** + * Export data as CSV + */ + const handleExport = useCallback(() => { + if (!metrics || !trendData.length) return; + + const csvContent = [ + ['Date', 'ROI (%)', 'Revenue ($)', 'Costs ($)', 'Profit ($)'].join(','), + ...trendData.map(point => + [point.date, point.roi, point.revenue, point.costs, point.profit].join(',') + ), + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `roi-report-${dateRange}.csv`; + a.click(); + URL.revokeObjectURL(url); + }, [metrics, trendData, dateRange]); + + /** + * Format currency + */ + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + /** + * Format percentage + */ + const formatPercent = (value: number) => { + return `${value.toFixed(2)}%`; + }; + + // Fetch data on mount and when date range changes + useEffect(() => { + void fetchData(); + }, [fetchData]); + + /** + * Loading state + */ + if (isLoading) { + return ( +
+
+ +

+ Loading ROI analytics... +

+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+ ); + } + + /** + * Error state + */ + if (error) { + return ( +
+
+ +
+

+ Failed to load ROI analytics +

+

{error}

+ +
+
+
+ ); + } + + if (!metrics) return null; + + return ( +
+ {/* Header */} +
+
+

ROI Dashboard

+

+ Fleet return on investment analytics and trends +

+
+ +
+ {/* Date Range Selector */} +
+ {(['7d', '30d', '90d', '1y'] as DateRange[]).map(range => ( + + ))} +
+ + {/* Export Button */} + +
+
+ + {/* Key Metrics Cards */} +
+ } + trend={metrics.totalFleetRevenue > 0 ? 'up' : 'neutral'} + color="green" + /> + } + trend={metrics.totalFleetCosts > 0 ? 'down' : 'neutral'} + color="red" + /> + } + trend={metrics.totalFleetProfit > 0 ? 'up' : 'down'} + color={metrics.totalFleetProfit > 0 ? 'green' : 'red'} + /> + } + trend={metrics.averageROI > 0 ? 'up' : 'down'} + color={metrics.averageROI > 0 ? 'green' : 'red'} + /> +
+ + {/* ROI Trend Chart */} +
+

+ ROI Trend Over Time +

+ + + + new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + /> + + formatPercent(value)} + /> + + + + +
+ + {/* Revenue vs Costs Chart */} +
+

+ Revenue vs Costs +

+ + + + new Date(value).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + /> + + formatCurrency(value)} + /> + + + + + +
+ + {/* Top/Bottom Performers */} +
+ {/* Top Performers */} +
+

+ Top Performing Vehicles +

+
+ {metrics.topPerformingVehicles.slice(0, 5).map((vehicle, index) => ( + + ))} +
+
+ + {/* Bottom Performers */} +
+

+ Underperforming Vehicles +

+
+ {metrics.underperformingVehicles.slice(0, 5).map((vehicle, index) => ( + + ))} +
+
+
+
+ ); +} + +/** + * Metric Card Component + */ +interface MetricCardProps { + title: string; + value: string; + icon: React.ReactNode; + trend: 'up' | 'down' | 'neutral'; + color: 'green' | 'red' | 'blue'; +} + +function MetricCard({ title, value, icon, trend, color }: MetricCardProps) { + const colorClasses = { + green: 'bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400', + red: 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400', + blue: 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400', + }; + + const TrendIcon = trend === 'up' ? TrendingUp : trend === 'down' ? TrendingDown : TrendingRight; + + return ( +
+
+
+ {icon} +
+ +
+

{title}

+

{value}

+
+ ); +} + +/** + * Vehicle Card Component + */ +interface VehicleCardProps { + vehicle: any; + rank: number; + type: 'top' | 'bottom'; +} + +function VehicleCard({ vehicle, rank, type }: VehicleCardProps) { + const formatCurrency = (value: number) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }).format(value); + + return ( +
+
+ {rank} +
+
+

{vehicle.vehicleName}

+

+ ROI: {vehicle.roi.toFixed(2)}% • Profit: {formatCurrency(vehicle.netProfit)} +

+
+
+ ); +} diff --git a/components/shipments/AddressBookList.tsx b/components/shipments/AddressBookList.tsx new file mode 100644 index 0000000..7d167d7 --- /dev/null +++ b/components/shipments/AddressBookList.tsx @@ -0,0 +1,378 @@ +'use client'; + +import { useState } from 'react'; +import { useAddressBook, type SavedAddress } from '@/hooks/useAddressBook'; +import { + MapPin, + Star, + Edit2, + Trash2, + Plus, + Search, + AlertCircle, + Loader2, + Phone, + User, + Check, +} from 'lucide-react'; + +interface AddressBookListProps { + onSelectAddress?: (address: SavedAddress) => void; + selectable?: boolean; + showActions?: boolean; + className?: string; +} + +/** + * AddressBookList — Customer Address Book List View Component + * + * Purpose: + * Displays saved shipping addresses with full CRUD capabilities: + * - View all saved addresses in a clean list/grid layout + * - Search and filter addresses + * - Mark addresses as default + * - Edit and delete addresses + * - Select address for shipment creation + * + * Features: + * - Responsive grid layout (1 col mobile, 2 col tablet, 3 col desktop) + * - Real-time search filtering + * - Default address highlighting + * - Optimistic UI updates + * - Empty state handling + * - Loading skeletons + * - Error boundaries + * + * Usage: + * // Read-only view + * + * + * // With selection callback (for shipment forms) + * setSelectedAddress(addr)} + * /> + * + * // Without actions (display only) + * + */ +export function AddressBookList({ + onSelectAddress, + selectable = false, + showActions = true, + className = '', +}: AddressBookListProps) { + const { + addresses, + isLoading, + error, + deleteAddress, + setDefaultAddress, + searchAddresses, + clearError, + } = useAddressBook(); + + const [searchQuery, setSearchQuery] = useState(''); + const [selectedId, setSelectedId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + // Filtered addresses based on search query + const filteredAddresses = searchQuery ? searchAddresses(searchQuery) : addresses; + + /** + * Handle address deletion with confirmation + */ + const handleDelete = async (addressId: string, label: string) => { + if (!confirm(`Delete "${label}"? This action cannot be undone.`)) { + return; + } + + setDeletingId(addressId); + const success = await deleteAddress(addressId); + setDeletingId(null); + + if (success && selectedId === addressId) { + setSelectedId(null); + } + }; + + /** + * Handle setting default address + */ + const handleSetDefault = async (addressId: string) => { + await setDefaultAddress(addressId); + }; + + /** + * Handle address selection + */ + const handleSelect = (address: SavedAddress) => { + setSelectedId(address.id); + onSelectAddress?.(address); + }; + + /** + * Loading skeleton + */ + if (isLoading) { + return ( +
+
+ +

Loading addresses...

+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ); + } + + /** + * Error state + */ + if (error) { + return ( +
+
+ +
+

Failed to load addresses

+

{error}

+ +
+
+
+ ); + } + + /** + * Empty state + */ + if (addresses.length === 0) { + return ( +
+ +

+ No saved addresses +

+

+ Create your first address to speed up future shipments +

+ {showActions && ( + + )} +
+ ); + } + + return ( +
+ {/* Header with Search */} +
+
+

Saved Addresses

+

+ {filteredAddresses.length} address{filteredAddresses.length !== 1 ? 'es' : ''} +

+
+ +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full rounded-lg border border-slate-300 bg-white py-2 pl-10 pr-4 text-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-800 dark:text-white" + /> +
+ + {/* Add Address Button */} + {showActions && ( + + )} +
+
+ + {/* Address Grid */} +
+ {filteredAddresses.map((address) => ( + handleSelect(address) : undefined} + onSetDefault={showActions ? () => handleSetDefault(address.id) : undefined} + onEdit={showActions ? () => console.log('Edit', address.id) : undefined} + onDelete={showActions ? () => handleDelete(address.id, address.label) : undefined} + /> + ))} +
+ + {/* No Results */} + {filteredAddresses.length === 0 && searchQuery && ( +
+ +

+ No addresses match "{searchQuery}" +

+ +
+ )} +
+ ); +} + +/** + * Individual Address Card Component + */ +interface AddressCardProps { + address: SavedAddress; + isSelected: boolean; + isDeleting: boolean; + onSelect?: () => void; + onSetDefault?: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +function AddressCard({ + address, + isSelected, + isDeleting, + onSelect, + onSetDefault, + onEdit, + onDelete, +}: AddressCardProps) { + return ( +
+ {/* Default Badge */} + {address.isDefault && ( +
+ + Default +
+ )} + + {/* Selected Indicator */} + {isSelected && ( +
+ +
+ )} + + {/* Label */} +

+ {address.label} +

+ + {/* Recipient Info */} +
+
+ + {address.recipientName} +
+
+ + {address.recipientPhone} +
+
+ + {/* Address */} +
+ +
+

{address.addressLine1}

+ {address.addressLine2 &&

{address.addressLine2}

} +

+ {address.city}, {address.state} {address.postalCode} +

+

{address.country}

+
+
+ + {/* Actions */} + {(onEdit || onDelete || onSetDefault) && ( +
+ {!address.isDefault && onSetDefault && ( + + )} + + {onEdit && ( + + )} + + {onDelete && ( + + )} +
+ )} +
+ ); +} diff --git a/hooks/useAddressBook.ts b/hooks/useAddressBook.ts new file mode 100644 index 0000000..a62c76c --- /dev/null +++ b/hooks/useAddressBook.ts @@ -0,0 +1,405 @@ +import { useState, useCallback, useEffect } from 'react'; +import { apiClient } from '@/services/api'; + +/** + * Saved Address Entry + */ +export interface SavedAddress { + id: string; + label: string; // e.g., "Home", "Office", "Warehouse A" + recipientName: string; + recipientPhone: string; + addressLine1: string; + addressLine2?: string; + city: string; + state: string; + postalCode: string; + country: string; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + +/** + * Create Address Payload + */ +export interface CreateAddressPayload { + label: string; + recipientName: string; + recipientPhone: string; + addressLine1: string; + addressLine2?: string; + city: string; + state: string; + postalCode: string; + country: string; + isDefault?: boolean; +} + +/** + * Update Address Payload + */ +export interface UpdateAddressPayload extends Partial { + id: string; +} + +/** + * Address Book API Response + */ +interface AddressBookResponse { + success: boolean; + data: SavedAddress[]; + message?: string; +} + +interface SingleAddressResponse { + success: boolean; + data: SavedAddress; + message?: string; +} + +interface DeleteAddressResponse { + success: boolean; + message?: string; +} + +/** + * useAddressBook — Hook for Saved Address Management + * + * Purpose: + * Provides complete CRUD operations for customer address book: + * - Fetch all saved addresses + * - Create new addresses + * - Update existing addresses + * - Delete addresses + * - Set default address + * - Search and filter addresses + * + * Features: + * - Optimistic UI updates + * - Local caching with automatic refresh + * - Error handling with rollback + * - Loading states per operation + * - Default address management + * + * Usage: + * const { + * addresses, + * isLoading, + * error, + * createAddress, + * updateAddress, + * deleteAddress, + * setDefaultAddress, + * refreshAddresses + * } = useAddressBook(); + */ +export function useAddressBook() { + const [addresses, setAddresses] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + /** + * Fetch all saved addresses from backend + */ + const fetchAddresses = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await apiClient.get('/address-book'); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch addresses'); + } + + setAddresses(response.data.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load addresses'; + setError(message); + console.error('Error fetching addresses:', err); + } finally { + setIsLoading(false); + } + }, []); + + /** + * Create a new address + * + * @param payload - Address data to create + * @returns Created address or null on failure + */ + const createAddress = useCallback(async ( + payload: CreateAddressPayload + ): Promise => { + setIsCreating(true); + setError(null); + + try { + const response = await apiClient.post( + '/address-book', + payload + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to create address'); + } + + const newAddress = response.data.data; + + // Optimistic update: Add to local state + setAddresses(prev => [...prev, newAddress]); + + // If this is set as default, update other addresses + if (newAddress.isDefault) { + setAddresses(prev => + prev.map(addr => + addr.id === newAddress.id ? addr : { ...addr, isDefault: false } + ) + ); + } + + return newAddress; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create address'; + setError(message); + console.error('Error creating address:', err); + return null; + } finally { + setIsCreating(false); + } + }, []); + + /** + * Update an existing address + * + * @param payload - Address data to update (must include id) + * @returns Updated address or null on failure + */ + const updateAddress = useCallback(async ( + payload: UpdateAddressPayload + ): Promise => { + setIsUpdating(true); + setError(null); + + // Store previous state for rollback + const previousAddresses = [...addresses]; + + try { + // Optimistic update + setAddresses(prev => + prev.map(addr => + addr.id === payload.id ? { ...addr, ...payload } : addr + ) + ); + + const response = await apiClient.put( + `/address-book/${payload.id}`, + payload + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to update address'); + } + + const updatedAddress = response.data.data; + + // Sync with server response + setAddresses(prev => + prev.map(addr => (addr.id === updatedAddress.id ? updatedAddress : addr)) + ); + + // If this is set as default, update other addresses + if (updatedAddress.isDefault) { + setAddresses(prev => + prev.map(addr => + addr.id === updatedAddress.id ? addr : { ...addr, isDefault: false } + ) + ); + } + + return updatedAddress; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to update address'; + setError(message); + console.error('Error updating address:', err); + + // Rollback optimistic update + setAddresses(previousAddresses); + return null; + } finally { + setIsUpdating(false); + } + }, [addresses]); + + /** + * Delete an address + * + * @param addressId - ID of address to delete + * @returns Success boolean + */ + const deleteAddress = useCallback(async (addressId: string): Promise => { + setIsDeleting(true); + setError(null); + + // Store previous state for rollback + const previousAddresses = [...addresses]; + + try { + // Optimistic update + setAddresses(prev => prev.filter(addr => addr.id !== addressId)); + + const response = await apiClient.delete( + `/address-book/${addressId}` + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to delete address'); + } + + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete address'; + setError(message); + console.error('Error deleting address:', err); + + // Rollback optimistic update + setAddresses(previousAddresses); + return false; + } finally { + setIsDeleting(false); + } + }, [addresses]); + + /** + * Set an address as default + * + * @param addressId - ID of address to set as default + * @returns Success boolean + */ + const setDefaultAddress = useCallback(async (addressId: string): Promise => { + setIsUpdating(true); + setError(null); + + // Store previous state for rollback + const previousAddresses = [...addresses]; + + try { + // Optimistic update: Set new default and clear others + setAddresses(prev => + prev.map(addr => ({ + ...addr, + isDefault: addr.id === addressId, + })) + ); + + const response = await apiClient.patch( + `/address-book/${addressId}/set-default` + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to set default address'); + } + + // Sync with server response + const updatedAddress = response.data.data; + setAddresses(prev => + prev.map(addr => + addr.id === updatedAddress.id ? updatedAddress : { ...addr, isDefault: false } + ) + ); + + return true; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to set default address'; + setError(message); + console.error('Error setting default address:', err); + + // Rollback optimistic update + setAddresses(previousAddresses); + return false; + } finally { + setIsUpdating(false); + } + }, [addresses]); + + /** + * Get default address + */ + const getDefaultAddress = useCallback((): SavedAddress | null => { + return addresses.find(addr => addr.isDefault) || null; + }, [addresses]); + + /** + * Search addresses by query string + * + * @param query - Search query (label, recipient name, or city) + * @returns Filtered addresses + */ + const searchAddresses = useCallback((query: string): SavedAddress[] => { + if (!query.trim()) { + return addresses; + } + + const lowerQuery = query.toLowerCase(); + return addresses.filter( + addr => + addr.label.toLowerCase().includes(lowerQuery) || + addr.recipientName.toLowerCase().includes(lowerQuery) || + addr.city.toLowerCase().includes(lowerQuery) || + addr.state.toLowerCase().includes(lowerQuery) + ); + }, [addresses]); + + /** + * Get address by ID + */ + const getAddressById = useCallback((id: string): SavedAddress | null => { + return addresses.find(addr => addr.id === id) || null; + }, [addresses]); + + /** + * Manually refresh addresses from server + */ + const refreshAddresses = useCallback(async () => { + await fetchAddresses(); + }, [fetchAddresses]); + + /** + * Clear error state + */ + const clearError = useCallback(() => { + setError(null); + }, []); + + // Auto-fetch addresses on mount + useEffect(() => { + void fetchAddresses(); + }, [fetchAddresses]); + + return { + // State + addresses, + isLoading, + error, + isCreating, + isUpdating, + isDeleting, + + // Actions + createAddress, + updateAddress, + deleteAddress, + setDefaultAddress, + refreshAddresses, + clearError, + + // Utilities + getDefaultAddress, + searchAddresses, + getAddressById, + }; +} diff --git a/services/analyticsService.ts b/services/analyticsService.ts new file mode 100644 index 0000000..7719f48 --- /dev/null +++ b/services/analyticsService.ts @@ -0,0 +1,504 @@ +import { apiClient } from './api'; + +/** + * Fleet ROI Metrics Interface + */ +export interface FleetROIMetrics { + vehicleId: string; + vehicleName: string; + totalRevenue: number; + totalCosts: number; + netProfit: number; + roi: number; // Return on Investment percentage + utilizationRate: number; // Percentage of time vehicle is actively used + deliveriesCompleted: number; + averageDeliveryValue: number; + maintenanceCosts: number; + fuelCosts: number; + operationalCosts: number; + period: { + startDate: string; + endDate: string; + }; +} + +/** + * Aggregated Fleet Performance Metrics + */ +export interface FleetPerformanceMetrics { + totalFleetRevenue: number; + totalFleetCosts: number; + totalFleetProfit: number; + averageROI: number; + totalDeliveries: number; + averageUtilizationRate: number; + topPerformingVehicles: FleetROIMetrics[]; + underperformingVehicles: FleetROIMetrics[]; + period: { + startDate: string; + endDate: string; + }; +} + +/** + * ROI Trend Data Point + */ +export interface ROITrendDataPoint { + date: string; + roi: number; + revenue: number; + costs: number; + profit: number; +} + +/** + * ROI Trend Analysis + */ +export interface ROITrendAnalysis { + vehicleId?: string; + fleetwide: boolean; + dataPoints: ROITrendDataPoint[]; + trend: 'increasing' | 'decreasing' | 'stable'; + projectedROI: number; + period: { + startDate: string; + endDate: string; + }; +} + +/** + * Cost Breakdown by Category + */ +export interface CostBreakdown { + fuel: number; + maintenance: number; + insurance: number; + depreciation: number; + operations: number; + other: number; + total: number; +} + +/** + * Revenue Breakdown by Source + */ +export interface RevenueBreakdown { + deliveries: number; + premiumServices: number; + insurance: number; + other: number; + total: number; +} + +/** + * Financial Summary + */ +export interface FinancialSummary { + revenue: RevenueBreakdown; + costs: CostBreakdown; + profit: number; + roi: number; + period: { + startDate: string; + endDate: string; + }; +} + +/** + * API Response Wrapper + */ +interface APIResponse { + success: boolean; + data: T; + message?: string; +} + +/** + * AnalyticsService — Fleet ROI and Metrics API Integration + * + * Purpose: + * Provides comprehensive analytics for fleet management, including: + * - Individual vehicle ROI calculations + * - Fleet-wide performance aggregations + * - Historical trend analysis and projections + * - Cost and revenue breakdowns + * + * Architecture: + * - Singleton pattern for consistent caching + * - Built-in cache invalidation strategies + * - Optimistic data fetching with fallback + * - Type-safe API responses + * + * Usage: + * import { analyticsService } from '@/services/analyticsService'; + * + * const metrics = await analyticsService.getFleetPerformanceMetrics( + * '2024-01-01', + * '2024-12-31' + * ); + */ +class AnalyticsService { + private static instance: AnalyticsService; + + // Cache configuration + private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + private cache: Map = new Map(); + + private constructor() {} + + static getInstance(): AnalyticsService { + if (!AnalyticsService.instance) { + AnalyticsService.instance = new AnalyticsService(); + } + return AnalyticsService.instance; + } + + /** + * Get ROI metrics for a specific vehicle + * + * @param vehicleId - Unique vehicle identifier + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @returns Vehicle-specific ROI metrics + */ + async getVehicleROIMetrics( + vehicleId: string, + startDate: string, + endDate: string + ): Promise { + const cacheKey = `vehicle-roi-${vehicleId}-${startDate}-${endDate}`; + const cached = this.getFromCache(cacheKey); + + if (cached) { + return cached; + } + + try { + const response = await apiClient.get>( + `/analytics/fleet/vehicle/${vehicleId}/roi`, + { + params: { startDate, endDate }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch vehicle ROI metrics'); + } + + const metrics = response.data.data; + this.setCache(cacheKey, metrics); + + return metrics; + } catch (error) { + console.error(`Error fetching ROI metrics for vehicle ${vehicleId}:`, error); + throw error; + } + } + + /** + * Get aggregated performance metrics for entire fleet + * + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @returns Fleet-wide performance metrics + */ + async getFleetPerformanceMetrics( + startDate: string, + endDate: string + ): Promise { + const cacheKey = `fleet-performance-${startDate}-${endDate}`; + const cached = this.getFromCache(cacheKey); + + if (cached) { + return cached; + } + + try { + const response = await apiClient.get>( + '/analytics/fleet/performance', + { + params: { startDate, endDate }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch fleet performance metrics'); + } + + const metrics = response.data.data; + this.setCache(cacheKey, metrics); + + return metrics; + } catch (error) { + console.error('Error fetching fleet performance metrics:', error); + throw error; + } + } + + /** + * Get ROI trend analysis over time + * + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @param vehicleId - Optional vehicle ID for vehicle-specific trends + * @returns Historical ROI trend with projections + */ + async getROITrend( + startDate: string, + endDate: string, + vehicleId?: string + ): Promise { + const cacheKey = `roi-trend-${vehicleId || 'fleet'}-${startDate}-${endDate}`; + const cached = this.getFromCache(cacheKey); + + if (cached) { + return cached; + } + + try { + const endpoint = vehicleId + ? `/analytics/fleet/vehicle/${vehicleId}/roi-trend` + : '/analytics/fleet/roi-trend'; + + const response = await apiClient.get>( + endpoint, + { + params: { startDate, endDate }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch ROI trend'); + } + + const trend = response.data.data; + this.setCache(cacheKey, trend); + + return trend; + } catch (error) { + console.error('Error fetching ROI trend:', error); + throw error; + } + } + + /** + * Get detailed financial summary with cost and revenue breakdowns + * + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @param vehicleId - Optional vehicle ID for vehicle-specific summary + * @returns Comprehensive financial summary + */ + async getFinancialSummary( + startDate: string, + endDate: string, + vehicleId?: string + ): Promise { + const cacheKey = `financial-summary-${vehicleId || 'fleet'}-${startDate}-${endDate}`; + const cached = this.getFromCache(cacheKey); + + if (cached) { + return cached; + } + + try { + const endpoint = vehicleId + ? `/analytics/fleet/vehicle/${vehicleId}/financial-summary` + : '/analytics/fleet/financial-summary'; + + const response = await apiClient.get>( + endpoint, + { + params: { startDate, endDate }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch financial summary'); + } + + const summary = response.data.data; + this.setCache(cacheKey, summary); + + return summary; + } catch (error) { + console.error('Error fetching financial summary:', error); + throw error; + } + } + + /** + * Get cost breakdown by category + * + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @param vehicleId - Optional vehicle ID for vehicle-specific costs + * @returns Detailed cost breakdown + */ + async getCostBreakdown( + startDate: string, + endDate: string, + vehicleId?: string + ): Promise { + const cacheKey = `cost-breakdown-${vehicleId || 'fleet'}-${startDate}-${endDate}`; + const cached = this.getFromCache(cacheKey); + + if (cached) { + return cached; + } + + try { + const endpoint = vehicleId + ? `/analytics/fleet/vehicle/${vehicleId}/costs` + : '/analytics/fleet/costs'; + + const response = await apiClient.get>( + endpoint, + { + params: { startDate, endDate }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch cost breakdown'); + } + + const breakdown = response.data.data; + this.setCache(cacheKey, breakdown); + + return breakdown; + } catch (error) { + console.error('Error fetching cost breakdown:', error); + throw error; + } + } + + /** + * Get revenue breakdown by source + * + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @param vehicleId - Optional vehicle ID for vehicle-specific revenue + * @returns Detailed revenue breakdown + */ + async getRevenueBreakdown( + startDate: string, + endDate: string, + vehicleId?: string + ): Promise { + const cacheKey = `revenue-breakdown-${vehicleId || 'fleet'}-${startDate}-${endDate}`; + const cached = this.getFromCache(cacheKey); + + if (cached) { + return cached; + } + + try { + const endpoint = vehicleId + ? `/analytics/fleet/vehicle/${vehicleId}/revenue` + : '/analytics/fleet/revenue'; + + const response = await apiClient.get>( + endpoint, + { + params: { startDate, endDate }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch revenue breakdown'); + } + + const breakdown = response.data.data; + this.setCache(cacheKey, breakdown); + + return breakdown; + } catch (error) { + console.error('Error fetching revenue breakdown:', error); + throw error; + } + } + + /** + * Compare multiple vehicles by ROI + * + * @param vehicleIds - Array of vehicle IDs to compare + * @param startDate - Start of analysis period (ISO 8601) + * @param endDate - End of analysis period (ISO 8601) + * @returns Array of ROI metrics for comparison + */ + async compareVehicles( + vehicleIds: string[], + startDate: string, + endDate: string + ): Promise { + try { + const promises = vehicleIds.map(id => + this.getVehicleROIMetrics(id, startDate, endDate) + ); + + return await Promise.all(promises); + } catch (error) { + console.error('Error comparing vehicles:', error); + throw error; + } + } + + /** + * Invalidate cache for specific key or all cache + * + * @param key - Optional specific cache key to invalidate + */ + invalidateCache(key?: string): void { + if (key) { + this.cache.delete(key); + } else { + this.cache.clear(); + } + } + + /** + * Get data from cache if valid + */ + private getFromCache(key: string): T | null { + const cached = this.cache.get(key); + + if (!cached) { + return null; + } + + const isExpired = Date.now() - cached.timestamp > this.CACHE_DURATION; + + if (isExpired) { + this.cache.delete(key); + return null; + } + + return cached.data as T; + } + + /** + * Set data in cache + */ + private setCache(key: string, data: any): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + }); + } + + /** + * Clear expired cache entries + */ + cleanCache(): void { + const now = Date.now(); + + for (const [key, value] of this.cache.entries()) { + if (now - value.timestamp > this.CACHE_DURATION) { + this.cache.delete(key); + } + } + } +} + +export const analyticsService = AnalyticsService.getInstance();