fix first online resevation

This commit is contained in:
root
2026-05-09 20:01:51 -04:00
parent c4a45c8b21
commit 09b0e3b55f
75 changed files with 6394 additions and 2190 deletions
@@ -1,7 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import type { ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
interface CustomerRow {
@@ -19,8 +18,8 @@ export default function CustomersPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<ApiPaginated<CustomerRow>>('/customers?pageSize=100')
.then((result) => setRows(result.data))
apiFetch<CustomerRow[]>('/customers?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
@@ -1,8 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Image from 'next/image'
import { ArrowLeft, Edit, X, Check, Upload, Trash2 } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
@@ -18,15 +19,78 @@ interface VehicleDetail {
transmission: string
fuelType: string
seats: number
mileage: number | null
licensePlate: string
photos: string[]
notes: string | null
isPublished: boolean
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function initMakeSelect(make: string) {
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
}
function initModelSelect(make: string, model: string) {
const presets = MODELS_BY_MAKE[make]
if (!presets) return model ? 'custom' : ''
return presets.includes(model) ? model : (model ? 'custom' : '')
}
function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// edit form state
const [form, setForm] = useState<{
make: string; model: string; year: number; category: string
dailyRate: string; licensePlate: string; color: string
seats: number; transmission: string; fuelType: string
status: string; notes: string; mileage: string
} | null>(null)
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('')
// photo management
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
const [uploadingPhotos, setUploadingPhotos] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
@@ -34,38 +98,400 @@ export default function FleetDetailPage() {
.catch((err) => setError(err.message))
}, [params.id])
const startEdit = () => {
if (!vehicle) return
setForm({
make: vehicle.make,
model: vehicle.model,
year: vehicle.year,
category: vehicle.category,
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
licensePlate: vehicle.licensePlate,
color: vehicle.color ?? '',
seats: vehicle.seats,
transmission: vehicle.transmission,
fuelType: vehicle.fuelType,
status: vehicle.status,
notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
})
setMakeSelect(initMakeSelect(vehicle.make))
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
setColorSelect(initColorSelect(vehicle.color ?? ''))
setSaveError(null)
setEditing(true)
}
const cancelEdit = () => {
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setEditing(false)
setForm(null)
setSaveError(null)
}
const handleSave = async () => {
if (!form || !vehicle) return
if (!form.make) { setSaveError('Make is required.'); return }
if (!form.model) { setSaveError('Model is required.'); return }
if (!form.licensePlate) { setSaveError('License plate is required.'); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError('Please enter a valid daily rate.'); return }
setSaving(true)
setSaveError(null)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
method: 'PATCH',
body: JSON.stringify({
make: form.make,
model: form.model,
year: Number(form.year),
category: form.category,
dailyRate: Math.round(rate * 100),
licensePlate: form.licensePlate,
color: form.color,
seats: Number(form.seats),
transmission: form.transmission,
fuelType: form.fuelType,
status: form.status,
notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
}),
})
let finalVehicle = updated
if (newPhotoFiles.length > 0) {
setUploadingPhotos(true)
const fd = new FormData()
newPhotoFiles.forEach((f) => fd.append('photos', f))
finalVehicle = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setUploadingPhotos(false)
}
setVehicle(finalVehicle)
setEditing(false)
setForm(null)
} catch (err: any) {
setSaveError(err.message ?? 'Failed to save changes.')
setUploadingPhotos(false)
} finally {
setSaving(false)
}
}
const handleDeletePhoto = async (idx: number) => {
if (!vehicle) return
setDeletingPhotoIdx(idx)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos/${idx}`, { method: 'DELETE' })
setVehicle(updated)
} catch (err: any) {
alert(err.message)
} finally {
setDeletingPhotoIdx(null)
}
}
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const totalExisting = (vehicle?.photos.length ?? 0)
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removeNewPhoto = (i: number) => {
URL.revokeObjectURL(newPhotoPreviews[i])
setNewPhotoFiles(newPhotoFiles.filter((_, idx) => idx !== i))
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
}
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">Loading vehicle</div>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-1">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
</div>
</div>
{!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> Edit
</button>
) : (
<div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> Cancel
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Check className="w-4 h-4" />
{saving ? (uploadingPhotos ? 'Uploading photos…' : 'Saving…') : 'Save'}
</button>
</div>
)}
</div>
{saveError && (
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
)}
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
{/* Photos */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
<div className="grid gap-4 sm:grid-cols-2">
{/* Existing photos */}
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" />
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
{editing && (
<button
onClick={() => handleDeletePhoto(index)}
disabled={deletingPhotoIdx === index}
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
>
{deletingPhotoIdx === index
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
{vehicle.photos.length === 0 && <div className="text-sm text-slate-400">No photos uploaded yet.</div>}
{/* New photo previews (edit mode) */}
{editing && newPhotoPreviews.map((src, i) => (
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
<button
onClick={() => removeNewPhoto(i)}
className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3.5 h-3.5" />
</button>
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">New</span>
</div>
))}
{vehicle.photos.length === 0 && !editing && (
<div className="text-sm text-slate-400">No photos uploaded yet.</div>
)}
</div>
{/* Upload new photos (edit mode) */}
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? 'Click to add photos' : `Add more photos (${totalPhotos}/10)`}
</button>
</div>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">Maximum 10 photos reached.</p>
)}
</div>
{/* Details / Edit form */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color}</dd></div>
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
{!editing || !form ? (
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Status</dt><dd className="text-slate-900">{vehicle.status}</dd></div>
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">Odometer</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
) : (
<div className="space-y-4 text-sm">
{/* Make */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">Select make</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type make…" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)}
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
{!presetModels ? (
<input className="input-field" placeholder="Model" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">Select model</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type model…" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<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} 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', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
<input type="number" className="input-field" min="0" step="0.01" 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" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
{/* Status */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Status</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">Select color</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type color…" 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>
{/* Transmission & Fuel */}
<div className="grid grid-cols-2 gap-3">
<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>
<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'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Notes</label>
<textarea
className="input-field resize-none"
rows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional notes…"
/>
</div>
</div>
)}
</div>
</div>
</div>
@@ -1,13 +1,11 @@
'use client'
import { useEffect, useState } from 'react'
import { Plus, Edit, Eye, ChevronDown, Search } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X } 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'
import type { ApiPaginated } from '@rentaldrivego/types'
interface Vehicle {
id: string
@@ -35,32 +33,106 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
OUT_OF_SERVICE: 'badge-red',
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
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',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '',
})
const [colorSelect, setColorSelect] = useState('')
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [photoFiles, setPhotoFiles] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined)
setPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removePhoto = (index: number) => {
URL.revokeObjectURL(photoPreviews[index])
const files = photoFiles.filter((_, i) => i !== index)
const previews = photoPreviews.filter((_, i) => i !== index)
setPhotoFiles(files)
setPhotoPreviews(previews)
}
const reset = () => {
setForm({ make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', mileage: '' })
setColorSelect('')
setMakeSelect('')
setModelSelect('')
photoPreviews.forEach((p) => URL.revokeObjectURL(p))
setPhotoFiles([])
setPhotoPreviews([])
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.make) { setError('Please select or enter a make.'); return }
if (!form.model) { setError('Please select or enter a model.'); return }
if (!form.licensePlate) { setError('Please enter a license plate.'); return }
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError('Please enter a valid daily rate.'); return }
setLoading(true)
setError(null)
try {
await apiFetch('/vehicles', {
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
method: 'POST',
body: JSON.stringify({
...form,
dailyRate: Math.round(parseFloat(form.dailyRate) * 100),
year: Number(form.year),
seats: Number(form.seats),
mileage: form.mileage ? Number(form.mileage) : undefined,
}),
})
if (photoFiles.length > 0) {
const fd = new FormData()
photoFiles.forEach((f) => fd.append('photos', f))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
}
onSaved()
onClose()
handleClose()
} catch (err: any) {
setError(err.message)
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.')
} finally {
setLoading(false)
}
@@ -68,21 +140,66 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
if (!open) return null
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : 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="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<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>
{error && (
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Make & Model */}
<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 })} />
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">Select make</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type make…" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
)}
</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 })} />
{!presetModels ? (
<input className="input-field" placeholder="Model" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">Select model</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type model…" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)}
</>
)}
</div>
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
@@ -97,6 +214,8 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</select>
</div>
</div>
{/* Daily Rate & License Plate */}
<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>
@@ -107,10 +226,27 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
{/* Color, Seats, Transmission */}
<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 })} />
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">Select</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
@@ -124,19 +260,73 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</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'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
{/* Fuel Type & Odometer */}
<div className="grid grid-cols-2 gap-4">
<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'].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
</div>
{error && (
<div className="px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Photos (up to 10)</label>
{photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => (
<div key={i} className="relative h-24 rounded-lg overflow-hidden bg-slate-100 group">
<Image src={src} alt={`preview ${i + 1}`} fill className="object-cover" unoptimized />
<button
type="button"
onClick={() => removePhoto(i)}
className="absolute top-1 right-1 p-0.5 bg-black/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{photoFiles.length < 10 && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handlePhotoChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{photoFiles.length === 0 ? 'Click to add photos' : 'Add more photos'}
</button>
</>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onClose} className="btn-secondary flex-1">Cancel</button>
<button type="button" onClick={handleClose} className="btn-secondary flex-1">Cancel</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? 'Adding…' : 'Add Vehicle'}
</button>
@@ -159,8 +349,8 @@ export default function FleetPage() {
const fetchVehicles = () => {
setLoading(true)
apiFetch<ApiPaginated<Vehicle>>('/vehicles?pageSize=100')
.then((result) => setVehicles(result.data))
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
.then((result) => setVehicles(result ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
@@ -209,31 +399,19 @@ export default function FleetPage() {
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select
className="input-field w-36"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<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)}
>
<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', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
className="input-field w-36"
value={publishedFilter}
onChange={(e) => setPublishedFilter(e.target.value)}
>
<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>
@@ -270,7 +448,7 @@ export default function FleetPage() {
<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" />
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
) : (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div>
)}
@@ -0,0 +1,325 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
interface OnlineReservation {
id: string
status: 'DRAFT' | 'CONFIRMED' | 'CANCELLED' | 'ACTIVE' | 'COMPLETED' | 'NO_SHOW'
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
dailyRate: number
notes: string | null
cancelReason: string | null
createdAt: string
vehicle: { make: string; model: string; year: number; licensePlate: string }
customer: { firstName: string; lastName: string; email: string; phone: string | null }
}
const STATUS_STYLE: Record<string, string> = {
DRAFT: 'bg-amber-100 text-amber-800',
CONFIRMED: 'bg-emerald-100 text-emerald-700',
CANCELLED: 'bg-rose-100 text-rose-700',
ACTIVE: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-slate-100 text-slate-600',
NO_SHOW: 'bg-orange-100 text-orange-700',
}
const STATUS_LABEL: Record<string, string> = {
DRAFT: 'Pending',
CONFIRMED: 'Confirmed',
CANCELLED: 'Declined',
ACTIVE: 'Active',
COMPLETED: 'Completed',
NO_SHOW: 'No show',
}
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
const [reason, setReason] = useState('')
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}>
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-base font-semibold text-slate-900">Decline reservation</h3>
<p className="mt-1 text-sm text-slate-500">Optionally provide a reason it will be visible in the reservation record.</p>
<textarea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Vehicle not available for these dates"
className="mt-4 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 resize-none"
/>
<div className="mt-4 flex gap-3">
<button onClick={onCancel} className="flex-1 rounded-full border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700">Cancel</button>
<button
disabled={loading}
onClick={() => onConfirm(reason)}
className="flex-1 rounded-full bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
>
{loading ? 'Declining…' : 'Decline'}
</button>
</div>
</div>
</div>
)
}
export default function OnlineReservationsPage() {
const [rows, setRows] = useState<OnlineReservation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [acting, setActing] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [declineTarget, setDeclineTarget] = useState<string | null>(null)
const load = useCallback(async () => {
try {
setLoading(true)
const result = await apiFetch<ApiPaginated<OnlineReservation>>('/reservations?source=MARKETPLACE&pageSize=100')
setRows(
(result.data ?? []).sort((a, b) => {
// DRAFT first, then by createdAt desc
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
if (a.status !== 'DRAFT' && b.status === 'DRAFT') return 1
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load reservations')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
async function confirm(id: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/confirm`, { method: 'POST', body: JSON.stringify({}) })
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to confirm reservation')
} finally {
setActing(null)
}
}
async function decline(id: string, reason: string) {
setActing(id)
setActionError(null)
try {
await apiFetch(`/reservations/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason: reason || undefined }) })
setDeclineTarget(null)
await load()
} catch (err: any) {
setActionError(err.message ?? 'Failed to decline reservation')
} finally {
setActing(null)
}
}
const pending = rows.filter((r) => r.status === 'DRAFT')
const history = rows.filter((r) => r.status !== 'DRAFT')
return (
<div className="space-y-8">
{declineTarget && (
<DeclineModal
loading={acting === declineTarget}
onConfirm={(reason) => decline(declineTarget, reason)}
onCancel={() => setDeclineTarget(null)}
/>
)}
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-semibold text-slate-900">Online Reservations</h2>
{pending.length > 0 && (
<span className="rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">Requests submitted by customers through the marketplace. Confirm or decline each one.</p>
</div>
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
Refresh
</button>
</div>
{actionError && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{actionError}</div>
)}
{/* Pending section */}
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> Pending approval
{pending.length > 0 && <span className="ml-1 text-amber-600">({pending.length})</span>}
</h3>
{loading ? (
<div className="card p-8 text-center text-sm text-slate-400">Loading</div>
) : pending.length === 0 ? (
<div className="card flex flex-col items-center gap-3 p-10 text-center">
<CheckCircle className="h-10 w-10 text-emerald-400" />
<p className="text-sm font-medium text-slate-600">All caught up no pending requests.</p>
</div>
) : (
<div className="space-y-4">
{pending.map((r) => (
<ReservationCard
key={r.id}
r={r}
acting={acting === r.id}
onConfirm={() => confirm(r.id)}
onDecline={() => setDeclineTarget(r.id)}
/>
))}
</div>
)}
</section>
{/* History section */}
{!loading && history.length > 0 && (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
History
</h3>
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Customer</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Dates</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Status</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{history.map((r) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}</p>
</td>
<td className="px-5 py-3.5 text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model}</td>
<td className="px-5 py-3.5 text-sm text-slate-500">
{dayjs(r.startDate).format('MMM D')} {dayjs(r.endDate).format('MMM D, YYYY')}
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLE[r.status] ?? 'bg-slate-100 text-slate-600'}`}>
{STATUS_LABEL[r.status] ?? r.status}
</span>
{r.cancelReason && (
<p className="mt-0.5 text-xs text-slate-400 truncate max-w-[160px]" title={r.cancelReason}>{r.cancelReason}</p>
)}
</td>
<td className="px-5 py-3.5 text-right text-sm font-semibold text-slate-900">
{formatCurrency(r.totalAmount, 'MAD')}
</td>
<td className="px-5 py-3.5">
<Link href={`/dashboard/reservations/${r.id}`} className="text-slate-400 hover:text-slate-700">
<ChevronRight className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
)
}
function ReservationCard({ r, acting, onConfirm, onDecline }: {
r: OnlineReservation
acting: boolean
onConfirm: () => void
onDecline: () => void
}) {
const days = r.totalDays
return (
<div className="card overflow-hidden border-l-4 border-l-amber-400">
<div className="p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
{/* Left: customer + vehicle + dates */}
<div className="space-y-3 min-w-0">
<div className="flex items-center gap-2">
<User className="h-4 w-4 flex-shrink-0 text-slate-400" />
<div>
<p className="text-sm font-semibold text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}{r.customer.phone ? ` · ${r.customer.phone}` : ''}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Car className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model} <span className="text-slate-400">· {r.vehicle.licensePlate}</span></p>
</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 flex-shrink-0 text-slate-400" />
<p className="text-sm text-slate-700">
{dayjs(r.startDate).format('MMM D, YYYY')} {dayjs(r.endDate).format('MMM D, YYYY')}
<span className="ml-2 text-slate-400">{days} day{days > 1 ? 's' : ''}</span>
</p>
</div>
{r.notes && (
<div className="flex items-start gap-2">
<MessageSquare className="h-4 w-4 flex-shrink-0 text-slate-400 mt-0.5" />
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
</div>
)}
</div>
{/* Right: total + actions */}
<div className="flex flex-col items-end gap-3 flex-shrink-0">
<div className="text-right">
<p className="text-xs text-slate-400">Estimated total</p>
<p className="text-xl font-bold text-slate-900">{formatCurrency(r.totalAmount, 'MAD')}</p>
<p className="text-xs text-slate-400">{formatCurrency(r.dailyRate, 'MAD')}/day</p>
</div>
<div className="flex gap-2">
<button
onClick={onDecline}
disabled={acting}
className="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
<XCircle className="h-4 w-4" />
Decline
</button>
<button
onClick={onConfirm}
disabled={acting}
className="flex items-center gap-1.5 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
<CheckCircle className="h-4 w-4" />
{acting ? 'Confirming…' : 'Confirm'}
</button>
</div>
<p className="text-xs text-slate-400">Received {dayjs(r.createdAt).format('MMM D [at] HH:mm')}</p>
</div>
</div>
</div>
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
<Link href={`/dashboard/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
View full detail <ChevronRight className="h-3 w-3" />
</Link>
</div>
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
interface ReportRow {
reservationId: string
@@ -55,7 +55,7 @@ export default function ReportsPage() {
setExporting(true)
setError(null)
try {
const token = (window as any).__clerk?.session ? await (window as any).__clerk.session.getToken() : null
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
@@ -24,7 +24,7 @@ export default function ReservationsPage() {
useEffect(() => {
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
.then((result) => setRows(result.data))
.then((result) => setRows(result.data ?? []))
.catch((err) => setError(err.message))
}, [])
@@ -1,11 +1,11 @@
'use client'
import { useState, useMemo } from 'react'
import { useUser } from '@clerk/nextjs'
import { useEffect, useMemo, useState } from 'react'
import { useTeam, TeamMember } from '@/hooks/useTeam'
import InviteModal from '@/components/team/InviteModal'
import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
@@ -73,8 +73,8 @@ function StatusBadge({ member }: { member: TeamMember }) {
}
export default function TeamPage() {
const { user } = useUser()
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
@@ -83,7 +83,22 @@ export default function TeamPage() {
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
const currentEmployee = members.find((member) => member.email === user?.primaryEmailAddress?.emailAddress)
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded))
setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
} catch {
setCurrentEmployeeId(null)
}
}, [])
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
@@ -247,7 +262,7 @@ export default function TeamPage() {
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.email === user?.primaryEmailAddress?.emailAddress && (
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
)}
</div>
@@ -1,21 +1,22 @@
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
import { DashboardLanguageSwitcher } from '@/components/I18nProvider'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/I18nProvider'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-slate-50">
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
<Sidebar />
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors">
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
Workspace preferences
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
</footer>