From 1a39aa8433c9d3acf913bc0203bc927c86d3e874 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 11 May 2026 23:40:40 -0400 Subject: [PATCH] fix and update language for company workspace --- .env.docker.dev | 2 +- apps/api/src/index.ts | 106 ++ .../cmp1s6doc0003wims028due25/brand/logo.jpg | Bin 0 -> 7735 bytes .../02d49acd5dfd85c60ee1758a14dbef07.jpg | Bin 0 -> 23274 bytes apps/api/src/routes/auth.employee.ts | 62 + apps/api/src/routes/marketplace.ts | 73 +- apps/api/src/routes/reservations.ts | 41 +- apps/api/src/routes/site.ts | 1 + apps/api/src/routes/vehicles.ts | 12 +- .../(dashboard)/dashboard/billing/page.tsx | 189 ++- .../(dashboard)/dashboard/customers/page.tsx | 58 +- .../(dashboard)/dashboard/fleet/[id]/page.tsx | 737 ++++++---- .../app/(dashboard)/dashboard/fleet/page.tsx | 368 +++-- .../dashboard/notifications/page.tsx | 113 +- .../app/(dashboard)/dashboard/offers/page.tsx | 48 +- .../dashboard/online-reservations/page.tsx | 70 +- .../src/app/(dashboard)/dashboard/page.tsx | 97 +- .../(dashboard)/dashboard/reports/page.tsx | 111 +- .../dashboard/reservations/[id]/page.tsx | 80 +- .../dashboard/reservations/page.tsx | 31 +- .../(dashboard)/dashboard/settings/page.tsx | 356 ++++- .../app/(dashboard)/dashboard/team/page.tsx | 167 ++- apps/dashboard/src/app/(dashboard)/layout.tsx | 2 +- .../dashboard/src/components/I18nProvider.tsx | 1182 +++++++++++++++++ .../src/components/VehicleCalendar.tsx | 66 +- .../src/components/layout/Sidebar.tsx | 219 ++- .../src/components/layout/TopBar.tsx | 157 ++- .../reservations/DamageInspectionCard.tsx | 68 +- apps/dashboard/src/components/ui/StatCard.tsx | 4 +- apps/dashboard/src/lib/api.ts | 6 +- .../src/app/explore/ExploreVehicleGrid.tsx | 32 +- apps/marketplace/src/app/explore/page.tsx | 6 + apps/marketplace/src/app/review/page.tsx | 198 +++ .../src/components/MarketplaceShell.tsx | 39 +- apps/public-site/src/app/book/BookClient.tsx | 11 +- .../src/app/book/confirmation/page.tsx | 5 +- apps/public-site/src/lib/i18n.ts | 68 +- .../00000000000000_init/migration.sql | 1016 ++++++++++++++ .../20260511000000_review_token/migration.sql | 9 + .../migration.sql | 2 + packages/database/prisma/schema.prisma | 12 +- packages/types/package.json | 4 +- project_design/api-routes.md | 8 +- 43 files changed, 5034 insertions(+), 802 deletions(-) create mode 100644 apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/brand/logo.jpg create mode 100644 apps/api/src/lib/storage/companies/cmp1s6doc0003wims028due25/vehicles/02d49acd5dfd85c60ee1758a14dbef07.jpg create mode 100644 apps/marketplace/src/app/review/page.tsx create mode 100644 packages/database/prisma/migrations/00000000000000_init/migration.sql create mode 100644 packages/database/prisma/migrations/20260511000000_review_token/migration.sql create mode 100644 packages/database/prisma/migrations/20260511000001_reservation_vehicle_category/migration.sql 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 0000000000000000000000000000000000000000..a67aad68bc8db5c94dbd9c98a72e48875295983d GIT binary patch literal 7735 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bbc749a21c3659b929705c22ca3baf911144cb3d GIT binary patch literal 23274 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 Xo6W^3N>Y@iFGb~eUg}@yb=m*f_bQgi literal 0 HcmV?d00001 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.

+ +

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}