fix the contract view and edit
This commit is contained in:
@@ -62,6 +62,99 @@ const createSchema = z.object({
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
})
|
||||
|
||||
const updateSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
pickupLocation: z.string().optional().nullable(),
|
||||
returnLocation: z.string().optional().nullable(),
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: z.string().max(50).optional().nullable(),
|
||||
damageChargeAmount: z.number().int().min(0).optional(),
|
||||
damageChargeNote: z.string().optional().nullable(),
|
||||
})
|
||||
|
||||
function parseReservationExtras(extras: unknown) {
|
||||
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return {} as Record<string, unknown>
|
||||
return { ...(extras as Record<string, unknown>) }
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined) {
|
||||
const next = typeof value === 'string' ? value.trim() : value
|
||||
return next ? next : null
|
||||
}
|
||||
|
||||
function calculateUpdatedInsuranceCharge(
|
||||
chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL',
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
baseRentalAmount: number,
|
||||
) {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY':
|
||||
return chargeValue * totalDays
|
||||
case 'PER_RENTAL':
|
||||
return chargeValue
|
||||
case 'PERCENTAGE_OF_RENTAL':
|
||||
return Math.round(baseRentalAmount * chargeValue / 100)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function calculateUpdatedAdditionalDriverCharge(
|
||||
chargeType: 'PER_DAY' | 'FLAT' | 'FREE',
|
||||
chargeValue: number,
|
||||
totalDays: number,
|
||||
) {
|
||||
switch (chargeType) {
|
||||
case 'PER_DAY':
|
||||
return chargeValue * totalDays
|
||||
case 'FLAT':
|
||||
return chargeValue
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function buildReservationWorkflow(reservation: {
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
extras: unknown
|
||||
}) {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
const closedAt = typeof extras.reservationClosedAt === 'string' ? extras.reservationClosedAt : null
|
||||
const closedBy = typeof extras.reservationClosedBy === 'string' ? extras.reservationClosedBy : null
|
||||
const contractGenerated = Boolean(reservation.contractNumber || reservation.invoiceNumber)
|
||||
const closed = Boolean(closedAt)
|
||||
|
||||
return {
|
||||
contractGenerated,
|
||||
closed,
|
||||
closedAt,
|
||||
closedBy,
|
||||
coreEditable: !closed && !contractGenerated && ['DRAFT', 'CONFIRMED'].includes(reservation.status),
|
||||
returnEditable: !closed && reservation.status === 'COMPLETED',
|
||||
checkInInspectionEditable: !closed && ['CONFIRMED', 'ACTIVE'].includes(reservation.status),
|
||||
checkOutInspectionEditable: !closed && reservation.status === 'COMPLETED',
|
||||
}
|
||||
}
|
||||
|
||||
function serializeReservationForDashboard<T extends {
|
||||
extras: unknown
|
||||
status: string
|
||||
contractNumber: string | null
|
||||
invoiceNumber: string | null
|
||||
}>(reservation: T) {
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
return {
|
||||
...reservation,
|
||||
paymentMode: typeof extras.paymentMode === 'string' ? extras.paymentMode : null,
|
||||
workflow: buildReservationWorkflow(reservation),
|
||||
}
|
||||
}
|
||||
|
||||
function formatDocumentNumber(prefix: string, sequence: number) {
|
||||
return `${prefix}-${String(sequence).padStart(6, '0')}`
|
||||
}
|
||||
@@ -228,7 +321,13 @@ router.get('/', async (req, res, next) => {
|
||||
prisma.reservation.count({ where }),
|
||||
])
|
||||
|
||||
res.json({ data: reservations, total, page: parseInt(page), pageSize: parseInt(pageSize), totalPages: Math.ceil(total / parseInt(pageSize)) })
|
||||
res.json({
|
||||
data: reservations.map((reservation) => serializeReservationForDashboard(reservation)),
|
||||
total,
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
totalPages: Math.ceil(total / parseInt(pageSize)),
|
||||
})
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -317,7 +416,152 @@ router.get('/:id', async (req, res, next) => {
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
res.json({ data: reservation })
|
||||
res.json({ data: serializeReservationForDashboard(reservation) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.patch('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const body = updateSchema.parse(req.body)
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: {
|
||||
insurances: true,
|
||||
additionalDrivers: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (reservation.status === 'CANCELLED' || reservation.status === 'NO_SHOW') {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'This reservation can no longer be edited', statusCode: 400 })
|
||||
}
|
||||
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
const isReturnEdit = workflow.returnEditable
|
||||
const requestedFields = Object.keys(body).filter((key) => body[key as keyof typeof body] !== undefined)
|
||||
const bookingFields = ['startDate', 'endDate', 'pickupLocation', 'returnLocation', 'depositAmount', 'notes', 'paymentMode']
|
||||
const returnFields = ['returnLocation', 'damageChargeAmount', 'damageChargeNote']
|
||||
|
||||
if (!workflow.coreEditable && !isReturnEdit) {
|
||||
return res.status(400).json({ error: 'reservation_locked', message: 'This reservation is locked for editing', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (workflow.coreEditable) {
|
||||
const invalidFields = requestedFields.filter((field) => !bookingFields.includes(field))
|
||||
if (invalidFields.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_fields', message: 'Only booking details can be edited at this stage', statusCode: 400 })
|
||||
}
|
||||
|
||||
const nextStartDate = body.startDate ? new Date(body.startDate) : reservation.startDate
|
||||
const nextEndDate = body.endDate ? new Date(body.endDate) : reservation.endDate
|
||||
const totalDays = Math.ceil((nextEndDate.getTime() - nextStartDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (nextEndDate <= nextStartDate || totalDays <= 0) {
|
||||
return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (body.startDate || body.endDate) {
|
||||
const conflict = await prisma.reservation.findFirst({
|
||||
where: {
|
||||
id: { not: reservation.id },
|
||||
vehicleId: reservation.vehicleId,
|
||||
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
|
||||
startDate: { lt: nextEndDate },
|
||||
endDate: { gt: nextStartDate },
|
||||
},
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
return res.status(409).json({ error: 'vehicle_unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
const baseAmount = reservation.dailyRate * totalDays
|
||||
const { applied, total: pricingRulesTotal } = await applyPricingRules(
|
||||
req.companyId,
|
||||
reservation.customerId,
|
||||
reservation.additionalDrivers.map((driver) => ({
|
||||
dateOfBirth: driver.dateOfBirth,
|
||||
licenseIssuedAt: driver.licenseIssuedAt,
|
||||
})),
|
||||
reservation.dailyRate,
|
||||
totalDays,
|
||||
)
|
||||
|
||||
const insuranceUpdates = reservation.insurances.map((insurance) => ({
|
||||
id: insurance.id,
|
||||
totalCharge: calculateUpdatedInsuranceCharge(insurance.chargeType, insurance.chargeValue, totalDays, baseAmount),
|
||||
}))
|
||||
const additionalDriverUpdates = reservation.additionalDrivers.map((driver) => ({
|
||||
id: driver.id,
|
||||
totalCharge: calculateUpdatedAdditionalDriverCharge(driver.chargeType, driver.chargeValue, totalDays),
|
||||
}))
|
||||
|
||||
const insuranceTotal = insuranceUpdates.reduce((sum, insurance) => sum + insurance.totalCharge, 0)
|
||||
const additionalDriverTotal = additionalDriverUpdates.reduce((sum, driver) => sum + driver.totalCharge, 0)
|
||||
const depositAmount = body.depositAmount ?? reservation.depositAmount
|
||||
const totalAmount = baseAmount - reservation.discountAmount + pricingRulesTotal + insuranceTotal + additionalDriverTotal + depositAmount
|
||||
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
if (body.paymentMode !== undefined) {
|
||||
const nextPaymentMode = normalizeOptionalString(body.paymentMode)
|
||||
if (nextPaymentMode) extras.paymentMode = nextPaymentMode
|
||||
else delete extras.paymentMode
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
startDate: nextStartDate,
|
||||
endDate: nextEndDate,
|
||||
totalDays,
|
||||
pickupLocation: body.pickupLocation !== undefined ? normalizeOptionalString(body.pickupLocation) : reservation.pickupLocation,
|
||||
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
||||
depositAmount,
|
||||
notes: body.notes !== undefined ? normalizeOptionalString(body.notes) : reservation.notes,
|
||||
pricingRulesApplied: applied,
|
||||
pricingRulesTotal,
|
||||
insuranceTotal,
|
||||
additionalDriverTotal,
|
||||
totalAmount,
|
||||
extras: (Object.keys(extras).length > 0 ? extras : {}) as any,
|
||||
},
|
||||
}),
|
||||
...insuranceUpdates.map((insurance) =>
|
||||
prisma.reservationInsurance.update({
|
||||
where: { id: insurance.id },
|
||||
data: { totalCharge: insurance.totalCharge },
|
||||
})
|
||||
),
|
||||
...additionalDriverUpdates.map((driver) =>
|
||||
prisma.additionalDriver.update({
|
||||
where: { id: driver.id },
|
||||
data: { totalCharge: driver.totalCharge },
|
||||
})
|
||||
),
|
||||
])
|
||||
} else {
|
||||
const invalidFields = requestedFields.filter((field) => !returnFields.includes(field))
|
||||
if (invalidFields.length > 0) {
|
||||
return res.status(400).json({ error: 'invalid_fields', message: 'Only return details can be edited after vehicle return', statusCode: 400 })
|
||||
}
|
||||
|
||||
await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: {
|
||||
returnLocation: body.returnLocation !== undefined ? normalizeOptionalString(body.returnLocation) : reservation.returnLocation,
|
||||
damageChargeAmount: body.damageChargeAmount !== undefined ? body.damageChargeAmount : reservation.damageChargeAmount,
|
||||
damageChargeNote: body.damageChargeNote !== undefined ? normalizeOptionalString(body.damageChargeNote) : reservation.damageChargeNote,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
|
||||
res.json({ data: serializeReservationForDashboard(updated) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
@@ -385,6 +629,7 @@ router.get('/:id/contract', async (req, res, next) => {
|
||||
res.json({
|
||||
data: {
|
||||
reservationId: reservation.id,
|
||||
contractLocale: reservation.company.brand?.defaultLocale ?? 'en',
|
||||
contractNumber: reservation.contractNumber,
|
||||
invoiceNumber: reservation.invoiceNumber,
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -571,6 +816,35 @@ router.post('/:id/checkout', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/close', async (req, res, next) => {
|
||||
try {
|
||||
const reservation = await prisma.reservation.findFirstOrThrow({
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
})
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
|
||||
if (reservation.status !== 'COMPLETED') {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'Only completed reservations can be closed', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (workflow.closed) {
|
||||
return res.status(400).json({ error: 'already_closed', message: 'This reservation is already closed', statusCode: 400 })
|
||||
}
|
||||
|
||||
const extras = parseReservationExtras(reservation.extras)
|
||||
extras.reservationClosedAt = new Date().toISOString()
|
||||
extras.reservationClosedBy = `${req.employee.firstName} ${req.employee.lastName}`
|
||||
|
||||
const updated = await prisma.reservation.update({
|
||||
where: { id: reservation.id },
|
||||
data: { extras: extras as any },
|
||||
include: { vehicle: true, customer: true, insurances: true, additionalDrivers: true, rentalPayments: true, damageReports: true },
|
||||
})
|
||||
|
||||
res.json({ data: serializeReservationForDashboard(updated) })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
router.post('/:id/cancel', async (req, res, next) => {
|
||||
try {
|
||||
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body)
|
||||
@@ -626,6 +900,19 @@ router.put('/:id/inspections/:type', async (req, res, next) => {
|
||||
where: { id: req.params.id, companyId: req.companyId },
|
||||
include: { vehicle: true, customer: true },
|
||||
})
|
||||
const workflow = buildReservationWorkflow(reservation)
|
||||
|
||||
if (workflow.closed) {
|
||||
return res.status(400).json({ error: 'reservation_closed', message: 'This reservation has already been closed', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (type === 'CHECKIN' && !workflow.checkInInspectionEditable) {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'Check-in inspection is not editable at this stage', statusCode: 400 })
|
||||
}
|
||||
|
||||
if (type === 'CHECKOUT' && !workflow.checkOutInspectionEditable) {
|
||||
return res.status(400).json({ error: 'invalid_status', message: 'Check-out inspection is only editable after the vehicle is returned', statusCode: 400 })
|
||||
}
|
||||
|
||||
const inspection = await prisma.damageInspection.upsert({
|
||||
where: { reservationId_type: { reservationId: reservation.id, type } },
|
||||
|
||||
Reference in New Issue
Block a user