304 lines
13 KiB
TypeScript
304 lines
13 KiB
TypeScript
'use client'
|
|
|
|
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'
|
|
type DamageType = 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
|
|
type DamageSeverity = 'MINOR' | 'MODERATE' | 'MAJOR'
|
|
|
|
export interface DamagePoint {
|
|
id?: string
|
|
viewType: 'TOP' | 'FRONT' | 'REAR' | 'LEFT' | 'RIGHT'
|
|
x: number
|
|
y: number
|
|
damageType: DamageType
|
|
severity: DamageSeverity
|
|
description?: string | null
|
|
isPreExisting: boolean
|
|
}
|
|
|
|
export interface DamageInspection {
|
|
id: string
|
|
type: InspectionType
|
|
mileage: number | null
|
|
fuelLevel: FuelLevel
|
|
fuelCharge: number | null
|
|
generalCondition: string | null
|
|
employeeNotes: string | null
|
|
customerAgreed: boolean
|
|
damagePoints: DamagePoint[]
|
|
}
|
|
|
|
const fuelLevels: FuelLevel[] = ['FULL', 'SEVEN_EIGHTHS', 'THREE_QUARTERS', 'FIVE_EIGHTHS', 'HALF', 'THREE_EIGHTHS', 'QUARTER', 'ONE_EIGHTH', 'EMPTY']
|
|
const damageTypes: DamageType[] = ['SCRATCH', 'DENT', 'CRACK', 'CHIP', 'MISSING', 'STAIN', 'OTHER']
|
|
const severityColors: Record<DamageSeverity, string> = {
|
|
MINOR: '#f59e0b',
|
|
MODERATE: '#f97316',
|
|
MAJOR: '#dc2626',
|
|
}
|
|
|
|
export 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 function comparisonPointsForCheckout(initialInspection: DamageInspection | null | undefined, comparisonPoints: DamagePoint[]): DamagePoint[] {
|
|
const storedPreExistingPoints = initialInspection?.damagePoints?.filter((point) => point.isPreExisting) ?? []
|
|
return [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
|
|
}
|
|
|
|
export function editableDamagePointsForInspection(type: InspectionType, initialInspection: DamageInspection | null | undefined): DamagePoint[] {
|
|
const points = initialInspection?.damagePoints ?? []
|
|
return type === 'CHECKOUT' ? points.filter((point) => !point.isPreExisting) : points
|
|
}
|
|
|
|
export function buildInspectionSavePayload(inspection: DamageInspection, overlayComparisonPoints: DamagePoint[]) {
|
|
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,
|
|
damageType,
|
|
severity,
|
|
...(description ? { description } : {}),
|
|
isPreExisting,
|
|
})),
|
|
]
|
|
|
|
return {
|
|
...(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,
|
|
}
|
|
}
|
|
|
|
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 overlayComparisonPoints = type === 'CHECKOUT'
|
|
? comparisonPointsForCheckout(initialInspection, comparisonPoints)
|
|
: []
|
|
|
|
const [inspection, setInspection] = useState<DamageInspection>({
|
|
id: initialInspection?.id ?? `${type.toLowerCase()}-draft`,
|
|
type,
|
|
mileage: initialInspection?.mileage ?? null,
|
|
fuelLevel: initialInspection?.fuelLevel ?? 'FULL',
|
|
fuelCharge: initialInspection?.fuelCharge ?? null,
|
|
generalCondition: initialInspection?.generalCondition ?? '',
|
|
employeeNotes: initialInspection?.employeeNotes ?? '',
|
|
customerAgreed: initialInspection?.customerAgreed ?? false,
|
|
damagePoints: editableDamagePointsForInspection(type, initialInspection),
|
|
})
|
|
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
|
|
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function saveInspection() {
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
const payload = buildInspectionSavePayload(inspection, overlayComparisonPoints)
|
|
|
|
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
})
|
|
setInspection({
|
|
...result,
|
|
damagePoints: type === 'CHECKOUT'
|
|
? result.damagePoints.filter((point) => !point.isPreExisting)
|
|
: result.damagePoints,
|
|
})
|
|
onSaved(result)
|
|
} catch (err: any) {
|
|
setError(err.message ?? r.failedSaveInspection)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
function addDamagePoint(viewType: DamagePoint['viewType'], x: number, y: number) {
|
|
setInspection((current) => ({
|
|
...current,
|
|
damagePoints: [
|
|
...current.damagePoints,
|
|
{ viewType, x, y, damageType, severity, description: '', isPreExisting: type === 'CHECKIN' },
|
|
],
|
|
}))
|
|
}
|
|
|
|
function removePoint(index: number) {
|
|
setInspection((current) => ({
|
|
...current,
|
|
damagePoints: current.damagePoints.filter((_, i) => i !== index),
|
|
}))
|
|
}
|
|
|
|
return (
|
|
<div className="card p-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<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 || !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">
|
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
|
{damageTypes.map((option) => (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
onClick={() => setDamageType(option)}
|
|
disabled={!editable}
|
|
className={`rounded-full px-3 py-1.5 font-semibold ${damageType === option ? 'bg-blue-700 text-white' : 'bg-white text-slate-600'}`}
|
|
>
|
|
{r.damageTypeLabels[option]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
|
{(Object.keys(severityColors) as DamageSeverity[]).map((option) => (
|
|
<button
|
|
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 }}
|
|
>
|
|
{r.severityLabels[option]}
|
|
</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-blue-700" />
|
|
{r.inspectionCardTitle('CHECKOUT')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-slate-700">{r.mileageLabel}</label>
|
|
<input
|
|
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} 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>
|
|
</div>
|
|
|
|
<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 ?? ''} 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 ?? ''} 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} disabled={!editable} onChange={(e) => setInspection((cur) => ({ ...cur, customerAgreed: e.target.checked }))} />
|
|
{r.customerAcknowledged}
|
|
</label>
|
|
|
|
<div className="rounded-2xl border border-slate-200">
|
|
<div className="border-b border-slate-200 px-4 py-3">
|
|
<p className="text-sm font-semibold text-slate-900">{r.damageMarkersHeading}</p>
|
|
</div>
|
|
<div className="divide-y divide-slate-100">
|
|
{inspection.damagePoints.map((point, index) => (
|
|
<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">{point.viewType} · x {point.x.toFixed(1)} · y {point.y.toFixed(1)}</p>
|
|
</div>
|
|
<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>
|
|
))}
|
|
{inspection.damagePoints.length === 0 && (
|
|
<div className="px-4 py-6 text-sm text-slate-400">{r.noMarkers}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|