prevent booking when no car added

This commit is contained in:
root
2026-06-11 12:30:41 -04:00
parent 919f583677
commit 6eeba99c45
2 changed files with 182 additions and 99 deletions
@@ -1,10 +1,11 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown, DollarSign } from 'lucide-react'
import { Plus, Edit, Eye, Search, Upload, X, DollarSign } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { apiFetch } from '@/lib/api'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -31,35 +32,13 @@ interface AddVehicleModalProps {
onSaved: () => void
}
const STATUS_BADGE: Record<Vehicle['status'], string> = {
AVAILABLE: 'badge-green',
RESERVED: 'badge-blue',
READY: 'badge-purple',
RENTED: 'badge-indigo',
RETURNED: 'badge-amber',
NEEDS_CLEANING: 'badge-amber',
MAINTENANCE: 'badge-amber',
DAMAGE_REVIEW: 'badge-red',
OUT_OF_SERVICE: 'badge-red',
}
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
const ALL_STATUSES: Vehicle['status'][] = [
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
]
const STATUS_DOT: Record<Vehicle['status'], string> = {
AVAILABLE: 'bg-green-500',
RESERVED: 'bg-blue-500',
READY: 'bg-teal-500',
RENTED: 'bg-indigo-500',
RETURNED: 'bg-amber-400',
NEEDS_CLEANING: 'bg-amber-500',
MAINTENANCE: 'bg-orange-500',
DAMAGE_REVIEW: 'bg-red-400',
OUT_OF_SERVICE: 'bg-red-600',
}
interface MaintenanceModalProps {
vehicleId: string
vehicleName: string
@@ -156,88 +135,48 @@ function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: Ma
)
}
function StatusDropdown({ vehicleId, status, onStatusChange }: {
function StatusDropdown({ vehicleId, status, onStatusChange, disabled, disabledReason, onError }: {
vehicleId: string
status: Vehicle['status']
onStatusChange: (id: string, status: Vehicle['status']) => void
disabled?: boolean
disabledReason?: string
onError: (message: string) => void
}) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const openDropdown = () => {
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(true)
}
useEffect(() => {
if (!open) return
const close = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!panelRef.current?.contains(e.target as Node)
) setOpen(false)
}
const closeOnScroll = () => setOpen(false)
document.addEventListener('mousedown', close)
window.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
window.removeEventListener('scroll', closeOnScroll, true)
}
}, [open])
const select = async (next: Vehicle['status']) => {
if (next === status) { setOpen(false); return }
setOpen(false)
if (next === status) return
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
alert(err.message)
onError(err.message ?? 'Failed to update vehicle status')
} finally {
setSaving(false)
}
}
return (
<>
<button
ref={triggerRef}
onClick={openDropdown}
disabled={saving}
className={`inline-flex items-center gap-1 ${STATUS_BADGE[status]} cursor-pointer select-none`}
>
{saving ? '…' : f.statusLabels[status]}
<ChevronDown className="w-3 h-3 opacity-60" />
</button>
{open && (
<div
ref={panelRef}
style={{ top: pos.top, left: pos.left }}
className="fixed z-50 min-w-[160px] rounded-xl border border-slate-100 bg-white shadow-lg py-1"
>
{ALL_STATUSES.map((s) => (
<button
key={s}
onClick={() => select(s)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-start hover:bg-slate-50 transition-colors ${s === status ? 'font-semibold' : ''}`}
>
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[s]}`} />
{f.statusLabels[s]}
</button>
))}
</div>
)}
</>
<select
value={status}
disabled={disabled || saving}
title={disabled ? disabledReason : undefined}
onChange={(e) => void select(e.target.value as Vehicle['status'])}
className={[
'rounded-full border border-slate-200 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 shadow-sm',
disabled || saving ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-slate-300',
].join(' ')}
>
{ALL_STATUSES.map((value) => (
<option key={value} value={value}>
{f.statusLabels[value]}
</option>
))}
</select>
)
}
@@ -605,12 +544,23 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
}
export default function FleetPage() {
const { dict } = useDashboardI18n()
const { dict, language } = useDashboardI18n()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const f = dict.fleet
const managerRequiredMessage =
language === 'fr'
? 'Cette action nécessite le rôle Gestionnaire ou supérieur.'
: language === 'ar'
? 'يتطلب هذا الإجراء دور المدير أو أعلى.'
: 'This action requires the Manager role or higher.'
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [employeeRole, setEmployeeRole] = useState<EmployeeRole | null>(null)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
@@ -627,12 +577,51 @@ export default function FleetPage() {
useEffect(() => { fetchVehicles() }, [])
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { role?: EmployeeRole }
if (profile.role) setEmployeeRole(profile.role)
} catch {}
}
let cancelled = false
apiFetch<{ employee: { role: EmployeeRole } }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
setEmployeeRole(employee.role)
})
.catch(() => {})
return () => { cancelled = true }
}, [])
useEffect(() => {
if (searchParams.get('modal') === 'add-car') {
setShowAddModal(true)
}
}, [searchParams])
const canManageFleet = employeeRole === 'OWNER' || employeeRole === 'MANAGER'
function closeAddModal() {
setShowAddModal(false)
if (searchParams.get('modal') === 'add-car') {
const params = new URLSearchParams(searchParams.toString())
params.delete('modal')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
}
}
const togglePublished = async (id: string, current: boolean) => {
setActionError(null)
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)
setActionError(err.statusCode === 403 ? managerRequiredMessage : (err.message ?? 'Failed to update publication status'))
}
}
@@ -665,11 +654,13 @@ export default function FleetPage() {
<h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
<p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
</div>
<button onClick={() => setShowAddModal(true)} className="btn-primary">
<button onClick={() => setShowAddModal(true)} className="btn-primary" disabled={!canManageFleet} title={!canManageFleet ? managerRequiredMessage : undefined}>
<Plus className="w-4 h-4" /> {f.addVehicle}
</button>
</div>
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
{/* Filters */}
<div className="card p-4 flex flex-wrap gap-3">
<div className="flex items-center gap-2 flex-1 min-w-48">
@@ -739,7 +730,14 @@ export default function FleetPage() {
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
</td>
<td className="px-6 py-4">
<StatusDropdown vehicleId={vehicle.id} status={vehicle.status} onStatusChange={changeStatus} />
<StatusDropdown
vehicleId={vehicle.id}
status={vehicle.status}
onStatusChange={changeStatus}
disabled={!canManageFleet}
disabledReason={managerRequiredMessage}
onError={setActionError}
/>
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
@@ -748,8 +746,11 @@ export default function FleetPage() {
<div className="flex items-center">
<button
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
disabled={!canManageFleet}
title={!canManageFleet ? managerRequiredMessage : undefined}
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',
!canManageFleet ? 'cursor-not-allowed opacity-60' : '',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
>
@@ -790,7 +791,7 @@ export default function FleetPage() {
)}
</div>
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
<AddVehicleModal open={showAddModal} onClose={closeAddModal} onSaved={fetchVehicles} />
{maintenanceModal && (
<MaintenanceModal
vehicleId={maintenanceModal.vehicleId}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -25,18 +26,26 @@ interface ReservationRow {
customer: { firstName: string; lastName: string; email: string }
}
interface VehicleRow {
id: string
}
export default function ReservationsPage() {
const { dict, language } = useDashboardI18n()
const router = useRouter()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [rows, setRows] = useState<ReservationRow[]>([])
const [error, setError] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [checkingNewReservation, setCheckingNewReservation] = useState(false)
const [showNoVehiclesModal, setShowNoVehiclesModal] = useState(false)
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
.catch((err) => setLoadError(err.message))
}, [])
const formatDate = (iso: string) =>
@@ -52,6 +61,40 @@ export default function ReservationsPage() {
}
return language === 'fr' ? 'Voir la réservation' : language === 'ar' ? 'عرض الحجز' : 'View reservation'
}
const noVehiclesMessage =
language === 'fr'
? 'Ajoutez au moins un véhicule à votre flotte avant de créer une réservation.'
: language === 'ar'
? 'أضف مركبة واحدة على الأقل إلى الأسطول قبل إنشاء حجز.'
: 'Add at least one vehicle to your fleet before creating a reservation.'
async function handleNewReservationClick() {
setActionError(null)
setCheckingNewReservation(true)
try {
const vehicles = await apiFetch<VehicleRow[]>('/vehicles?pageSize=1')
if (!vehicles || vehicles.length === 0) {
setShowNoVehiclesModal(true)
return
}
router.push('/reservations/new')
} catch (err: any) {
setActionError(err.message ?? (
language === 'fr'
? 'Impossible de vérifier la flotte avant de créer la réservation.'
: language === 'ar'
? 'تعذر التحقق من الأسطول قبل إنشاء الحجز.'
: 'Failed to verify fleet before creating the reservation.'
))
} finally {
setCheckingNewReservation(false)
}
}
function handleAddCar() {
setShowNoVehiclesModal(false)
router.push('/fleet?modal=add-car')
}
return (
<div className="space-y-6">
@@ -60,13 +103,29 @@ export default function ReservationsPage() {
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
</div>
<Link href="/reservations/new" className="btn-primary whitespace-nowrap">
{language === 'fr' ? 'Réserver une voiture' : language === 'ar' ? 'حجز سيارة' : 'Book car'}
</Link>
<button
type="button"
className="btn-primary whitespace-nowrap"
onClick={handleNewReservationClick}
disabled={checkingNewReservation}
>
{checkingNewReservation
? language === 'fr'
? 'Vérification…'
: language === 'ar'
? 'جارٍ التحقق…'
: 'Checking…'
: language === 'fr'
? 'Réserver une voiture'
: language === 'ar'
? 'حجز سيارة'
: 'Book car'}
</button>
</div>
{actionError ? <div className="card p-4 text-sm text-red-600">{actionError}</div> : null}
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
{loadError ? (
<div className="p-8 text-sm text-red-600">{loadError}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
@@ -109,6 +168,29 @@ export default function ReservationsPage() {
</div>
)}
</div>
{showNoVehiclesModal ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4"
onClick={(event) => {
if (event.target === event.currentTarget) setShowNoVehiclesModal(false)
}}
>
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl">
<h3 className="text-lg font-semibold text-slate-900">
{language === 'fr' ? 'Ajouter un véhicule' : language === 'ar' ? 'أضف مركبة' : 'Add a car'}
</h3>
<p className="mt-2 text-sm text-slate-600">{noVehiclesMessage}</p>
<div className="mt-6 flex gap-3">
<button type="button" className="btn-primary flex-1" onClick={handleAddCar}>
{language === 'fr' ? 'Ajouter une voiture' : language === 'ar' ? 'إضافة مركبة' : 'Add car'}
</button>
<button type="button" className="btn-secondary flex-1" onClick={() => setShowNoVehiclesModal(false)}>
{language === 'fr' ? 'Annuler' : language === 'ar' ? 'إلغاء' : 'Cancel'}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}