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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user