add first files
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Edit, Eye, ChevronDown, Search } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
make: string
|
||||
model: string
|
||||
year: number
|
||||
category: string
|
||||
status: 'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE'
|
||||
dailyRate: number
|
||||
isPublished: boolean
|
||||
primaryPhoto: string | null
|
||||
licensePlate: string
|
||||
}
|
||||
|
||||
interface AddVehicleModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<Vehicle['status'], string> = {
|
||||
AVAILABLE: 'badge-green',
|
||||
RENTED: 'badge-blue',
|
||||
MAINTENANCE: 'badge-amber',
|
||||
OUT_OF_SERVICE: 'badge-red',
|
||||
}
|
||||
|
||||
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
const [form, setForm] = useState({
|
||||
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
||||
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE',
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch('/vehicles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
|
||||
year: Number(form.year),
|
||||
seats: Number(form.seats),
|
||||
}),
|
||||
})
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-5">Add New Vehicle</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
||||
<input className="input-field" placeholder="Toyota" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
||||
<input className="input-field" placeholder="Corolla" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
||||
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
||||
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
|
||||
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
||||
<input className="input-field" placeholder="White" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
|
||||
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
|
||||
<option value="MANUAL">Manual</option>
|
||||
<option value="AUTOMATIC">Automatic</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID', 'LPG'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn-secondary flex-1">Cancel</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||
{loading ? 'Adding…' : 'Add Vehicle'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [publishedFilter, setPublishedFilter] = useState('')
|
||||
|
||||
const fetchVehicles = () => {
|
||||
setLoading(true)
|
||||
apiFetch<Vehicle[]>('/vehicles')
|
||||
.then(setVehicles)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { fetchVehicles() }, [])
|
||||
|
||||
const togglePublished = async (id: string, current: boolean) => {
|
||||
try {
|
||||
await apiFetch(`/vehicles/${id}`, { method: 'PATCH', body: JSON.stringify({ isPublished: !current }) })
|
||||
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, isPublished: !current } : v))
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = vehicles.filter((v) => {
|
||||
if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (statusFilter && v.status !== statusFilter) return false
|
||||
if (categoryFilter && v.category !== categoryFilter) return false
|
||||
if (publishedFilter === 'published' && !v.isPublished) return false
|
||||
if (publishedFilter === 'unpublished' && v.isPublished) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Fleet</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{vehicles.length} vehicle{vehicles.length !== 1 ? 's' : ''} total</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAddModal(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> Add Vehicle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4 flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-48">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
className="flex-1 text-sm outline-none placeholder:text-slate-400"
|
||||
placeholder="Search make, model, plate…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="input-field w-36"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input-field w-36"
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'CONVERTIBLE', 'PICKUP'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="input-field w-36"
|
||||
value={publishedFilter}
|
||||
onChange={(e) => setPublishedFilter(e.target.value)}
|
||||
>
|
||||
<option value="">All visibility</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="unpublished">Unpublished</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-red-600 font-medium">{error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Category</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Daily Rate</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Published</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map((vehicle) => (
|
||||
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
||||
{vehicle.primaryPhoto ? (
|
||||
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
||||
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="badge-gray">{vehicle.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={STATUS_BADGE[vehicle.status]}>{vehicle.status}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD')}/day
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
|
||||
className={[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
|
||||
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
|
||||
vehicle.isPublished ? 'translate-x-5' : 'translate-x-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
|
||||
{vehicles.length === 0 ? 'No vehicles yet. Add your first vehicle to get started.' : 'No vehicles match your filters.'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import StatCard from '@/components/ui/StatCard'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface DashboardData {
|
||||
kpis: {
|
||||
totalBookings: number
|
||||
activeVehicles: number
|
||||
monthlyRevenue: number
|
||||
totalCustomers: number
|
||||
bookingsChange: number
|
||||
vehiclesChange: number
|
||||
revenueChange: number
|
||||
customersChange: number
|
||||
}
|
||||
recentReservations: {
|
||||
id: string
|
||||
bookingRef: string
|
||||
customerName: string
|
||||
vehicleName: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
status: string
|
||||
totalAmount: number
|
||||
}[]
|
||||
sourceBreakdown: { source: string; count: number; revenue: number }[]
|
||||
subscription: {
|
||||
status: string
|
||||
planName: string
|
||||
trialEndsAt: string | null
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
CONFIRMED: 'badge-blue',
|
||||
PENDING: 'badge-amber',
|
||||
ACTIVE: 'badge-green',
|
||||
COMPLETED: 'badge-gray',
|
||||
CANCELLED: 'badge-red',
|
||||
}
|
||||
|
||||
function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string; planName: string; trialEndsAt: string | null }) {
|
||||
if (status === 'ACTIVE') return null
|
||||
|
||||
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
|
||||
TRIALING: {
|
||||
bg: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
message: trialEndsAt
|
||||
? `You're on a free trial. Trial ends ${dayjs(trialEndsAt).format('MMM D, YYYY')}.`
|
||||
: "You're on a free trial.",
|
||||
},
|
||||
PAST_DUE: {
|
||||
bg: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
message: 'Your payment is past due. Please update your billing information.',
|
||||
},
|
||||
SUSPENDED: {
|
||||
bg: 'bg-red-50 border-red-200 text-red-800',
|
||||
icon: <XCircle className="w-4 h-4" />,
|
||||
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||
},
|
||||
}
|
||||
|
||||
const config = configs[status]
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border mb-6 ${config.bg}`}>
|
||||
{config.icon}
|
||||
<p className="text-sm font-medium">{config.message}</p>
|
||||
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
|
||||
Manage billing →
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<DashboardData>('/analytics/dashboard')
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-red-600 font-medium">Failed to load dashboard</p>
|
||||
<p className="text-slate-500 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{data?.subscription && (
|
||||
<SubscriptionBanner
|
||||
status={data.subscription.status}
|
||||
planName={data.subscription.planName}
|
||||
trialEndsAt={data.subscription.trialEndsAt}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Total Bookings"
|
||||
value={kpis.totalBookings.toLocaleString()}
|
||||
change={kpis.bookingsChange}
|
||||
icon={Calendar}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Vehicles"
|
||||
value={kpis.activeVehicles.toLocaleString()}
|
||||
change={kpis.vehiclesChange}
|
||||
icon={Car}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Monthly Revenue"
|
||||
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||
change={kpis.revenueChange}
|
||||
icon={DollarSign}
|
||||
iconColor="text-amber-600"
|
||||
iconBg="bg-amber-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Customers"
|
||||
value={kpis.totalCustomers.toLocaleString()}
|
||||
change={kpis.customersChange}
|
||||
icon={Users}
|
||||
iconColor="text-purple-600"
|
||||
iconBg="bg-purple-50"
|
||||
/>
|
||||
</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">Booking Sources</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="Bookings" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" name="Revenue (centimes)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
|
||||
No booking data available yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">Quick Stats</h2>
|
||||
<div className="space-y-4">
|
||||
{(data?.sourceBreakdown ?? []).map((item) => (
|
||||
<div key={item.source} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 capitalize">{item.source.toLowerCase()}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-semibold text-slate-900">{item.count}</span>
|
||||
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
|
||||
<p className="text-sm text-slate-400">No data yet</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Reservations */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">Recent Reservations</h2>
|
||||
<a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Booking #</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{(data?.recentReservations ?? []).map((res) => (
|
||||
<tr key={res.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<a href={`/dashboard/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
|
||||
#{res.bookingRef}
|
||||
</a>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500">
|
||||
{dayjs(res.startDate).format('MMM D')} – {dayjs(res.endDate).format('MMM D, YYYY')}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
|
||||
{res.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
|
||||
{formatCurrency(res.totalAmount, 'MAD')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!data?.recentReservations || data.recentReservations.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
|
||||
No reservations yet. Once customers book vehicles, they'll appear here.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Sidebar from '@/components/layout/Sidebar'
|
||||
import TopBar from '@/components/layout/TopBar'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col ml-64 min-w-0">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--accent: #f59e0b;
|
||||
--sidebar-bg: #0f172a;
|
||||
--sidebar-text: #94a3b8;
|
||||
--sidebar-active: #ffffff;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 bg-white hover:bg-slate-50 text-slate-700 text-sm font-medium rounded-lg border border-slate-200 transition-colors;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply inline-flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 text-sm border border-slate-200 rounded-lg bg-white text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white border border-slate-200 rounded-xl shadow-sm;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700;
|
||||
}
|
||||
|
||||
.badge-amber {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700;
|
||||
}
|
||||
|
||||
.badge-red {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700;
|
||||
}
|
||||
|
||||
.badge-gray {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { ClerkProvider } from '@clerk/nextjs'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Car,
|
||||
Calendar,
|
||||
Users,
|
||||
Tag,
|
||||
UserPlus,
|
||||
BarChart2,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import { useClerk, useUser } from '@clerk/nextjs'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: true },
|
||||
{ href: '/dashboard/fleet', label: 'Fleet', icon: Car },
|
||||
{ href: '/dashboard/reservations', label: 'Reservations', icon: Calendar },
|
||||
{ href: '/dashboard/customers', label: 'Customers', icon: Users },
|
||||
{ href: '/dashboard/offers', label: 'Offers', icon: Tag },
|
||||
{ href: '/dashboard/team', label: 'Team', icon: UserPlus },
|
||||
{ href: '/dashboard/reports', label: 'Reports', icon: BarChart2 },
|
||||
{ href: '/dashboard/billing', label: 'Billing', icon: CreditCard },
|
||||
{ href: '/dashboard/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const { signOut } = useClerk()
|
||||
const { user } = useUser()
|
||||
|
||||
const isActive = (item: typeof NAV_ITEMS[0]) => {
|
||||
if (item.exact) return pathname === item.href
|
||||
return pathname.startsWith(item.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 w-64 bg-slate-900 flex flex-col z-40">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-6 py-5 border-b border-slate-800">
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<Car className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-white text-sm tracking-wide">RentalDriveGo</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-0.5 overflow-y-auto">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white hover:bg-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User menu */}
|
||||
<div className="px-3 py-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
|
||||
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="mt-2 w-full flex items-center gap-3 px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { Bell } from 'lucide-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useUser } from '@clerk/nextjs'
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'Dashboard',
|
||||
'/dashboard/fleet': 'Fleet Management',
|
||||
'/dashboard/reservations': 'Reservations',
|
||||
'/dashboard/customers': 'Customers',
|
||||
'/dashboard/offers': 'Offers',
|
||||
'/dashboard/team': 'Team',
|
||||
'/dashboard/reports': 'Reports',
|
||||
'/dashboard/billing': 'Billing',
|
||||
'/dashboard/settings': 'Settings',
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const pathname = usePathname()
|
||||
const { user } = useUser()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
|
||||
const title = PAGE_TITLES[pathname] ?? 'Dashboard'
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
.then((data) => setUnreadCount(data.unread))
|
||||
.catch(() => {})
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center justify-between px-6">
|
||||
<h1 className="text-lg font-semibold text-slate-900">{title}</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Notification bell */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowNotifs(!showNotifs)}
|
||||
className="relative p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-1 right-1 min-w-[16px] h-4 bg-red-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center px-1">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showNotifs && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 bg-white border border-slate-200 rounded-xl shadow-lg z-50 p-4">
|
||||
<p className="text-sm font-medium text-slate-900 mb-2">Notifications</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{unreadCount === 0 ? 'No new notifications' : `${unreadCount} unread notification${unreadCount > 1 ? 's' : ''}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User avatar */}
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{user?.firstName?.[0]?.toUpperCase() ?? 'U'}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: number
|
||||
icon: LucideIcon
|
||||
iconColor?: string
|
||||
iconBg?: string
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon: Icon,
|
||||
iconColor = 'text-blue-600',
|
||||
iconBg = 'bg-blue-50',
|
||||
}: StatCardProps) {
|
||||
const isPositive = change !== undefined && change >= 0
|
||||
const isNegative = change !== undefined && change < 0
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 font-medium">{title}</p>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{value}</p>
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<span
|
||||
className={[
|
||||
'text-xs font-medium',
|
||||
isPositive ? 'text-green-600' : isNegative ? 'text-red-600' : 'text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">vs last month</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-xl ${iconBg} flex items-center justify-center flex-shrink-0`}>
|
||||
<Icon className={`w-6 h-6 ${iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
async function getClerkToken(): Promise<string | null> {
|
||||
try {
|
||||
// In Next.js App Router, the Clerk session token is available via the Clerk SDK
|
||||
// For client-side calls, we read it from a cookie set by @clerk/nextjs
|
||||
if (typeof window !== 'undefined') {
|
||||
// Client-side: use window.__clerk if available
|
||||
const clerk = (window as any).__clerk
|
||||
if (clerk?.session) {
|
||||
return await clerk.session.getToken()
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = await getClerkToken()
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
let json: any
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(json?.message ?? json?.error ?? `Request failed with status ${res.status}`) as any
|
||||
err.code = json?.error
|
||||
err.statusCode = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
|
||||
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...(options?.headers as Record<string, string> ?? {}),
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
let json: any
|
||||
try {
|
||||
json = await res.json()
|
||||
} catch {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = new Error(json?.message ?? `Request failed with status ${res.status}`) as any
|
||||
err.statusCode = res.status
|
||||
throw err
|
||||
}
|
||||
|
||||
return (json?.data ?? json) as T
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
|
||||
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
'/dashboard(.*)',
|
||||
'/onboarding(.*)',
|
||||
])
|
||||
|
||||
export default clerkMiddleware((auth, req) => {
|
||||
if (isProtectedRoute(req)) {
|
||||
auth().protect()
|
||||
}
|
||||
})
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
|
||||
'/(api|trpc)(.*)',
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user