From 0d969ab0951adf7c888afced6b8d0f8716c1ea88 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 25 May 2026 18:29:05 -0400 Subject: [PATCH] add review and booking policies --- apps/api/src/app.ts | 5 + .../src/modules/complaints/complaint.repo.ts | 84 +++ .../modules/complaints/complaint.routes.ts | 55 ++ .../modules/complaints/complaint.schemas.ts | 44 ++ .../modules/complaints/complaint.service.ts | 75 ++ .../reservation.lifecycle.service.ts | 88 ++- .../reservations/reservation.photo.service.ts | 33 + .../modules/reservations/reservation.repo.ts | 2 +- .../reservations/reservation.routes.ts | 41 +- .../reservations/reservation.schemas.ts | 1 + apps/api/src/modules/reviews/review.repo.ts | 109 +++ apps/api/src/modules/reviews/review.routes.ts | 54 ++ .../api/src/modules/reviews/review.schemas.ts | 15 + .../api/src/modules/reviews/review.service.ts | 76 ++ .../subscriptions/subscription.routes.ts | 4 + .../subscriptions/subscription.service.ts | 6 + .../src/modules/vehicles/vehicle.routes.ts | 10 +- .../src/modules/vehicles/vehicle.schemas.ts | 3 +- .../src/modules/vehicles/vehicle.service.ts | 15 +- .../src/app/(dashboard)/complaints/page.tsx | 567 ++++++++++++++ .../src/app/(dashboard)/fleet/[id]/page.tsx | 2 +- .../src/app/(dashboard)/fleet/page.tsx | 42 +- apps/dashboard/src/app/(dashboard)/layout.tsx | 14 +- .../(dashboard)/reservations/[id]/page.tsx | 115 +++ .../src/app/(dashboard)/reviews/page.tsx | 422 +++++++++++ .../src/app/(dashboard)/subscription/page.tsx | 58 +- apps/dashboard/src/app/globals.css | 4 + .../dashboard/src/components/I18nProvider.tsx | 177 +++-- .../src/components/layout/Sidebar.tsx | 10 +- .../reservations/ReservationPhotoSection.tsx | 124 ++++ .../migration.sql | 19 + .../migration.sql | 6 + .../migration.sql | 83 +++ packages/database/prisma/schema.prisma | 106 ++- review_management_process.md | 694 +++++++++++++++++ scripts/docker-cleanup.sh | 30 + simple_robust_car_rental_process.md | 695 ++++++++++++++++++ 37 files changed, 3775 insertions(+), 113 deletions(-) create mode 100644 apps/api/src/modules/complaints/complaint.repo.ts create mode 100644 apps/api/src/modules/complaints/complaint.routes.ts create mode 100644 apps/api/src/modules/complaints/complaint.schemas.ts create mode 100644 apps/api/src/modules/complaints/complaint.service.ts create mode 100644 apps/api/src/modules/reservations/reservation.photo.service.ts create mode 100644 apps/api/src/modules/reviews/review.repo.ts create mode 100644 apps/api/src/modules/reviews/review.routes.ts create mode 100644 apps/api/src/modules/reviews/review.schemas.ts create mode 100644 apps/api/src/modules/reviews/review.service.ts create mode 100644 apps/dashboard/src/app/(dashboard)/complaints/page.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/reviews/page.tsx create mode 100644 apps/dashboard/src/components/reservations/ReservationPhotoSection.tsx create mode 100644 packages/database/prisma/migrations/20260525200000_reservation_photos/migration.sql create mode 100644 packages/database/prisma/migrations/20260525210000_expand_vehicle_status/migration.sql create mode 100644 packages/database/prisma/migrations/20260525220000_review_management/migration.sql create mode 100644 review_management_process.md create mode 100755 scripts/docker-cleanup.sh create mode 100644 simple_robust_car_rental_process.md diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7dee094..70f6b59 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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) => { diff --git a/apps/api/src/modules/complaints/complaint.repo.ts b/apps/api/src/modules/complaints/complaint.repo.ts new file mode 100644 index 0000000..1a5d068 --- /dev/null +++ b/apps/api/src/modules/complaints/complaint.repo.ts @@ -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, + 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) { + return prisma.complaint.update({ + where: { id }, + data, + include: COMPLAINT_INCLUDE, + }) +} + +export async function deleteById(id: string) { + return prisma.complaint.delete({ where: { id } }) +} diff --git a/apps/api/src/modules/complaints/complaint.routes.ts b/apps/api/src/modules/complaints/complaint.routes.ts new file mode 100644 index 0000000..0985a0f --- /dev/null +++ b/apps/api/src/modules/complaints/complaint.routes.ts @@ -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 diff --git a/apps/api/src/modules/complaints/complaint.schemas.ts b/apps/api/src/modules/complaints/complaint.schemas.ts new file mode 100644 index 0000000..ff89aaf --- /dev/null +++ b/apps/api/src/modules/complaints/complaint.schemas.ts @@ -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(), +}) diff --git a/apps/api/src/modules/complaints/complaint.service.ts b/apps/api/src/modules/complaints/complaint.service.ts new file mode 100644 index 0000000..c2a4250 --- /dev/null +++ b/apps/api/src/modules/complaints/complaint.service.ts @@ -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 = {} + 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 = { ...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 } +} diff --git a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts index 0424a82..f99e14d 100644 --- a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts +++ b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts @@ -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 diff --git a/apps/api/src/modules/reservations/reservation.photo.service.ts b/apps/api/src/modules/reservations/reservation.photo.service.ts new file mode 100644 index 0000000..1aa3119 --- /dev/null +++ b/apps/api/src/modules/reservations/reservation.photo.service.ts @@ -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 } }) +} diff --git a/apps/api/src/modules/reservations/reservation.repo.ts b/apps/api/src/modules/reservations/reservation.repo.ts index 7313938..822485b 100644 --- a/apps/api/src/modules/reservations/reservation.repo.ts +++ b/apps/api/src/modules/reservations/reservation.repo.ts @@ -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) { diff --git a/apps/api/src/modules/reservations/reservation.routes.ts b/apps/api/src/modules/reservations/reservation.routes.ts index 5868479..e6ab0ee 100644 --- a/apps/api/src/modules/reservations/reservation.routes.ts +++ b/apps/api/src/modules/reservations/reservation.routes.ts @@ -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 diff --git a/apps/api/src/modules/reservations/reservation.schemas.ts b/apps/api/src/modules/reservations/reservation.schemas.ts index 61e8c3d..4998957 100644 --- a/apps/api/src/modules/reservations/reservation.schemas.ts +++ b/apps/api/src/modules/reservations/reservation.schemas.ts @@ -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']) diff --git a/apps/api/src/modules/reviews/review.repo.ts b/apps/api/src/modules/reviews/review.repo.ts new file mode 100644 index 0000000..8d0318b --- /dev/null +++ b/apps/api/src/modules/reviews/review.repo.ts @@ -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, + 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) { + 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 = { 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 }, + }) +} diff --git a/apps/api/src/modules/reviews/review.routes.ts b/apps/api/src/modules/reviews/review.routes.ts new file mode 100644 index 0000000..5a1cb92 --- /dev/null +++ b/apps/api/src/modules/reviews/review.routes.ts @@ -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 diff --git a/apps/api/src/modules/reviews/review.schemas.ts b/apps/api/src/modules/reviews/review.schemas.ts new file mode 100644 index 0000000..0ea41ce --- /dev/null +++ b/apps/api/src/modules/reviews/review.schemas.ts @@ -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(), +}) diff --git a/apps/api/src/modules/reviews/review.service.ts b/apps/api/src/modules/reviews/review.service.ts new file mode 100644 index 0000000..63677b4 --- /dev/null +++ b/apps/api/src/modules/reviews/review.service.ts @@ -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 = {} + 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 } +} diff --git a/apps/api/src/modules/subscriptions/subscription.routes.ts b/apps/api/src/modules/subscriptions/subscription.routes.ts index 8d07fdf..3431102 100644 --- a/apps/api/src/modules/subscriptions/subscription.routes.ts +++ b/apps/api/src/modules/subscriptions/subscription.routes.ts @@ -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) => { diff --git a/apps/api/src/modules/subscriptions/subscription.service.ts b/apps/api/src/modules/subscriptions/subscription.service.ts index 49f86d6..371988d 100644 --- a/apps/api/src/modules/subscriptions/subscription.service.ts +++ b/apps/api/src/modules/subscriptions/subscription.service.ts @@ -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) { diff --git a/apps/api/src/modules/vehicles/vehicle.routes.ts b/apps/api/src/modules/vehicles/vehicle.routes.ts index b0e9b28..582b675 100644 --- a/apps/api/src/modules/vehicles/vehicle.routes.ts +++ b/apps/api/src/modules/vehicles/vehicle.routes.ts @@ -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) diff --git a/apps/api/src/modules/vehicles/vehicle.schemas.ts b/apps/api/src/modules/vehicles/vehicle.schemas.ts index 18da4a0..1097545 100644 --- a/apps/api/src/modules/vehicles/vehicle.schemas.ts +++ b/apps/api/src/modules/vehicles/vehicle.schemas.ts @@ -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() }) diff --git a/apps/api/src/modules/vehicles/vehicle.service.ts b/apps/api/src/modules/vehicles/vehicle.service.ts index af88bbc..6295ad0 100644 --- a/apps/api/src/modules/vehicles/vehicle.service.ts +++ b/apps/api/src/modules/vehicles/vehicle.service.ts @@ -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) } diff --git a/apps/dashboard/src/app/(dashboard)/complaints/page.tsx b/apps/dashboard/src/app/(dashboard)/complaints/page.tsx new file mode 100644 index 0000000..7376b10 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/complaints/page.tsx @@ -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, + }, + 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, + }, + 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, + }, +} 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([]) + const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const [filterStatus, setFilterStatus] = useState('') + const [filterSeverity, setFilterSeverity] = useState('') + const [page, setPage] = useState(1) + + const [expandedId, setExpandedId] = useState(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>({}) + const [editNotes, setEditNotes] = useState>({}) + const [editResolution, setEditResolution] = useState>({}) + const [savingId, setSavingId] = useState(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(`/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 ( +
+ {/* Header */} +
+
+

{t.heading}

+

{t.subtitle}

+
+ +
+ + {/* Create form */} + {showCreate && ( +
+

{t.createTitle}

+
+
+ + setFormReservationId(e.target.value)} + className="input-field" + /> +
+
+ + +
+
+ + +
+
+ + setFormSubject(e.target.value)} + className="input-field" + /> +
+
+ +