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>
|
||||
|
||||
@@ -303,7 +303,9 @@ export default function Sidebar() {
|
||||
const className = [
|
||||
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
paddingClass,
|
||||
active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
active
|
||||
? 'bg-orange-500 text-white shadow-[0_0_12px_rgba(249,115,22,0.3)] dark:shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
].join(' ')
|
||||
|
||||
if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) {
|
||||
@@ -364,22 +366,31 @@ export default function Sidebar() {
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'fixed inset-y-0 z-40 flex w-64 flex-col border-r border-blue-900/50 bg-[#0a1128]/94 backdrop-blur-xl transition-transform duration-300',
|
||||
'fixed inset-y-0 z-40 flex w-64 flex-col border-r transition-colors duration-300',
|
||||
theme === 'dark'
|
||||
? 'border-blue-900/50 bg-blue-950/80 backdrop-blur-xl'
|
||||
: 'border-stone-200/80 bg-white/92 backdrop-blur-xl shadow-[4px_0_20px_rgba(0,0,0,0.04)]',
|
||||
isRtl ? 'right-0' : 'left-0',
|
||||
'lg:translate-x-0',
|
||||
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
|
||||
].join(' ')}
|
||||
>
|
||||
<a href={marketplaceUrl} target="_top" className="flex items-center gap-3 border-b border-blue-900/50 px-6 py-5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-orange-400">
|
||||
<a
|
||||
href={marketplaceUrl}
|
||||
target="_top"
|
||||
className="flex items-center gap-3 border-b px-6 py-5 transition-colors border-blue-900/50 dark:border-blue-900/50"
|
||||
>
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-orange-400 to-orange-500 shadow-sm">
|
||||
{brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
|
||||
) : (
|
||||
<Car className="h-4 w-4 text-blue-950" />
|
||||
<Car className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`truncate text-sm font-bold tracking-wide ${theme === 'light' ? 'text-blue-600' : 'text-white'}`}>
|
||||
<span className={`truncate text-sm font-bold tracking-wide ${
|
||||
theme === 'dark' ? 'text-white' : 'text-blue-950'
|
||||
}`}>
|
||||
{brand?.displayName ?? 'RentalDriveGo'}
|
||||
</span>
|
||||
</a>
|
||||
@@ -394,7 +405,9 @@ export default function Sidebar() {
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
active
|
||||
? 'bg-orange-500 text-white shadow-[0_0_12px_rgba(249,115,22,0.3)] dark:shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
|
||||
@@ -189,7 +189,19 @@ export default function TopBar() {
|
||||
|
||||
return (
|
||||
<header className="relative z-[70] flex h-16 items-center justify-between border-b border-stone-200/80 bg-white/78 px-6 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/76">
|
||||
<h1 className="text-lg font-semibold text-blue-950 dark:text-stone-50">{title}</h1>
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<h1 className="text-lg font-semibold text-blue-950 dark:text-stone-50 shrink-0">{title}</h1>
|
||||
<div className="hidden sm:block relative max-w-xs w-full">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400 dark:text-stone-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search…"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-stone-50/60 pl-9 pr-4 py-2 text-sm text-stone-900 placeholder:text-stone-400 transition-colors focus:border-orange-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-orange-500/20 dark:border-blue-800 dark:bg-blue-950/30 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:border-orange-400 dark:focus:bg-blue-950/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
|
||||
@@ -9,6 +9,14 @@ interface StatCardProps {
|
||||
icon: LucideIcon
|
||||
iconColor?: string
|
||||
iconBg?: string
|
||||
/** Optional target value to show a progress bar beneath the value */
|
||||
target?: number
|
||||
/** Current progress (0–100) when target is set */
|
||||
progressPercent?: number
|
||||
/** Show a subtle pulse/glow animation on the card */
|
||||
pulse?: boolean
|
||||
/** Apply a gradient to the value text */
|
||||
valueGradient?: boolean
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
@@ -19,16 +27,42 @@ export default function StatCard({
|
||||
icon: Icon,
|
||||
iconColor = 'text-orange-700 dark:text-orange-300',
|
||||
iconBg = 'bg-orange-50 dark:bg-orange-950/30',
|
||||
target,
|
||||
progressPercent,
|
||||
pulse,
|
||||
valueGradient,
|
||||
}: StatCardProps) {
|
||||
const isPositive = change !== undefined && change >= 0
|
||||
const isNegative = change !== undefined && change < 0
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className={`card p-6 ${pulse ? 'pulse-glow' : ''}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-stone-500 dark:text-stone-400">{title}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-blue-950 dark:text-stone-50">{value}</p>
|
||||
<p
|
||||
className={[
|
||||
'mt-1 text-2xl font-bold',
|
||||
valueGradient
|
||||
? 'bg-gradient-to-r from-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-blue-400 dark:to-orange-300'
|
||||
: 'text-blue-950 dark:text-stone-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{target !== undefined && progressPercent !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="progress-bar flex-1">
|
||||
<div
|
||||
className="progress-fill orange"
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
{Math.round(progressPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{change !== undefined && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<span
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: Config = {
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user