fix and update language for company workspace
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ DASHBOARD_URL=http://localhost:3001
|
||||
JWT_SECRET=dev-secret
|
||||
JWT_EXPIRY=8h
|
||||
RENTER_JWT_EXPIRY=7d
|
||||
ADMIN_SEED_EMAIL=admin@rentaldrivego.com
|
||||
ADMIN_SEED_EMAIL=rentaldrivego@gmail.com
|
||||
ADMIN_SEED_PASSWORD=changeme123
|
||||
ADMIN_SEED_FIRST_NAME=Platform
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
|
||||
@@ -279,6 +279,112 @@ cron.schedule('0 9 * * *', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Daily: notify companies about upcoming and overdue vehicle maintenance (date- and odometer-based).
|
||||
// Repeats every day until the owner logs a new service entry that pushes the due date/mileage into the future.
|
||||
cron.schedule('0 8 * * *', async () => {
|
||||
const now = new Date()
|
||||
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
// Fetch all candidate logs (date-due or has an odometer target), ordered newest-first per vehicle+type.
|
||||
// We keep only the LATEST log per vehicle+type so that once the owner logs a new service the
|
||||
// old overdue log is superseded and notifications stop automatically.
|
||||
const allCandidates = await prisma.maintenanceLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ nextDueAt: { lte: in30Days } },
|
||||
{ nextDueMileage: { not: null } },
|
||||
],
|
||||
},
|
||||
include: { vehicle: { include: { company: { include: { employees: { where: { role: { in: ['OWNER', 'MANAGER'] }, isActive: true }, take: 1 } } } } } },
|
||||
orderBy: { performedAt: 'desc' },
|
||||
})
|
||||
|
||||
// Keep only the most-recent log per vehicle+type combination
|
||||
const latestByKey = new Map<string, typeof allCandidates[number]>()
|
||||
for (const log of allCandidates) {
|
||||
const key = `${log.vehicleId}:${log.type}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, log)
|
||||
}
|
||||
|
||||
for (const log of latestByKey.values()) {
|
||||
const vehicle = log.vehicle
|
||||
const company = vehicle.company
|
||||
const recipient = company.employees[0]
|
||||
if (!recipient) continue
|
||||
|
||||
// Determine date-based urgency
|
||||
let isOverdueByDate = false
|
||||
let daysLeft: number | null = null
|
||||
let dueSoonByDate = false
|
||||
if (log.nextDueAt) {
|
||||
daysLeft = Math.ceil((log.nextDueAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
isOverdueByDate = log.nextDueAt <= now
|
||||
dueSoonByDate = !isOverdueByDate && daysLeft <= 30
|
||||
}
|
||||
|
||||
// Determine odometer-based urgency
|
||||
let isOverdueByOdometer = false
|
||||
let kmLeft: number | null = null
|
||||
let dueSoonByOdometer = false
|
||||
if (log.nextDueMileage != null && vehicle.mileage != null) {
|
||||
kmLeft = log.nextDueMileage - vehicle.mileage
|
||||
isOverdueByOdometer = kmLeft <= 0
|
||||
dueSoonByOdometer = !isOverdueByOdometer && kmLeft <= 500
|
||||
}
|
||||
|
||||
// Skip if the latest log is no longer due (owner has updated it)
|
||||
const isOverdue = isOverdueByDate || isOverdueByOdometer
|
||||
const isDueSoon = !isOverdue && (dueSoonByDate || dueSoonByOdometer)
|
||||
if (!isOverdue && !isDueSoon) continue
|
||||
|
||||
// Dedup: don't send more than once per day for the same log
|
||||
const alreadySent = await prisma.notification.findFirst({
|
||||
where: {
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
companyId: company.id,
|
||||
createdAt: { gte: oneDayAgo },
|
||||
data: { path: ['maintenanceLogId'], equals: log.id },
|
||||
},
|
||||
})
|
||||
if (alreadySent) continue
|
||||
|
||||
// Build human-readable description
|
||||
const dueParts: string[] = []
|
||||
if (isOverdueByDate) dueParts.push(`overdue since ${log.nextDueAt!.toLocaleDateString()}`)
|
||||
else if (dueSoonByDate && daysLeft != null) dueParts.push(`due in ${daysLeft} day${daysLeft === 1 ? '' : 's'}`)
|
||||
if (isOverdueByOdometer) dueParts.push(`overdue by odometer (${Math.abs(kmLeft!).toLocaleString()} km ago)`)
|
||||
else if (dueSoonByOdometer && kmLeft != null) dueParts.push(`${kmLeft.toLocaleString()} km remaining`)
|
||||
|
||||
const title = isOverdue
|
||||
? `Overdue: ${log.type} — ${vehicle.make} ${vehicle.model}`
|
||||
: `${log.type} due soon — ${vehicle.make} ${vehicle.model}`
|
||||
const body = `${log.type} for ${vehicle.make} ${vehicle.model} (${vehicle.licensePlate}): ${dueParts.join('; ')}. Please log the service to dismiss this reminder.`
|
||||
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
type: 'VEHICLE_MAINTENANCE_DUE',
|
||||
title,
|
||||
body,
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
maintenanceLogId: log.id,
|
||||
maintenanceType: log.type,
|
||||
isOverdue,
|
||||
daysLeft,
|
||||
kmLeft,
|
||||
isOverdueByDate,
|
||||
isOverdueByOdometer,
|
||||
},
|
||||
companyId: company.id,
|
||||
employeeId: recipient.id,
|
||||
channel: 'IN_APP',
|
||||
status: 'DELIVERED',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Start ────────────────────────────────────────────────────
|
||||
const PORT = process.env.API_PORT ?? 4000
|
||||
server.listen(PORT, () => {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -28,6 +28,68 @@ function buildResetEmailHtml(resetUrl: string, firstName: string) {
|
||||
`
|
||||
}
|
||||
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Authentication required',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
let employeeId = ''
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; type: string }
|
||||
if (payload.type !== 'employee') {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid or expired session token',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
employeeId = payload.sub
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
message: 'Invalid or expired session token',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
include: { company: true },
|
||||
})
|
||||
|
||||
if (!employee || !employee.isActive) {
|
||||
return res.status(401).json({
|
||||
error: 'unauthenticated',
|
||||
message: 'Employee account not found or inactive',
|
||||
statusCode: 401,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
employee: {
|
||||
id: employee.id,
|
||||
email: employee.email,
|
||||
firstName: employee.firstName,
|
||||
lastName: employee.lastName,
|
||||
role: employee.role,
|
||||
companyId: employee.companyId,
|
||||
companyName: employee.company.name,
|
||||
companySlug: employee.company.slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = z.object({
|
||||
|
||||
@@ -50,7 +50,7 @@ router.get('/companies', async (req, res, next) => {
|
||||
where,
|
||||
include: {
|
||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true, publicCity: true, publicCountry: true, marketplaceRating: true, primaryColor: true } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true } } } },
|
||||
_count: { select: { vehicles: { where: { isPublished: true, status: 'AVAILABLE' } } } },
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
@@ -77,7 +77,7 @@ router.get('/search', async (req, res, next) => {
|
||||
const { city, startDate, endDate, category, maxPrice, transmission, make, model } = searchSchema.parse(req.query)
|
||||
const { page, pageSize } = paginationSchema.parse(req.query)
|
||||
|
||||
const where: any = { isPublished: true, company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
|
||||
const where: any = { isPublished: true, status: 'AVAILABLE', company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } } }
|
||||
if (category) where.category = category
|
||||
if (maxPrice !== undefined) where.dailyRate = { lte: maxPrice }
|
||||
if (transmission) where.transmission = transmission
|
||||
@@ -249,7 +249,7 @@ router.get('/:slug', async (req, res, next) => {
|
||||
where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } },
|
||||
include: {
|
||||
brand: true,
|
||||
vehicles: { where: { isPublished: true }, orderBy: { createdAt: 'desc' } },
|
||||
vehicles: { where: { isPublished: true, status: 'AVAILABLE' }, orderBy: { createdAt: 'desc' } },
|
||||
offers: { where: { isPublic: true, isActive: true, validUntil: { gte: new Date() } }, orderBy: { isFeatured: 'desc' } },
|
||||
},
|
||||
})
|
||||
@@ -294,7 +294,7 @@ router.get('/:slug/vehicles', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const vehicles = await prisma.vehicle.findMany({
|
||||
where: { companyId: company.id, isPublished: true },
|
||||
where: { companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ data: vehicles })
|
||||
@@ -308,7 +308,7 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
||||
try {
|
||||
const company = await prisma.company.findFirstOrThrow({ where: { slug: req.params.slug, status: { in: ['ACTIVE', 'TRIALING'] } } })
|
||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||
where: { id: req.params.id, companyId: company.id, isPublished: true, status: 'AVAILABLE' },
|
||||
include: {
|
||||
company: { include: { brand: true } },
|
||||
},
|
||||
@@ -343,6 +343,69 @@ router.get('/:slug/offers', async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Review token endpoints ───────────────────────────────────
|
||||
|
||||
router.get('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const reservation = await prisma.reservation.findUnique({
|
||||
where: { reviewToken: req.params.token },
|
||||
include: {
|
||||
vehicle: { select: { make: true, model: true, year: true, photos: true } },
|
||||
company: { include: { brand: { select: { displayName: true, logoUrl: true } } } },
|
||||
review: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
if (!reservation) return res.status(404).json({ error: 'invalid_token', message: 'Review link is invalid or has expired', statusCode: 404 })
|
||||
if (reservation.review) return res.status(409).json({ error: 'already_reviewed', message: 'A review has already been submitted for this reservation', statusCode: 409 })
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
reservationId: reservation.id,
|
||||
companyName: reservation.company.brand?.displayName ?? reservation.company.name,
|
||||
companyLogoUrl: reservation.company.brand?.logoUrl ?? null,
|
||||
vehicle: `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`,
|
||||
vehiclePhoto: reservation.vehicle.photos[0] ?? null,
|
||||
},
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/review/:token', async (req, res, next) => {
|
||||
try {
|
||||
const body = z.object({
|
||||
overallRating: z.number().int().min(1).max(5),
|
||||
vehicleRating: z.number().int().min(1).max(5).optional(),
|
||||
serviceRating: z.number().int().min(1).max(5).optional(),
|
||||
comment: z.string().max(2000).optional(),
|
||||
}).parse(req.body)
|
||||
|
||||
const reservation = await prisma.reservation.findUnique({
|
||||
where: { reviewToken: req.params.token },
|
||||
include: { review: { select: { id: true } } },
|
||||
})
|
||||
if (!reservation) return res.status(404).json({ error: 'invalid_token', message: 'Review link is invalid or has expired', statusCode: 404 })
|
||||
if (reservation.review) return res.status(409).json({ error: 'already_reviewed', message: 'A review has already been submitted for this reservation', statusCode: 409 })
|
||||
|
||||
const review = await prisma.review.create({
|
||||
data: {
|
||||
reservationId: reservation.id,
|
||||
companyId: reservation.companyId,
|
||||
renterId: reservation.renterId ?? undefined,
|
||||
overallRating: body.overallRating,
|
||||
vehicleRating: body.vehicleRating,
|
||||
serviceRating: body.serviceRating,
|
||||
comment: body.comment,
|
||||
isPublished: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Invalidate token so the link can't be reused
|
||||
await prisma.reservation.update({ where: { id: reservation.id }, data: { reviewToken: null } })
|
||||
|
||||
res.status(201).json({ data: { reviewId: review.id } })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/offers/:code/validate', async (req, res, next) => {
|
||||
try {
|
||||
const offer = await prisma.offer.findFirst({
|
||||
|
||||
@@ -8,7 +8,8 @@ import { applyAdditionalDriversToReservation } from '../services/additionalDrive
|
||||
import { applyInsurancesToReservation } from '../services/insuranceService'
|
||||
import { applyPricingRules } from '../services/pricingRuleService'
|
||||
import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||
import { sendNotification } from '../services/notificationService'
|
||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||
@@ -239,13 +240,45 @@ router.post('/:id/checkin', async (req, res, next) => {
|
||||
router.post('/:id/checkout', async (req, res, next) => {
|
||||
try {
|
||||
const { mileage } = z.object({ mileage: z.number().int().optional() }).parse(req.body)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true },
|
||||
})
|
||||
if (reservation.status !== 'ACTIVE') return res.status(400).json({ error: 'invalid_status', message: 'Only ACTIVE reservations can be checked out', statusCode: 400 })
|
||||
const updated = await prisma.reservation.update({ where: { id: req.params.id }, data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null } })
|
||||
|
||||
const reviewToken = crypto.randomBytes(32).toString('hex')
|
||||
const updated = await prisma.reservation.update({
|
||||
where: { id: req.params.id },
|
||||
data: { status: 'COMPLETED', checkedOutAt: new Date(), checkOutMileage: mileage ?? null, reviewToken },
|
||||
})
|
||||
await prisma.vehicle.update({ where: { id: reservation.vehicleId }, data: { status: 'AVAILABLE', mileage: mileage ?? undefined } })
|
||||
|
||||
// Send "How was your rental?" email with secure one-time review link
|
||||
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: reservation.customerId } })
|
||||
await sendNotification({ type: 'REVIEW_REQUEST', title: 'How was your rental?', body: 'Please leave a review for your recent rental.', companyId: req.companyId, email: customer.email, channels: ['EMAIL'] }).catch(() => null)
|
||||
const company = await prisma.company.findUnique({ where: { id: req.companyId }, include: { brand: { select: { displayName: true } } } })
|
||||
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
|
||||
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
|
||||
const marketplaceUrl = process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
|
||||
const reviewUrl = `${marketplaceUrl}/review?token=${reviewToken}`
|
||||
|
||||
sendTransactionalEmail({
|
||||
to: customer.email,
|
||||
subject: `How was your rental? — ${vehicleLabel}`,
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:auto;padding:32px;color:#1c1917">
|
||||
<h2 style="margin-bottom:4px">Hi ${customer.firstName},</h2>
|
||||
<p style="color:#57534e">Thank you for renting with <strong>${companyName}</strong>. We hope you enjoyed your <strong>${vehicleLabel}</strong>.</p>
|
||||
<p style="color:#57534e">Your feedback helps the company improve and helps other customers make informed choices. It only takes 30 seconds.</p>
|
||||
<div style="margin:32px 0;text-align:center">
|
||||
<a href="${reviewUrl}" style="background:#1c1917;color:#ffffff;padding:14px 32px;border-radius:999px;text-decoration:none;font-weight:600;font-size:15px;display:inline-block">
|
||||
Leave a review
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#a8a29e;font-size:12px">This link is unique to your reservation and can only be used once. If you did not rent a vehicle recently, you can ignore this email.</p>
|
||||
</div>
|
||||
`,
|
||||
text: `Hi ${customer.firstName},\n\nThank you for renting with ${companyName}. We hope you enjoyed your ${vehicleLabel}.\n\nLeave a review here (one-time link):\n${reviewUrl}\n\nThank you!`,
|
||||
}).catch(() => null)
|
||||
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
|
||||
@@ -308,6 +308,7 @@ router.post('/:slug/book', async (req, res, next) => {
|
||||
customerId: customer.id,
|
||||
offerId: body.offerId ?? null,
|
||||
promoCodeUsed: body.promoCodeUsed ?? null,
|
||||
vehicleCategory: vehicle.category,
|
||||
source: body.source,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
|
||||
@@ -28,6 +28,7 @@ const vehicleSchema = z.object({
|
||||
mileage: z.number().int().optional(),
|
||||
notes: z.string().optional(),
|
||||
isPublished: z.boolean().default(true),
|
||||
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
|
||||
})
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
@@ -65,9 +66,14 @@ router.get('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const body = vehicleSchema.partial().parse(req.body)
|
||||
const vehicle = await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: body })
|
||||
if (vehicle.count === 0) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
|
||||
const updated = await prisma.vehicle.findUniqueOrThrow({ where: { id: req.params.id } })
|
||||
if (body.status === 'MAINTENANCE' || body.status === 'OUT_OF_SERVICE') {
|
||||
body.isPublished = false
|
||||
} else if (body.status === 'AVAILABLE' || body.status === 'RENTED') {
|
||||
body.isPublished = true
|
||||
}
|
||||
const existing = await prisma.vehicle.findFirst({ where: { id: req.params.id, companyId: req.companyId } })
|
||||
if (!existing) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
|
||||
const updated = await prisma.vehicle.update({ where: { id: req.params.id }, data: body })
|
||||
res.json({ data: updated })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
|
||||
@@ -45,13 +46,8 @@ const INVOICE_STATUS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
const PLAN_FEATURES: Record<Plan, string[]> = {
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
|
||||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -63,6 +59,131 @@ export default function BillingPage() {
|
||||
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Billing',
|
||||
subtitle: 'Manage your plan, payment provider, and invoice history.',
|
||||
trial: 'Free trial',
|
||||
remaining: 'remaining. Subscribe before it ends to keep access.',
|
||||
currentPlan: 'Current plan',
|
||||
renews: 'renews',
|
||||
cancelScheduled: 'Cancellation scheduled at end of billing period.',
|
||||
undo: 'Undo',
|
||||
cancelling: 'Cancelling…',
|
||||
cancelPlan: 'Cancel plan',
|
||||
changePlan: 'Change plan',
|
||||
subscribe: 'Subscribe',
|
||||
selectPlan: 'Select a plan and payment provider to proceed.',
|
||||
monthly: 'Monthly',
|
||||
annual: 'Annual (save ~17%)',
|
||||
active: 'Active',
|
||||
perMonthShort: 'mo',
|
||||
perYearShort: 'yr',
|
||||
paymentProvider: 'Payment provider',
|
||||
total: 'Total',
|
||||
perMonth: 'month',
|
||||
perYear: 'year',
|
||||
redirecting: 'Redirecting…',
|
||||
subscribeNow: 'Subscribe now',
|
||||
invoiceHistory: 'Invoice history',
|
||||
date: 'Date',
|
||||
provider: 'Provider',
|
||||
status: 'Status',
|
||||
paid: 'Paid',
|
||||
amount: 'Amount',
|
||||
loading: 'Loading…',
|
||||
noInvoices: 'No invoices yet.',
|
||||
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
|
||||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||||
} as Record<Plan, string[]>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Facturation',
|
||||
subtitle: 'Gérez votre plan, le prestataire de paiement et l’historique des factures.',
|
||||
trial: 'Essai gratuit',
|
||||
remaining: 'restants. Abonnez-vous avant la fin pour garder l’accès.',
|
||||
currentPlan: 'Plan actuel',
|
||||
renews: 'renouvelle le',
|
||||
cancelScheduled: 'Annulation programmée à la fin de la période.',
|
||||
undo: 'Annuler',
|
||||
cancelling: 'Annulation…',
|
||||
cancelPlan: 'Annuler le plan',
|
||||
changePlan: 'Changer de plan',
|
||||
subscribe: 'S’abonner',
|
||||
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
|
||||
monthly: 'Mensuel',
|
||||
annual: 'Annuel (économie ~17%)',
|
||||
active: 'Actif',
|
||||
perMonthShort: 'mois',
|
||||
perYearShort: 'an',
|
||||
paymentProvider: 'Prestataire de paiement',
|
||||
total: 'Total',
|
||||
perMonth: 'mois',
|
||||
perYear: 'an',
|
||||
redirecting: 'Redirection…',
|
||||
subscribeNow: 'S’abonner maintenant',
|
||||
invoiceHistory: 'Historique des factures',
|
||||
date: 'Date',
|
||||
provider: 'Prestataire',
|
||||
status: 'Statut',
|
||||
paid: 'Payé',
|
||||
amount: 'Montant',
|
||||
loading: 'Chargement…',
|
||||
noInvoices: 'Aucune facture pour le moment.',
|
||||
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
|
||||
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
|
||||
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
|
||||
} as Record<Plan, string[]>,
|
||||
},
|
||||
ar: {
|
||||
title: 'الفوترة',
|
||||
subtitle: 'إدارة الخطة ومزوّد الدفع وسجل الفواتير.',
|
||||
trial: 'تجربة مجانية',
|
||||
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
|
||||
currentPlan: 'الخطة الحالية',
|
||||
renews: 'يتجدد في',
|
||||
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
|
||||
undo: 'تراجع',
|
||||
cancelling: 'جارٍ الإلغاء…',
|
||||
cancelPlan: 'إلغاء الخطة',
|
||||
changePlan: 'تغيير الخطة',
|
||||
subscribe: 'اشتراك',
|
||||
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
|
||||
monthly: 'شهري',
|
||||
annual: 'سنوي (توفير ~17%)',
|
||||
active: 'نشط',
|
||||
perMonthShort: 'شهر',
|
||||
perYearShort: 'سنة',
|
||||
paymentProvider: 'مزوّد الدفع',
|
||||
total: 'الإجمالي',
|
||||
perMonth: 'شهر',
|
||||
perYear: 'سنة',
|
||||
redirecting: 'جارٍ التحويل…',
|
||||
subscribeNow: 'اشترك الآن',
|
||||
invoiceHistory: 'سجل الفواتير',
|
||||
date: 'التاريخ',
|
||||
provider: 'المزوّد',
|
||||
status: 'الحالة',
|
||||
paid: 'مدفوع',
|
||||
amount: 'المبلغ',
|
||||
loading: 'جارٍ التحميل…',
|
||||
noInvoices: 'لا توجد فواتير حتى الآن.',
|
||||
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
|
||||
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
|
||||
planFeatures: {
|
||||
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
|
||||
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
|
||||
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
|
||||
} as Record<Plan, string[]>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -139,8 +260,8 @@ export default function BillingPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Billing</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Manage your plan, payment provider, and invoice history.</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -151,7 +272,7 @@ export default function BillingPage() {
|
||||
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
|
||||
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-sky-800">
|
||||
Free trial — <strong>{daysLeft} days</strong> remaining. Subscribe before it ends to keep access.
|
||||
{copy.trial} — <strong>{daysLeft} days</strong> {copy.remaining}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -161,21 +282,21 @@ export default function BillingPage() {
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">Current plan</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{subscription.status}
|
||||
{copy.statusLabels[subscription.status] ?? subscription.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{subscription.billingPeriod} · {subscription.currency}
|
||||
{subscription.currentPeriodEnd && ` · renews ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
|
||||
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
|
||||
</p>
|
||||
{subscription.cancelAtPeriodEnd && (
|
||||
<p className="mt-2 text-sm font-medium text-amber-700">
|
||||
Cancellation scheduled at end of billing period.{' '}
|
||||
<button onClick={handleResume} disabled={cancelling} className="underline">Undo</button>
|
||||
{copy.cancelScheduled}{' '}
|
||||
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -185,7 +306,7 @@ export default function BillingPage() {
|
||||
disabled={cancelling}
|
||||
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
|
||||
>
|
||||
{cancelling ? 'Cancelling…' : 'Cancel plan'}
|
||||
{cancelling ? copy.cancelling : copy.cancelPlan}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -196,9 +317,9 @@ export default function BillingPage() {
|
||||
<div className="card p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">
|
||||
{subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe'}
|
||||
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Select a plan and payment provider to proceed.</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
|
||||
</div>
|
||||
|
||||
{/* Billing period toggle */}
|
||||
@@ -211,7 +332,7 @@ export default function BillingPage() {
|
||||
billingPeriod === p ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{p === 'MONTHLY' ? 'Monthly' : 'Annual (save ~17%)'}
|
||||
{p === 'MONTHLY' ? copy.monthly : copy.annual}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -248,14 +369,14 @@ export default function BillingPage() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-slate-900">{plan}</p>
|
||||
{isActive && <span className="badge-green">Active</span>}
|
||||
{isActive && <span className="badge-green">{copy.active}</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-black text-slate-900">
|
||||
{price ? formatCurrency(price, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? 'mo' : 'yr'}</span>
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
|
||||
</p>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{PLAN_FEATURES[plan].map((f) => (
|
||||
{copy.planFeatures[plan].map((f) => (
|
||||
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
|
||||
<span className="text-green-500">✓</span> {f}
|
||||
</li>
|
||||
@@ -268,7 +389,7 @@ export default function BillingPage() {
|
||||
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">Payment provider</p>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
|
||||
<div className="flex gap-3">
|
||||
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
|
||||
<button
|
||||
@@ -287,10 +408,10 @@ export default function BillingPage() {
|
||||
{/* Checkout CTA */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Total</p>
|
||||
<p className="text-sm text-slate-500">{copy.total}</p>
|
||||
<p className="text-xl font-black text-slate-900">
|
||||
{planPrice ? formatCurrency(planPrice, currency) : '—'}
|
||||
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? 'month' : 'year'}</span>
|
||||
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -298,7 +419,7 @@ export default function BillingPage() {
|
||||
disabled={paying || loading}
|
||||
className="btn-primary px-8 py-3"
|
||||
>
|
||||
{paying ? 'Redirecting…' : subscription?.status === 'ACTIVE' ? 'Change plan' : 'Subscribe now'}
|
||||
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,31 +427,31 @@ export default function BillingPage() {
|
||||
{/* Invoice history */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-base font-semibold text-slate-900">Invoice history</h3>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Date</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Provider</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Paid</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Amount</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">Loading…</td></tr>
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">No invoices yet.</td></tr>
|
||||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
|
||||
) : invoices.map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||||
{inv.status}
|
||||
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface CustomerRow {
|
||||
id: string
|
||||
@@ -14,8 +15,47 @@ interface CustomerRow {
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [rows, setRows] = useState<CustomerRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Customers',
|
||||
subtitle: 'Company-scoped CRM with license validation and risk flags.',
|
||||
customer: 'Customer',
|
||||
contact: 'Contact',
|
||||
license: 'License',
|
||||
flags: 'Flags',
|
||||
noPhone: 'No phone',
|
||||
flagged: 'Flagged',
|
||||
clear: 'Clear',
|
||||
empty: 'No customers yet.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Clients',
|
||||
subtitle: 'CRM de l’entreprise avec validation de permis et indicateurs de risque.',
|
||||
customer: 'Client',
|
||||
contact: 'Contact',
|
||||
license: 'Permis',
|
||||
flags: 'Signalements',
|
||||
noPhone: 'Pas de téléphone',
|
||||
flagged: 'Signalé',
|
||||
clear: 'Aucun risque',
|
||||
empty: 'Aucun client pour le moment.',
|
||||
},
|
||||
ar: {
|
||||
title: 'العملاء',
|
||||
subtitle: 'نظام CRM خاص بالشركة مع التحقق من الرخصة ومؤشرات المخاطر.',
|
||||
customer: 'العميل',
|
||||
contact: 'التواصل',
|
||||
license: 'الرخصة',
|
||||
flags: 'الإشارات',
|
||||
noPhone: 'لا يوجد هاتف',
|
||||
flagged: 'مُعلّم',
|
||||
clear: 'سليم',
|
||||
empty: 'لا يوجد عملاء حتى الآن.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<CustomerRow[]>('/customers?pageSize=100')
|
||||
@@ -26,8 +66,8 @@ export default function CustomersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Customers</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Company-scoped CRM with license validation and risk flags.</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
@@ -37,10 +77,10 @@ export default function CustomersPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Contact</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">License</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Flags</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.contact}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.license}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.flags}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
@@ -49,15 +89,15 @@ export default function CustomersPage() {
|
||||
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="text-sm text-slate-700">{row.email}</p>
|
||||
<p className="text-xs text-slate-500">{row.phone ?? 'No phone'}</p>
|
||||
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
|
||||
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">Flagged</span> : <span className="badge-green">Clear</span>}</td>
|
||||
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">No customers yet.</td>
|
||||
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info } from 'lucide-react'
|
||||
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import VehicleCalendar from '@/components/VehicleCalendar'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface VehicleDetail {
|
||||
id: string
|
||||
@@ -66,11 +67,210 @@ function initColorSelect(color: string) {
|
||||
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
|
||||
}
|
||||
|
||||
type Tab = 'details' | 'calendar'
|
||||
interface MaintenanceLog {
|
||||
id: string
|
||||
type: string
|
||||
description: string | null
|
||||
cost: number | null
|
||||
mileage: number | null
|
||||
performedAt: string
|
||||
nextDueAt: string | null
|
||||
nextDueMileage: number | null
|
||||
}
|
||||
|
||||
const ROUTINE_TYPES = [
|
||||
{ key: 'Oil Change', intervalMonths: 6 },
|
||||
{ key: 'Technical Inspection', intervalMonths: 12 },
|
||||
{ key: 'Vehicle Registration', intervalMonths: 12 },
|
||||
{ key: 'Car Tax', intervalMonths: 12 },
|
||||
{ key: 'Tire Rotation', intervalMonths: 6 },
|
||||
{ key: 'Brake Inspection', intervalMonths: 12 },
|
||||
{ key: 'Air Filter', intervalMonths: 12 },
|
||||
{ key: 'Cabin Filter', intervalMonths: 12 },
|
||||
{ key: 'Battery Check', intervalMonths: 12 },
|
||||
{ key: 'Belt / Chain Service', intervalMonths: 24 },
|
||||
{ key: 'Coolant Flush', intervalMonths: 24 },
|
||||
{ key: 'Transmission Service', intervalMonths: 24 },
|
||||
]
|
||||
|
||||
function serviceStatus(log: MaintenanceLog | undefined, currentMileage: number | null): 'none' | 'overdue' | 'due-soon' | 'ok' {
|
||||
if (!log) return 'none'
|
||||
const now = new Date()
|
||||
let worstLevel: 'ok' | 'due-soon' | 'overdue' = 'ok'
|
||||
|
||||
if (log.nextDueAt) {
|
||||
const diffDays = (new Date(log.nextDueAt).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (diffDays < 0) return 'overdue'
|
||||
if (diffDays <= 30) worstLevel = 'due-soon'
|
||||
}
|
||||
|
||||
if (log.nextDueMileage != null && currentMileage != null) {
|
||||
const kmLeft = log.nextDueMileage - currentMileage
|
||||
if (kmLeft <= 0) return 'overdue'
|
||||
if (kmLeft <= 500 && worstLevel !== 'overdue') worstLevel = 'due-soon'
|
||||
}
|
||||
|
||||
return worstLevel
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: ReturnType<typeof serviceStatus> }) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const vd = dict.vehicleDetail
|
||||
if (status === 'none') return <span className="badge-gray text-xs">{vd.statusNone}</span>
|
||||
if (status === 'overdue') return <span className="badge-red text-xs">{vd.statusOverdue}</span>
|
||||
if (status === 'due-soon') return <span className="badge-amber text-xs">{vd.statusDueSoon}</span>
|
||||
return <span className="badge-green text-xs">{vd.statusOk}</span>
|
||||
}
|
||||
|
||||
function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, log, onLogged }: {
|
||||
vehicleId: string
|
||||
typeKey: string
|
||||
intervalMonths: number
|
||||
currentMileage: number | null
|
||||
log: MaintenanceLog | undefined
|
||||
onLogged: () => void
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const vd = dict.vehicleDetail
|
||||
const [open, setOpen] = useState(false)
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const defaultNextDue = (() => {
|
||||
const d = new Date()
|
||||
d.setMonth(d.getMonth() + intervalMonths)
|
||||
return d.toISOString().slice(0, 10)
|
||||
})()
|
||||
const [form, setForm] = useState({
|
||||
description: '', cost: '', mileage: '', performedAt: today,
|
||||
nextDueAt: defaultNextDue, nextDueMileage: '',
|
||||
})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.performedAt) { setError(vd.dateRequired); return }
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: typeKey,
|
||||
description: form.description || undefined,
|
||||
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
|
||||
mileage: form.mileage ? parseInt(form.mileage) : undefined,
|
||||
performedAt: new Date(form.performedAt).toISOString(),
|
||||
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
|
||||
nextDueMileage: form.nextDueMileage ? parseInt(form.nextDueMileage) : undefined,
|
||||
}),
|
||||
})
|
||||
setForm({ description: '', cost: '', mileage: '', performedAt: today, nextDueAt: defaultNextDue, nextDueMileage: '' })
|
||||
setOpen(false)
|
||||
onLogged()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? vd.savingLabel)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const status = serviceStatus(log, currentMileage)
|
||||
const displayLabel = vd.routineTypes[typeKey] ?? typeKey
|
||||
|
||||
const nextDueText = (() => {
|
||||
if (!log) return vd.noRecord
|
||||
const parts: string[] = [`${vd.lastPrefix} ${new Date(log.performedAt).toLocaleDateString()}`]
|
||||
if (log.mileage) parts.push(vd.atKm(log.mileage.toLocaleString()))
|
||||
const dueParts: string[] = []
|
||||
if (log.nextDueAt) dueParts.push(new Date(log.nextDueAt).toLocaleDateString())
|
||||
if (log.nextDueMileage != null) dueParts.push(`${log.nextDueMileage.toLocaleString()} km`)
|
||||
if (dueParts.length) parts.push(`${vd.duePrefix} ${dueParts.join(` ${vd.orSep} `)}`)
|
||||
if (currentMileage != null && log.nextDueMileage != null) {
|
||||
const kmLeft = log.nextDueMileage - currentMileage
|
||||
parts.push(`(${kmLeft > 0 ? vd.kmLeft(kmLeft.toLocaleString()) : vd.overdueByKm})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-100 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white hover:bg-slate-50 transition-colors">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Wrench className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{displayLabel}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5 truncate">{nextDueText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<StatusPill status={status} />
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{vd.logBtn}
|
||||
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<form onSubmit={handleSubmit} className="border-t border-slate-100 bg-slate-50 px-4 py-4 space-y-3">
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.serviceDateLabel}</label>
|
||||
<input type="date" className="input-field text-sm" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.odometerAtService}</label>
|
||||
<input type="number" className="input-field text-sm" placeholder={currentMileage ? String(currentMileage) : 'e.g. 52000'} min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueDateLabel}</label>
|
||||
<input type="date" className="input-field text-sm" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueKmLabel}</label>
|
||||
<input type="number" className="input-field text-sm" placeholder="e.g. 60000" min="0" value={form.nextDueMileage} onChange={(e) => setForm({ ...form, nextDueMileage: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.costLabel}</label>
|
||||
<input type="number" className="input-field text-sm" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.maintenanceNotesLabel}</label>
|
||||
<input className="input-field text-sm" placeholder={vd.optional} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setOpen(false)} className="btn-secondary text-xs py-1.5 px-3">{vd.cancelBtn}</button>
|
||||
<button type="submit" disabled={saving} className="btn-primary text-xs py-1.5 px-3">{saving ? vd.savingLabel : vd.saveLabel}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type Tab = 'details' | 'maintenance' | 'calendar'
|
||||
|
||||
export default function FleetDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const { dict } = useDashboardI18n()
|
||||
const vd = dict.vehicleDetail
|
||||
const fl = dict.fleet
|
||||
|
||||
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('details')
|
||||
@@ -78,7 +278,6 @@ export default function FleetDetailPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
|
||||
// edit form state
|
||||
const [form, setForm] = useState<{
|
||||
make: string; model: string; year: number; category: string
|
||||
dailyRate: string; licensePlate: string; color: string
|
||||
@@ -89,7 +288,16 @@ export default function FleetDetailPage() {
|
||||
const [modelSelect, setModelSelect] = useState('')
|
||||
const [colorSelect, setColorSelect] = useState('')
|
||||
|
||||
// photo management
|
||||
const [maintenanceLogs, setMaintenanceLogs] = useState<MaintenanceLog[]>([])
|
||||
|
||||
const fetchMaintenance = () => {
|
||||
apiFetch<MaintenanceLog[]>(`/vehicles/${params.id}/maintenance`)
|
||||
.then((logs) => setMaintenanceLogs(logs ?? []))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => { fetchMaintenance() }, [params.id])
|
||||
|
||||
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
|
||||
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
|
||||
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
|
||||
@@ -105,18 +313,13 @@ export default function FleetDetailPage() {
|
||||
const startEdit = () => {
|
||||
if (!vehicle) return
|
||||
setForm({
|
||||
make: vehicle.make,
|
||||
model: vehicle.model,
|
||||
year: vehicle.year,
|
||||
make: vehicle.make, model: vehicle.model, year: vehicle.year,
|
||||
category: vehicle.category,
|
||||
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
|
||||
licensePlate: vehicle.licensePlate,
|
||||
color: vehicle.color ?? '',
|
||||
seats: vehicle.seats,
|
||||
transmission: vehicle.transmission,
|
||||
fuelType: vehicle.fuelType,
|
||||
status: vehicle.status,
|
||||
notes: vehicle.notes ?? '',
|
||||
color: vehicle.color ?? '', seats: vehicle.seats,
|
||||
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
|
||||
status: vehicle.status, notes: vehicle.notes ?? '',
|
||||
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
|
||||
})
|
||||
setMakeSelect(initMakeSelect(vehicle.make))
|
||||
@@ -137,11 +340,11 @@ export default function FleetDetailPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form || !vehicle) return
|
||||
if (!form.make) { setSaveError('Make is required.'); return }
|
||||
if (!form.model) { setSaveError('Model is required.'); return }
|
||||
if (!form.licensePlate) { setSaveError('License plate is required.'); return }
|
||||
if (!form.make) { setSaveError(vd.makeRequired); return }
|
||||
if (!form.model) { setSaveError(vd.modelRequired); return }
|
||||
if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
|
||||
const rate = parseFloat(form.dailyRate)
|
||||
if (isNaN(rate) || rate < 0) { setSaveError('Please enter a valid daily rate.'); return }
|
||||
if (isNaN(rate) || rate < 0) { setSaveError(vd.rateInvalid); return }
|
||||
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
@@ -149,17 +352,11 @@ export default function FleetDetailPage() {
|
||||
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
make: form.make,
|
||||
model: form.model,
|
||||
year: Number(form.year),
|
||||
category: form.category,
|
||||
dailyRate: Math.round(rate * 100),
|
||||
licensePlate: form.licensePlate,
|
||||
color: form.color,
|
||||
seats: Number(form.seats),
|
||||
transmission: form.transmission,
|
||||
fuelType: form.fuelType,
|
||||
status: form.status,
|
||||
make: form.make, model: form.model, year: Number(form.year),
|
||||
category: form.category, dailyRate: Math.round(rate * 100),
|
||||
licensePlate: form.licensePlate, color: form.color,
|
||||
seats: Number(form.seats), transmission: form.transmission,
|
||||
fuelType: form.fuelType, status: form.status,
|
||||
notes: form.notes || undefined,
|
||||
mileage: form.mileage ? Number(form.mileage) : undefined,
|
||||
}),
|
||||
@@ -181,7 +378,7 @@ export default function FleetDetailPage() {
|
||||
setEditing(false)
|
||||
setForm(null)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message ?? 'Failed to save changes.')
|
||||
setSaveError(err.message ?? vd.failedSave)
|
||||
setUploadingPhotos(false)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -204,7 +401,7 @@ export default function FleetDetailPage() {
|
||||
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
if (!files.length) return
|
||||
const totalExisting = (vehicle?.photos.length ?? 0)
|
||||
const totalExisting = vehicle?.photos.length ?? 0
|
||||
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
|
||||
setNewPhotoFiles(combined)
|
||||
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
|
||||
@@ -217,12 +414,17 @@ export default function FleetDetailPage() {
|
||||
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
|
||||
}
|
||||
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">Loading vehicle…</div>
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">{vd.loadingVehicle}</div>
|
||||
|
||||
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
|
||||
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
|
||||
|
||||
const categoryLabel = fl.categoryLabels[vehicle.category as keyof typeof fl.categoryLabels] ?? vehicle.category
|
||||
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
|
||||
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
|
||||
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -233,21 +435,21 @@ export default function FleetDetailPage() {
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {vehicle.status}</p>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{vehicle.year} · {vehicle.licensePlate} · {statusLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
|
||||
<Edit className="w-4 h-4" /> Edit
|
||||
<Edit className="w-4 h-4" /> {vd.editBtn}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
|
||||
<X className="w-4 h-4" /> Cancel
|
||||
<X className="w-4 h-4" /> {vd.cancelBtn}
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
|
||||
<Check className="w-4 h-4" />
|
||||
{saving ? (uploadingPhotos ? 'Uploading photos…' : 'Saving…') : 'Save'}
|
||||
{saving ? (uploadingPhotos ? vd.uploadingPhotosBtn : vd.savingBtn) : vd.saveBtn}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -259,271 +461,276 @@ export default function FleetDetailPage() {
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={[
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'details'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<Info className="w-4 h-4" /> Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
className={[
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'calendar'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<CalendarDays className="w-4 h-4" /> Calendar
|
||||
</button>
|
||||
{(['details', 'maintenance', 'calendar'] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={[
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === tab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab === 'details' && <><Info className="w-4 h-4" /> {vd.tabDetails}</>}
|
||||
{tab === 'maintenance' && (
|
||||
<>
|
||||
<Wrench className="w-4 h-4" /> {vd.tabMaintenance}
|
||||
{maintenanceLogs.length === 0 && <span className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-amber-100 text-amber-700 text-[10px] font-bold">!</span>}
|
||||
</>
|
||||
)}
|
||||
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
{/* Photos */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Photos</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{/* Existing photos */}
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
|
||||
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
|
||||
{editing && (
|
||||
{/* Photos */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.photosHeading}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{vehicle.photos.map((photo, index) => (
|
||||
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
|
||||
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => handleDeletePhoto(index)}
|
||||
disabled={deletingPhotoIdx === index}
|
||||
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
|
||||
>
|
||||
{deletingPhotoIdx === index
|
||||
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{editing && newPhotoPreviews.map((src, i) => (
|
||||
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
|
||||
<button
|
||||
onClick={() => handleDeletePhoto(index)}
|
||||
disabled={deletingPhotoIdx === index}
|
||||
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
|
||||
onClick={() => removeNewPhoto(i)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{deletingPhotoIdx === index
|
||||
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />}
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">{vd.newPhotoBadge}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* New photo previews (edit mode) */}
|
||||
{editing && newPhotoPreviews.map((src, i) => (
|
||||
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
|
||||
{vehicle.photos.length === 0 && !editing && (
|
||||
<div className="text-sm text-slate-400">{vd.noPhotosYet}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && totalPhotos < 10 && (
|
||||
<div className="mt-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
|
||||
<button
|
||||
onClick={() => removeNewPhoto(i)}
|
||||
className="absolute top-2 right-2 p-1.5 bg-black/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<Upload className="w-4 h-4" />
|
||||
{totalPhotos === 0 ? vd.clickToAddPhotos : vd.addMorePhotos(totalPhotos)}
|
||||
</button>
|
||||
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">New</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{vehicle.photos.length === 0 && !editing && (
|
||||
<div className="text-sm text-slate-400">No photos uploaded yet.</div>
|
||||
)}
|
||||
{editing && totalPhotos >= 10 && (
|
||||
<p className="mt-3 text-xs text-slate-400 text-center">{vd.maxPhotosReached}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload new photos (edit mode) */}
|
||||
{editing && totalPhotos < 10 && (
|
||||
<div className="mt-4">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{totalPhotos === 0 ? 'Click to add photos' : `Add more photos (${totalPhotos}/10)`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{editing && totalPhotos >= 10 && (
|
||||
<p className="mt-3 text-xs text-slate-400 text-center">Maximum 10 photos reached.</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Details / Edit form */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.vehicleDetailsHeading}</h3>
|
||||
|
||||
{/* Details / Edit form */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-4">Vehicle details</h3>
|
||||
|
||||
{!editing || !form ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div><dt className="text-slate-500">Category</dt><dd className="text-slate-900">{vehicle.category}</dd></div>
|
||||
<div><dt className="text-slate-500">Daily rate</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Status</dt><dd className="text-slate-900">{vehicle.status}</dd></div>
|
||||
<div><dt className="text-slate-500">Seats</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
||||
<div><dt className="text-slate-500">Transmission</dt><dd className="text-slate-900">{vehicle.transmission}</dd></div>
|
||||
<div><dt className="text-slate-500">Fuel type</dt><dd className="text-slate-900">{vehicle.fuelType}</dd></div>
|
||||
<div><dt className="text-slate-500">Color</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
|
||||
<div><dt className="text-slate-500">Odometer</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
|
||||
<div><dt className="text-slate-500">Notes</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Make */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={makeSelect}
|
||||
onChange={(e) => {
|
||||
setMakeSelect(e.target.value)
|
||||
setModelSelect('')
|
||||
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
|
||||
else setForm({ ...form, make: '', model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select make…</option>
|
||||
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{makeSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type make…" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
||||
{!presetModels ? (
|
||||
<input className="input-field" placeholder="Model" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className="input-field"
|
||||
value={modelSelect}
|
||||
onChange={(e) => {
|
||||
setModelSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
|
||||
else setForm({ ...form, model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select model…</option>
|
||||
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
</select>
|
||||
{modelSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type model…" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year & Category */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{!editing || !form ? (
|
||||
<dl className="space-y-3 text-sm">
|
||||
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
|
||||
<div><dt className="text-slate-500">{vd.labelNotes}</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
{/* Make */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
||||
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Rate & License Plate */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
||||
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
|
||||
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Status</label>
|
||||
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Color & Seats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.makeLabel}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={colorSelect}
|
||||
value={makeSelect}
|
||||
onChange={(e) => {
|
||||
setColorSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
|
||||
else setForm({ ...form, color: '' })
|
||||
setMakeSelect(e.target.value)
|
||||
setModelSelect('')
|
||||
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
|
||||
else setForm({ ...form, make: '', model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select color…</option>
|
||||
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
<option value="">{vd.selectMake}</option>
|
||||
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">{vd.other}</option>
|
||||
</select>
|
||||
{colorSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||
{makeSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder={vd.typeMake} value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transmission & Fuel */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Model */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
|
||||
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
|
||||
<option value="MANUAL">Manual</option>
|
||||
<option value="AUTOMATIC">Automatic</option>
|
||||
</select>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.modelLabel}</label>
|
||||
{!presetModels ? (
|
||||
<input className="input-field" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
className="input-field"
|
||||
value={modelSelect}
|
||||
onChange={(e) => {
|
||||
setModelSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
|
||||
else setForm({ ...form, model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">{vd.selectModel}</option>
|
||||
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">{vd.other}</option>
|
||||
</select>
|
||||
{modelSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year & Category */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.yearLabel}</label>
|
||||
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel.replace('Type', '').trim() !== '' ? vd.fuelTypeLabel : fl.category}</label>
|
||||
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
|
||||
<option key={c} value={c}>{fl.categoryLabels[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Rate & License Plate */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
|
||||
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.licensePlateLabel}</label>
|
||||
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.statusLabel}</label>
|
||||
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||||
{(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'] as const).map((s) => (
|
||||
<option key={s} value={s}>{fl.statusLabels[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Odometer */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
placeholder="e.g. 45000"
|
||||
min="0"
|
||||
value={form.mileage}
|
||||
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{/* Color & Seats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.colorLabel}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={colorSelect}
|
||||
onChange={(e) => {
|
||||
setColorSelect(e.target.value)
|
||||
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
|
||||
else setForm({ ...form, color: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">{vd.selectColor}</option>
|
||||
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="custom">{vd.other}</option>
|
||||
</select>
|
||||
{colorSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder={vd.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.seatsLabel}</label>
|
||||
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Notes</label>
|
||||
<textarea
|
||||
className="input-field resize-none"
|
||||
rows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Optional notes…"
|
||||
/>
|
||||
{/* Transmission & Fuel */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.transmissionLabel}</label>
|
||||
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
|
||||
<option value="MANUAL">{fl.manual}</option>
|
||||
<option value="AUTOMATIC">{fl.automatic}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel}</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((f) => (
|
||||
<option key={f} value={f}>{fl.fuelTypeLabels[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Odometer */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.odometerLabel}</label>
|
||||
<input type="number" className="input-field" placeholder="e.g. 45000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.notesLabel}</label>
|
||||
<textarea className="input-field resize-none" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={vd.optionalNotes} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'maintenance' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-500">{vd.maintenanceIntro}</p>
|
||||
{ROUTINE_TYPES.map(({ key, intervalMonths }) => {
|
||||
const latest = maintenanceLogs
|
||||
.filter((l) => l.type === key)
|
||||
.sort((a, b) => new Date(b.performedAt).getTime() - new Date(a.performedAt).getTime())[0]
|
||||
return (
|
||||
<MaintenanceRow
|
||||
key={key}
|
||||
vehicleId={params.id}
|
||||
typeKey={key}
|
||||
intervalMonths={intervalMonths}
|
||||
currentMileage={vehicle.mileage}
|
||||
log={latest}
|
||||
onLogged={fetchMaintenance}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X } from 'lucide-react'
|
||||
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface Vehicle {
|
||||
id: string
|
||||
@@ -33,6 +34,193 @@ const STATUS_BADGE: Record<Vehicle['status'], string> = {
|
||||
OUT_OF_SERVICE: 'badge-red',
|
||||
}
|
||||
|
||||
const ALL_STATUSES: Vehicle['status'][] = ['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']
|
||||
|
||||
interface MaintenanceModalProps {
|
||||
vehicleId: string
|
||||
vehicleName: string
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: MaintenanceModalProps) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const [form, setForm] = useState({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const reset = () => {
|
||||
setForm({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handleClose = () => { reset(); onClose() }
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.type) { setError(f.maintenanceTypeRequired); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: form.type,
|
||||
description: form.description || undefined,
|
||||
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
|
||||
mileage: form.mileage ? parseInt(form.mileage) : undefined,
|
||||
performedAt: new Date(form.performedAt).toISOString(),
|
||||
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
|
||||
}),
|
||||
})
|
||||
onSaved()
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? f.failedToSave)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-1">{f.logMaintenance}</h2>
|
||||
<p className="text-sm text-slate-500 mb-5">{f.maintenanceSubtitle(vehicleName)}</p>
|
||||
{error && <div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.typeLabel}</label>
|
||||
<input className="input-field" placeholder={f.typePlaceholder} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.description}</label>
|
||||
<textarea className="input-field resize-none" rows={2} placeholder={f.descriptionPlaceholder} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.date}</label>
|
||||
<input type="date" className="input-field" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.nextDueDate}</label>
|
||||
<input type="date" className="input-field" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.cost}</label>
|
||||
<input type="number" className="input-field" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
|
||||
<input type="number" className="input-field" placeholder="e.g. 52000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.skip}</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? f.saving : f.saveLog}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDropdown({ vehicleId, status, onStatusChange }: {
|
||||
vehicleId: string
|
||||
status: Vehicle['status']
|
||||
onStatusChange: (id: string, status: Vehicle['status']) => void
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const openDropdown = () => {
|
||||
if (triggerRef.current) {
|
||||
const r = triggerRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = (e: MouseEvent) => {
|
||||
if (
|
||||
!triggerRef.current?.contains(e.target as Node) &&
|
||||
!panelRef.current?.contains(e.target as Node)
|
||||
) setOpen(false)
|
||||
}
|
||||
const closeOnScroll = () => setOpen(false)
|
||||
document.addEventListener('mousedown', close)
|
||||
window.addEventListener('scroll', closeOnScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', close)
|
||||
window.removeEventListener('scroll', closeOnScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const select = async (next: Vehicle['status']) => {
|
||||
if (next === status) { setOpen(false); return }
|
||||
setOpen(false)
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/vehicles/${vehicleId}`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
|
||||
onStatusChange(vehicleId, next)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={openDropdown}
|
||||
disabled={saving}
|
||||
className={`inline-flex items-center gap-1 ${STATUS_BADGE[status]} cursor-pointer select-none`}
|
||||
>
|
||||
{saving ? '…' : f.statusLabels[status]}
|
||||
<ChevronDown className="w-3 h-3 opacity-60" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
className="fixed z-50 min-w-[160px] rounded-xl border border-slate-100 bg-white shadow-lg py-1"
|
||||
>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => select(s)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-start hover:bg-slate-50 transition-colors ${s === status ? 'font-semibold' : ''}`}
|
||||
>
|
||||
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
s === 'AVAILABLE' ? 'bg-green-500' :
|
||||
s === 'RENTED' ? 'bg-blue-500' :
|
||||
s === 'MAINTENANCE' ? 'bg-amber-500' : 'bg-red-500'
|
||||
}`} />
|
||||
{f.statusLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
|
||||
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
|
||||
const MODELS_BY_MAKE: Record<string, string[]> = {
|
||||
@@ -61,6 +249,8 @@ const MODELS_BY_MAKE: Record<string, string[]> = {
|
||||
}
|
||||
|
||||
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const [form, setForm] = useState({
|
||||
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
|
||||
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
|
||||
@@ -80,7 +270,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
if (!files.length) return
|
||||
const combined = [...photoFiles, ...files].slice(0, 10)
|
||||
setPhotoFiles(combined)
|
||||
setPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
|
||||
setPhotoPreviews(combined.map((file) => URL.createObjectURL(file)))
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
@@ -107,10 +297,10 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.make) { setError('Please select or enter a make.'); return }
|
||||
if (!form.model) { setError('Please select or enter a model.'); return }
|
||||
if (!form.licensePlate) { setError('Please enter a license plate.'); return }
|
||||
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError('Please enter a valid daily rate.'); return }
|
||||
if (!form.make) { setError(f.makeMissing); return }
|
||||
if (!form.model) { setError(f.modelMissing); return }
|
||||
if (!form.licensePlate) { setError(f.plateMissing); return }
|
||||
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError(f.rateMissing); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -126,13 +316,13 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
})
|
||||
if (photoFiles.length > 0) {
|
||||
const fd = new FormData()
|
||||
photoFiles.forEach((f) => fd.append('photos', f))
|
||||
photoFiles.forEach((file) => fd.append('photos', file))
|
||||
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
|
||||
}
|
||||
onSaved()
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to add vehicle. Please check all fields and try again.')
|
||||
setError(err.message ?? f.failedToAdd)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -145,7 +335,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
|
||||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-5">Add New Vehicle</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-5">{f.addNewVehicle}</h2>
|
||||
{error && (
|
||||
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
@@ -153,7 +343,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
{/* Make & Model */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Make *</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.make}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={makeSelect}
|
||||
@@ -164,18 +354,18 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
else setForm({ ...form, make: '', model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select make…</option>
|
||||
<option value="">{f.selectMake}</option>
|
||||
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
<option value="custom">{f.other}</option>
|
||||
</select>
|
||||
{makeSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type make…" required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
|
||||
<input className="input-field mt-2" placeholder={f.typeMake} required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Model *</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.model}</label>
|
||||
{!presetModels ? (
|
||||
<input className="input-field" placeholder="Model" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
<input className="input-field" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
@@ -187,12 +377,12 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
else setForm({ ...form, model: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select model…</option>
|
||||
<option value="">{f.selectModel}</option>
|
||||
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
<option value="custom">{f.other}</option>
|
||||
</select>
|
||||
{modelSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type model…" required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
|
||||
<input className="input-field mt-2" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -202,14 +392,14 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
{/* Year & Category */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Year *</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.year}</label>
|
||||
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Category</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.category}</label>
|
||||
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
|
||||
<option key={c} value={c}>{f.categoryLabels[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -218,11 +408,11 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
{/* Daily Rate & License Plate */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Daily Rate (MAD) *</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dailyRate}</label>
|
||||
<input type="number" className="input-field" placeholder="350.00" min="0" step="0.01" required value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">License Plate *</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
|
||||
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +420,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
{/* Color, Seats, Transmission */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Color</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.color}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={colorSelect}
|
||||
@@ -240,23 +430,23 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
else setForm({ ...form, color: '' })
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
<option value="">{f.select}</option>
|
||||
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
<option value="custom">Other…</option>
|
||||
<option value="custom">{f.other}</option>
|
||||
</select>
|
||||
{colorSelect === 'custom' && (
|
||||
<input className="input-field mt-2" placeholder="Type color…" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
|
||||
<input className="input-field mt-2" placeholder={f.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Seats</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.seats}</label>
|
||||
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Transmission</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.transmission}</label>
|
||||
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
|
||||
<option value="MANUAL">Manual</option>
|
||||
<option value="AUTOMATIC">Automatic</option>
|
||||
<option value="MANUAL">{f.manual}</option>
|
||||
<option value="AUTOMATIC">{f.automatic}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,15 +454,15 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
{/* Fuel Type & Odometer */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Fuel Type</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.fuelType}</label>
|
||||
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
|
||||
{['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'].map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((fuel) => (
|
||||
<option key={fuel} value={fuel}>{f.fuelTypeLabels[fuel]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Odometer (km)</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
@@ -286,7 +476,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
|
||||
{/* Photos */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Photos (up to 10)</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
|
||||
{photoPreviews.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{photoPreviews.map((src, i) => (
|
||||
@@ -319,16 +509,16 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{photoFiles.length === 0 ? 'Click to add photos' : 'Add more photos'}
|
||||
{photoFiles.length === 0 ? f.clickToAddPhotos : f.addMorePhotos}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={handleClose} className="btn-secondary flex-1">Cancel</button>
|
||||
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.cancel}</button>
|
||||
<button type="submit" disabled={loading} className="btn-primary flex-1">
|
||||
{loading ? 'Adding…' : 'Add Vehicle'}
|
||||
{loading ? f.adding : f.addVehicle}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -338,6 +528,8 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const f = dict.fleet
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -346,6 +538,7 @@ export default function FleetPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [publishedFilter, setPublishedFilter] = useState('')
|
||||
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
|
||||
|
||||
const fetchVehicles = () => {
|
||||
setLoading(true)
|
||||
@@ -366,6 +559,16 @@ export default function FleetPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const changeStatus = (id: string, status: Vehicle['status']) => {
|
||||
const isPublished = status !== 'MAINTENANCE' && status !== 'OUT_OF_SERVICE'
|
||||
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
|
||||
setStatusFilter('')
|
||||
if (status === 'MAINTENANCE') {
|
||||
const vehicle = vehicles.find((v) => v.id === id)
|
||||
if (vehicle) setMaintenanceModal({ vehicleId: id, vehicleName: `${vehicle.make} ${vehicle.model}` })
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = vehicles.filter((v) => {
|
||||
if (search && !`${v.make} ${v.model} ${v.licensePlate}`.toLowerCase().includes(search.toLowerCase())) return false
|
||||
if (statusFilter && v.status !== statusFilter) return false
|
||||
@@ -380,11 +583,11 @@ export default function FleetPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Fleet</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{vehicles.length} vehicle{vehicles.length !== 1 ? 's' : ''} total</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
|
||||
<p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowAddModal(true)} className="btn-primary">
|
||||
<Plus className="w-4 h-4" /> Add Vehicle
|
||||
<Plus className="w-4 h-4" /> {f.addVehicle}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -394,27 +597,27 @@ export default function FleetPage() {
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
className="flex-1 text-sm outline-none placeholder:text-slate-400"
|
||||
placeholder="Search make, model, plate…"
|
||||
placeholder={f.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
|
||||
<option value="">All statuses</option>
|
||||
{['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'].map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
<option value="">{f.allStatuses}</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{f.statusLabels[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
|
||||
<option value="">All categories</option>
|
||||
{['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'].map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
<option value="">{f.allCategories}</option>
|
||||
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
|
||||
<option key={c} value={c}>{f.categoryLabels[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
|
||||
<option value="">All visibility</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="unpublished">Unpublished</option>
|
||||
<option value="">{f.allVisibility}</option>
|
||||
<option value="published">{f.published}</option>
|
||||
<option value="unpublished">{f.unpublished}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -433,12 +636,12 @@ export default function FleetPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Category</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Daily Rate</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Published</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Actions</th>
|
||||
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colVehicle}</th>
|
||||
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colCategory}</th>
|
||||
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colStatus}</th>
|
||||
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colDailyRate}</th>
|
||||
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colPublished}</th>
|
||||
<th className="text-end px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colActions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
@@ -450,7 +653,7 @@ export default function FleetPage() {
|
||||
{vehicle.primaryPhoto ? (
|
||||
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">No photo</div>
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">{f.noPhoto}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -460,32 +663,34 @@ export default function FleetPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="badge-gray">{vehicle.category}</span>
|
||||
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={STATUS_BADGE[vehicle.status]}>{vehicle.status}</span>
|
||||
<StatusDropdown vehicleId={vehicle.id} status={vehicle.status} onStatusChange={changeStatus} />
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-slate-900">
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD')}/day
|
||||
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<button
|
||||
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
|
||||
className={[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
|
||||
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
|
||||
className={[
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
|
||||
vehicle.isPublished ? 'translate-x-5' : 'translate-x-0',
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
|
||||
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
|
||||
vehicle.isPublished ? 'ltr:translate-x-5 rtl:-translate-x-5' : 'translate-x-0',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-end gap-2 rtl:justify-start">
|
||||
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
@@ -499,7 +704,7 @@ export default function FleetPage() {
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
|
||||
{vehicles.length === 0 ? 'No vehicles yet. Add your first vehicle to get started.' : 'No vehicles match your filters.'}
|
||||
{vehicles.length === 0 ? f.noVehicles : f.noMatch}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -510,6 +715,15 @@ export default function FleetPage() {
|
||||
</div>
|
||||
|
||||
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
|
||||
{maintenanceModal && (
|
||||
<MaintenanceModal
|
||||
vehicleId={maintenanceModal.vehicleId}
|
||||
vehicleName={maintenanceModal.vehicleName}
|
||||
open={true}
|
||||
onClose={() => setMaintenanceModal(null)}
|
||||
onSaved={() => setMaintenanceModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type NotificationItem = {
|
||||
id: string
|
||||
@@ -30,12 +31,90 @@ const COMPANY_EVENTS = [
|
||||
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
|
||||
|
||||
export default function DashboardNotificationsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
|
||||
const [activeTab, setActiveTab] = useState<'inbox' | 'preferences'>('inbox')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Track in-app alerts and control delivery preferences for your team account.',
|
||||
inbox: 'Inbox',
|
||||
preferences: 'Preferences',
|
||||
markAllRead: 'Mark all as read',
|
||||
loading: 'Loading notifications…',
|
||||
empty: 'No notifications yet.',
|
||||
read: 'Read',
|
||||
markRead: 'Mark as read',
|
||||
event: 'Event',
|
||||
savePreferences: 'Save preferences',
|
||||
saving: 'Saving…',
|
||||
failedLoad: 'Failed to load notifications',
|
||||
failedSave: 'Failed to save preferences',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'New reservation',
|
||||
RESERVATION_CANCELLED: 'Reservation cancelled',
|
||||
PAYMENT_RECEIVED: 'Payment received',
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
|
||||
MAINTENANCE_DUE: 'Maintenance à prévoir',
|
||||
OFFER_EXPIRING: 'Offer expiring',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Suivez les alertes de l’application 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 l’enregistrement des préférences',
|
||||
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l’application', 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 d’essai proche',
|
||||
MAINTENANCE_DUE: 'Maintenance due',
|
||||
OFFER_EXPIRING: 'Offre expirante',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'الإشعارات',
|
||||
subtitle: 'تابع تنبيهات التطبيق وتحكم في تفضيلات الإرسال لحساب فريقك.',
|
||||
inbox: 'صندوق الوارد',
|
||||
preferences: 'التفضيلات',
|
||||
markAllRead: 'تحديد الكل كمقروء',
|
||||
loading: 'جارٍ تحميل الإشعارات…',
|
||||
empty: 'لا توجد إشعارات حالياً.',
|
||||
read: 'مقروء',
|
||||
markRead: 'تحديد كمقروء',
|
||||
event: 'الحدث',
|
||||
savePreferences: 'حفظ التفضيلات',
|
||||
saving: 'جارٍ الحفظ…',
|
||||
failedLoad: 'فشل تحميل الإشعارات',
|
||||
failedSave: 'فشل حفظ التفضيلات',
|
||||
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
|
||||
events: {
|
||||
NEW_RESERVATION: 'حجز جديد',
|
||||
RESERVATION_CANCELLED: 'إلغاء حجز',
|
||||
PAYMENT_RECEIVED: 'تم استلام الدفع',
|
||||
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
|
||||
MAINTENANCE_DUE: 'صيانة مستحقة',
|
||||
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
|
||||
} as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
@@ -51,7 +130,7 @@ export default function DashboardNotificationsPage() {
|
||||
)
|
||||
setPreferences(mapped)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load notifications')
|
||||
setError(err.message ?? copy.failedLoad)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -66,11 +145,13 @@ export default function DashboardNotificationsPage() {
|
||||
setNotifications((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
|
||||
)
|
||||
window.dispatchEvent(new Event('notifications:updated'))
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
await apiFetch('/notifications/company/read-all', { method: 'POST' })
|
||||
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
|
||||
window.dispatchEvent(new Event('notifications:updated'))
|
||||
}
|
||||
|
||||
async function savePreferences() {
|
||||
@@ -86,7 +167,7 @@ export default function DashboardNotificationsPage() {
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save preferences')
|
||||
setError(err.message ?? copy.failedSave)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -107,8 +188,8 @@ export default function DashboardNotificationsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Notifications</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Track in-app alerts and control delivery preferences for your team account.</p>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">{copy.title}</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -116,14 +197,14 @@ export default function DashboardNotificationsPage() {
|
||||
onClick={() => setActiveTab('inbox')}
|
||||
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
Inbox
|
||||
{copy.inbox}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preferences')}
|
||||
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
|
||||
>
|
||||
Preferences
|
||||
{copy.preferences}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,26 +215,26 @@ export default function DashboardNotificationsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={markAllRead} className="btn-secondary">
|
||||
Mark all as read
|
||||
{copy.markAllRead}
|
||||
</button>
|
||||
</div>
|
||||
{loading ? <div className="card p-6 text-sm text-slate-500">Loading notifications…</div> : null}
|
||||
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">No notifications yet.</div> : null}
|
||||
{loading ? <div className="card p-6 text-sm text-slate-500">{copy.loading}</div> : null}
|
||||
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">{copy.empty}</div> : null}
|
||||
{!loading
|
||||
? notifications.map((notification) => (
|
||||
<article key={notification.id} className="card p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
{notification.type.replaceAll('_', ' ')}
|
||||
{copy.events[notification.type] ?? notification.type.replaceAll('_', ' ')}
|
||||
</p>
|
||||
<h2 className="mt-2 text-lg font-semibold text-slate-900">{notification.title}</h2>
|
||||
</div>
|
||||
{notification.readAt ? (
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Read</span>
|
||||
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">{copy.read}</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
|
||||
Mark as read
|
||||
{copy.markRead}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -169,10 +250,10 @@ export default function DashboardNotificationsPage() {
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600">Event</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-600">{copy.event}</th>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600">
|
||||
{channel.replace('_', ' ')}
|
||||
{copy.channels[channel] ?? channel.replace('_', ' ')}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
@@ -180,7 +261,7 @@ export default function DashboardNotificationsPage() {
|
||||
<tbody>
|
||||
{COMPANY_EVENTS.map((eventName) => (
|
||||
<tr key={eventName} className="border-t border-slate-100">
|
||||
<td className="px-4 py-3 font-medium text-slate-900">{eventName.replaceAll('_', ' ')}</td>
|
||||
<td className="px-4 py-3 font-medium text-slate-900">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
|
||||
{COMPANY_CHANNELS.map((channel) => (
|
||||
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
|
||||
<input
|
||||
@@ -198,7 +279,7 @@ export default function DashboardNotificationsPage() {
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-100 p-4">
|
||||
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save preferences'}
|
||||
{saving ? copy.saving : copy.savePreferences}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface OfferRow {
|
||||
id: string
|
||||
@@ -18,8 +19,41 @@ interface OfferRow {
|
||||
}
|
||||
|
||||
export default function OffersPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [rows, setRows] = useState<OfferRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Offers',
|
||||
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
|
||||
ends: 'ends',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
marketplace: 'Marketplace',
|
||||
featured: 'Featured',
|
||||
empty: 'No offers created yet.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Offres',
|
||||
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
|
||||
ends: 'expire le',
|
||||
active: 'Actif',
|
||||
inactive: 'Inactif',
|
||||
marketplace: 'Marketplace',
|
||||
featured: 'Mise en avant',
|
||||
empty: 'Aucune offre créée pour le moment.',
|
||||
},
|
||||
ar: {
|
||||
title: 'العروض',
|
||||
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
|
||||
ends: 'تنتهي في',
|
||||
active: 'نشط',
|
||||
inactive: 'غير نشط',
|
||||
marketplace: 'السوق',
|
||||
featured: 'مميز',
|
||||
empty: 'لا توجد عروض حتى الآن.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<OfferRow[]>('/offers')
|
||||
@@ -30,8 +64,8 @@ export default function OffersPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Offers</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Promotions shown on your site and optionally on the marketplace.</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((offer) => (
|
||||
@@ -39,13 +73,13 @@ export default function OffersPage() {
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">{offer.type} · ends {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
|
||||
<p className="text-sm text-slate-500 mt-1">{offer.type} · {copy.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
|
||||
</div>
|
||||
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? 'Active' : 'Inactive'}</span>
|
||||
<span className={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? copy.active : copy.inactive}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{offer.isPublic && <span className="badge-blue">Marketplace</span>}
|
||||
{offer.isFeatured && <span className="badge-purple">Featured</span>}
|
||||
{offer.isPublic && <span className="badge-blue">{copy.marketplace}</span>}
|
||||
{offer.isFeatured && <span className="badge-purple">{copy.featured}</span>}
|
||||
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
|
||||
</div>
|
||||
<p className="mt-4 text-2xl font-bold text-slate-900">
|
||||
@@ -54,7 +88,7 @@ export default function OffersPage() {
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && !error && (
|
||||
<div className="card p-8 text-sm text-slate-400">No offers created yet.</div>
|
||||
<div className="card p-8 text-sm text-slate-400">{copy.empty}</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface OnlineReservation {
|
||||
id: string
|
||||
@@ -71,6 +72,51 @@ function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: st
|
||||
}
|
||||
|
||||
export default function OnlineReservationsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Online Reservations',
|
||||
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
|
||||
refresh: 'Refresh',
|
||||
pendingApproval: 'Pending approval',
|
||||
loading: 'Loading…',
|
||||
allCaughtUp: 'All caught up — no pending requests.',
|
||||
history: 'History',
|
||||
customer: 'Customer',
|
||||
vehicle: 'Vehicle',
|
||||
dates: 'Dates',
|
||||
status: 'Status',
|
||||
total: 'Total',
|
||||
},
|
||||
fr: {
|
||||
title: 'Réservations en ligne',
|
||||
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
|
||||
refresh: 'Actualiser',
|
||||
pendingApproval: 'En attente de validation',
|
||||
loading: 'Chargement…',
|
||||
allCaughtUp: 'Tout est à jour, aucune demande en attente.',
|
||||
history: 'Historique',
|
||||
customer: 'Client',
|
||||
vehicle: 'Véhicule',
|
||||
dates: 'Dates',
|
||||
status: 'Statut',
|
||||
total: 'Total',
|
||||
},
|
||||
ar: {
|
||||
title: 'الحجوزات الإلكترونية',
|
||||
subtitle: 'طلبات مقدمة من العملاء عبر السوق. قم بالتأكيد أو الرفض لكل طلب.',
|
||||
refresh: 'تحديث',
|
||||
pendingApproval: 'بانتظار الموافقة',
|
||||
loading: 'جارٍ التحميل…',
|
||||
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
|
||||
history: 'السجل',
|
||||
customer: 'العميل',
|
||||
vehicle: 'المركبة',
|
||||
dates: 'التواريخ',
|
||||
status: 'الحالة',
|
||||
total: 'الإجمالي',
|
||||
},
|
||||
}[language]
|
||||
const [rows, setRows] = useState<OnlineReservation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -144,15 +190,15 @@ export default function OnlineReservationsPage() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-slate-900">Online Reservations</h2>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
{pending.length > 0 && (
|
||||
<span className="rounded-full bg-amber-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">Requests submitted by customers through the marketplace. Confirm or decline each one.</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
|
||||
Refresh
|
||||
{copy.refresh}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -163,16 +209,16 @@ export default function OnlineReservationsPage() {
|
||||
{/* Pending section */}
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Clock className="h-4 w-4" /> Pending approval
|
||||
<Clock className="h-4 w-4" /> {copy.pendingApproval}
|
||||
{pending.length > 0 && <span className="ml-1 text-amber-600">({pending.length})</span>}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="card p-8 text-center text-sm text-slate-400">Loading…</div>
|
||||
<div className="card p-8 text-center text-sm text-slate-400">{copy.loading}</div>
|
||||
) : pending.length === 0 ? (
|
||||
<div className="card flex flex-col items-center gap-3 p-10 text-center">
|
||||
<CheckCircle className="h-10 w-10 text-emerald-400" />
|
||||
<p className="text-sm font-medium text-slate-600">All caught up — no pending requests.</p>
|
||||
<p className="text-sm font-medium text-slate-600">{copy.allCaughtUp}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -193,17 +239,17 @@ export default function OnlineReservationsPage() {
|
||||
{!loading && history.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
History
|
||||
{copy.history}
|
||||
</h3>
|
||||
<div className="card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50">
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Customer</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Dates</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Status</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
|
||||
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
|
||||
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
|
||||
<th className="px-5 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import StatCard from '@/components/ui/StatCard'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import dayjs from 'dayjs'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
interface DashboardData {
|
||||
@@ -45,26 +45,44 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
CANCELLED: 'badge-red',
|
||||
}
|
||||
|
||||
function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string; planName: string; trialEndsAt: string | null }) {
|
||||
function SubscriptionBanner({
|
||||
status,
|
||||
trialEndsAt,
|
||||
trialEndsMsg,
|
||||
trialActiveMsg,
|
||||
pastDueMsg,
|
||||
suspendedMsg,
|
||||
manageBillingLabel,
|
||||
}: {
|
||||
status: string
|
||||
trialEndsAt: string | null
|
||||
trialEndsMsg: (date: string) => string
|
||||
trialActiveMsg: string
|
||||
pastDueMsg: string
|
||||
suspendedMsg: string
|
||||
manageBillingLabel: string
|
||||
}) {
|
||||
if (status === 'ACTIVE') return null
|
||||
|
||||
const localeDate = trialEndsAt
|
||||
? new Date(trialEndsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: ''
|
||||
|
||||
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
|
||||
TRIALING: {
|
||||
bg: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
message: trialEndsAt
|
||||
? `You're on a free trial. Trial ends ${dayjs(trialEndsAt).format('MMM D, YYYY')}.`
|
||||
: "You're on a free trial.",
|
||||
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
|
||||
},
|
||||
PAST_DUE: {
|
||||
bg: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
message: 'Your payment is past due. Please update your billing information.',
|
||||
message: pastDueMsg,
|
||||
},
|
||||
SUSPENDED: {
|
||||
bg: 'bg-red-50 border-red-200 text-red-800',
|
||||
icon: <XCircle className="w-4 h-4" />,
|
||||
message: 'Your account has been suspended. Please contact support or renew your subscription.',
|
||||
message: suspendedMsg,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,13 +94,17 @@ function SubscriptionBanner({ status, planName, trialEndsAt }: { status: string;
|
||||
{config.icon}
|
||||
<p className="text-sm font-medium">{config.message}</p>
|
||||
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
|
||||
Manage billing →
|
||||
{manageBillingLabel}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const d = dict.dashboardPage
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -105,7 +127,7 @@ export default function DashboardPage() {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-red-600 font-medium">Failed to load dashboard</p>
|
||||
<p className="text-red-600 font-medium">{d.failedToLoad}</p>
|
||||
<p className="text-slate-500 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
)
|
||||
@@ -113,46 +135,59 @@ export default function DashboardPage() {
|
||||
|
||||
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||
const formatDateYear = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{data?.subscription && (
|
||||
<SubscriptionBanner
|
||||
status={data.subscription.status}
|
||||
planName={data.subscription.planName}
|
||||
trialEndsAt={data.subscription.trialEndsAt}
|
||||
trialEndsMsg={d.trialEnds}
|
||||
trialActiveMsg={d.trialActive}
|
||||
pastDueMsg={d.pastDueMsg}
|
||||
suspendedMsg={d.suspendedMsg}
|
||||
manageBillingLabel={d.manageBilling}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="Total Bookings"
|
||||
title={d.kpiTotalBookings}
|
||||
value={kpis.totalBookings.toLocaleString()}
|
||||
change={kpis.bookingsChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Calendar}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Vehicles"
|
||||
title={d.kpiActiveVehicles}
|
||||
value={kpis.activeVehicles.toLocaleString()}
|
||||
change={kpis.vehiclesChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Car}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Monthly Revenue"
|
||||
title={d.kpiMonthlyRevenue}
|
||||
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||
change={kpis.revenueChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={DollarSign}
|
||||
iconColor="text-amber-600"
|
||||
iconBg="bg-amber-50"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Customers"
|
||||
title={d.kpiTotalCustomers}
|
||||
value={kpis.totalCustomers.toLocaleString()}
|
||||
change={kpis.customersChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Users}
|
||||
iconColor="text-purple-600"
|
||||
iconBg="bg-purple-50"
|
||||
@@ -162,7 +197,7 @@ export default function DashboardPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Booking Sources Chart */}
|
||||
<div className="card p-6 lg:col-span-2">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">Booking Sources</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
|
||||
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.sourceBreakdown}>
|
||||
@@ -173,20 +208,20 @@ export default function DashboardPage() {
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name="Bookings" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" name="Revenue (centimes)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
|
||||
No booking data available yet
|
||||
{d.noBookingData}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick stats */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">Quick Stats</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2>
|
||||
<div className="space-y-4">
|
||||
{(data?.sourceBreakdown ?? []).map((item) => (
|
||||
<div key={item.source} className="flex items-center justify-between">
|
||||
@@ -198,7 +233,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
))}
|
||||
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
|
||||
<p className="text-sm text-slate-400">No data yet</p>
|
||||
<p className="text-sm text-slate-400">{d.noData}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,21 +242,21 @@ export default function DashboardPage() {
|
||||
{/* Recent Reservations */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">Recent Reservations</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
|
||||
<a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
View all →
|
||||
{d.viewAll}
|
||||
</a>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Booking #</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colDates}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colStatus}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
@@ -235,11 +270,11 @@ export default function DashboardPage() {
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500">
|
||||
{dayjs(res.startDate).format('MMM D')} – {dayjs(res.endDate).format('MMM D, YYYY')}
|
||||
{formatDate(res.startDate)} – {formatDateYear(res.endDate)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
|
||||
{res.status}
|
||||
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
|
||||
@@ -250,7 +285,7 @@ export default function DashboardPage() {
|
||||
{(!data?.recentReservations || data.recentReservations.length === 0) && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
|
||||
No reservations yet. Once customers book vehicles, they'll appear here.
|
||||
{d.noReservations}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface ReportRow {
|
||||
reservationId: string
|
||||
@@ -30,18 +31,74 @@ interface ReportData {
|
||||
|
||||
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
|
||||
|
||||
const periodLabels: Record<ReportPeriod, string> = {
|
||||
WEEKLY: 'Weekly',
|
||||
MONTHLY: 'Monthly',
|
||||
QUARTERLY: 'Quarterly',
|
||||
ANNUAL: 'Annual',
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Reports',
|
||||
subtitle: 'Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.',
|
||||
exportCsv: 'Export CSV',
|
||||
exporting: 'Exporting…',
|
||||
bookings: 'Bookings',
|
||||
rental: 'Rental',
|
||||
insurance: 'Insurance',
|
||||
drivers: 'Drivers',
|
||||
discounts: 'Discounts',
|
||||
collected: 'Collected',
|
||||
reservation: 'Reservation',
|
||||
vehicle: 'Vehicle',
|
||||
period: 'Period',
|
||||
source: 'Source',
|
||||
payment: 'Payment',
|
||||
total: 'Total',
|
||||
emptyRows: 'No report rows available for this period.',
|
||||
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Rapports',
|
||||
subtitle: 'Exports prêts pour le comptable avec totaux assurance, conducteurs supplémentaires et règles tarifaires.',
|
||||
exportCsv: 'Exporter CSV',
|
||||
exporting: 'Export en cours…',
|
||||
bookings: 'Réservations',
|
||||
rental: 'Location',
|
||||
insurance: 'Assurance',
|
||||
drivers: 'Conducteurs',
|
||||
discounts: 'Remises',
|
||||
collected: 'Collecté',
|
||||
reservation: 'Réservation',
|
||||
vehicle: 'Véhicule',
|
||||
period: 'Période',
|
||||
source: 'Source',
|
||||
payment: 'Paiement',
|
||||
total: 'Total',
|
||||
emptyRows: 'Aucune ligne de rapport pour cette période.',
|
||||
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'التقارير',
|
||||
subtitle: 'تصدير جاهز للمحاسبة مع إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
|
||||
exportCsv: 'تصدير CSV',
|
||||
exporting: 'جارٍ التصدير…',
|
||||
bookings: 'الحجوزات',
|
||||
rental: 'الإيجار',
|
||||
insurance: 'التأمين',
|
||||
drivers: 'السائقون',
|
||||
discounts: 'الخصومات',
|
||||
collected: 'المحصّل',
|
||||
reservation: 'الحجز',
|
||||
vehicle: 'المركبة',
|
||||
period: 'الفترة',
|
||||
source: 'المصدر',
|
||||
payment: 'الدفع',
|
||||
total: 'الإجمالي',
|
||||
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
|
||||
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<ReportData>(`/analytics/report?period=${period}`)
|
||||
@@ -59,7 +116,7 @@ export default function ReportsPage() {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null)
|
||||
throw new Error(json?.message ?? 'Export failed')
|
||||
throw new Error(json?.message ?? copy.exportCsv)
|
||||
}
|
||||
const csv = await res.text()
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
@@ -70,7 +127,7 @@ export default function ReportsPage() {
|
||||
anchor.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Export failed')
|
||||
setError(err.message ?? copy.exportCsv)
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
@@ -80,21 +137,21 @@ export default function ReportsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Reports</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(periodLabels) as ReportPeriod[]).map((option) => (
|
||||
{(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setPeriod(option)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-slate-900 text-white' : 'border border-slate-300 text-slate-700'}`}
|
||||
>
|
||||
{periodLabels[option]}
|
||||
{copy.periodLabels[option]}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
|
||||
{exporting ? 'Exporting…' : 'Export CSV'}
|
||||
{exporting ? copy.exporting : copy.exportCsv}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,12 +160,12 @@ export default function ReportsPage() {
|
||||
|
||||
{report && (
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Bookings</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Rental</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Insurance</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Drivers</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Discounts</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">Collected</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
|
||||
<div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -117,12 +174,12 @@ export default function ReportsPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Reservation</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Vehicle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Period</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Source</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">Payment</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">Total</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.reservation}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.period}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
@@ -138,7 +195,7 @@ export default function ReportsPage() {
|
||||
))}
|
||||
{(report?.rows.length ?? 0) === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No report rows available for this period.</td>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
|
||||
|
||||
interface ReservationDetail {
|
||||
@@ -49,6 +49,10 @@ interface ReservationDetail {
|
||||
|
||||
export default function ReservationDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
|
||||
const [inspections, setInspections] = useState<DamageInspection[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -65,7 +69,7 @@ export default function ReservationDetailPage() {
|
||||
setInspections(inspectionData)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load reservation')
|
||||
setError(err.message ?? r.failedToLoad)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ export default function ReservationDetailPage() {
|
||||
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
|
||||
setActing(true)
|
||||
setActionError(null)
|
||||
const errorMsg = action === 'confirm' ? r.failedConfirm : action === 'checkin' ? r.failedCheckIn : r.failedCheckOut
|
||||
try {
|
||||
await apiFetch(`/reservations/${params.id}/${action}`, {
|
||||
method: 'POST',
|
||||
@@ -83,7 +88,7 @@ export default function ReservationDetailPage() {
|
||||
})
|
||||
await loadReservation()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? `Failed to ${action} reservation`)
|
||||
setActionError(err.message ?? errorMsg)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
@@ -99,17 +104,20 @@ export default function ReservationDetailPage() {
|
||||
})
|
||||
await loadReservation()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? 'Failed to approve additional driver')
|
||||
setActionError(err.message ?? r.failedApproveDriver)
|
||||
} finally {
|
||||
setActing(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!reservation) return <div className="card p-6 text-sm text-slate-500">Loading reservation…</div>
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
|
||||
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
|
||||
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
|
||||
if (!reservation) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
|
||||
|
||||
const checkinInspection = inspections.find((i) => i.type === 'CHECKIN')
|
||||
const checkoutInspection = inspections.find((i) => i.type === 'CHECKOUT')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -121,17 +129,17 @@ export default function ReservationDetailPage() {
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{reservation.status === 'DRAFT' && (
|
||||
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
|
||||
{acting ? 'Working…' : 'Confirm reservation'}
|
||||
{acting ? r.working : r.confirmReservation}
|
||||
</button>
|
||||
)}
|
||||
{reservation.status === 'CONFIRMED' && (
|
||||
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
|
||||
{acting ? 'Working…' : 'Check in vehicle'}
|
||||
{acting ? r.working : r.checkInVehicle}
|
||||
</button>
|
||||
)}
|
||||
{reservation.status === 'ACTIVE' && (
|
||||
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
|
||||
{acting ? 'Working…' : 'Check out vehicle'}
|
||||
{acting ? r.working : r.checkOutVehicle}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -141,25 +149,25 @@ export default function ReservationDetailPage() {
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Customer</h3>
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
|
||||
<p className="text-slate-600">{reservation.customer.email}</p>
|
||||
<p className="text-slate-600">{reservation.customer.phone ?? 'No phone provided'}</p>
|
||||
<p className="text-slate-600">License: {reservation.customer.driverLicense ?? 'Not captured'}</p>
|
||||
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
|
||||
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
|
||||
{reservation.customer.flagged && <span className="badge-red">Flagged</span>}
|
||||
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Vehicle</h3>
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionVehicle}</h3>
|
||||
<div className="space-y-2 text-sm text-slate-600">
|
||||
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
|
||||
<p>{reservation.vehicle.licensePlate}</p>
|
||||
<p>{dayjs(reservation.startDate).format('MMM D, YYYY')} - {dayjs(reservation.endDate).format('MMM D, YYYY')}</p>
|
||||
<p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,18 +175,18 @@ export default function ReservationDetailPage() {
|
||||
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Charges</h3>
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
|
||||
<dl className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-slate-500">Discount</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Insurance</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Additional drivers</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Pricing adjustments</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">Grand total</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
|
||||
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
|
||||
</dl>
|
||||
|
||||
{reservation.insurances.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">Applied insurance</p>
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
|
||||
<div className="space-y-2">
|
||||
{reservation.insurances.map((insurance) => (
|
||||
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
||||
@@ -192,7 +200,7 @@ export default function ReservationDetailPage() {
|
||||
|
||||
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">Pricing rules applied</p>
|
||||
<p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
|
||||
<div className="space-y-2">
|
||||
{reservation.pricingRulesApplied.map((rule) => (
|
||||
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
|
||||
@@ -223,23 +231,23 @@ export default function ReservationDetailPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Additional drivers</h3>
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
|
||||
<div className="space-y-3">
|
||||
{reservation.additionalDrivers.map((driver) => (
|
||||
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
|
||||
<p className="text-sm text-slate-500">License: {driver.driverLicense}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">Charge: {formatCurrency(driver.totalCharge, 'MAD')}</p>
|
||||
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
|
||||
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
|
||||
</div>
|
||||
{driver.requiresApproval && !driver.approvedAt ? (
|
||||
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold text-white">
|
||||
Approve
|
||||
{r.approveBtn}
|
||||
</button>
|
||||
) : (
|
||||
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
|
||||
{driver.approvedAt ? 'Approved' : 'No approval needed'}
|
||||
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -247,21 +255,21 @@ export default function ReservationDetailPage() {
|
||||
</div>
|
||||
))}
|
||||
{reservation.additionalDrivers.length === 0 && (
|
||||
<div className="text-sm text-slate-400">No additional drivers were added to this reservation.</div>
|
||||
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">Inspection summary</h3>
|
||||
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">Check-in inspection</span>
|
||||
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? 'Saved' : 'Pending'}</span>
|
||||
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
|
||||
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
|
||||
<span className="text-slate-600">Check-out inspection</span>
|
||||
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? 'Saved' : 'Pending'}</span>
|
||||
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
|
||||
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface ReservationRow {
|
||||
id: string
|
||||
@@ -19,6 +19,10 @@ interface ReservationRow {
|
||||
}
|
||||
|
||||
export default function ReservationsPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
|
||||
const [rows, setRows] = useState<ReservationRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
@@ -28,11 +32,16 @@ export default function ReservationsPage() {
|
||||
.catch((err) => setError(err.message))
|
||||
}, [])
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||
const formatDateYear = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Reservations</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">All booking sources, including dashboard, public site, and marketplace.</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
|
||||
</div>
|
||||
<div className="card overflow-hidden">
|
||||
{error ? (
|
||||
@@ -42,12 +51,12 @@ export default function ReservationsPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-200">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Customer</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Vehicle</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Dates</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Source</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Status</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">Total</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colCustomer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colVehicle}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
@@ -60,7 +69,7 @@ export default function ReservationsPage() {
|
||||
<p className="text-xs text-slate-500">{row.customer.email}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{dayjs(row.startDate).format('MMM D')} - {dayjs(row.endDate).format('MMM D, YYYY')}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
|
||||
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
|
||||
@@ -68,7 +77,7 @@ export default function ReservationsPage() {
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">No reservations yet.</td>
|
||||
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface BrandSettings {
|
||||
displayName: string
|
||||
@@ -70,6 +71,231 @@ const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeV
|
||||
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Advanced settings',
|
||||
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
|
||||
save: 'Saving…',
|
||||
saveBrand: 'Save brand',
|
||||
brandProfile: 'Brand and public profile',
|
||||
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
|
||||
payments: 'Rental payment methods',
|
||||
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
|
||||
savePayments: 'Save payments',
|
||||
customDomain: 'Custom domain',
|
||||
customDomainDesc: 'Point your own domain to the branded booking site.',
|
||||
saveDomain: 'Save domain',
|
||||
removeDomain: 'Remove custom domain',
|
||||
contractPolicies: 'Contract and driver policies',
|
||||
savePolicies: 'Save policies',
|
||||
insurancePolicies: 'Insurance policies',
|
||||
pricingRules: 'Pricing rules',
|
||||
accounting: 'Accounting and exports',
|
||||
saveAccounting: 'Save accounting',
|
||||
displayName: 'Display name',
|
||||
tagline: 'Tagline',
|
||||
publicEmail: 'Public email',
|
||||
publicPhone: 'Public phone',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
websiteUrl: 'Website URL',
|
||||
whatsapp: 'WhatsApp number',
|
||||
brandColor: 'Brand color',
|
||||
listedMarketplace: 'Listed on marketplace',
|
||||
logoUpload: 'Logo upload',
|
||||
heroUpload: 'Hero image upload',
|
||||
uploading: 'Uploading…',
|
||||
logoUploaded: 'Logo uploaded',
|
||||
noLogo: 'No logo uploaded yet',
|
||||
heroUploaded: 'Hero image uploaded',
|
||||
noHero: 'No hero image uploaded yet',
|
||||
subdomain: 'Subdomain',
|
||||
customDomainLabel: 'Custom domain',
|
||||
domainPlaceholder: 'cars.example.com',
|
||||
status: 'Status',
|
||||
verified: 'Verified',
|
||||
pendingDns: 'Pending DNS verification',
|
||||
notConfigured: 'Not configured',
|
||||
fuelPolicyType: 'Fuel policy type',
|
||||
additionalDriverCharge: 'Additional driver charge',
|
||||
dailyDriverRate: 'Daily driver rate',
|
||||
flatDriverRate: 'Flat driver rate',
|
||||
fuelPolicyNote: 'Fuel policy note',
|
||||
damagePolicy: 'Damage policy',
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
noInsurance: 'No insurance policies configured yet.',
|
||||
policyName: 'Policy name',
|
||||
chargeValue: 'Charge value',
|
||||
required: 'Required',
|
||||
addPolicy: 'Add policy',
|
||||
noRules: 'No pricing rules configured yet.',
|
||||
ruleName: 'Rule name',
|
||||
addRule: 'Add rule',
|
||||
reportingPeriod: 'Reporting period',
|
||||
reportFormat: 'Report format',
|
||||
accountantName: 'Accountant name',
|
||||
accountantEmail: 'Accountant email',
|
||||
autoSend: 'Auto-send reports to the accountant',
|
||||
amanpayMerchantId: 'AmanPay merchant ID',
|
||||
amanpaySecretKey: 'AmanPay secret key',
|
||||
paypalEmail: 'PayPal business email',
|
||||
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
|
||||
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
|
||||
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
|
||||
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
|
||||
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
|
||||
},
|
||||
fr: {
|
||||
title: 'Paramètres avancés',
|
||||
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
|
||||
save: 'Enregistrement…',
|
||||
saveBrand: 'Enregistrer la marque',
|
||||
brandProfile: 'Marque et profil public',
|
||||
brandProfileDesc: 'Contrôlez l’affichage 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 d’assurance',
|
||||
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 d’assurance 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 n’est configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
|
||||
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
|
||||
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
|
||||
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
|
||||
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
|
||||
},
|
||||
ar: {
|
||||
title: 'الإعدادات المتقدمة',
|
||||
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
|
||||
save: 'جارٍ الحفظ…',
|
||||
saveBrand: 'حفظ العلامة',
|
||||
brandProfile: 'العلامة والملف العام',
|
||||
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
|
||||
payments: 'طرق دفع الإيجار',
|
||||
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
|
||||
savePayments: 'حفظ الدفع',
|
||||
customDomain: 'نطاق مخصص',
|
||||
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
|
||||
saveDomain: 'حفظ النطاق',
|
||||
removeDomain: 'إزالة النطاق المخصص',
|
||||
contractPolicies: 'العقد وسياسات السائق',
|
||||
savePolicies: 'حفظ السياسات',
|
||||
insurancePolicies: 'سياسات التأمين',
|
||||
pricingRules: 'قواعد التسعير',
|
||||
accounting: 'المحاسبة والتصدير',
|
||||
saveAccounting: 'حفظ المحاسبة',
|
||||
displayName: 'اسم العرض',
|
||||
tagline: 'الشعار',
|
||||
publicEmail: 'البريد العام',
|
||||
publicPhone: 'الهاتف العام',
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
websiteUrl: 'رابط الموقع',
|
||||
whatsapp: 'رقم واتساب',
|
||||
brandColor: 'لون العلامة',
|
||||
listedMarketplace: 'مدرج في السوق',
|
||||
logoUpload: 'رفع الشعار',
|
||||
heroUpload: 'رفع صورة الواجهة',
|
||||
uploading: 'جارٍ الرفع…',
|
||||
logoUploaded: 'تم رفع الشعار',
|
||||
noLogo: 'لم يتم رفع شعار بعد',
|
||||
heroUploaded: 'تم رفع صورة الواجهة',
|
||||
noHero: 'لم يتم رفع صورة واجهة بعد',
|
||||
subdomain: 'النطاق الفرعي',
|
||||
customDomainLabel: 'النطاق المخصص',
|
||||
domainPlaceholder: 'cars.example.com',
|
||||
status: 'الحالة',
|
||||
verified: 'موثق',
|
||||
pendingDns: 'بانتظار التحقق من DNS',
|
||||
notConfigured: 'غير مُعدّ',
|
||||
fuelPolicyType: 'نوع سياسة الوقود',
|
||||
additionalDriverCharge: 'رسوم السائق الإضافي',
|
||||
dailyDriverRate: 'تعرفة السائق اليومية',
|
||||
flatDriverRate: 'تعرفة السائق الثابتة',
|
||||
fuelPolicyNote: 'ملاحظة سياسة الوقود',
|
||||
damagePolicy: 'سياسة الأضرار',
|
||||
active: 'نشط',
|
||||
inactive: 'غير نشط',
|
||||
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
|
||||
policyName: 'اسم السياسة',
|
||||
chargeValue: 'قيمة الرسوم',
|
||||
required: 'إلزامي',
|
||||
addPolicy: 'إضافة سياسة',
|
||||
noRules: 'لا توجد قواعد تسعير مهيأة.',
|
||||
ruleName: 'اسم القاعدة',
|
||||
addRule: 'إضافة قاعدة',
|
||||
reportingPeriod: 'فترة التقرير',
|
||||
reportFormat: 'تنسيق التقرير',
|
||||
accountantName: 'اسم المحاسب',
|
||||
accountantEmail: 'بريد المحاسب',
|
||||
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
|
||||
amanpayMerchantId: 'معرّف التاجر AmanPay',
|
||||
amanpaySecretKey: 'المفتاح السري AmanPay',
|
||||
paypalEmail: 'بريد أعمال PayPal',
|
||||
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
|
||||
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
|
||||
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
|
||||
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
|
||||
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
|
||||
},
|
||||
}[language]
|
||||
const [brand, setBrand] = useState<BrandSettings | null>(null)
|
||||
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
|
||||
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
|
||||
@@ -299,8 +525,8 @@ export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Advanced settings</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.</p>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
|
||||
@@ -310,67 +536,67 @@ export default function SettingsPage() {
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">Brand and public profile</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Control how your company appears on the marketplace and public booking site.</p>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
|
||||
</div>
|
||||
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save brand'}
|
||||
{saving ? copy.save : copy.saveBrand}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Display name</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
|
||||
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Tagline</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
|
||||
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public email</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
|
||||
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Public phone</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
|
||||
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">City</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
|
||||
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Country</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
|
||||
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Website URL</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
|
||||
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">WhatsApp number</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
|
||||
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Brand color</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
|
||||
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
|
||||
Listed on marketplace
|
||||
{copy.listedMarketplace}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Logo upload</span>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? 'Uploading…' : brand.logoUrl ? 'Logo uploaded' : 'No logo uploaded yet'}</p>
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">Hero image upload</span>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
|
||||
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? 'Uploading…' : brand.heroImageUrl ? 'Hero image uploaded' : 'No hero image uploaded yet'}</p>
|
||||
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,28 +605,28 @@ export default function SettingsPage() {
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">Rental payment methods</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Configure how renters pay your company on the public booking site.</p>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
|
||||
</div>
|
||||
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save payments'}
|
||||
{saving ? copy.save : copy.savePayments}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay merchant ID</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
|
||||
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">AmanPay secret key</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
|
||||
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">PayPal business email</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
|
||||
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
||||
If no payment method is configured, renters can still submit reservation requests and pay on pickup.
|
||||
{copy.noPaymentConfigured}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,28 +634,28 @@ export default function SettingsPage() {
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">Custom domain</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Point your own domain to the branded booking site.</p>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
|
||||
</div>
|
||||
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save domain'}
|
||||
{saving ? copy.save : copy.saveDomain}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Subdomain</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Custom domain</label>
|
||||
<input className="input-field" placeholder="cars.example.com" value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
|
||||
<input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
|
||||
Status: {brand.customDomain ? (brand.customDomainVerified ? 'Verified' : 'Pending DNS verification') : 'Not configured'}
|
||||
{copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
|
||||
</div>
|
||||
{brand.customDomain ? (
|
||||
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
|
||||
Remove custom domain
|
||||
{copy.removeDomain}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -441,38 +667,38 @@ export default function SettingsPage() {
|
||||
{contractSettings && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Contract and driver policies</h3>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
|
||||
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save policies'}
|
||||
{saving ? copy.save : copy.savePolicies}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy type</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
|
||||
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
|
||||
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Additional driver charge</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
|
||||
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
|
||||
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Daily driver rate</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
|
||||
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Flat driver rate</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
|
||||
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel policy note</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
|
||||
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Damage policy</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
|
||||
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,7 +707,7 @@ export default function SettingsPage() {
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">Insurance policies</h3>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
{insurancePolicies.map((policy) => (
|
||||
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
|
||||
@@ -490,31 +716,31 @@ export default function SettingsPage() {
|
||||
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
|
||||
{policy.isActive ? 'Active' : 'Inactive'}
|
||||
{policy.isActive ? copy.active : copy.inactive}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">No insurance policies configured yet.</div>}
|
||||
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<input className="input-field" placeholder="Policy name" value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
|
||||
<input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
|
||||
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
|
||||
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
|
||||
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
<input type="number" className="input-field" placeholder="Charge value" value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
|
||||
<input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> Required</label>
|
||||
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add policy'}</button>
|
||||
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
|
||||
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">Pricing rules</h3>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
|
||||
<div className="mt-5 space-y-3">
|
||||
{pricingRules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
|
||||
@@ -523,15 +749,15 @@ export default function SettingsPage() {
|
||||
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
|
||||
</div>
|
||||
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
|
||||
{rule.isActive ? 'Active' : 'Inactive'}
|
||||
{rule.isActive ? copy.active : copy.inactive}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pricingRules.length === 0 && <div className="text-sm text-slate-400">No pricing rules configured yet.</div>}
|
||||
{pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<input className="input-field" placeholder="Rule name" value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
|
||||
<input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
|
||||
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
|
||||
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
@@ -545,7 +771,7 @@ export default function SettingsPage() {
|
||||
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? 'Saving…' : 'Add rule'}</button>
|
||||
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,36 +779,36 @@ export default function SettingsPage() {
|
||||
{accountingSettings && (
|
||||
<div className="card p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Accounting and exports</h3>
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
|
||||
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save accounting'}
|
||||
{saving ? copy.save : copy.saveAccounting}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Reporting period</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
|
||||
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
|
||||
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Report format</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
|
||||
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
|
||||
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant name</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
|
||||
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Accountant email</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
|
||||
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
|
||||
Auto-send reports to the accountant
|
||||
{copy.autoSend}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,16 +6,17 @@ import EditMemberModal from '@/components/team/EditMemberModal'
|
||||
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
|
||||
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
function initials(m: TeamMember) {
|
||||
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string | null) {
|
||||
if (!dateStr) return 'Never'
|
||||
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
|
||||
if (!dateStr) return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 2) return 'Just now'
|
||||
if (mins < 2) return language === 'fr' ? 'À l’instant' : language === 'ar' ? 'الآن' : 'Just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
@@ -50,29 +51,119 @@ function RoleBadge({ role }: { role: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ member }: { member: TeamMember }) {
|
||||
function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
|
||||
if (member.invitationStatus === 'pending') {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border-amber-100 dark:border-amber-900/50">
|
||||
Pending invite
|
||||
{language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (!member.isActive) {
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
|
||||
Deactivated
|
||||
{language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
|
||||
Active
|
||||
{language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TeamPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Team',
|
||||
subtitle: 'Manage your employees and their access levels',
|
||||
inviteMember: 'Invite member',
|
||||
totalMembers: 'Total members',
|
||||
active: 'Active',
|
||||
pendingInvite: 'Pending invite',
|
||||
deactivated: 'Deactivated',
|
||||
search: 'Search by name or email…',
|
||||
allRoles: 'All roles',
|
||||
owner: 'Owner',
|
||||
manager: 'Manager',
|
||||
agent: 'Agent',
|
||||
allStatuses: 'All statuses',
|
||||
pending: 'Pending',
|
||||
loadingTeam: 'Loading team…',
|
||||
noMembers: 'No members match your filters',
|
||||
member: 'Member',
|
||||
role: 'Role',
|
||||
status: 'Status',
|
||||
lastActive: 'Last active',
|
||||
manage: 'Manage',
|
||||
inviteSent: 'Invite sent to',
|
||||
roleUpdated: 'Role updated',
|
||||
memberDeactivated: 'Member deactivated',
|
||||
memberReactivated: 'Member reactivated',
|
||||
memberRemoved: 'Member removed',
|
||||
you: '(you)',
|
||||
},
|
||||
fr: {
|
||||
title: 'Équipe',
|
||||
subtitle: 'Gérez vos employés et leurs niveaux d’accès',
|
||||
inviteMember: 'Inviter un membre',
|
||||
totalMembers: 'Total membres',
|
||||
active: 'Actif',
|
||||
pendingInvite: 'Invitation en attente',
|
||||
deactivated: 'Désactivé',
|
||||
search: 'Rechercher par nom ou email…',
|
||||
allRoles: 'Tous les rôles',
|
||||
owner: 'Propriétaire',
|
||||
manager: 'Manager',
|
||||
agent: 'Agent',
|
||||
allStatuses: 'Tous les statuts',
|
||||
pending: 'En attente',
|
||||
loadingTeam: 'Chargement de l’équipe…',
|
||||
noMembers: 'Aucun membre ne correspond aux filtres',
|
||||
member: 'Membre',
|
||||
role: 'Rôle',
|
||||
status: 'Statut',
|
||||
lastActive: 'Dernière activité',
|
||||
manage: 'Gérer',
|
||||
inviteSent: 'Invitation envoyée à',
|
||||
roleUpdated: 'Rôle mis à jour',
|
||||
memberDeactivated: 'Membre désactivé',
|
||||
memberReactivated: 'Membre réactivé',
|
||||
memberRemoved: 'Membre supprimé',
|
||||
you: '(vous)',
|
||||
},
|
||||
ar: {
|
||||
title: 'الفريق',
|
||||
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
|
||||
inviteMember: 'دعوة عضو',
|
||||
totalMembers: 'إجمالي الأعضاء',
|
||||
active: 'نشط',
|
||||
pendingInvite: 'دعوة قيد الانتظار',
|
||||
deactivated: 'معطل',
|
||||
search: 'ابحث بالاسم أو البريد الإلكتروني…',
|
||||
allRoles: 'كل الأدوار',
|
||||
owner: 'مالك',
|
||||
manager: 'مدير',
|
||||
agent: 'وكيل',
|
||||
allStatuses: 'كل الحالات',
|
||||
pending: 'قيد الانتظار',
|
||||
loadingTeam: 'جارٍ تحميل الفريق…',
|
||||
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
|
||||
member: 'العضو',
|
||||
role: 'الدور',
|
||||
status: 'الحالة',
|
||||
lastActive: 'آخر نشاط',
|
||||
manage: 'إدارة',
|
||||
inviteSent: 'تم إرسال الدعوة إلى',
|
||||
roleUpdated: 'تم تحديث الدور',
|
||||
memberDeactivated: 'تم تعطيل العضو',
|
||||
memberReactivated: 'تمت إعادة تفعيل العضو',
|
||||
memberRemoved: 'تم حذف العضو',
|
||||
you: '(أنت)',
|
||||
},
|
||||
}[language]
|
||||
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
|
||||
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
|
||||
|
||||
@@ -124,36 +215,36 @@ export default function TeamPage() {
|
||||
|
||||
const handleInvite = async (payload: InvitePayload) => {
|
||||
await invite(payload)
|
||||
showToast(`Invite sent to ${payload.email}`)
|
||||
showToast(`${copy.inviteSent} ${payload.email}`)
|
||||
}
|
||||
|
||||
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
|
||||
await updateRole(id, role)
|
||||
showToast('Role updated')
|
||||
showToast(copy.roleUpdated)
|
||||
}
|
||||
|
||||
const handleDeactivate = async (id: string) => {
|
||||
await deactivate(id)
|
||||
showToast('Member deactivated')
|
||||
showToast(copy.memberDeactivated)
|
||||
}
|
||||
|
||||
const handleReactivate = async (id: string) => {
|
||||
await reactivate(id)
|
||||
showToast('Member reactivated')
|
||||
showToast(copy.memberReactivated)
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
await remove(id)
|
||||
showToast('Member removed')
|
||||
showToast(copy.memberRemoved)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">Team</h1>
|
||||
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Manage your employees and their access levels
|
||||
{copy.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
{isOwner && (
|
||||
@@ -164,17 +255,17 @@ export default function TeamPage() {
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
|
||||
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
Invite member
|
||||
{copy.inviteMember}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
{[
|
||||
{ label: 'Total members', value: stats.total },
|
||||
{ label: 'Active', value: stats.active },
|
||||
{ label: 'Pending invite', value: stats.pending },
|
||||
{ label: 'Deactivated', value: stats.inactive },
|
||||
{ label: copy.totalMembers, value: stats.total },
|
||||
{ label: copy.active, value: stats.active },
|
||||
{ label: copy.pendingInvite, value: stats.pending },
|
||||
{ label: copy.deactivated, value: stats.inactive },
|
||||
].map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
@@ -194,7 +285,7 @@ export default function TeamPage() {
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or email…"
|
||||
placeholder={copy.search}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
|
||||
@@ -205,20 +296,20 @@ export default function TeamPage() {
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
||||
>
|
||||
<option value="">All roles</option>
|
||||
<option value="OWNER">Owner</option>
|
||||
<option value="MANAGER">Manager</option>
|
||||
<option value="AGENT">Agent</option>
|
||||
<option value="">{copy.allRoles}</option>
|
||||
<option value="OWNER">{copy.owner}</option>
|
||||
<option value="MANAGER">{copy.manager}</option>
|
||||
<option value="AGENT">{copy.agent}</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="inactive">Deactivated</option>
|
||||
<option value="">{copy.allStatuses}</option>
|
||||
<option value="active">{copy.active}</option>
|
||||
<option value="pending">{copy.pending}</option>
|
||||
<option value="inactive">{copy.deactivated}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -226,22 +317,22 @@ export default function TeamPage() {
|
||||
{loading ? (
|
||||
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
|
||||
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
|
||||
<span className="text-sm">Loading team…</span>
|
||||
<span className="text-sm">{copy.loadingTeam}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-12 text-center text-sm text-red-500">{error}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">No members match your filters</p>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Member</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Role</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">Status</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">Last active</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -263,7 +354,7 @@ export default function TeamPage() {
|
||||
<div className="text-sm font-medium text-zinc-900 dark:text-white">
|
||||
{member.firstName} {member.lastName}
|
||||
{member.id === currentEmployeeId && (
|
||||
<span className="ml-1.5 text-[10px] text-zinc-400">(you)</span>
|
||||
<span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
|
||||
@@ -276,12 +367,12 @@ export default function TeamPage() {
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge member={member} />
|
||||
<StatusBadge member={member} language={language} />
|
||||
</td>
|
||||
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-xs text-zinc-400 dark:text-zinc-500">
|
||||
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt)}
|
||||
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -291,7 +382,7 @@ export default function TeamPage() {
|
||||
onClick={() => setEditTarget(member)}
|
||||
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Manage
|
||||
{copy.manage}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
|
||||
<Sidebar />
|
||||
<div className="ml-64 flex min-h-screen min-w-0 flex-1 flex-col">
|
||||
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64">
|
||||
<TopBar />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
|
||||
|
||||
@@ -43,6 +44,8 @@ function daysBetween(start: Date, end: Date): number {
|
||||
}
|
||||
|
||||
export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const c = dict.calendar
|
||||
const today = new Date()
|
||||
const [year, setYear] = useState(today.getFullYear())
|
||||
const [month, setMonth] = useState(today.getMonth() + 1)
|
||||
@@ -71,11 +74,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
)
|
||||
setEvents(data)
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? 'Failed to load calendar')
|
||||
setError(e.message ?? c.failedLoad)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [vehicleId, year, month])
|
||||
}, [vehicleId, year, month, c])
|
||||
|
||||
useEffect(() => { fetchCalendar() }, [fetchCalendar])
|
||||
|
||||
@@ -142,8 +145,8 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
}
|
||||
|
||||
const handleSaveBlock = async () => {
|
||||
if (!blockStart || !blockEnd) { setSaveError('Start and end dates are required.'); return }
|
||||
if (blockEnd <= blockStart) { setSaveError('End date must be after start date.'); return }
|
||||
if (!blockStart || !blockEnd) { setSaveError(c.datesRequired); return }
|
||||
if (blockEnd <= blockStart) { setSaveError(c.endAfterStart); return }
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
@@ -154,23 +157,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
setShowModal(false)
|
||||
fetchCalendar()
|
||||
} catch (e: any) {
|
||||
setSaveError(e.message ?? 'Failed to create block')
|
||||
setSaveError(e.message ?? c.failedCreateBlock)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteBlock = async (id: string) => {
|
||||
if (!confirm('Remove this block?')) return
|
||||
if (!confirm(c.removeBlockConfirm)) return
|
||||
try {
|
||||
await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' })
|
||||
fetchCalendar()
|
||||
} catch (e: any) {
|
||||
alert(e.message ?? 'Failed to delete block')
|
||||
alert(e.message ?? c.failedDeleteBlock)
|
||||
}
|
||||
}
|
||||
|
||||
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
const monthLabel = new Date(year, month - 1, 1).toLocaleDateString(localeCode, { month: 'long', year: 'numeric' })
|
||||
|
||||
// Separate reservations vs blocks for the legend/list below
|
||||
const reservationEvents = events.filter((e) => e.type === 'RESERVATION')
|
||||
@@ -182,7 +186,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays className="w-5 h-5 text-slate-600" />
|
||||
<h3 className="text-base font-semibold text-slate-900">Availability Calendar</h3>
|
||||
<h3 className="text-base font-semibold text-slate-900">{c.heading}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
|
||||
@@ -193,24 +197,24 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
|
||||
<Plus className="w-3.5 h-3.5" /> Block dates
|
||||
<Plus className="w-3.5 h-3.5" /> {c.blockDates}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectStart && (
|
||||
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
|
||||
Click a second day to set the block end date (selected: {selectStart})
|
||||
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>cancel</button>
|
||||
{c.clickSecondDay(selectStart!)}
|
||||
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>{c.cancelSelection}</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />Reserved</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />Maintenance</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />Blocked</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />Available</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />{c.legendReserved}</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />{c.legendMaintenance}</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />{c.legendBlocked}</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />{c.legendAvailable}</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
|
||||
@@ -219,7 +223,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
<div className={loading ? 'opacity-50 pointer-events-none' : ''}>
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
{c.dayHeaders.map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -261,7 +265,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
)
|
||||
})}
|
||||
{dayEvents.length > 2 && (
|
||||
<div className="text-[10px] text-slate-400 pl-1">+{dayEvents.length - 2} more</div>
|
||||
<div className="text-[10px] text-slate-400 pl-1">{c.moreDays(dayEvents.length - 2)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,7 +279,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
<div className="space-y-3 pt-2">
|
||||
{reservationEvents.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Reservations this month</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.reservationsThisMonth}</p>
|
||||
<div className="space-y-1.5">
|
||||
{reservationEvents.map((e) => {
|
||||
const s = EVENT_STYLES[e.type]
|
||||
@@ -299,7 +303,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
|
||||
{blockEvents.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Blocks & maintenance</p>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">{c.blocksAndMaintenance}</p>
|
||||
<div className="space-y-1.5">
|
||||
{blockEvents.map((e) => {
|
||||
const s = EVENT_STYLES[e.type]
|
||||
@@ -317,7 +321,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
<button
|
||||
onClick={() => handleDeleteBlock(e.id)}
|
||||
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
|
||||
title="Remove block"
|
||||
title={c.removeBlock}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -335,7 +339,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={closeModal}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-base font-semibold text-slate-900">Block vehicle dates</h4>
|
||||
<h4 className="text-base font-semibold text-slate-900">{c.modalTitle}</h4>
|
||||
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -343,26 +347,26 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Block type</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.blockTypeLabel}</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBlockType('MANUAL')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" /> Manual block
|
||||
<Ban className="w-3.5 h-3.5" /> {c.manualBlock}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBlockType('MAINTENANCE')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-amber-50 border-amber-300 text-amber-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Wrench className="w-3.5 h-3.5" /> Maintenance
|
||||
<Wrench className="w-3.5 h-3.5" /> {c.maintenanceBlock}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Start date</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.startDate}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-field"
|
||||
@@ -371,7 +375,7 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">End date (exclusive)</label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.endDate}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-field"
|
||||
@@ -383,11 +387,11 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Reason <span className="font-normal">(optional)</span></label>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">{c.reasonLabel} <span className="font-normal">{c.optional}</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder={blockType === 'MAINTENANCE' ? 'e.g. Oil change, tire rotation…' : 'e.g. Reserved for VIP, company event…'}
|
||||
placeholder={blockType === 'MAINTENANCE' ? c.reasonPlaceholderMaintenance : c.reasonPlaceholderManual}
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.target.value)}
|
||||
/>
|
||||
@@ -399,9 +403,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={closeModal} className="flex-1 btn-secondary">Cancel</button>
|
||||
<button onClick={closeModal} className="flex-1 btn-secondary">{c.cancel}</button>
|
||||
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
|
||||
{saving ? 'Saving…' : 'Save block'}
|
||||
{saving ? c.saving : c.saveBlock}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Car,
|
||||
@@ -15,10 +16,30 @@ import {
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
|
||||
interface BrandSettings {
|
||||
displayName: string
|
||||
logoUrl: string | null
|
||||
}
|
||||
|
||||
interface SidebarUser {
|
||||
displayName: string
|
||||
subtitle: string
|
||||
initials: string
|
||||
}
|
||||
|
||||
function computeInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) return 'U'
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
|
||||
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
|
||||
@@ -35,9 +56,67 @@ const NAV_ITEMS = [
|
||||
] as const
|
||||
|
||||
export default function Sidebar() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const isRtl = language === 'ar'
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [brand, setBrand] = useState<BrandSettings | null>(null)
|
||||
const [user, setUser] = useState<SidebarUser>({
|
||||
displayName: dict.workspaceUser,
|
||||
subtitle: dict.localAuth,
|
||||
initials: 'W',
|
||||
})
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<BrandSettings>('/companies/me/brand').then(setBrand).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) {
|
||||
setUser({
|
||||
displayName: dict.workspaceUser,
|
||||
subtitle: dict.localAuth,
|
||||
initials: 'W',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
apiFetch<{
|
||||
employee: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: string
|
||||
}
|
||||
}>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
if (cancelled) return
|
||||
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
|
||||
const email = employee.email ?? ''
|
||||
const role = employee.role ?? ''
|
||||
const displayName = fullName || (email ? email.split('@')[0] : dict.workspaceUser)
|
||||
const subtitle = email || role || dict.localAuth
|
||||
const initials = computeInitials(fullName || email || dict.workspaceUser)
|
||||
setUser({ displayName, subtitle, initials })
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setUser({
|
||||
displayName: dict.workspaceUser,
|
||||
subtitle: dict.localAuth,
|
||||
initials: 'W',
|
||||
})
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [dict.localAuth, dict.workspaceUser])
|
||||
|
||||
// Close sidebar when navigating on mobile
|
||||
useEffect(() => { setOpen(false) }, [pathname])
|
||||
|
||||
const isActive = (item: typeof NAV_ITEMS[number]) => {
|
||||
if ('exact' in item && item.exact) return pathname === item.href
|
||||
@@ -51,52 +130,102 @@ export default function Sidebar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-40 flex w-64 flex-col bg-slate-900">
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-500">
|
||||
<Car className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-wide text-white">RentalDriveGo</span>
|
||||
</Link>
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{dict.nav[item.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-slate-800 px-3 py-4">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
|
||||
W
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">{dict.workspaceUser}</p>
|
||||
<p className="truncate text-xs text-slate-400">{dict.localAuth}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
className={[
|
||||
'fixed top-1/2 z-50 -translate-y-1/2 flex items-center justify-center w-6 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
|
||||
isRtl ? 'right-0 rounded-l-lg' : 'left-0 rounded-r-lg',
|
||||
].join(' ')}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{dict.signOut}
|
||||
{isRtl ? <ChevronLeft className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'fixed inset-y-0 z-40 flex w-64 flex-col bg-slate-900 transition-transform duration-300',
|
||||
isRtl ? 'right-0' : 'left-0',
|
||||
'lg:translate-x-0',
|
||||
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
|
||||
].join(' ')}
|
||||
>
|
||||
<Link href={marketplaceUrl} className="flex items-center gap-3 border-b border-slate-800 px-6 py-5">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500 overflow-hidden">
|
||||
{brand?.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
|
||||
) : (
|
||||
<Car className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-sm font-bold tracking-wide text-white">
|
||||
{brand?.displayName ?? 'RentalDriveGo'}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item)
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={[
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
active ? 'bg-blue-600 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{dict.nav[item.key]}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-slate-800 px-3 py-4">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
|
||||
{user.initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">{user.displayName}</p>
|
||||
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{dict.signOut}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className={[
|
||||
'absolute top-1/2 z-10 -translate-y-1/2 flex items-center justify-center w-5 h-14 bg-slate-900 text-white shadow-lg lg:hidden',
|
||||
isRtl ? '-left-5 rounded-l-lg' : '-right-5 rounded-r-lg',
|
||||
].join(' ')}
|
||||
>
|
||||
{isRtl ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronLeft className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { Bell } from 'lucide-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
function computeInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 0) return 'U'
|
||||
if (parts.length === 1) return parts[0].slice(0, 1).toUpperCase()
|
||||
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
|
||||
export default function TopBar() {
|
||||
const { dict } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [showNotifs, setShowNotifs] = useState(false)
|
||||
const [userInitials, setUserInitials] = useState('W')
|
||||
const [notifications, setNotifications] = useState<Array<{
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}>>([])
|
||||
const [loadingNotifs, setLoadingNotifs] = useState(false)
|
||||
|
||||
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
|
||||
async function refreshUnreadCount() {
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
setUnreadCount(data.unread)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
.then((data) => setUnreadCount(data.unread))
|
||||
.catch(() => {})
|
||||
refreshUnreadCount()
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
function onNotificationsUpdated() {
|
||||
refreshUnreadCount()
|
||||
}
|
||||
window.addEventListener('notifications:updated', onNotificationsUpdated)
|
||||
return () => window.removeEventListener('notifications:updated', onNotificationsUpdated)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotifs) return
|
||||
let cancelled = false
|
||||
setLoadingNotifs(true)
|
||||
apiFetch<Array<{
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
}>>('/notifications/company')
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
setNotifications(data.slice(0, 8))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setNotifications([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setLoadingNotifs(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [showNotifs])
|
||||
|
||||
useEffect(() => {
|
||||
setShowNotifs(false)
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) {
|
||||
setUserInitials(computeInitials(dict.workspaceUser))
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
apiFetch<{
|
||||
employee: {
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
}>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
if (cancelled) return
|
||||
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
|
||||
const email = employee.email ?? ''
|
||||
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setUserInitials(computeInitials(dict.workspaceUser))
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [dict.workspaceUser])
|
||||
|
||||
async function openNotificationsInbox(notificationId?: string) {
|
||||
if (notificationId) {
|
||||
try {
|
||||
await apiFetch(`/notifications/company/${notificationId}/read`, { method: 'POST' })
|
||||
setUnreadCount((current) => Math.max(current - 1, 0))
|
||||
window.dispatchEvent(new Event('notifications:updated'))
|
||||
} catch {}
|
||||
}
|
||||
setShowNotifs(false)
|
||||
router.push('/dashboard/notifications')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 transition-colors dark:border-slate-800 dark:bg-slate-950">
|
||||
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h1>
|
||||
@@ -40,16 +142,51 @@ export default function TopBar() {
|
||||
|
||||
{showNotifs ? (
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-xl border border-slate-200 bg-white p-4 shadow-lg dark:border-slate-700 dark:bg-slate-900">
|
||||
<p className="mb-2 text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{dict.notifications}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openNotificationsInbox()}
|
||||
className="text-xs font-medium text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingNotifs ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Loading…</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notifications.map((notification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
type="button"
|
||||
onClick={() => openNotificationsInbox(notification.id)}
|
||||
className={[
|
||||
'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-100 dark:hover:bg-slate-800',
|
||||
notification.readAt ? 'opacity-80' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
|
||||
{notification.body}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500 text-xs font-semibold text-white">
|
||||
W
|
||||
{userInitials}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type InspectionType = 'CHECKIN' | 'CHECKOUT'
|
||||
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
|
||||
@@ -50,6 +51,9 @@ export default function DamageInspectionCard({
|
||||
initialInspection?: DamageInspection | null
|
||||
onSaved: (inspection: DamageInspection) => void
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
|
||||
const [inspection, setInspection] = useState<DamageInspection>({
|
||||
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
|
||||
type,
|
||||
@@ -80,20 +84,14 @@ export default function DamageInspectionCard({
|
||||
employeeNotes: inspection.employeeNotes,
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
description,
|
||||
isPreExisting,
|
||||
viewType, x, y, damageType, severity, description, isPreExisting,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
setInspection(result)
|
||||
onSaved(result)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to save inspection')
|
||||
setError(err.message ?? r.failedSaveInspection)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -107,15 +105,7 @@ export default function DamageInspectionCard({
|
||||
...current,
|
||||
damagePoints: [
|
||||
...current.damagePoints,
|
||||
{
|
||||
viewType: 'TOP',
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
description: '',
|
||||
isPreExisting: type === 'CHECKIN',
|
||||
},
|
||||
{ viewType: 'TOP', x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
|
||||
],
|
||||
}))
|
||||
}
|
||||
@@ -123,7 +113,7 @@ export default function DamageInspectionCard({
|
||||
function removePoint(index: number) {
|
||||
setInspection((current) => ({
|
||||
...current,
|
||||
damagePoints: current.damagePoints.filter((_, pointIndex) => pointIndex !== index),
|
||||
damagePoints: current.damagePoints.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -131,11 +121,11 @@ export default function DamageInspectionCard({
|
||||
<div className="card p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">{type === 'CHECKIN' ? 'Check-in inspection' : 'Check-out inspection'}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Mark existing and newly discovered damage directly on the vehicle diagram.</p>
|
||||
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
|
||||
</div>
|
||||
<button onClick={saveInspection} disabled={saving} className="btn-primary">
|
||||
{saving ? 'Saving…' : 'Save inspection'}
|
||||
{saving ? r.savingInspection : r.saveInspection}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +141,7 @@ export default function DamageInspectionCard({
|
||||
onClick={() => setDamageType(option)}
|
||||
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
|
||||
>
|
||||
{option}
|
||||
{r.damageTypeLabels[option]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -164,7 +154,7 @@ export default function DamageInspectionCard({
|
||||
className="rounded-full px-3 py-1.5 font-semibold text-white"
|
||||
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
|
||||
>
|
||||
{option}
|
||||
{r.severityLabels[option]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -184,61 +174,61 @@ export default function DamageInspectionCard({
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<p className="mt-2 text-xs text-slate-500">Click anywhere on the diagram to add a damage marker with the selected type and severity.</p>
|
||||
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Mileage</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
value={inspection.mileage ?? ''}
|
||||
onChange={(event) => setInspection((current) => ({ ...current, mileage: event.target.value ? Number(event.target.value) : null }))}
|
||||
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Fuel level</label>
|
||||
<select className="input-field" value={inspection.fuelLevel} onChange={(event) => setInspection((current) => ({ ...current, fuelLevel: event.target.value as FuelLevel }))}>
|
||||
{fuelLevels.map((fuelLevel) => <option key={fuelLevel} value={fuelLevel}>{fuelLevel}</option>)}
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
|
||||
<select className="input-field" value={inspection.fuelLevel} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
|
||||
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">General condition</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(event) => setInspection((current) => ({ ...current, generalCondition: event.target.value }))} />
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">Employee notes</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(event) => setInspection((current) => ({ ...current, employeeNotes: event.target.value }))} />
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
|
||||
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" checked={inspection.customerAgreed} onChange={(event) => setInspection((current) => ({ ...current, customerAgreed: event.target.checked }))} />
|
||||
Customer acknowledged this inspection
|
||||
<input type="checkbox" checked={inspection.customerAgreed} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
|
||||
{r.customerAcknowledged}
|
||||
</label>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-slate-900">Damage markers</p>
|
||||
<p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{inspection.damagePoints.map((point, index) => (
|
||||
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{point.damageType} · {point.severity}</p>
|
||||
<p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
|
||||
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
Remove
|
||||
{r.removeMarker}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{inspection.damagePoints.length === 0 && (
|
||||
<div className="px-4 py-6 text-sm text-slate-400">No damage markers saved yet.</div>
|
||||
<div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: number
|
||||
vsLastMonthLabel?: string
|
||||
icon: LucideIcon
|
||||
iconColor?: string
|
||||
iconBg?: string
|
||||
@@ -13,6 +14,7 @@ export default function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
vsLastMonthLabel = 'vs last month',
|
||||
icon: Icon,
|
||||
iconColor = 'text-blue-600',
|
||||
iconBg = 'bg-blue-50',
|
||||
@@ -36,7 +38,7 @@ export default function StatCard({
|
||||
>
|
||||
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">vs last month</span>
|
||||
<span className="text-xs text-slate-400">{vsLastMonthLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const API_BASE =
|
||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
||||
?? process.env.NEXT_PUBLIC_API_URL
|
||||
?? 'http://localhost:4000/api/v1'
|
||||
typeof window === 'undefined'
|
||||
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1')
|
||||
|
||||
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ interface Dict {
|
||||
// modal
|
||||
reserveVehicle: string
|
||||
pickupDate: string
|
||||
pickupTime: string
|
||||
returnDate: string
|
||||
dropoffTime: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
@@ -85,7 +87,9 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
email: '',
|
||||
phone: '',
|
||||
startDate: today(),
|
||||
startTime: '10:00',
|
||||
endDate: tomorrow(),
|
||||
endTime: '10:00',
|
||||
notes: '',
|
||||
})
|
||||
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle')
|
||||
@@ -97,7 +101,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
setSelected(vehicle)
|
||||
setStatus('idle')
|
||||
setErrorMsg('')
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' })
|
||||
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, startTime: '10:00', endDate: minEnd, endTime: '10:00', notes: '' })
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -137,8 +141,8 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
lastName: form.lastName,
|
||||
email: form.email,
|
||||
phone: form.phone || undefined,
|
||||
startDate: new Date(form.startDate).toISOString(),
|
||||
endDate: new Date(form.endDate).toISOString(),
|
||||
startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(),
|
||||
endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(),
|
||||
notes: form.notes || undefined,
|
||||
})
|
||||
setStatus('success')
|
||||
@@ -260,7 +264,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
{/* Dates + Times */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label>
|
||||
@@ -284,6 +288,26 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime}</label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={form.startTime}
|
||||
onChange={set('startTime')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime}</label>
|
||||
<input
|
||||
type="time"
|
||||
required
|
||||
value={form.endTime}
|
||||
onChange={set('endTime')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated total */}
|
||||
|
||||
@@ -96,7 +96,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
// modal
|
||||
reserveVehicle: 'Reserve this vehicle',
|
||||
pickupDate: 'Pick-up date',
|
||||
pickupTime: 'Pick-up time',
|
||||
returnDate: 'Return date',
|
||||
dropoffTime: 'Drop-off time',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
email: 'Email address',
|
||||
@@ -152,7 +154,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
categories: ['Économie', 'Compacte', 'SUV', 'Luxe', 'Van', 'Camion', 'Électrique'],
|
||||
reserveVehicle: 'Réserver ce véhicule',
|
||||
pickupDate: 'Date de prise en charge',
|
||||
pickupTime: 'Heure de prise en charge',
|
||||
returnDate: 'Date de retour',
|
||||
dropoffTime: 'Heure de restitution',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
email: 'Adresse e-mail',
|
||||
@@ -208,7 +212,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
||||
categories: ['اقتصادية', 'مدمجة', 'SUV', 'فاخرة', 'فان', 'شاحنة', 'كهربائية'],
|
||||
reserveVehicle: 'احجز هذه السيارة',
|
||||
pickupDate: 'تاريخ الاستلام',
|
||||
pickupTime: 'وقت الاستلام',
|
||||
returnDate: 'تاريخ الإرجاع',
|
||||
dropoffTime: 'وقت الإرجاع',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
email: 'البريد الإلكتروني',
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Star } from 'lucide-react'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
interface ReviewInfo {
|
||||
reservationId: string
|
||||
companyName: string
|
||||
companyLogoUrl: string | null
|
||||
vehicle: string
|
||||
vehiclePhoto: string | null
|
||||
}
|
||||
|
||||
function StarRating({ value, onChange, label }: { value: number; onChange: (v: number) => void; label: string }) {
|
||||
const [hovered, setHovered] = useState(0)
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-700 mb-1.5">{label}</p>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => onChange(n)}
|
||||
onMouseEnter={() => setHovered(n)}
|
||||
onMouseLeave={() => setHovered(0)}
|
||||
className="p-0.5 transition-transform hover:scale-110"
|
||||
aria-label={`${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
n <= (hovered || value)
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'fill-stone-200 text-stone-200'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const params = useSearchParams()
|
||||
const token = params.get('token') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<ReviewInfo | null>(null)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'already_reviewed' | 'invalid'>('loading')
|
||||
|
||||
const [overall, setOverall] = useState(0)
|
||||
const [vehicle, setVehicle] = useState(0)
|
||||
const [service, setService] = useState(0)
|
||||
const [comment, setComment] = useState('')
|
||||
const [submitState, setSubmitState] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) { setLoadState('invalid'); return }
|
||||
fetch(`${API_BASE}/marketplace/review/${token}`)
|
||||
.then(async (r) => {
|
||||
if (r.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!r.ok) { setLoadState('invalid'); return }
|
||||
const json = await r.json()
|
||||
setInfo(json.data)
|
||||
setLoadState('ready')
|
||||
})
|
||||
.catch(() => setLoadState('invalid'))
|
||||
}, [token])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (overall === 0) { setErrorMsg('Please select an overall rating.'); return }
|
||||
setSubmitState('submitting')
|
||||
setErrorMsg('')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/marketplace/review/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
overallRating: overall,
|
||||
vehicleRating: vehicle || undefined,
|
||||
serviceRating: service || undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.status === 409) { setLoadState('already_reviewed'); return }
|
||||
if (!res.ok) {
|
||||
const json = await res.json()
|
||||
throw new Error(json.message ?? 'Something went wrong.')
|
||||
}
|
||||
setSubmitState('done')
|
||||
} catch (err: any) {
|
||||
setSubmitState('error')
|
||||
setErrorMsg(err.message ?? 'Something went wrong. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
if (loadState === 'loading') {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
||||
<p className="text-stone-500 text-sm">Loading…</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'invalid') {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
||||
<div className="card max-w-md w-full p-8 text-center space-y-3">
|
||||
<p className="text-2xl font-black text-stone-900">Link not found</p>
|
||||
<p className="text-stone-500 text-sm">This review link is invalid or has already been used.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadState === 'already_reviewed') {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
||||
<div className="card max-w-md w-full p-8 text-center space-y-3">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100">
|
||||
<svg className="h-7 w-7 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<p className="text-xl font-bold text-stone-900">Already reviewed</p>
|
||||
<p className="text-stone-500 text-sm">You have already submitted a review for this rental. Thank you!</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitState === 'done') {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
||||
<div className="card max-w-md w-full p-8 text-center space-y-4">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
|
||||
<svg className="h-8 w-8 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-black text-stone-900">Thank you!</h1>
|
||||
<p className="text-stone-500">Your review has been submitted and will be visible on {info?.companyName}'s profile.</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-stone-50 p-6">
|
||||
<div className="card max-w-lg w-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 border-b border-stone-100 p-6">
|
||||
{info?.vehiclePhoto ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={info.vehiclePhoto} alt={info.vehicle} className="h-16 w-24 rounded-xl object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="h-16 w-24 rounded-xl bg-stone-100 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-widest text-stone-400 truncate">{info?.companyName}</p>
|
||||
<h1 className="mt-0.5 text-lg font-bold text-stone-900 truncate">{info?.vehicle}</h1>
|
||||
<p className="mt-0.5 text-sm text-stone-500">How was your experience?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<StarRating value={overall} onChange={setOverall} label="Overall experience *" />
|
||||
<StarRating value={vehicle} onChange={setVehicle} label="Vehicle condition (optional)" />
|
||||
<StarRating value={service} onChange={setService} label="Customer service (optional)" />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-1.5">Comments (optional)</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Tell us about your rental experience…"
|
||||
className="w-full rounded-xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<p className="rounded-xl bg-rose-50 px-4 py-3 text-sm text-rose-700">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitState === 'submitting'}
|
||||
className="w-full rounded-full bg-stone-900 py-3 text-sm font-semibold text-white transition-colors hover:bg-stone-700 disabled:opacity-60"
|
||||
>
|
||||
{submitState === 'submitting' ? 'Submitting…' : 'Submit review'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -121,10 +121,12 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
||||
const router = useRouter()
|
||||
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||
const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
const [language, setLanguage] = useState<MarketplaceLanguage>('en')
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const previousLanguage = useRef<MarketplaceLanguage>('en')
|
||||
const [companyName, setCompanyName] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const storedLanguage = window.localStorage.getItem('marketplace-language')
|
||||
@@ -139,6 +141,19 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
||||
}
|
||||
|
||||
setHydrated(true)
|
||||
|
||||
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('employee_token='))?.split('=')[1]
|
||||
if (token) {
|
||||
fetch(`${apiUrl}/companies/me/brand`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(json => {
|
||||
const name = json?.data?.displayName ?? json?.displayName
|
||||
if (name) setCompanyName(name)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -212,12 +227,24 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
||||
>
|
||||
{dict.explore}
|
||||
</Link>
|
||||
<Link
|
||||
href={`${dashboardUrl}/sign-in`}
|
||||
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
|
||||
>
|
||||
{dict.ownerSignIn}
|
||||
</Link>
|
||||
{companyName ? (
|
||||
<Link
|
||||
href={`${dashboardUrl}/dashboard`}
|
||||
className="ml-2 flex items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
|
||||
>
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-stone-950/20">
|
||||
{companyName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{companyName}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={`${dashboardUrl}/sign-in`}
|
||||
className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
|
||||
>
|
||||
{dict.ownerSignIn}
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,9 @@ interface BookingOptions {
|
||||
|
||||
interface Step1 {
|
||||
startDate: string
|
||||
startTime: string
|
||||
endDate: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
interface Step2 {
|
||||
@@ -192,7 +194,7 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
|
||||
const [bookingOptions, setBookingOptions] = useState<BookingOptions | null>(null)
|
||||
const [bookingOptionsError, setBookingOptionsError] = useState('')
|
||||
|
||||
const [step1, setStep1] = useState<Step1>({ startDate: '', endDate: '' })
|
||||
const [step1, setStep1] = useState<Step1>({ startDate: '', startTime: '10:00', endDate: '', endTime: '10:00' })
|
||||
const [step1Errors, setStep1Errors] = useState<Partial<Step1>>({})
|
||||
const [step2, setStep2] = useState<Step2>({
|
||||
firstName: '',
|
||||
@@ -643,8 +645,13 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
{vehicle && (
|
||||
<div className="rounded-xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800">
|
||||
{dict.booking.categoryNotice(dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category)}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 divide-y divide-slate-100 text-sm">
|
||||
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${vehicle.make} ${vehicle.model}` : vehicleId} />
|
||||
<SummaryRow label={dict.booking.summary.vehicle} value={vehicle ? `${dict.vehicles.categoryLabels[vehicle.category as keyof typeof dict.vehicles.categoryLabels] ?? vehicle.category} — ${vehicle.make} ${vehicle.model}` : vehicleId} />
|
||||
<SummaryRow label={dict.booking.summary.dates} value={`${step1.startDate} -> ${step1.endDate} (${days} ${dayLabel})`} />
|
||||
<SummaryRow label={dict.booking.summary.primaryDriver} value={`${step2.firstName} ${step2.lastName}`} />
|
||||
<SummaryRow label={dict.booking.summary.email} value={step2.email} />
|
||||
|
||||
@@ -12,6 +12,7 @@ interface ReservationDetail {
|
||||
endDate: string
|
||||
totalDays: number
|
||||
totalAmount: number
|
||||
vehicleCategory: string | null
|
||||
vehicle: { make: string; model: string; year: number } | null
|
||||
customer: { firstName: string; lastName: string; email: string } | null
|
||||
}
|
||||
@@ -69,8 +70,8 @@ export default async function BookingConfirmationPage({ searchParams = {} }: Pag
|
||||
<div className="bg-slate-50 px-4 py-2.5 text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">
|
||||
{dict.confirmation.bookingSummary}
|
||||
</div>
|
||||
{reservation.vehicle && (
|
||||
<Row label={dict.confirmation.vehicle} value={`${reservation.vehicle.make} ${reservation.vehicle.model} (${reservation.vehicle.year})`} />
|
||||
{reservation.vehicleCategory && (
|
||||
<Row label={dict.confirmation.vehicleCategory} value={dict.vehicles.categoryLabels[reservation.vehicleCategory as keyof typeof dict.vehicles.categoryLabels] ?? reservation.vehicleCategory} />
|
||||
)}
|
||||
{reservation.customer && (
|
||||
<Row label={dict.confirmation.customer} value={`${reservation.customer.firstName} ${reservation.customer.lastName}`} />
|
||||
|
||||
@@ -111,6 +111,16 @@ export type PublicSiteDictionary = {
|
||||
seats: (count: number) => string
|
||||
perDay: string
|
||||
viewDetails: string
|
||||
categoryLabels: {
|
||||
ECONOMY: string
|
||||
COMPACT: string
|
||||
MIDSIZE: string
|
||||
FULLSIZE: string
|
||||
SUV: string
|
||||
LUXURY: string
|
||||
VAN: string
|
||||
TRUCK: string
|
||||
}
|
||||
filters: {
|
||||
title: string
|
||||
clearAll: string
|
||||
@@ -150,7 +160,9 @@ export type PublicSiteDictionary = {
|
||||
vehicleUnavailableNoDate: string
|
||||
bookingOptionsLoadError: string
|
||||
startDate: string
|
||||
startTime: string
|
||||
endDate: string
|
||||
endTime: string
|
||||
continueToDriverDetails: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -189,6 +201,7 @@ export type PublicSiteDictionary = {
|
||||
redirectingToPayment: string
|
||||
confirmPayAtPickup: string
|
||||
payWith: (provider: string) => string
|
||||
categoryNotice: (category: string) => string
|
||||
summary: {
|
||||
vehicle: string
|
||||
dates: string
|
||||
@@ -235,6 +248,7 @@ export type PublicSiteDictionary = {
|
||||
description: string
|
||||
bookingSummary: string
|
||||
vehicle: string
|
||||
vehicleCategory: string
|
||||
customer: string
|
||||
email: string
|
||||
dates: string
|
||||
@@ -364,6 +378,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
seats: (count) => `${count} seats`,
|
||||
perDay: 'per day',
|
||||
viewDetails: 'View details',
|
||||
categoryLabels: {
|
||||
ECONOMY: 'Economy',
|
||||
COMPACT: 'Compact',
|
||||
MIDSIZE: 'Midsize',
|
||||
FULLSIZE: 'Full-size',
|
||||
SUV: 'SUV',
|
||||
LUXURY: 'Luxury',
|
||||
VAN: 'Van',
|
||||
TRUCK: 'Truck',
|
||||
},
|
||||
filters: {
|
||||
title: 'Filters',
|
||||
clearAll: 'Clear all',
|
||||
@@ -402,8 +426,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
vehicleAvailableFrom: (date) => `This vehicle can only be reserved starting ${date}.`,
|
||||
vehicleUnavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||
bookingOptionsLoadError: 'Could not load booking options.',
|
||||
startDate: 'Start date',
|
||||
endDate: 'End date',
|
||||
startDate: 'Pick-up date',
|
||||
startTime: 'Pick-up time',
|
||||
endDate: 'Drop-off date',
|
||||
endTime: 'Drop-off time',
|
||||
continueToDriverDetails: 'Continue to driver details ->',
|
||||
firstName: 'First name',
|
||||
lastName: 'Last name',
|
||||
@@ -442,6 +468,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
redirectingToPayment: 'Redirecting to payment...',
|
||||
confirmPayAtPickup: 'Confirm - pay at pickup',
|
||||
payWith: (provider) => `Pay with ${provider} ->`,
|
||||
categoryNotice: (category) => `Your vehicle will be assigned from the ${category} category upon arrival. The specific car may differ from the one shown.`,
|
||||
summary: {
|
||||
vehicle: 'Vehicle',
|
||||
dates: 'Dates',
|
||||
@@ -488,6 +515,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
description: 'The reservation has been created in draft status and will be confirmed by staff or after payment.',
|
||||
bookingSummary: 'Booking summary',
|
||||
vehicle: 'Vehicle',
|
||||
vehicleCategory: 'Vehicle category',
|
||||
customer: 'Customer',
|
||||
email: 'Email',
|
||||
dates: 'Dates',
|
||||
@@ -615,6 +643,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
seats: (count) => `${count} places`,
|
||||
perDay: 'par jour',
|
||||
viewDetails: 'Voir les détails',
|
||||
categoryLabels: {
|
||||
ECONOMY: 'Économique',
|
||||
COMPACT: 'Compacte',
|
||||
MIDSIZE: 'Intermédiaire',
|
||||
FULLSIZE: 'Grande berline',
|
||||
SUV: 'SUV',
|
||||
LUXURY: 'Luxe',
|
||||
VAN: 'Van',
|
||||
TRUCK: 'Camionnette',
|
||||
},
|
||||
filters: {
|
||||
title: 'Filtres',
|
||||
clearAll: 'Tout effacer',
|
||||
@@ -653,8 +691,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
vehicleAvailableFrom: (date) => `Ce véhicule ne peut être réservé qu’à partir du ${date}.`,
|
||||
vehicleUnavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||
bookingOptionsLoadError: 'Impossible de charger les options de réservation.',
|
||||
startDate: 'Date de début',
|
||||
endDate: 'Date de fin',
|
||||
startDate: 'Date de prise en charge',
|
||||
startTime: 'Heure de prise en charge',
|
||||
endDate: 'Date de restitution',
|
||||
endTime: 'Heure de restitution',
|
||||
continueToDriverDetails: 'Continuer vers les informations conducteur ->',
|
||||
firstName: 'Prénom',
|
||||
lastName: 'Nom',
|
||||
@@ -693,6 +733,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
redirectingToPayment: 'Redirection vers le paiement...',
|
||||
confirmPayAtPickup: 'Confirmer - payer au retrait',
|
||||
payWith: (provider) => `Payer avec ${provider} ->`,
|
||||
categoryNotice: (category) => `Votre véhicule sera attribué depuis la catégorie ${category} à la prise en charge. Le véhicule exact peut différer de celui affiché.`,
|
||||
summary: {
|
||||
vehicle: 'Véhicule',
|
||||
dates: 'Dates',
|
||||
@@ -739,6 +780,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
description: 'La réservation a été créée en brouillon et sera confirmée par le personnel ou après paiement.',
|
||||
bookingSummary: 'Résumé de réservation',
|
||||
vehicle: 'Véhicule',
|
||||
vehicleCategory: 'Catégorie du véhicule',
|
||||
customer: 'Client',
|
||||
email: 'E-mail',
|
||||
dates: 'Dates',
|
||||
@@ -866,6 +908,16 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
seats: (count) => `${count} مقاعد`,
|
||||
perDay: 'في اليوم',
|
||||
viewDetails: 'عرض التفاصيل',
|
||||
categoryLabels: {
|
||||
ECONOMY: 'اقتصادية',
|
||||
COMPACT: 'مدمجة',
|
||||
MIDSIZE: 'متوسطة',
|
||||
FULLSIZE: 'كبيرة',
|
||||
SUV: 'دفع رباعي',
|
||||
LUXURY: 'فاخرة',
|
||||
VAN: 'فان',
|
||||
TRUCK: 'شاحنة',
|
||||
},
|
||||
filters: {
|
||||
title: 'الفلاتر',
|
||||
clearAll: 'مسح الكل',
|
||||
@@ -904,8 +956,10 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`,
|
||||
vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
|
||||
startDate: 'تاريخ البداية',
|
||||
endDate: 'تاريخ النهاية',
|
||||
startDate: 'تاريخ الاستلام',
|
||||
startTime: 'وقت الاستلام',
|
||||
endDate: 'تاريخ الإرجاع',
|
||||
endTime: 'وقت الإرجاع',
|
||||
continueToDriverDetails: 'المتابعة إلى بيانات السائق ->',
|
||||
firstName: 'الاسم الأول',
|
||||
lastName: 'اسم العائلة',
|
||||
@@ -944,6 +998,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
redirectingToPayment: 'جارٍ التحويل إلى الدفع...',
|
||||
confirmPayAtPickup: 'تأكيد - الدفع عند الاستلام',
|
||||
payWith: (provider) => `ادفع عبر ${provider} ->`,
|
||||
categoryNotice: (category) => `سيتم تخصيص مركبتك من فئة ${category} عند الاستلام. قد تختلف السيارة المحددة عن تلك المعروضة.`,
|
||||
summary: {
|
||||
vehicle: 'المركبة',
|
||||
dates: 'التواريخ',
|
||||
@@ -990,6 +1045,7 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
description: 'تم إنشاء الحجز بحالة مسودة وسيتم تأكيده من قبل الموظفين أو بعد الدفع.',
|
||||
bookingSummary: 'ملخص الحجز',
|
||||
vehicle: 'المركبة',
|
||||
vehicleCategory: 'فئة المركبة',
|
||||
customer: 'العميل',
|
||||
email: 'البريد الإلكتروني',
|
||||
dates: 'التواريخ',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
-- Add reviewToken to reservations for secure one-time review links
|
||||
ALTER TABLE "reservations" ADD COLUMN "reviewToken" TEXT;
|
||||
CREATE UNIQUE INDEX "reservations_reviewToken_key" ON "reservations"("reviewToken");
|
||||
|
||||
-- Make renterId optional on reviews (reviews can come from non-marketplace customers via token link)
|
||||
ALTER TABLE "reviews" ALTER COLUMN "renterId" DROP NOT NULL;
|
||||
ALTER TABLE "reviews" DROP CONSTRAINT IF EXISTS "reviews_renterId_fkey";
|
||||
ALTER TABLE "reviews" ADD CONSTRAINT "reviews_renterId_fkey"
|
||||
FOREIGN KEY ("renterId") REFERENCES "renters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "reservations" ADD COLUMN "vehicleCategory" "VehicleCategory";
|
||||
@@ -639,9 +639,10 @@ model Reservation {
|
||||
renter Renter? @relation("RenterReservations", fields: [renterId], references: [id])
|
||||
offerId String?
|
||||
offer Offer? @relation(fields: [offerId], references: [id])
|
||||
promoCodeUsed String?
|
||||
source BookingSource @default(DASHBOARD)
|
||||
marketplaceRef String?
|
||||
promoCodeUsed String?
|
||||
vehicleCategory VehicleCategory?
|
||||
source BookingSource @default(DASHBOARD)
|
||||
marketplaceRef String?
|
||||
status ReservationStatus @default(DRAFT)
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
@@ -663,6 +664,7 @@ model Reservation {
|
||||
cancelledBy CancelledBy?
|
||||
contractNumber String? @unique
|
||||
invoiceNumber String? @unique
|
||||
reviewToken String? @unique
|
||||
checkInFuelLevel String?
|
||||
checkOutFuelLevel String?
|
||||
|
||||
@@ -718,8 +720,8 @@ model Review {
|
||||
id String @id @default(cuid())
|
||||
reservationId String @unique
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
renterId String
|
||||
renter Renter @relation(fields: [renterId], references: [id])
|
||||
renterId String?
|
||||
renter Renter? @relation(fields: [renterId], references: [id])
|
||||
companyId String
|
||||
overallRating Int
|
||||
vehicleRating Int?
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"name": "@rentaldrivego/types",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit"
|
||||
|
||||
@@ -330,7 +330,7 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/reservations/:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
|
||||
| GET | `//:id/billing` | 🔒🏢💳 | Get full itemized billing breakdown (JSON — for invoice preview) |
|
||||
| PATCH | `/reservations/:id/billing` | 🔒🏢💳 | Add damage charges and extras to reservation |
|
||||
|
||||
---
|
||||
@@ -419,9 +419,9 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/reservations/:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
|
||||
| GET | `//:id/damage-reports` | 🔒🏢💳 | Get check-in + check-out damage reports |
|
||||
| POST | `/reservations/:id/damage-reports` | 🔒🏢💳 | Save damage report. Body: `{ type: "CHECKIN"|"CHECKOUT", damages: DamageZone[], fuelLevel, mileage, photos[], inspectedBy, customerSignedAt, customerName }` |
|
||||
| PATCH | `/reservations/:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
|
||||
| PATCH | `//:id/damage-reports/:type` | 🔒🏢💳 | Update a damage report (before finalizing) |
|
||||
|
||||
### Insurance Policies (Company configuration)
|
||||
|
||||
@@ -432,7 +432,7 @@ Base URL: `https://api.RentalDriveGo.com/api/v1`
|
||||
| PATCH | `/insurance-policies/:id` | 🔒🏢💳 | Update policy |
|
||||
| DELETE | `/insurance-policies/:id` | 🔒🏢💳 | Deactivate policy |
|
||||
| GET | `/reservations/:id/insurances` | 🔒🏢💳 | Insurance selections for a reservation |
|
||||
| POST | `/reservations/:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
|
||||
| POST | `//:id/insurances` | 🔒🏢💳 | Apply insurance(s) to reservation. Body: `{ policyIds: string[] }` |
|
||||
|
||||
### Additional Drivers
|
||||
|
||||
|
||||
Reference in New Issue
Block a user