fix architecture and write new tests
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user