diff --git a/.env.docker.dev b/.env.docker.dev index 89bfd92..36f1f26 100644 --- a/.env.docker.dev +++ b/.env.docker.dev @@ -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 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 59cbbd4..1820d68 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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() + 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, () => { diff --git a/apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/brand/logo.jpg b/apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/brand/logo.jpg new file mode 100644 index 0000000..a67aad6 Binary files /dev/null and b/apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/brand/logo.jpg differ diff --git a/apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/vehicles/02d49acd5dfd85c60ee1758a14dbef07.jpg b/apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/vehicles/02d49acd5dfd85c60ee1758a14dbef07.jpg new file mode 100644 index 0000000..bbc749a Binary files /dev/null and b/apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/vehicles/02d49acd5dfd85c60ee1758a14dbef07.jpg differ diff --git a/apps/api/src/routes/auth.employee.ts b/apps/api/src/routes/auth.employee.ts index c34b2cc..bf587a4 100644 --- a/apps/api/src/routes/auth.employee.ts +++ b/apps/api/src/routes/auth.employee.ts @@ -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({ diff --git a/apps/api/src/routes/marketplace.ts b/apps/api/src/routes/marketplace.ts index 952572e..cb967a0 100644 --- a/apps/api/src/routes/marketplace.ts +++ b/apps/api/src/routes/marketplace.ts @@ -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({ diff --git a/apps/api/src/routes/reservations.ts b/apps/api/src/routes/reservations.ts index 0f0b30f..62495a3 100644 --- a/apps/api/src/routes/reservations.ts +++ b/apps/api/src/routes/reservations.ts @@ -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: ` +
+

Hi ${customer.firstName},

+

Thank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.

+

Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.

+
+ + Leave a review + +
+

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.

+
+ `, + 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) } diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index a2c5c70..3d83db6 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -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, diff --git a/apps/api/src/routes/vehicles.ts b/apps/api/src/routes/vehicles.ts index 161dc69..072e2d1 100644 --- a/apps/api/src/routes/vehicles.ts +++ b/apps/api/src/routes/vehicles.ts @@ -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) } }) diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx index b2694a0..0914ef5 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/billing/page.tsx @@ -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 = { } const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] -const PLAN_FEATURES: Record = { - 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(null) const [invoices, setInvoices] = useState([]) 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, + invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record, + 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, + }, + fr: { + title: 'Facturation', + subtitle: 'Gérez votre plan, le prestataire de paiement et l’historique des factures.', + trial: 'Essai gratuit', + remaining: 'restants. Abonnez-vous avant la fin pour garder l’accè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: 'S’abonner', + 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: 'S’abonner 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, + invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record, + 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, + }, + 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, + invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record, + planFeatures: { + STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'], + GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'], + PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'], + } as Record, + }, + }[language] useEffect(() => { Promise.all([ @@ -139,8 +260,8 @@ export default function BillingPage() { return (
-

Billing

-

Manage your plan, payment provider, and invoice history.

+

{copy.title}

+

{copy.subtitle}

{error && ( @@ -151,7 +272,7 @@ export default function BillingPage() { {subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (

- Free trial — {daysLeft} days remaining. Subscribe before it ends to keep access. + {copy.trial} — {daysLeft} days {copy.remaining}

)} @@ -161,21 +282,21 @@ export default function BillingPage() {
-

Current plan

+

{copy.currentPlan}

{subscription.plan}

- {subscription.status} + {copy.statusLabels[subscription.status] ?? subscription.status}

{subscription.billingPeriod} · {subscription.currency} - {subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`} + {subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}

{subscription.cancelAtPeriodEnd && (

- Cancellation scheduled at end of billing period.{' '} - + {copy.cancelScheduled}{' '} +

)}
@@ -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} )}
@@ -196,9 +317,9 @@ export default function BillingPage() {

- {subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'} + {subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}

-

Select a plan and payment provider to proceed.

+

{copy.selectPlan}

{/* 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} ))}
@@ -248,14 +369,14 @@ export default function BillingPage() { >

{plan}

- {isActive && Active} + {isActive && {copy.active}}

{price ? formatCurrency(price, currency) : '—'} - /{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'} + /{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}

    - {PLAN_FEATURES[plan].map((f) => ( + {copy.planFeatures[plan].map((f) => (
  • {f}
  • @@ -268,7 +389,7 @@ export default function BillingPage() { {/* Provider selector */}
    -

    Payment provider

    +

    {copy.paymentProvider}

    {(['AMANPAY', 'PAYPAL'] as const).map((p) => (
    @@ -306,31 +427,31 @@ export default function BillingPage() { {/* Invoice history */}
    -

    Invoice history

    +

    {copy.invoiceHistory}

    - - - - - + + + + + {loading ? ( - + ) : invoices.length === 0 ? ( - + ) : invoices.map((inv) => (
    DateProviderStatusPaidAmount{copy.date}{copy.provider}{copy.status}{copy.paid}{copy.amount}
    Loading…
    {copy.loading}
    No invoices yet.
    {copy.noInvoices}
    {new Date(inv.createdAt).toLocaleDateString()} {inv.paymentProvider} - {inv.status} + {copy.invoiceStatusLabels[inv.status] ?? inv.status} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx index f066d1e..6a5e79a 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/customers/page.tsx @@ -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([]) const [error, setError] = useState(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 l’entreprise 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('/customers?pageSize=100') @@ -26,8 +66,8 @@ export default function CustomersPage() { return (
    -

    Customers

    -

    Company-scoped CRM with license validation and risk flags.

    +

    {copy.title}

    +

    {copy.subtitle}

    {error ? ( @@ -37,10 +77,10 @@ export default function CustomersPage() { - - - - + + + + @@ -49,15 +89,15 @@ export default function CustomersPage() { - + ))} {rows.length === 0 && ( - + )} diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx index 121fc54..8053a61 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx @@ -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 }) { + const { dict } = useDashboardI18n() + const vd = dict.vehicleDetail + if (status === 'none') return {vd.statusNone} + if (status === 'overdue') return {vd.statusOverdue} + if (status === 'due-soon') return {vd.statusDueSoon} + return {vd.statusOk} +} + +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(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 ( +
    +
    +
    + +
    +

    {displayLabel}

    +

    {nextDueText}

    +
    +
    +
    + + +
    +
    + + {open && ( +
    + {error &&

    {error}

    } + +
    +
    + + setForm({ ...form, performedAt: e.target.value })} required /> +
    +
    + + setForm({ ...form, mileage: e.target.value })} /> +
    +
    + +
    +
    + + setForm({ ...form, nextDueAt: e.target.value })} /> +
    +
    + + setForm({ ...form, nextDueMileage: e.target.value })} /> +
    +
    + +
    +
    + + setForm({ ...form, cost: e.target.value })} /> +
    +
    + + setForm({ ...form, description: e.target.value })} /> +
    +
    + +
    + + +
    + + )} +
    + ) +} + +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(null) const [error, setError] = useState(null) const [activeTab, setActiveTab] = useState('details') @@ -78,7 +278,6 @@ export default function FleetDetailPage() { const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(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([]) + + const fetchMaintenance = () => { + apiFetch(`/vehicles/${params.id}/maintenance`) + .then((logs) => setMaintenanceLogs(logs ?? [])) + .catch(() => {}) + } + + useEffect(() => { fetchMaintenance() }, [params.id]) + const [deletingPhotoIdx, setDeletingPhotoIdx] = useState(null) const [newPhotoFiles, setNewPhotoFiles] = useState([]) const [newPhotoPreviews, setNewPhotoPreviews] = useState([]) @@ -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(`/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) => { 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
    {error}
    - if (!vehicle) return
    Loading vehicle…
    + if (error) return
    {error}
    + if (!vehicle) return
    {vd.loadingVehicle}
    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 (
    {/* Header */} @@ -233,21 +435,21 @@ export default function FleetDetailPage() {

    {vehicle.make} {vehicle.model}

    -

    {vehicle.year} · {vehicle.licensePlate} · {vehicle.status}

    +

    {vehicle.year} · {vehicle.licensePlate} · {statusLabel}

    {!editing ? ( ) : (
    )} @@ -259,271 +461,276 @@ export default function FleetDetailPage() { {/* Tab bar */}
    - - + {(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => ( + + ))}
    {activeTab === 'details' && (
    - {/* Photos */} -
    -

    Photos

    -
    - {/* Existing photos */} - {vehicle.photos.map((photo, index) => ( -
    - - {editing && ( + {/* Photos */} +
    +

    {vd.photosHeading}

    +
    + {vehicle.photos.map((photo, index) => ( +
    + + {editing && ( + + )} +
    + ))} + + {editing && newPhotoPreviews.map((src, i) => ( +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {`new - )} -
    - ))} + {vd.newPhotoBadge} +
    + ))} - {/* New photo previews (edit mode) */} - {editing && newPhotoPreviews.map((src, i) => ( -
    - {/* eslint-disable-next-line @next/next/no-img-element */} - {`new + {vehicle.photos.length === 0 && !editing && ( +
    {vd.noPhotosYet}
    + )} +
    + + {editing && totalPhotos < 10 && ( +
    + - New
    - ))} - - {vehicle.photos.length === 0 && !editing && ( -
    No photos uploaded yet.
    + )} + {editing && totalPhotos >= 10 && ( +

    {vd.maxPhotosReached}

    )}
    - {/* Upload new photos (edit mode) */} - {editing && totalPhotos < 10 && ( -
    - - -
    - )} - {editing && totalPhotos >= 10 && ( -

    Maximum 10 photos reached.

    - )} -
    + {/* Details / Edit form */} +
    +

    {vd.vehicleDetailsHeading}

    - {/* Details / Edit form */} -
    -

    Vehicle details

    - - {!editing || !form ? ( -
    -
    Category
    {vehicle.category}
    -
    Daily rate
    {formatCurrency(vehicle.dailyRate, 'MAD')}
    -
    Status
    {vehicle.status}
    -
    Seats
    {vehicle.seats}
    -
    Transmission
    {vehicle.transmission}
    -
    Fuel type
    {vehicle.fuelType}
    -
    Color
    {vehicle.color || '—'}
    -
    Odometer
    {vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}
    -
    Notes
    {vehicle.notes ?? '—'}
    -
    - ) : ( -
    - {/* Make */} -
    - - - {makeSelect === 'custom' && ( - setForm({ ...form, make: e.target.value })} /> - )} -
    - - {/* Model */} -
    - - {!presetModels ? ( - setForm({ ...form, model: e.target.value })} /> - ) : ( - <> - - {modelSelect === 'custom' && ( - setForm({ ...form, model: e.target.value })} /> - )} - - )} -
    - - {/* Year & Category */} -
    + {!editing || !form ? ( +
    +
    {vd.labelCategory}
    {categoryLabel}
    +
    {vd.labelDailyRate}
    {formatCurrency(vehicle.dailyRate, 'MAD')}
    +
    {vd.labelStatus}
    {statusLabel}
    +
    {vd.labelSeats}
    {vehicle.seats}
    +
    {vd.labelTransmission}
    {transLabel}
    +
    {vd.labelFuelType}
    {fuelLabel}
    +
    {vd.labelColor}
    {vehicle.color || '—'}
    +
    {vd.labelOdometer}
    {vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}
    +
    {vd.labelNotes}
    {vehicle.notes ?? '—'}
    +
    + ) : ( +
    + {/* Make */}
    - - setForm({ ...form, year: Number(e.target.value) })} /> -
    -
    - - -
    -
    - - {/* Daily Rate & License Plate */} -
    -
    - - setForm({ ...form, dailyRate: e.target.value })} /> -
    -
    - - setForm({ ...form, licensePlate: e.target.value })} /> -
    -
    - - {/* Status */} -
    - - -
    - - {/* Color & Seats */} -
    -
    - + - {colorSelect === 'custom' && ( - setForm({ ...form, color: e.target.value })} /> + {makeSelect === 'custom' && ( + setForm({ ...form, make: e.target.value })} /> )}
    -
    - - setForm({ ...form, seats: Number(e.target.value) })} /> -
    -
    - {/* Transmission & Fuel */} -
    + {/* Model */}
    - - + + {!presetModels ? ( + setForm({ ...form, model: e.target.value })} /> + ) : ( + <> + + {modelSelect === 'custom' && ( + setForm({ ...form, model: e.target.value })} /> + )} + + )}
    + + {/* Year & Category */} +
    +
    + + setForm({ ...form, year: Number(e.target.value) })} /> +
    +
    + + +
    +
    + + {/* Daily Rate & License Plate */} +
    +
    + + setForm({ ...form, dailyRate: e.target.value })} /> +
    +
    + + setForm({ ...form, licensePlate: e.target.value })} /> +
    +
    + + {/* Status */}
    - - setForm({ ...form, status: e.target.value })}> + {(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'] as const).map((s) => ( + ))}
    -
    - {/* Odometer */} -
    - - setForm({ ...form, mileage: e.target.value })} - /> -
    + {/* Color & Seats */} +
    +
    + + + {colorSelect === 'custom' && ( + setForm({ ...form, color: e.target.value })} /> + )} +
    +
    + + setForm({ ...form, seats: Number(e.target.value) })} /> +
    +
    - {/* Notes */} -
    - -
    CustomerContactLicenseFlags{copy.customer}{copy.contact}{copy.license}{copy.flags}
    {row.firstName} {row.lastName}

    {row.email}

    -

    {row.phone ?? 'No phone'}

    +

    {row.phone ?? copy.noPhone}

    {row.licenseValidationStatus}{row.flagged ? Flagged : Clear}{row.flagged ? {copy.flagged} : {copy.clear}}
    No customers yet.{copy.empty}