add car reservation feature

This commit is contained in:
root
2026-05-09 21:10:38 -04:00
parent 09b0e3b55f
commit 6322b7d2a1
37 changed files with 1169 additions and 119 deletions
+9
View File
@@ -34,6 +34,15 @@ const nextConfig = {
],
},
transpilePackages: ['@rentaldrivego/types'],
async rewrites() {
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
return [
{
source: '/api/:path*',
destination: `${apiOrigin}/api/:path*`,
},
]
},
}
module.exports = nextConfig
@@ -3,9 +3,10 @@
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 { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar'
interface VehicleDetail {
id: string
@@ -65,11 +66,14 @@ function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
type Tab = 'details' | 'calendar'
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 [activeTab, setActiveTab] = useState<Tab>('details')
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -253,7 +257,34 @@ export default function FleetDetailPage() {
<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]">
{/* Tab bar */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
<button
onClick={() => setActiveTab('details')}
className={[
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'details'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
].join(' ')}
>
<Info className="w-4 h-4" /> Details
</button>
<button
onClick={() => setActiveTab('calendar')}
className={[
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === 'calendar'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
].join(' ')}
>
<CalendarDays className="w-4 h-4" /> Calendar
</button>
</div>
{activeTab === 'details' && (
<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>
@@ -493,7 +524,12 @@ export default function FleetDetailPage() {
</div>
)}
</div>
</div>
</div>
)}
{activeTab === 'calendar' && (
<VehicleCalendar vehicleId={params.id} />
)}
</div>
)
}
@@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
@@ -81,9 +81,9 @@ export default function OnlineReservationsPage() {
const load = useCallback(async () => {
try {
setLoading(true)
const result = await apiFetch<ApiPaginated<OnlineReservation>>('/reservations?source=MARKETPLACE&pageSize=100')
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&pageSize=100')
setRows(
(result.data ?? []).sort((a, b) => {
(result ?? []).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
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
interface ReportRow {
reservationId: string
@@ -37,8 +37,6 @@ const periodLabels: Record<ReportPeriod, string> = {
ANNUAL: 'Annual',
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function ReportsPage() {
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
@@ -3,10 +3,9 @@
import Link from 'next/link'
import { useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { API_BASE } from '@/lib/api'
import { marketplaceUrl } from '@/lib/urls'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
export default function ForgotPasswordPage() {
const { language } = useDashboardI18n()
const dict = {
@@ -4,8 +4,7 @@ import Link from 'next/link'
import { useState, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
import { API_BASE } from '@/lib/api'
export default function ResetPasswordPage() {
return (
@@ -4,11 +4,8 @@ import Link from 'next/link'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const ADMIN_URL = process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
export default function SignInPage() {
const { language } = useDashboardI18n()
@@ -141,11 +138,11 @@ function LocalSignInForm({ dict, portal }: {
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const forgotPasswordHref = portal === 'admin' ? `${ADMIN_URL}/forgot-password` : '/forgot-password'
const forgotPasswordHref = portal === 'admin' ? `${adminUrl}/forgot-password` : '/forgot-password'
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${ADMIN_URL}/auth-redirect#${hash}`
window.location.href = `${adminUrl}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
@@ -0,0 +1,412 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
import { apiFetch } from '@/lib/api'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
interface CalendarEvent {
id: string
type: EventType
startDate: string
endDate: string
status: string | null
label: string
}
interface Props {
vehicleId: string
}
const EVENT_STYLES: Record<EventType, { bg: string; text: string; border: string; dot: string }> = {
RESERVATION: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', dot: 'bg-blue-500' },
MAINTENANCE: { bg: 'bg-amber-100', text: 'text-amber-800', border: 'border-amber-300', dot: 'bg-amber-500' },
BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' },
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10)
}
function parseDateStr(s: string): Date {
const [y, m, day] = s.split('-').map(Number)
return new Date(y, m - 1, day)
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function daysBetween(start: Date, end: Date): number {
return Math.round((end.getTime() - start.getTime()) / 86400000)
}
export default function VehicleCalendar({ vehicleId }: Props) {
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Block-creation modal
const [showModal, setShowModal] = useState(false)
const [blockStart, setBlockStart] = useState('')
const [blockEnd, setBlockEnd] = useState('')
const [blockReason, setBlockReason] = useState('')
const [blockType, setBlockType] = useState<'MANUAL' | 'MAINTENANCE'>('MANUAL')
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Day-click selection for quick create
const [selectStart, setSelectStart] = useState<string | null>(null)
const fetchCalendar = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await apiFetch<CalendarEvent[]>(
`/vehicles/${vehicleId}/calendar?year=${year}&month=${month}`
)
setEvents(data)
} catch (e: any) {
setError(e.message ?? 'Failed to load calendar')
} finally {
setLoading(false)
}
}, [vehicleId, year, month])
useEffect(() => { fetchCalendar() }, [fetchCalendar])
const prevMonth = () => {
if (month === 1) { setYear(y => y - 1); setMonth(12) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 12) { setYear(y => y + 1); setMonth(1) }
else setMonth(m => m + 1)
}
// Build calendar grid (6 rows × 7 cols)
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay() // 0=Sun
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
// Map events to date strings for fast lookup
const eventsOnDay = (day: Date): CalendarEvent[] => {
const dayStr = toDateStr(day)
return events.filter((e) => {
const s = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return s <= dayStr && dayStr < end
})
}
const isToday = (day: Date) => isSameDay(day, today)
const handleDayClick = (day: Date) => {
const str = toDateStr(day)
if (!selectStart) {
setSelectStart(str)
} else {
const start = selectStart < str ? selectStart : str
const end = selectStart < str ? str : selectStart
// end date in the modal is the last night, so add one day
const endDate = toDateStr(new Date(parseDateStr(end).getTime() + 86400000))
setBlockStart(start)
setBlockEnd(endDate)
setSelectStart(null)
setShowModal(true)
}
}
const openModal = () => {
setBlockStart('')
setBlockEnd('')
setBlockReason('')
setBlockType('MANUAL')
setSaveError(null)
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setSaveError(null)
setSelectStart(null)
}
const handleSaveBlock = async () => {
if (!blockStart || !blockEnd) { setSaveError('Start and end dates are required.'); return }
if (blockEnd <= blockStart) { setSaveError('End date must be after start date.'); return }
setSaving(true)
setSaveError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks`, {
method: 'POST',
body: JSON.stringify({ startDate: blockStart, endDate: blockEnd, reason: blockReason || undefined, type: blockType }),
})
setShowModal(false)
fetchCalendar()
} catch (e: any) {
setSaveError(e.message ?? 'Failed to create block')
} finally {
setSaving(false)
}
}
const handleDeleteBlock = async (id: string) => {
if (!confirm('Remove this block?')) return
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
fetchCalendar()
} catch (e: any) {
alert(e.message ?? 'Failed to delete block')
}
}
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
// Separate reservations vs blocks for the legend/list below
const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
const blockEvents = events.filter((e) => e.type !== 'RESERVATION')
return (
<div className="card p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-slate-600" />
<h3 className="text-base font-semibold text-slate-900">Availability Calendar</h3>
</div>
<div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium text-slate-700 min-w-[130px] text-center">{monthLabel}</span>
<button onClick={nextMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
<ChevronRight className="w-4 h-4" />
</button>
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
<Plus className="w-3.5 h-3.5" /> Block dates
</button>
</div>
</div>
{selectStart && (
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
Click a second day to set the block end date (selected: {selectStart})
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>cancel</button>
</div>
)}
{/* Legend */}
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />Reserved</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />Maintenance</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />Blocked</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />Available</span>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
{/* Calendar grid */}
<div className={loading ? 'opacity-50 pointer-events-none' : ''}>
{/* Day headers */}
<div className="grid grid-cols-7 mb-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-px bg-slate-100 rounded-xl overflow-hidden border border-slate-100">
{gridDays.map((day, i) => {
if (!day) return <div key={`empty-${i}`} className="bg-white min-h-[72px]" />
const dayEvents = eventsOnDay(day)
const isSelected = selectStart === toDateStr(day)
const isPast = day < today && !isToday(day)
return (
<div
key={toDateStr(day)}
onClick={() => handleDayClick(day)}
className={[
'bg-white min-h-[72px] p-1.5 cursor-pointer transition-colors',
isToday(day) ? 'ring-2 ring-inset ring-blue-500' : '',
isSelected ? 'bg-blue-50' : 'hover:bg-slate-50',
isPast ? 'opacity-60' : '',
].filter(Boolean).join(' ')}
>
<div className={[
'text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full',
isToday(day) ? 'bg-blue-500 text-white' : 'text-slate-700',
].join(' ')}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 2).map((ev) => {
const s = EVENT_STYLES[ev.type]
return (
<div key={ev.id} className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${s.bg} ${s.text}`}>
{ev.label}
</div>
)
})}
{dayEvents.length > 2 && (
<div className="text-[10px] text-slate-400 pl-1">+{dayEvents.length - 2} more</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Events list for this month */}
{(reservationEvents.length > 0 || blockEvents.length > 0) && (
<div className="space-y-3 pt-2">
{reservationEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Reservations this month</p>
<div className="space-y-1.5">
{reservationEvents.map((e) => {
const s = EVENT_STYLES[e.type]
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
const days = daysBetween(parseDateStr(start), parseDateStr(end))
return (
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${s.dot}`} />
<span className={`font-medium ${s.text}`}>{e.label}</span>
<span className={`text-xs ${s.text} opacity-70`}>{start} {end} ({days}d)</span>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full bg-white/60 ${s.text}`}>{e.status}</span>
</div>
)
})}
</div>
</div>
)}
{blockEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Blocks &amp; maintenance</p>
<div className="space-y-1.5">
{blockEvents.map((e) => {
const s = EVENT_STYLES[e.type]
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
const days = daysBetween(parseDateStr(start), parseDateStr(end))
const Icon = e.type === 'MAINTENANCE' ? Wrench : Ban
return (
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
<div className="flex items-center gap-2">
<Icon className={`w-3.5 h-3.5 ${s.text}`} />
<span className={`font-medium ${s.text}`}>{e.label}</span>
<span className={`text-xs ${s.text} opacity-70`}>{start} {end} ({days}d)</span>
</div>
<button
onClick={() => handleDeleteBlock(e.id)}
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
title="Remove block"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)
})}
</div>
</div>
)}
</div>
)}
{/* Add Block Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={closeModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-slate-900">Block vehicle dates</h4>
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Block type</label>
<div className="flex gap-2">
<button
onClick={() => setBlockType('MANUAL')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Ban className="w-3.5 h-3.5" /> Manual block
</button>
<button
onClick={() => setBlockType('MAINTENANCE')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-amber-50 border-amber-300 text-amber-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
>
<Wrench className="w-3.5 h-3.5" /> Maintenance
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Start date</label>
<input
type="date"
className="input-field"
value={blockStart}
onChange={(e) => setBlockStart(e.target.value)}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">End date (exclusive)</label>
<input
type="date"
className="input-field"
value={blockEnd}
min={blockStart}
onChange={(e) => setBlockEnd(e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Reason <span className="font-normal">(optional)</span></label>
<input
type="text"
className="input-field"
placeholder={blockType === 'MAINTENANCE' ? 'e.g. Oil change, tire rotation…' : 'e.g. Reserved for VIP, company event…'}
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
/>
</div>
</div>
{saveError && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{saveError}</div>
)}
<div className="flex gap-2 pt-1">
<button onClick={closeModal} className="flex-1 btn-secondary">Cancel</button>
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
{saving ? 'Saving…' : 'Save block'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+2 -2
View File
@@ -1,5 +1,5 @@
const API_BASE =
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
export const API_BASE =
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
?? process.env.NEXT_PUBLIC_API_URL
?? 'http://localhost:4000/api/v1'
+12
View File
@@ -0,0 +1,12 @@
export function resolveBrowserAppUrl(fallback: string): string {
if (typeof window === 'undefined') return fallback
try {
const target = new URL(fallback)
target.protocol = window.location.protocol
target.hostname = window.location.hostname
return target.toString().replace(/\/$/, '')
} catch {
return fallback
}
}
+4 -1
View File
@@ -1 +1,4 @@
export const marketplaceUrl = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export const marketplaceUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000')
export const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')