prevent booking when no car added
This commit is contained in:
@@ -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`}
|
||||
<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(' ')}
|
||||
>
|
||||
{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>
|
||||
{ALL_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{f.statusLabels[value]}
|
||||
</option>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user