add car reservation feature
This commit is contained in:
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user