1309 lines
49 KiB
Markdown
1309 lines
49 KiB
Markdown
---
|
||
name: rental-car-advanced-features
|
||
description: Build advanced rental operations features for RentalDriveGo. Use this skill when the user asks about: vehicle damage diagrams/inspection (contract car layout), insurance policies and charges, second driver (additional driver) with optional charge, driver license validation (expiry check, 3-month rule, flagging), age-based pricing rules (25+ discount, license+5 surcharge), gasoline/fuel policies (FULL_TO_FULL etc.), billing period reporting (weekly/monthly/yearly export for accountants), or the platform admin panel (managing tenants, staff, roles, permissions). This skill works alongside document-service.md and schema.md.
|
||
---
|
||
|
||
# Skill: RentalDriveGo Advanced Features
|
||
|
||
## Features Covered
|
||
|
||
1. Vehicle Damage Inspection Map (contract car diagram)
|
||
2. Insurance Policies & Per-Reservation Charges
|
||
3. Second / Additional Driver
|
||
4. Driver License Validation (expiry + 3-month rule + flagging)
|
||
5. Age-Based Pricing Rules (25+ discount, license+5 surcharge)
|
||
6. Fuel/Gasoline Policy (structured types)
|
||
7. Billing Period Reporting (accountant export)
|
||
8. Platform Admin Panel (tenant + staff + role management)
|
||
|
||
---
|
||
|
||
## 1. Vehicle Damage Inspection Map
|
||
|
||
### What it is
|
||
An interactive SVG car diagram embedded in the check-in/check-out workflow (dashboard + printable on contract). Staff marks pre-existing damage zones before handing over the vehicle. At return, the same diagram shows original vs new damage.
|
||
|
||
### Data model
|
||
|
||
```prisma
|
||
// Damage report: one per check-in, one per check-out
|
||
model DamageReport {
|
||
id String @id @default(cuid())
|
||
reservationId String
|
||
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
|
||
companyId String // denormalized for tenant scoping
|
||
type DamageReportType // CHECKIN | CHECKOUT
|
||
|
||
// Array of marked damage zones
|
||
// Each zone: { zone: string, severity: string, note: string }
|
||
// zone values: "hood", "roof", "trunk", "front-left-door", "front-right-door",
|
||
// "rear-left-door", "rear-right-door", "front-left-fender", "front-right-fender",
|
||
// "rear-left-fender", "rear-right-fender", "front-bumper", "rear-bumper",
|
||
// "windshield", "rear-window", "left-mirror", "right-mirror",
|
||
// "front-left-wheel", "front-right-wheel", "rear-left-wheel", "rear-right-wheel"
|
||
// severity: "SCRATCH" | "DENT" | "CRACK" | "MISSING" | "OTHER"
|
||
damages Json // DamageZone[]
|
||
|
||
// Photos of damage (Cloudinary URLs, optional)
|
||
photos String[]
|
||
|
||
// Fuel level at this inspection point
|
||
fuelLevel FuelLevel
|
||
|
||
// Odometer reading
|
||
mileage Int?
|
||
|
||
// Captured at
|
||
inspectedAt DateTime @default(now())
|
||
inspectedBy String? // Employee name who did the inspection
|
||
|
||
// Customer acknowledgement
|
||
customerSignedAt DateTime?
|
||
customerName String? // printed name of who acknowledged
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@unique([reservationId, type])
|
||
@@index([companyId])
|
||
@@map("damage_reports")
|
||
}
|
||
|
||
enum DamageReportType { CHECKIN CHECKOUT }
|
||
enum FuelLevel { FULL THREE_QUARTERS HALF QUARTER EMPTY }
|
||
```
|
||
|
||
Add to `Reservation` model:
|
||
```prisma
|
||
damageReports DamageReport[]
|
||
// Remove old checkInFuelLevel/checkOutFuelLevel String? — replaced by DamageReport.fuelLevel
|
||
```
|
||
|
||
### Damage zone TypeScript type
|
||
|
||
```typescript
|
||
// types/damage.ts
|
||
export interface DamageZone {
|
||
zone: VehicleZone
|
||
severity: 'SCRATCH' | 'DENT' | 'CRACK' | 'MISSING' | 'OTHER'
|
||
note?: string
|
||
}
|
||
|
||
export type VehicleZone =
|
||
| 'hood' | 'roof' | 'trunk'
|
||
| 'front-left-door' | 'front-right-door'
|
||
| 'rear-left-door' | 'rear-right-door'
|
||
| 'front-left-fender' | 'front-right-fender'
|
||
| 'rear-left-fender' | 'rear-right-fender'
|
||
| 'front-bumper' | 'rear-bumper'
|
||
| 'windshield' | 'rear-window'
|
||
| 'left-mirror' | 'right-mirror'
|
||
| 'front-left-wheel' | 'front-right-wheel'
|
||
| 'rear-left-wheel' | 'rear-right-wheel'
|
||
| 'interior' | 'under-hood' | 'undercarriage'
|
||
|
||
// Coordinates on the SVG diagram for each zone
|
||
// Used by the interactive damage map UI and the PDF render
|
||
export const DAMAGE_ZONE_COORDS: Record<VehicleZone, { x: number; y: number; w: number; h: number }> = {
|
||
'hood': { x: 48, y: 8, w: 64, h: 28 },
|
||
'roof': { x: 48, y: 52, w: 64, h: 56 },
|
||
'trunk': { x: 48, y: 124, w: 64, h: 28 },
|
||
'front-bumper': { x: 48, y: 0, w: 64, h: 10 },
|
||
'rear-bumper': { x: 48, y: 150, w: 64, h: 10 },
|
||
'front-left-fender': { x: 20, y: 10, w: 30, h: 35 },
|
||
'front-right-fender': { x: 110, y: 10, w: 30, h: 35 },
|
||
'front-left-door': { x: 20, y: 52, w: 30, h: 30 },
|
||
'front-right-door': { x: 110, y: 52, w: 30, h: 30 },
|
||
'rear-left-door': { x: 20, y: 82, w: 30, h: 30 },
|
||
'rear-right-door': { x: 110, y: 82, w: 30, h: 30 },
|
||
'rear-left-fender': { x: 20, y: 112, w: 30, h: 35 },
|
||
'rear-right-fender': { x: 110, y: 112, w: 30, h: 35 },
|
||
'windshield': { x: 52, y: 38, w: 56, h: 18 },
|
||
'rear-window': { x: 52, y: 104, w: 56, h: 18 },
|
||
'left-mirror': { x: 8, y: 50, w: 14, h: 10 },
|
||
'right-mirror': { x: 138, y: 50, w: 14, h: 10 },
|
||
'front-left-wheel': { x: 10, y: 24, w: 22, h: 26 },
|
||
'front-right-wheel': { x: 128, y: 24, w: 22, h: 26 },
|
||
'rear-left-wheel': { x: 10, y: 110, w: 22, h: 26 },
|
||
'rear-right-wheel': { x: 128, y: 110, w: 22, h: 26 },
|
||
'interior': { x: 55, y: 58, w: 50, h: 44 },
|
||
'under-hood': { x: 52, y: 10, w: 56, h: 20 },
|
||
'undercarriage': { x: 52, y: 130, w: 56, h: 20 },
|
||
}
|
||
|
||
// Severity colors for the SVG overlay
|
||
export const SEVERITY_COLORS = {
|
||
SCRATCH: '#F59E0B', // amber
|
||
DENT: '#EF4444', // red
|
||
CRACK: '#8B5CF6', // purple
|
||
MISSING: '#374151', // dark gray
|
||
OTHER: '#6B7280', // gray
|
||
}
|
||
```
|
||
|
||
### Interactive Damage Map UI (React Component)
|
||
|
||
```tsx
|
||
// components/reservations/DamageMap.tsx
|
||
import { useState } from 'react'
|
||
import { DAMAGE_ZONE_COORDS, SEVERITY_COLORS, VehicleZone, DamageZone } from '@/types/damage'
|
||
|
||
interface DamageMapProps {
|
||
damages: DamageZone[]
|
||
onChange?: (damages: DamageZone[]) => void // undefined = read-only
|
||
readonly?: boolean
|
||
primaryColor?: string
|
||
}
|
||
|
||
export function DamageMap({ damages, onChange, readonly, primaryColor = '#1A56DB' }: DamageMapProps) {
|
||
const [selected, setSelected] = useState<VehicleZone | null>(null)
|
||
const [severity, setSeverity] = useState<DamageZone['severity']>('SCRATCH')
|
||
const [note, setNote] = useState('')
|
||
|
||
const getDamageForZone = (zone: VehicleZone) =>
|
||
damages.find(d => d.zone === zone)
|
||
|
||
const handleZoneClick = (zone: VehicleZone) => {
|
||
if (readonly || !onChange) return
|
||
setSelected(zone)
|
||
const existing = getDamageForZone(zone)
|
||
if (existing) {
|
||
setSeverity(existing.severity)
|
||
setNote(existing.note ?? '')
|
||
} else {
|
||
setSeverity('SCRATCH')
|
||
setNote('')
|
||
}
|
||
}
|
||
|
||
const applyDamage = () => {
|
||
if (!selected || !onChange) return
|
||
const updated = damages.filter(d => d.zone !== selected)
|
||
updated.push({ zone: selected, severity, note })
|
||
onChange(updated)
|
||
setSelected(null)
|
||
}
|
||
|
||
const clearZone = (zone: VehicleZone) => {
|
||
if (!onChange) return
|
||
onChange(damages.filter(d => d.zone !== zone))
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col items-center gap-4">
|
||
{/* SVG Car Diagram — top-down view */}
|
||
<div className="relative">
|
||
<svg viewBox="0 0 160 165" width={280} height={290} className="select-none">
|
||
|
||
{/* Car body — top-down silhouette */}
|
||
<CarTopDownSVG primaryColor={primaryColor} />
|
||
|
||
{/* Damage zone hit areas — transparent clickable rects */}
|
||
{(Object.entries(DAMAGE_ZONE_COORDS) as [VehicleZone, any][]).map(([zone, coords]) => {
|
||
const damage = getDamageForZone(zone)
|
||
return (
|
||
<g key={zone} onClick={() => handleZoneClick(zone)} className={readonly ? '' : 'cursor-pointer'}>
|
||
<rect
|
||
x={coords.x} y={coords.y} width={coords.w} height={coords.h}
|
||
rx={3}
|
||
fill={damage ? SEVERITY_COLORS[damage.severity] : 'transparent'}
|
||
fillOpacity={damage ? 0.45 : 0}
|
||
stroke={selected === zone ? '#1A56DB' : damage ? SEVERITY_COLORS[damage.severity] : 'transparent'}
|
||
strokeWidth={selected === zone ? 2 : 1}
|
||
className={readonly ? '' : 'hover:fill-blue-500/20 hover:stroke-blue-500'}
|
||
/>
|
||
{/* Damage indicator dot */}
|
||
{damage && (
|
||
<circle
|
||
cx={coords.x + coords.w / 2}
|
||
cy={coords.y + coords.h / 2}
|
||
r={4}
|
||
fill={SEVERITY_COLORS[damage.severity]}
|
||
stroke="white"
|
||
strokeWidth={1}
|
||
/>
|
||
)}
|
||
</g>
|
||
)
|
||
})}
|
||
</svg>
|
||
|
||
{/* Direction labels */}
|
||
<div className="absolute top-1 left-1/2 -translate-x-1/2 text-[10px] text-gray-400 font-medium">FRONT</div>
|
||
<div className="absolute bottom-1 left-1/2 -translate-x-1/2 text-[10px] text-gray-400 font-medium">REAR</div>
|
||
</div>
|
||
|
||
{/* Severity legend */}
|
||
<div className="flex flex-wrap gap-2 justify-center">
|
||
{(Object.entries(SEVERITY_COLORS) as [DamageZone['severity'], string][]).map(([s, color]) => (
|
||
<span key={s} className="flex items-center gap-1 text-xs text-gray-600">
|
||
<span className="w-3 h-3 rounded-full inline-block" style={{ backgroundColor: color }} />
|
||
{s}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{/* Zone editor (appears when zone is clicked, in edit mode) */}
|
||
{selected && !readonly && (
|
||
<div className="w-full border border-blue-200 bg-blue-50 rounded-lg p-3">
|
||
<p className="text-sm font-medium text-blue-900 mb-2 capitalize">
|
||
{selected.replace(/-/g, ' ')}
|
||
</p>
|
||
<div className="flex gap-2 flex-wrap mb-2">
|
||
{(['SCRATCH', 'DENT', 'CRACK', 'MISSING', 'OTHER'] as const).map(s => (
|
||
<button key={s} onClick={() => setSeverity(s)}
|
||
className={`px-2 py-1 text-xs rounded font-medium border
|
||
${severity === s ? 'text-white border-transparent' : 'bg-white border-gray-200 text-gray-600'}`}
|
||
style={severity === s ? { backgroundColor: SEVERITY_COLORS[s], borderColor: SEVERITY_COLORS[s] } : {}}>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<input type="text" placeholder="Note (optional)" value={note}
|
||
onChange={e => setNote(e.target.value)}
|
||
className="w-full text-sm border border-gray-200 rounded px-2 py-1 mb-2" />
|
||
<div className="flex gap-2">
|
||
<button onClick={applyDamage}
|
||
className="btn-primary text-xs flex-1">Mark Damage</button>
|
||
<button onClick={() => { clearZone(selected); setSelected(null) }}
|
||
className="btn-ghost text-xs">Clear Zone</button>
|
||
<button onClick={() => setSelected(null)}
|
||
className="btn-ghost text-xs text-gray-400">Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Damage list */}
|
||
{damages.length > 0 && (
|
||
<div className="w-full">
|
||
<p className="text-xs font-medium text-gray-500 mb-1">Marked damages ({damages.length})</p>
|
||
<div className="space-y-1">
|
||
{damages.map(d => (
|
||
<div key={d.zone} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: SEVERITY_COLORS[d.severity] }} />
|
||
<span className="capitalize font-medium">{d.zone.replace(/-/g, ' ')}</span>
|
||
<span className="text-gray-400">— {d.severity}</span>
|
||
{d.note && <span className="text-gray-400 italic">"{d.note}"</span>}
|
||
</div>
|
||
{!readonly && (
|
||
<button onClick={() => clearZone(d.zone)} className="text-red-400 hover:text-red-600 ml-2">✕</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Simplified SVG path for top-down car silhouette
|
||
function CarTopDownSVG({ primaryColor }: { primaryColor: string }) {
|
||
return (
|
||
<g>
|
||
{/* Outer car body */}
|
||
<rect x={30} y={4} width={100} height={157} rx={18} fill="#F3F4F6" stroke="#D1D5DB" strokeWidth={1.5} />
|
||
{/* Hood section */}
|
||
<rect x={38} y={8} width={84} height={32} rx={8} fill="#E5E7EB" />
|
||
{/* Windshield */}
|
||
<rect x={44} y={38} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.8} />
|
||
{/* Roof / cabin */}
|
||
<rect x={36} y={54} width={88} height={58} rx={4} fill={primaryColor} fillOpacity={0.08} stroke={primaryColor} strokeWidth={0.5} strokeDasharray="3,2" />
|
||
{/* Rear window */}
|
||
<rect x={44} y={108} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.8} />
|
||
{/* Trunk */}
|
||
<rect x={38} y={124} width={84} height={30} rx={8} fill="#E5E7EB" />
|
||
{/* Rear bumper */}
|
||
<rect x={42} y={152} width={76} height={9} rx={4} fill="#D1D5DB" />
|
||
{/* Front bumper */}
|
||
<rect x={42} y={4} width={76} height={9} rx={4} fill="#D1D5DB" />
|
||
{/* Mirrors */}
|
||
<rect x={16} y={52} width={16} height={10} rx={3} fill="#E5E7EB" stroke="#D1D5DB" strokeWidth={1} />
|
||
<rect x={128} y={52} width={16} height={10} rx={3} fill="#E5E7EB" stroke="#D1D5DB" strokeWidth={1} />
|
||
{/* Wheels */}
|
||
<rect x={10} y={24} width={22} height={28} rx={5} fill="#374151" />
|
||
<rect x={128} y={24} width={22} height={28} rx={5} fill="#374151" />
|
||
<rect x={10} y={110} width={22} height={28} rx={5} fill="#374151" />
|
||
<rect x={128} y={110} width={22} height={28} rx={5} fill="#374151" />
|
||
{/* Center line */}
|
||
<line x1={80} y1={54} x2={80} y2={112} stroke="#E5E7EB" strokeWidth={1} strokeDasharray="4,3" />
|
||
</g>
|
||
)
|
||
}
|
||
```
|
||
|
||
### Damage Map in the PDF Contract
|
||
|
||
In `RentalContractPDF.tsx`, add a damage section after the vehicle block using `@react-pdf/renderer` SVG primitives (the interactive React SVG won't work in the PDF renderer — use static `Svg`, `Rect`, `Circle`, `Line` elements from `@react-pdf/renderer`):
|
||
|
||
```tsx
|
||
// In RentalContractPDF.tsx — add after vehicle section
|
||
import { Svg, Rect, Circle, Line, Path, G, Text as SvgText } from '@react-pdf/renderer'
|
||
|
||
function DamageMapPDF({ damages, label }: { damages: DamageZone[]; label: string }) {
|
||
return (
|
||
<View>
|
||
<Text style={styles.sectionLabel}>{label}</Text>
|
||
<View style={{ flexDirection: 'row', gap: 20 }}>
|
||
{/* SVG car diagram — static top-down view */}
|
||
<Svg viewBox="0 0 160 165" width={100} height={105}>
|
||
{/* Repeat the CarTopDownSVG shapes using Svg primitives */}
|
||
<Rect x={30} y={4} width={100} height={157} rx={18} fill="#F3F4F6" stroke="#D1D5DB" strokeWidth={1.5} />
|
||
<Rect x={38} y={8} width={84} height={32} rx={8} fill="#E5E7EB" />
|
||
<Rect x={44} y={38} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.6} />
|
||
<Rect x={38} y={54} width={84} height={58} rx={4} fill="#F9FAFB" stroke="#E5E7EB" strokeWidth={0.5} />
|
||
<Rect x={44} y={108} width={72} height={18} rx={4} fill="#BFDBFE" fillOpacity={0.6} />
|
||
<Rect x={38} y={124} width={84} height={30} rx={8} fill="#E5E7EB" />
|
||
<Rect x={10} y={24} width={22} height={28} rx={5} fill="#374151" />
|
||
<Rect x={128} y={24} width={22} height={28} rx={5} fill="#374151" />
|
||
<Rect x={10} y={110} width={22} height={28} rx={5} fill="#374151" />
|
||
<Rect x={128} y={110} width={22} height={28} rx={5} fill="#374151" />
|
||
{/* Damage markers */}
|
||
{damages.map((d, i) => {
|
||
const coords = DAMAGE_ZONE_COORDS[d.zone]
|
||
return (
|
||
<G key={i}>
|
||
<Rect x={coords.x} y={coords.y} width={coords.w} height={coords.h}
|
||
fill={SEVERITY_COLORS[d.severity]} fillOpacity={0.35}
|
||
stroke={SEVERITY_COLORS[d.severity]} strokeWidth={0.8} />
|
||
<Circle cx={coords.x + coords.w / 2} cy={coords.y + coords.h / 2}
|
||
r={3.5} fill={SEVERITY_COLORS[d.severity]} stroke="white" strokeWidth={0.7} />
|
||
</G>
|
||
)
|
||
})}
|
||
</Svg>
|
||
|
||
{/* Damage list */}
|
||
<View style={{ flex: 1 }}>
|
||
{damages.length === 0
|
||
? <Text style={{ fontSize: 8, color: '#10B981' }}>✓ No damage recorded</Text>
|
||
: damages.map((d, i) => (
|
||
<View key={i} style={{ flexDirection: 'row', marginBottom: 2.5 }}>
|
||
<View style={{
|
||
width: 7, height: 7, borderRadius: 3.5, marginRight: 4, marginTop: 1,
|
||
backgroundColor: SEVERITY_COLORS[d.severity]
|
||
}} />
|
||
<Text style={{ fontSize: 7.5, flex: 1, color: '#374151' }}>
|
||
<Text style={{ fontWeight: 700 }}>{d.zone.replace(/-/g, ' ')}</Text>
|
||
{` — ${d.severity}${d.note ? ` (${d.note})` : ''}`}
|
||
</Text>
|
||
</View>
|
||
))
|
||
}
|
||
</View>
|
||
</View>
|
||
</View>
|
||
)
|
||
}
|
||
|
||
// Usage in the contract — two damage sections:
|
||
// 1. At check-in: <DamageMapPDF damages={checkInReport?.damages ?? []} label="Vehicle Condition at Pickup" />
|
||
// 2. At checkout: <DamageMapPDF damages={checkOutReport?.damages ?? []} label="Vehicle Condition at Return" />
|
||
// Both are shown on the contract, side by side if space, or stacked.
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Insurance Policies
|
||
|
||
### Data model
|
||
|
||
```prisma
|
||
// ─── Insurance Policy ─────────────────────────────────────────
|
||
// Company configures insurance options available to renters.
|
||
// At booking, renter selects one (or NONE if all are optional).
|
||
|
||
model InsurancePolicy {
|
||
id String @id @default(cuid())
|
||
companyId String
|
||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||
|
||
name String // e.g. "Basic Coverage", "Full Coverage", "CDW", "SCDW"
|
||
description String? // what it covers (shown to customer at booking)
|
||
type InsuranceType
|
||
|
||
// Pricing — one of the following applies:
|
||
chargeType InsuranceChargeType
|
||
chargeValue Int // in smallest currency unit
|
||
// Examples:
|
||
// chargeType=PER_DAY, chargeValue=3000 → 30 MAD/day
|
||
// chargeType=PER_RENTAL, chargeValue=15000 → 150 MAD flat
|
||
// chargeType=PERCENTAGE_OF_RENTAL, chargeValue=10 → 10% of base rental
|
||
|
||
isRequired Boolean @default(false) // if true, auto-applied, customer cannot opt out
|
||
isActive Boolean @default(true)
|
||
sortOrder Int @default(0) // display order at booking
|
||
|
||
reservations ReservationInsurance[]
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([companyId])
|
||
@@index([companyId, isActive])
|
||
@@map("insurance_policies")
|
||
}
|
||
|
||
enum InsuranceType {
|
||
CDW // Collision Damage Waiver
|
||
SCDW // Super CDW (lower excess)
|
||
THEFT // Theft protection
|
||
THIRD_PARTY // Third-party liability
|
||
FULL // Full coverage bundle
|
||
BASIC // Basic minimal coverage
|
||
ROADSIDE // Roadside assistance
|
||
PERSONAL // Personal accident insurance
|
||
CUSTOM // Company-defined name
|
||
}
|
||
|
||
enum InsuranceChargeType {
|
||
PER_DAY
|
||
PER_RENTAL // flat fee per reservation
|
||
PERCENTAGE_OF_RENTAL // % of base rental amount
|
||
}
|
||
|
||
// ─── Reservation Insurance ────────────────────────────────────
|
||
// Junction: which insurance(s) were selected for a reservation
|
||
|
||
model ReservationInsurance {
|
||
id String @id @default(cuid())
|
||
reservationId String
|
||
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
|
||
insurancePolicyId String
|
||
insurancePolicy InsurancePolicy @relation(fields: [insurancePolicyId], references: [id])
|
||
|
||
// Snapshot of policy at time of booking (prices may change later)
|
||
policyName String
|
||
policyType InsuranceType
|
||
chargeType InsuranceChargeType
|
||
chargeValue Int // locked at booking time
|
||
totalCharge Int // calculated total for this reservation (in smallest unit)
|
||
|
||
@@unique([reservationId, insurancePolicyId])
|
||
@@map("reservation_insurances")
|
||
}
|
||
```
|
||
|
||
Add to `Reservation` model:
|
||
```prisma
|
||
insurances ReservationInsurance[]
|
||
insuranceTotal Int @default(0) // sum of all insurance charges (cached)
|
||
```
|
||
|
||
### Insurance charge calculation
|
||
|
||
```typescript
|
||
// services/insuranceService.ts
|
||
|
||
export function calculateInsuranceCharge(
|
||
policy: InsurancePolicy,
|
||
totalDays: number,
|
||
baseRentalAmount: number // dailyRate × totalDays, in smallest unit
|
||
): number {
|
||
switch (policy.chargeType) {
|
||
case 'PER_DAY':
|
||
return policy.chargeValue * totalDays
|
||
case 'PER_RENTAL':
|
||
return policy.chargeValue
|
||
case 'PERCENTAGE_OF_RENTAL':
|
||
return Math.round(baseRentalAmount * policy.chargeValue / 100)
|
||
default:
|
||
return 0
|
||
}
|
||
}
|
||
|
||
export async function applyInsurancesToReservation(
|
||
reservationId: string,
|
||
companyId: string,
|
||
selectedPolicyIds: string[],
|
||
totalDays: number,
|
||
baseRentalAmount: number
|
||
) {
|
||
// Always include required policies
|
||
const allPolicies = await prisma.insurancePolicy.findMany({
|
||
where: { companyId, isActive: true }
|
||
})
|
||
|
||
const required = allPolicies.filter(p => p.isRequired)
|
||
const selected = allPolicies.filter(p => selectedPolicyIds.includes(p.id) && !p.isRequired)
|
||
const toApply = [...required, ...selected]
|
||
|
||
const records = toApply.map(policy => ({
|
||
reservationId,
|
||
insurancePolicyId: policy.id,
|
||
policyName: policy.name,
|
||
policyType: policy.type,
|
||
chargeType: policy.chargeType,
|
||
chargeValue: policy.chargeValue,
|
||
totalCharge: calculateInsuranceCharge(policy, totalDays, baseRentalAmount)
|
||
}))
|
||
|
||
const insuranceTotal = records.reduce((sum, r) => sum + r.totalCharge, 0)
|
||
|
||
await prisma.$transaction([
|
||
prisma.reservationInsurance.createMany({ data: records }),
|
||
prisma.reservation.update({
|
||
where: { id: reservationId },
|
||
data: { insuranceTotal }
|
||
})
|
||
])
|
||
|
||
return { records, insuranceTotal }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Second / Additional Driver
|
||
|
||
### Data model
|
||
|
||
```prisma
|
||
model AdditionalDriver {
|
||
id String @id @default(cuid())
|
||
reservationId String
|
||
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
|
||
companyId String // tenant scoping
|
||
|
||
firstName String
|
||
lastName String
|
||
email String?
|
||
phone String?
|
||
driverLicense String
|
||
licenseExpiry Date?
|
||
dateOfBirth DateTime?
|
||
nationality String?
|
||
|
||
// Charge for adding this driver
|
||
chargeType AdditionalDriverCharge @default(FREE)
|
||
chargeValue Int @default(0) // flat or per-day in smallest unit
|
||
totalCharge Int @default(0)
|
||
|
||
// Validation flags
|
||
licenseExpired Boolean @default(false)
|
||
licenseExpiringSoon Boolean @default(false) // < 3 months validity remaining
|
||
requiresApproval Boolean @default(false) // flagged for manual approval
|
||
approvedBy String? // Employee who approved a flagged license
|
||
approvedAt DateTime?
|
||
approvalNote String?
|
||
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([reservationId])
|
||
@@index([companyId])
|
||
@@map("additional_drivers")
|
||
}
|
||
|
||
enum AdditionalDriverCharge {
|
||
FREE
|
||
PER_DAY // charge per rental day
|
||
FLAT // one-time flat charge per reservation
|
||
}
|
||
```
|
||
|
||
Add to company `ContractSettings`:
|
||
```prisma
|
||
// Additional driver pricing (company sets these)
|
||
additionalDriverCharge AdditionalDriverCharge @default(FREE)
|
||
additionalDriverDailyRate Int @default(0)
|
||
additionalDriverFlatRate Int @default(0)
|
||
```
|
||
|
||
Add to `Reservation`:
|
||
```prisma
|
||
additionalDrivers AdditionalDriver[]
|
||
additionalDriverTotal Int @default(0)
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Driver License Validation
|
||
|
||
### Fields on Customer model (additions)
|
||
|
||
```prisma
|
||
// Add to model Customer { ... }
|
||
licenseExpiry DateTime? // expiry date of driver's license
|
||
licenseIssuedAt DateTime? // issue date
|
||
licenseCountry String? // issuing country
|
||
licenseNumber String? // license number (may differ from driverLicense ID)
|
||
licenseCategory String? // e.g. "B", "B+E", "A" (for motorcycle)
|
||
|
||
// Validation flags
|
||
licenseExpired Boolean @default(false)
|
||
licenseExpiringSoon Boolean @default(false) // < 3 months validity
|
||
licenseValidationStatus LicenseStatus @default(PENDING)
|
||
licenseApprovedBy String? // Employee who approved/denied
|
||
licenseApprovedAt DateTime?
|
||
licenseApprovalNote String?
|
||
```
|
||
|
||
```prisma
|
||
enum LicenseStatus {
|
||
PENDING // not yet checked
|
||
VALID // checked and valid (>3 months remaining)
|
||
EXPIRING // flagged: <3 months remaining — awaiting employee decision
|
||
APPROVED // expired/expiring but manually approved by employee
|
||
DENIED // employee denied the booking due to license
|
||
EXPIRED // license has already expired
|
||
}
|
||
```
|
||
|
||
### License validation service
|
||
|
||
```typescript
|
||
// services/licenseValidationService.ts
|
||
|
||
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
|
||
|
||
export interface LicenseValidationResult {
|
||
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
|
||
daysUntilExpiry: number | null
|
||
requiresApproval: boolean
|
||
message: string
|
||
}
|
||
|
||
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
|
||
if (!licenseExpiry) {
|
||
return {
|
||
status: 'VALID', // no expiry stored — cannot determine
|
||
daysUntilExpiry: null,
|
||
requiresApproval: false,
|
||
message: 'No expiry date on record'
|
||
}
|
||
}
|
||
|
||
const now = new Date()
|
||
const expiryMs = licenseExpiry.getTime()
|
||
const daysUntilExpiry = Math.ceil((expiryMs - now.getTime()) / (1000 * 60 * 60 * 24))
|
||
|
||
if (expiryMs <= now.getTime()) {
|
||
return {
|
||
status: 'EXPIRED',
|
||
daysUntilExpiry: daysUntilExpiry, // negative number
|
||
requiresApproval: true,
|
||
message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago`
|
||
}
|
||
}
|
||
|
||
if (expiryMs - now.getTime() < THREE_MONTHS_MS) {
|
||
return {
|
||
status: 'EXPIRING',
|
||
daysUntilExpiry,
|
||
requiresApproval: true,
|
||
message: `License expires in ${daysUntilExpiry} day(s) — approval required`
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: 'VALID',
|
||
daysUntilExpiry,
|
||
requiresApproval: false,
|
||
message: `Valid — expires in ${daysUntilExpiry} day(s)`
|
||
}
|
||
}
|
||
|
||
// Called when creating/updating a customer or adding an additional driver
|
||
export async function validateAndFlagLicense(customerId: string) {
|
||
const customer = await prisma.customer.findUniqueOrThrow({
|
||
where: { id: customerId }
|
||
})
|
||
|
||
const result = validateLicense(customer.licenseExpiry)
|
||
|
||
const status: LicenseStatus =
|
||
result.status === 'EXPIRED' ? 'EXPIRED' :
|
||
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID'
|
||
|
||
await prisma.customer.update({
|
||
where: { id: customerId },
|
||
data: {
|
||
licenseExpired: result.status === 'EXPIRED',
|
||
licenseExpiringSoon: result.status === 'EXPIRING',
|
||
licenseValidationStatus: status,
|
||
}
|
||
})
|
||
|
||
return result
|
||
}
|
||
```
|
||
|
||
### License approval UI (dashboard)
|
||
|
||
When a reservation is created for a customer with `licenseValidationStatus: EXPIRING | EXPIRED`:
|
||
- Show a yellow (EXPIRING) or red (EXPIRED) banner in the reservation detail page
|
||
- Banner text: "⚠️ Customer's license expires in X days — please verify before confirming"
|
||
- Two action buttons: **[Approve and Continue]** / **[Deny Reservation]**
|
||
- Clicking Approve: `PATCH /customers/:id/license-approval { decision: 'APPROVE', note: '...' }`
|
||
- Clicking Deny: opens cancel reservation flow
|
||
|
||
---
|
||
|
||
## 5. Age-Based Pricing Rules
|
||
|
||
### Data model
|
||
|
||
```prisma
|
||
model PricingRule {
|
||
id String @id @default(cuid())
|
||
companyId String
|
||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||
|
||
name String // e.g. "Young Driver Surcharge", "Senior Discount"
|
||
type PricingRuleType
|
||
condition PricingCondition
|
||
conditionValue Int // e.g. 25 for "age < 25", 70 for "age > 70"
|
||
|
||
adjustmentType PriceAdjustmentType
|
||
adjustmentValue Int // percentage or flat in smallest unit
|
||
// For LICENSE_YEARS condition: conditionValue = minimum years since license issue date
|
||
|
||
isActive Boolean @default(true)
|
||
description String? // shown to customer at booking
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@index([companyId])
|
||
@@map("pricing_rules")
|
||
}
|
||
|
||
enum PricingRuleType {
|
||
SURCHARGE // adds cost
|
||
DISCOUNT // reduces cost
|
||
}
|
||
|
||
enum PricingCondition {
|
||
AGE_LESS_THAN // customer age (years) < conditionValue
|
||
AGE_GREATER_THAN // customer age > conditionValue
|
||
LICENSE_YEARS_LESS_THAN // years since license issue < conditionValue
|
||
LICENSE_YEARS_GREATER_THAN
|
||
}
|
||
|
||
enum PriceAdjustmentType {
|
||
PERCENTAGE // % of base daily rate
|
||
FLAT_PER_DAY // flat amount per day in smallest unit
|
||
FLAT_TOTAL // flat amount per reservation
|
||
}
|
||
```
|
||
|
||
Add to `Reservation`:
|
||
```prisma
|
||
pricingRulesApplied Json? // Array of { ruleId, name, type, amount } — snapshot at booking
|
||
pricingRulesTotal Int @default(0) // net surcharges minus discounts
|
||
```
|
||
|
||
### Built-in rules (pre-configured defaults companies can edit)
|
||
|
||
```typescript
|
||
// The system ships with these as suggested defaults (companies can modify or delete):
|
||
const DEFAULT_PRICING_RULES = [
|
||
{
|
||
name: 'Young Driver Surcharge (Under 25)',
|
||
type: 'SURCHARGE',
|
||
condition: 'AGE_LESS_THAN',
|
||
conditionValue: 25,
|
||
adjustmentType: 'PERCENTAGE',
|
||
adjustmentValue: 15, // +15% of base rate
|
||
description: 'Additional charge for drivers under 25 years of age'
|
||
},
|
||
{
|
||
name: 'Experienced License Discount (5+ Years)',
|
||
type: 'DISCOUNT',
|
||
condition: 'LICENSE_YEARS_GREATER_THAN',
|
||
conditionValue: 5,
|
||
adjustmentType: 'PERCENTAGE',
|
||
adjustmentValue: 5, // -5% of base rate
|
||
description: 'Discount for drivers with 5+ years of driving experience'
|
||
},
|
||
]
|
||
```
|
||
|
||
### Rule application service
|
||
|
||
```typescript
|
||
// services/pricingRuleService.ts
|
||
|
||
export async function applyPricingRules(
|
||
companyId: string,
|
||
customerId: string,
|
||
additionalDrivers: { dateOfBirth?: Date | null; licenseIssuedAt?: Date | null }[],
|
||
dailyRate: number,
|
||
totalDays: number
|
||
): Promise<{ applied: any[]; total: number }> {
|
||
|
||
const rules = await prisma.pricingRule.findMany({
|
||
where: { companyId, isActive: true }
|
||
})
|
||
|
||
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
|
||
const allDrivers = [customer, ...additionalDrivers]
|
||
|
||
const applied: any[] = []
|
||
const baseAmount = dailyRate * totalDays
|
||
|
||
for (const driver of allDrivers) {
|
||
for (const rule of rules) {
|
||
const driverAge = driver.dateOfBirth
|
||
? Math.floor((Date.now() - driver.dateOfBirth.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
||
: null
|
||
|
||
const licenseYears = (driver as any).licenseIssuedAt
|
||
? Math.floor((Date.now() - (driver as any).licenseIssuedAt.getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
||
: null
|
||
|
||
let conditionMet = false
|
||
if (rule.condition === 'AGE_LESS_THAN' && driverAge !== null)
|
||
conditionMet = driverAge < rule.conditionValue
|
||
else if (rule.condition === 'AGE_GREATER_THAN' && driverAge !== null)
|
||
conditionMet = driverAge > rule.conditionValue
|
||
else if (rule.condition === 'LICENSE_YEARS_LESS_THAN' && licenseYears !== null)
|
||
conditionMet = licenseYears < rule.conditionValue
|
||
else if (rule.condition === 'LICENSE_YEARS_GREATER_THAN' && licenseYears !== null)
|
||
conditionMet = licenseYears > rule.conditionValue
|
||
|
||
if (!conditionMet) continue
|
||
|
||
let amount = 0
|
||
if (rule.adjustmentType === 'PERCENTAGE')
|
||
amount = Math.round(baseAmount * rule.adjustmentValue / 100)
|
||
else if (rule.adjustmentType === 'FLAT_PER_DAY')
|
||
amount = rule.adjustmentValue * totalDays
|
||
else
|
||
amount = rule.adjustmentValue
|
||
|
||
if (rule.type === 'DISCOUNT') amount = -amount
|
||
|
||
applied.push({ ruleId: rule.id, name: rule.name, type: rule.type, amount })
|
||
}
|
||
}
|
||
|
||
// Deduplicate: each rule type applied once even if multiple drivers trigger it
|
||
const deduped = applied.reduce((acc: any[], item) => {
|
||
if (!acc.find(a => a.ruleId === item.ruleId)) acc.push(item)
|
||
return acc
|
||
}, [])
|
||
|
||
const total = deduped.reduce((sum, r) => sum + r.amount, 0)
|
||
return { applied: deduped, total }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Fuel / Gasoline Policy (Structured)
|
||
|
||
Replace the free-text `fuelPolicy` string in `ContractSettings` with a structured enum.
|
||
|
||
```prisma
|
||
// In ContractSettings — replace fuelPolicy String with:
|
||
fuelPolicyType FuelPolicyType @default(FULL_TO_FULL)
|
||
fuelPolicyNote String? // optional extra note
|
||
prepaidFuelPrice Int? // price per liter in smallest unit (for PREPAID type)
|
||
```
|
||
|
||
```prisma
|
||
enum FuelPolicyType {
|
||
FULL_TO_FULL // Customer receives full, must return full. Extra charges if returned low.
|
||
FULL_TO_EMPTY // Customer receives full, keeps all fuel (no refund for unused fuel).
|
||
SAME_TO_SAME // Returns vehicle with same level as received.
|
||
PREPAID // Customer pays for a full tank upfront, returns at any level.
|
||
FREE // Fuel included in rental price.
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// lib/fuelPolicyLabels.ts
|
||
export const FUEL_POLICY_LABELS: Record<FuelPolicyType, Record<'en'|'fr'|'ar', string>> = {
|
||
FULL_TO_FULL: {
|
||
en: 'Full-to-Full: Return vehicle with a full tank. A refueling fee applies if returned with less fuel.',
|
||
fr: 'Plein-à-Plein: Retournez le véhicule avec le plein. Des frais de carburant s\'appliquent sinon.',
|
||
ar: 'ممتلئ إلى ممتلئ: يجب إعادة السيارة بخزان ممتلئ. رسوم إضافية في حال الإعادة بمستوى أقل.'
|
||
},
|
||
FULL_TO_EMPTY: {
|
||
en: 'Full-to-Empty: Vehicle is provided with a full tank. No refund for unused fuel upon return.',
|
||
fr: 'Plein-à-Vide: Le véhicule est fourni avec un plein. Aucun remboursement pour carburant non utilisé.',
|
||
ar: 'ممتلئ إلى فارغ: تُسلَّم السيارة بخزان ممتلئ. لا استرداد للوقود غير المستخدم.'
|
||
},
|
||
SAME_TO_SAME: {
|
||
en: 'Same-to-Same: Return vehicle with the same fuel level as at pickup.',
|
||
fr: 'Même niveau: Retournez avec le même niveau de carburant qu\'au départ.',
|
||
ar: 'نفس المستوى: أعد السيارة بنفس مستوى الوقود عند الاستلام.'
|
||
},
|
||
PREPAID: {
|
||
en: 'Prepaid Fuel: You pre-purchase a full tank at a fixed rate. Return at any fuel level.',
|
||
fr: 'Carburant prépayé: Vous prépayez un plein à tarif fixe. Retour à n\'importe quel niveau.',
|
||
ar: 'الوقود المدفوع مسبقاً: تدفع مقدماً لخزان كامل بسعر ثابت. العودة بأي مستوى.'
|
||
},
|
||
FREE: {
|
||
en: 'Fuel Included: Fuel cost is included in the rental price.',
|
||
fr: 'Carburant inclus: Le coût du carburant est inclus dans le tarif de location.',
|
||
ar: 'الوقود مشمول: تكلفة الوقود مشمولة في سعر الإيجار.'
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 7. Billing Period Reporting (Accountant Export)
|
||
|
||
Companies need to export their financial data by week, month, or year.
|
||
|
||
### Report model (optional — or generate entirely on-demand)
|
||
|
||
```prisma
|
||
// No model needed — reports are generated on-demand from Reservation + RentalPayment data
|
||
// The API query uses date range filters
|
||
```
|
||
|
||
### Report API
|
||
|
||
```typescript
|
||
// GET /api/v1/reports/financial
|
||
// Query params: period=WEEK|MONTH|YEAR, startDate, endDate, format=JSON|CSV
|
||
|
||
export async function generateFinancialReport(
|
||
companyId: string,
|
||
startDate: Date,
|
||
endDate: Date
|
||
) {
|
||
const reservations = await prisma.reservation.findMany({
|
||
where: {
|
||
companyId,
|
||
status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] },
|
||
startDate: { gte: startDate },
|
||
endDate: { lte: endDate }
|
||
},
|
||
include: {
|
||
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
|
||
customer: { select: { firstName: true, lastName: true, email: true } },
|
||
rentalPayments: { where: { status: 'SUCCEEDED' } },
|
||
insurances: true,
|
||
additionalDrivers: true,
|
||
},
|
||
orderBy: { startDate: 'asc' }
|
||
})
|
||
|
||
// Aggregate totals
|
||
const summary = {
|
||
totalReservations: reservations.length,
|
||
totalRentalRevenue: reservations.reduce((s, r) => s + r.dailyRate * r.totalDays, 0),
|
||
totalDiscounts: reservations.reduce((s, r) => s + r.discountAmount, 0),
|
||
totalInsurance: reservations.reduce((s, r) => s + r.insuranceTotal, 0),
|
||
totalAdditionalDrivers: reservations.reduce((s, r) => s + r.additionalDriverTotal, 0),
|
||
totalPricingRulesAdjustments: reservations.reduce((s, r) => s + r.pricingRulesTotal, 0),
|
||
totalDeposits: reservations.reduce((s, r) => s + r.depositAmount, 0),
|
||
totalCollected: reservations.reduce((s, r) => s + r.totalAmount, 0),
|
||
averageRentalDays: reservations.length > 0
|
||
? reservations.reduce((s, r) => s + r.totalDays, 0) / reservations.length : 0,
|
||
}
|
||
|
||
// Per-reservation rows for the table / CSV
|
||
const rows = reservations.map(r => ({
|
||
reservationId: r.id,
|
||
contractNumber: r.contractNumber ?? '—',
|
||
invoiceNumber: r.invoiceNumber ?? '—',
|
||
customerName: `${r.customer.firstName} ${r.customer.lastName}`,
|
||
vehicle: `${r.vehicle.year} ${r.vehicle.make} ${r.vehicle.model}`,
|
||
plate: r.vehicle.licensePlate,
|
||
startDate: r.startDate.toISOString().split('T')[0],
|
||
endDate: r.endDate.toISOString().split('T')[0],
|
||
days: r.totalDays,
|
||
dailyRate: r.dailyRate,
|
||
baseAmount: r.dailyRate * r.totalDays,
|
||
discount: r.discountAmount,
|
||
insurance: r.insuranceTotal,
|
||
additionalDriver: r.additionalDriverTotal,
|
||
pricingAdj: r.pricingRulesTotal,
|
||
deposit: r.depositAmount,
|
||
totalAmount: r.totalAmount,
|
||
paymentStatus: r.rentalPayments[0]?.status ?? 'UNPAID',
|
||
paymentMethod: r.rentalPayments[0]?.paymentMethod ?? '—',
|
||
source: r.source,
|
||
}))
|
||
|
||
return { summary, rows, period: { startDate, endDate } }
|
||
}
|
||
```
|
||
|
||
### CSV export
|
||
|
||
```typescript
|
||
// lib/csvExport.ts
|
||
export function toCsv(rows: Record<string, any>[]): string {
|
||
if (rows.length === 0) return ''
|
||
const headers = Object.keys(rows[0])
|
||
const lines = [
|
||
headers.join(','),
|
||
...rows.map(row =>
|
||
headers.map(h => {
|
||
const val = row[h] ?? ''
|
||
return typeof val === 'string' && val.includes(',') ? `"${val}"` : String(val)
|
||
}).join(',')
|
||
)
|
||
]
|
||
return lines.join('\n')
|
||
}
|
||
```
|
||
|
||
### Dashboard: Reports Page
|
||
|
||
Located at `/dashboard/reports` (new page).
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────┐
|
||
│ Financial Reports [Export CSV] │
|
||
│ │
|
||
│ Period: [This Week ▼] [This Month ▼] [This Year ▼] │
|
||
│ Or custom: [From date] → [To date] [Apply] │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ Summary Cards (6 cards in a row) │ │
|
||
│ │ Total Bookings · Revenue · Discounts · Insurance│ │
|
||
│ │ Additional Drivers · Net Collected │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ Reservations Table (all in period) │
|
||
│ Contract# · Invoice# · Customer · Vehicle · Dates │
|
||
│ Days · Base · Discount · Insurance · AddDriver │
|
||
│ PricingAdj · Total · Payment Status · Source │
|
||
└─────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Platform Admin Panel
|
||
|
||
The platform administrator (RentalDriveGo team) manages all companies and their staff.
|
||
|
||
### Admin model
|
||
|
||
```prisma
|
||
model AdminUser {
|
||
id String @id @default(cuid())
|
||
email String @unique
|
||
firstName String
|
||
lastName String
|
||
passwordHash String
|
||
role AdminRole @default(SUPPORT)
|
||
isActive Boolean @default(true)
|
||
lastLoginAt DateTime?
|
||
|
||
auditLogs AdminAuditLog[]
|
||
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
@@map("admin_users")
|
||
}
|
||
|
||
enum AdminRole {
|
||
SUPER_ADMIN // full access: create/edit/delete companies, manage other admins
|
||
ADMIN // create/edit companies, manage staff, cannot delete or manage other admins
|
||
SUPPORT // read-only access to companies + reservations, can impersonate
|
||
}
|
||
|
||
model AdminAuditLog {
|
||
id String @id @default(cuid())
|
||
adminId String
|
||
admin AdminUser @relation(fields: [adminId], references: [id])
|
||
action String // e.g. "CREATE_COMPANY", "SUSPEND_COMPANY", "EDIT_EMPLOYEE"
|
||
targetType String // "Company" | "Employee" | "Reservation"
|
||
targetId String
|
||
before Json? // state before change
|
||
after Json? // state after change
|
||
ip String?
|
||
createdAt DateTime @default(now())
|
||
|
||
@@index([adminId])
|
||
@@index([targetType, targetId])
|
||
@@map("admin_audit_logs")
|
||
}
|
||
```
|
||
|
||
### Admin API routes
|
||
|
||
```
|
||
Base: https://admin.RentalDriveGo.com/api/v1/ (separate app)
|
||
OR: https://api.RentalDriveGo.com/api/v1/admin/ (preferred — same API, admin middleware)
|
||
|
||
Middleware: requireAdminAuth → attaches req.admin (AdminUser)
|
||
```
|
||
|
||
| Method | Path | Role | Description |
|
||
|--------|------|------|-------------|
|
||
| POST | `/admin/auth/login` | — | Admin login (email + password) |
|
||
| GET | `/admin/auth/me` | Any admin | Current admin profile |
|
||
| **Companies** | | | |
|
||
| GET | `/admin/companies` | Any | List all companies + search/filter |
|
||
| POST | `/admin/companies` | ADMIN+ | Create company (name, slug, email, plan) |
|
||
| GET | `/admin/companies/:id` | Any | Full company detail |
|
||
| PATCH | `/admin/companies/:id` | ADMIN+ | Edit company profile |
|
||
| PATCH | `/admin/companies/:id/status` | ADMIN+ | Change status (ACTIVE/SUSPENDED) |
|
||
| PATCH | `/admin/companies/:id/plan` | ADMIN+ | Change plan without payment |
|
||
| DELETE | `/admin/companies/:id` | SUPER_ADMIN | Hard delete company (irreversible) |
|
||
| POST | `/admin/companies/:id/impersonate` | ADMIN+ | Generate impersonation token |
|
||
| **Staff** | | | |
|
||
| GET | `/admin/companies/:id/employees` | Any | List employees |
|
||
| POST | `/admin/companies/:id/employees` | ADMIN+ | Add employee (name, email, role) |
|
||
| PATCH | `/admin/companies/:id/employees/:eid` | ADMIN+ | Edit employee |
|
||
| PATCH | `/admin/companies/:id/employees/:eid/role` | ADMIN+ | Change role |
|
||
| DELETE | `/admin/companies/:id/employees/:eid` | ADMIN+ | Deactivate employee |
|
||
| POST | `/admin/companies/:id/employees/:eid/reset-password` | ADMIN+ | Trigger password reset email |
|
||
| **Platform** | | | |
|
||
| GET | `/admin/metrics` | Any | MRR, ARR, churn, signups, active |
|
||
| GET | `/admin/admins` | SUPER_ADMIN | List admin users |
|
||
| POST | `/admin/admins` | SUPER_ADMIN | Create admin user |
|
||
| PATCH | `/admin/admins/:id` | SUPER_ADMIN | Edit admin |
|
||
| PATCH | `/admin/admins/:id/role` | SUPER_ADMIN | Change admin role |
|
||
| DELETE | `/admin/admins/:id` | SUPER_ADMIN | Deactivate admin |
|
||
| GET | `/admin/audit-log` | SUPER_ADMIN | Full audit log |
|
||
|
||
### Impersonation
|
||
|
||
Admins can impersonate any company employee for support purposes:
|
||
|
||
```typescript
|
||
// POST /admin/companies/:id/impersonate
|
||
// Returns a short-lived JWT that grants dashboard access as the company OWNER
|
||
// Duration: 30 minutes max
|
||
// All actions logged in AdminAuditLog
|
||
// Dashboard shows "⚠️ Impersonation Mode — RentalDriveGo Admin" banner
|
||
|
||
async function impersonateCompany(req, res) {
|
||
const company = await prisma.company.findUniqueOrThrow({
|
||
where: { id: req.params.id },
|
||
include: { employees: { where: { role: 'OWNER' } } }
|
||
})
|
||
|
||
await prisma.adminAuditLog.create({
|
||
data: {
|
||
adminId: req.admin.id,
|
||
action: 'IMPERSONATE_COMPANY',
|
||
targetType: 'Company',
|
||
targetId: company.id,
|
||
ip: req.ip
|
||
}
|
||
})
|
||
|
||
// Generate a short-lived JWT signed with JWT_SECRET
|
||
const token = jwt.sign(
|
||
{ companyId: company.id, employeeId: company.employees[0]?.id, isImpersonation: true },
|
||
process.env.JWT_SECRET!,
|
||
{ expiresIn: '30m' }
|
||
)
|
||
|
||
res.json({ data: { token, expiresIn: 1800 } })
|
||
}
|
||
```
|
||
|
||
### Admin UI (admin.RentalDriveGo.com)
|
||
|
||
Separate Next.js app with its own auth (email + password, NO Clerk).
|
||
|
||
| Page | Description |
|
||
|------|-------------|
|
||
| `/` → redirect to `/companies` | |
|
||
| `/login` | Admin login |
|
||
| `/companies` | Searchable table: name, slug, plan, status, employees count, vehicles count, MRR, created. Actions: Edit, Suspend, Impersonate |
|
||
| `/companies/new` | Create company form |
|
||
| `/companies/:id` | Company profile: overview, subscription, employees, vehicles, reservations, audit log |
|
||
| `/companies/:id/employees` | Employee list + add/edit/deactivate/change role |
|
||
| `/metrics` | Platform KPIs: MRR, ARR, churn rate, new signups chart, top companies |
|
||
| `/admins` | Admin user management (SUPER_ADMIN only) |
|
||
| `/audit-log` | Full audit log with filters |
|
||
|
||
---
|
||
|
||
## Invoice: Full Price Breakdown
|
||
|
||
The `CustomerInvoicePDF` line items now must include all charge types:
|
||
|
||
```typescript
|
||
// Full line items array for invoice generation
|
||
const lineItems = [
|
||
// 1. Base rental
|
||
{
|
||
description: `${vehicle.year} ${vehicle.make} ${vehicle.model} — ${totalDays} day(s) × ${fmt(dailyRate)}/day`,
|
||
qty: totalDays, unitPrice: dailyRate, total: dailyRate * totalDays,
|
||
category: 'RENTAL'
|
||
},
|
||
|
||
// 2. Insurance lines (one per policy selected)
|
||
...insurances.map(ins => ({
|
||
description: `${ins.policyName} (${insuranceChargeLabel(ins.chargeType, ins.chargeValue, totalDays, locale)})`,
|
||
qty: ins.chargeType === 'PER_DAY' ? totalDays : 1,
|
||
unitPrice: ins.chargeType === 'PER_DAY' ? ins.chargeValue : ins.totalCharge,
|
||
total: ins.totalCharge,
|
||
category: 'INSURANCE'
|
||
})),
|
||
|
||
// 3. Additional driver(s)
|
||
...additionalDrivers.filter(d => d.totalCharge > 0).map(d => ({
|
||
description: `Additional Driver — ${d.firstName} ${d.lastName}`,
|
||
qty: d.chargeType === 'PER_DAY' ? totalDays : 1,
|
||
unitPrice: d.chargeType === 'PER_DAY' ? d.chargeValue : d.totalCharge,
|
||
total: d.totalCharge,
|
||
category: 'ADDITIONAL_DRIVER'
|
||
})),
|
||
|
||
// 4. Pricing rule adjustments (surcharges as positive, discounts as negative)
|
||
...(pricingRulesApplied ?? []).map((rule: any) => ({
|
||
description: rule.name,
|
||
qty: 1, unitPrice: rule.amount, total: rule.amount,
|
||
category: rule.amount > 0 ? 'SURCHARGE' : 'DISCOUNT'
|
||
})),
|
||
|
||
// 5. Deposit (always last, marked as refundable)
|
||
...(depositAmount > 0 ? [{
|
||
description: locale === 'ar' ? 'تأمين (قابل للاسترداد)' : locale === 'fr' ? 'Caution (remboursable)' : 'Security Deposit (refundable)',
|
||
qty: 1, unitPrice: depositAmount, total: depositAmount,
|
||
category: 'DEPOSIT'
|
||
}] : []),
|
||
]
|
||
|
||
// Totals
|
||
const subtotal = dailyRate * totalDays
|
||
const discountTotal = -Math.abs(discountAmount + pricingDiscountsTotal)
|
||
const surchargeTotal = pricingSurchargesTotal + pricingRulesTotal
|
||
const insuranceTotal = insurances.reduce((s, i) => s + i.totalCharge, 0)
|
||
const additionalDriverTotal = additionalDrivers.reduce((s, d) => s + d.totalCharge, 0)
|
||
const taxAmount = showTax ? Math.round(subtotal * (taxRate ?? 0)) : 0
|
||
const grandTotal = subtotal + discountTotal + surchargeTotal + insuranceTotal + additionalDriverTotal + taxAmount + depositAmount
|
||
```
|
||
|
||
---
|
||
|
||
## Updated ContractSettings model (additions to existing)
|
||
|
||
```prisma
|
||
// ADD to model ContractSettings — replacing fuelPolicy String:
|
||
fuelPolicyType FuelPolicyType @default(FULL_TO_FULL)
|
||
fuelPolicyNote String?
|
||
|
||
// Additional driver config
|
||
additionalDriverCharge AdditionalDriverCharge @default(FREE)
|
||
additionalDriverDailyRate Int @default(0)
|
||
additionalDriverFlatRate Int @default(0)
|
||
```
|
||
|
||
## Updated Reservation model (additional fields)
|
||
|
||
```prisma
|
||
// ADD to model Reservation:
|
||
insurances ReservationInsurance[]
|
||
insuranceTotal Int @default(0)
|
||
additionalDrivers AdditionalDriver[]
|
||
additionalDriverTotal Int @default(0)
|
||
pricingRulesApplied Json? // snapshot of applied pricing rules
|
||
pricingRulesTotal Int @default(0)
|
||
damageReports DamageReport[]
|
||
```
|
||
|
||
## New Company relations (add to model Company)
|
||
|
||
```prisma
|
||
insurancePolicies InsurancePolicy[]
|
||
pricingRules PricingRule[]
|
||
```
|