fix and update language for company workspace

This commit is contained in:
root
2026-05-11 23:40:40 -04:00
committed by Administrator
parent 193aeae834
commit 1a39aa8433
43 changed files with 5034 additions and 802 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ DASHBOARD_URL=http://localhost:3001
JWT_SECRET=dev-secret
JWT_EXPIRY=8h
RENTER_JWT_EXPIRY=7d
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
ADMIN_SEED_PASSWORD=changeme123
ADMIN_SEED_FIRST_NAME=Platform
ADMIN_SEED_LAST_NAME=Admin
+106
View File
@@ -279,6 +279,112 @@ cron.schedule('0 9 * * *', async () => {
}
})
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
cron.schedule('0 8 * * *', async () => {
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
// old overdue log is superseded and notifications stop automatically.
const allCandidates = await prisma.maintenanceLog.findMany({
where: {
OR: [
{ nextDueAt: { lte: in30Days } },
{ nextDueMileage: { not: null } },
],
},
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
orderBy: { performedAt: 'desc' },
})
// Keep only the most-recent log per vehicle+type combination
const latestByKey = new Map<string, typeof allCandidates[number]>()
for (const log of allCandidates) {
const key = `${log.vehicleId}:${log.type}`
if (!latestByKey.has(key)) latestByKey.set(key, log)
}
for (const log of latestByKey.values()) {
const vehicle = log.vehicle
const company = vehicle.company
const recipient = company.employees[0]
if (!recipient) continue
// Determine date-based urgency
let isOverdueByDate = false
let daysLeft: number | null = null
let dueSoonByDate = false
if (log.nextDueAt) {
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
isOverdueByDate = log.nextDueAt <= now
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
}
// Determine odometer-based urgency
let isOverdueByOdometer = false
let kmLeft: number | null = null
let dueSoonByOdometer = false
if (log.nextDueMileage != null && vehicle.mileage != null) {
kmLeft = log.nextDueMileage - vehicle.mileage
isOverdueByOdometer = kmLeft <= 0
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
}
// Skip if the latest log is no longer due (owner has updated it)
const isOverdue = isOverdueByDate || isOverdueByOdometer
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
if (!isOverdue && !isDueSoon) continue
// Dedup: don't send more than once per day for the same log
const alreadySent = await prisma.notification.findFirst({
where: {
type: 'VEHICLE_MAINTENANCE_DUE',
companyId: company.id,
createdAt: { gte: oneDayAgo },
data: { path: ['maintenanceLogId'], equals: log.id },
},
})
if (alreadySent) continue
// Build human-readable description
const dueParts: string[] = []
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
const title = isOverdue
? `Overdue: ${log.type}${vehicle.make} ${vehicle.model}`
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
await prisma.notification.create({
data: {
type: 'VEHICLE_MAINTENANCE_DUE',
title,
body,
data: {
vehicleId: vehicle.id,
maintenanceLogId: log.id,
maintenanceType: log.type,
isOverdue,
daysLeft,
kmLeft,
isOverdueByDate,
isOverdueByOdometer,
},
companyId: company.id,
employeeId: recipient.id,
channel: 'IN_APP',
status: 'DELIVERED',
},
})
}
})
// ─── Start ────────────────────────────────────────────────────
const PORT = process.env.API_PORT ?? 4000
server.listen(PORT, () => {
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+62
View File
@@ -28,6 +28,68 @@ function buildResetEmailHtml(resetUrl: string, firstName: string) {
`
}
router.get('/me', async (req, res, next) => {
try {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
let employeeId = ''
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'employee') {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
employeeId = payload.sub
} catch {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
res.json({
data: {
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
},
})
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => {
try {
const { email, password } = z.object({
+68 -5
View File
@@ -50,7 +50,7 @@ router.get('/companies', async (req, res, next) => {
where,
include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
_count: { select: { vehicles: { where: { isPublished: true } } } },
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
},
skip: (page - 1) * pageSize,
take: pageSize,
@@ -77,7 +77,7 @@ router.get('/search', async (req, res, next) => {
const { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
const { page, pageSize } = paginationSchema.parse(req.query)
const where: any = { isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
const where: any = { isPublished: true, status: 'AVAILABLE', company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
if (category) where.category = category
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
if (transmission) where.transmission = transmission
@@ -249,7 +249,7 @@ router.get('/:slug', async (req, res, next) => {
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
include: {
brand: true,
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
},
})
@@ -294,7 +294,7 @@ router.get('/:slug/vehicles', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true },
where: { companyId: company.id, isPublished: true, status: 'AVAILABLE' },
orderBy: { createdAt: 'desc' },
})
res.json({ data: vehicles })
@@ -308,7 +308,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: req.params.id, companyId: company.id, isPublished: true },
where: { id: req.params.id, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
include: {
company: { include: { brand: true } },
},
@@ -343,6 +343,69 @@ router.get('/:slug/offers', async (req, res, next) => {
}
})
// ─── Review token endpoints ───────────────────────────────────
router.get('/review/:token', async (req, res, next) => {
try {
const reservation = await prisma.reservation.findUnique({
where: { reviewToken: req.params.token },
include: {
vehicle: { select: { make: true, model: true, year: true, photos: true } },
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
review: { select: { id: true } },
},
})
if (!reservation) return res.status(404).json({ error: 'invalid_token', message: 'Review link is invalid or has expired', statusCode: 404 })
if (reservation.review) return res.status(409).json({ error: 'already_reviewed', message: 'A review has already been submitted for this reservation', statusCode: 409 })
res.json({
data: {
reservationId: reservation.id,
companyName: reservation.company.brand?.displayName ?? reservation.company.name,
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
},
})
} catch (err) { next(err) }
})
router.post('/review/:token', async (req, res, next) => {
try {
const body = z.object({
overallRating: z.number().int().min(1).max(5),
vehicleRating: z.number().int().min(1).max(5).optional(),
serviceRating: z.number().int().min(1).max(5).optional(),
comment: z.string().max(2000).optional(),
}).parse(req.body)
const reservation = await prisma.reservation.findUnique({
where: { reviewToken: req.params.token },
include: { review: { select: { id: true } } },
})
if (!reservation) return res.status(404).json({ error: 'invalid_token', message: 'Review link is invalid or has expired', statusCode: 404 })
if (reservation.review) return res.status(409).json({ error: 'already_reviewed', message: 'A review has already been submitted for this reservation', statusCode: 409 })
const review = await prisma.review.create({
data: {
reservationId: reservation.id,
companyId: reservation.companyId,
renterId: reservation.renterId ?? undefined,
overallRating: body.overallRating,
vehicleRating: body.vehicleRating,
serviceRating: body.serviceRating,
comment: body.comment,
isPublished: true,
},
})
// Invalidate token so the link can't be reused
await prisma.reservation.update({ where: { id: reservation.id }, data: { reviewToken: null } })
res.status(201).json({ data: { reviewId: review.id } })
} catch (err) { next(err) }
})
router.post('/offers/:code/validate', async (req, res, next) => {
try {
const offer = await prisma.offer.findFirst({
+37 -4
View File
@@ -8,7 +8,8 @@ import { applyAdditionalDriversToReservation } from '../services/additionalDrive
import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import { sendNotification } from '../services/notificationService'
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
import crypto from 'crypto'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
@@ -239,13 +240,45 @@ router.post('/:id/checkin', async (req, res, next) => {
router.post('/:id/checkout', async (req, res, next) => {
try {
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const reservation = await prisma.reservation.findFirstOrThrow({
where: { id: req.params.id, companyId: req.companyId },
include: { vehicle: true },
})
if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 })
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null } })
const reviewToken = crypto.randomBytes(32).toString('hex')
const updated = await prisma.reservation.update({
where: { id: req.params.id },
data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null, reviewToken },
})
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } })
// Send "How was your rental?" email with secure one-time review link
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null)
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true } } } })
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: `How was your rental? — ${vehicleLabel}`,
html: `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<h2 style="margin-bottom:4px">Hi ${customer.firstName},</h2>
<p style="color:#57534e">Thank you for renting with <strong>${companyName}</strong>. We hope you enjoyed your <strong>${vehicleLabel}</strong>.</p>
<p style="color:#57534e">Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.</p>
<div style="margin:32px 0;text-align:center">
<a href="${reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
Leave a review
</a>
</div>
<p style="color:#a8a29e;font-size:12px">This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.</p>
</div>
`,
text: `Hi ${customer.firstName},\n\nThank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.\n\nLeave a review here (one-time link):\n${reviewUrl}\n\nThank you!`,
}).catch(() => null)
res.json({ data: updated })
} catch (err) { next(err) }
+1
View File
@@ -308,6 +308,7 @@ router.post('/:slug/book', async (req, res, next) => {
customerId: customer.id,
offerId: body.offerId ?? null,
promoCodeUsed: body.promoCodeUsed ?? null,
vehicleCategory: vehicle.category,
source: body.source,
startDate: start,
endDate: end,
+9 -3
View File
@@ -28,6 +28,7 @@ const vehicleSchema = z.object({
mileage: z.number().int().optional(),
notes: z.string().optional(),
isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
})
router.get('/', async (req, res, next) => {
@@ -65,9 +66,14 @@ router.get('/:id', async (req, res, next) => {
router.patch('/:id', async (req, res, next) => {
try {
const body = vehicleSchema.partial().parse(req.body)
const vehicle = await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body })
if (vehicle.count === 0) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const updated = await prisma.vehicle.findUniqueOrThrow({ where: { id: req.params.id } })
if (body.status === 'MAINTENANCE' || body.status === 'OUT_OF_SERVICE') {
body.isPublished = false
} else if (body.status === 'AVAILABLE' || body.status === 'RENTED') {
body.isPublished = true
}
const existing = await prisma.vehicle.findFirst({ where: { id: req.params.id, companyId: req.companyId } })
if (!existing) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
const updated = await prisma.vehicle.update({ where: { id: req.params.id }, data: body })
res.json({ data: updated })
} catch (err) { next(err) }
})
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
@@ -45,13 +46,8 @@ const INVOICE_STATUS: Record<string, string> = {
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const PLAN_FEATURES: Record<Plan, string[]> = {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
}
export default function BillingPage() {
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
@@ -63,6 +59,131 @@ export default function BillingPage() {
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
en: {
title: 'Billing',
subtitle: 'Manage your plan, payment provider, and invoice history.',
trial: 'Free trial',
remaining: 'remaining. Subscribe before it ends to keep access.',
currentPlan: 'Current plan',
renews: 'renews',
cancelScheduled: 'Cancellation scheduled at end of billing period.',
undo: 'Undo',
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
paymentProvider: 'Payment provider',
total: 'Total',
perMonth: 'month',
perYear: 'year',
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
date: 'Date',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>,
},
fr: {
title: 'Facturation',
subtitle: 'Gérez votre plan, le prestataire de paiement et lhistorique des factures.',
trial: 'Essai gratuit',
remaining: 'restants. Abonnez-vous avant la fin pour garder laccès.',
currentPlan: 'Plan actuel',
renews: 'renouvelle le',
cancelScheduled: 'Annulation programmée à la fin de la période.',
undo: 'Annuler',
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
paymentProvider: 'Prestataire de paiement',
total: 'Total',
perMonth: 'mois',
perYear: 'an',
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
noInvoices: 'Aucune facture pour le moment.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>,
},
ar: {
title: 'الفوترة',
subtitle: 'إدارة الخطة ومزوّد الدفع وسجل الفواتير.',
trial: 'تجربة مجانية',
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
currentPlan: 'الخطة الحالية',
renews: 'يتجدد في',
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
undo: 'تراجع',
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
paymentProvider: 'مزوّد الدفع',
total: 'الإجمالي',
perMonth: 'شهر',
perYear: 'سنة',
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>,
},
}[language]
useEffect(() => {
Promise.all([
@@ -139,8 +260,8 @@ export default function BillingPage() {
return (
<div className="space-y-8">
<div>
<h2 className="text-xl font-semibold text-slate-900">Billing</h2>
<p className="text-sm text-slate-500 mt-1">Manage your plan, payment provider, and invoice history.</p>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
{error && (
@@ -151,7 +272,7 @@ export default function BillingPage() {
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800">
Free trial <strong>{daysLeft} days</strong> remaining. Subscribe before it ends to keep access.
{copy.trial} <strong>{daysLeft} days</strong> {copy.remaining}
</p>
</div>
)}
@@ -161,21 +282,21 @@ export default function BillingPage() {
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Current plan</p>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{subscription.status}
{copy.statusLabels[subscription.status] ?? subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-amber-700">
Cancellation scheduled at end of billing period.{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">Undo</button>
{copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p>
)}
</div>
@@ -185,7 +306,7 @@ export default function BillingPage() {
disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
>
{cancelling ? 'Cancelling…' : 'Cancel plan'}
{cancelling ? copy.cancelling : copy.cancelPlan}
</button>
)}
</div>
@@ -196,9 +317,9 @@ export default function BillingPage() {
<div className="card p-6 space-y-6">
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'}
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">Select a plan and payment provider to proceed.</p>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
</div>
{/* Billing period toggle */}
@@ -211,7 +332,7 @@ export default function BillingPage() {
billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? 'Monthly' : 'Annual (save ~17%)'}
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
@@ -248,14 +369,14 @@ export default function BillingPage() {
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">Active</span>}
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, currency) : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'}</span>
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{PLAN_FEATURES[plan].map((f) => (
{copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
@@ -268,7 +389,7 @@ export default function BillingPage() {
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">Payment provider</p>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
<button
@@ -287,10 +408,10 @@ export default function BillingPage() {
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm text-slate-500">Total</p>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, currency) : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? 'month' : 'year'}</span>
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p>
</div>
<button
@@ -298,7 +419,7 @@ export default function BillingPage() {
disabled={paying || loading}
className="btn-primary px-8 py-3"
>
{paying ? 'Redirecting…' : subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe now'}
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
</button>
</div>
</div>
@@ -306,31 +427,31 @@ export default function BillingPage() {
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">Invoice history</h3>
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Date</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Provider</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Paid</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Amount</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">Loading</td></tr>
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">No invoices yet.</td></tr>
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{inv.status}
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface CustomerRow {
id: string
@@ -14,8 +15,47 @@ interface CustomerRow {
}
export default function CustomersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<CustomerRow[]>([])
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Customers',
subtitle: 'Company-scoped CRM with license validation and risk flags.',
customer: 'Customer',
contact: 'Contact',
license: 'License',
flags: 'Flags',
noPhone: 'No phone',
flagged: 'Flagged',
clear: 'Clear',
empty: 'No customers yet.',
},
fr: {
title: 'Clients',
subtitle: 'CRM de lentreprise avec validation de permis et indicateurs de risque.',
customer: 'Client',
contact: 'Contact',
license: 'Permis',
flags: 'Signalements',
noPhone: 'Pas de téléphone',
flagged: 'Signalé',
clear: 'Aucun risque',
empty: 'Aucun client pour le moment.',
},
ar: {
title: 'العملاء',
subtitle: 'نظام CRM خاص بالشركة مع التحقق من الرخصة ومؤشرات المخاطر.',
customer: 'العميل',
contact: 'التواصل',
license: 'الرخصة',
flags: 'الإشارات',
noPhone: 'لا يوجد هاتف',
flagged: 'مُعلّم',
clear: 'سليم',
empty: 'لا يوجد عملاء حتى الآن.',
},
}[language]
useEffect(() => {
apiFetch<CustomerRow[]>('/customers?pageSize=100')
@@ -26,8 +66,8 @@ export default function CustomersPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Customers</h2>
<p className="text-sm text-slate-500 mt-1">Company-scoped CRM with license validation and risk flags.</p>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
<div className="card overflow-hidden">
{error ? (
@@ -37,10 +77,10 @@ export default function CustomersPage() {
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Contact</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">License</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Flags</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.contact}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.license}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.flags}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@@ -49,15 +89,15 @@ export default function CustomersPage() {
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? 'No phone'}</p>
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td>
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">Flagged</span> : <span className="badge-green">Clear</span>}</td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">No customers yet.</td>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
@@ -3,10 +3,11 @@
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Image from 'next/image'
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info } from 'lucide-react'
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar'
import { useDashboardI18n } from '@/components/I18nProvider'
interface VehicleDetail {
id: string
@@ -66,11 +67,210 @@ function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
type Tab = 'details' | 'calendar'
interface MaintenanceLog {
id: string
type: string
description: string | null
cost: number | null
mileage: number | null
performedAt: string
nextDueAt: string | null
nextDueMileage: number | null
}
const ROUTINE_TYPES = [
{ key: 'Oil Change', intervalMonths: 6 },
{ key: 'Technical Inspection', intervalMonths: 12 },
{ key: 'Vehicle Registration', intervalMonths: 12 },
{ key: 'Car Tax', intervalMonths: 12 },
{ key: 'Tire Rotation', intervalMonths: 6 },
{ key: 'Brake Inspection', intervalMonths: 12 },
{ key: 'Air Filter', intervalMonths: 12 },
{ key: 'Cabin Filter', intervalMonths: 12 },
{ key: 'Battery Check', intervalMonths: 12 },
{ key: 'Belt / Chain Service', intervalMonths: 24 },
{ key: 'Coolant Flush', intervalMonths: 24 },
{ key: 'Transmission Service', intervalMonths: 24 },
]
function serviceStatus(log: MaintenanceLog | undefined, currentMileage: number | null): 'none' | 'overdue' | 'due-soon' | 'ok' {
if (!log) return 'none'
const now = new Date()
let worstLevel: 'ok' | 'due-soon' | 'overdue' = 'ok'
if (log.nextDueAt) {
const diffDays = (new Date(log.nextDueAt).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays < 0) return 'overdue'
if (diffDays <= 30) worstLevel = 'due-soon'
}
if (log.nextDueMileage != null && currentMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
if (kmLeft <= 0) return 'overdue'
if (kmLeft <= 500 && worstLevel !== 'overdue') worstLevel = 'due-soon'
}
return worstLevel
}
function StatusPill({ status }: { status: ReturnType<typeof serviceStatus> }) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
if (status === 'none') return <span className="badge-gray text-xs">{vd.statusNone}</span>
if (status === 'overdue') return <span className="badge-red text-xs">{vd.statusOverdue}</span>
if (status === 'due-soon') return <span className="badge-amber text-xs">{vd.statusDueSoon}</span>
return <span className="badge-green text-xs">{vd.statusOk}</span>
}
function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, log, onLogged }: {
vehicleId: string
typeKey: string
intervalMonths: number
currentMileage: number | null
log: MaintenanceLog | undefined
onLogged: () => void
}) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const [open, setOpen] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const defaultNextDue = (() => {
const d = new Date()
d.setMonth(d.getMonth() + intervalMonths)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
description: '', cost: '', mileage: '', performedAt: today,
nextDueAt: defaultNextDue, nextDueMileage: '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.performedAt) { setError(vd.dateRequired); return }
setSaving(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: typeKey,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
nextDueMileage: form.nextDueMileage ? parseInt(form.nextDueMileage) : undefined,
}),
})
setForm({ description: '', cost: '', mileage: '', performedAt: today, nextDueAt: defaultNextDue, nextDueMileage: '' })
setOpen(false)
onLogged()
} catch (err: any) {
setError(err.message ?? vd.savingLabel)
} finally {
setSaving(false)
}
}
const status = serviceStatus(log, currentMileage)
const displayLabel = vd.routineTypes[typeKey] ?? typeKey
const nextDueText = (() => {
if (!log) return vd.noRecord
const parts: string[] = [`${vd.lastPrefix} ${new Date(log.performedAt).toLocaleDateString()}`]
if (log.mileage) parts.push(vd.atKm(log.mileage.toLocaleString()))
const dueParts: string[] = []
if (log.nextDueAt) dueParts.push(new Date(log.nextDueAt).toLocaleDateString())
if (log.nextDueMileage != null) dueParts.push(`${log.nextDueMileage.toLocaleString()} km`)
if (dueParts.length) parts.push(`${vd.duePrefix} ${dueParts.join(` ${vd.orSep} `)}`)
if (currentMileage != null && log.nextDueMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
parts.push(`(${kmLeft > 0 ? vd.kmLeft(kmLeft.toLocaleString()) : vd.overdueByKm})`)
}
return parts.join(' ')
})()
return (
<div className="rounded-xl border border-slate-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-white hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Wrench className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{displayLabel}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{nextDueText}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusPill status={status} />
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{vd.logBtn}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
{open && (
<form onSubmit={handleSubmit} className="border-t border-slate-100 bg-slate-50 px-4 py-4 space-y-3">
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.serviceDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.odometerAtService}</label>
<input type="number" className="input-field text-sm" placeholder={currentMileage ? String(currentMileage) : 'e.g. 52000'} min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueKmLabel}</label>
<input type="number" className="input-field text-sm" placeholder="e.g. 60000" min="0" value={form.nextDueMileage} onChange={(e) => setForm({ ...form, nextDueMileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.costLabel}</label>
<input type="number" className="input-field text-sm" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.maintenanceNotesLabel}</label>
<input className="input-field text-sm" placeholder={vd.optional} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)} className="btn-secondary text-xs py-1.5 px-3">{vd.cancelBtn}</button>
<button type="submit" disabled={saving} className="btn-primary text-xs py-1.5 px-3">{saving ? vd.savingLabel : vd.saveLabel}</button>
</div>
</form>
)}
</div>
)
}
type Tab = 'details' | 'maintenance' | 'calendar'
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const fl = dict.fleet
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<Tab>('details')
@@ -78,7 +278,6 @@ export default function FleetDetailPage() {
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
@@ -89,7 +288,16 @@ export default function FleetDetailPage() {
const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('')
// photo management
const [maintenanceLogs, setMaintenanceLogs] = useState<MaintenanceLog[]>([])
const fetchMaintenance = () => {
apiFetch<MaintenanceLog[]>(`/vehicles/${params.id}/maintenance`)
.then((logs) => setMaintenanceLogs(logs ?? []))
.catch(() => {})
}
useEffect(() => { fetchMaintenance() }, [params.id])
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
@@ -105,18 +313,13 @@ export default function FleetDetailPage() {
const startEdit = () => {
if (!vehicle) return
setForm({
make: vehicle.make,
model: vehicle.model,
year: vehicle.year,
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 ?? '',
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))
@@ -137,11 +340,11 @@ export default function FleetDetailPage() {
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 }
if (!form.make) { setSaveError(vd.makeRequired); return }
if (!form.model) { setSaveError(vd.modelRequired); return }
if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError('Please enter a valid daily rate.'); return }
if (isNaN(rate) || rate < 0) { setSaveError(vd.rateInvalid); return }
setSaving(true)
setSaveError(null)
@@ -149,17 +352,11 @@ export default function FleetDetailPage() {
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,
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,
}),
@@ -181,7 +378,7 @@ export default function FleetDetailPage() {
setEditing(false)
setForm(null)
} catch (err: any) {
setSaveError(err.message ?? 'Failed to save changes.')
setSaveError(err.message ?? vd.failedSave)
setUploadingPhotos(false)
} finally {
setSaving(false)
@@ -204,7 +401,7 @@ export default function FleetDetailPage() {
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const totalExisting = (vehicle?.photos.length ?? 0)
const totalExisting = vehicle?.photos.length ?? 0
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
@@ -217,12 +414,17 @@ export default function FleetDetailPage() {
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>
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">{vd.loadingVehicle}</div>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
const categoryLabel = fl.categoryLabels[vehicle.category as keyof typeof fl.categoryLabels] ?? vehicle.category
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
return (
<div className="space-y-6">
{/* Header */}
@@ -233,21 +435,21 @@ export default function FleetDetailPage() {
</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>
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {statusLabel}</p>
</div>
</div>
{!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> Edit
<Edit className="w-4 h-4" /> {vd.editBtn}
</button>
) : (
<div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> Cancel
<X className="w-4 h-4" /> {vd.cancelBtn}
</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'}
{saving ? (uploadingPhotos ? vd.uploadingPhotosBtn : vd.savingBtn) : vd.saveBtn}
</button>
</div>
)}
@@ -259,271 +461,276 @@ export default function FleetDetailPage() {
{/* 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>
{(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={[
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
].join(' ')}
>
{tab === 'details' && <><Info className="w-4 h-4" /> {vd.tabDetails}</>}
{tab === 'maintenance' && (
<>
<Wrench className="w-4 h-4" /> {vd.tabMaintenance}
{maintenanceLogs.length === 0 && <span className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-amber-100 text-amber-700 text-[10px] font-bold">!</span>}
</>
)}
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
</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>
<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 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 && (
{/* Photos */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.photosHeading}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<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>
))}
{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={() => 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"
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"
>
{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" />}
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">{vd.newPhotoBadge}</span>
</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" />
{vehicle.photos.length === 0 && !editing && (
<div className="text-sm text-slate-400">{vd.noPhotosYet}</div>
)}
</div>
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<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"
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"
>
<X className="w-3.5 h-3.5" />
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? vd.clickToAddPhotos : vd.addMorePhotos(totalPhotos)}
</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>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">{vd.maxPhotosReached}</p>
)}
</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">{vd.vehicleDetailsHeading}</h3>
{/* Details / Edit form */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
{!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">
{!editing || !form ? (
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelNotes}</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">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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.makeLabel}</label>
<select
className="input-field"
value={colorSelect}
value={makeSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
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 color</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option>
<option value="">{vd.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.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 })} />
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeMake} value={form.make} onChange={(e) => setForm({ ...form, make: 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">
{/* Model */}
<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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.modelLabel}</label>
{!presetModels ? (
<input className="input-field" placeholder={vd.typeModel} 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="">{vd.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeModel} 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">{vd.yearLabel}</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">{vd.fuelTypeLabel.replace('Type', '').trim() !== '' ? vd.fuelTypeLabel : fl.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'] as const).map((c) => (
<option key={c} value={c}>{fl.categoryLabels[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">{vd.dailyRateLabel}</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">{vd.licensePlateLabel}</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">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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.statusLabel}</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'] as const).map((s) => (
<option key={s} value={s}>{fl.statusLabels[s]}</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>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.colorLabel}</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="">{vd.selectColor}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{vd.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeColor} 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">{vd.seatsLabel}</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>
{/* 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…"
/>
{/* Transmission & Fuel */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.transmissionLabel}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{fl.manual}</option>
<option value="AUTOMATIC">{fl.automatic}</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((f) => (
<option key={f} value={f}>{fl.fuelTypeLabels[f]}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.odometerLabel}</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">{vd.notesLabel}</label>
<textarea className="input-field resize-none" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={vd.optionalNotes} />
</div>
</div>
</div>
)}
)}
</div>
</div>
)}
{activeTab === 'maintenance' && (
<div className="space-y-3">
<p className="text-sm text-slate-500">{vd.maintenanceIntro}</p>
{ROUTINE_TYPES.map(({ key, intervalMonths }) => {
const latest = maintenanceLogs
.filter((l) => l.type === key)
.sort((a, b) => new Date(b.performedAt).getTime() - new Date(a.performedAt).getTime())[0]
return (
<MaintenanceRow
key={key}
vehicleId={params.id}
typeKey={key}
intervalMonths={intervalMonths}
currentMileage={vehicle.mileage}
log={latest}
onLogged={fetchMaintenance}
/>
)
})}
</div>
)}
@@ -1,11 +1,12 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X } from 'lucide-react'
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Vehicle {
id: string
@@ -33,6 +34,193 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
OUT_OF_SERVICE: 'badge-red',
}
const ALL_STATUSES: Vehicle['status'][] = ['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']
interface MaintenanceModalProps {
vehicleId: string
vehicleName: string
open: boolean
onClose: () => void
onSaved: () => void
}
function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: MaintenanceModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const today = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const reset = () => {
setForm({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.type) { setError(f.maintenanceTypeRequired); return }
setLoading(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: form.type,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
}),
})
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToSave)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<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-md mx-4 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-1">{f.logMaintenance}</h2>
<p className="text-sm text-slate-500 mb-5">{f.maintenanceSubtitle(vehicleName)}</p>
{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">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.typeLabel}</label>
<input className="input-field" placeholder={f.typePlaceholder} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.description}</label>
<textarea className="input-field resize-none" rows={2} placeholder={f.descriptionPlaceholder} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.date}</label>
<input type="date" className="input-field" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.nextDueDate}</label>
<input type="date" className="input-field" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.cost}</label>
<input type="number" className="input-field" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input type="number" className="input-field" placeholder="e.g. 52000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.skip}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? f.saving : f.saveLog}</button>
</div>
</form>
</div>
</div>
)
}
function StatusDropdown({ vehicleId, status, onStatusChange }: {
vehicleId: string
status: Vehicle['status']
onStatusChange: (id: string, status: Vehicle['status']) => 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)
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
alert(err.message)
} 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 ${
s === 'AVAILABLE' ? 'bg-green-500' :
s === 'RENTED' ? 'bg-blue-500' :
s === 'MAINTENANCE' ? 'bg-amber-500' : 'bg-red-500'
}`} />
{f.statusLabels[s]}
</button>
))}
</div>
)}
</>
)
}
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[]> = {
@@ -61,6 +249,8 @@ const MODELS_BY_MAKE: Record<string, string[]> = {
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
@@ -80,7 +270,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined)
setPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
setPhotoPreviews(combined.map((file) => URL.createObjectURL(file)))
e.target.value = ''
}
@@ -107,10 +297,10 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
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 }
if (!form.make) { setError(f.makeMissing); return }
if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError(f.plateMissing); return }
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError(f.rateMissing); return }
setLoading(true)
setError(null)
try {
@@ -126,13 +316,13 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
})
if (photoFiles.length > 0) {
const fd = new FormData()
photoFiles.forEach((f) => fd.append('photos', f))
photoFiles.forEach((file) => fd.append('photos', file))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
}
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.')
setError(err.message ?? f.failedToAdd)
} finally {
setLoading(false)
}
@@ -145,7 +335,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
return (
<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>
<h2 className="text-lg font-semibold text-slate-900 mb-5">{f.addNewVehicle}</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>
)}
@@ -153,7 +343,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.make}</label>
<select
className="input-field"
value={makeSelect}
@@ -164,18 +354,18 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">Select make</option>
<option value="">{f.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
<option value="custom">{f.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 />
<input className="input-field mt-2" placeholder={f.typeMake} 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.model}</label>
{!presetModels ? (
<input className="input-field" placeholder="Model" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
<input className="input-field" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
@@ -187,12 +377,12 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
else setForm({ ...form, model: '' })
}}
>
<option value="">Select model</option>
<option value="">{f.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
<option value="custom">{f.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 />
<input className="input-field mt-2" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)}
</>
)}
@@ -202,14 +392,14 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.year}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.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>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
</div>
@@ -218,11 +408,11 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dailyRate}</label>
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
@@ -230,7 +420,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.color}</label>
<select
className="input-field"
value={colorSelect}
@@ -240,23 +430,23 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
else setForm({ ...form, color: '' })
}}
>
<option value="">Select</option>
<option value="">{f.select}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option>
<option value="custom">{f.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 />
<input className="input-field mt-2" placeholder={f.typeColor} 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.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>
<option value="MANUAL">{f.manual}</option>
<option value="AUTOMATIC">{f.automatic}</option>
</select>
</div>
</div>
@@ -264,15 +454,15 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* 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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.fuelType}</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>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((fuel) => (
<option key={fuel} value={fuel}>{f.fuelTypeLabels[fuel]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input
type="number"
className="input-field"
@@ -286,7 +476,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Photos (up to 10)</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
{photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => (
@@ -319,16 +509,16 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
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'}
{photoFiles.length === 0 ? f.clickToAddPhotos : f.addMorePhotos}
</button>
</>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">Cancel</button>
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.cancel}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? 'Adding…' : 'Add Vehicle'}
{loading ? f.adding : f.addVehicle}
</button>
</div>
</form>
@@ -338,6 +528,8 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
}
export default function FleetPage() {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -346,6 +538,7 @@ export default function FleetPage() {
const [statusFilter, setStatusFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [publishedFilter, setPublishedFilter] = useState('')
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
const fetchVehicles = () => {
setLoading(true)
@@ -366,6 +559,16 @@ export default function FleetPage() {
}
}
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = status !== 'MAINTENANCE' && status !== 'OUT_OF_SERVICE'
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
const vehicle = vehicles.find((v) => v.id === id)
if (vehicle) setMaintenanceModal({ vehicleId: id, vehicleName: `${vehicle.make} ${vehicle.model}` })
}
}
const filtered = vehicles.filter((v) => {
if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false
if (statusFilter && v.status !== statusFilter) return false
@@ -380,11 +583,11 @@ export default function FleetPage() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Fleet</h2>
<p className="text-sm text-slate-500 mt-0.5">{vehicles.length} vehicle{vehicles.length !== 1 ? 's' : ''} total</p>
<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">
<Plus className="w-4 h-4" /> Add Vehicle
<Plus className="w-4 h-4" /> {f.addVehicle}
</button>
</div>
@@ -394,27 +597,27 @@ export default function FleetPage() {
<Search className="w-4 h-4 text-slate-400" />
<input
className="flex-1 text-sm outline-none placeholder:text-slate-400"
placeholder="Search make, model, plate…"
placeholder={f.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<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>
<option value="">{f.allStatuses}</option>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{f.statusLabels[s]}</option>
))}
</select>
<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>
<option value="">{f.allCategories}</option>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
<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>
<option value="">{f.allVisibility}</option>
<option value="published">{f.published}</option>
<option value="unpublished">{f.unpublished}</option>
</select>
</div>
@@ -433,12 +636,12 @@ export default function FleetPage() {
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Category</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Daily Rate</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Published</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Actions</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colVehicle}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colCategory}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colStatus}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colDailyRate}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colPublished}</th>
<th className="text-end px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colActions}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@@ -450,7 +653,7 @@ export default function FleetPage() {
{vehicle.primaryPhoto ? (
<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>
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">{f.noPhoto}</div>
)}
</div>
<div>
@@ -460,32 +663,34 @@ export default function FleetPage() {
</div>
</td>
<td className="px-6 py-4">
<span className="badge-gray">{vehicle.category}</span>
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
</td>
<td className="px-6 py-4">
<span className={STATUS_BADGE[vehicle.status]}>{vehicle.status}</span>
<StatusDropdown vehicleId={vehicle.id} status={vehicle.status} onStatusChange={changeStatus} />
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}/day
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
</td>
<td className="px-6 py-4">
<button
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
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',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
>
<span
<div className="flex items-center">
<button
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
vehicle.isPublished ? 'translate-x-5' : 'translate-x-0',
'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',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
/>
</button>
>
<span
className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
vehicle.isPublished ? 'ltr:translate-x-5 rtl:-translate-x-5' : 'translate-x-0',
].join(' ')}
/>
</button>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2">
<div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
@@ -499,7 +704,7 @@ export default function FleetPage() {
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
{vehicles.length === 0 ? 'No vehicles yet. Add your first vehicle to get started.' : 'No vehicles match your filters.'}
{vehicles.length === 0 ? f.noVehicles : f.noMatch}
</td>
</tr>
)}
@@ -510,6 +715,15 @@ export default function FleetPage() {
</div>
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
{maintenanceModal && (
<MaintenanceModal
vehicleId={maintenanceModal.vehicleId}
vehicleName={maintenanceModal.vehicleName}
open={true}
onClose={() => setMaintenanceModal(null)}
onSaved={() => setMaintenanceModal(null)}
/>
)}
</div>
)
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type NotificationItem = {
id: string
@@ -30,12 +31,90 @@ const COMPANY_EVENTS = [
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
export default function DashboardNotificationsPage() {
const { language } = useDashboardI18n()
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Notifications',
subtitle: 'Track in-app alerts and control delivery preferences for your team account.',
inbox: 'Inbox',
preferences: 'Preferences',
markAllRead: 'Mark all as read',
loading: 'Loading notifications…',
empty: 'No notifications yet.',
read: 'Read',
markRead: 'Mark as read',
event: 'Event',
savePreferences: 'Save preferences',
saving: 'Saving…',
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance à prévoir',
OFFER_EXPIRING: 'Offer expiring',
} as Record<string, string>,
},
fr: {
title: 'Notifications',
subtitle: 'Suivez les alertes de lapplication et gérez les préférences de diffusion de votre équipe.',
inbox: 'Boîte de réception',
preferences: 'Préférences',
markAllRead: 'Tout marquer comme lu',
loading: 'Chargement des notifications…',
empty: 'Aucune notification pour le moment.',
read: 'Lu',
markRead: 'Marquer comme lu',
event: 'Événement',
savePreferences: 'Enregistrer les préférences',
saving: 'Enregistrement…',
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de lenregistrement des préférences',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans lapplication', PUSH: 'Push' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin dessai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
} as Record<string, string>,
},
ar: {
title: 'الإشعارات',
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.',
inbox: 'صندوق الوارد',
preferences: 'التفضيلات',
markAllRead: 'تحديد الكل كمقروء',
loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات حالياً.',
read: 'مقروء',
markRead: 'تحديد كمقروء',
event: 'الحدث',
savePreferences: 'حفظ التفضيلات',
saving: 'جارٍ الحفظ…',
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
} as Record<string, string>,
},
}[language]
async function load() {
setLoading(true)
@@ -51,7 +130,7 @@ export default function DashboardNotificationsPage() {
)
setPreferences(mapped)
} catch (err: any) {
setError(err.message ?? 'Failed to load notifications')
setError(err.message ?? copy.failedLoad)
} finally {
setLoading(false)
}
@@ -66,11 +145,13 @@ export default function DashboardNotificationsPage() {
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
)
window.dispatchEvent(new Event('notifications:updated'))
}
async function markAllRead() {
await apiFetch('/notifications/company/read-all', { method: 'POST' })
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
window.dispatchEvent(new Event('notifications:updated'))
}
async function savePreferences() {
@@ -86,7 +167,7 @@ export default function DashboardNotificationsPage() {
body: JSON.stringify(body),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save preferences')
setError(err.message ?? copy.failedSave)
} finally {
setSaving(false)
}
@@ -107,8 +188,8 @@ export default function DashboardNotificationsPage() {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-900">Notifications</h1>
<p className="mt-1 text-sm text-slate-500">Track in-app alerts and control delivery preferences for your team account.</p>
<h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<button
@@ -116,14 +197,14 @@ export default function DashboardNotificationsPage() {
onClick={() => setActiveTab('inbox')}
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
>
Inbox
{copy.inbox}
</button>
<button
type="button"
onClick={() => setActiveTab('preferences')}
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
>
Preferences
{copy.preferences}
</button>
</div>
</div>
@@ -134,26 +215,26 @@ export default function DashboardNotificationsPage() {
<div className="space-y-4">
<div className="flex justify-end">
<button type="button" onClick={markAllRead} className="btn-secondary">
Mark all as read
{copy.markAllRead}
</button>
</div>
{loading ? <div className="card p-6 text-sm text-slate-500">Loading notifications</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">No notifications yet.</div> : null}
{loading ? <div className="card p-6 text-sm text-slate-500">{copy.loading}</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">{copy.empty}</div> : null}
{!loading
? notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
{notification.type.replaceAll('_', ' ')}
{copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')}
</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Read</span>
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{copy.read}</span>
) : (
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
Mark as read
{copy.markRead}
</button>
)}
</div>
@@ -169,10 +250,10 @@ export default function DashboardNotificationsPage() {
<table className="min-w-full text-sm">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-600">Event</th>
<th className="px-4 py-3 text-left font-semibold text-slate-600">{copy.event}</th>
{COMPANY_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
{channel.replace('_', ' ')}
{copy.channels[channel] ?? channel.replace('_', ' ')}
</th>
))}
</tr>
@@ -180,7 +261,7 @@ export default function DashboardNotificationsPage() {
<tbody>
{COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-900">{eventName.replaceAll('_', ' ')}</td>
<td className="px-4 py-3 font-medium text-slate-900">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
{COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input
@@ -198,7 +279,7 @@ export default function DashboardNotificationsPage() {
</div>
<div className="flex justify-end border-t border-slate-100 p-4">
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save preferences'}
{saving ? copy.saving : copy.savePreferences}
</button>
</div>
</div>
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OfferRow {
id: string
@@ -18,8 +19,41 @@ interface OfferRow {
}
export default function OffersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<OfferRow[]>([])
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Offers',
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
ends: 'ends',
active: 'Active',
inactive: 'Inactive',
marketplace: 'Marketplace',
featured: 'Featured',
empty: 'No offers created yet.',
},
fr: {
title: 'Offres',
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
ends: 'expire le',
active: 'Actif',
inactive: 'Inactif',
marketplace: 'Marketplace',
featured: 'Mise en avant',
empty: 'Aucune offre créée pour le moment.',
},
ar: {
title: 'العروض',
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
ends: 'تنتهي في',
active: 'نشط',
inactive: 'غير نشط',
marketplace: 'السوق',
featured: 'مميز',
empty: 'لا توجد عروض حتى الآن.',
},
}[language]
useEffect(() => {
apiFetch<OfferRow[]>('/offers')
@@ -30,8 +64,8 @@ export default function OffersPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Offers</h2>
<p className="text-sm text-slate-500 mt-1">Promotions shown on your site and optionally on the marketplace.</p>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((offer) => (
@@ -39,13 +73,13 @@ export default function OffersPage() {
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-1">{offer.type} · ends {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
<p className="text-sm text-slate-500 mt-1">{offer.type} · {copy.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
</div>
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? 'Active' : 'Inactive'}</span>
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? copy.active : copy.inactive}</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">Marketplace</span>}
{offer.isFeatured && <span className="badge-purple">Featured</span>}
{offer.isPublic && <span className="badge-blue">{copy.marketplace}</span>}
{offer.isFeatured && <span className="badge-purple">{copy.featured}</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
</div>
<p className="mt-4 text-2xl font-bold text-slate-900">
@@ -54,7 +88,7 @@ export default function OffersPage() {
</div>
))}
{rows.length === 0 && !error && (
<div className="card p-8 text-sm text-slate-400">No offers created yet.</div>
<div className="card p-8 text-sm text-slate-400">{copy.empty}</div>
)}
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
@@ -6,6 +6,7 @@ import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OnlineReservation {
id: string
@@ -71,6 +72,51 @@ function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: st
}
export default function OnlineReservationsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Online Reservations',
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
refresh: 'Refresh',
pendingApproval: 'Pending approval',
loading: 'Loading…',
allCaughtUp: 'All caught up — no pending requests.',
history: 'History',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
status: 'Status',
total: 'Total',
},
fr: {
title: 'Réservations en ligne',
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
refresh: 'Actualiser',
pendingApproval: 'En attente de validation',
loading: 'Chargement…',
allCaughtUp: 'Tout est à jour, aucune demande en attente.',
history: 'Historique',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
status: 'Statut',
total: 'Total',
},
ar: {
title: 'الحجوزات الإلكترونية',
subtitle: 'طلبات مقدمة من العملاء عبر السوق. قم بالتأكيد أو الرفض لكل طلب.',
refresh: 'تحديث',
pendingApproval: 'بانتظار الموافقة',
loading: 'جارٍ التحميل…',
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
history: 'السجل',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
status: 'الحالة',
total: 'الإجمالي',
},
}[language]
const [rows, setRows] = useState<OnlineReservation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -144,15 +190,15 @@ export default function OnlineReservationsPage() {
<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>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</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>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</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
{copy.refresh}
</button>
</div>
@@ -163,16 +209,16 @@ export default function OnlineReservationsPage() {
{/* 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
<Clock className="h-4 w-4" /> {copy.pendingApproval}
{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>
<div className="card p-8 text-center text-sm text-slate-400">{copy.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>
<p className="text-sm font-medium text-slate-600">{copy.allCaughtUp}</p>
</div>
) : (
<div className="space-y-4">
@@ -193,17 +239,17 @@ export default function OnlineReservationsPage() {
{!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
{copy.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 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-5 py-3" />
</tr>
</thead>
@@ -5,7 +5,7 @@ import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard'
import { apiFetch } from '@/lib/api'
import dayjs from 'dayjs'
import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types'
interface DashboardData {
@@ -45,26 +45,44 @@ const STATUS_COLORS: Record<string, string> = {
CANCELLED: 'badge-red',
}
function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string; planName: string; trialEndsAt: string | null }) {
function SubscriptionBanner({
status,
trialEndsAt,
trialEndsMsg,
trialActiveMsg,
pastDueMsg,
suspendedMsg,
manageBillingLabel,
}: {
status: string
trialEndsAt: string | null
trialEndsMsg: (date: string) => string
trialActiveMsg: string
pastDueMsg: string
suspendedMsg: string
manageBillingLabel: string
}) {
if (status === 'ACTIVE') return null
const localeDate = trialEndsAt
? new Date(trialEndsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
TRIALING: {
bg: 'bg-blue-50 border-blue-200 text-blue-800',
icon: <Clock className="w-4 h-4" />,
message: trialEndsAt
? `You're on a free trial. Trial ends ${dayjs(trialEndsAt).format('MMM D, YYYY')}.`
: "You're on a free trial.",
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
},
PAST_DUE: {
bg: 'bg-amber-50 border-amber-200 text-amber-800',
icon: <AlertTriangle className="w-4 h-4" />,
message: 'Your payment is past due. Please update your billing information.',
message: pastDueMsg,
},
SUSPENDED: {
bg: 'bg-red-50 border-red-200 text-red-800',
icon: <XCircle className="w-4 h-4" />,
message: 'Your account has been suspended. Please contact support or renew your subscription.',
message: suspendedMsg,
},
}
@@ -76,13 +94,17 @@ function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string;
{config.icon}
<p className="text-sm font-medium">{config.message}</p>
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
Manage billing
{manageBillingLabel}
</a>
</div>
)
}
export default function DashboardPage() {
const { dict, language } = useDashboardI18n()
const d = dict.dashboardPage
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -105,7 +127,7 @@ export default function DashboardPage() {
if (error) {
return (
<div className="card p-6 text-center">
<p className="text-red-600 font-medium">Failed to load dashboard</p>
<p className="text-red-600 font-medium">{d.failedToLoad}</p>
<p className="text-slate-500 text-sm mt-1">{error}</p>
</div>
)
@@ -113,46 +135,59 @@ export default function DashboardPage() {
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return (
<div className="space-y-6">
{data?.subscription && (
<SubscriptionBanner
status={data.subscription.status}
planName={data.subscription.planName}
trialEndsAt={data.subscription.trialEndsAt}
trialEndsMsg={d.trialEnds}
trialActiveMsg={d.trialActive}
pastDueMsg={d.pastDueMsg}
suspendedMsg={d.suspendedMsg}
manageBillingLabel={d.manageBilling}
/>
)}
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Total Bookings"
title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()}
change={kpis.bookingsChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Calendar}
iconColor="text-blue-600"
iconBg="bg-blue-50"
/>
<StatCard
title="Active Vehicles"
title={d.kpiActiveVehicles}
value={kpis.activeVehicles.toLocaleString()}
change={kpis.vehiclesChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Car}
iconColor="text-green-600"
iconBg="bg-green-50"
/>
<StatCard
title="Monthly Revenue"
title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign}
iconColor="text-amber-600"
iconBg="bg-amber-50"
/>
<StatCard
title="Total Customers"
title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()}
change={kpis.customersChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Users}
iconColor="text-purple-600"
iconBg="bg-purple-50"
@@ -162,7 +197,7 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Booking Sources Chart */}
<div className="card p-6 lg:col-span-2">
<h2 className="text-base font-semibold text-slate-900 mb-4">Booking Sources</h2>
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data.sourceBreakdown}>
@@ -173,20 +208,20 @@ export default function DashboardPage() {
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="count" fill="#3b82f6" name="Bookings" radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#10b981" name="Revenue (centimes)" radius={[4, 4, 0, 0]} />
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
No booking data available yet
{d.noBookingData}
</div>
)}
</div>
{/* Quick stats */}
<div className="card p-6">
<h2 className="text-base font-semibold text-slate-900 mb-4">Quick Stats</h2>
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2>
<div className="space-y-4">
{(data?.sourceBreakdown ?? []).map((item) => (
<div key={item.source} className="flex items-center justify-between">
@@ -198,7 +233,7 @@ export default function DashboardPage() {
</div>
))}
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
<p className="text-sm text-slate-400">No data yet</p>
<p className="text-sm text-slate-400">{d.noData}</p>
)}
</div>
</div>
@@ -207,21 +242,21 @@ export default function DashboardPage() {
{/* Recent Reservations */}
<div className="card">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">Recent Reservations</h2>
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
<a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
View all
{d.viewAll}
</a>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Booking #</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@@ -235,11 +270,11 @@ export default function DashboardPage() {
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
<td className="px-6 py-3 text-sm text-slate-500">
{dayjs(res.startDate).format('MMM D')} {dayjs(res.endDate).format('MMM D, YYYY')}
{formatDate(res.startDate)} {formatDateYear(res.endDate)}
</td>
<td className="px-6 py-3">
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
{res.status}
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
</span>
</td>
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
@@ -250,7 +285,7 @@ export default function DashboardPage() {
{(!data?.recentReservations || data.recentReservations.length === 0) && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
No reservations yet. Once customers book vehicles, they'll appear here.
{d.noReservations}
</td>
</tr>
)}
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow {
reservationId: string
@@ -30,18 +31,74 @@ interface ReportData {
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
const periodLabels: Record<ReportPeriod, string> = {
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
QUARTERLY: 'Quarterly',
ANNUAL: 'Annual',
}
export default function ReportsPage() {
const { language } = useDashboardI18n()
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const copy = {
en: {
title: 'Reports',
subtitle: 'Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.',
exportCsv: 'Export CSV',
exporting: 'Exporting…',
bookings: 'Bookings',
rental: 'Rental',
insurance: 'Insurance',
drivers: 'Drivers',
discounts: 'Discounts',
collected: 'Collected',
reservation: 'Reservation',
vehicle: 'Vehicle',
period: 'Period',
source: 'Source',
payment: 'Payment',
total: 'Total',
emptyRows: 'No report rows available for this period.',
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
},
fr: {
title: 'Rapports',
subtitle: 'Exports prêts pour le comptable avec totaux assurance, conducteurs supplémentaires et règles tarifaires.',
exportCsv: 'Exporter CSV',
exporting: 'Export en cours…',
bookings: 'Réservations',
rental: 'Location',
insurance: 'Assurance',
drivers: 'Conducteurs',
discounts: 'Remises',
collected: 'Collecté',
reservation: 'Réservation',
vehicle: 'Véhicule',
period: 'Période',
source: 'Source',
payment: 'Paiement',
total: 'Total',
emptyRows: 'Aucune ligne de rapport pour cette période.',
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
},
ar: {
title: 'التقارير',
subtitle: 'تصدير جاهز للمحاسبة مع إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات',
rental: 'الإيجار',
insurance: 'التأمين',
drivers: 'السائقون',
discounts: 'الخصومات',
collected: 'المحصّل',
reservation: 'الحجز',
vehicle: 'المركبة',
period: 'الفترة',
source: 'المصدر',
payment: 'الدفع',
total: 'الإجمالي',
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
},
}[language]
useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`)
@@ -59,7 +116,7 @@ export default function ReportsPage() {
})
if (!res.ok) {
const json = await res.json().catch(() => null)
throw new Error(json?.message ?? 'Export failed')
throw new Error(json?.message ?? copy.exportCsv)
}
const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
@@ -70,7 +127,7 @@ export default function ReportsPage() {
anchor.click()
window.URL.revokeObjectURL(url)
} catch (err: any) {
setError(err.message ?? 'Export failed')
setError(err.message ?? copy.exportCsv)
} finally {
setExporting(false)
}
@@ -80,21 +137,21 @@ export default function ReportsPage() {
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reports</h2>
<p className="mt-1 text-sm text-slate-500">Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.</p>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(periodLabels) as ReportPeriod[]).map((option) => (
{(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
<button
key={option}
onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-700'}`}
>
{periodLabels[option]}
{copy.periodLabels[option]}
</button>
))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? 'Exporting…' : 'Export CSV'}
{exporting ? copy.exporting : copy.exportCsv}
</button>
</div>
</div>
@@ -103,12 +160,12 @@ export default function ReportsPage() {
{report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">Bookings</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Rental</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Insurance</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Drivers</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Discounts</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Collected</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div>
)}
@@ -117,12 +174,12 @@ export default function ReportsPage() {
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Reservation</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Period</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Source</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Payment</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.reservation}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.period}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@@ -138,7 +195,7 @@ export default function ReportsPage() {
))}
{(report?.rows.length ?? 0) === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No report rows available for this period.</td>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
</tr>
)}
</tbody>
@@ -2,9 +2,9 @@
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import dayjs from 'dayjs'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
interface ReservationDetail {
@@ -49,6 +49,10 @@ interface ReservationDetail {
export default function ReservationDetailPage() {
const params = useParams<{ id: string }>()
const { dict, language } = useDashboardI18n()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
const [inspections, setInspections] = useState<DamageInspection[]>([])
const [error, setError] = useState<string | null>(null)
@@ -65,7 +69,7 @@ export default function ReservationDetailPage() {
setInspections(inspectionData)
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load reservation')
setError(err.message ?? r.failedToLoad)
}
}
@@ -76,6 +80,7 @@ export default function ReservationDetailPage() {
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
setActing(true)
setActionError(null)
const errorMsg = action === 'confirm' ? r.failedConfirm : action === 'checkin' ? r.failedCheckIn : r.failedCheckOut
try {
await apiFetch(`/reservations/${params.id}/${action}`, {
method: 'POST',
@@ -83,7 +88,7 @@ export default function ReservationDetailPage() {
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? `Failed to ${action} reservation`)
setActionError(err.message ?? errorMsg)
} finally {
setActing(false)
}
@@ -99,17 +104,20 @@ export default function ReservationDetailPage() {
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? 'Failed to approve additional driver')
setActionError(err.message ?? r.failedApproveDriver)
} finally {
setActing(false)
}
}
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!reservation) return <div className="card p-6 text-sm text-slate-500">Loading reservation</div>
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!reservation) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
const checkinInspection = inspections.find((i) => i.type === 'CHECKIN')
const checkoutInspection = inspections.find((i) => i.type === 'CHECKOUT')
return (
<div className="space-y-6">
@@ -121,17 +129,17 @@ export default function ReservationDetailPage() {
<div className="flex flex-wrap gap-3">
{reservation.status === 'DRAFT' && (
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
{acting ? 'Working…' : 'Confirm reservation'}
{acting ? r.working : r.confirmReservation}
</button>
)}
{reservation.status === 'CONFIRMED' && (
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
{acting ? 'Working…' : 'Check in vehicle'}
{acting ? r.working : r.checkInVehicle}
</button>
)}
{reservation.status === 'ACTIVE' && (
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
{acting ? 'Working…' : 'Check out vehicle'}
{acting ? r.working : r.checkOutVehicle}
</button>
)}
</div>
@@ -141,25 +149,25 @@ export default function ReservationDetailPage() {
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Customer</h3>
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
<div className="space-y-2 text-sm">
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
<p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? 'No phone provided'}</p>
<p className="text-slate-600">License: {reservation.customer.driverLicense ?? 'Not captured'}</p>
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
<div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">Flagged</span>}
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
</div>
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Vehicle</h3>
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionVehicle}</h3>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
<p>{reservation.vehicle.licensePlate}</p>
<p>{dayjs(reservation.startDate).format('MMM D, YYYY')} - {dayjs(reservation.endDate).format('MMM D, YYYY')}</p>
<p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
</div>
</div>
</div>
@@ -167,18 +175,18 @@ export default function ReservationDetailPage() {
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Charges</h3>
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-slate-500">Discount</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Insurance</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Additional drivers</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Pricing adjustments</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Grand total</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
</dl>
{reservation.insurances.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">Applied insurance</p>
<p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
<div className="space-y-2">
{reservation.insurances.map((insurance) => (
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
@@ -192,7 +200,7 @@ export default function ReservationDetailPage() {
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">Pricing rules applied</p>
<p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
<div className="space-y-2">
{reservation.pricingRulesApplied.map((rule) => (
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
@@ -223,23 +231,23 @@ export default function ReservationDetailPage() {
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Additional drivers</h3>
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
<div className="space-y-3">
{reservation.additionalDrivers.map((driver) => (
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
<p className="text-sm text-slate-500">License: {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">Charge: {formatCurrency(driver.totalCharge, 'MAD')}</p>
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
</div>
{driver.requiresApproval && !driver.approvedAt ? (
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white">
Approve
{r.approveBtn}
</button>
) : (
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
{driver.approvedAt ? 'Approved' : 'No approval needed'}
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
</span>
)}
</div>
@@ -247,21 +255,21 @@ export default function ReservationDetailPage() {
</div>
))}
{reservation.additionalDrivers.length === 0 && (
<div className="text-sm text-slate-400">No additional drivers were added to this reservation.</div>
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
)}
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Inspection summary</h3>
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">Check-in inspection</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? 'Saved' : 'Pending'}</span>
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">Check-out inspection</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? 'Saved' : 'Pending'}</span>
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
</div>
</div>
@@ -1,10 +1,10 @@
'use client'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReservationRow {
id: string
@@ -19,6 +19,10 @@ interface ReservationRow {
}
export default function ReservationsPage() {
const { dict, language } = useDashboardI18n()
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)
@@ -28,11 +32,16 @@ export default function ReservationsPage() {
.catch((err) => setError(err.message))
}, [])
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reservations</h2>
<p className="text-sm text-slate-500 mt-1">All booking sources, including dashboard, public site, and marketplace.</p>
<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>
<div className="card overflow-hidden">
{error ? (
@@ -42,12 +51,12 @@ export default function ReservationsPage() {
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Source</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
@@ -60,7 +69,7 @@ export default function ReservationsPage() {
<p className="text-xs text-slate-500">{row.customer.email}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
<td className="px-6 py-4 text-sm text-slate-500">{dayjs(row.startDate).format('MMM D')} - {dayjs(row.endDate).format('MMM D, YYYY')}</td>
<td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
@@ -68,7 +77,7 @@ export default function ReservationsPage() {
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No reservations yet.</td>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
</tr>
)}
</tbody>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface BrandSettings {
displayName: string
@@ -70,6 +71,231 @@ const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeV
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
export default function SettingsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Advanced settings',
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
save: 'Saving…',
saveBrand: 'Save brand',
brandProfile: 'Brand and public profile',
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
payments: 'Rental payment methods',
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
savePayments: 'Save payments',
customDomain: 'Custom domain',
customDomainDesc: 'Point your own domain to the branded booking site.',
saveDomain: 'Save domain',
removeDomain: 'Remove custom domain',
contractPolicies: 'Contract and driver policies',
savePolicies: 'Save policies',
insurancePolicies: 'Insurance policies',
pricingRules: 'Pricing rules',
accounting: 'Accounting and exports',
saveAccounting: 'Save accounting',
displayName: 'Display name',
tagline: 'Tagline',
publicEmail: 'Public email',
publicPhone: 'Public phone',
city: 'City',
country: 'Country',
websiteUrl: 'Website URL',
whatsapp: 'WhatsApp number',
brandColor: 'Brand color',
listedMarketplace: 'Listed on marketplace',
logoUpload: 'Logo upload',
heroUpload: 'Hero image upload',
uploading: 'Uploading…',
logoUploaded: 'Logo uploaded',
noLogo: 'No logo uploaded yet',
heroUploaded: 'Hero image uploaded',
noHero: 'No hero image uploaded yet',
subdomain: 'Subdomain',
customDomainLabel: 'Custom domain',
domainPlaceholder: 'cars.example.com',
status: 'Status',
verified: 'Verified',
pendingDns: 'Pending DNS verification',
notConfigured: 'Not configured',
fuelPolicyType: 'Fuel policy type',
additionalDriverCharge: 'Additional driver charge',
dailyDriverRate: 'Daily driver rate',
flatDriverRate: 'Flat driver rate',
fuelPolicyNote: 'Fuel policy note',
damagePolicy: 'Damage policy',
active: 'Active',
inactive: 'Inactive',
noInsurance: 'No insurance policies configured yet.',
policyName: 'Policy name',
chargeValue: 'Charge value',
required: 'Required',
addPolicy: 'Add policy',
noRules: 'No pricing rules configured yet.',
ruleName: 'Rule name',
addRule: 'Add rule',
reportingPeriod: 'Reporting period',
reportFormat: 'Report format',
accountantName: 'Accountant name',
accountantEmail: 'Accountant email',
autoSend: 'Auto-send reports to the accountant',
amanpayMerchantId: 'AmanPay merchant ID',
amanpaySecretKey: 'AmanPay secret key',
paypalEmail: 'PayPal business email',
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
},
fr: {
title: 'Paramètres avancés',
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
save: 'Enregistrement…',
saveBrand: 'Enregistrer la marque',
brandProfile: 'Marque et profil public',
brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la marketplace et le site de réservation.',
payments: 'Méthodes de paiement location',
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
savePayments: 'Enregistrer les paiements',
customDomain: 'Domaine personnalisé',
customDomainDesc: 'Pointez votre propre domaine vers le site de réservation.',
saveDomain: 'Enregistrer le domaine',
removeDomain: 'Supprimer le domaine',
contractPolicies: 'Contrat et politiques conducteur',
savePolicies: 'Enregistrer les politiques',
insurancePolicies: 'Polices dassurance',
pricingRules: 'Règles tarifaires',
accounting: 'Comptabilité et exports',
saveAccounting: 'Enregistrer la comptabilité',
displayName: 'Nom affiché',
tagline: 'Slogan',
publicEmail: 'Email public',
publicPhone: 'Téléphone public',
city: 'Ville',
country: 'Pays',
websiteUrl: 'URL du site',
whatsapp: 'Numéro WhatsApp',
brandColor: 'Couleur de marque',
listedMarketplace: 'Listé sur la marketplace',
logoUpload: 'Logo',
heroUpload: 'Image héro',
uploading: 'Téléversement…',
logoUploaded: 'Logo téléversé',
noLogo: 'Aucun logo téléversé',
heroUploaded: 'Image héro téléversée',
noHero: 'Aucune image héro téléversée',
subdomain: 'Sous-domaine',
customDomainLabel: 'Domaine personnalisé',
domainPlaceholder: 'voitures.exemple.com',
status: 'Statut',
verified: 'Vérifié',
pendingDns: 'Vérification DNS en attente',
notConfigured: 'Non configuré',
fuelPolicyType: 'Type de politique carburant',
additionalDriverCharge: 'Supplément conducteur additionnel',
dailyDriverRate: 'Tarif conducteur/jour',
flatDriverRate: 'Tarif conducteur fixe',
fuelPolicyNote: 'Note politique carburant',
damagePolicy: 'Politique dommages',
active: 'Actif',
inactive: 'Inactif',
noInsurance: 'Aucune police dassurance configurée.',
policyName: 'Nom de la police',
chargeValue: 'Valeur de facturation',
required: 'Obligatoire',
addPolicy: 'Ajouter la police',
noRules: 'Aucune règle tarifaire configurée.',
ruleName: 'Nom de la règle',
addRule: 'Ajouter la règle',
reportingPeriod: 'Période de reporting',
reportFormat: 'Format de rapport',
accountantName: 'Nom du comptable',
accountantEmail: 'Email du comptable',
autoSend: 'Envoyer automatiquement les rapports au comptable',
amanpayMerchantId: 'ID marchand AmanPay',
amanpaySecretKey: 'Clé secrète AmanPay',
paypalEmail: 'Email business PayPal',
noPaymentConfigured: 'Si aucun moyen de paiement nest configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
},
ar: {
title: 'الإعدادات المتقدمة',
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
save: 'جارٍ الحفظ…',
saveBrand: 'حفظ العلامة',
brandProfile: 'العلامة والملف العام',
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
payments: 'طرق دفع الإيجار',
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
savePayments: 'حفظ الدفع',
customDomain: 'نطاق مخصص',
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
saveDomain: 'حفظ النطاق',
removeDomain: 'إزالة النطاق المخصص',
contractPolicies: 'العقد وسياسات السائق',
savePolicies: 'حفظ السياسات',
insurancePolicies: 'سياسات التأمين',
pricingRules: 'قواعد التسعير',
accounting: 'المحاسبة والتصدير',
saveAccounting: 'حفظ المحاسبة',
displayName: 'اسم العرض',
tagline: 'الشعار',
publicEmail: 'البريد العام',
publicPhone: 'الهاتف العام',
city: 'المدينة',
country: 'الدولة',
websiteUrl: 'رابط الموقع',
whatsapp: 'رقم واتساب',
brandColor: 'لون العلامة',
listedMarketplace: 'مدرج في السوق',
logoUpload: 'رفع الشعار',
heroUpload: 'رفع صورة الواجهة',
uploading: 'جارٍ الرفع…',
logoUploaded: 'تم رفع الشعار',
noLogo: 'لم يتم رفع شعار بعد',
heroUploaded: 'تم رفع صورة الواجهة',
noHero: 'لم يتم رفع صورة واجهة بعد',
subdomain: 'النطاق الفرعي',
customDomainLabel: 'النطاق المخصص',
domainPlaceholder: 'cars.example.com',
status: 'الحالة',
verified: 'موثق',
pendingDns: 'بانتظار التحقق من DNS',
notConfigured: 'غير مُعدّ',
fuelPolicyType: 'نوع سياسة الوقود',
additionalDriverCharge: 'رسوم السائق الإضافي',
dailyDriverRate: 'تعرفة السائق اليومية',
flatDriverRate: 'تعرفة السائق الثابتة',
fuelPolicyNote: 'ملاحظة سياسة الوقود',
damagePolicy: 'سياسة الأضرار',
active: 'نشط',
inactive: 'غير نشط',
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
policyName: 'اسم السياسة',
chargeValue: 'قيمة الرسوم',
required: 'إلزامي',
addPolicy: 'إضافة سياسة',
noRules: 'لا توجد قواعد تسعير مهيأة.',
ruleName: 'اسم القاعدة',
addRule: 'إضافة قاعدة',
reportingPeriod: 'فترة التقرير',
reportFormat: 'تنسيق التقرير',
accountantName: 'اسم المحاسب',
accountantEmail: 'بريد المحاسب',
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
amanpayMerchantId: 'معرّف التاجر AmanPay',
amanpaySecretKey: 'المفتاح السري AmanPay',
paypalEmail: 'بريد أعمال PayPal',
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
},
}[language]
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
@@ -299,8 +525,8 @@ export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">Advanced settings</h2>
<p className="mt-1 text-sm text-slate-500">Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.</p>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
@@ -310,67 +536,67 @@ export default function SettingsPage() {
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">Brand and public profile</h3>
<p className="mt-1 text-sm text-slate-500">Control how your company appears on the marketplace and public booking site.</p>
<h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save brand'}
{saving ? copy.save : copy.saveBrand}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Display name</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Tagline</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public email</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public phone</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">City</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Country</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Website URL</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">WhatsApp number</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Brand color</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
Listed on marketplace
{copy.listedMarketplace}
</label>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Logo upload</span>
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? 'Uploading…' : brand.logoUrl ? 'Logo uploaded' : 'No logo uploaded yet'}</p>
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Hero image upload</span>
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? 'Uploading…' : brand.heroImageUrl ? 'Hero image uploaded' : 'No hero image uploaded yet'}</p>
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
</label>
</div>
</div>
@@ -379,28 +605,28 @@ export default function SettingsPage() {
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">Rental payment methods</h3>
<p className="mt-1 text-sm text-slate-500">Configure how renters pay your company on the public booking site.</p>
<h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save payments'}
{saving ? copy.save : copy.savePayments}
</button>
</div>
<div className="mt-5 grid gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay merchant ID</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay secret key</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">PayPal business email</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
If no payment method is configured, renters can still submit reservation requests and pay on pickup.
{copy.noPaymentConfigured}
</div>
</div>
</div>
@@ -408,28 +634,28 @@ export default function SettingsPage() {
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">Custom domain</h3>
<p className="mt-1 text-sm text-slate-500">Point your own domain to the branded booking site.</p>
<h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
</div>
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
{saving ? 'Saving…' : 'Save domain'}
{saving ? copy.save : copy.saveDomain}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Subdomain</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Custom domain</label>
<input className="input-field" placeholder="cars.example.com" value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
<input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
Status: {brand.customDomain ? (brand.customDomainVerified ? 'Verified' : 'Pending DNS verification') : 'Not configured'}
{copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
</div>
{brand.customDomain ? (
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
Remove custom domain
{copy.removeDomain}
</button>
) : null}
</div>
@@ -441,38 +667,38 @@ export default function SettingsPage() {
{contractSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">Contract and driver policies</h3>
<h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save policies'}
{saving ? copy.save : copy.savePolicies}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy type</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{option}</option>)}
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Additional driver charge</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{option}</option>)}
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Daily driver rate</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Flat driver rate</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy note</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">Damage policy</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
</div>
</div>
@@ -481,7 +707,7 @@ export default function SettingsPage() {
<div className="grid gap-6 xl:grid-cols-2">
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">Insurance policies</h3>
<h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
<div className="mt-5 space-y-3">
{insurancePolicies.map((policy) => (
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
@@ -490,31 +716,31 @@ export default function SettingsPage() {
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
</div>
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
{policy.isActive ? 'Active' : 'Inactive'}
{policy.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">No insurance policies configured yet.</div>}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder="Policy name" value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" placeholder="Charge value" value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
<input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
</div>
<div className="mt-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> Required</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add policy'}</button>
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">Pricing rules</h3>
<h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
<div className="mt-5 space-y-3">
{pricingRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
@@ -523,15 +749,15 @@ export default function SettingsPage() {
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
</div>
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
{rule.isActive ? 'Active' : 'Inactive'}
{rule.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">No pricing rules configured yet.</div>}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder="Rule name" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
@@ -545,7 +771,7 @@ export default function SettingsPage() {
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
</div>
<div className="mt-3">
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add rule'}</button>
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
</div>
</div>
</div>
@@ -553,36 +779,36 @@ export default function SettingsPage() {
{accountingSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">Accounting and exports</h3>
<h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save accounting'}
{saving ? copy.save : copy.saveAccounting}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Reporting period</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Report format</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{option}</option>)}
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant name</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant email</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
</div>
</div>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
Auto-send reports to the accountant
{copy.autoSend}
</label>
</div>
)}
@@ -6,16 +6,17 @@ import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null) {
if (!dateStr) return 'Never'
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) return 'Just now'
if (mins < 2) return language === 'fr' ? 'À linstant' : language === 'ar' ? 'الآن' : 'Just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
@@ -50,29 +51,119 @@ function RoleBadge({ role }: { role: string }) {
)
}
function StatusBadge({ member }: { member: TeamMember }) {
function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
Pending invite
{language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
Deactivated
{language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
Active
{language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
</span>
)
}
export default function TeamPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Team',
subtitle: 'Manage your employees and their access levels',
inviteMember: 'Invite member',
totalMembers: 'Total members',
active: 'Active',
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
search: 'Search by name or email…',
allRoles: 'All roles',
owner: 'Owner',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'All statuses',
pending: 'Pending',
loadingTeam: 'Loading team…',
noMembers: 'No members match your filters',
member: 'Member',
role: 'Role',
status: 'Status',
lastActive: 'Last active',
manage: 'Manage',
inviteSent: 'Invite sent to',
roleUpdated: 'Role updated',
memberDeactivated: 'Member deactivated',
memberReactivated: 'Member reactivated',
memberRemoved: 'Member removed',
you: '(you)',
},
fr: {
title: 'Équipe',
subtitle: 'Gérez vos employés et leurs niveaux daccès',
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
search: 'Rechercher par nom ou email…',
allRoles: 'Tous les rôles',
owner: 'Propriétaire',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: 'Chargement de l’équipe…',
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
status: 'Statut',
lastActive: 'Dernière activité',
manage: 'Gérer',
inviteSent: 'Invitation envoyée à',
roleUpdated: 'Rôle mis à jour',
memberDeactivated: 'Membre désactivé',
memberReactivated: 'Membre réactivé',
memberRemoved: 'Membre supprimé',
you: '(vous)',
},
ar: {
title: 'الفريق',
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
inviteMember: 'دعوة عضو',
totalMembers: 'إجمالي الأعضاء',
active: 'نشط',
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
search: 'ابحث بالاسم أو البريد الإلكتروني…',
allRoles: 'كل الأدوار',
owner: 'مالك',
manager: 'مدير',
agent: 'وكيل',
allStatuses: 'كل الحالات',
pending: 'قيد الانتظار',
loadingTeam: 'جارٍ تحميل الفريق…',
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
member: 'العضو',
role: 'الدور',
status: 'الحالة',
lastActive: 'آخر نشاط',
manage: 'إدارة',
inviteSent: 'تم إرسال الدعوة إلى',
roleUpdated: 'تم تحديث الدور',
memberDeactivated: 'تم تعطيل العضو',
memberReactivated: 'تمت إعادة تفعيل العضو',
memberRemoved: 'تم حذف العضو',
you: '(أنت)',
},
}[language]
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
@@ -124,36 +215,36 @@ export default function TeamPage() {
const handleInvite = async (payload: InvitePayload) => {
await invite(payload)
showToast(`Invite sent to ${payload.email}`)
showToast(`${copy.inviteSent} ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast('Role updated')
showToast(copy.roleUpdated)
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast('Member deactivated')
showToast(copy.memberDeactivated)
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast('Member reactivated')
showToast(copy.memberReactivated)
}
const handleRemove = async (id: string) => {
await remove(id)
showToast('Member removed')
showToast(copy.memberRemoved)
}
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Manage your employees and their access levels
{copy.subtitle}
</p>
</div>
{isOwner && (
@@ -164,17 +255,17 @@ export default function TeamPage() {
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Invite member
{copy.inviteMember}
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: 'Total members', value: stats.total },
{ label: 'Active', value: stats.active },
{ label: 'Pending invite', value: stats.pending },
{ label: 'Deactivated', value: stats.inactive },
{ label: copy.totalMembers, value: stats.total },
{ label: copy.active, value: stats.active },
{ label: copy.pendingInvite, value: stats.pending },
{ label: copy.deactivated, value: stats.inactive },
].map((s) => (
<div
key={s.label}
@@ -194,7 +285,7 @@ export default function TeamPage() {
</svg>
<input
type="text"
placeholder="Search by name or email…"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
@@ -205,20 +296,20 @@ export default function TeamPage() {
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All roles</option>
<option value="OWNER">Owner</option>
<option value="MANAGER">Manager</option>
<option value="AGENT">Agent</option>
<option value="">{copy.allRoles}</option>
<option value="OWNER">{copy.owner}</option>
<option value="MANAGER">{copy.manager}</option>
<option value="AGENT">{copy.agent}</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="inactive">Deactivated</option>
<option value="">{copy.allStatuses}</option>
<option value="active">{copy.active}</option>
<option value="pending">{copy.pending}</option>
<option value="inactive">{copy.deactivated}</option>
</select>
</div>
@@ -226,22 +317,22 @@ export default function TeamPage() {
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">Loading team</span>
<span className="text-sm">{copy.loadingTeam}</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
<th className="px-4 py-3" />
</tr>
</thead>
@@ -263,7 +354,7 @@ export default function TeamPage() {
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
<span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
@@ -276,12 +367,12 @@ export default function TeamPage() {
</td>
<td className="px-4 py-3">
<StatusBadge member={member} />
<StatusBadge member={member} language={language} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
</span>
</td>
@@ -291,7 +382,7 @@ export default function TeamPage() {
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Manage
{copy.manage}
</button>
)}
</td>
@@ -6,7 +6,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return (
<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">
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64">
<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 dark:border-slate-800 dark:bg-slate-950/90">
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
@@ -43,6 +44,8 @@ function daysBetween(start: Date, end: Date): number {
}
export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
const today = new Date()
const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1)
@@ -71,11 +74,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)
setEvents(data)
} catch (e: any) {
setError(e.message ?? 'Failed to load calendar')
setError(e.message ?? c.failedLoad)
} finally {
setLoading(false)
}
}, [vehicleId, year, month])
}, [vehicleId, year, month, c])
useEffect(() => { fetchCalendar() }, [fetchCalendar])
@@ -142,8 +145,8 @@ export default function VehicleCalendar({ vehicleId }: Props) {
}
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 }
if (!blockStart || !blockEnd) { setSaveError(c.datesRequired); return }
if (blockEnd <= blockStart) { setSaveError(c.endAfterStart); return }
setSaving(true)
setSaveError(null)
try {
@@ -154,23 +157,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
setShowModal(false)
fetchCalendar()
} catch (e: any) {
setSaveError(e.message ?? 'Failed to create block')
setSaveError(e.message ?? c.failedCreateBlock)
} finally {
setSaving(false)
}
}
const handleDeleteBlock = async (id: string) => {
if (!confirm('Remove this block?')) return
if (!confirm(c.removeBlockConfirm)) return
try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
fetchCalendar()
} catch (e: any) {
alert(e.message ?? 'Failed to delete block')
alert(e.message ?? c.failedDeleteBlock)
}
}
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString(localeCode, { month: 'long', year: 'numeric' })
// Separate reservations vs blocks for the legend/list below
const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
@@ -182,7 +186,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<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>
<h3 className="text-base font-semibold text-slate-900">{c.heading}</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">
@@ -193,24 +197,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<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
<Plus className="w-3.5 h-3.5" /> {c.blockDates}
</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>
{c.clickSecondDay(selectStart!)}
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>{c.cancelSelection}</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>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />{c.legendReserved}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />{c.legendMaintenance}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />{c.legendBlocked}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />{c.legendAvailable}</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>}
@@ -219,7 +223,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<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) => (
{c.dayHeaders.map((d) => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))}
</div>
@@ -261,7 +265,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)
})}
{dayEvents.length > 2 && (
<div className="text-[10px] text-slate-400 pl-1">+{dayEvents.length - 2} more</div>
<div className="text-[10px] text-slate-400 pl-1">{c.moreDays(dayEvents.length - 2)}</div>
)}
</div>
</div>
@@ -275,7 +279,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<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>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.reservationsThisMonth}</p>
<div className="space-y-1.5">
{reservationEvents.map((e) => {
const s = EVENT_STYLES[e.type]
@@ -299,7 +303,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
{blockEvents.length > 0 && (
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Blocks &amp; maintenance</p>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.blocksAndMaintenance}</p>
<div className="space-y-1.5">
{blockEvents.map((e) => {
const s = EVENT_STYLES[e.type]
@@ -317,7 +321,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<button
onClick={() => handleDeleteBlock(e.id)}
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
title="Remove block"
title={c.removeBlock}
>
<X className="w-3.5 h-3.5" />
</button>
@@ -335,7 +339,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<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>
<h4 className="text-base font-semibold text-slate-900">{c.modalTitle}</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>
@@ -343,26 +347,26 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="space-y-3 text-sm">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Block type</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.blockTypeLabel}</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
<Ban className="w-3.5 h-3.5" /> {c.manualBlock}
</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
<Wrench className="w-3.5 h-3.5" /> {c.maintenanceBlock}
</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>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.startDate}</label>
<input
type="date"
className="input-field"
@@ -371,7 +375,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">End date (exclusive)</label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.endDate}</label>
<input
type="date"
className="input-field"
@@ -383,11 +387,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Reason <span className="font-normal">(optional)</span></label>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.reasonLabel} <span className="font-normal">{c.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…'}
placeholder={blockType === 'MAINTENANCE' ? c.reasonPlaceholderMaintenance : c.reasonPlaceholderManual}
value={blockReason}
onChange={(e) => setBlockReason(e.target.value)}
/>
@@ -399,9 +403,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)}
<div className="flex gap-2 pt-1">
<button onClick={closeModal} className="flex-1 btn-secondary">Cancel</button>
<button onClick={closeModal} className="flex-1 btn-secondary">{c.cancel}</button>
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
{saving ? 'Saving…' : 'Save block'}
{saving ? c.saving : c.saveBlock}
</button>
</div>
</div>
+174 -45
View File
@@ -2,6 +2,7 @@
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import {
LayoutDashboard,
Car,
@@ -15,10 +16,30 @@ import {
Bell,
Settings,
LogOut,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
interface BrandSettings {
displayName: string
logoUrl: string | null
}
interface SidebarUser {
displayName: string
subtitle: string
initials: string
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
@@ -35,9 +56,67 @@ const NAV_ITEMS = [
] as const
export default function Sidebar() {
const { dict } = useDashboardI18n()
const { dict, language } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const router = useRouter()
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
const [open, setOpen] = useState(false)
useEffect(() => {
apiFetch<BrandSettings>('/companies/me/brand').then(setBrand).catch(() => {})
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
return
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
role: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
const role = employee.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : dict.workspaceUser)
const subtitle = email || role || dict.localAuth
const initials = computeInitials(fullName || email || dict.workspaceUser)
setUser({ displayName, subtitle, initials })
})
.catch(() => {
if (cancelled) return
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
})
return () => { cancelled = true }
}, [dict.localAuth, dict.workspaceUser])
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return pathname === item.href
@@ -51,52 +130,102 @@ export default function Sidebar() {
}
return (
<aside className="fixed inset-y-0 left-0 z-40 flex w-64 flex-col bg-slate-900">
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500">
<Car className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-bold tracking-wide text-white">RentalDriveGo</span>
</Link>
<>
{/* Mobile backdrop */}
{open && (
<div
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
onClick={() => setOpen(false)}
/>
)}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{dict.workspaceUser}</p>
<p className="truncate text-xs text-slate-400">{dict.localAuth}</p>
</div>
</div>
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{!open && (
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
onClick={() => setOpen(true)}
aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
>
<LogOut className="h-4 w-4" />
{dict.signOut}
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</button>
</div>
</aside>
)}
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col bg-slate-900 transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : (
<Car className="h-4 w-4 text-white" />
)}
</div>
<span className="truncate text-sm font-bold tracking-wide text-white">
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</Link>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
{user.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
</>
)
}
+147 -10
View File
@@ -1,25 +1,127 @@
'use client'
import { Bell } from 'lucide-react'
import { usePathname } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { apiFetch } from '@/lib/api'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
export default function TopBar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
const [userInitials, setUserInitials] = useState('W')
const [notifications, setNotifications] = useState<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {}
}
useEffect(() => {
apiFetch<{ unread: number }>('/notifications/unread-count')
.then((data) => setUnreadCount(data.unread))
.catch(() => {})
refreshUnreadCount()
}, [pathname])
useEffect(() => {
function onNotificationsUpdated() {
refreshUnreadCount()
}
window.addEventListener('notifications:updated', onNotificationsUpdated)
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
}, [])
useEffect(() => {
if (!showNotifs) return
let cancelled = false
setLoadingNotifs(true)
apiFetch<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>('/notifications/company')
.then((data) => {
if (cancelled) return
setNotifications(data.slice(0, 8))
})
.catch(() => {
if (cancelled) return
setNotifications([])
})
.finally(() => {
if (cancelled) return
setLoadingNotifs(false)
})
return () => { cancelled = true }
}, [showNotifs])
useEffect(() => {
setShowNotifs(false)
}, [pathname])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUserInitials(computeInitials(dict.workspaceUser))
return
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
})
.catch(() => {
if (cancelled) return
setUserInitials(computeInitials(dict.workspaceUser))
})
return () => { cancelled = true }
}, [dict.workspaceUser])
async function openNotificationsInbox(notificationId?: string) {
if (notificationId) {
try {
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
setUnreadCount((current) => Math.max(current - 1, 0))
window.dispatchEvent(new Event('notifications:updated'))
} catch {}
}
setShowNotifs(false)
router.push('/dashboard/notifications')
}
return (
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 transition-colors dark:border-slate-800 dark:bg-slate-950">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
@@ -40,16 +142,51 @@ export default function TopBar() {
{showNotifs ? (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-slate-200 bg-white p-4 shadow-lg dark:border-slate-700 dark:bg-slate-900">
<p className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
<button
type="button"
onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
View all
</button>
</div>
{loadingNotifs ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
) : (
<div className="space-y-1">
{notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => openNotificationsInbox(notification.id)}
className={[
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-100 dark:hover:bg-slate-800',
notification.readAt ? 'opacity-80' : '',
].join(' ')}
>
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{notification.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body}
</p>
</button>
))}
</div>
)}
</div>
) : null}
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
{userInitials}
</div>
</div>
</header>
@@ -2,6 +2,7 @@
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
@@ -50,6 +51,9 @@ export default function DamageInspectionCard({
initialInspection?: DamageInspection | null
onSaved: (inspection: DamageInspection) => void
}) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
type,
@@ -80,20 +84,14 @@ export default function DamageInspectionCard({
employeeNotes: inspection.employeeNotes,
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
description,
isPreExisting,
viewType, x, y, damageType, severity, description, isPreExisting,
})),
}),
})
setInspection(result)
onSaved(result)
} catch (err: any) {
setError(err.message ?? 'Failed to save inspection')
setError(err.message ?? r.failedSaveInspection)
} finally {
setSaving(false)
}
@@ -107,15 +105,7 @@ export default function DamageInspectionCard({
...current,
damagePoints: [
...current.damagePoints,
{
viewType: 'TOP',
x,
y,
damageType,
severity,
description: '',
isPreExisting: type === 'CHECKIN',
},
{ viewType: 'TOP', x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
],
}))
}
@@ -123,7 +113,7 @@ export default function DamageInspectionCard({
function removePoint(index: number) {
setInspection((current) => ({
...current,
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index),
damagePoints: current.damagePoints.filter((_, i) => i !== index),
}))
}
@@ -131,11 +121,11 @@ export default function DamageInspectionCard({
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{type === 'CHECKIN' ? 'Check-in inspection' : 'Check-out inspection'}</h3>
<p className="mt-1 text-sm text-slate-500">Mark existing and newly discovered damage directly on the vehicle diagram.</p>
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
</div>
<button onClick={saveInspection} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save inspection'}
{saving ? r.savingInspection : r.saveInspection}
</button>
</div>
@@ -151,7 +141,7 @@ export default function DamageInspectionCard({
onClick={() => setDamageType(option)}
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
>
{option}
{r.damageTypeLabels[option]}
</button>
))}
</div>
@@ -164,7 +154,7 @@ export default function DamageInspectionCard({
className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
>
{option}
{r.severityLabels[option]}
</button>
))}
</div>
@@ -184,61 +174,61 @@ export default function DamageInspectionCard({
</g>
))}
</svg>
<p className="mt-2 text-xs text-slate-500">Click anywhere on the diagram to add a damage marker with the selected type and severity.</p>
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
</div>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Mileage</label>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
<input
type="number"
className="input-field"
value={inspection.mileage ?? ''}
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))}
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel level</label>
<select className="input-field" value={inspection.fuelLevel} onChange={(event) => setInspection((current) => ({ ...current, fuelLevel: event.target.value as FuelLevel }))}>
{fuelLevels.map((fuelLevel) => <option key={fuelLevel} value={fuelLevel}>{fuelLevel}</option>)}
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
<select className="input-field" value={inspection.fuelLevel} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
</select>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">General condition</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(event) => setInspection((current) => ({ ...current, generalCondition: event.target.value }))} />
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Employee notes</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(event) => setInspection((current) => ({ ...current, employeeNotes: event.target.value }))} />
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
</div>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={inspection.customerAgreed} onChange={(event) => setInspection((current) => ({ ...current, customerAgreed: event.target.checked }))} />
Customer acknowledged this inspection
<input type="checkbox" checked={inspection.customerAgreed} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
{r.customerAcknowledged}
</label>
<div className="rounded-2xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">Damage markers</p>
<p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</p>
</div>
<div className="divide-y divide-slate-100">
{inspection.damagePoints.map((point, index) => (
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{point.damageType} · {point.severity}</p>
<p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
</div>
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
Remove
{r.removeMarker}
</button>
</div>
))}
{inspection.damagePoints.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div>
<div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
)}
</div>
</div>
@@ -4,6 +4,7 @@ interface StatCardProps {
title: string
value: string | number
change?: number
vsLastMonthLabel?: string
icon: LucideIcon
iconColor?: string
iconBg?: string
@@ -13,6 +14,7 @@ export default function StatCard({
title,
value,
change,
vsLastMonthLabel = 'vs last month',
icon: Icon,
iconColor = 'text-blue-600',
iconBg = 'bg-blue-50',
@@ -36,7 +38,7 @@ export default function StatCard({
>
{isPositive ? '+' : ''}{change.toFixed(1)}%
</span>
<span className="text-xs text-slate-400">vs last month</span>
<span className="text-xs text-slate-400">{vsLastMonthLabel}</span>
</div>
)}
</div>
+3 -3
View File
@@ -1,7 +1,7 @@
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'
typeof window === 'undefined'
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
: (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1')
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
@@ -44,7 +44,9 @@ interface Dict {
// modal
reserveVehicle: string
pickupDate: string
pickupTime: string
returnDate: string
dropoffTime: string
firstName: string
lastName: string
email: string
@@ -85,7 +87,9 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
email: '',
phone: '',
startDate: today(),
startTime: '10:00',
endDate: tomorrow(),
endTime: '10:00',
notes: '',
})
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
@@ -97,7 +101,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
setSelected(vehicle)
setStatus('idle')
setErrorMsg('')
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' })
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, startTime: '10:00', endDate: minEnd, endTime: '10:00', notes: '' })
}
function closeModal() {
@@ -137,8 +141,8 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
lastName: form.lastName,
email: form.email,
phone: form.phone || undefined,
startDate: new Date(form.startDate).toISOString(),
endDate: new Date(form.endDate).toISOString(),
startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(),
endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(),
notes: form.notes || undefined,
})
setStatus('success')
@@ -260,7 +264,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
</div>
)}
{/* Dates */}
{/* Dates + Times */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label>
@@ -284,6 +288,26 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime}</label>
<input
type="time"
required
value={form.startTime}
onChange={set('startTime')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime}</label>
<input
type="time"
required
value={form.endTime}
onChange={set('endTime')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
</div>
{/* Estimated total */}
@@ -96,7 +96,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
// modal
reserveVehicle: 'Reserve this vehicle',
pickupDate: 'Pick-up date',
pickupTime: 'Pick-up time',
returnDate: 'Return date',
dropoffTime: 'Drop-off time',
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
@@ -152,7 +154,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
reserveVehicle: 'Réserver ce véhicule',
pickupDate: 'Date de prise en charge',
pickupTime: 'Heure de prise en charge',
returnDate: 'Date de retour',
dropoffTime: 'Heure de restitution',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
@@ -208,7 +212,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
reserveVehicle: 'احجز هذه السيارة',
pickupDate: 'تاريخ الاستلام',
pickupTime: 'وقت الاستلام',
returnDate: 'تاريخ الإرجاع',
dropoffTime: 'وقت الإرجاع',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
+198
View File
@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Star } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
interface ReviewInfo {
reservationId: string
companyName: string
companyLogoUrl: string | null
vehicle: string
vehiclePhoto: string | null
}
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
const [hovered, setHovered] = useState(0)
return (
<div>
<p className="text-sm font-medium text-stone-700 mb-1.5">{label}</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
onMouseEnter={() => setHovered(n)}
onMouseLeave={() => setHovered(0)}
className="p-0.5 transition-transform hover:scale-110"
aria-label={`${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={`h-8 w-8 transition-colors ${
n <= (hovered || value)
? 'fill-amber-400 text-amber-400'
: 'fill-stone-200 text-stone-200'
}`}
/>
</button>
))}
</div>
</div>
)
}
export default function ReviewPage() {
const params = useSearchParams()
const token = params.get('token') ?? ''
const [info, setInfo] = useState<ReviewInfo | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
const [overall, setOverall] = useState(0)
const [vehicle, setVehicle] = useState(0)
const [service, setService] = useState(0)
const [comment, setComment] = useState('')
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
if (!token) { setLoadState('invalid'); return }
fetch(`${API_BASE}/marketplace/review/${token}`)
.then(async (r) => {
if (r.status === 409) { setLoadState('already_reviewed'); return }
if (!r.ok) { setLoadState('invalid'); return }
const json = await r.json()
setInfo(json.data)
setLoadState('ready')
})
.catch(() => setLoadState('invalid'))
}, [token])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
setSubmitState('submitting')
setErrorMsg('')
try {
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
overallRating: overall,
vehicleRating: vehicle || undefined,
serviceRating: service || undefined,
comment: comment.trim() || undefined,
}),
})
if (res.status === 409) { setLoadState('already_reviewed'); return }
if (!res.ok) {
const json = await res.json()
throw new Error(json.message ?? 'Something went wrong.')
}
setSubmitState('done')
} catch (err: any) {
setSubmitState('error')
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
}
}
if (loadState === 'loading') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<p className="text-stone-500 text-sm">Loading</p>
</main>
)
}
if (loadState === 'invalid') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-md w-full p-8 text-center space-y-3">
<p className="text-2xl font-black text-stone-900">Link not found</p>
<p className="text-stone-500 text-sm">This review link is invalid or has already been used.</p>
</div>
</main>
)
}
if (loadState === 'already_reviewed') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-md w-full p-8 text-center space-y-3">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<p className="text-xl font-bold text-stone-900">Already reviewed</p>
<p className="text-stone-500 text-sm">You have already submitted a review for this rental. Thank you!</p>
</div>
</main>
)
}
if (submitState === 'done') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-md w-full p-8 text-center space-y-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<h1 className="text-2xl font-black text-stone-900">Thank you!</h1>
<p className="text-stone-500">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
</div>
</main>
)
}
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-lg w-full overflow-hidden">
{/* Header */}
<div className="flex items-center gap-4 border-b border-stone-100 p-6">
{info?.vehiclePhoto ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
) : (
<div className="h-16 w-24 rounded-xl bg-stone-100 flex-shrink-0" />
)}
<div className="min-w-0">
<p className="text-xs uppercase tracking-widest text-stone-400 truncate">{info?.companyName}</p>
<h1 className="mt-0.5 text-lg font-bold text-stone-900 truncate">{info?.vehicle}</h1>
<p className="mt-0.5 text-sm text-stone-500">How was your experience?</p>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">Comments (optional)</label>
<textarea
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Tell us about your rental experience…"
className="w-full rounded-xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
/>
</div>
{errorMsg && (
<p className="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
)}
<button
type="submit"
disabled={submitState === 'submitting'}
className="w-full rounded-full bg-stone-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-stone-700 disabled:opacity-60"
>
{submitState === 'submitting' ? 'Submitting' : 'Submit review'}
</button>
</form>
</div>
</main>
)
}
@@ -121,10 +121,12 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
const router = useRouter()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
const [theme, setTheme] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en')
const [companyName, setCompanyName] = useState<string | null>(null)
useEffect(() => {
const storedLanguage = window.localStorage.getItem('marketplace-language')
@@ -139,6 +141,19 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
}
setHydrated(true)
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('employee_token='))?.split('=')[1]
if (token) {
fetch(`${apiUrl}/companies/me/brand`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(json => {
const name = json?.data?.displayName ?? json?.displayName
if (name) setCompanyName(name)
})
.catch(() => {})
}
}, [])
useEffect(() => {
@@ -212,12 +227,24 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
>
{dict.explore}
</Link>
<Link
href={`${dashboardUrl}/sign-in`}
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.ownerSignIn}
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
className="ml-2 flex items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-in`}
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.ownerSignIn}
</Link>
)}
</nav>
</div>
</div>
+9 -2
View File
@@ -50,7 +50,9 @@ interface BookingOptions {
interface Step1 {
startDate: string
startTime: string
endDate: string
endTime: string
}
interface Step2 {
@@ -192,7 +194,7 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
const [bookingOptions, setBookingOptions] = useState<BookingOptions | null>(null)
const [bookingOptionsError, setBookingOptionsError] = useState('')
const [step1, setStep1] = useState<Step1>({ startDate: '', endDate: '' })
const [step1, setStep1] = useState<Step1>({ startDate: '', startTime: '10:00', endDate: '', endTime: '10:00' })
const [step1Errors, setStep1Errors] = useState<Partial<Step1>>({})
const [step2, setStep2] = useState<Step2>({
firstName: '',
@@ -643,8 +645,13 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
{step === 3 && (
<div className="space-y-6">
{vehicle && (
<div className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
{dict.booking.categoryNotice(dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category)}
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 divide-y divide-slate-100 text-sm">
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${vehicle.make} ${vehicle.model}` : vehicleId} />
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category}${vehicle.make} ${vehicle.model}` : vehicleId} />
<SummaryRow label={dict.booking.summary.dates} value={`${step1.startDate} -> ${step1.endDate} (${days} ${dayLabel})`} />
<SummaryRow label={dict.booking.summary.primaryDriver} value={`${step2.firstName} ${step2.lastName}`} />
<SummaryRow label={dict.booking.summary.email} value={step2.email} />
@@ -12,6 +12,7 @@ interface ReservationDetail {
endDate: string
totalDays: number
totalAmount: number
vehicleCategory: string | null
vehicle: { make: string; model: string; year: number } | null
customer: { firstName: string; lastName: string; email: string } | null
}
@@ -69,8 +70,8 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
<div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.confirmation.bookingSummary}
</div>
{reservation.vehicle && (
<Row label={dict.confirmation.vehicle} value={`${reservation.vehicle.make} ${reservation.vehicle.model} (${reservation.vehicle.year})`} />
{reservation.vehicleCategory && (
<Row label={dict.confirmation.vehicleCategory} value={dict.vehicles.categoryLabels[reservation.vehicleCategory as keyof typeof dict.vehicles.categoryLabels] ?? reservation.vehicleCategory} />
)}
{reservation.customer && (
<Row label={dict.confirmation.customer} value={`${reservation.customer.firstName} ${reservation.customer.lastName}`} />
+62 -6
View File
@@ -111,6 +111,16 @@ export type PublicSiteDictionary = {
seats: (count: number) => string
perDay: string
viewDetails: string
categoryLabels: {
ECONOMY: string
COMPACT: string
MIDSIZE: string
FULLSIZE: string
SUV: string
LUXURY: string
VAN: string
TRUCK: string
}
filters: {
title: string
clearAll: string
@@ -150,7 +160,9 @@ export type PublicSiteDictionary = {
vehicleUnavailableNoDate: string
bookingOptionsLoadError: string
startDate: string
startTime: string
endDate: string
endTime: string
continueToDriverDetails: string
firstName: string
lastName: string
@@ -189,6 +201,7 @@ export type PublicSiteDictionary = {
redirectingToPayment: string
confirmPayAtPickup: string
payWith: (provider: string) => string
categoryNotice: (category: string) => string
summary: {
vehicle: string
dates: string
@@ -235,6 +248,7 @@ export type PublicSiteDictionary = {
description: string
bookingSummary: string
vehicle: string
vehicleCategory: string
customer: string
email: string
dates: string
@@ -364,6 +378,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
seats: (count) => `${count} seats`,
perDay: 'per day',
viewDetails: 'View details',
categoryLabels: {
ECONOMY: 'Economy',
COMPACT: 'Compact',
MIDSIZE: 'Midsize',
FULLSIZE: 'Full-size',
SUV: 'SUV',
LUXURY: 'Luxury',
VAN: 'Van',
TRUCK: 'Truck',
},
filters: {
title: 'Filters',
clearAll: 'Clear all',
@@ -402,8 +426,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
vehicleAvailableFrom: (date) => `This vehicle can only be reserved starting ${date}.`,
vehicleUnavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
bookingOptionsLoadError: 'Could not load booking options.',
startDate: 'Start date',
endDate: 'End date',
startDate: 'Pick-up date',
startTime: 'Pick-up time',
endDate: 'Drop-off date',
endTime: 'Drop-off time',
continueToDriverDetails: 'Continue to driver details ->',
firstName: 'First name',
lastName: 'Last name',
@@ -442,6 +468,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
redirectingToPayment: 'Redirecting to payment...',
confirmPayAtPickup: 'Confirm - pay at pickup',
payWith: (provider) => `Pay with ${provider} ->`,
categoryNotice: (category) => `Your vehicle will be assigned from the ${category} category upon arrival. The specific car may differ from the one shown.`,
summary: {
vehicle: 'Vehicle',
dates: 'Dates',
@@ -488,6 +515,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
description: 'The reservation has been created in draft status and will be confirmed by staff or after payment.',
bookingSummary: 'Booking summary',
vehicle: 'Vehicle',
vehicleCategory: 'Vehicle category',
customer: 'Customer',
email: 'Email',
dates: 'Dates',
@@ -615,6 +643,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
seats: (count) => `${count} places`,
perDay: 'par jour',
viewDetails: 'Voir les détails',
categoryLabels: {
ECONOMY: 'Économique',
COMPACT: 'Compacte',
MIDSIZE: 'Intermédiaire',
FULLSIZE: 'Grande berline',
SUV: 'SUV',
LUXURY: 'Luxe',
VAN: 'Van',
TRUCK: 'Camionnette',
},
filters: {
title: 'Filtres',
clearAll: 'Tout effacer',
@@ -653,8 +691,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
vehicleAvailableFrom: (date) => `Ce véhicule ne peut être réservé qu’à partir du ${date}.`,
vehicleUnavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
bookingOptionsLoadError: 'Impossible de charger les options de réservation.',
startDate: 'Date de début',
endDate: 'Date de fin',
startDate: 'Date de prise en charge',
startTime: 'Heure de prise en charge',
endDate: 'Date de restitution',
endTime: 'Heure de restitution',
continueToDriverDetails: 'Continuer vers les informations conducteur ->',
firstName: 'Prénom',
lastName: 'Nom',
@@ -693,6 +733,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
redirectingToPayment: 'Redirection vers le paiement...',
confirmPayAtPickup: 'Confirmer - payer au retrait',
payWith: (provider) => `Payer avec ${provider} ->`,
categoryNotice: (category) => `Votre véhicule sera attribué depuis la catégorie ${category} à la prise en charge. Le véhicule exact peut différer de celui affiché.`,
summary: {
vehicle: 'Véhicule',
dates: 'Dates',
@@ -739,6 +780,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
description: 'La réservation a été créée en brouillon et sera confirmée par le personnel ou après paiement.',
bookingSummary: 'Résumé de réservation',
vehicle: 'Véhicule',
vehicleCategory: 'Catégorie du véhicule',
customer: 'Client',
email: 'E-mail',
dates: 'Dates',
@@ -866,6 +908,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
seats: (count) => `${count} مقاعد`,
perDay: 'في اليوم',
viewDetails: 'عرض التفاصيل',
categoryLabels: {
ECONOMY: 'اقتصادية',
COMPACT: 'مدمجة',
MIDSIZE: 'متوسطة',
FULLSIZE: 'كبيرة',
SUV: 'دفع رباعي',
LUXURY: 'فاخرة',
VAN: 'فان',
TRUCK: 'شاحنة',
},
filters: {
title: 'الفلاتر',
clearAll: 'مسح الكل',
@@ -904,8 +956,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`,
vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
startDate: 'تاريخ البداية',
endDate: 'تاريخ النهاية',
startDate: 'تاريخ الاستلام',
startTime: 'وقت الاستلام',
endDate: 'تاريخ الإرجاع',
endTime: 'وقت الإرجاع',
continueToDriverDetails: 'المتابعة إلى بيانات السائق ->',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
@@ -944,6 +998,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
redirectingToPayment: 'جارٍ التحويل إلى الدفع...',
confirmPayAtPickup: 'تأكيد - الدفع عند الاستلام',
payWith: (provider) => `ادفع عبر ${provider} ->`,
categoryNotice: (category) => `سيتم تخصيص مركبتك من فئة ${category} عند الاستلام. قد تختلف السيارة المحددة عن تلك المعروضة.`,
summary: {
vehicle: 'المركبة',
dates: 'التواريخ',
@@ -990,6 +1045,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
description: 'تم إنشاء الحجز بحالة مسودة وسيتم تأكيده من قبل الموظفين أو بعد الدفع.',
bookingSummary: 'ملخص الحجز',
vehicle: 'المركبة',
vehicleCategory: 'فئة المركبة',
customer: 'العميل',
email: 'البريد الإلكتروني',
dates: 'التواريخ',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
-- Add reviewToken to reservations for secure one-time review links
ALTER TABLE "reservations" ADD COLUMN "reviewToken" TEXT;
CREATE UNIQUE INDEX "reservations_reviewToken_key" ON "reservations"("reviewToken");
-- Make renterId optional on reviews (reviews can come from non-marketplace customers via token link)
ALTER TABLE "reviews" ALTER COLUMN "renterId" DROP NOT NULL;
ALTER TABLE "reviews" DROP CONSTRAINT IF EXISTS "reviews_renterId_fkey";
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_renterId_fkey"
FOREIGN KEY ("renterId") REFERENCES "renters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reservations" ADD COLUMN "vehicleCategory" "VehicleCategory";
+7 -5
View File
@@ -639,9 +639,10 @@ model Reservation {
renter Renter? @relation("RenterReservations", fields: [renterId], references: [id])
offerId String?
offer Offer? @relation(fields: [offerId], references: [id])
promoCodeUsed String?
source BookingSource @default(DASHBOARD)
marketplaceRef String?
promoCodeUsed String?
vehicleCategory VehicleCategory?
source BookingSource @default(DASHBOARD)
marketplaceRef String?
status ReservationStatus @default(DRAFT)
startDate DateTime
endDate DateTime
@@ -663,6 +664,7 @@ model Reservation {
cancelledBy CancelledBy?
contractNumber String? @unique
invoiceNumber String? @unique
reviewToken String? @unique
checkInFuelLevel String?
checkOutFuelLevel String?
@@ -718,8 +720,8 @@ model Review {
id String @id @default(cuid())
reservationId String @unique
reservation Reservation @relation(fields: [reservationId], references: [id])
renterId String
renter Renter @relation(fields: [renterId], references: [id])
renterId String?
renter Renter? @relation(fields: [renterId], references: [id])
companyId String
overallRating Int
vehicleRating Int?
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "@rentaldrivego/types",
"version": "1.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
+4 -4
View File
@@ -330,7 +330,7 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations/:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
| GET | `//:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
| PATCH | `/reservations/:id/billing` | 🔒🏢💳 | Add damage charges and extras to reservation |
---
@@ -419,9 +419,9 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/reservations/:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
| GET | `//:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
| POST | `/reservations/:id/damage-reports` | 🔒🏢💳 | Save damage report. Body: `{ type: "CHECKIN"|"CHECKOUT", damages: DamageZone[], fuelLevel, mileage, photos[], inspectedBy, customerSignedAt, customerName }` |
| PATCH | `/reservations/:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
| PATCH | `//:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
### Insurance Policies (Company configuration)
@@ -432,7 +432,7 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
| PATCH | `/insurance-policies/:id` | 🔒🏢💳 | Update policy |
| DELETE | `/insurance-policies/:id` | 🔒🏢💳 | Deactivate policy |
| GET | `/reservations/:id/insurances` | 🔒🏢💳 | Insurance selections for a reservation |
| POST | `/reservations/:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
| POST | `//:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
### Additional Drivers