add review and booking policies

This commit is contained in:
root
2026-05-25 18:29:05 -04:00
parent 8ed572b3bd
commit 0d969ab095
37 changed files with 3775 additions and 113 deletions
+5
View File
@@ -26,6 +26,8 @@ import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes'
import marketplaceRouter from './modules/marketplace/marketplace.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
// ─── Centralized error handling ───────────────────────────────
import { errorMiddleware } from './http/errors/errorMiddleware'
@@ -62,6 +64,7 @@ const routeDocs = [
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
{ method: 'GET', path: `${v1}/subscriptions/features`, description: 'Subscription plan features' },
{ method: 'POST', path: `${v1}/admin/auth/login`, description: 'Admin login' },
]
@@ -129,6 +132,8 @@ export function createApp() {
app.use(`${v1}/companies`, apiLimiter, companiesRouter)
app.use(`${v1}/subscriptions`, apiLimiter, subscriptionsRouter)
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
// ─── Health / Docs ──────────────────────────────────────────
app.get('/health', (_req, res) => {
@@ -0,0 +1,84 @@
import { prisma } from '../../lib/prisma'
const COMPLAINT_INCLUDE = {
reservation: {
select: {
id: true,
startDate: true,
endDate: true,
status: true,
vehicle: { select: { make: true, model: true, year: true } },
},
},
customer: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
phone: true,
},
},
review: {
select: {
id: true,
overallRating: true,
comment: true,
},
},
}
export async function findMany(
companyId: string,
where: Record<string, unknown>,
skip: number,
take: number,
) {
const baseWhere = { companyId, ...where }
return Promise.all([
prisma.complaint.findMany({
where: baseWhere,
include: COMPLAINT_INCLUDE,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.complaint.count({ where: baseWhere }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.complaint.findFirst({
where: { id, companyId },
include: COMPLAINT_INCLUDE,
})
}
export async function create(data: {
companyId: string
reservationId?: string
reviewId?: string
customerId?: string
severity: string
category: string
subject: string
description?: string
assignedTo?: string
}) {
return prisma.complaint.create({
data: data as any,
include: COMPLAINT_INCLUDE,
})
}
export async function updateById(id: string, data: Record<string, unknown>) {
return prisma.complaint.update({
where: { id },
data,
include: COMPLAINT_INCLUDE,
})
}
export async function deleteById(id: string) {
return prisma.complaint.delete({ where: { id } })
}
@@ -0,0 +1,55 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import * as service from './complaint.service'
import { createSchema, updateSchema, listQuerySchema, idParamSchema } from './complaint.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listComplaints(req.companyId, query)
ok(res, result)
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
try {
const body = parseBody(createSchema, req)
const complaint = await service.createComplaint(req.companyId, body)
created(res, complaint)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const complaint = await service.getComplaint(id, req.companyId)
ok(res, complaint)
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const body = parseBody(updateSchema, req)
const updated = await service.updateComplaint(id, req.companyId, body)
ok(res, updated)
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.deleteComplaint(id, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,44 @@
import { z } from 'zod'
const CATEGORIES = [
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
] as const
const SEVERITIES = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3'] as const
const STATUSES = ['OPEN', 'INVESTIGATING', 'RESOLVED', 'CLOSED'] as const
export const createSchema = z.object({
reservationId: z.string().optional(),
reviewId: z.string().optional(),
customerId: z.string().optional(),
severity: z.enum(SEVERITIES).default('LEVEL_1'),
category: z.enum(CATEGORIES),
subject: z.string().min(1),
description: z.string().optional(),
assignedTo: z.string().optional(),
})
export const updateSchema = z.object({
status: z.enum(STATUSES).optional(),
severity: z.enum(SEVERITIES).optional(),
category: z.enum(CATEGORIES).optional(),
subject: z.string().min(1).optional(),
description: z.string().optional(),
notes: z.string().optional(),
resolution: z.string().optional(),
assignedTo: z.string().optional(),
})
export const listQuerySchema = z.object({
status: z.string().optional(),
severity: z.string().optional(),
category: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const idParamSchema = z.object({
id: z.string(),
})
@@ -0,0 +1,75 @@
import { NotFoundError } from '../../http/errors'
import * as repo from './complaint.repo'
export async function listComplaints(
companyId: string,
query: { status?: string; severity?: string; category?: string; page: number; pageSize: number },
) {
const { status, severity, category, page, pageSize } = query
const where: Record<string, unknown> = {}
if (status) where.status = status
if (severity) where.severity = severity
if (category) where.category = category
const [complaints, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
return {
data: complaints,
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
}
}
export async function getComplaint(id: string, companyId: string) {
const complaint = await repo.findById(id, companyId)
if (!complaint) throw new NotFoundError('Complaint not found')
return complaint
}
export async function createComplaint(
companyId: string,
data: {
reservationId?: string
reviewId?: string
customerId?: string
severity: string
category: string
subject: string
description?: string
assignedTo?: string
},
) {
return repo.create({ companyId, ...data })
}
export async function updateComplaint(
id: string,
companyId: string,
data: {
status?: string
severity?: string
category?: string
subject?: string
description?: string
notes?: string
resolution?: string
assignedTo?: string
},
) {
const existing = await repo.findById(id, companyId)
if (!existing) throw new NotFoundError('Complaint not found')
const updateData: Record<string, unknown> = { ...data }
// When status changes to RESOLVED, set resolvedAt
if (data.status === 'RESOLVED' && existing.status !== 'RESOLVED') {
updateData.resolvedAt = new Date()
}
return repo.updateById(id, updateData)
}
export async function deleteComplaint(id: string, companyId: string) {
const existing = await repo.findById(id, companyId)
if (!existing) throw new NotFoundError('Complaint not found')
await repo.deleteById(id)
return { success: true }
}
@@ -50,6 +50,7 @@ export async function confirmReservation(id: string, companyId: string) {
await assertLicenseCompliance(reservation.id, companyId)
const updated = await repo.updateById(id, { status: 'CONFIRMED' })
await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED')
const [customer, company, vehicle] = await Promise.all([
repo.findCustomerById(reservation.customerId),
@@ -117,30 +118,14 @@ export async function checkoutReservation(id: string, companyId: string, mileage
const reservation = await repo.findByIdForCheckout(id, companyId)
if (reservation.status !== 'ACTIVE') throw new AppError('Only ACTIVE reservations can be checked out', 400, 'invalid_status')
const reviewToken = crypto.randomBytes(32).toString('hex')
const reviewToken = reservation.reviewToken ?? crypto.randomBytes(32).toString('hex')
const updated = await repo.updateById(id, {
status: 'COMPLETED',
checkedOutAt: new Date(),
checkOutMileage: mileage ?? null,
reviewToken,
})
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE', mileage)
const [customer, company] = await Promise.all([
repo.findCustomerById(reservation.customerId),
repo.findCompanyWithBrand(companyId),
])
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${reservation.vehicle.year} ${reservation.vehicle.make} ${reservation.vehicle.model}`
const reviewUrl = `${process.env.MARKETPLACE_URL ?? process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
}).catch(() => null)
await repo.updateVehicleStatus(reservation.vehicleId, 'RETURNED', mileage)
return updated
}
@@ -161,16 +146,81 @@ export async function closeReservation(id: string, companyId: string, closedBy:
data: { extras: extras as any },
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
})
// Send review request email if eligible
if (reservation.reviewToken && !(reservation as any).reviewPaused) {
try {
const customer = updated.customer as any
if (!customer.reviewOptOut && customer.email) {
const company = await repo.findCompanyWithBrand(companyId)
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicleLabel = `${updated.vehicle.year} ${updated.vehicle.make} ${updated.vehicle.model}`
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
}).catch(() => null)
await prisma.reservation.update({
where: { id: reservation.id },
data: { reviewRequestSentAt: new Date() },
})
}
} catch {
// Non-blocking: do not fail close if email fails
}
}
return serializeReservationForDashboard(updated)
}
export async function extendReservation(id: string, companyId: string, newEndDate: Date, reason: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (!['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
throw new AppError('Only CONFIRMED or ACTIVE reservations can be extended', 400, 'invalid_status')
}
if (newEndDate <= reservation.endDate) {
throw new AppError('New end date must be after the current end date', 400, 'invalid_end_date')
}
const conflict = await repo.findConflict(reservation.vehicleId, reservation.endDate, newEndDate, id)
if (conflict) {
throw new AppError('Vehicle is unavailable for the requested extension period', 409, 'vehicle_unavailable')
}
const additionalDays = Math.ceil((newEndDate.getTime() - reservation.endDate.getTime()) / (1000 * 60 * 60 * 24))
const additionalAmount = additionalDays * reservation.dailyRate
const extras = parseReservationExtras(reservation.extras)
if (!Array.isArray(extras.extensions)) extras.extensions = []
;(extras.extensions as unknown[]).push({
previousEndDate: reservation.endDate.toISOString(),
newEndDate: newEndDate.toISOString(),
additionalDays,
additionalAmount,
reason,
approvedAt: new Date().toISOString(),
})
const updated = await prisma.reservation.update({
where: { id },
data: {
endDate: newEndDate,
totalDays: reservation.totalDays + additionalDays,
totalAmount: reservation.totalAmount + additionalAmount,
extras: extras as any,
},
})
return updated
}
export async function cancelReservation(id: string, companyId: string, reason?: string) {
const reservation = await repo.findByIdSimple(id, companyId)
if (['COMPLETED', 'CANCELLED'].includes(reservation.status)) {
throw new AppError('Reservation cannot be cancelled', 400, 'invalid_status')
}
const updated = await repo.updateById(id, { status: 'CANCELLED', cancelReason: reason ?? null, cancelledBy: 'COMPANY' })
if (['CONFIRMED', 'ACTIVE'].includes(reservation.status)) {
if (['CONFIRMED', 'ACTIVE', 'DRAFT'].includes(reservation.status)) {
await repo.updateVehicleStatus(reservation.vehicleId, 'AVAILABLE')
}
return updated
@@ -0,0 +1,33 @@
import { prisma } from '../../lib/prisma'
import { uploadImage, deleteImage } from '../../lib/storage'
import { NotFoundError } from '../../http/errors'
export async function listPhotos(reservationId: string, companyId: string) {
const reservation = await prisma.reservation.findFirst({ where: { id: reservationId, companyId }, select: { id: true } })
if (!reservation) throw new NotFoundError('Reservation not found')
return prisma.reservationPhoto.findMany({
where: { reservationId },
orderBy: { createdAt: 'asc' },
})
}
export async function uploadPhoto(
reservationId: string,
companyId: string,
type: 'PICKUP' | 'DROPOFF',
buffer: Buffer,
) {
const reservation = await prisma.reservation.findFirst({ where: { id: reservationId, companyId }, select: { id: true } })
if (!reservation) throw new NotFoundError('Reservation not found')
const url = await uploadImage(buffer, `companies/${companyId}/reservations/${reservationId}/photos`)
return prisma.reservationPhoto.create({ data: { reservationId, type, url } })
}
export async function deletePhoto(photoId: string, reservationId: string, companyId: string) {
const photo = await prisma.reservationPhoto.findFirst({
where: { id: photoId, reservationId, reservation: { companyId } },
})
if (!photo) throw new NotFoundError('Photo not found')
await deleteImage(photo.url)
await prisma.reservationPhoto.delete({ where: { id: photoId } })
}
@@ -74,7 +74,7 @@ export async function findForBilling(id: string, companyId: string) {
}
export async function findForClose(id: string, companyId: string) {
return prisma.reservation.findFirstOrThrow({ where: { id, companyId } })
return prisma.reservation.findFirstOrThrow({ where: { id, companyId }, include: { customer: true } })
}
export async function findForInspection(id: string, companyId: string) {
@@ -1,21 +1,27 @@
import { Router } from 'express'
import { z } from 'zod'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { imageUpload, assertImageFile } from '../../http/upload'
import * as service from './reservation.service'
import * as lifecycle from './reservation.lifecycle.service'
import * as inspectionService from './reservation.inspection.service'
import * as additionalDriverService from './reservation.additional-driver.service'
import * as photoService from './reservation.photo.service'
import { getContract, getBilling } from './reservation.document.service'
import {
createSchema, updateSchema, listQuerySchema,
checkinSchema, checkoutSchema, cancelSchema, approvalSchema,
checkinSchema, checkoutSchema, extendSchema, cancelSchema, approvalSchema,
inspectionSchema, inspectionTypeParam,
idParamSchema, driverParamSchema, inspectionParamSchema,
} from './reservation.schemas'
const photoTypeSchema = z.enum(['PICKUP', 'DROPOFF'])
const photoParamSchema = z.object({ id: z.string(), photoId: z.string() })
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
@@ -103,6 +109,14 @@ router.post('/:id/close', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/extend', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { newEndDate, reason } = parseBody(extendSchema, req)
ok(res, await lifecycle.extendReservation(id, req.companyId, new Date(newEndDate), reason))
} catch (err) { next(err) }
})
router.post('/:id/cancel', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
@@ -141,4 +155,29 @@ router.patch('/:id/additional-drivers/:driverId/approval', async (req, res, next
} catch (err) { next(err) }
})
router.get('/:id/photos', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
ok(res, await photoService.listPhotos(id, req.companyId))
} catch (err) { next(err) }
})
router.post('/:id/photos', imageUpload.single('photo'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
assertImageFile(req.file, 'photo')
const type = photoTypeSchema.parse(req.body.type)
const photo = await photoService.uploadPhoto(id, req.companyId, type, req.file.buffer)
created(res, photo)
} catch (err) { next(err) }
})
router.delete('/:id/photos/:photoId', async (req, res, next) => {
try {
const { id, photoId } = parseParams(photoParamSchema, req)
await photoService.deletePhoto(photoId, id, req.companyId)
ok(res, { deleted: true })
} catch (err) { next(err) }
})
export default router
@@ -70,6 +70,7 @@ export const listQuerySchema = z.object({
export const checkinSchema = z.object({ mileage: z.number().int().optional() })
export const checkoutSchema = z.object({ mileage: z.number().int().optional() })
export const extendSchema = z.object({ newEndDate: z.string().datetime(), reason: z.string().min(1) })
export const cancelSchema = z.object({ reason: z.string().optional() })
export const approvalSchema = z.object({ approved: z.boolean(), note: z.string().optional() })
export const inspectionTypeParam = z.enum(['CHECKIN', 'CHECKOUT'])
+109
View File
@@ -0,0 +1,109 @@
import { prisma } from '../../lib/prisma'
const REVIEW_INCLUDE = {
reservation: {
select: {
id: true,
startDate: true,
endDate: true,
reviewToken: true,
reviewRequestSentAt: true,
reviewReminderSentAt: true,
reviewFinalReminderSentAt: true,
reviewPaused: true,
customer: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
reviewOptOut: true,
},
},
vehicle: {
select: {
id: true,
make: true,
model: true,
year: true,
},
},
},
},
}
export async function findMany(
companyId: string,
where: Record<string, unknown>,
skip: number,
take: number,
) {
const baseWhere = { companyId, ...where }
return Promise.all([
prisma.review.findMany({
where: baseWhere,
include: REVIEW_INCLUDE,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.review.count({ where: baseWhere }),
])
}
export async function findById(id: string, companyId: string) {
return prisma.review.findFirst({
where: { id, companyId },
include: REVIEW_INCLUDE,
})
}
export async function updateById(id: string, data: Record<string, unknown>) {
return prisma.review.update({
where: { id },
data,
include: REVIEW_INCLUDE,
})
}
export async function getStats(companyId: string) {
const reviews = await prisma.review.findMany({
where: { companyId },
select: { overallRating: true, vehicleRating: true, serviceRating: true },
})
const total = reviews.length
const byRating: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }
let sumOverall = 0
let sumVehicle = 0
let countVehicle = 0
let sumService = 0
let countService = 0
for (const r of reviews) {
sumOverall += r.overallRating
byRating[r.overallRating] = (byRating[r.overallRating] ?? 0) + 1
if (r.vehicleRating != null) { sumVehicle += r.vehicleRating; countVehicle++ }
if (r.serviceRating != null) { sumService += r.serviceRating; countService++ }
}
return {
total,
averageOverall: total > 0 ? Math.round((sumOverall / total) * 10) / 10 : 0,
averageVehicle: countVehicle > 0 ? Math.round((sumVehicle / countVehicle) * 10) / 10 : 0,
averageService: countService > 0 ? Math.round((sumService / countService) * 10) / 10 : 0,
byRating,
}
}
export async function sendReminder(
reservationId: string,
sentAt: Date,
field: 'reviewReminderSentAt' | 'reviewFinalReminderSentAt',
) {
return prisma.reservation.update({
where: { id: reservationId },
data: { [field]: sentAt },
})
}
@@ -0,0 +1,54 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscription } from '../../middleware/requireSubscription'
import { parseBody, parseQuery, parseParams } from '../../http/validate'
import { ok } from '../../http/respond'
import * as service from './review.service'
import { replySchema, listQuerySchema, idParamSchema } from './review.schemas'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.get('/', async (req, res, next) => {
try {
const query = parseQuery(listQuerySchema, req)
const result = await service.listReviews(req.companyId, query)
ok(res, result)
} catch (err) { next(err) }
})
router.get('/stats', async (req, res, next) => {
try {
const stats = await service.getStats(req.companyId)
ok(res, stats)
} catch (err) { next(err) }
})
router.get('/:id', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const review = await service.getReview(id, req.companyId)
ok(res, review)
} catch (err) { next(err) }
})
router.patch('/:id/reply', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { companyReply } = parseBody(replySchema, req)
const updated = await service.replyToReview(id, req.companyId, companyReply)
ok(res, updated)
} catch (err) { next(err) }
})
router.post('/:id/remind', async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const result = await service.sendReviewReminder(id, req.companyId)
ok(res, result)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,15 @@
import { z } from 'zod'
export const replySchema = z.object({
companyReply: z.string().min(1),
})
export const listQuerySchema = z.object({
rating: z.coerce.number().int().min(1).max(5).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
export const idParamSchema = z.object({
id: z.string(),
})
@@ -0,0 +1,76 @@
import { NotFoundError, AppError } from '../../http/errors'
import { sendTransactionalEmail } from '../../services/notificationService'
import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './review.repo'
import * as reservationRepo from '../reservations/reservation.repo'
export async function listReviews(
companyId: string,
query: { rating?: number; page: number; pageSize: number },
) {
const { rating, page, pageSize } = query
const where: Record<string, unknown> = {}
if (rating != null) where.overallRating = rating
const [reviews, total] = await repo.findMany(companyId, where, (page - 1) * pageSize, pageSize)
return {
data: reviews,
meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) },
}
}
export async function getReview(id: string, companyId: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
return review
}
export async function replyToReview(id: string, companyId: string, companyReply: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
return repo.updateById(id, {
companyReply,
companyRepliedAt: new Date(),
})
}
export async function getStats(companyId: string) {
return repo.getStats(companyId)
}
export async function sendReviewReminder(id: string, companyId: string) {
const review = await repo.findById(id, companyId)
if (!review) throw new NotFoundError('Review not found')
const reservation = review.reservation as any
if (!reservation) throw new AppError('Review has no linked reservation', 400, 'no_reservation')
if (reservation.reviewPaused) throw new AppError('Review requests are paused for this reservation', 400, 'review_paused')
const customer = reservation.customer
if (!customer || customer.reviewOptOut) throw new AppError('Customer has opted out of review requests', 400, 'opted_out')
if (!customer.email) throw new AppError('Customer has no email address', 400, 'no_email')
if (!reservation.reviewToken) throw new AppError('No review token available for this reservation', 400, 'no_token')
// Determine which reminder field to update
const field: 'reviewReminderSentAt' | 'reviewFinalReminderSentAt' =
reservation.reviewReminderSentAt ? 'reviewFinalReminderSentAt' : 'reviewReminderSentAt'
const company = await reservationRepo.findCompanyWithBrand(companyId)
const lang = (company?.brand?.defaultLocale ?? 'fr') as Lang
const companyName = company?.brand?.displayName ?? company?.name ?? 'the rental company'
const vehicle = reservation.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : 'your rental vehicle'
const reviewUrl = `${process.env.MARKETPLACE_URL ?? 'http://localhost:3000'}/review?token=${reservation.reviewToken}`
await sendTransactionalEmail({
to: customer.email,
subject: reviewRequestEmail.subject(vehicleLabel, lang),
html: reviewRequestEmail.html({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
text: reviewRequestEmail.text({ firstName: customer.firstName, companyName, vehicleLabel, reviewUrl }, lang),
})
await repo.sendReminder(reservation.id, new Date(), field)
return { success: true, field }
}
@@ -30,6 +30,10 @@ publicRouter.get('/providers', (_req, res) => {
ok(res, service.getProviders())
})
publicRouter.get('/features', (_req, res, next) => {
service.getPlanFeatures().then((d) => ok(res, d)).catch(next)
})
// ─── Webhooks (no auth) ────────────────────────────────────────
webhookRouter.post('/webhooks/amanpay', async (req, res, next) => {
@@ -31,6 +31,12 @@ export function getProviders() {
return { amanpay: amanpay.isConfigured(), paypal: paypal.isConfigured() }
}
export function getPlanFeatures() {
return prisma.planFeature.findMany({
orderBy: [{ plan: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
// ─── Reads ────────────────────────────────────────────────────
export function getSubscription(companyId: string) {
@@ -9,7 +9,7 @@ import { imageUpload, assertImageFiles } from '../../http/upload'
import * as service from './vehicle.service'
import {
vehicleSchema, listQuerySchema, availabilityQuerySchema, calendarQuerySchema,
calendarBlockSchema, maintenanceLogSchema, publishSchema,
calendarBlockSchema, maintenanceLogSchema, publishSchema, statusSchema,
idParamSchema, photoIdxSchema, blockIdParamSchema,
} from './vehicle.schemas'
@@ -76,6 +76,14 @@ router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next)
} catch (err) { next(err) }
})
router.patch('/:id/status', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
const { status } = parseBody(statusSchema, req)
ok(res, await service.setStatus(id, req.companyId, status))
} catch (err) { next(err) }
})
router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => {
try {
const { id } = parseParams(idParamSchema, req)
@@ -16,7 +16,7 @@ export 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(),
status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']).optional(),
pickupLocations: z.array(z.string().trim().min(1)).default([]),
allowDifferentDropoff: z.boolean().default(false),
dropoffLocations: z.array(z.string().trim().min(1)).default([]),
@@ -58,6 +58,7 @@ export const maintenanceLogSchema = z.object({
})
export const publishSchema = z.object({ isPublished: z.boolean() })
export const statusSchema = z.object({ status: z.enum(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE']) })
export const idParamSchema = z.object({ id: z.string() })
export const photoIdxSchema = z.object({ id: z.string(), idx: z.string() })
@@ -61,18 +61,25 @@ export async function createVehicle(data: any, companyId: string) {
return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId }))
}
const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = applyLocationSettings(data)
if (patch.status === 'MAINTENANCE' || patch.status === 'OUT_OF_SERVICE') {
patch.isPublished = false
} else if (patch.status === 'AVAILABLE' || patch.status === 'RENTED') {
patch.isPublished = true
if (patch.status) {
patch.isPublished = PUBLISHED_STATUSES.has(patch.status)
}
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
return presentVehicle(await repo.updateById(id, patch))
}
export async function setStatus(id: string, companyId: string, status: string) {
const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found')
const isPublished = PUBLISHED_STATUSES.has(status)
return presentVehicle(await repo.updateById(id, { status, isPublished }))
}
export async function deleteVehicle(id: string, companyId: string) {
return repo.softDelete(id, companyId)
}
@@ -0,0 +1,567 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Complaints',
subtitle: 'Manage customer complaints and escalations',
newComplaint: 'New complaint',
filterStatus: 'Status',
filterSeverity: 'Severity',
allStatuses: 'All statuses',
allSeverities: 'All severities',
statusOpen: 'Open',
statusInvestigating: 'Investigating',
statusResolved: 'Resolved',
statusClosed: 'Closed',
sevL1: 'Level 1',
sevL2: 'Level 2',
sevL3: 'Level 3',
noComplaints: 'No complaints found',
category: 'Category',
subject: 'Subject',
reservation: 'Reservation',
date: 'Date',
status: 'Status',
severity: 'Severity',
details: 'Details',
description: 'Description',
notes: 'Notes',
resolution: 'Resolution',
updateStatus: 'Update status',
saving: 'Saving…',
save: 'Save',
cancel: 'Cancel',
createTitle: 'Create complaint',
fieldReservationId: 'Reservation ID (optional)',
fieldCategory: 'Category',
fieldSeverity: 'Severity',
fieldSubject: 'Subject',
fieldDescription: 'Description (optional)',
create: 'Create',
creating: 'Creating…',
errorLoad: 'Failed to load complaints',
errorCreate: 'Failed to create complaint',
errorUpdate: 'Failed to update complaint',
categories: {
BOOKING: 'Booking', PICKUP: 'Pickup', VEHICLE_CLEANLINESS: 'Vehicle Cleanliness',
VEHICLE_CONDITION: 'Vehicle Condition', STAFF_SERVICE: 'Staff Service', PRICING: 'Pricing',
DEPOSIT: 'Deposit', INSURANCE: 'Insurance', DAMAGE_CLAIM: 'Damage Claim',
RETURN_PROCESS: 'Return Process', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Roadside Assistance', BILLING: 'Billing', OTHER: 'Other',
} as Record<string, string>,
},
fr: {
heading: 'Plaintes',
subtitle: 'Gérez les plaintes et escalades clients',
newComplaint: 'Nouvelle plainte',
filterStatus: 'Statut',
filterSeverity: 'Sévérité',
allStatuses: 'Tous les statuts',
allSeverities: 'Toutes les sévérités',
statusOpen: 'Ouvert',
statusInvestigating: 'En cours',
statusResolved: 'Résolu',
statusClosed: 'Fermé',
sevL1: 'Niveau 1',
sevL2: 'Niveau 2',
sevL3: 'Niveau 3',
noComplaints: 'Aucune plainte trouvée',
category: 'Catégorie',
subject: 'Sujet',
reservation: 'Réservation',
date: 'Date',
status: 'Statut',
severity: 'Sévérité',
details: 'Détails',
description: 'Description',
notes: 'Notes',
resolution: 'Résolution',
updateStatus: 'Mettre à jour le statut',
saving: 'Enregistrement…',
save: 'Enregistrer',
cancel: 'Annuler',
createTitle: 'Créer une plainte',
fieldReservationId: 'ID de réservation (optionnel)',
fieldCategory: 'Catégorie',
fieldSeverity: 'Sévérité',
fieldSubject: 'Sujet',
fieldDescription: 'Description (optionnel)',
create: 'Créer',
creating: 'Création…',
errorLoad: 'Échec du chargement des plaintes',
errorCreate: 'Échec de la création de la plainte',
errorUpdate: 'Échec de la mise à jour de la plainte',
categories: {
BOOKING: 'Réservation', PICKUP: 'Prise en charge', VEHICLE_CLEANLINESS: 'Propreté du véhicule',
VEHICLE_CONDITION: 'État du véhicule', STAFF_SERVICE: 'Service du personnel', PRICING: 'Tarification',
DEPOSIT: 'Dépôt de garantie', INSURANCE: 'Assurance', DAMAGE_CLAIM: 'Réclamation dommages',
RETURN_PROCESS: 'Processus de retour', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Assistance routière', BILLING: 'Facturation', OTHER: 'Autre',
} as Record<string, string>,
},
ar: {
heading: 'الشكاوى',
subtitle: 'إدارة شكاوى العملاء والتصعيدات',
newComplaint: 'شكوى جديدة',
filterStatus: 'الحالة',
filterSeverity: 'الخطورة',
allStatuses: 'جميع الحالات',
allSeverities: 'جميع مستويات الخطورة',
statusOpen: 'مفتوح',
statusInvestigating: 'قيد التحقيق',
statusResolved: 'تم الحل',
statusClosed: 'مغلق',
sevL1: 'المستوى 1',
sevL2: 'المستوى 2',
sevL3: 'المستوى 3',
noComplaints: 'لا توجد شكاوى',
category: 'الفئة',
subject: 'الموضوع',
reservation: 'الحجز',
date: 'التاريخ',
status: 'الحالة',
severity: 'الخطورة',
details: 'التفاصيل',
description: 'الوصف',
notes: 'ملاحظات',
resolution: 'الحل',
updateStatus: 'تحديث الحالة',
saving: 'جار الحفظ…',
save: 'حفظ',
cancel: 'إلغاء',
createTitle: 'إنشاء شكوى',
fieldReservationId: 'رقم الحجز (اختياري)',
fieldCategory: 'الفئة',
fieldSeverity: 'الخطورة',
fieldSubject: 'الموضوع',
fieldDescription: 'الوصف (اختياري)',
create: 'إنشاء',
creating: 'جار الإنشاء…',
errorLoad: 'فشل تحميل الشكاوى',
errorCreate: 'فشل إنشاء الشكوى',
errorUpdate: 'فشل تحديث الشكوى',
categories: {
BOOKING: 'الحجز', PICKUP: 'الاستلام', VEHICLE_CLEANLINESS: 'نظافة المركبة',
VEHICLE_CONDITION: 'حالة المركبة', STAFF_SERVICE: 'خدمة الموظفين', PRICING: 'التسعير',
DEPOSIT: 'التأمين', INSURANCE: 'التأمين', DAMAGE_CLAIM: 'مطالبة الأضرار',
RETURN_PROCESS: 'عملية الإرجاع', COMMUNICATION: 'التواصل',
ROADSIDE_ASSISTANCE: 'المساعدة على الطريق', BILLING: 'الفواتير', OTHER: 'أخرى',
} as Record<string, string>,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface Complaint {
id: string
companyId: string
reservationId: string | null
reviewId: string | null
customerId: string | null
severity: string
status: string
category: string
subject: string
description: string | null
resolution: string | null
notes: string | null
assignedTo: string | null
resolvedAt: string | null
createdAt: string
reservation: { id: string; startDate: string; status: string; vehicle: { make: string; model: string; year: number } } | null
customer: { id: string; firstName: string; lastName: string; email: string } | null
review: { id: string; overallRating: number } | null
}
interface ComplaintsResponse {
data: Complaint[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
const CATEGORIES = [
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
]
function severityBadgeClass(sev: string) {
if (sev === 'LEVEL_3') return 'badge-red'
if (sev === 'LEVEL_2') return 'badge-amber'
return 'badge-gray'
}
function statusBadgeClass(status: string) {
if (status === 'RESOLVED' || status === 'CLOSED') return 'badge-green'
if (status === 'INVESTIGATING') return 'badge-blue'
return 'badge-amber'
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ComplaintsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [complaints, setComplaints] = useState<Complaint[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterSeverity, setFilterSeverity] = useState('')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
// Create form state
const [formReservationId, setFormReservationId] = useState('')
const [formCategory, setFormCategory] = useState('')
const [formSeverity, setFormSeverity] = useState('LEVEL_1')
const [formSubject, setFormSubject] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formCreating, setFormCreating] = useState(false)
// Edit state (for expanded complaint)
const [editStatus, setEditStatus] = useState<Record<string, string>>({})
const [editNotes, setEditNotes] = useState<Record<string, string>>({})
const [editResolution, setEditResolution] = useState<Record<string, string>>({})
const [savingId, setSavingId] = useState<string | null>(null)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterStatus) params.set('status', filterStatus)
if (filterSeverity) params.set('severity', filterSeverity)
const data = await apiFetch<ComplaintsResponse>(`/complaints?${params}`)
setComplaints(data.data ?? [])
setMeta(data.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterStatus, filterSeverity])
function toggleExpand(id: string, complaint: Complaint) {
if (expandedId === id) {
setExpandedId(null)
} else {
setExpandedId(id)
setEditStatus((prev) => ({ ...prev, [id]: complaint.status }))
setEditNotes((prev) => ({ ...prev, [id]: complaint.notes ?? '' }))
setEditResolution((prev) => ({ ...prev, [id]: complaint.resolution ?? '' }))
}
}
async function handleCreate() {
if (!formCategory || !formSubject.trim()) return
setFormCreating(true)
try {
await apiFetch('/complaints', {
method: 'POST',
body: JSON.stringify({
reservationId: formReservationId.trim() || undefined,
category: formCategory,
severity: formSeverity,
subject: formSubject.trim(),
description: formDescription.trim() || undefined,
}),
})
setShowCreate(false)
setFormReservationId('')
setFormCategory('')
setFormSeverity('LEVEL_1')
setFormSubject('')
setFormDescription('')
await loadData()
} catch {
alert(t.errorCreate)
} finally {
setFormCreating(false)
}
}
async function handleUpdate(id: string) {
setSavingId(id)
try {
await apiFetch(`/complaints/${id}`, {
method: 'PATCH',
body: JSON.stringify({
status: editStatus[id],
notes: editNotes[id] || undefined,
resolution: editResolution[id] || undefined,
}),
})
await loadData()
} catch {
alert(t.errorUpdate)
} finally {
setSavingId(null)
}
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 p-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
<button onClick={() => setShowCreate((v) => !v)} className="btn-primary">
<Plus className="h-4 w-4" />
{t.newComplaint}
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-6 space-y-4">
<h2 className="text-lg font-semibold text-blue-950 dark:text-white">{t.createTitle}</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldReservationId}</label>
<input
type="text"
value={formReservationId}
onChange={(e) => setFormReservationId(e.target.value)}
className="input-field"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldCategory}</label>
<select
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
className="input-field"
>
<option value=""> select </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{t.categories[c] ?? c}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSeverity}</label>
<select value={formSeverity} onChange={(e) => setFormSeverity(e.target.value)} className="input-field">
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSubject}</label>
<input
type="text"
value={formSubject}
onChange={(e) => setFormSubject(e.target.value)}
className="input-field"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldDescription}</label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={3}
className="input-field resize-none"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={formCreating || !formCategory || !formSubject.trim()}
className="btn-primary"
>
{formCreating ? t.creating : t.create}
</button>
<button onClick={() => setShowCreate(false)} className="btn-secondary">{t.cancel}</button>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterStatus}</label>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allStatuses}</option>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterSeverity}</label>
<select
value={filterSeverity}
onChange={(e) => { setFilterSeverity(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allSeverities}</option>
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
</div>
</div>
{/* List */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : complaints.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noComplaints}</div>
) : (
<div className="space-y-3">
{complaints.map((complaint) => {
const isExpanded = expandedId === complaint.id
const customerName = complaint.customer
? `${complaint.customer.firstName} ${complaint.customer.lastName}`
: null
return (
<div key={complaint.id} className="card overflow-hidden">
<button
onClick={() => toggleExpand(complaint.id, complaint)}
className="flex w-full items-start justify-between p-5 text-left hover:bg-stone-50 dark:hover:bg-blue-950/30"
>
<div className="flex flex-1 flex-wrap items-center gap-3">
<span className={severityBadgeClass(complaint.severity)}>
<AlertTriangle className="mr-1 h-3 w-3" />
{complaint.severity === 'LEVEL_1' ? t.sevL1 : complaint.severity === 'LEVEL_2' ? t.sevL2 : t.sevL3}
</span>
<span className={statusBadgeClass(complaint.status)}>
{complaint.status === 'OPEN' ? t.statusOpen
: complaint.status === 'INVESTIGATING' ? t.statusInvestigating
: complaint.status === 'RESOLVED' ? t.statusResolved
: t.statusClosed}
</span>
<span className="badge-gray">{t.categories[complaint.category] ?? complaint.category}</span>
<span className="font-medium text-blue-950 dark:text-white">{complaint.subject}</span>
{customerName && <span className="text-sm text-stone-500 dark:text-slate-400">{customerName}</span>}
{complaint.reservationId && (
<span className="font-mono text-xs text-stone-400 dark:text-slate-500">
{complaint.reservationId.slice(0, 8)}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(complaint.createdAt).toLocaleDateString()}
</span>
{isExpanded ? <ChevronUp className="h-4 w-4 text-stone-400" /> : <ChevronDown className="h-4 w-4 text-stone-400" />}
</div>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-stone-100 p-5 dark:border-blue-900/30 space-y-4">
{complaint.description && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.description}</p>
<p className="text-sm text-stone-700 dark:text-slate-300">{complaint.description}</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.updateStatus}</label>
<select
value={editStatus[complaint.id] ?? complaint.status}
onChange={(e) => setEditStatus((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
className="input-field"
>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.notes}</label>
<textarea
value={editNotes[complaint.id] ?? ''}
onChange={(e) => setEditNotes((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.resolution}</label>
<textarea
value={editResolution[complaint.id] ?? ''}
onChange={(e) => setEditResolution((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => handleUpdate(complaint.id)}
disabled={savingId === complaint.id}
className="btn-primary text-sm"
>
{savingId === complaint.id ? t.saving : t.save}
</button>
<button onClick={() => setExpandedId(null)} className="btn-secondary text-sm">{t.cancel}</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
@@ -673,7 +673,7 @@ export default function FleetDetailPage() {
<div>
<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) => (
{(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const).map((s) => (
<option key={s} value={s}>{fl.statusLabels[s]}</option>
))}
</select>
@@ -14,7 +14,7 @@ interface Vehicle {
model: string
year: number
category: string
status: 'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE'
status: 'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE'
dailyRate: number
isPublished: boolean
primaryPhoto: string | null
@@ -31,13 +31,33 @@ interface AddVehicleModalProps {
}
const STATUS_BADGE: Record<Vehicle['status'], string> = {
AVAILABLE: 'badge-green',
RENTED: 'badge-blue',
MAINTENANCE: 'badge-amber',
AVAILABLE: 'badge-green',
RESERVED: 'badge-blue',
READY: 'badge-purple',
RENTED: 'badge-indigo',
RETURNED: 'badge-amber',
NEEDS_CLEANING: 'badge-amber',
MAINTENANCE: 'badge-amber',
DAMAGE_REVIEW: 'badge-red',
OUT_OF_SERVICE: 'badge-red',
}
const ALL_STATUSES: Vehicle['status'][] = ['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']
const ALL_STATUSES: Vehicle['status'][] = [
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
]
const STATUS_DOT: Record<Vehicle['status'], string> = {
AVAILABLE: 'bg-green-500',
RESERVED: 'bg-blue-500',
READY: 'bg-teal-500',
RENTED: 'bg-indigo-500',
RETURNED: 'bg-amber-400',
NEEDS_CLEANING: 'bg-amber-500',
MAINTENANCE: 'bg-orange-500',
DAMAGE_REVIEW: 'bg-red-400',
OUT_OF_SERVICE: 'bg-red-600',
}
interface MaintenanceModalProps {
vehicleId: string
@@ -178,7 +198,7 @@ function StatusDropdown({ vehicleId, status, onStatusChange }: {
setOpen(false)
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
alert(err.message)
@@ -210,11 +230,7 @@ function StatusDropdown({ vehicleId, status, onStatusChange }: {
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-orange-500' : 'bg-red-500'
}`} />
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[s]}`} />
{f.statusLabels[s]}
</button>
))}
@@ -620,8 +636,10 @@ export default function FleetPage() {
}
}
const PUBLISHED = new Set<Vehicle['status']>(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = status !== 'MAINTENANCE' && status !== 'OUT_OF_SERVICE'
const isPublished = PUBLISHED.has(status)
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
+1 -13
View File
@@ -1,10 +1,9 @@
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/I18nProvider'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] print:block print:min-h-0 print:bg-white">
<div className="flex min-h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<Sidebar />
</div>
@@ -13,17 +12,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<TopBar />
</div>
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">{children}</main>
<footer className="border-t border-stone-200/80 bg-white/72 px-6 py-4 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/72 print:hidden">
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
Workspace preferences
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
</footer>
</div>
</div>
)
@@ -6,6 +6,7 @@ import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
import ReservationPhotoSection from '@/components/reservations/ReservationPhotoSection'
interface ReservationDetail {
id: string
@@ -118,6 +119,18 @@ const detailCopy = {
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
licenseImageLabel: 'License image',
noLicenseImage: 'No license image uploaded.',
pickupPhotosTitle: 'Pickup photos',
dropoffPhotosTitle: 'Return photos',
pickupPhotosReadOnly: 'Pickup photos can be added once the reservation is confirmed.',
dropoffPhotosReadOnly: 'Return photos become available once the vehicle is checked out.',
extendBtn: 'Extend rental',
extendTitle: 'Extend rental',
extendNewEnd: 'New return date & time',
extendReason: 'Reason for extension',
extendReasonPlaceholder: 'e.g. Customer requested 2 extra days',
extendSubmit: 'Confirm extension',
extendCancel: 'Cancel',
extendFailed: 'Failed to extend reservation',
},
fr: {
editBooking: 'Modifier la réservation',
@@ -155,6 +168,18 @@ const detailCopy = {
inspectionClosed: 'Cette réservation est clôturée. Les modifications dinspection sont désactivées.',
licenseImageLabel: 'Image du permis',
noLicenseImage: 'Aucune image de permis téléversée.',
pickupPhotosTitle: 'Photos de départ',
dropoffPhotosTitle: 'Photos de retour',
pickupPhotosReadOnly: 'Les photos de départ sont disponibles après confirmation de la réservation.',
dropoffPhotosReadOnly: 'Les photos de retour sont disponibles après la restitution du véhicule.',
extendBtn: 'Prolonger la location',
extendTitle: 'Prolonger la location',
extendNewEnd: 'Nouvelle date de retour',
extendReason: 'Motif de prolongation',
extendReasonPlaceholder: 'ex. Le client a demandé 2 jours supplémentaires',
extendSubmit: 'Confirmer la prolongation',
extendCancel: 'Annuler',
extendFailed: 'Échec de la prolongation',
},
ar: {
editBooking: 'تعديل الحجز',
@@ -192,6 +217,18 @@ const detailCopy = {
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
pickupPhotosTitle: 'صور الاستلام',
dropoffPhotosTitle: 'صور الإرجاع',
pickupPhotosReadOnly: 'تتوفر صور الاستلام بعد تأكيد الحجز.',
dropoffPhotosReadOnly: 'تتوفر صور الإرجاع بعد إعادة السيارة.',
extendBtn: 'تمديد التأجير',
extendTitle: 'تمديد التأجير',
extendNewEnd: 'تاريخ الإرجاع الجديد',
extendReason: 'سبب التمديد',
extendReasonPlaceholder: 'مثال: طلب العميل يومين إضافيين',
extendSubmit: 'تأكيد التمديد',
extendCancel: 'إلغاء',
extendFailed: 'فشل تمديد الحجز',
},
} as const
@@ -233,6 +270,9 @@ export default function ReservationDetailPage() {
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [acting, setActing] = useState(false)
const [showExtend, setShowExtend] = useState(false)
const [extendForm, setExtendForm] = useState({ newEndDate: '', reason: '' })
const [extendError, setExtendError] = useState<string | null>(null)
async function loadReservation() {
try {
@@ -337,6 +377,25 @@ export default function ReservationDetailPage() {
}
}
async function submitExtend() {
if (!extendForm.newEndDate || !extendForm.reason.trim()) return
setActing(true)
setExtendError(null)
try {
await apiFetch(`/reservations/${params.id}/extend`, {
method: 'POST',
body: JSON.stringify({ newEndDate: new Date(extendForm.newEndDate).toISOString(), reason: extendForm.reason }),
})
setShowExtend(false)
setExtendForm({ newEndDate: '', reason: '' })
await loadReservation()
} catch (err: any) {
setExtendError(err.message ?? copy.extendFailed)
} finally {
setActing(false)
}
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
@@ -424,9 +483,49 @@ export default function ReservationDetailPage() {
{acting ? r.working : r.checkOutVehicle}
</button>
)}
{['CONFIRMED', 'ACTIVE'].includes(reservation.status) && !reservation.workflow.closed && (
<button disabled={acting} onClick={() => { setShowExtend(true); setExtendError(null) }} className="btn-secondary">
{copy.extendBtn}
</button>
)}
</div>
</div>
{showExtend && (
<div className="card border border-blue-200 bg-blue-50 p-5 space-y-4">
<h4 className="text-sm font-semibold text-slate-900">{copy.extendTitle}</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendNewEnd}</label>
<input
type="datetime-local"
className="input-field"
value={extendForm.newEndDate}
onChange={(e) => setExtendForm((f) => ({ ...f, newEndDate: e.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendReason}</label>
<input
className="input-field"
placeholder={copy.extendReasonPlaceholder}
value={extendForm.reason}
onChange={(e) => setExtendForm((f) => ({ ...f, reason: e.target.value }))}
/>
</div>
</div>
{extendError && <p className="text-xs text-red-600">{extendError}</p>}
<div className="flex gap-3">
<button disabled={acting} onClick={submitExtend} className="btn-primary text-sm">
{acting ? r.working : copy.extendSubmit}
</button>
<button onClick={() => { setShowExtend(false); setExtendForm({ newEndDate: '', reason: '' }) }} className="btn-secondary text-sm">
{copy.extendCancel}
</button>
</div>
</div>
)}
{workflowMessage && (
<div className="card border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
{workflowMessage}
@@ -550,6 +649,14 @@ export default function ReservationDetailPage() {
</div>
</div>
<ReservationPhotoSection
reservationId={reservation.id}
type="PICKUP"
editable={reservation.workflow.checkInInspectionEditable}
title={copy.pickupPhotosTitle}
readOnlyMessage={copy.pickupPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.returnDetails}</h3>
<div className="grid gap-4 md:grid-cols-2">
@@ -593,6 +700,14 @@ export default function ReservationDetailPage() {
</div>
</div>
<ReservationPhotoSection
reservationId={reservation.id}
type="DROPOFF"
editable={reservation.workflow.checkOutInspectionEditable}
title={copy.dropoffPhotosTitle}
readOnlyMessage={copy.dropoffPhotosReadOnly}
/>
<div className="card p-6">
<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">
@@ -0,0 +1,422 @@
'use client'
import { useEffect, useState } from 'react'
import { Star, MessageSquare, AlertTriangle } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Reviews',
subtitle: 'Customer feedback for your company',
totalReviews: 'Total Reviews',
avgRating: 'Average Rating',
fiveStars: '5-Star Reviews',
negative: 'Negative Reviews',
filterRating: 'Filter by rating',
allRatings: 'All ratings',
filterDate: 'Date range',
last7: 'Last 7 days',
last30: 'Last 30 days',
last90: 'Last 90 days',
allTime: 'All time',
customer: 'Customer',
vehicle: 'Vehicle',
date: 'Date',
rating: 'Rating',
comment: 'Comment',
reply: 'Reply',
replied: 'Replied',
noReply: 'No reply yet',
saveReply: 'Save reply',
cancel: 'Cancel',
saving: 'Saving…',
noReviews: 'No reviews yet',
newComplaint: 'New complaint',
replyPlaceholder: 'Write your reply…',
errorLoad: 'Failed to load reviews',
errorReply: 'Failed to save reply',
stars: (n: number) => n === 1 ? '1 star' : `${n} stars`,
},
fr: {
heading: 'Avis',
subtitle: 'Retours clients pour votre entreprise',
totalReviews: 'Total des avis',
avgRating: 'Note moyenne',
fiveStars: 'Avis 5 étoiles',
negative: 'Avis négatifs',
filterRating: 'Filtrer par note',
allRatings: 'Toutes les notes',
filterDate: 'Période',
last7: '7 derniers jours',
last30: '30 derniers jours',
last90: '90 derniers jours',
allTime: 'Tout le temps',
customer: 'Client',
vehicle: 'Véhicule',
date: 'Date',
rating: 'Note',
comment: 'Commentaire',
reply: 'Répondre',
replied: 'Répondu',
noReply: 'Pas encore de réponse',
saveReply: 'Enregistrer',
cancel: 'Annuler',
saving: 'Enregistrement…',
noReviews: 'Aucun avis pour l\'instant',
newComplaint: 'Nouvelle plainte',
replyPlaceholder: 'Rédigez votre réponse…',
errorLoad: 'Échec du chargement des avis',
errorReply: 'Échec de l\'enregistrement de la réponse',
stars: (n: number) => n === 1 ? '1 étoile' : `${n} étoiles`,
},
ar: {
heading: 'التقييمات',
subtitle: 'آراء العملاء لشركتك',
totalReviews: 'إجمالي التقييمات',
avgRating: 'متوسط التقييم',
fiveStars: 'تقييمات 5 نجوم',
negative: 'تقييمات سلبية',
filterRating: 'تصفية حسب التقييم',
allRatings: 'جميع التقييمات',
filterDate: 'النطاق الزمني',
last7: 'آخر 7 أيام',
last30: 'آخر 30 يوماً',
last90: 'آخر 90 يوماً',
allTime: 'كل الوقت',
customer: 'العميل',
vehicle: 'المركبة',
date: 'التاريخ',
rating: 'التقييم',
comment: 'تعليق',
reply: 'رد',
replied: 'تم الرد',
noReply: 'لم يتم الرد بعد',
saveReply: 'حفظ الرد',
cancel: 'إلغاء',
saving: 'جار الحفظ…',
noReviews: 'لا توجد تقييمات بعد',
newComplaint: 'شكوى جديدة',
replyPlaceholder: 'اكتب ردك هنا…',
errorLoad: 'فشل تحميل التقييمات',
errorReply: 'فشل حفظ الرد',
stars: (n: number) => `${n} نجوم`,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface ReviewReservation {
id: string
startDate: string
endDate: string
customer: { id: string; firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; year: number }
}
interface Review {
id: string
reservationId: string
overallRating: number
vehicleRating: number | null
serviceRating: number | null
comment: string | null
companyReply: string | null
companyRepliedAt: string | null
isPublished: boolean
createdAt: string
reservation: ReviewReservation
}
interface ReviewsResponse {
data: Review[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
interface Stats {
total: number
averageOverall: number
averageVehicle: number
averageService: number
byRating: Record<string, number>
}
// ─── Star display ──────────────────────────────────────────────────────────────
function StarDisplay({ rating, size = 'sm' }: { rating: number; size?: 'sm' | 'md' }) {
const sz = size === 'md' ? 'h-5 w-5' : 'h-4 w-4'
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`${sz} ${s <= rating ? 'fill-amber-400 text-amber-400' : 'text-stone-300 dark:text-blue-900'}`}
/>
))}
</div>
)
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ReviewsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [reviews, setReviews] = useState<Review[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterRating, setFilterRating] = useState<string>('')
const [filterDays, setFilterDays] = useState<string>('')
const [page, setPage] = useState(1)
const [replyingId, setReplyingId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [replySaving, setReplySaving] = useState(false)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterRating) params.set('rating', filterRating)
const [reviewsData, statsData] = await Promise.all([
apiFetch<ReviewsResponse>(`/reviews?${params}`),
apiFetch<Stats>('/reviews/stats'),
])
let data = reviewsData.data ?? []
if (filterDays) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - Number(filterDays))
data = data.filter((r) => new Date(r.createdAt) >= cutoff)
}
setReviews(data)
setMeta(reviewsData.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
setStats(statsData)
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterRating, filterDays])
async function handleSaveReply(reviewId: string) {
if (!replyText.trim()) return
setReplySaving(true)
try {
await apiFetch(`/reviews/${reviewId}/reply`, {
method: 'PATCH',
body: JSON.stringify({ companyReply: replyText.trim() }),
})
setReplyingId(null)
setReplyText('')
await loadData()
} catch {
alert(t.errorReply)
} finally {
setReplySaving(false)
}
}
function startReply(review: Review) {
setReplyingId(review.id)
setReplyText(review.companyReply ?? '')
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 p-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.totalReviews}</p>
<p className="mt-2 text-3xl font-bold text-blue-950 dark:text-white">{stats.total}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.avgRating}</p>
<div className="mt-2 flex items-center gap-2">
<p className="text-3xl font-bold text-blue-950 dark:text-white">{stats.averageOverall.toFixed(1)}</p>
<StarDisplay rating={Math.round(stats.averageOverall)} size="md" />
</div>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.fiveStars}</p>
<p className="mt-2 text-3xl font-bold text-emerald-600 dark:text-emerald-400">{stats.byRating['5'] ?? 0}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.negative}</p>
<p className="mt-2 text-3xl font-bold text-red-600 dark:text-red-400">
{(stats.byRating['1'] ?? 0) + (stats.byRating['2'] ?? 0)}
</p>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterRating}</label>
<select
value={filterRating}
onChange={(e) => { setFilterRating(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allRatings}</option>
{[5, 4, 3, 2, 1].map((r) => (
<option key={r} value={String(r)}>{t.stars(r)}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterDate}</label>
<select
value={filterDays}
onChange={(e) => { setFilterDays(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allTime}</option>
<option value="7">{t.last7}</option>
<option value="30">{t.last30}</option>
<option value="90">{t.last90}</option>
</select>
</div>
</div>
</div>
{/* Reviews list */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : reviews.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noReviews}</div>
) : (
<div className="space-y-4">
{reviews.map((review) => {
const customer = review.reservation?.customer
const vehicle = review.reservation?.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : '—'
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : '—'
const isReplying = replyingId === review.id
const isNegative = review.overallRating <= 2
return (
<div key={review.id} className={`card p-5 ${isNegative ? 'border-red-200 dark:border-red-900/40' : ''}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<span className="font-semibold text-blue-950 dark:text-white">{customerName}</span>
<span className="text-sm text-stone-400 dark:text-slate-500">{vehicleLabel}</span>
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(review.createdAt).toLocaleDateString()}
</span>
</div>
<StarDisplay rating={review.overallRating} />
{review.comment && (
<p className="mt-2 line-clamp-3 text-sm text-stone-600 dark:text-slate-300">{review.comment}</p>
)}
{review.companyReply && !isReplying && (
<div className="mt-3 rounded-2xl border border-blue-100 bg-blue-50 p-3 dark:border-blue-900/40 dark:bg-blue-950/30">
<div className="mb-1 flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">{t.replied}</span>
</div>
<p className="text-sm text-stone-600 dark:text-slate-300">{review.companyReply}</p>
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button
onClick={() => startReply(review)}
className="btn-secondary text-xs"
>
<MessageSquare className="h-3.5 w-3.5" />
{review.companyReply ? t.replied : t.reply}
</button>
<a
href={`/complaints?reservationId=${review.reservation?.id ?? ''}`}
className="btn-secondary text-xs"
>
<AlertTriangle className="h-3.5 w-3.5" />
{t.newComplaint}
</a>
</div>
</div>
{/* Inline reply form */}
{isReplying && (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t.replyPlaceholder}
rows={3}
className="input-field resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleSaveReply(review.id)}
disabled={replySaving || !replyText.trim()}
className="btn-primary text-sm"
>
{replySaving ? t.saving : t.saveReply}
</button>
<button
onClick={() => { setReplyingId(null); setReplyText('') }}
className="btn-secondary text-sm"
>
{t.cancel}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -35,6 +35,13 @@ interface ProviderAvailability {
paypal: boolean
}
interface PlanFeature {
id: string
plan: Plan
label: string
sortOrder: number
}
interface EmployeeProfile {
role?: string
}
@@ -69,6 +76,8 @@ export default function SubscriptionPage() {
const currency = 'MAD'
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
@@ -228,6 +237,15 @@ export default function SubscriptionPage() {
})
}, [router])
const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([
apiFetch<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
apiFetch<PlanFeature[]>('/subscriptions/features'),
])
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
if (features) setPlanFeaturesList(features)
}, [])
useEffect(() => {
if (canViewPage !== true) return
@@ -235,6 +253,7 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
setProviderAvailability(availability)
@@ -250,7 +269,21 @@ export default function SubscriptionPage() {
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [canViewPage])
}, [canViewPage, fetchPlanData])
useEffect(() => {
if (canViewPage !== true) return
const handleFocus = () => { fetchPlanData().catch(() => {}) }
const handleVisibility = () => {
if (document.visibilityState === 'visible') fetchPlanData().catch(() => {})
}
window.addEventListener('focus', handleFocus)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
window.removeEventListener('focus', handleFocus)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [canViewPage, fetchPlanData])
if (canViewPage !== true) {
return null
@@ -309,7 +342,7 @@ export default function SubscriptionPage() {
}
}
const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency]
const planPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
@@ -402,8 +435,9 @@ export default function SubscriptionPage() {
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
const price = planPrices[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
const features = planFeaturesList.filter((f) => f.plan === plan)
return (
<button
key={plan}
@@ -423,11 +457,17 @@ export default function SubscriptionPage() {
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{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>
))}
{features.length > 0
? features.map((f) => (
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f.label}
</li>
))
: 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>
))}
</ul>
</button>
)
+4
View File
@@ -91,6 +91,10 @@ html.dark body {
.badge-purple {
@apply inline-flex items-center rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-700 dark:bg-teal-950/40 dark:text-teal-300;
}
.badge-indigo {
@apply inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-300;
}
}
@media print {
+133 -44
View File
@@ -8,7 +8,7 @@ export type DashboardLanguage = 'en' | 'fr' | 'ar'
export type DashboardTheme = 'light' | 'dark'
type FleetDict = {
statusLabels: Record<'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE', string>
statusLabels: Record<'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE', string>
logMaintenance: string
maintenanceSubtitle: (name: string) => string
maintenanceTypeRequired: string
@@ -330,6 +330,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
contracts: 'Contracts',
notifications: 'Notifications',
settings: 'Settings',
reviews: 'Reviews',
complaints: 'Complaints',
},
titles: {
'/': 'Dashboard',
@@ -358,7 +360,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
light: 'Light',
dark: 'Dark',
fleet: {
statusLabels: { AVAILABLE: 'Available', RENTED: 'Rented', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Out of Service' },
statusLabels: { AVAILABLE: 'Available', RESERVED: 'Reserved', READY: 'Ready for pickup', RENTED: 'On rent', RETURNED: 'Returned', NEEDS_CLEANING: 'Needs cleaning', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Damage review', OUT_OF_SERVICE: 'Out of service' },
logMaintenance: 'Log Maintenance',
maintenanceSubtitle: (name) => `${name} has been set to maintenance. Add details below.`,
maintenanceTypeRequired: 'Please enter a maintenance type.',
@@ -682,6 +684,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
contracts: 'Contrats',
notifications: 'Notifications',
settings: 'Paramètres',
reviews: 'Avis',
complaints: 'Plaintes',
},
titles: {
'/': 'Tableau de bord',
@@ -710,7 +714,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
light: 'Clair',
dark: 'Sombre',
fleet: {
statusLabels: { AVAILABLE: 'Disponible', RENTED: 'Loué', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Hors service' },
statusLabels: { AVAILABLE: 'Disponible', RESERVED: 'Réservé', READY: 'Prêt pour remise', RENTED: 'En location', RETURNED: 'Rendu', NEEDS_CLEANING: 'Nettoyage requis', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Révision dommages', OUT_OF_SERVICE: 'Hors service' },
logMaintenance: 'Journal de maintenance',
maintenanceSubtitle: (name) => `${name} a été mis en maintenance. Ajoutez les détails ci-dessous.`,
maintenanceTypeRequired: 'Veuillez saisir un type de maintenance.',
@@ -1034,6 +1038,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
contracts: 'العقود',
notifications: 'الإشعارات',
settings: 'الإعدادات',
reviews: 'التقييمات',
complaints: 'الشكاوى',
},
titles: {
'/': 'لوحة التحكم',
@@ -1062,7 +1068,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
light: 'فاتح',
dark: 'داكن',
fleet: {
statusLabels: { AVAILABLE: 'متاح', RENTED: 'مؤجر', MAINTENANCE: 'صيانة', OUT_OF_SERVICE: 'خارج الخدمة' },
statusLabels: { AVAILABLE: 'متاح', RESERVED: 'محجوز', READY: 'جاهز للتسليم', RENTED: 'قيد التأجير', RETURNED: 'مُعاد', NEEDS_CLEANING: 'يحتاج تنظيف', MAINTENANCE: 'صيانة', DAMAGE_REVIEW: 'مراجعة أضرار', OUT_OF_SERVICE: 'خارج الخدمة' },
logMaintenance: 'تسجيل الصيانة',
maintenanceSubtitle: (name) => `تم تعيين ${name} في وضع الصيانة. أضف التفاصيل أدناه.`,
maintenanceTypeRequired: 'الرجاء إدخال نوع الصيانة.',
@@ -1531,78 +1537,161 @@ export function useDashboardI18n() {
}
export function DashboardLanguageSwitcher() {
const { language, setLanguage, dict } = useDashboardI18n()
const { language, setLanguage } = useDashboardI18n()
const [open, setOpen] = useState(false)
const [embedded, setEmbedded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
setEmbedded(window.self !== window.top)
}, [])
useEffect(() => {
if (!open) return
function onMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onMouseDown)
return () => document.removeEventListener('mousedown', onMouseDown)
}, [open])
if (embedded) return null
const languageMeta = {
en: { flag: '🇺🇸', shortLabel: 'EN' },
fr: { flag: '🇫🇷', shortLabel: 'FR' },
ar: { flag: '🇲🇦', shortLabel: 'AR' },
} as const
function handleLanguageChange(value: DashboardLanguage) {
setLanguage(value)
apiFetch('/auth/employee/me/language', {
method: 'PATCH',
body: JSON.stringify({ language: value }),
}).catch(() => {})
setOpen(false)
}
const current = languageMeta[language]
return (
<div className="flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2 py-1 shadow-sm transition-colors dark:border-blue-800 dark:bg-[#0d1b38]/85">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
{dict.language}
</span>
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
const active = value === language
return (
<button
key={value}
type="button"
onClick={() => handleLanguageChange(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-[#162038]'
}`}
>
{value.toUpperCase()}
</button>
)
})}
<div ref={ref} className="relative flex-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
>
<span className="flex items-center gap-1.5">
<span aria-hidden="true">{current.flag}</span>
<span>{current.shortLabel}</span>
</span>
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
const meta = languageMeta[value]
const active = value === language
return (
<button
key={value}
type="button"
onClick={() => handleLanguageChange(value)}
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
active
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
}`}
>
<span aria-hidden="true">{meta.flag}</span>
<span>{meta.shortLabel}</span>
</button>
)
})}
</div>
)}
</div>
)
}
export function DashboardThemeSwitcher() {
const { theme, setTheme, dict } = useDashboardI18n()
const [open, setOpen] = useState(false)
const [embedded, setEmbedded] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
setEmbedded(window.self !== window.top)
}, [])
useEffect(() => {
if (!open) return
function onMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onMouseDown)
return () => document.removeEventListener('mousedown', onMouseDown)
}, [open])
if (embedded) return null
return (
<div className="flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2 py-1 shadow-sm transition-colors dark:border-blue-800 dark:bg-[#0d1b38]/85">
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-[#162038]'
}`}
>
{value === 'light' ? dict.light : dict.dark}
</button>
)
})}
<div ref={ref} className="relative flex-1">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
>
<span className="flex items-center gap-1.5">
{theme === 'light' ? (
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
) : (
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)}
<span>{theme === 'light' ? dict.light : dict.dark}</span>
</span>
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => { setTheme(value); setOpen(false) }}
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
active
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
}`}
>
{value === 'light' ? (
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
</svg>
) : (
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
</svg>
)}
<span>{value === 'light' ? dict.light : dict.dark}</span>
</button>
)
})}
</div>
)}
</div>
)
}
@@ -19,8 +19,10 @@ import {
FileText,
ChevronLeft,
ChevronRight,
Star,
AlertTriangle,
} from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
@@ -73,6 +75,8 @@ const NAV_ITEMS = [
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
@@ -256,6 +260,10 @@ export default function Sidebar() {
<LogOut className="h-4 w-4" />
{dict.signOut}
</button>
<div className="mt-3 flex items-center gap-2 border-t border-blue-900/50 pt-3">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
@@ -0,0 +1,124 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { apiFetch } from '@/lib/api'
interface ReservationPhoto {
id: string
type: 'PICKUP' | 'DROPOFF'
url: string
createdAt: string
}
interface Props {
reservationId: string
type: 'PICKUP' | 'DROPOFF'
editable: boolean
title: string
readOnlyMessage?: string
}
export default function ReservationPhotoSection({ reservationId, type, editable, title, readOnlyMessage }: Props) {
const [photos, setPhotos] = useState<ReservationPhoto[]>([])
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const loadPhotos = useCallback(async () => {
try {
const all = await apiFetch<ReservationPhoto[]>(`/reservations/${reservationId}/photos`)
setPhotos(all.filter((p) => p.type === type))
} catch {
// silent — don't block the page if photos fail to load
}
}, [reservationId, type])
useEffect(() => { loadPhotos() }, [loadPhotos])
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
setUploading(true)
setError(null)
try {
for (const file of Array.from(files)) {
const body = new FormData()
body.append('photo', file)
body.append('type', type)
await apiFetch(`/reservations/${reservationId}/photos`, { method: 'POST', body })
}
await loadPhotos()
} catch (err: any) {
setError(err.message ?? 'Upload failed')
} finally {
setUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
async function handleDelete(photoId: string) {
setError(null)
try {
await apiFetch(`/reservations/${reservationId}/photos/${photoId}`, { method: 'DELETE' })
setPhotos((prev) => prev.filter((p) => p.id !== photoId))
} catch (err: any) {
setError(err.message ?? 'Delete failed')
}
}
return (
<div className="card p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
{editable && (
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="btn-secondary text-xs"
>
{uploading ? 'Uploading…' : '+ Add photos'}
</button>
)}
</div>
{!editable && readOnlyMessage && (
<p className="mb-4 text-xs text-slate-500">{readOnlyMessage}</p>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{error && <p className="mb-3 text-xs text-red-600">{error}</p>}
{photos.length === 0 ? (
<p className="text-sm text-slate-400">No photos yet.</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{photos.map((photo) => (
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-xl border border-slate-200">
<img src={photo.url} alt="" className="h-full w-full object-cover" />
{editable && (
<button
type="button"
onClick={() => handleDelete(photo.id)}
className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white group-hover:flex"
aria-label="Delete photo"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
</svg>
</button>
)}
</div>
))}
</div>
)}
</div>
)
}