add review and booking policies
This commit is contained in:
@@ -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'])
|
||||
|
||||
@@ -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,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 d’inspection 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>
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReservationPhotoType" AS ENUM ('PICKUP', 'DROPOFF');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "reservation_photos" (
|
||||
"id" TEXT NOT NULL,
|
||||
"reservationId" TEXT NOT NULL,
|
||||
"type" "ReservationPhotoType" NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "reservation_photos_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "reservation_photos_reservationId_idx" ON "reservation_photos"("reservationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reservation_photos" ADD CONSTRAINT "reservation_photos_reservationId_fkey" FOREIGN KEY ("reservationId") REFERENCES "reservations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterEnum (PostgreSQL enum values must be added outside a transaction)
|
||||
ALTER TYPE "VehicleStatus" ADD VALUE 'RESERVED';
|
||||
ALTER TYPE "VehicleStatus" ADD VALUE 'READY';
|
||||
ALTER TYPE "VehicleStatus" ADD VALUE 'RETURNED';
|
||||
ALTER TYPE "VehicleStatus" ADD VALUE 'NEEDS_CLEANING';
|
||||
ALTER TYPE "VehicleStatus" ADD VALUE 'DAMAGE_REVIEW';
|
||||
@@ -0,0 +1,83 @@
|
||||
-- CreateEnum: FeedbackCategory
|
||||
CREATE TYPE "FeedbackCategory" AS ENUM (
|
||||
'BOOKING',
|
||||
'PICKUP',
|
||||
'VEHICLE_CLEANLINESS',
|
||||
'VEHICLE_CONDITION',
|
||||
'STAFF_SERVICE',
|
||||
'PRICING',
|
||||
'DEPOSIT',
|
||||
'INSURANCE',
|
||||
'DAMAGE_CLAIM',
|
||||
'RETURN_PROCESS',
|
||||
'COMMUNICATION',
|
||||
'ROADSIDE_ASSISTANCE',
|
||||
'BILLING',
|
||||
'OTHER'
|
||||
);
|
||||
|
||||
-- CreateEnum: ComplaintSeverity
|
||||
CREATE TYPE "ComplaintSeverity" AS ENUM ('LEVEL_1', 'LEVEL_2', 'LEVEL_3');
|
||||
|
||||
-- CreateEnum: ComplaintStatus
|
||||
CREATE TYPE "ComplaintStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'CLOSED');
|
||||
|
||||
-- AlterTable: reviews — add category column
|
||||
ALTER TABLE "reviews" ADD COLUMN "category" "FeedbackCategory";
|
||||
|
||||
-- AlterTable: reservations — add review tracking columns
|
||||
ALTER TABLE "reservations"
|
||||
ADD COLUMN "reviewRequestSentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "reviewReminderSentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "reviewFinalReminderSentAt" TIMESTAMP(3),
|
||||
ADD COLUMN "reviewPaused" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable: customers — add reviewOptOut column
|
||||
ALTER TABLE "customers" ADD COLUMN "reviewOptOut" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable: complaints
|
||||
CREATE TABLE "complaints" (
|
||||
"id" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"reservationId" TEXT,
|
||||
"reviewId" TEXT,
|
||||
"customerId" TEXT,
|
||||
"severity" "ComplaintSeverity" NOT NULL DEFAULT 'LEVEL_1',
|
||||
"status" "ComplaintStatus" NOT NULL DEFAULT 'OPEN',
|
||||
"category" "FeedbackCategory" NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"resolution" TEXT,
|
||||
"notes" TEXT,
|
||||
"assignedTo" TEXT,
|
||||
"resolvedAt" TIMESTAMP(3),
|
||||
"resolvedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "complaints_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "complaints_companyId_idx" ON "complaints"("companyId");
|
||||
CREATE INDEX "complaints_reservationId_idx" ON "complaints"("reservationId");
|
||||
|
||||
-- AddForeignKey: complaints → companies
|
||||
ALTER TABLE "complaints"
|
||||
ADD CONSTRAINT "complaints_companyId_fkey"
|
||||
FOREIGN KEY ("companyId") REFERENCES "companies"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey: complaints → reservations
|
||||
ALTER TABLE "complaints"
|
||||
ADD CONSTRAINT "complaints_reservationId_fkey"
|
||||
FOREIGN KEY ("reservationId") REFERENCES "reservations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey: complaints → reviews
|
||||
ALTER TABLE "complaints"
|
||||
ADD CONSTRAINT "complaints_reviewId_fkey"
|
||||
FOREIGN KEY ("reviewId") REFERENCES "reviews"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey: complaints → customers
|
||||
ALTER TABLE "complaints"
|
||||
ADD CONSTRAINT "complaints_customerId_fkey"
|
||||
FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -172,8 +172,13 @@ enum EmployeeRole {
|
||||
|
||||
enum VehicleStatus {
|
||||
AVAILABLE
|
||||
RESERVED
|
||||
READY
|
||||
RENTED
|
||||
RETURNED
|
||||
NEEDS_CLEANING
|
||||
MAINTENANCE
|
||||
DAMAGE_REVIEW
|
||||
OUT_OF_SERVICE
|
||||
}
|
||||
|
||||
@@ -223,6 +228,11 @@ enum BookingSource {
|
||||
API
|
||||
}
|
||||
|
||||
enum ReservationPhotoType {
|
||||
PICKUP
|
||||
DROPOFF
|
||||
}
|
||||
|
||||
enum CancelledBy {
|
||||
COMPANY
|
||||
RENTER
|
||||
@@ -398,6 +408,36 @@ enum NotificationType {
|
||||
REVIEW_REQUEST
|
||||
}
|
||||
|
||||
enum FeedbackCategory {
|
||||
BOOKING
|
||||
PICKUP
|
||||
VEHICLE_CLEANLINESS
|
||||
VEHICLE_CONDITION
|
||||
STAFF_SERVICE
|
||||
PRICING
|
||||
DEPOSIT
|
||||
INSURANCE
|
||||
DAMAGE_CLAIM
|
||||
RETURN_PROCESS
|
||||
COMMUNICATION
|
||||
ROADSIDE_ASSISTANCE
|
||||
BILLING
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum ComplaintSeverity {
|
||||
LEVEL_1
|
||||
LEVEL_2
|
||||
LEVEL_3
|
||||
}
|
||||
|
||||
enum ComplaintStatus {
|
||||
OPEN
|
||||
INVESTIGATING
|
||||
RESOLVED
|
||||
CLOSED
|
||||
}
|
||||
|
||||
enum NotificationChannel {
|
||||
EMAIL
|
||||
SMS
|
||||
@@ -450,6 +490,7 @@ model Company {
|
||||
insurancePolicies InsurancePolicy[]
|
||||
pricingRules PricingRule[]
|
||||
notifications Notification[] @relation("CompanyNotifications")
|
||||
complaints Complaint[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1083,7 +1124,9 @@ model Customer {
|
||||
licenseApprovedAt DateTime?
|
||||
licenseApprovalNote String?
|
||||
|
||||
reservations Reservation[]
|
||||
reservations Reservation[]
|
||||
complaints Complaint[]
|
||||
reviewOptOut Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -1134,6 +1177,11 @@ model Reservation {
|
||||
checkInFuelLevel String?
|
||||
checkOutFuelLevel String?
|
||||
|
||||
reviewRequestSentAt DateTime?
|
||||
reviewReminderSentAt DateTime?
|
||||
reviewFinalReminderSentAt DateTime?
|
||||
reviewPaused Boolean @default(false)
|
||||
|
||||
insurances ReservationInsurance[]
|
||||
insuranceTotal Int @default(0)
|
||||
additionalDrivers AdditionalDriver[]
|
||||
@@ -1143,7 +1191,9 @@ model Reservation {
|
||||
damageReports DamageReport[]
|
||||
rentalPayments RentalPayment[]
|
||||
inspections DamageInspection[]
|
||||
photos ReservationPhoto[]
|
||||
review Review?
|
||||
complaints Complaint[]
|
||||
damageChargeAmount Int?
|
||||
damageChargeNote String?
|
||||
extras Json?
|
||||
@@ -1183,19 +1233,21 @@ model RentalPayment {
|
||||
}
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
reservationId String @unique
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
id String @id @default(cuid())
|
||||
reservationId String @unique
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id])
|
||||
renterId String?
|
||||
renter Renter? @relation(fields: [renterId], references: [id])
|
||||
renter Renter? @relation(fields: [renterId], references: [id])
|
||||
companyId String
|
||||
overallRating Int
|
||||
vehicleRating Int?
|
||||
serviceRating Int?
|
||||
comment String?
|
||||
isPublished Boolean @default(true)
|
||||
isPublished Boolean @default(true)
|
||||
companyReply String?
|
||||
companyRepliedAt DateTime?
|
||||
category FeedbackCategory?
|
||||
complaints Complaint[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -1203,6 +1255,36 @@ model Review {
|
||||
@@map("reviews")
|
||||
}
|
||||
|
||||
model Complaint {
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
reservationId String?
|
||||
reservation Reservation? @relation(fields: [reservationId], references: [id])
|
||||
reviewId String?
|
||||
review Review? @relation(fields: [reviewId], references: [id])
|
||||
customerId String?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
|
||||
severity ComplaintSeverity @default(LEVEL_1)
|
||||
status ComplaintStatus @default(OPEN)
|
||||
category FeedbackCategory
|
||||
subject String
|
||||
description String?
|
||||
resolution String?
|
||||
notes String?
|
||||
assignedTo String?
|
||||
resolvedAt DateTime?
|
||||
resolvedBy String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([companyId])
|
||||
@@index([reservationId])
|
||||
@@map("complaints")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// NOTIFICATIONS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -1645,3 +1727,15 @@ model PricingPromotion {
|
||||
@@index([isActive])
|
||||
@@map("pricing_promotions")
|
||||
}
|
||||
|
||||
model ReservationPhoto {
|
||||
id String @id @default(cuid())
|
||||
reservationId String
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
|
||||
type ReservationPhotoType
|
||||
url String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([reservationId])
|
||||
@@map("reservation_photos")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
# Review Management Process After Rental Completion
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The review management process helps the rental company collect customer feedback after each completed rental, improve service quality, recover unhappy customers, and increase positive public reviews.
|
||||
|
||||
The goal is not only to collect high ratings. The goal is to identify service problems, fix weak operations, and encourage satisfied customers to share their experience publicly.
|
||||
|
||||
---
|
||||
|
||||
## 2. When the Review Process Starts
|
||||
|
||||
The review process starts only after the rental is fully closed.
|
||||
|
||||
A rental is considered closed when:
|
||||
|
||||
- The vehicle has been returned
|
||||
- The return inspection is completed
|
||||
- The final invoice has been sent
|
||||
- The deposit has been released or adjusted
|
||||
- Any damage, billing, or dispute case is closed or clearly documented
|
||||
- The customer has received the final receipt
|
||||
|
||||
Do not send a review request before the final billing is clear. Customers should not be asked for a review while they are still waiting for a deposit update, refund, or charge explanation.
|
||||
|
||||
---
|
||||
|
||||
## 3. Main Review Process Flow
|
||||
|
||||
### Step 1: Close the Rental
|
||||
|
||||
Before sending a review request, staff must confirm:
|
||||
|
||||
- Vehicle returned
|
||||
- Final charges completed
|
||||
- Receipt sent
|
||||
- Deposit status updated
|
||||
- Any customer issue notes added
|
||||
- Vehicle status updated
|
||||
|
||||
Once complete, the booking is marked as:
|
||||
|
||||
**Rental Closed**
|
||||
|
||||
This status triggers the review process.
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Check Review Eligibility
|
||||
|
||||
Before sending a review request, the system or staff must confirm that the customer is eligible.
|
||||
|
||||
A review request should be sent only if:
|
||||
|
||||
- Rental status is **Closed**
|
||||
- Final invoice has been sent
|
||||
- No open dispute exists
|
||||
- No active complaint exists
|
||||
- No major damage case is open
|
||||
- Customer has not opted out of messages
|
||||
- Customer has a valid phone number or email address
|
||||
|
||||
A review request should be paused if:
|
||||
|
||||
- Customer has an open damage dispute
|
||||
- Customer has an unpaid balance
|
||||
- Customer has an active complaint
|
||||
- Customer was charged for major damage
|
||||
- Rental is under insurance investigation
|
||||
- Customer requested no further messages
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Send Review Request
|
||||
|
||||
Send the review request within:
|
||||
|
||||
**2 to 6 hours after rental closure**
|
||||
|
||||
This timing is soon enough that the customer remembers the experience, but not so immediate that it feels automated and careless.
|
||||
|
||||
The message should include:
|
||||
|
||||
- Customer name
|
||||
- Thank-you message
|
||||
- Short review request
|
||||
- Review link
|
||||
- Support contact in case there was a problem
|
||||
|
||||
---
|
||||
|
||||
## 4. Review Channels
|
||||
|
||||
The company should define where customer reviews are collected.
|
||||
|
||||
Recommended review channels:
|
||||
|
||||
- Google Business Profile
|
||||
- Trustpilot
|
||||
- Facebook
|
||||
- Yelp, if relevant
|
||||
- Internal company review form
|
||||
- App store review, if using a mobile app
|
||||
|
||||
Recommended simple setup:
|
||||
|
||||
- Satisfied customers are sent to a public review link
|
||||
- Unsatisfied customers are routed to internal support first
|
||||
|
||||
The company should not fake reviews, pressure customers, or hide legitimate negative feedback. The process should encourage honest reviews and give unhappy customers a clear path to resolution.
|
||||
|
||||
---
|
||||
|
||||
## 5. Customer Rating Filter
|
||||
|
||||
Before sending customers to a public review page, the company may use a short internal satisfaction question.
|
||||
|
||||
Example question:
|
||||
|
||||
**How was your rental experience?**
|
||||
|
||||
Options:
|
||||
|
||||
- Excellent
|
||||
- Good
|
||||
- Okay
|
||||
- Bad
|
||||
- Very bad
|
||||
|
||||
Routing rules:
|
||||
|
||||
| Customer Response | Action |
|
||||
|---|---|
|
||||
| Excellent | Send public review link |
|
||||
| Good | Send public review link |
|
||||
| Okay | Ask for internal feedback |
|
||||
| Bad | Create support case |
|
||||
| Very bad | Create urgent support case |
|
||||
|
||||
This helps the company recover unhappy customers before the issue becomes a public complaint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Review Request Timing
|
||||
|
||||
| Time After Rental Closure | Action |
|
||||
|---|---|
|
||||
| 2 to 6 hours | Send first review request |
|
||||
| 48 hours | Send reminder if no response |
|
||||
| 7 days | Send final reminder |
|
||||
| After 7 days | Stop messaging |
|
||||
|
||||
Maximum number of review messages:
|
||||
|
||||
**3 total**
|
||||
|
||||
Do not keep messaging customers after the final reminder.
|
||||
|
||||
---
|
||||
|
||||
## 7. Review Message Rules
|
||||
|
||||
A good review request should be:
|
||||
|
||||
- Short
|
||||
- Polite
|
||||
- Personalized
|
||||
- Easy to act on
|
||||
- Sent at the right time
|
||||
- Focused on one clear link
|
||||
|
||||
Avoid review messages that are:
|
||||
|
||||
- Too long
|
||||
- Begging for 5 stars
|
||||
- Sent too many times
|
||||
- Sent before billing is closed
|
||||
- Sent during a dispute
|
||||
- Written like spam
|
||||
|
||||
Do not say:
|
||||
|
||||
> Please give us 5 stars.
|
||||
|
||||
Better wording:
|
||||
|
||||
> Your feedback helps us improve and helps other customers choose us.
|
||||
|
||||
---
|
||||
|
||||
## 8. Review Request Templates
|
||||
|
||||
### SMS Template
|
||||
|
||||
Hi {{customer_name}}, thank you for renting with {{company_name}}. We hope everything went smoothly. Please take 30 seconds to review your experience: {{review_link}}
|
||||
|
||||
If anything was not right, reply here and our team will help.
|
||||
|
||||
---
|
||||
|
||||
### Email Template
|
||||
|
||||
**Subject:** How was your rental experience?
|
||||
|
||||
Hi {{customer_name}},
|
||||
|
||||
Thank you for renting with {{company_name}}.
|
||||
|
||||
We hope your experience was smooth from pickup to return. Please take a moment to review us here:
|
||||
|
||||
{{review_link}}
|
||||
|
||||
If something was not right, reply to this email and our team will review it.
|
||||
|
||||
Thank you,
|
||||
{{company_name}}
|
||||
|
||||
---
|
||||
|
||||
### Reminder Template
|
||||
|
||||
Hi {{customer_name}}, just a quick reminder. Your feedback helps us improve our rental service. You can review your experience here: {{review_link}}
|
||||
|
||||
---
|
||||
|
||||
## 9. Handling Positive Reviews
|
||||
|
||||
When a customer leaves a positive review, staff should:
|
||||
|
||||
- Thank the customer publicly
|
||||
- Keep the reply short
|
||||
- Mention the customer’s experience if appropriate
|
||||
- Avoid copying the same reply every time
|
||||
- Tag the booking as positive feedback
|
||||
|
||||
Example public reply:
|
||||
|
||||
> Thank you for your review. We are glad your rental experience went smoothly and appreciate you choosing us.
|
||||
|
||||
If the review mentions a staff member, the manager should be notified.
|
||||
|
||||
Positive reviews should be tracked by:
|
||||
|
||||
- Branch
|
||||
- Vehicle
|
||||
- Staff member
|
||||
- Booking channel
|
||||
- Rental type
|
||||
|
||||
---
|
||||
|
||||
## 10. Handling Negative Reviews
|
||||
|
||||
Negative reviews must be handled quickly and calmly.
|
||||
|
||||
Target response time:
|
||||
|
||||
**Within 24 hours**
|
||||
|
||||
Internal steps:
|
||||
|
||||
1. Create a complaint case.
|
||||
2. Link the complaint to the rental agreement.
|
||||
3. Review pickup photos.
|
||||
4. Review return photos.
|
||||
5. Review invoice and charges.
|
||||
6. Review staff notes.
|
||||
7. Contact the customer privately.
|
||||
8. Offer a fair solution if the company made a mistake.
|
||||
9. Reply publicly with a professional response.
|
||||
10. Record the final outcome.
|
||||
|
||||
Public reply rules:
|
||||
|
||||
- Stay calm
|
||||
- Acknowledge the concern
|
||||
- Do not argue
|
||||
- Do not expose customer details
|
||||
- Invite the customer to private support
|
||||
- Show that the company takes feedback seriously
|
||||
|
||||
Example public reply:
|
||||
|
||||
> We are sorry to hear your experience did not meet expectations. We take this seriously and would like to review the rental details with you. Please contact our support team at {{contact_info}} so we can investigate and help resolve this.
|
||||
|
||||
---
|
||||
|
||||
## 11. Complaint Severity Levels
|
||||
|
||||
### Level 1: Minor Issue
|
||||
|
||||
Examples:
|
||||
|
||||
- Long wait time
|
||||
- Car not perfectly clean
|
||||
- Staff communication issue
|
||||
- Confusing instructions
|
||||
|
||||
Action:
|
||||
|
||||
- Apologize
|
||||
- Record feedback
|
||||
- Offer small goodwill if appropriate
|
||||
- Inform branch manager
|
||||
|
||||
---
|
||||
|
||||
### Level 2: Serious Issue
|
||||
|
||||
Examples:
|
||||
|
||||
- Incorrect billing
|
||||
- Deposit delay
|
||||
- Vehicle mechanical problem
|
||||
- Wrong car category
|
||||
- Poor staff behavior
|
||||
|
||||
Action:
|
||||
|
||||
- Manager review required
|
||||
- Contact customer within 24 hours
|
||||
- Investigate records
|
||||
- Offer correction or compensation if valid
|
||||
|
||||
---
|
||||
|
||||
### Level 3: Critical Issue
|
||||
|
||||
Examples:
|
||||
|
||||
- Safety issue
|
||||
- Accident handling failure
|
||||
- Major billing dispute
|
||||
- Legal threat
|
||||
- Fraud accusation
|
||||
- Discrimination complaint
|
||||
|
||||
Action:
|
||||
|
||||
- Immediate manager escalation
|
||||
- Preserve all records
|
||||
- Stop automated review reminders
|
||||
- Senior manager or owner handles case
|
||||
- Legal or insurance review if needed
|
||||
|
||||
---
|
||||
|
||||
## 12. Internal Feedback Form
|
||||
|
||||
The company may use an internal form before sending customers to public review platforms.
|
||||
|
||||
Recommended questions:
|
||||
|
||||
1. How was the booking process?
|
||||
2. How was the pickup experience?
|
||||
3. Was the car clean?
|
||||
4. Was the car in good condition?
|
||||
5. Was the return process easy?
|
||||
6. Was pricing clear?
|
||||
7. Would you rent from us again?
|
||||
8. What should we improve?
|
||||
|
||||
Recommended score scale:
|
||||
|
||||
| Score | Meaning |
|
||||
|---|---|
|
||||
| 1 | Very bad |
|
||||
| 2 | Bad |
|
||||
| 3 | Okay |
|
||||
| 4 | Good |
|
||||
| 5 | Excellent |
|
||||
|
||||
---
|
||||
|
||||
## 13. Review Data to Track
|
||||
|
||||
The company should track:
|
||||
|
||||
- Number of review requests sent
|
||||
- Number of reviews received
|
||||
- Review response rate
|
||||
- Average review score
|
||||
- Number of positive reviews
|
||||
- Number of negative reviews
|
||||
- Main complaint categories
|
||||
- Branch performance
|
||||
- Staff performance
|
||||
- Vehicle-related complaints
|
||||
- Billing-related complaints
|
||||
- Deposit-related complaints
|
||||
- Pickup delay complaints
|
||||
- Return delay complaints
|
||||
|
||||
A monthly review report should show:
|
||||
|
||||
- Top 5 problems
|
||||
- Top 5 positive mentions
|
||||
- Best-performing branch
|
||||
- Worst-performing branch
|
||||
- Repeat complaint patterns
|
||||
- Corrective actions taken
|
||||
|
||||
---
|
||||
|
||||
## 14. Feedback Categories
|
||||
|
||||
Every review or complaint should be tagged under one main category:
|
||||
|
||||
- Booking
|
||||
- Pickup
|
||||
- Vehicle cleanliness
|
||||
- Vehicle condition
|
||||
- Staff service
|
||||
- Pricing
|
||||
- Deposit
|
||||
- Insurance
|
||||
- Damage claim
|
||||
- Return process
|
||||
- Communication
|
||||
- Roadside assistance
|
||||
- Billing
|
||||
- Other
|
||||
|
||||
This makes patterns easier to identify and fix.
|
||||
|
||||
Examples:
|
||||
|
||||
- Many pickup complaints = staffing or preparation issue
|
||||
- Many deposit complaints = unclear deposit communication
|
||||
- Many cleanliness complaints = weak cleaning control
|
||||
- Many billing complaints = unclear pricing or invoice process
|
||||
|
||||
---
|
||||
|
||||
## 15. Staff Responsibilities
|
||||
|
||||
### Rental Agent
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Closing rental properly
|
||||
- Confirming customer contact details
|
||||
- Adding notes about any issue
|
||||
- Marking rental ready for review request
|
||||
|
||||
---
|
||||
|
||||
### Customer Support
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Monitoring reviews
|
||||
- Replying to simple feedback
|
||||
- Creating complaint cases
|
||||
- Contacting unhappy customers
|
||||
|
||||
---
|
||||
|
||||
### Branch Manager
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Reviewing negative feedback
|
||||
- Approving compensation
|
||||
- Coaching staff
|
||||
- Fixing branch-level problems
|
||||
|
||||
---
|
||||
|
||||
### Owner or Senior Manager
|
||||
|
||||
Responsible for:
|
||||
|
||||
- Reviewing monthly review report
|
||||
- Handling serious complaints
|
||||
- Updating company policy
|
||||
- Tracking reputation score
|
||||
|
||||
---
|
||||
|
||||
## 16. Review Response Standards
|
||||
|
||||
### Positive Review Response
|
||||
|
||||
Target response time:
|
||||
|
||||
**Within 3 business days**
|
||||
|
||||
Tone:
|
||||
|
||||
- Thankful
|
||||
- Short
|
||||
- Professional
|
||||
|
||||
---
|
||||
|
||||
### Negative Review Response
|
||||
|
||||
Target response time:
|
||||
|
||||
**Within 24 hours**
|
||||
|
||||
Tone:
|
||||
|
||||
- Calm
|
||||
- Responsible
|
||||
- Not defensive
|
||||
- No private customer details
|
||||
|
||||
---
|
||||
|
||||
### False or Abusive Review
|
||||
|
||||
If a review appears fake, abusive, or unrelated:
|
||||
|
||||
1. Take a screenshot.
|
||||
2. Check if the reviewer matches a customer.
|
||||
3. Report the review to the platform if it violates rules.
|
||||
4. Reply professionally if needed.
|
||||
5. Do not insult the reviewer.
|
||||
|
||||
Example reply:
|
||||
|
||||
> We cannot locate a rental under this name, but we take feedback seriously. Please contact us at {{contact_info}} so we can review this properly.
|
||||
|
||||
---
|
||||
|
||||
## 17. Compensation Rules
|
||||
|
||||
Compensation should be controlled and approved based on severity.
|
||||
|
||||
Possible compensation options:
|
||||
|
||||
- Apology only
|
||||
- Discount on next rental
|
||||
- Partial refund
|
||||
- Cleaning fee refund
|
||||
- Upgrade on next rental
|
||||
- Delivery fee refund
|
||||
- Deposit correction
|
||||
- Full refund, only in serious cases
|
||||
|
||||
Manager approval should be required for:
|
||||
|
||||
- Refunds above a set amount
|
||||
- Free rental days
|
||||
- Damage fee removal
|
||||
- Public complaint compensation
|
||||
- Legal threat situations
|
||||
|
||||
Compensation should be fair, documented, and linked to the actual issue.
|
||||
|
||||
---
|
||||
|
||||
## 18. Automation Rules
|
||||
|
||||
The review system should be automated but controlled.
|
||||
|
||||
### Automatic Review Request Trigger
|
||||
|
||||
Send a review request when:
|
||||
|
||||
- Rental status = Closed
|
||||
- Final invoice sent
|
||||
- No open dispute
|
||||
- No active complaint
|
||||
- Customer has valid phone or email
|
||||
- Customer has not opted out
|
||||
|
||||
---
|
||||
|
||||
### Automatic Reminder Trigger
|
||||
|
||||
Send a reminder when:
|
||||
|
||||
- No review received after 48 hours
|
||||
- No complaint opened
|
||||
- Customer has not opted out
|
||||
|
||||
---
|
||||
|
||||
### Stop Automation When
|
||||
|
||||
Stop review automation when:
|
||||
|
||||
- Customer replies with complaint
|
||||
- Review has already been submitted
|
||||
- Dispute is opened
|
||||
- Customer opts out
|
||||
- Staff manually pauses request
|
||||
|
||||
---
|
||||
|
||||
## 19. Simple Review Workflows
|
||||
|
||||
### Normal Happy Customer
|
||||
|
||||
1. Rental closed
|
||||
2. Review request sent
|
||||
3. Customer leaves review
|
||||
4. Company replies
|
||||
5. Review recorded
|
||||
6. No further action
|
||||
|
||||
---
|
||||
|
||||
### No Response
|
||||
|
||||
1. Rental closed
|
||||
2. First request sent
|
||||
3. No response after 48 hours
|
||||
4. Reminder sent
|
||||
5. No response after 7 days
|
||||
6. Final reminder sent
|
||||
7. Stop
|
||||
|
||||
---
|
||||
|
||||
### Unhappy Customer
|
||||
|
||||
1. Rental closed
|
||||
2. Customer gives low internal rating or bad public review
|
||||
3. Complaint case created
|
||||
4. Manager reviews rental
|
||||
5. Customer contacted
|
||||
6. Issue resolved or documented
|
||||
7. Public reply posted
|
||||
8. Root cause logged
|
||||
|
||||
---
|
||||
|
||||
### Dispute Customer
|
||||
|
||||
1. Rental closed with dispute
|
||||
2. Review request paused
|
||||
3. Dispute handled first
|
||||
4. Manager decides whether review request should be sent after resolution
|
||||
|
||||
---
|
||||
|
||||
## 20. Key Rules
|
||||
|
||||
The review process should follow these rules:
|
||||
|
||||
- Never send a review request before the final invoice.
|
||||
- Never send a review request during an open dispute.
|
||||
- Send the first request within 2 to 6 hours after closure.
|
||||
- Send no more than 3 total review messages.
|
||||
- Respond to negative reviews within 24 hours.
|
||||
- Track every complaint by category.
|
||||
- Use feedback to fix operations.
|
||||
- Do not argue publicly.
|
||||
- Do not ask directly for fake or forced 5-star reviews.
|
||||
- Stop messaging customers who opt out.
|
||||
|
||||
---
|
||||
|
||||
## 21. Minimum Setup for a Small Rental Company
|
||||
|
||||
A small rental company can manage the process with:
|
||||
|
||||
- Rental management system
|
||||
- Google review link
|
||||
- SMS or email tool
|
||||
- Complaint spreadsheet or CRM
|
||||
- Monthly review report
|
||||
- Staff member assigned to monitor reviews daily
|
||||
|
||||
Minimum tracking fields:
|
||||
|
||||
| Field | Example |
|
||||
|---|---|
|
||||
| Booking ID | R-1029 |
|
||||
| Customer Name | John Smith |
|
||||
| Return Date | 2026-05-25 |
|
||||
| Review Request Sent | Yes |
|
||||
| Review Score | 5 |
|
||||
| Complaint? | No |
|
||||
| Category | Pickup |
|
||||
| Staff Owner | Sarah |
|
||||
| Status | Closed |
|
||||
|
||||
---
|
||||
|
||||
## 22. Best Operating Rule
|
||||
|
||||
The review process should be simple:
|
||||
|
||||
**Ask every eligible customer.
|
||||
Pause disputed customers.
|
||||
Respond fast.
|
||||
Fix repeated problems.**
|
||||
|
||||
A review system that only collects praise is vanity. A review system that identifies problems and fixes them is management.
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Removes unused Docker images, stopped containers, and dangling volumes.
|
||||
# Runs every 2 hours via cron — see scripts/cronjobs.md for setup.
|
||||
|
||||
LOG_FILE="/tmp/docker-cleanup.log"
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
echo "[$TIMESTAMP] Starting Docker cleanup..." >> "$LOG_FILE"
|
||||
|
||||
# Remove stopped containers
|
||||
CONTAINERS=$(docker container prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Containers: $CONTAINERS" >> "$LOG_FILE"
|
||||
|
||||
# Remove dangling images (untagged layers with no container)
|
||||
DANGLING=$(docker image prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Dangling images: $DANGLING" >> "$LOG_FILE"
|
||||
|
||||
# Remove unused images (not referenced by any container)
|
||||
IMAGES=$(docker image prune -a -f --filter "until=2h" 2>&1)
|
||||
echo "[$TIMESTAMP] Unused images: $IMAGES" >> "$LOG_FILE"
|
||||
|
||||
# Remove unused volumes
|
||||
VOLUMES=$(docker volume prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Volumes: $VOLUMES" >> "$LOG_FILE"
|
||||
|
||||
# Remove unused networks
|
||||
NETWORKS=$(docker network prune -f 2>&1)
|
||||
echo "[$TIMESTAMP] Networks: $NETWORKS" >> "$LOG_FILE"
|
||||
|
||||
echo "[$TIMESTAMP] Cleanup complete." >> "$LOG_FILE"
|
||||
@@ -0,0 +1,695 @@
|
||||
# Simple and Robust Car Rental Process
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines a simple, robust operating process for a rental car company. It covers the full rental lifecycle from booking to vehicle return.
|
||||
|
||||
The process is designed to be easy for staff to follow, fast for customers, and strong enough to protect the company from payment issues, damage disputes, missing vehicles, and unclear responsibilities.
|
||||
|
||||
## Core Process
|
||||
|
||||
The rental operation should be built around four control points:
|
||||
|
||||
1. Booking
|
||||
2. Vehicle Preparation
|
||||
3. Pickup
|
||||
4. Return
|
||||
|
||||
Everything in the process should support one of these goals:
|
||||
|
||||
- Confirm the customer is eligible to rent.
|
||||
- Confirm the vehicle is available and ready.
|
||||
- Secure payment and deposit before release.
|
||||
- Document vehicle condition at pickup and return.
|
||||
- Close the rental clearly and fairly.
|
||||
|
||||
---
|
||||
|
||||
# 1. Booking
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of booking is to confirm who the customer is, what they need, and whether the company can safely rent to them.
|
||||
|
||||
## Required Customer Information
|
||||
|
||||
Collect the following information for every booking:
|
||||
|
||||
- Full customer name
|
||||
- Phone number
|
||||
- Email address
|
||||
- Driver's license details
|
||||
- Pickup date and time
|
||||
- Return date and time
|
||||
- Pickup location
|
||||
- Return location
|
||||
- Vehicle class
|
||||
- Payment method
|
||||
- Insurance choice
|
||||
- Special requests, if any
|
||||
|
||||
Special requests may include:
|
||||
|
||||
- Child seat
|
||||
- GPS device
|
||||
- Additional driver
|
||||
- One-way rental
|
||||
- Airport pickup
|
||||
- After-hours return
|
||||
- Electric vehicle request
|
||||
|
||||
## Booking Checks
|
||||
|
||||
Before confirming a booking, staff or the system must check:
|
||||
|
||||
- Vehicle availability
|
||||
- Driver age eligibility
|
||||
- Driver's license validity
|
||||
- Deposit requirement
|
||||
- Payment method validity
|
||||
- Previous unpaid balance
|
||||
- Customer blacklist or restriction status
|
||||
|
||||
If any check fails, the booking must not be confirmed until the issue is reviewed by a manager.
|
||||
|
||||
## Booking Confirmation
|
||||
|
||||
After approval, send the customer a confirmation by email or SMS.
|
||||
|
||||
The confirmation must include:
|
||||
|
||||
- Booking number
|
||||
- Pickup date, time, and location
|
||||
- Return date, time, and location
|
||||
- Vehicle class
|
||||
- Estimated total price
|
||||
- Deposit amount
|
||||
- Required documents
|
||||
- Fuel or charging rule
|
||||
- Late return rule
|
||||
- Cancellation rule
|
||||
- Company contact information
|
||||
|
||||
## Booking Checklist
|
||||
|
||||
- [ ] Customer details collected
|
||||
- [ ] License details collected
|
||||
- [ ] Vehicle availability confirmed
|
||||
- [ ] Price confirmed
|
||||
- [ ] Deposit amount confirmed
|
||||
- [ ] Insurance choice recorded
|
||||
- [ ] Special requests recorded
|
||||
- [ ] Confirmation sent to customer
|
||||
|
||||
---
|
||||
|
||||
# 2. Vehicle Preparation
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of vehicle preparation is to make sure the car is ready before the customer arrives.
|
||||
|
||||
## Vehicle Assignment
|
||||
|
||||
Assign a specific vehicle to the booking before pickup whenever possible.
|
||||
|
||||
The assigned vehicle must match:
|
||||
|
||||
- Booked vehicle class
|
||||
- Required seating capacity
|
||||
- Transmission preference, if applicable
|
||||
- Fuel or electric vehicle preference, if applicable
|
||||
- Special requests, if applicable
|
||||
|
||||
If the exact vehicle is unavailable, staff may assign an equal or higher-class vehicle according to company policy.
|
||||
|
||||
## Vehicle Readiness Checklist
|
||||
|
||||
Before pickup, staff must check:
|
||||
|
||||
- [ ] Vehicle is clean inside
|
||||
- [ ] Vehicle is clean outside
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery level recorded
|
||||
- [ ] Tires checked visually
|
||||
- [ ] No dashboard warning lights
|
||||
- [ ] Lights working
|
||||
- [ ] Registration document available
|
||||
- [ ] Insurance document available
|
||||
- [ ] Keys available
|
||||
- [ ] Requested accessories installed or included
|
||||
- [ ] Pickup photos taken
|
||||
- [ ] Existing damage recorded
|
||||
|
||||
## Vehicle Statuses
|
||||
|
||||
Use simple vehicle statuses only:
|
||||
|
||||
| Status | Meaning |
|
||||
|---|---|
|
||||
| Available | Vehicle can be booked. |
|
||||
| Reserved | Vehicle is assigned to a future booking. |
|
||||
| Ready | Vehicle is prepared for pickup. |
|
||||
| On Rent | Vehicle is currently with a customer. |
|
||||
| Returned | Vehicle has been returned but not yet processed. |
|
||||
| Needs Cleaning | Vehicle requires cleaning before reuse. |
|
||||
| Needs Maintenance | Vehicle requires mechanical service. |
|
||||
| Damage Review | Vehicle has possible damage that must be reviewed. |
|
||||
| Blocked | Vehicle cannot be rented. |
|
||||
|
||||
## Preparation Checklist
|
||||
|
||||
- [ ] Booking reviewed
|
||||
- [ ] Vehicle assigned
|
||||
- [ ] Vehicle cleaned
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery recorded
|
||||
- [ ] Vehicle photos taken
|
||||
- [ ] Documents inside vehicle
|
||||
- [ ] Keys ready
|
||||
- [ ] Accessories ready
|
||||
- [ ] Vehicle status changed to Ready
|
||||
|
||||
---
|
||||
|
||||
# 3. Pickup
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of pickup is to verify the customer, secure payment, document the vehicle, and release the car quickly.
|
||||
|
||||
A standard pickup should normally take 10 to 15 minutes.
|
||||
|
||||
## Required Customer Documents
|
||||
|
||||
The customer must provide:
|
||||
|
||||
- Valid driver's license
|
||||
- Government ID, if required
|
||||
- Accepted payment card
|
||||
- Proof of insurance, if using personal insurance
|
||||
- Booking confirmation, if needed
|
||||
|
||||
The name on the driver's license should match the booking and payment method unless manager approval is given.
|
||||
|
||||
## Pickup Steps
|
||||
|
||||
1. Find the booking in the system.
|
||||
2. Verify the customer's identity.
|
||||
3. Verify the driver's license.
|
||||
4. Confirm return date, time, and location.
|
||||
5. Confirm vehicle class and assigned vehicle.
|
||||
6. Process rental payment.
|
||||
7. Authorize or collect deposit.
|
||||
8. Confirm insurance choice.
|
||||
9. Walk around the vehicle with the customer.
|
||||
10. Take pickup photos.
|
||||
11. Record mileage.
|
||||
12. Record fuel or battery level.
|
||||
13. Record existing damage.
|
||||
14. Have the customer sign the rental agreement.
|
||||
15. Give the customer the keys and emergency contact information.
|
||||
16. Change vehicle status to On Rent.
|
||||
|
||||
## Pickup Inspection
|
||||
|
||||
The pickup inspection must include:
|
||||
|
||||
- Front of vehicle
|
||||
- Rear of vehicle
|
||||
- Left side
|
||||
- Right side
|
||||
- Roof, if visible
|
||||
- Windshield
|
||||
- Windows
|
||||
- Mirrors
|
||||
- Tires
|
||||
- Wheels
|
||||
- Interior seats
|
||||
- Dashboard
|
||||
- Trunk
|
||||
- Fuel or charging area
|
||||
|
||||
Photos must be uploaded to the rental record before the vehicle is released.
|
||||
|
||||
## Rental Agreement Requirements
|
||||
|
||||
The rental agreement must include:
|
||||
|
||||
- Customer name
|
||||
- Authorized drivers
|
||||
- Vehicle make, model, color, and plate number
|
||||
- Pickup date and time
|
||||
- Expected return date and time
|
||||
- Pickup and return locations
|
||||
- Starting mileage
|
||||
- Starting fuel or battery level
|
||||
- Rental rate
|
||||
- Deposit amount
|
||||
- Insurance choice
|
||||
- Mileage limit, if applicable
|
||||
- Fuel or charging policy
|
||||
- Late return policy
|
||||
- Damage policy
|
||||
- Cleaning policy
|
||||
- Smoking policy
|
||||
- Toll and fine responsibility
|
||||
- Accident reporting process
|
||||
- Customer signature
|
||||
- Staff signature
|
||||
|
||||
## Pickup Checklist
|
||||
|
||||
- [ ] Booking found
|
||||
- [ ] Customer ID verified
|
||||
- [ ] Driver's license verified
|
||||
- [ ] Return details confirmed
|
||||
- [ ] Payment completed
|
||||
- [ ] Deposit authorized or collected
|
||||
- [ ] Insurance choice confirmed
|
||||
- [ ] Vehicle inspected with customer
|
||||
- [ ] Pickup photos uploaded
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery level recorded
|
||||
- [ ] Existing damage recorded
|
||||
- [ ] Agreement signed
|
||||
- [ ] Keys given
|
||||
- [ ] Emergency contact provided
|
||||
- [ ] Vehicle status changed to On Rent
|
||||
|
||||
---
|
||||
|
||||
# 4. During the Rental
|
||||
|
||||
## Goal
|
||||
|
||||
The goal during the rental period is to handle changes, incidents, and support requests without confusion.
|
||||
|
||||
## Customer Support
|
||||
|
||||
The customer must be able to contact the company for:
|
||||
|
||||
- Rental extension
|
||||
- Accident
|
||||
- Breakdown
|
||||
- Flat tire
|
||||
- Lost key
|
||||
- Vehicle issue
|
||||
- Return location question
|
||||
- Payment issue
|
||||
|
||||
## Rental Extension Process
|
||||
|
||||
Before approving an extension, staff must:
|
||||
|
||||
1. Check vehicle availability.
|
||||
2. Confirm the new return date and time.
|
||||
3. Calculate additional charges.
|
||||
4. Process additional payment or authorization.
|
||||
5. Update the rental agreement.
|
||||
6. Send written confirmation to the customer.
|
||||
|
||||
Verbal-only extensions are not allowed.
|
||||
|
||||
## Accident Process
|
||||
|
||||
If the customer reports an accident, staff must instruct the customer to:
|
||||
|
||||
- Stop safely.
|
||||
- Call emergency services if needed.
|
||||
- Take photos of all vehicles and damage.
|
||||
- Collect other driver details, if applicable.
|
||||
- Get a police report if required.
|
||||
- Contact the rental company immediately.
|
||||
- Avoid driving the vehicle if it is unsafe.
|
||||
|
||||
Company staff must:
|
||||
|
||||
- Open an incident record.
|
||||
- Collect photos and documents.
|
||||
- Contact the insurer if needed.
|
||||
- Arrange towing if needed.
|
||||
- Arrange a replacement vehicle if approved.
|
||||
- Change vehicle status if the vehicle is no longer rentable.
|
||||
|
||||
## Breakdown Process
|
||||
|
||||
If the vehicle breaks down, staff must:
|
||||
|
||||
1. Confirm the customer's location.
|
||||
2. Confirm customer safety.
|
||||
3. Ask about warning lights or symptoms.
|
||||
4. Contact roadside assistance.
|
||||
5. Arrange towing if required.
|
||||
6. Arrange a replacement vehicle if approved.
|
||||
7. Record the incident.
|
||||
8. Mark the vehicle as Needs Maintenance or Blocked.
|
||||
|
||||
---
|
||||
|
||||
# 5. Return
|
||||
|
||||
## Goal
|
||||
|
||||
The goal of the return process is to close the rental fairly, document the condition of the vehicle, and prepare the car for the next customer.
|
||||
|
||||
## Return Steps
|
||||
|
||||
1. Find the rental agreement.
|
||||
2. Record actual return time.
|
||||
3. Record return mileage.
|
||||
4. Record fuel or battery level.
|
||||
5. Inspect the exterior.
|
||||
6. Inspect the interior.
|
||||
7. Compare condition with pickup photos.
|
||||
8. Check keys and accessories.
|
||||
9. Check for customer belongings.
|
||||
10. Take return photos.
|
||||
11. Calculate final charges.
|
||||
12. Release or adjust the deposit.
|
||||
13. Send final receipt.
|
||||
14. Change vehicle status.
|
||||
|
||||
## Return Inspection
|
||||
|
||||
The return inspection must include:
|
||||
|
||||
- Exterior damage
|
||||
- Interior damage
|
||||
- Glass condition
|
||||
- Tire condition
|
||||
- Wheel condition
|
||||
- Fuel or battery level
|
||||
- Mileage
|
||||
- Smoking odor
|
||||
- Pet hair
|
||||
- Stains
|
||||
- Trash
|
||||
- Missing accessories
|
||||
- Warning lights
|
||||
- Keys and key fobs
|
||||
|
||||
The return inspection should be completed with the customer present whenever possible.
|
||||
|
||||
## Damage Found at Return
|
||||
|
||||
If new damage is found, staff must:
|
||||
|
||||
1. Compare pickup photos with return photos.
|
||||
2. Show the customer the damage.
|
||||
3. Take clear photos of the damage.
|
||||
4. Record the damage location and description.
|
||||
5. Ask the customer to sign the damage report.
|
||||
6. Escalate to a manager if the customer disputes the damage.
|
||||
7. Keep the vehicle in Damage Review status until resolved.
|
||||
|
||||
If the customer refuses to sign, staff must write:
|
||||
|
||||
> Customer refused to sign damage acknowledgment.
|
||||
|
||||
The staff member must still complete the damage report and upload evidence.
|
||||
|
||||
## Final Charges
|
||||
|
||||
Final billing may include:
|
||||
|
||||
- Base rental charge
|
||||
- Extra rental time
|
||||
- Late return fee
|
||||
- Extra mileage fee
|
||||
- Fuel refill fee
|
||||
- EV recharge fee
|
||||
- Cleaning fee
|
||||
- Smoking fee
|
||||
- Damage charge
|
||||
- Lost key charge
|
||||
- Missing accessory charge
|
||||
- Toll charges
|
||||
- Traffic fines
|
||||
- Administration fees
|
||||
|
||||
All charges must be supported by the rental agreement, system records, or photo evidence.
|
||||
|
||||
## Deposit Handling
|
||||
|
||||
If there are no additional charges:
|
||||
|
||||
- Close the rental.
|
||||
- Release the deposit hold.
|
||||
- Send the final receipt.
|
||||
|
||||
If there are additional charges:
|
||||
|
||||
- Deduct approved charges.
|
||||
- Send an itemized invoice.
|
||||
- Attach evidence if needed.
|
||||
- Release any remaining deposit.
|
||||
|
||||
## Return Checklist
|
||||
|
||||
- [ ] Rental agreement found
|
||||
- [ ] Return time recorded
|
||||
- [ ] Mileage recorded
|
||||
- [ ] Fuel or battery level checked
|
||||
- [ ] Exterior inspected
|
||||
- [ ] Interior inspected
|
||||
- [ ] Pickup photos reviewed
|
||||
- [ ] Return photos taken
|
||||
- [ ] Keys returned
|
||||
- [ ] Accessories returned
|
||||
- [ ] Lost property checked
|
||||
- [ ] Final charges calculated
|
||||
- [ ] Deposit released or adjusted
|
||||
- [ ] Receipt sent
|
||||
- [ ] Vehicle status updated
|
||||
|
||||
---
|
||||
|
||||
# 6. Post-Return Processing
|
||||
|
||||
## Lost Property Check
|
||||
|
||||
Staff must check:
|
||||
|
||||
- Glove box
|
||||
- Center console
|
||||
- Door pockets
|
||||
- Under seats
|
||||
- Trunk
|
||||
- Seat pockets
|
||||
- Charging cable area
|
||||
|
||||
Any found item must be logged with:
|
||||
|
||||
- Date found
|
||||
- Vehicle plate number
|
||||
- Rental agreement number
|
||||
- Item description
|
||||
- Staff name
|
||||
- Storage location
|
||||
- Customer notification status
|
||||
|
||||
## Cleaning
|
||||
|
||||
After return, assign the vehicle to the correct cleaning level:
|
||||
|
||||
- Light cleaning
|
||||
- Standard cleaning
|
||||
- Deep cleaning
|
||||
- Smoke treatment
|
||||
- Pet hair removal
|
||||
- Stain removal
|
||||
|
||||
If extra cleaning is required, staff must take photos before cleaning.
|
||||
|
||||
## Maintenance Check
|
||||
|
||||
Staff must check for:
|
||||
|
||||
- Dashboard warning lights
|
||||
- Tire pressure issues
|
||||
- Fluid leaks
|
||||
- Brake issues
|
||||
- Unusual sounds
|
||||
- Service due alerts
|
||||
- EV charging issues
|
||||
|
||||
After cleaning and maintenance review, update the vehicle status to one of the following:
|
||||
|
||||
- Available
|
||||
- Needs Cleaning
|
||||
- Needs Maintenance
|
||||
- Damage Review
|
||||
- Blocked
|
||||
|
||||
---
|
||||
|
||||
# 7. Simple Policy Set
|
||||
|
||||
The company must define clear written policies for:
|
||||
|
||||
- Cancellation
|
||||
- No-show
|
||||
- Late pickup
|
||||
- Late return
|
||||
- Fuel return
|
||||
- EV battery return
|
||||
- Mileage limit
|
||||
- Extra mileage
|
||||
- Insurance
|
||||
- Damage
|
||||
- Smoking
|
||||
- Pets
|
||||
- Cleaning
|
||||
- Lost keys
|
||||
- Tolls
|
||||
- Traffic fines
|
||||
- Unauthorized drivers
|
||||
- Accidents
|
||||
- Breakdowns
|
||||
- Deposit release timing
|
||||
|
||||
Each policy should be written in plain language and shown to the customer before pickup.
|
||||
|
||||
---
|
||||
|
||||
# 8. Staff Roles
|
||||
|
||||
## Rental Agent
|
||||
|
||||
The rental agent is responsible for:
|
||||
|
||||
- Processing bookings
|
||||
- Verifying customer documents
|
||||
- Explaining rental terms
|
||||
- Processing payments
|
||||
- Completing pickup inspections
|
||||
- Completing return inspections
|
||||
- Updating vehicle status
|
||||
|
||||
## Fleet Staff
|
||||
|
||||
Fleet staff are responsible for:
|
||||
|
||||
- Cleaning vehicles
|
||||
- Fueling or charging vehicles
|
||||
- Moving vehicles
|
||||
- Checking readiness
|
||||
- Reporting damage or maintenance issues
|
||||
|
||||
## Manager
|
||||
|
||||
The manager is responsible for:
|
||||
|
||||
- Approving exceptions
|
||||
- Handling disputes
|
||||
- Reviewing damage claims
|
||||
- Approving high-risk rentals
|
||||
- Monitoring daily operations
|
||||
- Reviewing overdue vehicles
|
||||
|
||||
---
|
||||
|
||||
# 9. System Requirements
|
||||
|
||||
The rental company should use one central rental system for:
|
||||
|
||||
- Bookings
|
||||
- Customer records
|
||||
- Vehicle records
|
||||
- Availability
|
||||
- Payments
|
||||
- Deposits
|
||||
- Rental agreements
|
||||
- Pickup photos
|
||||
- Return photos
|
||||
- Damage records
|
||||
- Invoices
|
||||
- Vehicle statuses
|
||||
|
||||
## Core System Rule
|
||||
|
||||
Each rental must have:
|
||||
|
||||
- One booking
|
||||
- One customer record
|
||||
- One assigned vehicle
|
||||
- One rental agreement
|
||||
- One final invoice
|
||||
|
||||
If staff need to check several different places to understand one rental, the system is too complicated.
|
||||
|
||||
---
|
||||
|
||||
# 10. Non-Negotiable Controls
|
||||
|
||||
The following controls must never be skipped:
|
||||
|
||||
- Valid driver's license check
|
||||
- Payment before vehicle release
|
||||
- Deposit before vehicle release
|
||||
- Signed rental agreement
|
||||
- Pickup photos
|
||||
- Return photos
|
||||
- Mileage record at pickup and return
|
||||
- Fuel or battery record at pickup and return
|
||||
- Written insurance choice
|
||||
- Written extension approval
|
||||
- Damage evidence before charging customer
|
||||
|
||||
These controls protect the company from avoidable losses and disputes.
|
||||
|
||||
---
|
||||
|
||||
# 11. Standard Operating Flow
|
||||
|
||||
## Before Pickup
|
||||
|
||||
1. Customer books vehicle.
|
||||
2. System confirms availability.
|
||||
3. Staff assigns vehicle.
|
||||
4. Vehicle is cleaned and checked.
|
||||
5. Customer receives pickup reminder.
|
||||
|
||||
## At Pickup
|
||||
|
||||
1. Staff verifies customer.
|
||||
2. Staff verifies license.
|
||||
3. Payment and deposit are processed.
|
||||
4. Vehicle is inspected.
|
||||
5. Agreement is signed.
|
||||
6. Keys are released.
|
||||
7. Vehicle status becomes On Rent.
|
||||
|
||||
## During Rental
|
||||
|
||||
1. Company supports customer if needed.
|
||||
2. Extensions are handled in writing.
|
||||
3. Accidents and breakdowns are recorded.
|
||||
4. Overdue rentals are monitored.
|
||||
|
||||
## At Return
|
||||
|
||||
1. Vehicle is returned.
|
||||
2. Mileage and fuel or battery are recorded.
|
||||
3. Vehicle is inspected.
|
||||
4. Final charges are calculated.
|
||||
5. Deposit is released or adjusted.
|
||||
6. Receipt is sent.
|
||||
7. Vehicle is cleaned and reset.
|
||||
|
||||
---
|
||||
|
||||
# 12. Operating Principle
|
||||
|
||||
The company should design the process so a new employee can follow it using checklists, not memory.
|
||||
|
||||
The process should rely on:
|
||||
|
||||
- Clear vehicle statuses
|
||||
- Short checklists
|
||||
- Photo evidence
|
||||
- Written confirmations
|
||||
- Manager approval for exceptions
|
||||
|
||||
A simple rental process is not a weak process. It is a controlled process with fewer places for mistakes to hide.
|
||||
Reference in New Issue
Block a user