fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
buildInspectionSavePayload,
comparisonPointsForCheckout,
editableDamagePointsForInspection,
pointKey,
type DamageInspection,
type DamagePoint,
} from './DamageInspectionCard'
function point(overrides: Partial<DamagePoint> = {}): DamagePoint {
return {
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: null,
isPreExisting: false,
...overrides,
}
}
function inspection(overrides: Partial<DamageInspection> = {}): DamageInspection {
return {
id: 'inspection-1',
type: 'CHECKOUT',
mileage: 45000,
fuelLevel: 'FULL',
fuelCharge: null,
generalCondition: 'Clean',
employeeNotes: 'No issues',
customerAgreed: true,
damagePoints: [],
...overrides,
}
}
describe('DamageInspectionCard helpers', () => {
it('builds stable duplicate keys using rounded coordinates and damage metadata', () => {
expect(pointKey(point({ x: 12.3456, y: 78.994 }))).toBe('TOP:12.35:78.99:SCRATCH:MINOR')
expect(pointKey(point({ viewType: 'LEFT', damageType: 'DENT', severity: 'MAJOR' }))).toBe('LEFT:10.00:20.00:DENT:MAJOR')
})
it('deduplicates checkout comparison points against stored pre-existing points', () => {
const duplicate = point({ id: 'cmp-1', isPreExisting: true, description: 'old scratch' })
const storedDuplicate = point({ id: 'stored-1', isPreExisting: true, description: 'stored copy' })
const storedUnique = point({ id: 'stored-2', viewType: 'REAR', x: 33, y: 44, isPreExisting: true })
const result = comparisonPointsForCheckout(
inspection({ damagePoints: [storedDuplicate, storedUnique] }),
[duplicate]
)
expect(result).toEqual([duplicate, storedUnique])
})
it('keeps checkout editing focused on new damage while check-in exposes all points', () => {
const oldDamage = point({ id: 'old', isPreExisting: true })
const newDamage = point({ id: 'new', isPreExisting: false })
const record = inspection({ damagePoints: [oldDamage, newDamage] })
expect(editableDamagePointsForInspection('CHECKOUT', record)).toEqual([newDamage])
expect(editableDamagePointsForInspection('CHECKIN', record)).toEqual([oldDamage, newDamage])
})
it('builds compact save payloads and omits nullable/empty fields', () => {
const payload = buildInspectionSavePayload(
inspection({
mileage: null,
fuelCharge: null,
generalCondition: '',
employeeNotes: '',
customerAgreed: false,
damagePoints: [point({ description: '' })],
}),
[point({ id: 'pre', viewType: 'FRONT', description: 'pre-existing chip', isPreExisting: true })]
)
expect(payload).toEqual({
fuelLevel: 'FULL',
customerAgreed: false,
damagePoints: [
{
viewType: 'FRONT',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
description: 'pre-existing chip',
isPreExisting: true,
},
{
viewType: 'TOP',
x: 10,
y: 20,
damageType: 'SCRATCH',
severity: 'MINOR',
isPreExisting: false,
},
],
})
})
})
@@ -41,10 +41,54 @@ const severityColors: Record<DamageSeverity, string> = {
MAJOR: '#dc2626',
}
function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
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,
@@ -64,11 +108,8 @@ export default function DamageInspectionCard({
}) {
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)
? comparisonPointsForCheckout(initialInspection, comparisonPoints)
: []
const [inspection, setInspection] = useState<DamageInspection>({
@@ -80,9 +121,7 @@ export default function DamageInspectionCard({
generalCondition: initialInspection?.generalCondition ?? '',
employeeNotes: initialInspection?.employeeNotes ?? '',
customerAgreed: initialInspection?.customerAgreed ?? false,
damagePoints: type === 'CHECKOUT'
? (initialInspection?.damagePoints ?? []).filter((point) => !point.isPreExisting)
: (initialInspection?.damagePoints ?? []),
damagePoints: editableDamagePointsForInspection(type, initialInspection),
})
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
@@ -93,36 +132,7 @@ export default function DamageInspectionCard({
setSaving(true)
setError(null)
try {
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,
})),
]
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 payload = buildInspectionSavePayload(inspection, overlayComparisonPoints)
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
SHEET_HEIGHT,
SHEET_WIDTH,
resolvePointPosition,
resolveVehicleSheetClick,
severityColors,
viewBoxes,
type VehicleSheetPoint,
} from './VehicleConditionSheet'
const rect = { left: 10, top: 20, width: SHEET_WIDTH * 2, height: SHEET_HEIGHT * 2 } as Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>
function sheetClientPoint(x: number, y: number) {
return {
clientX: rect.left + (x / SHEET_WIDTH) * rect.width,
clientY: rect.top + (y / SHEET_HEIGHT) * rect.height,
}
}
describe('VehicleConditionSheet geometry helpers', () => {
it('maps percentage positions into absolute sheet coordinates per view', () => {
expect(resolvePointPosition({ viewType: 'TOP', x: 50, y: 50, severity: 'MINOR' })).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w / 2,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h / 2,
})
expect(resolvePointPosition({ viewType: 'LEFT', x: 0, y: 100, severity: 'MAJOR' })).toEqual({
cx: viewBoxes.LEFT.x,
cy: viewBoxes.LEFT.y + viewBoxes.LEFT.h,
})
})
it('defaults missing viewType to TOP for legacy damage points', () => {
const legacyPoint = { x: 25, y: 10, severity: 'MODERATE' } satisfies VehicleSheetPoint
expect(resolvePointPosition(legacyPoint)).toEqual({
cx: viewBoxes.TOP.x + viewBoxes.TOP.w * 0.25,
cy: viewBoxes.TOP.y + viewBoxes.TOP.h * 0.1,
})
})
it('resolves clicks inside each vehicle region to local percentages', () => {
const topCenter = sheetClientPoint(viewBoxes.TOP.x + viewBoxes.TOP.w / 2, viewBoxes.TOP.y + viewBoxes.TOP.h / 2)
const frontCorner = sheetClientPoint(viewBoxes.FRONT.x, viewBoxes.FRONT.y)
const rearCorner = sheetClientPoint(viewBoxes.REAR.x + viewBoxes.REAR.w, viewBoxes.REAR.y + viewBoxes.REAR.h)
expect(resolveVehicleSheetClick(topCenter.clientX, topCenter.clientY, rect)).toEqual({ viewType: 'TOP', x: 50, y: 50 })
expect(resolveVehicleSheetClick(frontCorner.clientX, frontCorner.clientY, rect)).toEqual({ viewType: 'FRONT', x: 0, y: 0 })
expect(resolveVehicleSheetClick(rearCorner.clientX, rearCorner.clientY, rect)).toEqual({ viewType: 'REAR', x: 100, y: 100 })
})
it('rejects clicks outside vehicle regions instead of creating impossible damage points', () => {
const outside = sheetClientPoint(40, 40)
expect(resolveVehicleSheetClick(outside.clientX, outside.clientY, rect)).toBeNull()
})
it('keeps severity color mapping explicit for rendering and comparison markers', () => {
expect(severityColors).toEqual({
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
})
})
})
@@ -9,17 +9,17 @@ export type VehicleSheetPoint = {
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
}
const SHEET_WIDTH = 573
const SHEET_HEIGHT = 876
export const SHEET_WIDTH = 573
export const SHEET_HEIGHT = 876
const SHEET_IMAGE_PATH = '/dashboard/vehicle-condition-template.png'
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
export const severityColors: Record<VehicleSheetPoint['severity'], string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
export 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 },
@@ -27,7 +27,7 @@ const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h:
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
}
function resolvePointPosition(point: VehicleSheetPoint) {
export function resolvePointPosition(point: VehicleSheetPoint) {
const viewType = point.viewType ?? 'TOP'
const box = viewBoxes[viewType]
@@ -37,6 +37,25 @@ function resolvePointPosition(point: VehicleSheetPoint) {
}
}
export function resolveVehicleSheetClick(clientX: number, clientY: number, rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>): { viewType: VehicleSheetView; x: number; y: number } | null {
const x = ((clientX - rect.left) / rect.width) * SHEET_WIDTH
const y = ((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 null
const [viewType, box] = target
return {
viewType,
x: ((x - box.x) / box.w) * 100,
y: ((y - box.y) / box.h) * 100,
}
}
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
const { cx, cy } = resolvePointPosition(point)
const color = severityColors[point.severity]
@@ -79,20 +98,10 @@ export default function VehicleConditionSheet({
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 resolved = resolveVehicleSheetClick(event.clientX, event.clientY, event.currentTarget.getBoundingClientRect())
if (!resolved) return
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)
onAddPoint(resolved.viewType, resolved.x, resolved.y)
}
return (