fix the contract view and edit

This commit is contained in:
root
2026-05-15 11:34:29 -04:00
committed by Administrator
parent f317fa12dd
commit 593ff202a3
13 changed files with 2331 additions and 381 deletions
@@ -316,7 +316,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
team: 'Team',
reports: 'Reports',
subscription: 'Subscription',
billing: 'Rent Car Billing',
billing: 'Customer Billing',
contracts: 'Contracts',
notifications: 'Notifications',
settings: 'Settings',
@@ -332,7 +332,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard/team': 'Team',
'/dashboard/reports': 'Reports',
'/dashboard/subscription': 'Subscription',
'/dashboard/billing': 'Rent Car Billing',
'/dashboard/billing': 'Customer Billing',
'/dashboard/contracts': 'Contracts',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Settings',
@@ -659,7 +659,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
team: 'Équipe',
reports: 'Rapports',
subscription: 'Abonnement',
billing: 'Facturation location',
billing: 'Facturation clients',
contracts: 'Contrats',
notifications: 'Notifications',
settings: 'Paramètres',
@@ -675,7 +675,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard/team': 'Équipe',
'/dashboard/reports': 'Rapports',
'/dashboard/subscription': 'Abonnement',
'/dashboard/billing': 'Facturation location',
'/dashboard/billing': 'Facturation clients',
'/dashboard/contracts': 'Contrats',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Paramètres',
@@ -1002,7 +1002,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
team: 'الفريق',
reports: 'التقارير',
subscription: 'الاشتراك',
billing: 'فوترة تأجير السيارات',
billing: 'فوترة العملاء',
contracts: 'العقود',
notifications: 'الإشعارات',
settings: 'الإعدادات',
@@ -1018,7 +1018,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard/team': 'الفريق',
'/dashboard/reports': 'التقارير',
'/dashboard/subscription': 'الاشتراك',
'/dashboard/billing': 'فوترة تأجير السيارات',
'/dashboard/billing': 'فوترة العملاء',
'/dashboard/contracts': 'العقود',
'/dashboard/notifications': 'الإشعارات',
'/dashboard/settings': 'الإعدادات',
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import VehicleConditionSheet from './VehicleConditionSheet'
type InspectionType = 'CHECKIN' | 'CHECKOUT'
type FuelLevel = 'FULL' | 'SEVEN_EIGHTHS' | 'THREE_QUARTERS' | 'FIVE_EIGHTHS' | 'HALF' | 'THREE_EIGHTHS' | 'QUARTER' | 'ONE_EIGHTH' | 'EMPTY'
@@ -40,19 +41,35 @@ const severityColors: Record<DamageSeverity, string> = {
MAJOR: '#dc2626',
}
function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
return `${point.viewType}:${point.x.toFixed(2)}:${point.y.toFixed(2)}:${point.damageType}:${point.severity}`
}
export default function DamageInspectionCard({
reservationId,
type,
initialInspection,
comparisonPoints = [],
editable = true,
readOnlyMessage,
onSaved,
}: {
reservationId: string
type: InspectionType
initialInspection?: DamageInspection | null
comparisonPoints?: DamagePoint[]
editable?: boolean
readOnlyMessage?: string | null
onSaved: (inspection: DamageInspection) => void
}) {
const { dict } = useDashboardI18n()
const r = dict.reservations
const storedPreExistingPoints = type === 'CHECKOUT'
? (initialInspection?.damagePoints ?? []).filter((point) => point.isPreExisting)
: []
const overlayComparisonPoints = type === 'CHECKOUT'
? [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
: []
const [inspection, setInspection] = useState<DamageInspection>({
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
@@ -63,7 +80,9 @@ export default function DamageInspectionCard({
generalCondition: initialInspection?.generalCondition ?? '',
employeeNotes: initialInspection?.employeeNotes ?? '',
customerAgreed: initialInspection?.customerAgreed ?? false,
damagePoints: initialInspection?.damagePoints ?? [],
damagePoints: type === 'CHECKOUT'
? (initialInspection?.damagePoints ?? []).filter((point) => !point.isPreExisting)
: (initialInspection?.damagePoints ?? []),
})
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
@@ -74,14 +93,17 @@ export default function DamageInspectionCard({
setSaving(true)
setError(null)
try {
const payload = {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
const payloadPoints = [
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting: true,
})),
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
@@ -90,13 +112,28 @@ export default function DamageInspectionCard({
...(description ? { description } : {}),
isPreExisting,
})),
]
const payload = {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: payloadPoints,
}
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
setInspection(result)
setInspection({
...result,
damagePoints: type === 'CHECKOUT'
? result.damagePoints.filter((point) => !point.isPreExisting)
: result.damagePoints,
})
onSaved(result)
} catch (err: any) {
setError(err.message ?? r.failedSaveInspection)
@@ -105,15 +142,12 @@ export default function DamageInspectionCard({
}
}
function addDamagePoint(event: React.MouseEvent<SVGSVGElement>) {
const rect = event.currentTarget.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * 100
const y = ((event.clientY - rect.top) / rect.height) * 180
function addDamagePoint(viewType: DamagePoint['viewType'], x: number, y: number) {
setInspection((current) => ({
...current,
damagePoints: [
...current.damagePoints,
{ viewType: 'TOP', x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
{ viewType, x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
],
}))
}
@@ -132,12 +166,17 @@ export default function DamageInspectionCard({
<h3 className="text-base font-semibold text-slate-900">{r.inspectionCardTitle(type)}</h3>
<p className="mt-1 text-sm text-slate-500">{r.inspectionSubtitle}</p>
</div>
<button onClick={saveInspection} disabled={saving} className="btn-primary">
<button onClick={saveInspection} disabled={saving || !editable} className="btn-primary">
{saving ? r.savingInspection : r.saveInspection}
</button>
</div>
{error && <div className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{!editable && readOnlyMessage && (
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
{readOnlyMessage}
</div>
)}
<div className="mt-5 grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4">
@@ -147,6 +186,7 @@ export default function DamageInspectionCard({
key={option}
type="button"
onClick={() => setDamageType(option)}
disabled={!editable}
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-slate-900 text-white' : 'bg-white text-slate-600'}`}
>
{r.damageTypeLabels[option]}
@@ -159,6 +199,7 @@ export default function DamageInspectionCard({
key={option}
type="button"
onClick={() => setSeverity(option)}
disabled={!editable}
className="rounded-full px-3 py-1.5 font-semibold text-white"
style={{ backgroundColor: severityColors[option], opacity: severity === option ? 1 : 0.55 }}
>
@@ -166,22 +207,26 @@ export default function DamageInspectionCard({
</button>
))}
</div>
{type === 'CHECKOUT' && overlayComparisonPoints.length > 0 && (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-2xl border border-slate-200 bg-slate-100 px-3 py-2 text-xs text-slate-600">
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full border-2 border-dashed border-slate-500 bg-white" />
{r.inspectionCardTitle('CHECKIN')}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-3.5 w-3.5 rounded-full bg-slate-900" />
{r.inspectionCardTitle('CHECKOUT')}
</span>
</div>
)}
<svg viewBox="0 0 100 180" className="w-full cursor-crosshair rounded-2xl border border-slate-200 bg-white" onClick={addDamagePoint}>
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
{inspection.damagePoints.map((point, index) => (
<g key={`${point.x}-${point.y}-${index}`}>
<circle cx={point.x} cy={point.y} r="3.8" fill={severityColors[point.severity]} stroke="white" strokeWidth="1.5" />
</g>
))}
</svg>
<VehicleConditionSheet
points={inspection.damagePoints}
comparisonPoints={overlayComparisonPoints}
interactive={editable}
onAddPoint={editable ? addDamagePoint : undefined}
className={`w-full rounded-2xl border border-slate-200 bg-white ${editable ? 'cursor-crosshair' : 'cursor-default'}`}
/>
<p className="mt-2 text-xs text-slate-500">{r.diagramHelp}</p>
</div>
@@ -193,12 +238,13 @@ export default function DamageInspectionCard({
type="number"
className="input-field"
value={inspection.mileage ?? ''}
disabled={!editable}
onChange={(e) => setInspection((cur) => ({ ...cur, mileage: e.target.value ? Number(e.target.value) : null }))}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.fuelLevelLabel}</label>
<select className="input-field" value={inspection.fuelLevel} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
<select className="input-field" value={inspection.fuelLevel} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, fuelLevel: e.target.value as FuelLevel }))}>
{fuelLevels.map((fl) => <option key={fl} value={fl}>{r.fuelLevels[fl]}</option>)}
</select>
</div>
@@ -206,16 +252,16 @@ export default function DamageInspectionCard({
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.generalConditionLabel}</label>
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
<textarea className="input-field min-h-24" value={inspection.generalCondition ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, generalCondition: e.target.value }))} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.employeeNotesLabel}</label>
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
<textarea className="input-field min-h-24" value={inspection.employeeNotes ?? ''} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, employeeNotes: e.target.value }))} />
</div>
<label className="flex items-center gap-3 text-sm font-medium text-slate-700">
<input type="checkbox" checked={inspection.customerAgreed} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
<input type="checkbox" checked={inspection.customerAgreed} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
{r.customerAcknowledged}
</label>
@@ -228,9 +274,9 @@ export default function DamageInspectionCard({
<div key={`${point.x}-${point.y}-${index}`} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{r.damageTypeLabels[point.damageType]} · {r.severityLabels[point.severity]}</p>
<p className="text-xs text-slate-500">x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
<p className="text-xs text-slate-500">{point.viewType} · x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
</div>
<button type="button" onClick={() => removePoint(index)} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600">
<button type="button" onClick={() => removePoint(index)} disabled={!editable} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 disabled:cursor-not-allowed disabled:opacity-50">
{r.removeMarker}
</button>
</div>
@@ -0,0 +1,110 @@
'use client'
export type VehicleSheetView = 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
export type VehicleSheetPoint = {
viewType?: VehicleSheetView
x: number
y: number
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
}
const SHEET_WIDTH = 573
const SHEET_HEIGHT = 876
const SHEET_IMAGE_PATH = '/vehicle-condition-template.png'
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
FRONT: { x: 161, y: 4, w: 251, h: 108 },
TOP: { x: 150, y: 112, w: 273, h: 613 },
REAR: { x: 156, y: 724, w: 252, h: 123 },
LEFT: { x: 4, y: 114, w: 146, h: 618 },
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
}
function resolvePointPosition(point: VehicleSheetPoint) {
const viewType = point.viewType ?? 'TOP'
const box = viewBoxes[viewType]
return {
cx: box.x + (point.x / 100) * box.w,
cy: box.y + (point.y / 100) * box.h,
}
}
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
const { cx, cy } = resolvePointPosition(point)
const color = severityColors[point.severity]
if (variant === 'comparison') {
return (
<g key={`comparison-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}>
<circle cx={cx} cy={cy} r="9" fill="rgba(255,255,255,0.92)" stroke={color} strokeWidth="2.5" strokeDasharray="5 4" />
<circle cx={cx} cy={cy} r="4" fill={color} opacity="0.28" />
</g>
)
}
return (
<circle
key={`current-${point.viewType ?? 'TOP'}-${point.x}-${point.y}-${index}`}
cx={cx}
cy={cy}
r="7.5"
fill={color}
stroke="white"
strokeWidth="2.5"
/>
)
}
export default function VehicleConditionSheet({
points,
comparisonPoints = [],
interactive = false,
className = '',
onAddPoint,
}: {
points: VehicleSheetPoint[]
comparisonPoints?: VehicleSheetPoint[]
interactive?: boolean
className?: string
onAddPoint?: (viewType: VehicleSheetView, x: number, y: number) => void
}) {
function handleClick(event: React.MouseEvent<SVGSVGElement>) {
if (!interactive || !onAddPoint) return
const rect = event.currentTarget.getBoundingClientRect()
const x = ((event.clientX - rect.left) / rect.width) * SHEET_WIDTH
const y = ((event.clientY - rect.top) / rect.height) * SHEET_HEIGHT
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>)
.find(([, box]) => x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h)
if (!target) return
const [viewType, box] = target
const localX = ((x - box.x) / box.w) * 100
const localY = ((y - box.y) / box.h) * 100
onAddPoint(viewType, localX, localY)
}
return (
<svg
viewBox={`0 0 ${SHEET_WIDTH} ${SHEET_HEIGHT}`}
className={className}
onClick={handleClick}
>
<rect x="1.5" y="1.5" width={SHEET_WIDTH - 3} height={SHEET_HEIGHT - 3} rx="18" fill="#fff" stroke="#cbd5e1" strokeWidth="3" />
<image href={SHEET_IMAGE_PATH} x="0" y="0" width={SHEET_WIDTH} height={SHEET_HEIGHT} preserveAspectRatio="xMidYMid meet" />
{comparisonPoints.map((point, index) => renderMarker(point, index, 'comparison'))}
{points.map((point, index) => renderMarker(point, index, 'current'))}
</svg>
)
}