fix and update language for company workspace

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

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+62
View File
@@ -28,6 +28,68 @@ function buildResetEmailHtml(resetUrl: string, firstName: string) {
` `
} }
router.get('/me', async (req, res, next) => {
try {
const authHeader = req.headers.authorization
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
if (!token) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Authentication required',
statusCode: 401,
})
}
let employeeId = ''
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
if (payload.type !== 'employee') {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
employeeId = payload.sub
} catch {
return res.status(401).json({
error: 'invalid_token',
message: 'Invalid or expired session token',
statusCode: 401,
})
}
const employee = await prisma.employee.findUnique({
where: { id: employeeId },
include: { company: true },
})
if (!employee || !employee.isActive) {
return res.status(401).json({
error: 'unauthenticated',
message: 'Employee account not found or inactive',
statusCode: 401,
})
}
res.json({
data: {
employee: {
id: employee.id,
email: employee.email,
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
},
},
})
} catch (err) { next(err) }
})
router.post('/login', async (req, res, next) => { router.post('/login', async (req, res, next) => {
try { try {
const { email, password } = z.object({ const { email, password } = z.object({
+68 -5
View File
@@ -50,7 +50,7 @@ router.get('/companies', async (req, res, next) => {
where, where,
include: { include: {
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } }, 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, skip: (page - 1) * pageSize,
take: 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 { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
const { page, pageSize } = paginationSchema.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 (category) where.category = category
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice } if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
if (transmission) where.transmission = transmission 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'] } }, where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
include: { include: {
brand: true, 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' } }, 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 { try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } }) const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicles = await prisma.vehicle.findMany({ const vehicles = await prisma.vehicle.findMany({
where: { companyId: company.id, isPublished: true }, where: { companyId: company.id, isPublished: true, status: 'AVAILABLE' },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}) })
res.json({ data: vehicles }) res.json({ data: vehicles })
@@ -308,7 +308,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
try { try {
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } }) const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
const vehicle = await prisma.vehicle.findFirstOrThrow({ 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: { include: {
company: { include: { brand: true } }, 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) => { router.post('/offers/:code/validate', async (req, res, next) => {
try { try {
const offer = await prisma.offer.findFirst({ const offer = await prisma.offer.findFirst({
+37 -4
View File
@@ -8,7 +8,8 @@ import { applyAdditionalDriversToReservation } from '../services/additionalDrive
import { applyInsurancesToReservation } from '../services/insuranceService' import { applyInsurancesToReservation } from '../services/insuranceService'
import { applyPricingRules } from '../services/pricingRuleService' import { applyPricingRules } from '../services/pricingRuleService'
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService' import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
import { sendNotification } from '../services/notificationService' import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
import crypto from 'crypto'
const router = Router() const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription) 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) => { router.post('/:id/checkout', async (req, res, next) => {
try { try {
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body) 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 }) 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 } }) 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 } }) const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null) const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true } } } })
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: `How was your rental? — ${vehicleLabel}`,
html: `
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
<h2 style="margin-bottom:4px">Hi ${customer.firstName},</h2>
<p style="color:#57534e">Thank you for renting with <strong>${companyName}</strong>. We hope you enjoyed your <strong>${vehicleLabel}</strong>.</p>
<p style="color:#57534e">Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.</p>
<div style="margin:32px 0;text-align:center">
<a href="${reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
Leave a review
</a>
</div>
<p style="color:#a8a29e;font-size:12px">This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.</p>
</div>
`,
text: `Hi ${customer.firstName},\n\nThank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.\n\nLeave a review here (one-time link):\n${reviewUrl}\n\nThank you!`,
}).catch(() => null)
res.json({ data: updated }) res.json({ data: updated })
} catch (err) { next(err) } } catch (err) { next(err) }
+1
View File
@@ -308,6 +308,7 @@ router.post('/:slug/book', async (req, res, next) => {
customerId: customer.id, customerId: customer.id,
offerId: body.offerId ?? null, offerId: body.offerId ?? null,
promoCodeUsed: body.promoCodeUsed ?? null, promoCodeUsed: body.promoCodeUsed ?? null,
vehicleCategory: vehicle.category,
source: body.source, source: body.source,
startDate: start, startDate: start,
endDate: end, endDate: end,
+9 -3
View File
@@ -28,6 +28,7 @@ const vehicleSchema = z.object({
mileage: z.number().int().optional(), mileage: z.number().int().optional(),
notes: z.string().optional(), notes: z.string().optional(),
isPublished: z.boolean().default(true), isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
}) })
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
@@ -65,9 +66,14 @@ router.get('/:id', async (req, res, next) => {
router.patch('/:id', async (req, res, next) => { router.patch('/:id', async (req, res, next) => {
try { try {
const body = vehicleSchema.partial().parse(req.body) const body = vehicleSchema.partial().parse(req.body)
const vehicle = await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body }) if (body.status === 'MAINTENANCE' || body.status === 'OUT_OF_SERVICE') {
if (vehicle.count === 0) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 }) body.isPublished = false
const updated = await prisma.vehicle.findUniqueOrThrow({ where: { id: req.params.id } }) } 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 }) res.json({ data: updated })
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types' import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL' type BillingPeriod = 'MONTHLY' | 'ANNUAL'
@@ -45,13 +46,8 @@ const INVOICE_STATUS: Record<string, string> = {
} }
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
const PLAN_FEATURES: Record<Plan, string[]> = {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
}
export default function BillingPage() { export default function BillingPage() {
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null) const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([]) const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -63,6 +59,131 @@ export default function BillingPage() {
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY') const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [paying, setPaying] = useState(false) const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false) const [cancelling, setCancelling] = useState(false)
const copy = {
en: {
title: 'Billing',
subtitle: 'Manage your plan, payment provider, and invoice history.',
trial: 'Free trial',
remaining: 'remaining. Subscribe before it ends to keep access.',
currentPlan: 'Current plan',
renews: 'renews',
cancelScheduled: 'Cancellation scheduled at end of billing period.',
undo: 'Undo',
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
paymentProvider: 'Payment provider',
total: 'Total',
perMonth: 'month',
perYear: 'year',
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
date: 'Date',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>,
},
fr: {
title: 'Facturation',
subtitle: 'Gérez votre plan, le prestataire de paiement et lhistorique des factures.',
trial: 'Essai gratuit',
remaining: 'restants. Abonnez-vous avant la fin pour garder laccès.',
currentPlan: 'Plan actuel',
renews: 'renouvelle le',
cancelScheduled: 'Annulation programmée à la fin de la période.',
undo: 'Annuler',
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
paymentProvider: 'Prestataire de paiement',
total: 'Total',
perMonth: 'mois',
perYear: 'an',
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
noInvoices: 'Aucune facture pour le moment.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>,
},
ar: {
title: 'الفوترة',
subtitle: 'إدارة الخطة ومزوّد الدفع وسجل الفواتير.',
trial: 'تجربة مجانية',
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
currentPlan: 'الخطة الحالية',
renews: 'يتجدد في',
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
undo: 'تراجع',
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
paymentProvider: 'مزوّد الدفع',
total: 'الإجمالي',
perMonth: 'شهر',
perYear: 'سنة',
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>,
},
}[language]
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
@@ -139,8 +260,8 @@ export default function BillingPage() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Billing</h2> <h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">Manage your plan, payment provider, and invoice history.</p> <p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div> </div>
{error && ( {error && (
@@ -151,7 +272,7 @@ export default function BillingPage() {
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && ( {subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between"> <div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800"> <p className="text-sm font-medium text-sky-800">
Free trial <strong>{daysLeft} days</strong> remaining. Subscribe before it ends to keep access. {copy.trial} <strong>{daysLeft} days</strong> {copy.remaining}
</p> </p>
</div> </div>
)} )}
@@ -161,21 +282,21 @@ export default function BillingPage() {
<div className="card p-6"> <div className="card p-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Current plan</p> <p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3"> <div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3> <h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{subscription.status} {copy.statusLabels[subscription.status] ?? subscription.status}
</span> </span>
</div> </div>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency} {subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`} {subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p> </p>
{subscription.cancelAtPeriodEnd && ( {subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-amber-700"> <p className="mt-2 text-sm font-medium text-amber-700">
Cancellation scheduled at end of billing period.{' '} {copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">Undo</button> <button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p> </p>
)} )}
</div> </div>
@@ -185,7 +306,7 @@ export default function BillingPage() {
disabled={cancelling} disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm" className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
> >
{cancelling ? 'Cancelling…' : 'Cancel plan'} {cancelling ? copy.cancelling : copy.cancelPlan}
</button> </button>
)} )}
</div> </div>
@@ -196,9 +317,9 @@ export default function BillingPage() {
<div className="card p-6 space-y-6"> <div className="card p-6 space-y-6">
<div> <div>
<h3 className="text-base font-semibold text-slate-900"> <h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'} {subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3> </h3>
<p className="mt-1 text-sm text-slate-500">Select a plan and payment provider to proceed.</p> <p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
</div> </div>
{/* Billing period toggle */} {/* 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' billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`} }`}
> >
{p === 'MONTHLY' ? 'Monthly' : 'Annual (save ~17%)'} {p === 'MONTHLY' ? copy.monthly : copy.annual}
</button> </button>
))} ))}
</div> </div>
@@ -248,14 +369,14 @@ export default function BillingPage() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p> <p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">Active</span>} {isActive && <span className="badge-green">{copy.active}</span>}
</div> </div>
<p className="mt-2 text-2xl font-black text-slate-900"> <p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, currency) : '—'} {price ? formatCurrency(price, currency) : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'}</span> <span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p> </p>
<ul className="mt-3 space-y-1"> <ul className="mt-3 space-y-1">
{PLAN_FEATURES[plan].map((f) => ( {copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5"> <li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f} <span className="text-green-500"></span> {f}
</li> </li>
@@ -268,7 +389,7 @@ export default function BillingPage() {
{/* Provider selector */} {/* Provider selector */}
<div> <div>
<p className="text-sm font-medium text-slate-700 mb-2">Payment provider</p> <p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3"> <div className="flex gap-3">
{(['AMANPAY', 'PAYPAL'] as const).map((p) => ( {(['AMANPAY', 'PAYPAL'] as const).map((p) => (
<button <button
@@ -287,10 +408,10 @@ export default function BillingPage() {
{/* Checkout CTA */} {/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100"> <div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div> <div>
<p className="text-sm text-slate-500">Total</p> <p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900"> <p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, currency) : '—'} {planPrice ? formatCurrency(planPrice, currency) : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? 'month' : 'year'}</span> <span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p> </p>
</div> </div>
<button <button
@@ -298,7 +419,7 @@ export default function BillingPage() {
disabled={paying || loading} disabled={paying || loading}
className="btn-primary px-8 py-3" className="btn-primary px-8 py-3"
> >
{paying ? 'Redirecting…' : subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe now'} {paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
</button> </button>
</div> </div>
</div> </div>
@@ -306,31 +427,31 @@ export default function BillingPage() {
{/* Invoice history */} {/* Invoice history */}
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200"> <div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">Invoice history</h3> <h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-200"> <tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Date</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Provider</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Paid</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Amount</th> <th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
{loading ? ( {loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">Loading</td></tr> <tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? ( ) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">No invoices yet.</td></tr> <tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => ( ) : invoices.map((inv) => (
<tr key={inv.id}> <tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td> <td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td> <td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{inv.status} {copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span> </span>
</td> </td>
<td className="px-6 py-4 text-sm text-slate-500"> <td className="px-6 py-4 text-sm text-slate-500">
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface CustomerRow { interface CustomerRow {
id: string id: string
@@ -14,8 +15,47 @@ interface CustomerRow {
} }
export default function CustomersPage() { export default function CustomersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<CustomerRow[]>([]) const [rows, setRows] = useState<CustomerRow[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Customers',
subtitle: 'Company-scoped CRM with license validation and risk flags.',
customer: 'Customer',
contact: 'Contact',
license: 'License',
flags: 'Flags',
noPhone: 'No phone',
flagged: 'Flagged',
clear: 'Clear',
empty: 'No customers yet.',
},
fr: {
title: 'Clients',
subtitle: 'CRM de lentreprise avec validation de permis et indicateurs de risque.',
customer: 'Client',
contact: 'Contact',
license: 'Permis',
flags: 'Signalements',
noPhone: 'Pas de téléphone',
flagged: 'Signalé',
clear: 'Aucun risque',
empty: 'Aucun client pour le moment.',
},
ar: {
title: 'العملاء',
subtitle: 'نظام CRM خاص بالشركة مع التحقق من الرخصة ومؤشرات المخاطر.',
customer: 'العميل',
contact: 'التواصل',
license: 'الرخصة',
flags: 'الإشارات',
noPhone: 'لا يوجد هاتف',
flagged: 'مُعلّم',
clear: 'سليم',
empty: 'لا يوجد عملاء حتى الآن.',
},
}[language]
useEffect(() => { useEffect(() => {
apiFetch<CustomerRow[]>('/customers?pageSize=100') apiFetch<CustomerRow[]>('/customers?pageSize=100')
@@ -26,8 +66,8 @@ export default function CustomersPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Customers</h2> <h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">Company-scoped CRM with license validation and risk flags.</p> <p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div> </div>
<div className="card overflow-hidden"> <div className="card overflow-hidden">
{error ? ( {error ? (
@@ -37,10 +77,10 @@ export default function CustomersPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-200"> <tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Contact</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.contact}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">License</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.license}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Flags</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.flags}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@@ -49,15 +89,15 @@ export default function CustomersPage() {
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td> <td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<p className="text-sm text-slate-700">{row.email}</p> <p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? 'No phone'}</p> <p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td> </td>
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td> <td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">Flagged</span> : <span className="badge-green">Clear</span>}</td> <td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr> </tr>
))} ))}
{rows.length === 0 && ( {rows.length === 0 && (
<tr> <tr>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">No customers yet.</td> <td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -3,10 +3,11 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import Image from 'next/image' 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 { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar' import VehicleCalendar from '@/components/VehicleCalendar'
import { useDashboardI18n } from '@/components/I18nProvider'
interface VehicleDetail { interface VehicleDetail {
id: string id: string
@@ -66,11 +67,210 @@ function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '') return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
} }
type Tab = 'details' | 'calendar' interface MaintenanceLog {
id: string
type: string
description: string | null
cost: number | null
mileage: number | null
performedAt: string
nextDueAt: string | null
nextDueMileage: number | null
}
const ROUTINE_TYPES = [
{ key: 'Oil Change', intervalMonths: 6 },
{ key: 'Technical Inspection', intervalMonths: 12 },
{ key: 'Vehicle Registration', intervalMonths: 12 },
{ key: 'Car Tax', intervalMonths: 12 },
{ key: 'Tire Rotation', intervalMonths: 6 },
{ key: 'Brake Inspection', intervalMonths: 12 },
{ key: 'Air Filter', intervalMonths: 12 },
{ key: 'Cabin Filter', intervalMonths: 12 },
{ key: 'Battery Check', intervalMonths: 12 },
{ key: 'Belt / Chain Service', intervalMonths: 24 },
{ key: 'Coolant Flush', intervalMonths: 24 },
{ key: 'Transmission Service', intervalMonths: 24 },
]
function serviceStatus(log: MaintenanceLog | undefined, currentMileage: number | null): 'none' | 'overdue' | 'due-soon' | 'ok' {
if (!log) return 'none'
const now = new Date()
let worstLevel: 'ok' | 'due-soon' | 'overdue' = 'ok'
if (log.nextDueAt) {
const diffDays = (new Date(log.nextDueAt).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays < 0) return 'overdue'
if (diffDays <= 30) worstLevel = 'due-soon'
}
if (log.nextDueMileage != null && currentMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
if (kmLeft <= 0) return 'overdue'
if (kmLeft <= 500 && worstLevel !== 'overdue') worstLevel = 'due-soon'
}
return worstLevel
}
function StatusPill({ status }: { status: ReturnType<typeof serviceStatus> }) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
if (status === 'none') return <span className="badge-gray text-xs">{vd.statusNone}</span>
if (status === 'overdue') return <span className="badge-red text-xs">{vd.statusOverdue}</span>
if (status === 'due-soon') return <span className="badge-amber text-xs">{vd.statusDueSoon}</span>
return <span className="badge-green text-xs">{vd.statusOk}</span>
}
function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, log, onLogged }: {
vehicleId: string
typeKey: string
intervalMonths: number
currentMileage: number | null
log: MaintenanceLog | undefined
onLogged: () => void
}) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const [open, setOpen] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const defaultNextDue = (() => {
const d = new Date()
d.setMonth(d.getMonth() + intervalMonths)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
description: '', cost: '', mileage: '', performedAt: today,
nextDueAt: defaultNextDue, nextDueMileage: '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.performedAt) { setError(vd.dateRequired); return }
setSaving(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: typeKey,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
nextDueMileage: form.nextDueMileage ? parseInt(form.nextDueMileage) : undefined,
}),
})
setForm({ description: '', cost: '', mileage: '', performedAt: today, nextDueAt: defaultNextDue, nextDueMileage: '' })
setOpen(false)
onLogged()
} catch (err: any) {
setError(err.message ?? vd.savingLabel)
} finally {
setSaving(false)
}
}
const status = serviceStatus(log, currentMileage)
const displayLabel = vd.routineTypes[typeKey] ?? typeKey
const nextDueText = (() => {
if (!log) return vd.noRecord
const parts: string[] = [`${vd.lastPrefix} ${new Date(log.performedAt).toLocaleDateString()}`]
if (log.mileage) parts.push(vd.atKm(log.mileage.toLocaleString()))
const dueParts: string[] = []
if (log.nextDueAt) dueParts.push(new Date(log.nextDueAt).toLocaleDateString())
if (log.nextDueMileage != null) dueParts.push(`${log.nextDueMileage.toLocaleString()} km`)
if (dueParts.length) parts.push(`${vd.duePrefix} ${dueParts.join(` ${vd.orSep} `)}`)
if (currentMileage != null && log.nextDueMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
parts.push(`(${kmLeft > 0 ? vd.kmLeft(kmLeft.toLocaleString()) : vd.overdueByKm})`)
}
return parts.join(' ')
})()
return (
<div className="rounded-xl border border-slate-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-white hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Wrench className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{displayLabel}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{nextDueText}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusPill status={status} />
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{vd.logBtn}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
{open && (
<form onSubmit={handleSubmit} className="border-t border-slate-100 bg-slate-50 px-4 py-4 space-y-3">
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.serviceDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.odometerAtService}</label>
<input type="number" className="input-field text-sm" placeholder={currentMileage ? String(currentMileage) : 'e.g. 52000'} min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueKmLabel}</label>
<input type="number" className="input-field text-sm" placeholder="e.g. 60000" min="0" value={form.nextDueMileage} onChange={(e) => setForm({ ...form, nextDueMileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.costLabel}</label>
<input type="number" className="input-field text-sm" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.maintenanceNotesLabel}</label>
<input className="input-field text-sm" placeholder={vd.optional} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)} className="btn-secondary text-xs py-1.5 px-3">{vd.cancelBtn}</button>
<button type="submit" disabled={saving} className="btn-primary text-xs py-1.5 px-3">{saving ? vd.savingLabel : vd.saveLabel}</button>
</div>
</form>
)}
</div>
)
}
type Tab = 'details' | 'maintenance' | 'calendar'
export default function FleetDetailPage() { export default function FleetDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const router = useRouter() const router = useRouter()
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const fl = dict.fleet
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null) const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<Tab>('details') const [activeTab, setActiveTab] = useState<Tab>('details')
@@ -78,7 +278,6 @@ export default function FleetDetailPage() {
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
// edit form state
const [form, setForm] = useState<{ const [form, setForm] = useState<{
make: string; model: string; year: number; category: string make: string; model: string; year: number; category: string
dailyRate: string; licensePlate: string; color: string dailyRate: string; licensePlate: string; color: string
@@ -89,7 +288,16 @@ export default function FleetDetailPage() {
const [modelSelect, setModelSelect] = useState('') const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('') const [colorSelect, setColorSelect] = useState('')
// photo management const [maintenanceLogs, setMaintenanceLogs] = useState<MaintenanceLog[]>([])
const fetchMaintenance = () => {
apiFetch<MaintenanceLog[]>(`/vehicles/${params.id}/maintenance`)
.then((logs) => setMaintenanceLogs(logs ?? []))
.catch(() => {})
}
useEffect(() => { fetchMaintenance() }, [params.id])
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null) const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([]) const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([]) const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
@@ -105,18 +313,13 @@ export default function FleetDetailPage() {
const startEdit = () => { const startEdit = () => {
if (!vehicle) return if (!vehicle) return
setForm({ setForm({
make: vehicle.make, make: vehicle.make, model: vehicle.model, year: vehicle.year,
model: vehicle.model,
year: vehicle.year,
category: vehicle.category, category: vehicle.category,
dailyRate: (vehicle.dailyRate / 100).toFixed(2), dailyRate: (vehicle.dailyRate / 100).toFixed(2),
licensePlate: vehicle.licensePlate, licensePlate: vehicle.licensePlate,
color: vehicle.color ?? '', color: vehicle.color ?? '', seats: vehicle.seats,
seats: vehicle.seats, transmission: vehicle.transmission, fuelType: vehicle.fuelType,
transmission: vehicle.transmission, status: vehicle.status, notes: vehicle.notes ?? '',
fuelType: vehicle.fuelType,
status: vehicle.status,
notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '', mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
}) })
setMakeSelect(initMakeSelect(vehicle.make)) setMakeSelect(initMakeSelect(vehicle.make))
@@ -137,11 +340,11 @@ export default function FleetDetailPage() {
const handleSave = async () => { const handleSave = async () => {
if (!form || !vehicle) return if (!form || !vehicle) return
if (!form.make) { setSaveError('Make is required.'); return } if (!form.make) { setSaveError(vd.makeRequired); return }
if (!form.model) { setSaveError('Model is required.'); return } if (!form.model) { setSaveError(vd.modelRequired); return }
if (!form.licensePlate) { setSaveError('License plate is required.'); return } if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
const rate = parseFloat(form.dailyRate) 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) setSaving(true)
setSaveError(null) setSaveError(null)
@@ -149,17 +352,11 @@ export default function FleetDetailPage() {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, { const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ body: JSON.stringify({
make: form.make, make: form.make, model: form.model, year: Number(form.year),
model: form.model, category: form.category, dailyRate: Math.round(rate * 100),
year: Number(form.year), licensePlate: form.licensePlate, color: form.color,
category: form.category, seats: Number(form.seats), transmission: form.transmission,
dailyRate: Math.round(rate * 100), fuelType: form.fuelType, status: form.status,
licensePlate: form.licensePlate,
color: form.color,
seats: Number(form.seats),
transmission: form.transmission,
fuelType: form.fuelType,
status: form.status,
notes: form.notes || undefined, notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined, mileage: form.mileage ? Number(form.mileage) : undefined,
}), }),
@@ -181,7 +378,7 @@ export default function FleetDetailPage() {
setEditing(false) setEditing(false)
setForm(null) setForm(null)
} catch (err: any) { } catch (err: any) {
setSaveError(err.message ?? 'Failed to save changes.') setSaveError(err.message ?? vd.failedSave)
setUploadingPhotos(false) setUploadingPhotos(false)
} finally { } finally {
setSaving(false) setSaving(false)
@@ -204,7 +401,7 @@ export default function FleetDetailPage() {
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []) const files = Array.from(e.target.files ?? [])
if (!files.length) return if (!files.length) return
const totalExisting = (vehicle?.photos.length ?? 0) const totalExisting = vehicle?.photos.length ?? 0
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting) const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined) setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f))) setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
@@ -217,12 +414,17 @@ export default function FleetDetailPage() {
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i)) setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
} }
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div> if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">Loading vehicle</div> if (!vehicle) return <div className="card p-6 text-sm text-slate-500">{vd.loadingVehicle}</div>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@@ -233,21 +435,21 @@ export default function FleetDetailPage() {
</button> </button>
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2> <h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p> <p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {statusLabel}</p>
</div> </div>
</div> </div>
{!editing ? ( {!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2"> <button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> Edit <Edit className="w-4 h-4" /> {vd.editBtn}
</button> </button>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2"> <button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> Cancel <X className="w-4 h-4" /> {vd.cancelBtn}
</button> </button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2"> <button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
{saving ? (uploadingPhotos ? 'Uploading photos…' : 'Saving…') : 'Save'} {saving ? (uploadingPhotos ? vd.uploadingPhotosBtn : vd.savingBtn) : vd.saveBtn}
</button> </button>
</div> </div>
)} )}
@@ -259,271 +461,276 @@ export default function FleetDetailPage() {
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800"> <div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
<button {(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => (
onClick={() => setActiveTab('details')} <button
className={[ key={tab}
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors', onClick={() => setActiveTab(tab)}
activeTab === 'details' className={[
? 'border-blue-500 text-blue-600' 'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300', activeTab === tab
].join(' ')} ? 'border-blue-500 text-blue-600'
> : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
<Info className="w-4 h-4" /> Details ].join(' ')}
</button> >
<button {tab === 'details' && <><Info className="w-4 h-4" /> {vd.tabDetails}</>}
onClick={() => setActiveTab('calendar')} {tab === 'maintenance' && (
className={[ <>
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors', <Wrench className="w-4 h-4" /> {vd.tabMaintenance}
activeTab === 'calendar' {maintenanceLogs.length === 0 && <span className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-amber-100 text-amber-700 text-[10px] font-bold">!</span>}
? 'border-blue-500 text-blue-600' </>
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300', )}
].join(' ')} {tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
> </button>
<CalendarDays className="w-4 h-4" /> Calendar ))}
</button>
</div> </div>
{activeTab === 'details' && ( {activeTab === 'details' && (
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]"> <div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
{/* Photos */} {/* Photos */}
<div className="card p-6"> <div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3> <h3 className="text-base font-semibold text-slate-900 mb-4">{vd.photosHeading}</h3>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{/* Existing photos */} {vehicle.photos.map((photo, index) => (
{vehicle.photos.map((photo, index) => ( <div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group"> <Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} /> {editing && (
{editing && ( <button
onClick={() => handleDeletePhoto(index)}
disabled={deletingPhotoIdx === index}
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
>
{deletingPhotoIdx === index
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
{editing && newPhotoPreviews.map((src, i) => (
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
<button <button
onClick={() => handleDeletePhoto(index)} onClick={() => removeNewPhoto(i)}
disabled={deletingPhotoIdx === index} className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
> >
{deletingPhotoIdx === index <X className="w-3.5 h-3.5" />
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button> </button>
)} <span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">{vd.newPhotoBadge}</span>
</div> </div>
))} ))}
{/* New photo previews (edit mode) */} {vehicle.photos.length === 0 && !editing && (
{editing && newPhotoPreviews.map((src, i) => ( <div className="text-sm text-slate-400">{vd.noPhotosYet}</div>
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400"> )}
{/* eslint-disable-next-line @next/next/no-img-element */} </div>
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button <button
onClick={() => removeNewPhoto(i)} type="button"
className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
> >
<X className="w-3.5 h-3.5" /> <Upload className="w-4 h-4" />
{totalPhotos === 0 ? vd.clickToAddPhotos : vd.addMorePhotos(totalPhotos)}
</button> </button>
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">New</span>
</div> </div>
))} )}
{editing && totalPhotos >= 10 && (
{vehicle.photos.length === 0 && !editing && ( <p className="mt-3 text-xs text-slate-400 text-center">{vd.maxPhotosReached}</p>
<div className="text-sm text-slate-400">No photos uploaded yet.</div>
)} )}
</div> </div>
{/* Upload new photos (edit mode) */} {/* Details / Edit form */}
{editing && totalPhotos < 10 && ( <div className="card p-6">
<div className="mt-4"> <h3 className="text-base font-semibold text-slate-900 mb-4">{vd.vehicleDetailsHeading}</h3>
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? 'Click to add photos' : `Add more photos (${totalPhotos}/10)`}
</button>
</div>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">Maximum 10 photos reached.</p>
)}
</div>
{/* Details / Edit form */} {!editing || !form ? (
<div className="card p-6"> <dl className="space-y-3 text-sm">
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3> <div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
{!editing || !form ? ( <div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
<dl className="space-y-3 text-sm"> <div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div> <div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div> <div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
<div><dt className="text-slate-500">Status</dt><dd className="text-slate-900">{vehicle.status}</dd></div> <div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div> <div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div> <div><dt className="text-slate-500">{vd.labelNotes}</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div> </dl>
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div> ) : (
<div><dt className="text-slate-500">Odometer</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div> <div className="space-y-4 text-sm">
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div> {/* Make */}
</dl>
) : (
<div className="space-y-4 text-sm">
{/* Make */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">Select make</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type make…" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)}
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
{!presetModels ? (
<input className="input-field" placeholder="Model" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">Select model</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type model…" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.makeLabel}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
{/* Status */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Status</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
<select <select
className="input-field" className="input-field"
value={colorSelect} value={makeSelect}
onChange={(e) => { onChange={(e) => {
setColorSelect(e.target.value) setMakeSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value }) setModelSelect('')
else setForm({ ...form, color: '' }) if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}} }}
> >
<option value="">Select color</option> <option value="">{vd.selectMake}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)} {PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option> <option value="custom">{vd.other}</option>
</select> </select>
{colorSelect === 'custom' && ( {makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} /> <input className="input-field mt-2" placeholder={vd.typeMake} value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)} )}
</div> </div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
</div>
{/* Transmission & Fuel */} {/* Model */}
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.modelLabel}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}> {!presetModels ? (
<option value="MANUAL">Manual</option> <input className="input-field" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
<option value="AUTOMATIC">Automatic</option> ) : (
</select> <>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{vd.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div> </div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.yearLabel}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel.replace('Type', '').trim() !== '' ? vd.fuelTypeLabel : fl.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{fl.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.licensePlateLabel}</label>
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
{/* Status */}
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.statusLabel}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}> <select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => ( {(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'] as const).map((s) => (
<option key={f} value={f}>{f}</option> <option key={s} value={s}>{fl.statusLabels[s]}</option>
))} ))}
</select> </select>
</div> </div>
</div>
{/* Odometer */} {/* Color & Seats */}
<div> <div className="grid grid-cols-2 gap-3">
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label> <div>
<input <label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.colorLabel}</label>
type="number" <select
className="input-field" className="input-field"
placeholder="e.g. 45000" value={colorSelect}
min="0" onChange={(e) => {
value={form.mileage} setColorSelect(e.target.value)
onChange={(e) => setForm({ ...form, mileage: e.target.value })} if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
/> else setForm({ ...form, color: '' })
</div> }}
>
<option value="">{vd.selectColor}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{vd.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.seatsLabel}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
</div>
{/* Notes */} {/* Transmission & Fuel */}
<div> <div className="grid grid-cols-2 gap-3">
<label className="block text-xs font-medium text-slate-500 mb-1.5">Notes</label> <div>
<textarea <label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.transmissionLabel}</label>
className="input-field resize-none" <select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
rows={3} <option value="MANUAL">{fl.manual}</option>
value={form.notes} <option value="AUTOMATIC">{fl.automatic}</option>
onChange={(e) => setForm({ ...form, notes: e.target.value })} </select>
placeholder="Optional notes…" </div>
/> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((f) => (
<option key={f} value={f}>{fl.fuelTypeLabels[f]}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.odometerLabel}</label>
<input type="number" className="input-field" placeholder="e.g. 45000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.notesLabel}</label>
<textarea className="input-field resize-none" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={vd.optionalNotes} />
</div>
</div> </div>
</div> )}
)} </div>
</div> </div>
)}
{activeTab === 'maintenance' && (
<div className="space-y-3">
<p className="text-sm text-slate-500">{vd.maintenanceIntro}</p>
{ROUTINE_TYPES.map(({ key, intervalMonths }) => {
const latest = maintenanceLogs
.filter((l) => l.type === key)
.sort((a, b) => new Date(b.performedAt).getTime() - new Date(a.performedAt).getTime())[0]
return (
<MaintenanceRow
key={key}
vehicleId={params.id}
typeKey={key}
intervalMonths={intervalMonths}
currentMileage={vehicle.mileage}
log={latest}
onLogged={fetchMaintenance}
/>
)
})}
</div> </div>
)} )}
@@ -1,11 +1,12 @@
'use client' 'use client'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X } from 'lucide-react' import { Plus, Edit, Eye, Search, Upload, X, ChevronDown } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Vehicle { interface Vehicle {
id: string id: string
@@ -33,6 +34,193 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
OUT_OF_SERVICE: 'badge-red', OUT_OF_SERVICE: 'badge-red',
} }
const ALL_STATUSES: Vehicle['status'][] = ['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']
interface MaintenanceModalProps {
vehicleId: string
vehicleName: string
open: boolean
onClose: () => void
onSaved: () => void
}
function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: MaintenanceModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const today = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const reset = () => {
setForm({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.type) { setError(f.maintenanceTypeRequired); return }
setLoading(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: form.type,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
}),
})
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToSave)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-1">{f.logMaintenance}</h2>
<p className="text-sm text-slate-500 mb-5">{f.maintenanceSubtitle(vehicleName)}</p>
{error && <div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.typeLabel}</label>
<input className="input-field" placeholder={f.typePlaceholder} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.description}</label>
<textarea className="input-field resize-none" rows={2} placeholder={f.descriptionPlaceholder} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.date}</label>
<input type="date" className="input-field" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.nextDueDate}</label>
<input type="date" className="input-field" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.cost}</label>
<input type="number" className="input-field" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input type="number" className="input-field" placeholder="e.g. 52000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.skip}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? f.saving : f.saveLog}</button>
</div>
</form>
</div>
</div>
)
}
function StatusDropdown({ vehicleId, status, onStatusChange }: {
vehicleId: string
status: Vehicle['status']
onStatusChange: (id: string, status: Vehicle['status']) => void
}) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const openDropdown = () => {
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(true)
}
useEffect(() => {
if (!open) return
const close = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!panelRef.current?.contains(e.target as Node)
) setOpen(false)
}
const closeOnScroll = () => setOpen(false)
document.addEventListener('mousedown', close)
window.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
window.removeEventListener('scroll', closeOnScroll, true)
}
}, [open])
const select = async (next: Vehicle['status']) => {
if (next === status) { setOpen(false); return }
setOpen(false)
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
alert(err.message)
} finally {
setSaving(false)
}
}
return (
<>
<button
ref={triggerRef}
onClick={openDropdown}
disabled={saving}
className={`inline-flex items-center gap-1 ${STATUS_BADGE[status]} cursor-pointer select-none`}
>
{saving ? '…' : f.statusLabels[status]}
<ChevronDown className="w-3 h-3 opacity-60" />
</button>
{open && (
<div
ref={panelRef}
style={{ top: pos.top, left: pos.left }}
className="fixed z-50 min-w-[160px] rounded-xl border border-slate-100 bg-white shadow-lg py-1"
>
{ALL_STATUSES.map((s) => (
<button
key={s}
onClick={() => select(s)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-start hover:bg-slate-50 transition-colors ${s === status ? 'font-semibold' : ''}`}
>
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
s === 'AVAILABLE' ? 'bg-green-500' :
s === 'RENTED' ? 'bg-blue-500' :
s === 'MAINTENANCE' ? 'bg-amber-500' : 'bg-red-500'
}`} />
{f.statusLabels[s]}
</button>
))}
</div>
)}
</>
)
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold'] const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo'] const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = { const MODELS_BY_MAKE: Record<string, string[]> = {
@@ -61,6 +249,8 @@ const MODELS_BY_MAKE: Record<string, string[]> = {
} }
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) { function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [form, setForm] = useState({ const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
@@ -80,7 +270,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
if (!files.length) return if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10) const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined) setPhotoFiles(combined)
setPhotoPreviews(combined.map((f) => URL.createObjectURL(f))) setPhotoPreviews(combined.map((file) => URL.createObjectURL(file)))
e.target.value = '' e.target.value = ''
} }
@@ -107,10 +297,10 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!form.make) { setError('Please select or enter a make.'); return } if (!form.make) { setError(f.makeMissing); return }
if (!form.model) { setError('Please select or enter a model.'); return } if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError('Please enter a license plate.'); return } if (!form.licensePlate) { setError(f.plateMissing); return }
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError('Please enter a valid daily rate.'); return } if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError(f.rateMissing); return }
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@@ -126,13 +316,13 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
}) })
if (photoFiles.length > 0) { if (photoFiles.length > 0) {
const fd = new FormData() const fd = new FormData()
photoFiles.forEach((f) => fd.append('photos', f)) photoFiles.forEach((file) => fd.append('photos', file))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd }) await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
} }
onSaved() onSaved()
handleClose() handleClose()
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.') setError(err.message ?? f.failedToAdd)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -145,7 +335,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-slate-900 mb-5">Add New Vehicle</h2> <h2 className="text-lg font-semibold text-slate-900 mb-5">{f.addNewVehicle}</h2>
{error && ( {error && (
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div> <div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)} )}
@@ -153,7 +343,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Make & Model */} {/* Make & Model */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.make}</label>
<select <select
className="input-field" className="input-field"
value={makeSelect} value={makeSelect}
@@ -164,18 +354,18 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
else setForm({ ...form, make: '', model: '' }) else setForm({ ...form, make: '', model: '' })
}} }}
> >
<option value="">Select make</option> <option value="">{f.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)} {PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option> <option value="custom">{f.other}</option>
</select> </select>
{makeSelect === 'custom' && ( {makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type make…" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus /> <input className="input-field mt-2" placeholder={f.typeMake} required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
)} )}
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.model}</label>
{!presetModels ? ( {!presetModels ? (
<input className="input-field" placeholder="Model" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} /> <input className="input-field" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : ( ) : (
<> <>
<select <select
@@ -187,12 +377,12 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
else setForm({ ...form, model: '' }) else setForm({ ...form, model: '' })
}} }}
> >
<option value="">Select model</option> <option value="">{f.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)} {presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">Other</option> <option value="custom">{f.other}</option>
</select> </select>
{modelSelect === 'custom' && ( {modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type model…" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus /> <input className="input-field mt-2" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)} )}
</> </>
)} )}
@@ -202,14 +392,14 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Year & Category */} {/* Year & Category */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.year}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} /> <input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}> <select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => ( {(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{c}</option> <option key={c} value={c}>{f.categoryLabels[c]}</option>
))} ))}
</select> </select>
</div> </div>
@@ -218,11 +408,11 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Daily Rate & License Plate */} {/* Daily Rate & License Plate */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dailyRate}</label>
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} /> <input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} /> <input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div> </div>
</div> </div>
@@ -230,7 +420,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Color, Seats, Transmission */} {/* Color, Seats, Transmission */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.color}</label>
<select <select
className="input-field" className="input-field"
value={colorSelect} value={colorSelect}
@@ -240,23 +430,23 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
else setForm({ ...form, color: '' }) else setForm({ ...form, color: '' })
}} }}
> >
<option value="">Select</option> <option value="">{f.select}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)} {PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">Other</option> <option value="custom">{f.other}</option>
</select> </select>
{colorSelect === 'custom' && ( {colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus /> <input className="input-field mt-2" placeholder={f.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
)} )}
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.seats}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} /> <input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.transmission}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}> <select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">Manual</option> <option value="MANUAL">{f.manual}</option>
<option value="AUTOMATIC">Automatic</option> <option value="AUTOMATIC">{f.automatic}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -264,15 +454,15 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Fuel Type & Odometer */} {/* Fuel Type & Odometer */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.fuelType}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}> <select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => ( {(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((fuel) => (
<option key={f} value={f}>{f}</option> <option key={fuel} value={fuel}>{f.fuelTypeLabels[fuel]}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input <input
type="number" type="number"
className="input-field" className="input-field"
@@ -286,7 +476,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
{/* Photos */} {/* Photos */}
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Photos (up to 10)</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
{photoPreviews.length > 0 && ( {photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3"> <div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => ( {photoPreviews.map((src, i) => (
@@ -319,16 +509,16 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors" className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
{photoFiles.length === 0 ? 'Click to add photos' : 'Add more photos'} {photoFiles.length === 0 ? f.clickToAddPhotos : f.addMorePhotos}
</button> </button>
</> </>
)} )}
</div> </div>
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">Cancel</button> <button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.cancel}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1"> <button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? 'Adding…' : 'Add Vehicle'} {loading ? f.adding : f.addVehicle}
</button> </button>
</div> </div>
</form> </form>
@@ -338,6 +528,8 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
} }
export default function FleetPage() { export default function FleetPage() {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [vehicles, setVehicles] = useState<Vehicle[]>([]) const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -346,6 +538,7 @@ export default function FleetPage() {
const [statusFilter, setStatusFilter] = useState('') const [statusFilter, setStatusFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('') const [categoryFilter, setCategoryFilter] = useState('')
const [publishedFilter, setPublishedFilter] = useState('') const [publishedFilter, setPublishedFilter] = useState('')
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
const fetchVehicles = () => { const fetchVehicles = () => {
setLoading(true) setLoading(true)
@@ -366,6 +559,16 @@ export default function FleetPage() {
} }
} }
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = status !== 'MAINTENANCE' && status !== 'OUT_OF_SERVICE'
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
const vehicle = vehicles.find((v) => v.id === id)
if (vehicle) setMaintenanceModal({ vehicleId: id, vehicleName: `${vehicle.make} ${vehicle.model}` })
}
}
const filtered = vehicles.filter((v) => { const filtered = vehicles.filter((v) => {
if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false
if (statusFilter && v.status !== statusFilter) return false if (statusFilter && v.status !== statusFilter) return false
@@ -380,11 +583,11 @@ export default function FleetPage() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Fleet</h2> <h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
<p className="text-sm text-slate-500 mt-0.5">{vehicles.length} vehicle{vehicles.length !== 1 ? 's' : ''} total</p> <p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
</div> </div>
<button onClick={() => setShowAddModal(true)} className="btn-primary"> <button onClick={() => setShowAddModal(true)} className="btn-primary">
<Plus className="w-4 h-4" /> Add Vehicle <Plus className="w-4 h-4" /> {f.addVehicle}
</button> </button>
</div> </div>
@@ -394,27 +597,27 @@ export default function FleetPage() {
<Search className="w-4 h-4 text-slate-400" /> <Search className="w-4 h-4 text-slate-400" />
<input <input
className="flex-1 text-sm outline-none placeholder:text-slate-400" className="flex-1 text-sm outline-none placeholder:text-slate-400"
placeholder="Search make, model, plate…" placeholder={f.searchPlaceholder}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
</div> </div>
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}> <select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">All statuses</option> <option value="">{f.allStatuses}</option>
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => ( {ALL_STATUSES.map((s) => (
<option key={s} value={s}>{s}</option> <option key={s} value={s}>{f.statusLabels[s]}</option>
))} ))}
</select> </select>
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}> <select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
<option value="">All categories</option> <option value="">{f.allCategories}</option>
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => ( {(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{c}</option> <option key={c} value={c}>{f.categoryLabels[c]}</option>
))} ))}
</select> </select>
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}> <select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
<option value="">All visibility</option> <option value="">{f.allVisibility}</option>
<option value="published">Published</option> <option value="published">{f.published}</option>
<option value="unpublished">Unpublished</option> <option value="unpublished">{f.unpublished}</option>
</select> </select>
</div> </div>
@@ -433,12 +636,12 @@ export default function FleetPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-slate-100 bg-slate-50"> <tr className="border-b border-slate-100 bg-slate-50">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th> <th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Category</th> <th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colCategory}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th> <th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colStatus}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Daily Rate</th> <th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colDailyRate}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Published</th> <th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colPublished}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Actions</th> <th className="text-end px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colActions}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@@ -450,7 +653,7 @@ export default function FleetPage() {
{vehicle.primaryPhoto ? ( {vehicle.primaryPhoto ? (
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} /> <Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div> <div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">{f.noPhoto}</div>
)} )}
</div> </div>
<div> <div>
@@ -460,32 +663,34 @@ export default function FleetPage() {
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="badge-gray">{vehicle.category}</span> <span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className={STATUS_BADGE[vehicle.status]}>{vehicle.status}</span> <StatusDropdown vehicleId={vehicle.id} status={vehicle.status} onStatusChange={changeStatus} />
</td> </td>
<td className="px-6 py-4 text-sm font-medium text-slate-900"> <td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}/day {formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<button <div className="flex items-center">
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)} <button
className={[ onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
>
<span
className={[ className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200', 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
vehicle.isPublished ? 'translate-x-5' : 'translate-x-0', vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')} ].join(' ')}
/> >
</button> <span
className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
vehicle.isPublished ? 'ltr:translate-x-5 rtl:-translate-x-5' : 'translate-x-0',
].join(' ')}
/>
</button>
</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"> <Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Link> </Link>
@@ -499,7 +704,7 @@ export default function FleetPage() {
{filtered.length === 0 && ( {filtered.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400"> <td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
{vehicles.length === 0 ? 'No vehicles yet. Add your first vehicle to get started.' : 'No vehicles match your filters.'} {vehicles.length === 0 ? f.noVehicles : f.noMatch}
</td> </td>
</tr> </tr>
)} )}
@@ -510,6 +715,15 @@ export default function FleetPage() {
</div> </div>
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} /> <AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
{maintenanceModal && (
<MaintenanceModal
vehicleId={maintenanceModal.vehicleId}
vehicleName={maintenanceModal.vehicleName}
open={true}
onClose={() => setMaintenanceModal(null)}
onSaved={() => setMaintenanceModal(null)}
/>
)}
</div> </div>
) )
} }
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type NotificationItem = { type NotificationItem = {
id: string id: string
@@ -30,12 +31,90 @@ const COMPANY_EVENTS = [
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH'] const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
export default function DashboardNotificationsPage() { export default function DashboardNotificationsPage() {
const { language } = useDashboardI18n()
const [notifications, setNotifications] = useState<NotificationItem[]>([]) const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({}) const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox') const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Notifications',
subtitle: 'Track in-app alerts and control delivery preferences for your team account.',
inbox: 'Inbox',
preferences: 'Preferences',
markAllRead: 'Mark all as read',
loading: 'Loading notifications…',
empty: 'No notifications yet.',
read: 'Read',
markRead: 'Mark as read',
event: 'Event',
savePreferences: 'Save preferences',
saving: 'Saving…',
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance à prévoir',
OFFER_EXPIRING: 'Offer expiring',
} as Record<string, string>,
},
fr: {
title: 'Notifications',
subtitle: 'Suivez les alertes de lapplication et gérez les préférences de diffusion de votre équipe.',
inbox: 'Boîte de réception',
preferences: 'Préférences',
markAllRead: 'Tout marquer comme lu',
loading: 'Chargement des notifications…',
empty: 'Aucune notification pour le moment.',
read: 'Lu',
markRead: 'Marquer comme lu',
event: 'Événement',
savePreferences: 'Enregistrer les préférences',
saving: 'Enregistrement…',
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de lenregistrement des préférences',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans lapplication', PUSH: 'Push' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin dessai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
} as Record<string, string>,
},
ar: {
title: 'الإشعارات',
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.',
inbox: 'صندوق الوارد',
preferences: 'التفضيلات',
markAllRead: 'تحديد الكل كمقروء',
loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات حالياً.',
read: 'مقروء',
markRead: 'تحديد كمقروء',
event: 'الحدث',
savePreferences: 'حفظ التفضيلات',
saving: 'جارٍ الحفظ…',
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
} as Record<string, string>,
},
}[language]
async function load() { async function load() {
setLoading(true) setLoading(true)
@@ -51,7 +130,7 @@ export default function DashboardNotificationsPage() {
) )
setPreferences(mapped) setPreferences(mapped)
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to load notifications') setError(err.message ?? copy.failedLoad)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -66,11 +145,13 @@ export default function DashboardNotificationsPage() {
setNotifications((current) => setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)), current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
) )
window.dispatchEvent(new Event('notifications:updated'))
} }
async function markAllRead() { async function markAllRead() {
await apiFetch('/notifications/company/read-all', { method: 'POST' }) await apiFetch('/notifications/company/read-all', { method: 'POST' })
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() }))) setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
window.dispatchEvent(new Event('notifications:updated'))
} }
async function savePreferences() { async function savePreferences() {
@@ -86,7 +167,7 @@ export default function DashboardNotificationsPage() {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to save preferences') setError(err.message ?? copy.failedSave)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -107,8 +188,8 @@ export default function DashboardNotificationsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-semibold text-slate-900">Notifications</h1> <h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1>
<p className="mt-1 text-sm text-slate-500">Track in-app alerts and control delivery preferences for your team account.</p> <p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -116,14 +197,14 @@ export default function DashboardNotificationsPage() {
onClick={() => setActiveTab('inbox')} onClick={() => setActiveTab('inbox')}
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'} className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
> >
Inbox {copy.inbox}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setActiveTab('preferences')} onClick={() => setActiveTab('preferences')}
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'} className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
> >
Preferences {copy.preferences}
</button> </button>
</div> </div>
</div> </div>
@@ -134,26 +215,26 @@ export default function DashboardNotificationsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end">
<button type="button" onClick={markAllRead} className="btn-secondary"> <button type="button" onClick={markAllRead} className="btn-secondary">
Mark all as read {copy.markAllRead}
</button> </button>
</div> </div>
{loading ? <div className="card p-6 text-sm text-slate-500">Loading notifications</div> : null} {loading ? <div className="card p-6 text-sm text-slate-500">{copy.loading}</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">No notifications yet.</div> : null} {!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">{copy.empty}</div> : null}
{!loading {!loading
? notifications.map((notification) => ( ? notifications.map((notification) => (
<article key={notification.id} className="card p-6"> <article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500"> <p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
{notification.type.replaceAll('_', ' ')} {copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')}
</p> </p>
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2> <h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
</div> </div>
{notification.readAt ? ( {notification.readAt ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Read</span> <span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{copy.read}</span>
) : ( ) : (
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary"> <button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
Mark as read {copy.markRead}
</button> </button>
)} )}
</div> </div>
@@ -169,10 +250,10 @@ export default function DashboardNotificationsPage() {
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<thead className="bg-slate-50"> <thead className="bg-slate-50">
<tr> <tr>
<th className="px-4 py-3 text-left font-semibold text-slate-600">Event</th> <th className="px-4 py-3 text-left font-semibold text-slate-600">{copy.event}</th>
{COMPANY_CHANNELS.map((channel) => ( {COMPANY_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600"> <th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
{channel.replace('_', ' ')} {copy.channels[channel] ?? channel.replace('_', ' ')}
</th> </th>
))} ))}
</tr> </tr>
@@ -180,7 +261,7 @@ export default function DashboardNotificationsPage() {
<tbody> <tbody>
{COMPANY_EVENTS.map((eventName) => ( {COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100"> <tr key={eventName} className="border-t border-slate-100">
<td className="px-4 py-3 font-medium text-slate-900">{eventName.replaceAll('_', ' ')}</td> <td className="px-4 py-3 font-medium text-slate-900">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
{COMPANY_CHANNELS.map((channel) => ( {COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center"> <td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input <input
@@ -198,7 +279,7 @@ export default function DashboardNotificationsPage() {
</div> </div>
<div className="flex justify-end border-t border-slate-100 p-4"> <div className="flex justify-end border-t border-slate-100 p-4">
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary"> <button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save preferences'} {saving ? copy.saving : copy.savePreferences}
</button> </button>
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OfferRow { interface OfferRow {
id: string id: string
@@ -18,8 +19,41 @@ interface OfferRow {
} }
export default function OffersPage() { export default function OffersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<OfferRow[]>([]) const [rows, setRows] = useState<OfferRow[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Offers',
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
ends: 'ends',
active: 'Active',
inactive: 'Inactive',
marketplace: 'Marketplace',
featured: 'Featured',
empty: 'No offers created yet.',
},
fr: {
title: 'Offres',
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
ends: 'expire le',
active: 'Actif',
inactive: 'Inactif',
marketplace: 'Marketplace',
featured: 'Mise en avant',
empty: 'Aucune offre créée pour le moment.',
},
ar: {
title: 'العروض',
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
ends: 'تنتهي في',
active: 'نشط',
inactive: 'غير نشط',
marketplace: 'السوق',
featured: 'مميز',
empty: 'لا توجد عروض حتى الآن.',
},
}[language]
useEffect(() => { useEffect(() => {
apiFetch<OfferRow[]>('/offers') apiFetch<OfferRow[]>('/offers')
@@ -30,8 +64,8 @@ export default function OffersPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Offers</h2> <h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">Promotions shown on your site and optionally on the marketplace.</p> <p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{rows.map((offer) => ( {rows.map((offer) => (
@@ -39,13 +73,13 @@ export default function OffersPage() {
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3> <h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-1">{offer.type} · ends {dayjs(offer.validUntil).format('MMM D, YYYY')}</p> <p className="text-sm text-slate-500 mt-1">{offer.type} · {copy.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
</div> </div>
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? 'Active' : 'Inactive'}</span> <span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? copy.active : copy.inactive}</span>
</div> </div>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">Marketplace</span>} {offer.isPublic && <span className="badge-blue">{copy.marketplace}</span>}
{offer.isFeatured && <span className="badge-purple">Featured</span>} {offer.isFeatured && <span className="badge-purple">{copy.featured}</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>} {offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
</div> </div>
<p className="mt-4 text-2xl font-bold text-slate-900"> <p className="mt-4 text-2xl font-bold text-slate-900">
@@ -54,7 +88,7 @@ export default function OffersPage() {
</div> </div>
))} ))}
{rows.length === 0 && !error && ( {rows.length === 0 && !error && (
<div className="card p-8 text-sm text-slate-400">No offers created yet.</div> <div className="card p-8 text-sm text-slate-400">{copy.empty}</div>
)} )}
</div> </div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>} {error && <div className="card p-6 text-sm text-red-600">{error}</div>}
@@ -6,6 +6,7 @@ import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react' import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OnlineReservation { interface OnlineReservation {
id: string id: string
@@ -71,6 +72,51 @@ function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: st
} }
export default function OnlineReservationsPage() { export default function OnlineReservationsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Online Reservations',
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
refresh: 'Refresh',
pendingApproval: 'Pending approval',
loading: 'Loading…',
allCaughtUp: 'All caught up — no pending requests.',
history: 'History',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
status: 'Status',
total: 'Total',
},
fr: {
title: 'Réservations en ligne',
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
refresh: 'Actualiser',
pendingApproval: 'En attente de validation',
loading: 'Chargement…',
allCaughtUp: 'Tout est à jour, aucune demande en attente.',
history: 'Historique',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
status: 'Statut',
total: 'Total',
},
ar: {
title: 'الحجوزات الإلكترونية',
subtitle: 'طلبات مقدمة من العملاء عبر السوق. قم بالتأكيد أو الرفض لكل طلب.',
refresh: 'تحديث',
pendingApproval: 'بانتظار الموافقة',
loading: 'جارٍ التحميل…',
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
history: 'السجل',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
status: 'الحالة',
total: 'الإجمالي',
},
}[language]
const [rows, setRows] = useState<OnlineReservation[]>([]) const [rows, setRows] = useState<OnlineReservation[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -144,15 +190,15 @@ export default function OnlineReservationsPage() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-blue-600" /> <Globe className="h-5 w-5 text-blue-600" />
<h2 className="text-xl font-semibold text-slate-900">Online Reservations</h2> <h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
{pending.length > 0 && ( {pending.length > 0 && (
<span className="rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span> <span className="rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
)} )}
</div> </div>
<p className="mt-1 text-sm text-slate-500">Requests submitted by customers through the marketplace. Confirm or decline each one.</p> <p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div> </div>
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50"> <button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
Refresh {copy.refresh}
</button> </button>
</div> </div>
@@ -163,16 +209,16 @@ export default function OnlineReservationsPage() {
{/* Pending section */} {/* Pending section */}
<section> <section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500"> <h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> Pending approval <Clock className="h-4 w-4" /> {copy.pendingApproval}
{pending.length > 0 && <span className="ml-1 text-amber-600">({pending.length})</span>} {pending.length > 0 && <span className="ml-1 text-amber-600">({pending.length})</span>}
</h3> </h3>
{loading ? ( {loading ? (
<div className="card p-8 text-center text-sm text-slate-400">Loading</div> <div className="card p-8 text-center text-sm text-slate-400">{copy.loading}</div>
) : pending.length === 0 ? ( ) : pending.length === 0 ? (
<div className="card flex flex-col items-center gap-3 p-10 text-center"> <div className="card flex flex-col items-center gap-3 p-10 text-center">
<CheckCircle className="h-10 w-10 text-emerald-400" /> <CheckCircle className="h-10 w-10 text-emerald-400" />
<p className="text-sm font-medium text-slate-600">All caught up no pending requests.</p> <p className="text-sm font-medium text-slate-600">{copy.allCaughtUp}</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
@@ -193,17 +239,17 @@ export default function OnlineReservationsPage() {
{!loading && history.length > 0 && ( {!loading && history.length > 0 && (
<section> <section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500"> <h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
History {copy.history}
</h3> </h3>
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-slate-100 bg-slate-50"> <tr className="border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Customer</th> <th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th> <th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Dates</th> <th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Status</th> <th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th> <th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-5 py-3" /> <th className="px-5 py-3" />
</tr> </tr>
</thead> </thead>
@@ -5,7 +5,7 @@ import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard' import StatCard from '@/components/ui/StatCard'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import dayjs from 'dayjs' import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
interface DashboardData { interface DashboardData {
@@ -45,26 +45,44 @@ const STATUS_COLORS: Record<string, string> = {
CANCELLED: 'badge-red', CANCELLED: 'badge-red',
} }
function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string; planName: string; trialEndsAt: string | null }) { function SubscriptionBanner({
status,
trialEndsAt,
trialEndsMsg,
trialActiveMsg,
pastDueMsg,
suspendedMsg,
manageBillingLabel,
}: {
status: string
trialEndsAt: string | null
trialEndsMsg: (date: string) => string
trialActiveMsg: string
pastDueMsg: string
suspendedMsg: string
manageBillingLabel: string
}) {
if (status === 'ACTIVE') return null if (status === 'ACTIVE') return null
const localeDate = trialEndsAt
? new Date(trialEndsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = { const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
TRIALING: { TRIALING: {
bg: 'bg-blue-50 border-blue-200 text-blue-800', bg: 'bg-blue-50 border-blue-200 text-blue-800',
icon: <Clock className="w-4 h-4" />, icon: <Clock className="w-4 h-4" />,
message: trialEndsAt message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
? `You're on a free trial. Trial ends ${dayjs(trialEndsAt).format('MMM D, YYYY')}.`
: "You're on a free trial.",
}, },
PAST_DUE: { PAST_DUE: {
bg: 'bg-amber-50 border-amber-200 text-amber-800', bg: 'bg-amber-50 border-amber-200 text-amber-800',
icon: <AlertTriangle className="w-4 h-4" />, icon: <AlertTriangle className="w-4 h-4" />,
message: 'Your payment is past due. Please update your billing information.', message: pastDueMsg,
}, },
SUSPENDED: { SUSPENDED: {
bg: 'bg-red-50 border-red-200 text-red-800', bg: 'bg-red-50 border-red-200 text-red-800',
icon: <XCircle className="w-4 h-4" />, icon: <XCircle className="w-4 h-4" />,
message: 'Your account has been suspended. Please contact support or renew your subscription.', message: suspendedMsg,
}, },
} }
@@ -76,13 +94,17 @@ function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string;
{config.icon} {config.icon}
<p className="text-sm font-medium">{config.message}</p> <p className="text-sm font-medium">{config.message}</p>
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline"> <a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
Manage billing {manageBillingLabel}
</a> </a>
</div> </div>
) )
} }
export default function DashboardPage() { export default function DashboardPage() {
const { dict, language } = useDashboardI18n()
const d = dict.dashboardPage
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -105,7 +127,7 @@ export default function DashboardPage() {
if (error) { if (error) {
return ( return (
<div className="card p-6 text-center"> <div className="card p-6 text-center">
<p className="text-red-600 font-medium">Failed to load dashboard</p> <p className="text-red-600 font-medium">{d.failedToLoad}</p>
<p className="text-slate-500 text-sm mt-1">{error}</p> <p className="text-slate-500 text-sm mt-1">{error}</p>
</div> </div>
) )
@@ -113,46 +135,59 @@ export default function DashboardPage() {
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 } const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{data?.subscription && ( {data?.subscription && (
<SubscriptionBanner <SubscriptionBanner
status={data.subscription.status} status={data.subscription.status}
planName={data.subscription.planName}
trialEndsAt={data.subscription.trialEndsAt} trialEndsAt={data.subscription.trialEndsAt}
trialEndsMsg={d.trialEnds}
trialActiveMsg={d.trialActive}
pastDueMsg={d.pastDueMsg}
suspendedMsg={d.suspendedMsg}
manageBillingLabel={d.manageBilling}
/> />
)} )}
{/* KPI Cards */} {/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
title="Total Bookings" title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()} value={kpis.totalBookings.toLocaleString()}
change={kpis.bookingsChange} change={kpis.bookingsChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Calendar} icon={Calendar}
iconColor="text-blue-600" iconColor="text-blue-600"
iconBg="bg-blue-50" iconBg="bg-blue-50"
/> />
<StatCard <StatCard
title="Active Vehicles" title={d.kpiActiveVehicles}
value={kpis.activeVehicles.toLocaleString()} value={kpis.activeVehicles.toLocaleString()}
change={kpis.vehiclesChange} change={kpis.vehiclesChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Car} icon={Car}
iconColor="text-green-600" iconColor="text-green-600"
iconBg="bg-green-50" iconBg="bg-green-50"
/> />
<StatCard <StatCard
title="Monthly Revenue" title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')} value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange} change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign} icon={DollarSign}
iconColor="text-amber-600" iconColor="text-amber-600"
iconBg="bg-amber-50" iconBg="bg-amber-50"
/> />
<StatCard <StatCard
title="Total Customers" title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()} value={kpis.totalCustomers.toLocaleString()}
change={kpis.customersChange} change={kpis.customersChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Users} icon={Users}
iconColor="text-purple-600" iconColor="text-purple-600"
iconBg="bg-purple-50" iconBg="bg-purple-50"
@@ -162,7 +197,7 @@ export default function DashboardPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Booking Sources Chart */} {/* Booking Sources Chart */}
<div className="card p-6 lg:col-span-2"> <div className="card p-6 lg:col-span-2">
<h2 className="text-base font-semibold text-slate-900 mb-4">Booking Sources</h2> <h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? ( {data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>
<BarChart data={data.sourceBreakdown}> <BarChart data={data.sourceBreakdown}>
@@ -173,20 +208,20 @@ export default function DashboardPage() {
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }} contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
/> />
<Legend wrapperStyle={{ fontSize: '12px' }} /> <Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="count" fill="#3b82f6" name="Bookings" radius={[4, 4, 0, 0]} /> <Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#10b981" name="Revenue (centimes)" radius={[4, 4, 0, 0]} /> <Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<div className="h-48 flex items-center justify-center text-slate-400 text-sm"> <div className="h-48 flex items-center justify-center text-slate-400 text-sm">
No booking data available yet {d.noBookingData}
</div> </div>
)} )}
</div> </div>
{/* Quick stats */} {/* Quick stats */}
<div className="card p-6"> <div className="card p-6">
<h2 className="text-base font-semibold text-slate-900 mb-4">Quick Stats</h2> <h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2>
<div className="space-y-4"> <div className="space-y-4">
{(data?.sourceBreakdown ?? []).map((item) => ( {(data?.sourceBreakdown ?? []).map((item) => (
<div key={item.source} className="flex items-center justify-between"> <div key={item.source} className="flex items-center justify-between">
@@ -198,7 +233,7 @@ export default function DashboardPage() {
</div> </div>
))} ))}
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && ( {(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
<p className="text-sm text-slate-400">No data yet</p> <p className="text-sm text-slate-400">{d.noData}</p>
)} )}
</div> </div>
</div> </div>
@@ -207,21 +242,21 @@ export default function DashboardPage() {
{/* Recent Reservations */} {/* Recent Reservations */}
<div className="card"> <div className="card">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between"> <div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">Recent Reservations</h2> <h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
<a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium"> <a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
View all {d.viewAll}
</a> </a>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-slate-100"> <tr className="border-b border-slate-100">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Booking #</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th> <th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@@ -235,11 +270,11 @@ export default function DashboardPage() {
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td> <td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td> <td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
<td className="px-6 py-3 text-sm text-slate-500"> <td className="px-6 py-3 text-sm text-slate-500">
{dayjs(res.startDate).format('MMM D')} {dayjs(res.endDate).format('MMM D, YYYY')} {formatDate(res.startDate)} {formatDateYear(res.endDate)}
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}> <span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
{res.status} {d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
</span> </span>
</td> </td>
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right"> <td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
@@ -250,7 +285,7 @@ export default function DashboardPage() {
{(!data?.recentReservations || data.recentReservations.length === 0) && ( {(!data?.recentReservations || data.recentReservations.length === 0) && (
<tr> <tr>
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400"> <td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
No reservations yet. Once customers book vehicles, they'll appear here. {d.noReservations}
</td> </td>
</tr> </tr>
)} )}
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api' import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow { interface ReportRow {
reservationId: string reservationId: string
@@ -30,18 +31,74 @@ interface ReportData {
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL' type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
const periodLabels: Record<ReportPeriod, string> = {
WEEKLY: 'Weekly',
MONTHLY: 'Monthly',
QUARTERLY: 'Quarterly',
ANNUAL: 'Annual',
}
export default function ReportsPage() { export default function ReportsPage() {
const { language } = useDashboardI18n()
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY') const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null) const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
const copy = {
en: {
title: 'Reports',
subtitle: 'Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.',
exportCsv: 'Export CSV',
exporting: 'Exporting…',
bookings: 'Bookings',
rental: 'Rental',
insurance: 'Insurance',
drivers: 'Drivers',
discounts: 'Discounts',
collected: 'Collected',
reservation: 'Reservation',
vehicle: 'Vehicle',
period: 'Period',
source: 'Source',
payment: 'Payment',
total: 'Total',
emptyRows: 'No report rows available for this period.',
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
},
fr: {
title: 'Rapports',
subtitle: 'Exports prêts pour le comptable avec totaux assurance, conducteurs supplémentaires et règles tarifaires.',
exportCsv: 'Exporter CSV',
exporting: 'Export en cours…',
bookings: 'Réservations',
rental: 'Location',
insurance: 'Assurance',
drivers: 'Conducteurs',
discounts: 'Remises',
collected: 'Collecté',
reservation: 'Réservation',
vehicle: 'Véhicule',
period: 'Période',
source: 'Source',
payment: 'Paiement',
total: 'Total',
emptyRows: 'Aucune ligne de rapport pour cette période.',
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
},
ar: {
title: 'التقارير',
subtitle: 'تصدير جاهز للمحاسبة مع إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات',
rental: 'الإيجار',
insurance: 'التأمين',
drivers: 'السائقون',
discounts: 'الخصومات',
collected: 'المحصّل',
reservation: 'الحجز',
vehicle: 'المركبة',
period: 'الفترة',
source: 'المصدر',
payment: 'الدفع',
total: 'الإجمالي',
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
},
}[language]
useEffect(() => { useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`) apiFetch<ReportData>(`/analytics/report?period=${period}`)
@@ -59,7 +116,7 @@ export default function ReportsPage() {
}) })
if (!res.ok) { if (!res.ok) {
const json = await res.json().catch(() => null) const json = await res.json().catch(() => null)
throw new Error(json?.message ?? 'Export failed') throw new Error(json?.message ?? copy.exportCsv)
} }
const csv = await res.text() const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
@@ -70,7 +127,7 @@ export default function ReportsPage() {
anchor.click() anchor.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Export failed') setError(err.message ?? copy.exportCsv)
} finally { } finally {
setExporting(false) setExporting(false)
} }
@@ -80,21 +137,21 @@ export default function ReportsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Reports</h2> <h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.</p> <p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(Object.keys(periodLabels) as ReportPeriod[]).map((option) => ( {(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
<button <button
key={option} key={option}
onClick={() => setPeriod(option)} onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-700'}`} className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-700'}`}
> >
{periodLabels[option]} {copy.periodLabels[option]}
</button> </button>
))} ))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900"> <button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? 'Exporting…' : 'Export CSV'} {exporting ? copy.exporting : copy.exportCsv}
</button> </button>
</div> </div>
</div> </div>
@@ -103,12 +160,12 @@ export default function ReportsPage() {
{report && ( {report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">Bookings</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div> <div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Rental</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div> <div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Insurance</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div> <div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Drivers</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div> <div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Discounts</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div> <div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">Collected</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div> <div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div> </div>
)} )}
@@ -117,12 +174,12 @@ export default function ReportsPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-slate-200 bg-slate-50"> <tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Reservation</th> <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.reservation}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th> <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Period</th> <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.period}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Source</th> <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Payment</th> <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th> <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@@ -138,7 +195,7 @@ export default function ReportsPage() {
))} ))}
{(report?.rows.length ?? 0) === 0 && ( {(report?.rows.length ?? 0) === 0 && (
<tr> <tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No report rows available for this period.</td> <td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -2,9 +2,9 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import dayjs from 'dayjs'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard' import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
interface ReservationDetail { interface ReservationDetail {
@@ -49,6 +49,10 @@ interface ReservationDetail {
export default function ReservationDetailPage() { export default function ReservationDetailPage() {
const params = useParams<{ id: string }>() const params = useParams<{ id: string }>()
const { dict, language } = useDashboardI18n()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [reservation, setReservation] = useState<ReservationDetail | null>(null) const [reservation, setReservation] = useState<ReservationDetail | null>(null)
const [inspections, setInspections] = useState<DamageInspection[]>([]) const [inspections, setInspections] = useState<DamageInspection[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -65,7 +69,7 @@ export default function ReservationDetailPage() {
setInspections(inspectionData) setInspections(inspectionData)
setError(null) setError(null)
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to load reservation') setError(err.message ?? r.failedToLoad)
} }
} }
@@ -76,6 +80,7 @@ export default function ReservationDetailPage() {
async function runAction(action: 'confirm' | 'checkin' | 'checkout') { async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
setActing(true) setActing(true)
setActionError(null) setActionError(null)
const errorMsg = action === 'confirm' ? r.failedConfirm : action === 'checkin' ? r.failedCheckIn : r.failedCheckOut
try { try {
await apiFetch(`/reservations/${params.id}/${action}`, { await apiFetch(`/reservations/${params.id}/${action}`, {
method: 'POST', method: 'POST',
@@ -83,7 +88,7 @@ export default function ReservationDetailPage() {
}) })
await loadReservation() await loadReservation()
} catch (err: any) { } catch (err: any) {
setActionError(err.message ?? `Failed to ${action} reservation`) setActionError(err.message ?? errorMsg)
} finally { } finally {
setActing(false) setActing(false)
} }
@@ -99,17 +104,20 @@ export default function ReservationDetailPage() {
}) })
await loadReservation() await loadReservation()
} catch (err: any) { } catch (err: any) {
setActionError(err.message ?? 'Failed to approve additional driver') setActionError(err.message ?? r.failedApproveDriver)
} finally { } finally {
setActing(false) setActing(false)
} }
} }
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div> const formatDate = (iso: string) =>
if (!reservation) return <div className="card p-6 text-sm text-slate-500">Loading reservation</div> new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN') if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT') if (!reservation) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
const checkinInspection = inspections.find((i) => i.type === 'CHECKIN')
const checkoutInspection = inspections.find((i) => i.type === 'CHECKOUT')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -121,17 +129,17 @@ export default function ReservationDetailPage() {
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
{reservation.status === 'DRAFT' && ( {reservation.status === 'DRAFT' && (
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary"> <button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
{acting ? 'Working…' : 'Confirm reservation'} {acting ? r.working : r.confirmReservation}
</button> </button>
)} )}
{reservation.status === 'CONFIRMED' && ( {reservation.status === 'CONFIRMED' && (
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary"> <button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
{acting ? 'Working…' : 'Check in vehicle'} {acting ? r.working : r.checkInVehicle}
</button> </button>
)} )}
{reservation.status === 'ACTIVE' && ( {reservation.status === 'ACTIVE' && (
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary"> <button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
{acting ? 'Working…' : 'Check out vehicle'} {acting ? r.working : r.checkOutVehicle}
</button> </button>
)} )}
</div> </div>
@@ -141,25 +149,25 @@ export default function ReservationDetailPage() {
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6"> <div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Customer</h3> <h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p> <p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
<p className="text-slate-600">{reservation.customer.email}</p> <p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? 'No phone provided'}</p> <p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
<p className="text-slate-600">License: {reservation.customer.driverLicense ?? 'Not captured'}</p> <p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span> <span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">Flagged</span>} {reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
</div> </div>
</div> </div>
</div> </div>
<div className="card p-6"> <div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Vehicle</h3> <h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionVehicle}</h3>
<div className="space-y-2 text-sm text-slate-600"> <div className="space-y-2 text-sm text-slate-600">
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p> <p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
<p>{reservation.vehicle.licensePlate}</p> <p>{reservation.vehicle.licensePlate}</p>
<p>{dayjs(reservation.startDate).format('MMM D, YYYY')} - {dayjs(reservation.endDate).format('MMM D, YYYY')}</p> <p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -167,18 +175,18 @@ export default function ReservationDetailPage() {
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6"> <div className="space-y-6">
<div className="card p-6"> <div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Charges</h3> <h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2"> <dl className="grid gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-slate-500">Discount</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div> <div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Insurance</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div> <div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Additional drivers</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div> <div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Pricing adjustments</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div> <div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">Grand total</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div> <div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
</dl> </dl>
{reservation.insurances.length > 0 && ( {reservation.insurances.length > 0 && (
<div className="mt-5"> <div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">Applied insurance</p> <p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
<div className="space-y-2"> <div className="space-y-2">
{reservation.insurances.map((insurance) => ( {reservation.insurances.map((insurance) => (
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm"> <div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
@@ -192,7 +200,7 @@ export default function ReservationDetailPage() {
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && ( {reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
<div className="mt-5"> <div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">Pricing rules applied</p> <p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
<div className="space-y-2"> <div className="space-y-2">
{reservation.pricingRulesApplied.map((rule) => ( {reservation.pricingRulesApplied.map((rule) => (
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm"> <div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
@@ -223,23 +231,23 @@ export default function ReservationDetailPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="card p-6"> <div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Additional drivers</h3> <h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
<div className="space-y-3"> <div className="space-y-3">
{reservation.additionalDrivers.map((driver) => ( {reservation.additionalDrivers.map((driver) => (
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4"> <div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p> <p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
<p className="text-sm text-slate-500">License: {driver.driverLicense}</p> <p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">Charge: {formatCurrency(driver.totalCharge, 'MAD')}</p> <p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
</div> </div>
{driver.requiresApproval && !driver.approvedAt ? ( {driver.requiresApproval && !driver.approvedAt ? (
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white"> <button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white">
Approve {r.approveBtn}
</button> </button>
) : ( ) : (
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}> <span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
{driver.approvedAt ? 'Approved' : 'No approval needed'} {driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
</span> </span>
)} )}
</div> </div>
@@ -247,21 +255,21 @@ export default function ReservationDetailPage() {
</div> </div>
))} ))}
{reservation.additionalDrivers.length === 0 && ( {reservation.additionalDrivers.length === 0 && (
<div className="text-sm text-slate-400">No additional drivers were added to this reservation.</div> <div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
)} )}
</div> </div>
</div> </div>
<div className="card p-6"> <div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">Inspection summary</h3> <h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">Check-in inspection</span> <span className="text-slate-600">{r.checkInInspectionLabel}</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? 'Saved' : 'Pending'}</span> <span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
</div> </div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3"> <div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">Check-out inspection</span> <span className="text-slate-600">{r.checkOutInspectionLabel}</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? 'Saved' : 'Pending'}</span> <span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1,10 +1,10 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import Link from 'next/link' import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types' import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReservationRow { interface ReservationRow {
id: string id: string
@@ -19,6 +19,10 @@ interface ReservationRow {
} }
export default function ReservationsPage() { export default function ReservationsPage() {
const { dict, language } = useDashboardI18n()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [rows, setRows] = useState<ReservationRow[]>([]) const [rows, setRows] = useState<ReservationRow[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -28,11 +32,16 @@ export default function ReservationsPage() {
.catch((err) => setError(err.message)) .catch((err) => setError(err.message))
}, []) }, [])
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Reservations</h2> <h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
<p className="text-sm text-slate-500 mt-1">All booking sources, including dashboard, public site, and marketplace.</p> <p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
</div> </div>
<div className="card overflow-hidden"> <div className="card overflow-hidden">
{error ? ( {error ? (
@@ -42,12 +51,12 @@ export default function ReservationsPage() {
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-slate-50 border-b border-slate-200"> <tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Source</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th> <th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100">
@@ -60,7 +69,7 @@ export default function ReservationsPage() {
<p className="text-xs text-slate-500">{row.customer.email}</p> <p className="text-xs text-slate-500">{row.customer.email}</p>
</td> </td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td> <td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
<td className="px-6 py-4 text-sm text-slate-500">{dayjs(row.startDate).format('MMM D')} - {dayjs(row.endDate).format('MMM D, YYYY')}</td> <td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td> <td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td> <td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td> <td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
@@ -68,7 +77,7 @@ export default function ReservationsPage() {
))} ))}
{rows.length === 0 && ( {rows.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No reservations yet.</td> <td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface BrandSettings { interface BrandSettings {
displayName: string displayName: string
@@ -70,6 +71,231 @@ const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeV
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true } const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
export default function SettingsPage() { export default function SettingsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Advanced settings',
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
save: 'Saving…',
saveBrand: 'Save brand',
brandProfile: 'Brand and public profile',
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
payments: 'Rental payment methods',
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
savePayments: 'Save payments',
customDomain: 'Custom domain',
customDomainDesc: 'Point your own domain to the branded booking site.',
saveDomain: 'Save domain',
removeDomain: 'Remove custom domain',
contractPolicies: 'Contract and driver policies',
savePolicies: 'Save policies',
insurancePolicies: 'Insurance policies',
pricingRules: 'Pricing rules',
accounting: 'Accounting and exports',
saveAccounting: 'Save accounting',
displayName: 'Display name',
tagline: 'Tagline',
publicEmail: 'Public email',
publicPhone: 'Public phone',
city: 'City',
country: 'Country',
websiteUrl: 'Website URL',
whatsapp: 'WhatsApp number',
brandColor: 'Brand color',
listedMarketplace: 'Listed on marketplace',
logoUpload: 'Logo upload',
heroUpload: 'Hero image upload',
uploading: 'Uploading…',
logoUploaded: 'Logo uploaded',
noLogo: 'No logo uploaded yet',
heroUploaded: 'Hero image uploaded',
noHero: 'No hero image uploaded yet',
subdomain: 'Subdomain',
customDomainLabel: 'Custom domain',
domainPlaceholder: 'cars.example.com',
status: 'Status',
verified: 'Verified',
pendingDns: 'Pending DNS verification',
notConfigured: 'Not configured',
fuelPolicyType: 'Fuel policy type',
additionalDriverCharge: 'Additional driver charge',
dailyDriverRate: 'Daily driver rate',
flatDriverRate: 'Flat driver rate',
fuelPolicyNote: 'Fuel policy note',
damagePolicy: 'Damage policy',
active: 'Active',
inactive: 'Inactive',
noInsurance: 'No insurance policies configured yet.',
policyName: 'Policy name',
chargeValue: 'Charge value',
required: 'Required',
addPolicy: 'Add policy',
noRules: 'No pricing rules configured yet.',
ruleName: 'Rule name',
addRule: 'Add rule',
reportingPeriod: 'Reporting period',
reportFormat: 'Report format',
accountantName: 'Accountant name',
accountantEmail: 'Accountant email',
autoSend: 'Auto-send reports to the accountant',
amanpayMerchantId: 'AmanPay merchant ID',
amanpaySecretKey: 'AmanPay secret key',
paypalEmail: 'PayPal business email',
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
},
fr: {
title: 'Paramètres avancés',
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
save: 'Enregistrement…',
saveBrand: 'Enregistrer la marque',
brandProfile: 'Marque et profil public',
brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la marketplace et le site de réservation.',
payments: 'Méthodes de paiement location',
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
savePayments: 'Enregistrer les paiements',
customDomain: 'Domaine personnalisé',
customDomainDesc: 'Pointez votre propre domaine vers le site de réservation.',
saveDomain: 'Enregistrer le domaine',
removeDomain: 'Supprimer le domaine',
contractPolicies: 'Contrat et politiques conducteur',
savePolicies: 'Enregistrer les politiques',
insurancePolicies: 'Polices dassurance',
pricingRules: 'Règles tarifaires',
accounting: 'Comptabilité et exports',
saveAccounting: 'Enregistrer la comptabilité',
displayName: 'Nom affiché',
tagline: 'Slogan',
publicEmail: 'Email public',
publicPhone: 'Téléphone public',
city: 'Ville',
country: 'Pays',
websiteUrl: 'URL du site',
whatsapp: 'Numéro WhatsApp',
brandColor: 'Couleur de marque',
listedMarketplace: 'Listé sur la marketplace',
logoUpload: 'Logo',
heroUpload: 'Image héro',
uploading: 'Téléversement…',
logoUploaded: 'Logo téléversé',
noLogo: 'Aucun logo téléversé',
heroUploaded: 'Image héro téléversée',
noHero: 'Aucune image héro téléversée',
subdomain: 'Sous-domaine',
customDomainLabel: 'Domaine personnalisé',
domainPlaceholder: 'voitures.exemple.com',
status: 'Statut',
verified: 'Vérifié',
pendingDns: 'Vérification DNS en attente',
notConfigured: 'Non configuré',
fuelPolicyType: 'Type de politique carburant',
additionalDriverCharge: 'Supplément conducteur additionnel',
dailyDriverRate: 'Tarif conducteur/jour',
flatDriverRate: 'Tarif conducteur fixe',
fuelPolicyNote: 'Note politique carburant',
damagePolicy: 'Politique dommages',
active: 'Actif',
inactive: 'Inactif',
noInsurance: 'Aucune police dassurance configurée.',
policyName: 'Nom de la police',
chargeValue: 'Valeur de facturation',
required: 'Obligatoire',
addPolicy: 'Ajouter la police',
noRules: 'Aucune règle tarifaire configurée.',
ruleName: 'Nom de la règle',
addRule: 'Ajouter la règle',
reportingPeriod: 'Période de reporting',
reportFormat: 'Format de rapport',
accountantName: 'Nom du comptable',
accountantEmail: 'Email du comptable',
autoSend: 'Envoyer automatiquement les rapports au comptable',
amanpayMerchantId: 'ID marchand AmanPay',
amanpaySecretKey: 'Clé secrète AmanPay',
paypalEmail: 'Email business PayPal',
noPaymentConfigured: 'Si aucun moyen de paiement nest configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
},
ar: {
title: 'الإعدادات المتقدمة',
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
save: 'جارٍ الحفظ…',
saveBrand: 'حفظ العلامة',
brandProfile: 'العلامة والملف العام',
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
payments: 'طرق دفع الإيجار',
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
savePayments: 'حفظ الدفع',
customDomain: 'نطاق مخصص',
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
saveDomain: 'حفظ النطاق',
removeDomain: 'إزالة النطاق المخصص',
contractPolicies: 'العقد وسياسات السائق',
savePolicies: 'حفظ السياسات',
insurancePolicies: 'سياسات التأمين',
pricingRules: 'قواعد التسعير',
accounting: 'المحاسبة والتصدير',
saveAccounting: 'حفظ المحاسبة',
displayName: 'اسم العرض',
tagline: 'الشعار',
publicEmail: 'البريد العام',
publicPhone: 'الهاتف العام',
city: 'المدينة',
country: 'الدولة',
websiteUrl: 'رابط الموقع',
whatsapp: 'رقم واتساب',
brandColor: 'لون العلامة',
listedMarketplace: 'مدرج في السوق',
logoUpload: 'رفع الشعار',
heroUpload: 'رفع صورة الواجهة',
uploading: 'جارٍ الرفع…',
logoUploaded: 'تم رفع الشعار',
noLogo: 'لم يتم رفع شعار بعد',
heroUploaded: 'تم رفع صورة الواجهة',
noHero: 'لم يتم رفع صورة واجهة بعد',
subdomain: 'النطاق الفرعي',
customDomainLabel: 'النطاق المخصص',
domainPlaceholder: 'cars.example.com',
status: 'الحالة',
verified: 'موثق',
pendingDns: 'بانتظار التحقق من DNS',
notConfigured: 'غير مُعدّ',
fuelPolicyType: 'نوع سياسة الوقود',
additionalDriverCharge: 'رسوم السائق الإضافي',
dailyDriverRate: 'تعرفة السائق اليومية',
flatDriverRate: 'تعرفة السائق الثابتة',
fuelPolicyNote: 'ملاحظة سياسة الوقود',
damagePolicy: 'سياسة الأضرار',
active: 'نشط',
inactive: 'غير نشط',
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
policyName: 'اسم السياسة',
chargeValue: 'قيمة الرسوم',
required: 'إلزامي',
addPolicy: 'إضافة سياسة',
noRules: 'لا توجد قواعد تسعير مهيأة.',
ruleName: 'اسم القاعدة',
addRule: 'إضافة قاعدة',
reportingPeriod: 'فترة التقرير',
reportFormat: 'تنسيق التقرير',
accountantName: 'اسم المحاسب',
accountantEmail: 'بريد المحاسب',
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
amanpayMerchantId: 'معرّف التاجر AmanPay',
amanpaySecretKey: 'المفتاح السري AmanPay',
paypalEmail: 'بريد أعمال PayPal',
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
},
}[language]
const [brand, setBrand] = useState<BrandSettings | null>(null) const [brand, setBrand] = useState<BrandSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null) const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([]) const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
@@ -299,8 +525,8 @@ export default function SettingsPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-xl font-semibold text-slate-900">Advanced settings</h2> <h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.</p> <p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div> </div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>} {error && <div className="card p-6 text-sm text-red-600">{error}</div>}
@@ -310,67 +536,67 @@ export default function SettingsPage() {
<div className="card p-6"> <div className="card p-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h3 className="text-base font-semibold text-slate-900">Brand and public profile</h3> <h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
<p className="mt-1 text-sm text-slate-500">Control how your company appears on the marketplace and public booking site.</p> <p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
</div> </div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary"> <button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save brand'} {saving ? copy.save : copy.saveBrand}
</button> </button>
</div> </div>
<div className="mt-5 grid gap-4 lg:grid-cols-2"> <div className="mt-5 grid gap-4 lg:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Display name</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} /> <input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Tagline</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} /> <input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public email</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} /> <input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public phone</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} /> <input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">City</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} /> <input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Country</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} /> <input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Website URL</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} /> <input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">WhatsApp number</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} /> <input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Brand color</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} /> <input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700"> <label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} /> <input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
Listed on marketplace {copy.listedMarketplace}
</label> </label>
</div> </div>
</div> </div>
<div className="mt-5 grid gap-4 lg:grid-cols-2"> <div className="mt-5 grid gap-4 lg:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Logo upload</span> <span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" /> <input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? 'Uploading…' : brand.logoUrl ? 'Logo uploaded' : 'No logo uploaded yet'}</p> <p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
</label> </label>
<label className="block"> <label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">Hero image upload</span> <span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" /> <input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? 'Uploading…' : brand.heroImageUrl ? 'Hero image uploaded' : 'No hero image uploaded yet'}</p> <p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
</label> </label>
</div> </div>
</div> </div>
@@ -379,28 +605,28 @@ export default function SettingsPage() {
<div className="card p-6"> <div className="card p-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h3 className="text-base font-semibold text-slate-900">Rental payment methods</h3> <h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
<p className="mt-1 text-sm text-slate-500">Configure how renters pay your company on the public booking site.</p> <p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
</div> </div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary"> <button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save payments'} {saving ? copy.save : copy.savePayments}
</button> </button>
</div> </div>
<div className="mt-5 grid gap-4"> <div className="mt-5 grid gap-4">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay merchant ID</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} /> <input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay secret key</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} /> <input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">PayPal business email</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} /> <input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600"> <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
If no payment method is configured, renters can still submit reservation requests and pay on pickup. {copy.noPaymentConfigured}
</div> </div>
</div> </div>
</div> </div>
@@ -408,28 +634,28 @@ export default function SettingsPage() {
<div className="card p-6"> <div className="card p-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<h3 className="text-base font-semibold text-slate-900">Custom domain</h3> <h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
<p className="mt-1 text-sm text-slate-500">Point your own domain to the branded booking site.</p> <p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
</div> </div>
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary"> <button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
{saving ? 'Saving…' : 'Save domain'} {saving ? copy.save : copy.saveDomain}
</button> </button>
</div> </div>
<div className="mt-5 space-y-4"> <div className="mt-5 space-y-4">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Subdomain</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div> <div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Custom domain</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
<input className="input-field" placeholder="cars.example.com" value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} /> <input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600"> <div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
Status: {brand.customDomain ? (brand.customDomainVerified ? 'Verified' : 'Pending DNS verification') : 'Not configured'} {copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
</div> </div>
{brand.customDomain ? ( {brand.customDomain ? (
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary"> <button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
Remove custom domain {copy.removeDomain}
</button> </button>
) : null} ) : null}
</div> </div>
@@ -441,38 +667,38 @@ export default function SettingsPage() {
{contractSettings && ( {contractSettings && (
<div className="card p-6"> <div className="card p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">Contract and driver policies</h3> <h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
<button onClick={saveContractSettings} disabled={saving} className="btn-primary"> <button onClick={saveContractSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save policies'} {saving ? copy.save : copy.savePolicies}
</button> </button>
</div> </div>
<div className="mt-5 grid gap-4 lg:grid-cols-2"> <div className="mt-5 grid gap-4 lg:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy type</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}> <select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{option}</option>)} {['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Additional driver charge</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}> <select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{option}</option>)} {['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Daily driver rate</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} /> <input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Flat driver rate</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} /> <input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
</div> </div>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy note</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} /> <textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
</div> </div>
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">Damage policy</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} /> <textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
</div> </div>
</div> </div>
@@ -481,7 +707,7 @@ export default function SettingsPage() {
<div className="grid gap-6 xl:grid-cols-2"> <div className="grid gap-6 xl:grid-cols-2">
<div className="card p-6"> <div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">Insurance policies</h3> <h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
{insurancePolicies.map((policy) => ( {insurancePolicies.map((policy) => (
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm"> <div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
@@ -490,31 +716,31 @@ export default function SettingsPage() {
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p> <p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
</div> </div>
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}> <button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
{policy.isActive ? 'Active' : 'Inactive'} {policy.isActive ? copy.active : copy.inactive}
</button> </button>
</div> </div>
))} ))}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">No insurance policies configured yet.</div>} {insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
</div> </div>
<div className="mt-5 grid gap-3 sm:grid-cols-2"> <div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder="Policy name" value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} /> <input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}> <select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)} {['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
</select> </select>
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}> <select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)} {['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select> </select>
<input type="number" className="input-field" placeholder="Charge value" value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} /> <input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
</div> </div>
<div className="mt-3 flex items-center gap-4 text-sm"> <div className="mt-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> Required</label> <label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add policy'}</button> <button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
</div> </div>
</div> </div>
<div className="card p-6"> <div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">Pricing rules</h3> <h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
{pricingRules.map((rule) => ( {pricingRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm"> <div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
@@ -523,15 +749,15 @@ export default function SettingsPage() {
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p> <p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
</div> </div>
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}> <button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
{rule.isActive ? 'Active' : 'Inactive'} {rule.isActive ? copy.active : copy.inactive}
</button> </button>
</div> </div>
))} ))}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">No pricing rules configured yet.</div>} {pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
</div> </div>
<div className="mt-5 grid gap-3 sm:grid-cols-2"> <div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder="Rule name" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} /> <input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}> <select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)} {['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select> </select>
@@ -545,7 +771,7 @@ export default function SettingsPage() {
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} /> <input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
</div> </div>
<div className="mt-3"> <div className="mt-3">
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add rule'}</button> <button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -553,36 +779,36 @@ export default function SettingsPage() {
{accountingSettings && ( {accountingSettings && (
<div className="card p-6"> <div className="card p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">Accounting and exports</h3> <h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary"> <button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save accounting'} {saving ? copy.save : copy.saveAccounting}
</button> </button>
</div> </div>
<div className="mt-5 grid gap-4 lg:grid-cols-2"> <div className="mt-5 grid gap-4 lg:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Reporting period</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}> <select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{option}</option>)} {['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Report format</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}> <select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{option}</option>)} {['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
</select> </select>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant name</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} /> <input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant email</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} /> <input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
</div> </div>
</div> </div>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700"> <label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} /> <input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
Auto-send reports to the accountant {copy.autoSend}
</label> </label>
</div> </div>
)} )}
@@ -6,16 +6,17 @@ import EditMemberModal from '@/components/team/EditMemberModal'
import PermissionsMatrix from '@/components/team/PermissionsMatrix' import PermissionsMatrix from '@/components/team/PermissionsMatrix'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api' import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam' import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
function initials(m: TeamMember) { function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '') return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
} }
function timeAgo(dateStr: string | null) { function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) return 'Never' if (!dateStr) return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
const diff = Date.now() - new Date(dateStr).getTime() const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000) const mins = Math.floor(diff / 60000)
if (mins < 2) return 'Just now' if (mins < 2) return language === 'fr' ? 'À linstant' : language === 'ar' ? 'الآن' : 'Just now'
if (mins < 60) return `${mins}m ago` if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60) const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago` if (hrs < 24) return `${hrs}h ago`
@@ -50,29 +51,119 @@ function RoleBadge({ role }: { role: string }) {
) )
} }
function StatusBadge({ member }: { member: TeamMember }) { function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
if (member.invitationStatus === 'pending') { if (member.invitationStatus === 'pending') {
return ( return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50"> <span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
Pending invite {language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
</span> </span>
) )
} }
if (!member.isActive) { if (!member.isActive) {
return ( return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700"> <span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
Deactivated {language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
</span> </span>
) )
} }
return ( return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50"> <span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
Active {language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
</span> </span>
) )
} }
export default function TeamPage() { export default function TeamPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Team',
subtitle: 'Manage your employees and their access levels',
inviteMember: 'Invite member',
totalMembers: 'Total members',
active: 'Active',
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
search: 'Search by name or email…',
allRoles: 'All roles',
owner: 'Owner',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'All statuses',
pending: 'Pending',
loadingTeam: 'Loading team…',
noMembers: 'No members match your filters',
member: 'Member',
role: 'Role',
status: 'Status',
lastActive: 'Last active',
manage: 'Manage',
inviteSent: 'Invite sent to',
roleUpdated: 'Role updated',
memberDeactivated: 'Member deactivated',
memberReactivated: 'Member reactivated',
memberRemoved: 'Member removed',
you: '(you)',
},
fr: {
title: 'Équipe',
subtitle: 'Gérez vos employés et leurs niveaux daccès',
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
search: 'Rechercher par nom ou email…',
allRoles: 'Tous les rôles',
owner: 'Propriétaire',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: 'Chargement de l’équipe…',
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
status: 'Statut',
lastActive: 'Dernière activité',
manage: 'Gérer',
inviteSent: 'Invitation envoyée à',
roleUpdated: 'Rôle mis à jour',
memberDeactivated: 'Membre désactivé',
memberReactivated: 'Membre réactivé',
memberRemoved: 'Membre supprimé',
you: '(vous)',
},
ar: {
title: 'الفريق',
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
inviteMember: 'دعوة عضو',
totalMembers: 'إجمالي الأعضاء',
active: 'نشط',
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
search: 'ابحث بالاسم أو البريد الإلكتروني…',
allRoles: 'كل الأدوار',
owner: 'مالك',
manager: 'مدير',
agent: 'وكيل',
allStatuses: 'كل الحالات',
pending: 'قيد الانتظار',
loadingTeam: 'جارٍ تحميل الفريق…',
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
member: 'العضو',
role: 'الدور',
status: 'الحالة',
lastActive: 'آخر نشاط',
manage: 'إدارة',
inviteSent: 'تم إرسال الدعوة إلى',
roleUpdated: 'تم تحديث الدور',
memberDeactivated: 'تم تعطيل العضو',
memberReactivated: 'تمت إعادة تفعيل العضو',
memberRemoved: 'تم حذف العضو',
you: '(أنت)',
},
}[language]
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam() const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null) const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
@@ -124,36 +215,36 @@ export default function TeamPage() {
const handleInvite = async (payload: InvitePayload) => { const handleInvite = async (payload: InvitePayload) => {
await invite(payload) await invite(payload)
showToast(`Invite sent to ${payload.email}`) showToast(`${copy.inviteSent} ${payload.email}`)
} }
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => { const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role) await updateRole(id, role)
showToast('Role updated') showToast(copy.roleUpdated)
} }
const handleDeactivate = async (id: string) => { const handleDeactivate = async (id: string) => {
await deactivate(id) await deactivate(id)
showToast('Member deactivated') showToast(copy.memberDeactivated)
} }
const handleReactivate = async (id: string) => { const handleReactivate = async (id: string) => {
await reactivate(id) await reactivate(id)
showToast('Member reactivated') showToast(copy.memberReactivated)
} }
const handleRemove = async (id: string) => { const handleRemove = async (id: string) => {
await remove(id) await remove(id)
showToast('Member removed') showToast(copy.memberRemoved)
} }
return ( return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6"> <div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-6">
<div> <div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1> <h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1"> <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
Manage your employees and their access levels {copy.subtitle}
</p> </p>
</div> </div>
{isOwner && ( {isOwner && (
@@ -164,17 +255,17 @@ export default function TeamPage() {
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16"> <svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg> </svg>
Invite member {copy.inviteMember}
</button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[ {[
{ label: 'Total members', value: stats.total }, { label: copy.totalMembers, value: stats.total },
{ label: 'Active', value: stats.active }, { label: copy.active, value: stats.active },
{ label: 'Pending invite', value: stats.pending }, { label: copy.pendingInvite, value: stats.pending },
{ label: 'Deactivated', value: stats.inactive }, { label: copy.deactivated, value: stats.inactive },
].map((s) => ( ].map((s) => (
<div <div
key={s.label} key={s.label}
@@ -194,7 +285,7 @@ export default function TeamPage() {
</svg> </svg>
<input <input
type="text" type="text"
placeholder="Search by name or email…" placeholder={copy.search}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent" className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
@@ -205,20 +296,20 @@ export default function TeamPage() {
onChange={(e) => setRoleFilter(e.target.value)} onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white" className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
> >
<option value="">All roles</option> <option value="">{copy.allRoles}</option>
<option value="OWNER">Owner</option> <option value="OWNER">{copy.owner}</option>
<option value="MANAGER">Manager</option> <option value="MANAGER">{copy.manager}</option>
<option value="AGENT">Agent</option> <option value="AGENT">{copy.agent}</option>
</select> </select>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white" className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
> >
<option value="">All statuses</option> <option value="">{copy.allStatuses}</option>
<option value="active">Active</option> <option value="active">{copy.active}</option>
<option value="pending">Pending</option> <option value="pending">{copy.pending}</option>
<option value="inactive">Deactivated</option> <option value="inactive">{copy.deactivated}</option>
</select> </select>
</div> </div>
@@ -226,22 +317,22 @@ export default function TeamPage() {
{loading ? ( {loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400"> <div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">Loading team</span> <span className="text-sm">{copy.loadingTeam}</span>
</div> </div>
) : error ? ( ) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div> <div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="py-16 text-center"> <div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p> <p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
</div> </div>
) : ( ) : (
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700"> <tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th> <th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th> <th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th> <th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th> <th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
@@ -263,7 +354,7 @@ export default function TeamPage() {
<div className="text-sm font-medium text-zinc-900 dark:text-white"> <div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName} {member.firstName} {member.lastName}
{member.id === currentEmployeeId && ( {member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span> <span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
)} )}
</div> </div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div> <div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
@@ -276,12 +367,12 @@ export default function TeamPage() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<StatusBadge member={member} /> <StatusBadge member={member} language={language} />
</td> </td>
<td className="px-4 py-3 hidden sm:table-cell"> <td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500"> <span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)} {member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
</span> </span>
</td> </td>
@@ -291,7 +382,7 @@ export default function TeamPage() {
onClick={() => setEditTarget(member)} onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors" className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
> >
Manage {copy.manage}
</button> </button>
)} )}
</td> </td>
@@ -6,7 +6,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return ( return (
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950"> <div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
<Sidebar /> <Sidebar />
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col"> <div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64">
<TopBar /> <TopBar />
<main className="flex-1 overflow-y-auto p-6">{children}</main> <main className="flex-1 overflow-y-auto p-6">{children}</main>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90"> <footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react' import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
@@ -43,6 +44,8 @@ function daysBetween(start: Date, end: Date): number {
} }
export default function VehicleCalendar({ vehicleId }: Props) { export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
const today = new Date() const today = new Date()
const [year, setYear] = useState(today.getFullYear()) const [year, setYear] = useState(today.getFullYear())
const [month, setMonth] = useState(today.getMonth() + 1) const [month, setMonth] = useState(today.getMonth() + 1)
@@ -71,11 +74,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
) )
setEvents(data) setEvents(data)
} catch (e: any) { } catch (e: any) {
setError(e.message ?? 'Failed to load calendar') setError(e.message ?? c.failedLoad)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [vehicleId, year, month]) }, [vehicleId, year, month, c])
useEffect(() => { fetchCalendar() }, [fetchCalendar]) useEffect(() => { fetchCalendar() }, [fetchCalendar])
@@ -142,8 +145,8 @@ export default function VehicleCalendar({ vehicleId }: Props) {
} }
const handleSaveBlock = async () => { const handleSaveBlock = async () => {
if (!blockStart || !blockEnd) { setSaveError('Start and end dates are required.'); return } if (!blockStart || !blockEnd) { setSaveError(c.datesRequired); return }
if (blockEnd <= blockStart) { setSaveError('End date must be after start date.'); return } if (blockEnd <= blockStart) { setSaveError(c.endAfterStart); return }
setSaving(true) setSaving(true)
setSaveError(null) setSaveError(null)
try { try {
@@ -154,23 +157,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
setShowModal(false) setShowModal(false)
fetchCalendar() fetchCalendar()
} catch (e: any) { } catch (e: any) {
setSaveError(e.message ?? 'Failed to create block') setSaveError(e.message ?? c.failedCreateBlock)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
const handleDeleteBlock = async (id: string) => { const handleDeleteBlock = async (id: string) => {
if (!confirm('Remove this block?')) return if (!confirm(c.removeBlockConfirm)) return
try { try {
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' }) await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
fetchCalendar() fetchCalendar()
} catch (e: any) { } catch (e: any) {
alert(e.message ?? 'Failed to delete block') alert(e.message ?? c.failedDeleteBlock)
} }
} }
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString(localeCode, { month: 'long', year: 'numeric' })
// Separate reservations vs blocks for the legend/list below // Separate reservations vs blocks for the legend/list below
const reservationEvents = events.filter((e) => e.type === 'RESERVATION') const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
@@ -182,7 +186,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CalendarDays className="w-5 h-5 text-slate-600" /> <CalendarDays className="w-5 h-5 text-slate-600" />
<h3 className="text-base font-semibold text-slate-900">Availability Calendar</h3> <h3 className="text-base font-semibold text-slate-900">{c.heading}</h3>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors"> <button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
@@ -193,24 +197,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3"> <button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
<Plus className="w-3.5 h-3.5" /> Block dates <Plus className="w-3.5 h-3.5" /> {c.blockDates}
</button> </button>
</div> </div>
</div> </div>
{selectStart && ( {selectStart && (
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2"> <div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
Click a second day to set the block end date (selected: {selectStart}) {c.clickSecondDay(selectStart!)}
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>cancel</button> <button className="ml-2 underline" onClick={() => setSelectStart(null)}>{c.cancelSelection}</button>
</div> </div>
)} )}
{/* Legend */} {/* Legend */}
<div className="flex flex-wrap gap-4 text-xs text-slate-600"> <div className="flex flex-wrap gap-4 text-xs text-slate-600">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />Reserved</span> <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />{c.legendReserved}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />Maintenance</span> <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />{c.legendMaintenance}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />Blocked</span> <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />{c.legendBlocked}</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />Available</span> <span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />{c.legendAvailable}</span>
</div> </div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>} {error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
@@ -219,7 +223,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className={loading ? 'opacity-50 pointer-events-none' : ''}> <div className={loading ? 'opacity-50 pointer-events-none' : ''}>
{/* Day headers */} {/* Day headers */}
<div className="grid grid-cols-7 mb-1"> <div className="grid grid-cols-7 mb-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => ( {c.dayHeaders.map((d) => (
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div> <div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
))} ))}
</div> </div>
@@ -261,7 +265,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
) )
})} })}
{dayEvents.length > 2 && ( {dayEvents.length > 2 && (
<div className="text-[10px] text-slate-400 pl-1">+{dayEvents.length - 2} more</div> <div className="text-[10px] text-slate-400 pl-1">{c.moreDays(dayEvents.length - 2)}</div>
)} )}
</div> </div>
</div> </div>
@@ -275,7 +279,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{reservationEvents.length > 0 && ( {reservationEvents.length > 0 && (
<div> <div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Reservations this month</p> <p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.reservationsThisMonth}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{reservationEvents.map((e) => { {reservationEvents.map((e) => {
const s = EVENT_STYLES[e.type] const s = EVENT_STYLES[e.type]
@@ -299,7 +303,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
{blockEvents.length > 0 && ( {blockEvents.length > 0 && (
<div> <div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Blocks &amp; maintenance</p> <p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.blocksAndMaintenance}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{blockEvents.map((e) => { {blockEvents.map((e) => {
const s = EVENT_STYLES[e.type] const s = EVENT_STYLES[e.type]
@@ -317,7 +321,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<button <button
onClick={() => handleDeleteBlock(e.id)} onClick={() => handleDeleteBlock(e.id)}
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`} className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
title="Remove block" title={c.removeBlock}
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
@@ -335,7 +339,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={closeModal}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={closeModal}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}> <div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-slate-900">Block vehicle dates</h4> <h4 className="text-base font-semibold text-slate-900">{c.modalTitle}</h4>
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100"> <button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -343,26 +347,26 @@ export default function VehicleCalendar({ vehicleId }: Props) {
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Block type</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{c.blockTypeLabel}</label>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setBlockType('MANUAL')} onClick={() => setBlockType('MANUAL')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`} className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
> >
<Ban className="w-3.5 h-3.5" /> Manual block <Ban className="w-3.5 h-3.5" /> {c.manualBlock}
</button> </button>
<button <button
onClick={() => setBlockType('MAINTENANCE')} onClick={() => setBlockType('MAINTENANCE')}
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-amber-50 border-amber-300 text-amber-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`} className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-amber-50 border-amber-300 text-amber-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
> >
<Wrench className="w-3.5 h-3.5" /> Maintenance <Wrench className="w-3.5 h-3.5" /> {c.maintenanceBlock}
</button> </button>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Start date</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{c.startDate}</label>
<input <input
type="date" type="date"
className="input-field" className="input-field"
@@ -371,7 +375,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
/> />
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">End date (exclusive)</label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{c.endDate}</label>
<input <input
type="date" type="date"
className="input-field" className="input-field"
@@ -383,11 +387,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
</div> </div>
<div> <div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">Reason <span className="font-normal">(optional)</span></label> <label className="block text-xs font-medium text-slate-500 mb-1.5">{c.reasonLabel} <span className="font-normal">{c.optional}</span></label>
<input <input
type="text" type="text"
className="input-field" className="input-field"
placeholder={blockType === 'MAINTENANCE' ? 'e.g. Oil change, tire rotation…' : 'e.g. Reserved for VIP, company event…'} placeholder={blockType === 'MAINTENANCE' ? c.reasonPlaceholderMaintenance : c.reasonPlaceholderManual}
value={blockReason} value={blockReason}
onChange={(e) => setBlockReason(e.target.value)} onChange={(e) => setBlockReason(e.target.value)}
/> />
@@ -399,9 +403,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
)} )}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">
<button onClick={closeModal} className="flex-1 btn-secondary">Cancel</button> <button onClick={closeModal} className="flex-1 btn-secondary">{c.cancel}</button>
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary"> <button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
{saving ? 'Saving…' : 'Save block'} {saving ? c.saving : c.saveBlock}
</button> </button>
</div> </div>
</div> </div>
+174 -45
View File
@@ -2,6 +2,7 @@
import Link from 'next/link' import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { import {
LayoutDashboard, LayoutDashboard,
Car, Car,
@@ -15,10 +16,30 @@ import {
Bell, Bell,
Settings, Settings,
LogOut, LogOut,
ChevronLeft,
ChevronRight,
} from 'lucide-react' } from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls' import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api' import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
interface BrandSettings {
displayName: string
logoUrl: string | null
}
interface SidebarUser {
displayName: string
subtitle: string
initials: string
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true }, { href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
@@ -35,9 +56,67 @@ const NAV_ITEMS = [
] as const ] as const
export default function Sidebar() { export default function Sidebar() {
const { dict } = useDashboardI18n() const { dict, language } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
const [open, setOpen] = useState(false)
useEffect(() => {
apiFetch<BrandSettings>('/companies/me/brand').then(setBrand).catch(() => {})
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
return
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
role: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
const role = employee.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : dict.workspaceUser)
const subtitle = email || role || dict.localAuth
const initials = computeInitials(fullName || email || dict.workspaceUser)
setUser({ displayName, subtitle, initials })
})
.catch(() => {
if (cancelled) return
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
})
return () => { cancelled = true }
}, [dict.localAuth, dict.workspaceUser])
// Close sidebar when navigating on mobile
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => { const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return pathname === item.href if ('exact' in item && item.exact) return pathname === item.href
@@ -51,52 +130,102 @@ export default function Sidebar() {
} }
return ( return (
<aside className="fixed inset-y-0 left-0 z-40 flex w-64 flex-col bg-slate-900"> <>
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5"> {/* Mobile backdrop */}
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500"> {open && (
<Car className="h-4 w-4 text-white" /> <div
</div> className="fixed inset-0 z-30 bg-black/50 lg:hidden"
<span className="text-sm font-bold tracking-wide text-white">RentalDriveGo</span> onClick={() => setOpen(false)}
</Link> />
)}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> {/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{NAV_ITEMS.map((item) => { {!open && (
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{dict.workspaceUser}</p>
<p className="truncate text-xs text-slate-400">{dict.localAuth}</p>
</div>
</div>
<button <button
onClick={signOut} onClick={() => setOpen(true)}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white" aria-label="Open sidebar"
className={[
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
].join(' ')}
> >
<LogOut className="h-4 w-4" /> {isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
{dict.signOut}
</button> </button>
</div> )}
</aside>
<aside
className={[
'fixed inset-y-0 z-40 flex w-64 flex-col bg-slate-900 transition-transform duration-300',
isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')}
>
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
{brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : (
<Car className="h-4 w-4 text-white" />
)}
</div>
<span className="truncate text-sm font-bold tracking-wide text-white">
{brand?.displayName ?? 'RentalDriveGo'}
</span>
</Link>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const active = isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
].join(' ')}
>
<Icon className="h-4 w-4 flex-shrink-0" />
{dict.nav[item.key]}
</Link>
)
})}
</nav>
<div className="border-t border-slate-800 px-3 py-4">
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
{user.initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
</div>
</div>
<button
onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
>
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button
onClick={() => setOpen(false)}
aria-label="Close sidebar"
className={[
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
].join(' ')}
>
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
</button>
</aside>
</>
) )
} }
+147 -10
View File
@@ -1,25 +1,127 @@
'use client' 'use client'
import { Bell } from 'lucide-react' import { Bell } from 'lucide-react'
import { usePathname } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { apiFetch } from '@/lib/api' import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return 'U'
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
export default function TopBar() { export default function TopBar() {
const { dict } = useDashboardI18n() const { dict } = useDashboardI18n()
const pathname = usePathname() const pathname = usePathname()
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0) const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false) const [showNotifs, setShowNotifs] = useState(false)
const [userInitials, setUserInitials] = useState('W')
const [notifications, setNotifications] = useState<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const title = dict.titles[pathname] ?? dict.titles['/dashboard'] const title = dict.titles[pathname] ?? dict.titles['/dashboard']
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {}
}
useEffect(() => { useEffect(() => {
apiFetch<{ unread: number }>('/notifications/unread-count') refreshUnreadCount()
.then((data) => setUnreadCount(data.unread))
.catch(() => {})
}, [pathname]) }, [pathname])
useEffect(() => {
function onNotificationsUpdated() {
refreshUnreadCount()
}
window.addEventListener('notifications:updated', onNotificationsUpdated)
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
}, [])
useEffect(() => {
if (!showNotifs) return
let cancelled = false
setLoadingNotifs(true)
apiFetch<Array<{
id: string
title: string
body: string
readAt: string | null
createdAt: string
}>>('/notifications/company')
.then((data) => {
if (cancelled) return
setNotifications(data.slice(0, 8))
})
.catch(() => {
if (cancelled) return
setNotifications([])
})
.finally(() => {
if (cancelled) return
setLoadingNotifs(false)
})
return () => { cancelled = true }
}, [showNotifs])
useEffect(() => {
setShowNotifs(false)
}, [pathname])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUserInitials(computeInitials(dict.workspaceUser))
return
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
}
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
})
.catch(() => {
if (cancelled) return
setUserInitials(computeInitials(dict.workspaceUser))
})
return () => { cancelled = true }
}, [dict.workspaceUser])
async function openNotificationsInbox(notificationId?: string) {
if (notificationId) {
try {
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
setUnreadCount((current) => Math.max(current - 1, 0))
window.dispatchEvent(new Event('notifications:updated'))
} catch {}
}
setShowNotifs(false)
router.push('/dashboard/notifications')
}
return ( return (
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 transition-colors dark:border-slate-800 dark:bg-slate-950"> <header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 transition-colors dark:border-slate-800 dark:bg-slate-950">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h1> <h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
@@ -40,16 +142,51 @@ export default function TopBar() {
{showNotifs ? ( {showNotifs ? (
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-slate-200 bg-white p-4 shadow-lg dark:border-slate-700 dark:bg-slate-900"> <div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-slate-200 bg-white p-4 shadow-lg dark:border-slate-700 dark:bg-slate-900">
<p className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p> <div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)} <button
</p> type="button"
onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
View all
</button>
</div>
{loadingNotifs ? (
<p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p>
) : (
<div className="space-y-1">
{notifications.map((notification) => (
<button
key={notification.id}
type="button"
onClick={() => openNotificationsInbox(notification.id)}
className={[
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-100 dark:hover:bg-slate-800',
notification.readAt ? 'opacity-80' : '',
].join(' ')}
>
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
{notification.title}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body}
</p>
</button>
))}
</div>
)}
</div> </div>
) : null} ) : null}
</div> </div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
W {userInitials}
</div> </div>
</div> </div>
</header> </header>
@@ -2,6 +2,7 @@
import { useState } from 'react' import { useState } from 'react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type InspectionType = 'CHECKIN' | 'CHECKOUT' type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY' type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
@@ -50,6 +51,9 @@ export default function DamageInspectionCard({
initialInspection?: DamageInspection | null initialInspection?: DamageInspection | null
onSaved: (inspection: DamageInspection) => void onSaved: (inspection: DamageInspection) => void
}) { }) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const [inspection, setInspection] = useState<DamageInspection>({ const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`, id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
type, type,
@@ -80,20 +84,14 @@ export default function DamageInspectionCard({
employeeNotes: inspection.employeeNotes, employeeNotes: inspection.employeeNotes,
customerAgreed: inspection.customerAgreed, customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({ damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType, viewType, x, y, damageType, severity, description, isPreExisting,
x,
y,
damageType,
severity,
description,
isPreExisting,
})), })),
}), }),
}) })
setInspection(result) setInspection(result)
onSaved(result) onSaved(result)
} catch (err: any) { } catch (err: any) {
setError(err.message ?? 'Failed to save inspection') setError(err.message ?? r.failedSaveInspection)
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -107,15 +105,7 @@ export default function DamageInspectionCard({
...current, ...current,
damagePoints: [ damagePoints: [
...current.damagePoints, ...current.damagePoints,
{ { viewType: 'TOP', x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
viewType: 'TOP',
x,
y,
damageType,
severity,
description: '',
isPreExisting: type === 'CHECKIN',
},
], ],
})) }))
} }
@@ -123,7 +113,7 @@ export default function DamageInspectionCard({
function removePoint(index: number) { function removePoint(index: number) {
setInspection((current) => ({ setInspection((current) => ({
...current, ...current,
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index), damagePoints: current.damagePoints.filter((_, i) => i !== index),
})) }))
} }
@@ -131,11 +121,11 @@ export default function DamageInspectionCard({
<div className="card p-6"> <div className="card p-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<h3 className="text-base font-semibold text-slate-900">{type === 'CHECKIN' ? 'Check-in inspection' : 'Check-out inspection'}</h3> <h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
<p className="mt-1 text-sm text-slate-500">Mark existing and newly discovered damage directly on the vehicle diagram.</p> <p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
</div> </div>
<button onClick={saveInspection} disabled={saving} className="btn-primary"> <button onClick={saveInspection} disabled={saving} className="btn-primary">
{saving ? 'Saving…' : 'Save inspection'} {saving ? r.savingInspection : r.saveInspection}
</button> </button>
</div> </div>
@@ -151,7 +141,7 @@ export default function DamageInspectionCard({
onClick={() => setDamageType(option)} onClick={() => setDamageType(option)}
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`} className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
> >
{option} {r.damageTypeLabels[option]}
</button> </button>
))} ))}
</div> </div>
@@ -164,7 +154,7 @@ export default function DamageInspectionCard({
className="rounded-full px-3 py-1.5 font-semibold text-white" className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }} style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
> >
{option} {r.severityLabels[option]}
</button> </button>
))} ))}
</div> </div>
@@ -184,61 +174,61 @@ export default function DamageInspectionCard({
</g> </g>
))} ))}
</svg> </svg>
<p className="mt-2 text-xs text-slate-500">Click anywhere on the diagram to add a damage marker with the selected type and severity.</p> <p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Mileage</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
<input <input
type="number" type="number"
className="input-field" className="input-field"
value={inspection.mileage ?? ''} value={inspection.mileage ?? ''}
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))} onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel level</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
<select className="input-field" value={inspection.fuelLevel} onChange={(event) => setInspection((current) => ({ ...current, fuelLevel: event.target.value as FuelLevel }))}> <select className="input-field" value={inspection.fuelLevel} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
{fuelLevels.map((fuelLevel) => <option key={fuelLevel} value={fuelLevel}>{fuelLevel}</option>)} {fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
</select> </select>
</div> </div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">General condition</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(event) => setInspection((current) => ({ ...current, generalCondition: event.target.value }))} /> <textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">Employee notes</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(event) => setInspection((current) => ({ ...current, employeeNotes: event.target.value }))} /> <textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
</div> </div>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700"> <label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={inspection.customerAgreed} onChange={(event) => setInspection((current) => ({ ...current, customerAgreed: event.target.checked }))} /> <input type="checkbox" checked={inspection.customerAgreed} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
Customer acknowledged this inspection {r.customerAcknowledged}
</label> </label>
<div className="rounded-2xl border border-slate-200"> <div className="rounded-2xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-3"> <div className="border-b border-slate-200 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">Damage markers</p> <p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</p>
</div> </div>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{inspection.damagePoints.map((point, index) => ( {inspection.damagePoints.map((point, index) => (
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm"> <div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div> <div>
<p className="font-medium text-slate-900">{point.damageType} · {point.severity}</p> <p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p> <p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
</div> </div>
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600"> <button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
Remove {r.removeMarker}
</button> </button>
</div> </div>
))} ))}
{inspection.damagePoints.length === 0 && ( {inspection.damagePoints.length === 0 && (
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div> <div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
)} )}
</div> </div>
</div> </div>
@@ -4,6 +4,7 @@ interface StatCardProps {
title: string title: string
value: string | number value: string | number
change?: number change?: number
vsLastMonthLabel?: string
icon: LucideIcon icon: LucideIcon
iconColor?: string iconColor?: string
iconBg?: string iconBg?: string
@@ -13,6 +14,7 @@ export default function StatCard({
title, title,
value, value,
change, change,
vsLastMonthLabel = 'vs last month',
icon: Icon, icon: Icon,
iconColor = 'text-blue-600', iconColor = 'text-blue-600',
iconBg = 'bg-blue-50', iconBg = 'bg-blue-50',
@@ -36,7 +38,7 @@ export default function StatCard({
> >
{isPositive ? '+' : ''}{change.toFixed(1)}% {isPositive ? '+' : ''}{change.toFixed(1)}%
</span> </span>
<span className="text-xs text-slate-400">vs last month</span> <span className="text-xs text-slate-400">{vsLastMonthLabel}</span>
</div> </div>
)} )}
</div> </div>
+3 -3
View File
@@ -1,7 +1,7 @@
export const API_BASE = export const API_BASE =
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1') typeof window === 'undefined'
?? process.env.NEXT_PUBLIC_API_URL ? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
?? 'http://localhost:4000/api/v1' : (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1')
export const EMPLOYEE_TOKEN_KEY = 'employee_token' export const EMPLOYEE_TOKEN_KEY = 'employee_token'
@@ -44,7 +44,9 @@ interface Dict {
// modal // modal
reserveVehicle: string reserveVehicle: string
pickupDate: string pickupDate: string
pickupTime: string
returnDate: string returnDate: string
dropoffTime: string
firstName: string firstName: string
lastName: string lastName: string
email: string email: string
@@ -85,7 +87,9 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
email: '', email: '',
phone: '', phone: '',
startDate: today(), startDate: today(),
startTime: '10:00',
endDate: tomorrow(), endDate: tomorrow(),
endTime: '10:00',
notes: '', notes: '',
}) })
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
@@ -97,7 +101,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
setSelected(vehicle) setSelected(vehicle)
setStatus('idle') setStatus('idle')
setErrorMsg('') setErrorMsg('')
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' }) setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, startTime: '10:00', endDate: minEnd, endTime: '10:00', notes: '' })
} }
function closeModal() { function closeModal() {
@@ -137,8 +141,8 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
lastName: form.lastName, lastName: form.lastName,
email: form.email, email: form.email,
phone: form.phone || undefined, phone: form.phone || undefined,
startDate: new Date(form.startDate).toISOString(), startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(),
endDate: new Date(form.endDate).toISOString(), endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(),
notes: form.notes || undefined, notes: form.notes || undefined,
}) })
setStatus('success') setStatus('success')
@@ -260,7 +264,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
</div> </div>
)} )}
{/* Dates */} {/* Dates + Times */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label> <label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label>
@@ -284,6 +288,26 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900" className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/> />
</div> </div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime}</label>
<input
type="time"
required
value={form.startTime}
onChange={set('startTime')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime}</label>
<input
type="time"
required
value={form.endTime}
onChange={set('endTime')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
/>
</div>
</div> </div>
{/* Estimated total */} {/* Estimated total */}
@@ -96,7 +96,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
// modal // modal
reserveVehicle: 'Reserve this vehicle', reserveVehicle: 'Reserve this vehicle',
pickupDate: 'Pick-up date', pickupDate: 'Pick-up date',
pickupTime: 'Pick-up time',
returnDate: 'Return date', returnDate: 'Return date',
dropoffTime: 'Drop-off time',
firstName: 'First name', firstName: 'First name',
lastName: 'Last name', lastName: 'Last name',
email: 'Email address', email: 'Email address',
@@ -152,7 +154,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'], categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
reserveVehicle: 'Réserver ce véhicule', reserveVehicle: 'Réserver ce véhicule',
pickupDate: 'Date de prise en charge', pickupDate: 'Date de prise en charge',
pickupTime: 'Heure de prise en charge',
returnDate: 'Date de retour', returnDate: 'Date de retour',
dropoffTime: 'Heure de restitution',
firstName: 'Prénom', firstName: 'Prénom',
lastName: 'Nom', lastName: 'Nom',
email: 'Adresse e-mail', email: 'Adresse e-mail',
@@ -208,7 +212,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'], categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
reserveVehicle: 'احجز هذه السيارة', reserveVehicle: 'احجز هذه السيارة',
pickupDate: 'تاريخ الاستلام', pickupDate: 'تاريخ الاستلام',
pickupTime: 'وقت الاستلام',
returnDate: 'تاريخ الإرجاع', returnDate: 'تاريخ الإرجاع',
dropoffTime: 'وقت الإرجاع',
firstName: 'الاسم الأول', firstName: 'الاسم الأول',
lastName: 'اسم العائلة', lastName: 'اسم العائلة',
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
+198
View File
@@ -0,0 +1,198 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Star } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
interface ReviewInfo {
reservationId: string
companyName: string
companyLogoUrl: string | null
vehicle: string
vehiclePhoto: string | null
}
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
const [hovered, setHovered] = useState(0)
return (
<div>
<p className="text-sm font-medium text-stone-700 mb-1.5">{label}</p>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => onChange(n)}
onMouseEnter={() => setHovered(n)}
onMouseLeave={() => setHovered(0)}
className="p-0.5 transition-transform hover:scale-110"
aria-label={`${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={`h-8 w-8 transition-colors ${
n <= (hovered || value)
? 'fill-amber-400 text-amber-400'
: 'fill-stone-200 text-stone-200'
}`}
/>
</button>
))}
</div>
</div>
)
}
export default function ReviewPage() {
const params = useSearchParams()
const token = params.get('token') ?? ''
const [info, setInfo] = useState<ReviewInfo | null>(null)
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
const [overall, setOverall] = useState(0)
const [vehicle, setVehicle] = useState(0)
const [service, setService] = useState(0)
const [comment, setComment] = useState('')
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
if (!token) { setLoadState('invalid'); return }
fetch(`${API_BASE}/marketplace/review/${token}`)
.then(async (r) => {
if (r.status === 409) { setLoadState('already_reviewed'); return }
if (!r.ok) { setLoadState('invalid'); return }
const json = await r.json()
setInfo(json.data)
setLoadState('ready')
})
.catch(() => setLoadState('invalid'))
}, [token])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
setSubmitState('submitting')
setErrorMsg('')
try {
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
overallRating: overall,
vehicleRating: vehicle || undefined,
serviceRating: service || undefined,
comment: comment.trim() || undefined,
}),
})
if (res.status === 409) { setLoadState('already_reviewed'); return }
if (!res.ok) {
const json = await res.json()
throw new Error(json.message ?? 'Something went wrong.')
}
setSubmitState('done')
} catch (err: any) {
setSubmitState('error')
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
}
}
if (loadState === 'loading') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<p className="text-stone-500 text-sm">Loading</p>
</main>
)
}
if (loadState === 'invalid') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-md w-full p-8 text-center space-y-3">
<p className="text-2xl font-black text-stone-900">Link not found</p>
<p className="text-stone-500 text-sm">This review link is invalid or has already been used.</p>
</div>
</main>
)
}
if (loadState === 'already_reviewed') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-md w-full p-8 text-center space-y-3">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<p className="text-xl font-bold text-stone-900">Already reviewed</p>
<p className="text-stone-500 text-sm">You have already submitted a review for this rental. Thank you!</p>
</div>
</main>
)
}
if (submitState === 'done') {
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-md w-full p-8 text-center space-y-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
</div>
<h1 className="text-2xl font-black text-stone-900">Thank you!</h1>
<p className="text-stone-500">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
</div>
</main>
)
}
return (
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
<div className="card max-w-lg w-full overflow-hidden">
{/* Header */}
<div className="flex items-center gap-4 border-b border-stone-100 p-6">
{info?.vehiclePhoto ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
) : (
<div className="h-16 w-24 rounded-xl bg-stone-100 flex-shrink-0" />
)}
<div className="min-w-0">
<p className="text-xs uppercase tracking-widest text-stone-400 truncate">{info?.companyName}</p>
<h1 className="mt-0.5 text-lg font-bold text-stone-900 truncate">{info?.vehicle}</h1>
<p className="mt-0.5 text-sm text-stone-500">How was your experience?</p>
</div>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
<div>
<label className="block text-sm font-medium text-stone-700 mb-1.5">Comments (optional)</label>
<textarea
rows={4}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Tell us about your rental experience…"
className="w-full rounded-xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
/>
</div>
{errorMsg && (
<p className="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
)}
<button
type="submit"
disabled={submitState === 'submitting'}
className="w-full rounded-full bg-stone-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-stone-700 disabled:opacity-60"
>
{submitState === 'submitting' ? 'Submitting' : 'Submit review'}
</button>
</form>
</div>
</main>
)
}
@@ -121,10 +121,12 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
const router = useRouter() const router = useRouter()
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001') const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002') const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
const [language, setLanguage] = useState<MarketplaceLanguage>('en') const [language, setLanguage] = useState<MarketplaceLanguage>('en')
const [theme, setTheme] = useState<Theme>('light') const [theme, setTheme] = useState<Theme>('light')
const [hydrated, setHydrated] = useState(false) const [hydrated, setHydrated] = useState(false)
const previousLanguage = useRef<MarketplaceLanguage>('en') const previousLanguage = useRef<MarketplaceLanguage>('en')
const [companyName, setCompanyName] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const storedLanguage = window.localStorage.getItem('marketplace-language') const storedLanguage = window.localStorage.getItem('marketplace-language')
@@ -139,6 +141,19 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
} }
setHydrated(true) setHydrated(true)
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('employee_token='))?.split('=')[1]
if (token) {
fetch(`${apiUrl}/companies/me/brand`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(json => {
const name = json?.data?.displayName ?? json?.displayName
if (name) setCompanyName(name)
})
.catch(() => {})
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@@ -212,12 +227,24 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
> >
{dict.explore} {dict.explore}
</Link> </Link>
<Link {companyName ? (
href={`${dashboardUrl}/sign-in`} <Link
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300" href={`${dashboardUrl}/dashboard`}
> className="ml-2 flex items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
{dict.ownerSignIn} >
</Link> <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
{companyName.charAt(0).toUpperCase()}
</span>
{companyName}
</Link>
) : (
<Link
href={`${dashboardUrl}/sign-in`}
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{dict.ownerSignIn}
</Link>
)}
</nav> </nav>
</div> </div>
</div> </div>
+9 -2
View File
@@ -50,7 +50,9 @@ interface BookingOptions {
interface Step1 { interface Step1 {
startDate: string startDate: string
startTime: string
endDate: string endDate: string
endTime: string
} }
interface Step2 { interface Step2 {
@@ -192,7 +194,7 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
const [bookingOptions, setBookingOptions] = useState<BookingOptions | null>(null) const [bookingOptions, setBookingOptions] = useState<BookingOptions | null>(null)
const [bookingOptionsError, setBookingOptionsError] = useState('') const [bookingOptionsError, setBookingOptionsError] = useState('')
const [step1, setStep1] = useState<Step1>({ startDate: '', endDate: '' }) const [step1, setStep1] = useState<Step1>({ startDate: '', startTime: '10:00', endDate: '', endTime: '10:00' })
const [step1Errors, setStep1Errors] = useState<Partial<Step1>>({}) const [step1Errors, setStep1Errors] = useState<Partial<Step1>>({})
const [step2, setStep2] = useState<Step2>({ const [step2, setStep2] = useState<Step2>({
firstName: '', firstName: '',
@@ -643,8 +645,13 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
{step === 3 && ( {step === 3 && (
<div className="space-y-6"> <div className="space-y-6">
{vehicle && (
<div className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
{dict.booking.categoryNotice(dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category)}
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 divide-y divide-slate-100 text-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 divide-y divide-slate-100 text-sm">
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${vehicle.make} ${vehicle.model}` : vehicleId} /> <SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category}${vehicle.make} ${vehicle.model}` : vehicleId} />
<SummaryRow label={dict.booking.summary.dates} value={`${step1.startDate} -> ${step1.endDate} (${days} ${dayLabel})`} /> <SummaryRow label={dict.booking.summary.dates} value={`${step1.startDate} -> ${step1.endDate} (${days} ${dayLabel})`} />
<SummaryRow label={dict.booking.summary.primaryDriver} value={`${step2.firstName} ${step2.lastName}`} /> <SummaryRow label={dict.booking.summary.primaryDriver} value={`${step2.firstName} ${step2.lastName}`} />
<SummaryRow label={dict.booking.summary.email} value={step2.email} /> <SummaryRow label={dict.booking.summary.email} value={step2.email} />
@@ -12,6 +12,7 @@ interface ReservationDetail {
endDate: string endDate: string
totalDays: number totalDays: number
totalAmount: number totalAmount: number
vehicleCategory: string | null
vehicle: { make: string; model: string; year: number } | null vehicle: { make: string; model: string; year: number } | null
customer: { firstName: string; lastName: string; email: string } | null customer: { firstName: string; lastName: string; email: string } | null
} }
@@ -69,8 +70,8 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
<div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500"> <div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
{dict.confirmation.bookingSummary} {dict.confirmation.bookingSummary}
</div> </div>
{reservation.vehicle && ( {reservation.vehicleCategory && (
<Row label={dict.confirmation.vehicle} value={`${reservation.vehicle.make} ${reservation.vehicle.model} (${reservation.vehicle.year})`} /> <Row label={dict.confirmation.vehicleCategory} value={dict.vehicles.categoryLabels[reservation.vehicleCategory as keyof typeof dict.vehicles.categoryLabels] ?? reservation.vehicleCategory} />
)} )}
{reservation.customer && ( {reservation.customer && (
<Row label={dict.confirmation.customer} value={`${reservation.customer.firstName} ${reservation.customer.lastName}`} /> <Row label={dict.confirmation.customer} value={`${reservation.customer.firstName} ${reservation.customer.lastName}`} />
+62 -6
View File
@@ -111,6 +111,16 @@ export type PublicSiteDictionary = {
seats: (count: number) => string seats: (count: number) => string
perDay: string perDay: string
viewDetails: string viewDetails: string
categoryLabels: {
ECONOMY: string
COMPACT: string
MIDSIZE: string
FULLSIZE: string
SUV: string
LUXURY: string
VAN: string
TRUCK: string
}
filters: { filters: {
title: string title: string
clearAll: string clearAll: string
@@ -150,7 +160,9 @@ export type PublicSiteDictionary = {
vehicleUnavailableNoDate: string vehicleUnavailableNoDate: string
bookingOptionsLoadError: string bookingOptionsLoadError: string
startDate: string startDate: string
startTime: string
endDate: string endDate: string
endTime: string
continueToDriverDetails: string continueToDriverDetails: string
firstName: string firstName: string
lastName: string lastName: string
@@ -189,6 +201,7 @@ export type PublicSiteDictionary = {
redirectingToPayment: string redirectingToPayment: string
confirmPayAtPickup: string confirmPayAtPickup: string
payWith: (provider: string) => string payWith: (provider: string) => string
categoryNotice: (category: string) => string
summary: { summary: {
vehicle: string vehicle: string
dates: string dates: string
@@ -235,6 +248,7 @@ export type PublicSiteDictionary = {
description: string description: string
bookingSummary: string bookingSummary: string
vehicle: string vehicle: string
vehicleCategory: string
customer: string customer: string
email: string email: string
dates: string dates: string
@@ -364,6 +378,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
seats: (count) => `${count} seats`, seats: (count) => `${count} seats`,
perDay: 'per day', perDay: 'per day',
viewDetails: 'View details', viewDetails: 'View details',
categoryLabels: {
ECONOMY: 'Economy',
COMPACT: 'Compact',
MIDSIZE: 'Midsize',
FULLSIZE: 'Full-size',
SUV: 'SUV',
LUXURY: 'Luxury',
VAN: 'Van',
TRUCK: 'Truck',
},
filters: { filters: {
title: 'Filters', title: 'Filters',
clearAll: 'Clear all', clearAll: 'Clear all',
@@ -402,8 +426,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
vehicleAvailableFrom: (date) => `This vehicle can only be reserved starting ${date}.`, vehicleAvailableFrom: (date) => `This vehicle can only be reserved starting ${date}.`,
vehicleUnavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.', vehicleUnavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
bookingOptionsLoadError: 'Could not load booking options.', bookingOptionsLoadError: 'Could not load booking options.',
startDate: 'Start date', startDate: 'Pick-up date',
endDate: 'End date', startTime: 'Pick-up time',
endDate: 'Drop-off date',
endTime: 'Drop-off time',
continueToDriverDetails: 'Continue to driver details ->', continueToDriverDetails: 'Continue to driver details ->',
firstName: 'First name', firstName: 'First name',
lastName: 'Last name', lastName: 'Last name',
@@ -442,6 +468,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
redirectingToPayment: 'Redirecting to payment...', redirectingToPayment: 'Redirecting to payment...',
confirmPayAtPickup: 'Confirm - pay at pickup', confirmPayAtPickup: 'Confirm - pay at pickup',
payWith: (provider) => `Pay with ${provider} ->`, payWith: (provider) => `Pay with ${provider} ->`,
categoryNotice: (category) => `Your vehicle will be assigned from the ${category} category upon arrival. The specific car may differ from the one shown.`,
summary: { summary: {
vehicle: 'Vehicle', vehicle: 'Vehicle',
dates: 'Dates', dates: 'Dates',
@@ -488,6 +515,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
description: 'The reservation has been created in draft status and will be confirmed by staff or after payment.', description: 'The reservation has been created in draft status and will be confirmed by staff or after payment.',
bookingSummary: 'Booking summary', bookingSummary: 'Booking summary',
vehicle: 'Vehicle', vehicle: 'Vehicle',
vehicleCategory: 'Vehicle category',
customer: 'Customer', customer: 'Customer',
email: 'Email', email: 'Email',
dates: 'Dates', dates: 'Dates',
@@ -615,6 +643,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
seats: (count) => `${count} places`, seats: (count) => `${count} places`,
perDay: 'par jour', perDay: 'par jour',
viewDetails: 'Voir les détails', viewDetails: 'Voir les détails',
categoryLabels: {
ECONOMY: 'Économique',
COMPACT: 'Compacte',
MIDSIZE: 'Intermédiaire',
FULLSIZE: 'Grande berline',
SUV: 'SUV',
LUXURY: 'Luxe',
VAN: 'Van',
TRUCK: 'Camionnette',
},
filters: { filters: {
title: 'Filtres', title: 'Filtres',
clearAll: 'Tout effacer', clearAll: 'Tout effacer',
@@ -653,8 +691,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
vehicleAvailableFrom: (date) => `Ce véhicule ne peut être réservé qu’à partir du ${date}.`, vehicleAvailableFrom: (date) => `Ce véhicule ne peut être réservé qu’à partir du ${date}.`,
vehicleUnavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.', vehicleUnavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
bookingOptionsLoadError: 'Impossible de charger les options de réservation.', bookingOptionsLoadError: 'Impossible de charger les options de réservation.',
startDate: 'Date de début', startDate: 'Date de prise en charge',
endDate: 'Date de fin', startTime: 'Heure de prise en charge',
endDate: 'Date de restitution',
endTime: 'Heure de restitution',
continueToDriverDetails: 'Continuer vers les informations conducteur ->', continueToDriverDetails: 'Continuer vers les informations conducteur ->',
firstName: 'Prénom', firstName: 'Prénom',
lastName: 'Nom', lastName: 'Nom',
@@ -693,6 +733,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
redirectingToPayment: 'Redirection vers le paiement...', redirectingToPayment: 'Redirection vers le paiement...',
confirmPayAtPickup: 'Confirmer - payer au retrait', confirmPayAtPickup: 'Confirmer - payer au retrait',
payWith: (provider) => `Payer avec ${provider} ->`, payWith: (provider) => `Payer avec ${provider} ->`,
categoryNotice: (category) => `Votre véhicule sera attribué depuis la catégorie ${category} à la prise en charge. Le véhicule exact peut différer de celui affiché.`,
summary: { summary: {
vehicle: 'Véhicule', vehicle: 'Véhicule',
dates: 'Dates', dates: 'Dates',
@@ -739,6 +780,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
description: 'La réservation a été créée en brouillon et sera confirmée par le personnel ou après paiement.', description: 'La réservation a été créée en brouillon et sera confirmée par le personnel ou après paiement.',
bookingSummary: 'Résumé de réservation', bookingSummary: 'Résumé de réservation',
vehicle: 'Véhicule', vehicle: 'Véhicule',
vehicleCategory: 'Catégorie du véhicule',
customer: 'Client', customer: 'Client',
email: 'E-mail', email: 'E-mail',
dates: 'Dates', dates: 'Dates',
@@ -866,6 +908,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
seats: (count) => `${count} مقاعد`, seats: (count) => `${count} مقاعد`,
perDay: 'في اليوم', perDay: 'في اليوم',
viewDetails: 'عرض التفاصيل', viewDetails: 'عرض التفاصيل',
categoryLabels: {
ECONOMY: 'اقتصادية',
COMPACT: 'مدمجة',
MIDSIZE: 'متوسطة',
FULLSIZE: 'كبيرة',
SUV: 'دفع رباعي',
LUXURY: 'فاخرة',
VAN: 'فان',
TRUCK: 'شاحنة',
},
filters: { filters: {
title: 'الفلاتر', title: 'الفلاتر',
clearAll: 'مسح الكل', clearAll: 'مسح الكل',
@@ -904,8 +956,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`, vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`,
vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.', vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.', bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
startDate: 'تاريخ البداية', startDate: 'تاريخ الاستلام',
endDate: 'تاريخ النهاية', startTime: 'وقت الاستلام',
endDate: 'تاريخ الإرجاع',
endTime: 'وقت الإرجاع',
continueToDriverDetails: 'المتابعة إلى بيانات السائق ->', continueToDriverDetails: 'المتابعة إلى بيانات السائق ->',
firstName: 'الاسم الأول', firstName: 'الاسم الأول',
lastName: 'اسم العائلة', lastName: 'اسم العائلة',
@@ -944,6 +998,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
redirectingToPayment: 'جارٍ التحويل إلى الدفع...', redirectingToPayment: 'جارٍ التحويل إلى الدفع...',
confirmPayAtPickup: 'تأكيد - الدفع عند الاستلام', confirmPayAtPickup: 'تأكيد - الدفع عند الاستلام',
payWith: (provider) => `ادفع عبر ${provider} ->`, payWith: (provider) => `ادفع عبر ${provider} ->`,
categoryNotice: (category) => `سيتم تخصيص مركبتك من فئة ${category} عند الاستلام. قد تختلف السيارة المحددة عن تلك المعروضة.`,
summary: { summary: {
vehicle: 'المركبة', vehicle: 'المركبة',
dates: 'التواريخ', dates: 'التواريخ',
@@ -990,6 +1045,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
description: 'تم إنشاء الحجز بحالة مسودة وسيتم تأكيده من قبل الموظفين أو بعد الدفع.', description: 'تم إنشاء الحجز بحالة مسودة وسيتم تأكيده من قبل الموظفين أو بعد الدفع.',
bookingSummary: 'ملخص الحجز', bookingSummary: 'ملخص الحجز',
vehicle: 'المركبة', vehicle: 'المركبة',
vehicleCategory: 'فئة المركبة',
customer: 'العميل', customer: 'العميل',
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
dates: 'التواريخ', dates: 'التواريخ',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,9 @@
-- Add reviewToken to reservations for secure one-time review links
ALTER TABLE "reservations" ADD COLUMN "reviewToken" TEXT;
CREATE UNIQUE INDEX "reservations_reviewToken_key" ON "reservations"("reviewToken");
-- Make renterId optional on reviews (reviews can come from non-marketplace customers via token link)
ALTER TABLE "reviews" ALTER COLUMN "renterId" DROP NOT NULL;
ALTER TABLE "reviews" DROP CONSTRAINT IF EXISTS "reviews_renterId_fkey";
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_renterId_fkey"
FOREIGN KEY ("renterId") REFERENCES "renters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reservations" ADD COLUMN "vehicleCategory" "VehicleCategory";
+7 -5
View File
@@ -639,9 +639,10 @@ model Reservation {
renter Renter? @relation("RenterReservations", fields: [renterId], references: [id]) renter Renter? @relation("RenterReservations", fields: [renterId], references: [id])
offerId String? offerId String?
offer Offer? @relation(fields: [offerId], references: [id]) offer Offer? @relation(fields: [offerId], references: [id])
promoCodeUsed String? promoCodeUsed String?
source BookingSource @default(DASHBOARD) vehicleCategory VehicleCategory?
marketplaceRef String? source BookingSource @default(DASHBOARD)
marketplaceRef String?
status ReservationStatus @default(DRAFT) status ReservationStatus @default(DRAFT)
startDate DateTime startDate DateTime
endDate DateTime endDate DateTime
@@ -663,6 +664,7 @@ model Reservation {
cancelledBy CancelledBy? cancelledBy CancelledBy?
contractNumber String? @unique contractNumber String? @unique
invoiceNumber String? @unique invoiceNumber String? @unique
reviewToken String? @unique
checkInFuelLevel String? checkInFuelLevel String?
checkOutFuelLevel String? checkOutFuelLevel String?
@@ -718,8 +720,8 @@ model Review {
id String @id @default(cuid()) id String @id @default(cuid())
reservationId String @unique reservationId String @unique
reservation Reservation @relation(fields: [reservationId], references: [id]) reservation Reservation @relation(fields: [reservationId], references: [id])
renterId String renterId String?
renter Renter @relation(fields: [renterId], references: [id]) renter Renter? @relation(fields: [renterId], references: [id])
companyId String companyId String
overallRating Int overallRating Int
vehicleRating Int? vehicleRating Int?
+2 -2
View File
@@ -2,8 +2,8 @@
"name": "@rentaldrivego/types", "name": "@rentaldrivego/types",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "./dist/index.js", "main": "./src/index.ts",
"types": "./dist/index.d.ts", "types": "./src/index.ts",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
+4 -4
View File
@@ -330,7 +330,7 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| GET | `/reservations/:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) | | GET | `//:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
| PATCH | `/reservations/:id/billing` | 🔒🏢💳 | Add damage charges and extras to reservation | | PATCH | `/reservations/:id/billing` | 🔒🏢💳 | Add damage charges and extras to reservation |
--- ---
@@ -419,9 +419,9 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| GET | `/reservations/:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports | | GET | `//:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
| POST | `/reservations/:id/damage-reports` | 🔒🏢💳 | Save damage report. Body: `{ type: "CHECKIN"|"CHECKOUT", damages: DamageZone[], fuelLevel, mileage, photos[], inspectedBy, customerSignedAt, customerName }` | | POST | `/reservations/:id/damage-reports` | 🔒🏢💳 | Save damage report. Body: `{ type: "CHECKIN"|"CHECKOUT", damages: DamageZone[], fuelLevel, mileage, photos[], inspectedBy, customerSignedAt, customerName }` |
| PATCH | `/reservations/:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) | | PATCH | `//:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
### Insurance Policies (Company configuration) ### Insurance Policies (Company configuration)
@@ -432,7 +432,7 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
| PATCH | `/insurance-policies/:id` | 🔒🏢💳 | Update policy | | PATCH | `/insurance-policies/:id` | 🔒🏢💳 | Update policy |
| DELETE | `/insurance-policies/:id` | 🔒🏢💳 | Deactivate policy | | DELETE | `/insurance-policies/:id` | 🔒🏢💳 | Deactivate policy |
| GET | `/reservations/:id/insurances` | 🔒🏢💳 | Insurance selections for a reservation | | GET | `/reservations/:id/insurances` | 🔒🏢💳 | Insurance selections for a reservation |
| POST | `/reservations/:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` | | POST | `//:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
### Additional Drivers ### Additional Drivers