update dashboard ux
This commit is contained in:
@@ -2,13 +2,25 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp } from 'lucide-react'
|
||||
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp, MapPin } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import StatCard from '@/components/ui/StatCard'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
/**
|
||||
* Location breakdown item — requires backend field on /analytics/dashboard.
|
||||
* Flagged as a contract change (not faked).
|
||||
*/
|
||||
interface LocationBreakdown {
|
||||
name: string
|
||||
bookingCount: number
|
||||
revenue: number
|
||||
/** 0–100 utilization percentage */
|
||||
utilizationPct: number
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
kpis: {
|
||||
totalBookings: number
|
||||
@@ -19,6 +31,12 @@ interface DashboardData {
|
||||
vehiclesChange: number
|
||||
revenueChange: number
|
||||
customersChange: number
|
||||
/** Requires backend field — flagged as contract change */
|
||||
utilizationRate?: number
|
||||
/** Requires backend field — flagged as contract change */
|
||||
revenuePerVehicle?: number
|
||||
/** Requires backend field — flagged as contract change */
|
||||
fulfillmentRate?: number
|
||||
}
|
||||
recentReservations: {
|
||||
id: string
|
||||
@@ -31,6 +49,8 @@ interface DashboardData {
|
||||
totalAmount: number
|
||||
}[]
|
||||
sourceBreakdown: { source: string; count: number; revenue: number }[]
|
||||
/** Requires backend field — flagged as contract change */
|
||||
locationBreakdown?: LocationBreakdown[]
|
||||
subscription: {
|
||||
status: string
|
||||
planName: string
|
||||
@@ -132,6 +152,48 @@ function SubscriptionBanner({
|
||||
)
|
||||
}
|
||||
|
||||
function BranchPerformanceList({
|
||||
locations,
|
||||
formatCurrencyFn,
|
||||
}: {
|
||||
locations: LocationBreakdown[]
|
||||
formatCurrencyFn: (amount: number, currency: string) => string
|
||||
}) {
|
||||
const maxCount = Math.max(...locations.map((l) => l.bookingCount), 1)
|
||||
|
||||
return (
|
||||
<div className="glass-card p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-orange-500" />
|
||||
<h2 className="text-base font-semibold text-blue-950 dark:text-stone-100">Location Performance</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.name}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-blue-950 dark:text-stone-100">{loc.name}</span>
|
||||
<span className="text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
{loc.bookingCount} bookings · {formatCurrencyFn(loc.revenue, 'MAD')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="progress-bar flex-1">
|
||||
<div
|
||||
className="progress-fill blue"
|
||||
style={{ width: `${(loc.bookingCount / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
{loc.utilizationPct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const d = dict.dashboardPage
|
||||
@@ -251,6 +313,7 @@ export default function DashboardPage() {
|
||||
icon={Calendar}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
pulse
|
||||
/>
|
||||
<StatCard
|
||||
title={d.kpiActiveVehicles}
|
||||
@@ -270,6 +333,7 @@ export default function DashboardPage() {
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
valueGradient
|
||||
/>
|
||||
) : null}
|
||||
<StatCard
|
||||
@@ -283,32 +347,72 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* New metrics row — contract change flag */}
|
||||
{kpis.utilizationRate !== undefined && (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard
|
||||
title="Utilization Rate"
|
||||
value={`${kpis.utilizationRate}%`}
|
||||
icon={TrendingUp}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
progressPercent={kpis.utilizationRate}
|
||||
target={100}
|
||||
/>
|
||||
<StatCard
|
||||
title="Revenue / Vehicle"
|
||||
value={formatCurrency(kpis.revenuePerVehicle ?? 0, 'MAD')}
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Fulfillment Rate"
|
||||
value={`${kpis.fulfillmentRate ?? 0}%`}
|
||||
icon={BarChart3}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
progressPercent={kpis.fulfillmentRate ?? 0}
|
||||
target={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Booking Sources Chart */}
|
||||
<div className="card p-6 lg:col-span-2">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
|
||||
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.sourceBreakdown}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
|
||||
{d.noBookingData}
|
||||
</div>
|
||||
)}
|
||||
{/* Left column — 2/3: Booking Sources + Location Performance */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
|
||||
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.sourceBreakdown}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
|
||||
{d.noBookingData}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data?.locationBreakdown && data.locationBreakdown.length > 0 ? (
|
||||
<BranchPerformanceList
|
||||
locations={data.locationBreakdown}
|
||||
formatCurrencyFn={(a, c) => formatCurrency(a, c as 'MAD')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
{/* Right column — 1/3: Quick Stats */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -17,6 +17,20 @@ html.dark {
|
||||
--sidebar-bg: #0a1128;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-active: #ffffff;
|
||||
|
||||
--bg-elevated: rgb(255 255 255 / 0.82);
|
||||
--border-accent: rgb(231 229 228 / 0.8);
|
||||
--glass-bg: rgb(255 255 255 / 0.72);
|
||||
--shadow-card: 0 30px 80px rgba(28, 25, 23, 0.08);
|
||||
--shadow-glow: 0 0 20px rgba(249, 115, 22, 0.15);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--bg-elevated: rgb(11 25 55 / 0.78);
|
||||
--border-accent: rgb(30 60 140 / 0.40);
|
||||
--glass-bg: rgb(11 25 55 / 0.65);
|
||||
--shadow-card: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
--shadow-glow: 0 0 20px rgba(249, 115, 22, 0.25);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -41,6 +55,68 @@ html.dark body {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-card {
|
||||
border-color: var(--border-accent);
|
||||
background-color: var(--glass-bg);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
@apply rounded-[1.75rem] border transition-colors;
|
||||
}
|
||||
|
||||
html.dark .glass-card {
|
||||
border-color: rgb(30 60 140 / 0.40);
|
||||
background-color: rgb(11 25 55 / 0.65);
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
@apply h-2 w-full overflow-hidden rounded-full;
|
||||
background-color: rgb(231 229 228 / 0.5);
|
||||
}
|
||||
|
||||
html.dark .progress-bar {
|
||||
background-color: rgb(30 60 140 / 0.3);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full rounded-full transition-all duration-500;
|
||||
}
|
||||
|
||||
.progress-fill.blue {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-400;
|
||||
}
|
||||
|
||||
.progress-fill.orange {
|
||||
@apply bg-gradient-to-r from-orange-500 to-orange-400;
|
||||
}
|
||||
|
||||
html.dark .progress-fill.blue {
|
||||
@apply bg-gradient-to-r from-blue-400 to-blue-300;
|
||||
}
|
||||
|
||||
html.dark .progress-fill.orange {
|
||||
@apply bg-gradient-to-r from-orange-400 to-orange-300;
|
||||
}
|
||||
|
||||
/* Pulse glow animation for live indicators */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { box-shadow: 0 0 4px rgba(249, 115, 22, 0.3); }
|
||||
50% { box-shadow: 0 0 12px rgba(249, 115, 22, 0.6); }
|
||||
}
|
||||
|
||||
.pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pulse-glow,
|
||||
.progress-fill {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-orange-500 dark:hover:bg-orange-400;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { JetBrains_Mono } from 'next/font/google'
|
||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
|
||||
import './globals.css'
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
@@ -30,7 +37,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="font-sans">
|
||||
<body className={`font-sans ${jetbrainsMono.variable}`}>
|
||||
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user