diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b434ea6..b4283cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ const BlockchainLedger = lazy(() => import('./pages/BlockchainLedger/BlockchainL const Settlements = lazy(() => import('./pages/Settlements/Settlements')); const Analytics = lazy(() => import('./pages/Analytics/Analytics')); const RevenueAnalytics = lazy(() => import('./pages/Analytics/RevenueAnalytics')); +const ExceptionDashboard = lazy(() => import('./pages/dashboard/ExceptionDashboard')); const CompanySettings = lazy(() => import('./pages/dashboard/Company/Settings/CompanySettings')); const Settings = lazy(() => import('./pages/Settings/Settings')); const HelpCenter = lazy(() => import('./pages/HelpCenter/HelpCenter')); @@ -82,6 +83,7 @@ const router = createBrowserRouter([ { path: '/dashboard/payments', element: S() }, { path: '/dashboard/analytics', element: S() }, { path: '/dashboard/analytics/revenue', element: S() }, + { path: '/dashboard/analytics/exceptions', element: S() }, { path: '/dashboard/team', element: S() }, { path: '/dashboard/shipments/create', element: }, { path: '/dashboard/company-settings', element: S() }, diff --git a/frontend/src/components/Navbar/Navbar.tsx b/frontend/src/components/Navbar/Navbar.tsx index 209de4a..f68a49f 100644 --- a/frontend/src/components/Navbar/Navbar.tsx +++ b/frontend/src/components/Navbar/Navbar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { Menu, X } from 'lucide-react'; import { useScrollSpy } from '../../hooks/useScrollSpy'; @@ -15,6 +15,7 @@ const navLinks = [ const Navbar: React.FC = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); + const [companyLogo, setCompanyLogo] = useState(null); const location = useLocation(); const isLandingPage = location.pathname === '/'; @@ -36,6 +37,11 @@ const Navbar: React.FC = () => { } }; + useEffect(() => { + const storedLogo = window.localStorage.getItem('navin-company-logo'); + setCompanyLogo(storedLogo ?? null); + }, [location.pathname]); + const handleLogoClick = () => setIsMenuOpen(false); return ( @@ -47,7 +53,11 @@ const Navbar: React.FC = () => { className="flex items-center gap-2 no-underline font-albert font-normal text-[30px] text-white transition-opacity duration-300 hover:opacity-80 absolute left-8" onClick={handleLogoClick} > - Navin Logo + {companyLogo ? ( + Company logo + ) : ( + Navin Logo + )} Navin diff --git a/frontend/src/pages/Settings/CompanyProfile/CompanyProfile.test.tsx b/frontend/src/pages/Settings/CompanyProfile/CompanyProfile.test.tsx new file mode 100644 index 0000000..e30b8c9 --- /dev/null +++ b/frontend/src/pages/Settings/CompanyProfile/CompanyProfile.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CompanyProfile from './CompanyProfile'; + +describe('CompanyProfile', () => { + it('shows validation errors and unsaved state for contact details', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText('Company Profile')).toBeInTheDocument(); + + const emailInput = screen.getByLabelText(/email/i); + const phoneInput = screen.getByLabelText(/phone/i); + const websiteInput = screen.getByLabelText(/website/i); + + await user.type(emailInput, 'not-an-email'); + await user.type(phoneInput, 'abc'); + await user.type(websiteInput, 'not-a-url'); + + expect(screen.getByLabelText(/unsaved changes/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /save contact details/i })); + + expect(await screen.findByText(/please enter a valid email address/i)).toBeInTheDocument(); + expect(screen.getByText(/please enter a valid phone number/i)).toBeInTheDocument(); + expect(screen.getByText(/please enter a valid website url/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/Settings/CompanyProfile/CompanyProfile.tsx b/frontend/src/pages/Settings/CompanyProfile/CompanyProfile.tsx new file mode 100644 index 0000000..162bd9a --- /dev/null +++ b/frontend/src/pages/Settings/CompanyProfile/CompanyProfile.tsx @@ -0,0 +1,550 @@ +import React, { useEffect, useState } from 'react'; +import { Building2, MapPin, Save, UploadCloud } from 'lucide-react'; +import FileUpload from '../../../components/ui/FileUpload'; + +type CompanyInfoData = { + name: string; + registrationNumber: string; + industry: string; +}; + +type ContactDetailsData = { + email: string; + phone: string; + website: string; +}; + +type AddressData = { + addressLine1: string; + city: string; + state: string; + postalCode: string; + country: string; +}; + +type BrandingData = { + logoDataUrl: string | null; + logoName: string; +}; + +type CompanyProfileState = { + companyInfo: CompanyInfoData; + contactDetails: ContactDetailsData; + address: AddressData; + branding: BrandingData; +}; + +const STORAGE_KEY = 'navin-company-profile'; +const LOGO_STORAGE_KEY = 'navin-company-logo'; + +const readStoredProfile = (): CompanyProfileState => { + if (typeof window === 'undefined') { + return getInitialProfile(); + } + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return getInitialProfile(); + } + + const parsed = JSON.parse(raw) as Partial; + return { + companyInfo: { + name: parsed.companyInfo?.name ?? '', + registrationNumber: parsed.companyInfo?.registrationNumber ?? '', + industry: parsed.companyInfo?.industry ?? '', + }, + contactDetails: { + email: parsed.contactDetails?.email ?? '', + phone: parsed.contactDetails?.phone ?? '', + website: parsed.contactDetails?.website ?? '', + }, + address: { + addressLine1: parsed.address?.addressLine1 ?? '', + city: parsed.address?.city ?? '', + state: parsed.address?.state ?? '', + postalCode: parsed.address?.postalCode ?? '', + country: parsed.address?.country ?? '', + }, + branding: { + logoDataUrl: parsed.branding?.logoDataUrl ?? null, + logoName: parsed.branding?.logoName ?? '', + }, + }; + } catch { + return getInitialProfile(); + } +}; + +function getInitialProfile(): CompanyProfileState { + return { + companyInfo: { + name: '', + registrationNumber: '', + industry: '', + }, + contactDetails: { + email: '', + phone: '', + website: '', + }, + address: { + addressLine1: '', + city: '', + state: '', + postalCode: '', + country: '', + }, + branding: { + logoDataUrl: null, + logoName: '', + }, + }; +} + +function validateEmail(email: string): string | null { + if (!email) return null; + const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return pattern.test(email) ? null : 'Please enter a valid email address.'; +} + +function validatePhone(phone: string): string | null { + if (!phone) return null; + const pattern = /^\+?[0-9\s().-]{7,15}$/; + return pattern.test(phone) ? null : 'Please enter a valid phone number.'; +} + +function validateWebsite(website: string): string | null { + if (!website) return null; + try { + const parsed = new URL(website.includes('://') ? website : `https://${website}`); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? null + : 'Please enter a valid website URL.'; + } catch { + return 'Please enter a valid website URL.'; + } +} + +function createSquarePreview(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const img = new Image(); + img.onload = () => { + const size = Math.min(img.width, img.height); + const canvas = document.createElement('canvas'); + canvas.width = 320; + canvas.height = 320; + const context = canvas.getContext('2d'); + + if (!context) { + reject(new Error('Unable to create image preview.')); + return; + } + + const sx = (img.width - size) / 2; + const sy = (img.height - size) / 2; + context.drawImage(img, sx, sy, size, size, 0, 0, canvas.width, canvas.height); + resolve(canvas.toDataURL('image/png')); + }; + img.onerror = () => reject(new Error('Unable to read image.')); + img.src = reader.result as string; + }; + reader.onerror = () => reject(new Error('Unable to read image.')); + reader.readAsDataURL(file); + }); +} + +const CompanyProfile: React.FC = () => { + const [profile, setProfile] = useState(() => readStoredProfile()); + const [savedProfile, setSavedProfile] = useState(() => readStoredProfile()); + const [companyInfoErrors, setCompanyInfoErrors] = useState>({}); + const [contactErrors, setContactErrors] = useState>({}); + const [addressErrors, setAddressErrors] = useState>({}); + const [brandingErrors, setBrandingErrors] = useState>({}); + const [logoPreview, setLogoPreview] = useState(null); + const [logoName, setLogoName] = useState(''); + + useEffect(() => { + const stored = readStoredProfile(); + setProfile(stored); + setSavedProfile(stored); + setLogoPreview(stored.branding.logoDataUrl ?? null); + setLogoName(stored.branding.logoName ?? ''); + }, []); + + const updateProfile = (updater: (current: CompanyProfileState) => CompanyProfileState) => { + setProfile((current) => updater(current)); + }; + + const isDirty = { + companyInfo: + JSON.stringify(profile.companyInfo) !== JSON.stringify(savedProfile.companyInfo), + contactDetails: + JSON.stringify(profile.contactDetails) !== JSON.stringify(savedProfile.contactDetails), + address: JSON.stringify(profile.address) !== JSON.stringify(savedProfile.address), + branding: Boolean(logoPreview && logoPreview !== savedProfile.branding.logoDataUrl), + }; + + const saveCompanyInfo = () => { + const nextErrors: Record = {}; + if (!profile.companyInfo.name.trim()) nextErrors.name = 'Company name is required.'; + if (!profile.companyInfo.registrationNumber.trim()) nextErrors.registrationNumber = 'Registration number is required.'; + if (!profile.companyInfo.industry.trim()) nextErrors.industry = 'Industry is required.'; + + setCompanyInfoErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) { + return; + } + + const nextProfile = { ...profile, companyInfo: profile.companyInfo }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextProfile)); + window.localStorage.setItem('navin-company-name', profile.companyInfo.name); + setSavedProfile(nextProfile); + setCompanyInfoErrors({}); + }; + + const saveContactDetails = () => { + const nextErrors: Record = {}; + const emailError = validateEmail(profile.contactDetails.email); + const phoneError = validatePhone(profile.contactDetails.phone); + const websiteError = validateWebsite(profile.contactDetails.website); + + if (emailError) nextErrors.email = emailError; + if (phoneError) nextErrors.phone = phoneError; + if (websiteError) nextErrors.website = websiteError; + + setContactErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) { + return; + } + + const nextProfile = { ...profile, contactDetails: profile.contactDetails }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextProfile)); + setSavedProfile(nextProfile); + setContactErrors({}); + }; + + const saveAddress = () => { + const nextErrors: Record = {}; + if (!profile.address.addressLine1.trim()) nextErrors.addressLine1 = 'Address line 1 is required.'; + if (!profile.address.city.trim()) nextErrors.city = 'City is required.'; + if (!profile.address.country.trim()) nextErrors.country = 'Country is required.'; + + setAddressErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) { + return; + } + + const nextProfile = { ...profile, address: profile.address }; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextProfile)); + setSavedProfile(nextProfile); + setAddressErrors({}); + }; + + const saveBranding = () => { + if (!logoPreview) { + setBrandingErrors({ logo: 'Upload a company logo before saving.' }); + return; + } + + const nextProfile = { + ...profile, + branding: { + logoDataUrl: logoPreview, + logoName, + }, + }; + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextProfile)); + window.localStorage.setItem(LOGO_STORAGE_KEY, logoPreview); + setSavedProfile(nextProfile); + setBrandingErrors({}); + }; + + const handleLogoSelection = async (files: File[]) => { + const file = files[0]; + if (!file) return; + + try { + const preview = await createSquarePreview(file); + setLogoPreview(preview); + setLogoName(file.name); + setBrandingErrors({}); + } catch { + setBrandingErrors({ logo: 'Unable to prepare the selected image.' }); + } + }; + + const inputClassName = + 'w-full rounded-xl border border-slate-700 bg-slate-950/70 px-4 py-2.5 text-sm text-white outline-none transition focus:border-cyan-400'; + + const SectionCard = ({ + title, + description, + icon, + dirty, + onSave, + children, + }: { + title: string; + description: string; + icon: React.ReactNode; + dirty: boolean; + onSave: () => void; + children: React.ReactNode; + }) => ( +
+
+
+
{icon}
+
+
+

{title}

+ {dirty && ( + + )} +
+

{description}

+
+
+ +
+ {children} +
+ ); + + return ( +
+
+

Company Profile

+

Manage your company details

+

+ Keep your company information current and your branding visible across the app. +

+
+ + } + dirty={isDirty.companyInfo} + onSave={saveCompanyInfo} + > +
+
+ + updateProfile((current) => ({ ...current, companyInfo: { ...current.companyInfo, name: e.target.value } }))} + className={inputClassName} + placeholder="YieldVault" + /> + {companyInfoErrors.name &&

{companyInfoErrors.name}

} +
+
+ + updateProfile((current) => ({ ...current, companyInfo: { ...current.companyInfo, registrationNumber: e.target.value } }))} + className={inputClassName} + placeholder="RC123456" + /> + {companyInfoErrors.registrationNumber &&

{companyInfoErrors.registrationNumber}

} +
+
+ + updateProfile((current) => ({ ...current, companyInfo: { ...current.companyInfo, industry: e.target.value } }))} + className={inputClassName} + placeholder="Logistics" + /> + {companyInfoErrors.industry &&

{companyInfoErrors.industry}

} +
+
+
+ + } + dirty={isDirty.contactDetails} + onSave={saveContactDetails} + > +
+
+ + updateProfile((current) => ({ ...current, contactDetails: { ...current.contactDetails, email: e.target.value } }))} + className={inputClassName} + placeholder="hello@company.com" + /> + {contactErrors.email &&

{contactErrors.email}

} +
+
+ + updateProfile((current) => ({ ...current, contactDetails: { ...current.contactDetails, phone: e.target.value } }))} + className={inputClassName} + placeholder="+234 800 123 4567" + /> + {contactErrors.phone &&

{contactErrors.phone}

} +
+
+ + updateProfile((current) => ({ ...current, contactDetails: { ...current.contactDetails, website: e.target.value } }))} + className={inputClassName} + placeholder="https://company.com" + /> + {contactErrors.website &&

{contactErrors.website}

} +
+
+
+ + } + dirty={isDirty.address} + onSave={saveAddress} + > +
+
+ + updateProfile((current) => ({ ...current, address: { ...current.address, addressLine1: e.target.value } }))} + className={inputClassName} + placeholder="42 Marina Road" + /> + {addressErrors.addressLine1 &&

{addressErrors.addressLine1}

} +
+
+ + updateProfile((current) => ({ ...current, address: { ...current.address, city: e.target.value } }))} + className={inputClassName} + placeholder="Lagos" + /> + {addressErrors.city &&

{addressErrors.city}

} +
+
+ + updateProfile((current) => ({ ...current, address: { ...current.address, state: e.target.value } }))} + className={inputClassName} + placeholder="Lagos State" + /> +
+
+ + updateProfile((current) => ({ ...current, address: { ...current.address, postalCode: e.target.value } }))} + className={inputClassName} + placeholder="100001" + /> +
+
+ + updateProfile((current) => ({ ...current, address: { ...current.address, country: e.target.value } }))} + className={inputClassName} + placeholder="Nigeria" + /> + {addressErrors.country &&

{addressErrors.country}

} +
+
+
+ + } + dirty={isDirty.branding} + onSave={saveBranding} + > +
+ + +
+
+ {logoPreview ? ( + Company logo preview + ) : ( + No logo + )} +
+
+

{logoName || 'Upload an image to preview your square logo'}

+

+ The image is cropped to a square preview before saving so it displays neatly in the navbar. +

+ {brandingErrors.logo &&

{brandingErrors.logo}

} +
+
+
+
+
+ ); +}; + +export default CompanyProfile; diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index f207b51..0e3da42 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -6,6 +6,7 @@ import PageSkeleton from '../../components/ui/PageSkeleton'; import Breadcrumb from '../../components/ui/Breadcrumb'; const ProfileSection = lazy(() => import('./sections/ProfileSection')); +const CompanyProfileSection = lazy(() => import('./CompanyProfile/CompanyProfile')); const SecuritySection = lazy(() => import('./sections/SecuritySection/SecuritySection')); const NotificationsSection = lazy(() => import('./sections/NotificationsSection')); const WalletsSection = lazy(() => import('./sections/WalletsSection')); @@ -17,7 +18,7 @@ const MyTemplatesSection = lazy(() => import('./sections/MyTemplatesSection')); const TeamSection = lazy(() => import('./sections/TeamSection')); const AddressBookSection = lazy(() => import('./sections/AddressBookSection')); -type Tab = 'profile' | 'security' | 'notifications' | 'appearance' | 'wallets' | 'api-keys' | 'templates' | 'team' | 'address-book' | 'danger'; +type Tab = 'profile' | 'company-profile' | 'security' | 'notifications' | 'appearance' | 'wallets' | 'api-keys' | 'templates' | 'team' | 'address-book' | 'danger'; interface TabDef { key: Tab; @@ -27,6 +28,7 @@ interface TabDef { const TABS: TabDef[] = [ { key: 'profile', label: 'Profile' }, + { key: 'company-profile', label: 'Company Profile', companyOnly: true }, { key: 'security', label: 'Security' }, { key: 'notifications', label: 'Notifications' }, { key: 'appearance', label: 'Appearance' }, @@ -89,6 +91,7 @@ const Settings: React.FC = () => { {/* Tab content */} }> {activeTab === 'profile' && } + {activeTab === 'company-profile' && isCompany && } {activeTab === 'security' && } {activeTab === 'notifications' && } {activeTab === 'appearance' && } diff --git a/frontend/src/pages/dashboard/ExceptionDashboard.test.tsx b/frontend/src/pages/dashboard/ExceptionDashboard.test.tsx new file mode 100644 index 0000000..57dd877 --- /dev/null +++ b/frontend/src/pages/dashboard/ExceptionDashboard.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ExceptionDashboard from './ExceptionDashboard'; + +describe('ExceptionDashboard', () => { + it('renders KPI cards, filters, and inline resolution controls', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText(/exception rate dashboard/i)).toBeInTheDocument(); + expect(screen.getByText(/total exceptions this week/i)).toBeInTheDocument(); + expect(screen.getByText(/exception trend/i)).toBeInTheDocument(); + expect(screen.getByText(/open exceptions/i)).toBeInTheDocument(); + + await user.selectOptions(screen.getByLabelText(/filter/i), 'DELAYED'); + await user.click(screen.getAllByRole('button', { name: /resolve/i })[0]); + + expect(screen.getByPlaceholderText(/add an update/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/pages/dashboard/ExceptionDashboard.tsx b/frontend/src/pages/dashboard/ExceptionDashboard.tsx new file mode 100644 index 0000000..7febede --- /dev/null +++ b/frontend/src/pages/dashboard/ExceptionDashboard.tsx @@ -0,0 +1,344 @@ +import React, { useMemo, useState } from 'react'; +import { AlertTriangle, CalendarRange, Clock3, Filter, RefreshCw, Search, TrendingUp, Truck } from 'lucide-react'; +import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +type ExceptionType = 'DELAYED' | 'DAMAGED' | 'LOST' | 'RETURNED' | 'CUSTOMS_HOLD'; +type ExceptionStatus = 'OPEN' | 'RESOLVING' | 'RESOLVED'; + +type ExceptionItem = { + id: string; + shipmentId: string; + type: ExceptionType; + status: ExceptionStatus; + ageHours: number; + owner: string; + route: string; + openedAt: string; + resolutionHours: number; + severity: 'LOW' | 'MEDIUM' | 'HIGH'; +}; + +type FilterState = { + type: 'ALL' | ExceptionType; + dateRange: '7d' | '14d' | '30d' | 'all'; + route: string; +}; + +const EXCEPTION_TYPES: Array<{ value: ExceptionType; label: string; color: string }> = [ + { value: 'DELAYED', label: 'Delayed', color: '#38bdf8' }, + { value: 'DAMAGED', label: 'Damaged', color: '#fb923c' }, + { value: 'LOST', label: 'Lost', color: '#f43f5e' }, + { value: 'RETURNED', label: 'Returned', color: '#a78bfa' }, + { value: 'CUSTOMS_HOLD', label: 'Customs Hold', color: '#34d399' }, +]; + +const initialExceptions: ExceptionItem[] = [ + { + id: 'EX-1021', + shipmentId: 'SHP-1042', + type: 'DELAYED', + status: 'OPEN', + ageHours: 38, + owner: 'Amina', + route: 'Lagos → Abuja', + openedAt: '2026-06-24', + resolutionHours: 12, + severity: 'HIGH', + }, + { + id: 'EX-1022', + shipmentId: 'SHP-1127', + type: 'DAMAGED', + status: 'OPEN', + ageHours: 18, + owner: 'Bolanle', + route: 'Abuja → Kano', + openedAt: '2026-06-25', + resolutionHours: 8, + severity: 'MEDIUM', + }, + { + id: 'EX-1023', + shipmentId: 'SHP-1188', + type: 'LOST', + status: 'RESOLVING', + ageHours: 72, + owner: 'Chris', + route: 'Port Harcourt → Enugu', + openedAt: '2026-06-22', + resolutionHours: 15, + severity: 'HIGH', + }, + { + id: 'EX-1024', + shipmentId: 'SHP-1203', + type: 'RETURNED', + status: 'OPEN', + ageHours: 9, + owner: 'Dayo', + route: 'Ibadan → Lagos', + openedAt: '2026-06-26', + resolutionHours: 4, + severity: 'LOW', + }, + { + id: 'EX-1025', + shipmentId: 'SHP-1291', + type: 'CUSTOMS_HOLD', + status: 'OPEN', + ageHours: 51, + owner: 'Nneka', + route: 'Lagos → Abuja', + openedAt: '2026-06-23', + resolutionHours: 10, + severity: 'MEDIUM', + }, +]; + +const generateTrendData = () => { + const dates = Array.from({ length: 30 }, (_, index) => { + const date = new Date(); + date.setDate(date.getDate() - (29 - index)); + return date; + }); + + return dates.map((date, index) => ({ + date: date.toLocaleDateString('en', { month: 'short', day: 'numeric' }), + DELAYED: 1 + ((index + 2) % 4), + DAMAGED: 1 + (index % 3), + LOST: index % 5 === 0 ? 2 : 1, + RETURNED: 1 + ((index + 1) % 3), + CUSTOMS_HOLD: 1 + (index % 2), + })); +}; + +const ExceptionDashboard: React.FC = () => { + const [exceptions, setExceptions] = useState(initialExceptions); + const [filters, setFilters] = useState({ type: 'ALL', dateRange: '30d', route: '' }); + const [sortKey, setSortKey] = useState<'age' | 'type' | 'owner'>('age'); + const [resolvingId, setResolvingId] = useState(null); + const [note, setNote] = useState(''); + + const trendData = useMemo(() => generateTrendData(), []); + + const visibleExceptions = useMemo(() => { + const now = new Date(); + return exceptions.filter((item) => { + const matchesType = filters.type === 'ALL' || item.type === filters.type; + const matchesRoute = !filters.route || item.route.toLowerCase().includes(filters.route.toLowerCase()); + const itemDate = new Date(item.openedAt); + const rangeDays = filters.dateRange === '7d' ? 7 : filters.dateRange === '14d' ? 14 : filters.dateRange === '30d' ? 30 : Number.MAX_SAFE_INTEGER; + const withinRange = (now.getTime() - itemDate.getTime()) / (1000 * 60 * 60 * 24) <= rangeDays; + return matchesType && matchesRoute && withinRange; + }).sort((a, b) => { + if (sortKey === 'age') return a.ageHours - b.ageHours; + if (sortKey === 'type') return a.type.localeCompare(b.type); + return a.owner.localeCompare(b.owner); + }); + }, [exceptions, filters, sortKey]); + + const kpis = useMemo(() => { + const openExceptions = visibleExceptions.filter((item) => item.status !== 'RESOLVED'); + const total = openExceptions.length; + const totalShipments = 128; + const exceptionRate = ((total / totalShipments) * 100).toFixed(1); + const avgResolution = Math.round(openExceptions.reduce((sum, item) => sum + item.resolutionHours, 0) / Math.max(total, 1)); + const momDelta = 4.2; + + return [ + { label: 'Total exceptions this week', value: `${total}`, accent: 'text-cyan-400' }, + { label: 'Exception rate', value: `${exceptionRate}%`, accent: 'text-amber-400' }, + { label: 'MoM change', value: `${momDelta > 0 ? '+' : ''}${momDelta}%`, accent: momDelta >= 0 ? 'text-emerald-400' : 'text-rose-400' }, + { label: 'Avg resolution time (hours)', value: `${avgResolution}h`, accent: 'text-violet-400' }, + ]; + }, [visibleExceptions]); + + const handleResolve = (id: string) => { + setResolvingId(id); + setNote(''); + }; + + const submitResolution = (id: string) => { + setExceptions((current) => current.map((item) => (item.id === id ? { ...item, status: 'RESOLVED' } : item))); + setResolvingId(null); + setNote(''); + }; + + return ( +
+
+
+
+

Shipment Exceptions

+

Exception rate dashboard

+

Monitor delays, damages, lost shipments, and customs holds across the network.

+
+
+
+ + Auto-refreshing every 5 min +
+
+
+
+ +
+ {kpis.map((card) => ( +
+

{card.label}

+

{card.value}

+
+ ))} +
+ +
+
+
+

Exception trend

+

Stacked daily counts by exception type over the last 30 days.

+
+
+ + Last 30 days +
+
+ + + + + + + {EXCEPTION_TYPES.map((type) => ( + + ))} + + +
+ +
+
+
+

Open exceptions

+

Sortable queue of open issues with inline resolution workflow.

+
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + {visibleExceptions.map((item) => ( + + + + + + + +
Shipment + + Status + + + + RouteAction
{item.shipmentId} + + {item.type.replace('_', ' ')} + + {item.status}{item.ageHours}h{item.owner}{item.route} + {item.status !== 'RESOLVED' ? ( +
+ + {resolvingId === item.id && ( +
+