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,64 @@
import { describe, expect, it } from 'vitest'
import {
calendarGridDays,
daysBetween,
eventsForCalendarDay,
isSameDay,
parseDateStr,
selectedBlockRange,
toDateStr,
type CalendarEvent,
} from './VehicleCalendar'
describe('VehicleCalendar helpers', () => {
it('formats and parses local date-only strings without shifting the calendar day', () => {
const parsed = parseDateStr('2026-02-09')
expect(parsed.getFullYear()).toBe(2026)
expect(parsed.getMonth()).toBe(1)
expect(parsed.getDate()).toBe(9)
expect(toDateStr(parsed)).toBe('2026-02-09')
})
it('builds a full week-aligned month grid with leading and trailing blanks', () => {
const grid = calendarGridDays(2026, 2)
expect(grid).toHaveLength(28)
expect(grid[0]?.getDate()).toBe(1)
expect(grid.at(-1)?.getDate()).toBe(28)
const march = calendarGridDays(2026, 3)
expect(march).toHaveLength(35)
expect(march[0]?.getDate()).toBe(1)
expect(march[6]?.getDate()).toBe(7)
expect(march.at(-1)).toBeNull()
})
it('treats reservation end dates as exclusive when mapping events to days', () => {
const events: CalendarEvent[] = [
{ id: 'r1', type: 'RESERVATION', startDate: '2026-06-10T10:00:00.000Z', endDate: '2026-06-13T10:00:00.000Z', status: 'CONFIRMED', label: 'Reservation' },
{ id: 'b1', type: 'BLOCK', startDate: '2026-06-13T00:00:00.000Z', endDate: '2026-06-14T00:00:00.000Z', status: null, label: 'Block' },
]
expect(eventsForCalendarDay(events, parseDateStr('2026-06-10')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-12')).map((event) => event.id)).toEqual(['r1'])
expect(eventsForCalendarDay(events, parseDateStr('2026-06-13')).map((event) => event.id)).toEqual(['b1'])
})
it('normalizes two clicked days into an inclusive visual range with exclusive end date', () => {
expect(selectedBlockRange('2026-06-12', parseDateStr('2026-06-10'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
expect(selectedBlockRange('2026-06-10', parseDateStr('2026-06-12'))).toEqual({
startDate: '2026-06-10',
endDate: '2026-06-13',
})
})
it('compares calendar days and calculates day spans independent of clock time', () => {
expect(isSameDay(new Date(2026, 5, 9, 1), new Date(2026, 5, 9, 23))).toBe(true)
expect(isSameDay(new Date(2026, 5, 9), new Date(2026, 5, 10))).toBe(false)
expect(daysBetween(parseDateStr('2026-06-09'), parseDateStr('2026-06-12'))).toBe(3)
})
})
@@ -7,7 +7,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
interface CalendarEvent {
export interface CalendarEvent {
id: string
type: EventType
startDate: string
@@ -26,23 +26,56 @@ const EVENT_STYLES: Record<EventType, { bg: string; text: string; border: string
BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' },
}
function toDateStr(d: Date): string {
export function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10)
}
function parseDateStr(s: string): Date {
export function parseDateStr(s: string): Date {
const [y, m, day] = s.split('-').map(Number)
return new Date(y, m - 1, day)
}
function isSameDay(a: Date, b: Date): boolean {
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function daysBetween(start: Date, end: Date): number {
export function daysBetween(start: Date, end: Date): number {
return Math.round((end.getTime() - start.getTime()) / 86400000)
}
export function calendarGridDays(year: number, month: number): (Date | null)[] {
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay()
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
return gridDays
}
export function eventsForCalendarDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
const dayStr = toDateStr(day)
return events.filter((e) => {
const start = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return start <= dayStr && dayStr < end
})
}
export function selectedBlockRange(selectStart: string, clickedDay: Date): { startDate: string; endDate: string } {
const clicked = toDateStr(clickedDay)
const start = selectStart < clicked ? selectStart : clicked
const end = selectStart < clicked ? clicked : selectStart
return {
startDate: start,
endDate: toDateStr(new Date(parseDateStr(end).getTime() + 86400000)),
}
}
export default function VehicleCalendar({ vehicleId }: Props) {
const { dict, language } = useDashboardI18n()
const c = dict.calendar
@@ -92,24 +125,10 @@ export default function VehicleCalendar({ vehicleId }: Props) {
}
// Build calendar grid (6 rows × 7 cols)
const firstDay = new Date(year, month - 1, 1)
const daysInMonth = new Date(year, month, 0).getDate()
const startOffset = firstDay.getDay() // 0=Sun
const gridDays: (Date | null)[] = []
for (let i = 0; i < startOffset; i++) gridDays.push(null)
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
while (gridDays.length % 7 !== 0) gridDays.push(null)
const gridDays = calendarGridDays(year, month)
// Map events to date strings for fast lookup
const eventsOnDay = (day: Date): CalendarEvent[] => {
const dayStr = toDateStr(day)
return events.filter((e) => {
const s = e.startDate.slice(0, 10)
const end = e.endDate.slice(0, 10)
return s <= dayStr && dayStr < end
})
}
const eventsOnDay = (day: Date): CalendarEvent[] => eventsForCalendarDay(events, day)
const isToday = (day: Date) => isSameDay(day, today)
@@ -118,12 +137,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
if (!selectStart) {
setSelectStart(str)
} else {
const start = selectStart < str ? selectStart : str
const end = selectStart < str ? str : selectStart
// end date in the modal is the last night, so add one day
const endDate = toDateStr(new Date(parseDateStr(end).getTime() + 86400000))
setBlockStart(start)
setBlockEnd(endDate)
const range = selectedBlockRange(selectStart, day)
setBlockStart(range.startDate)
setBlockEnd(range.endDate)
setSelectStart(null)
setShowModal(true)
}
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import {
buildConfigForm,
buildRuleForm,
centsToInput,
inputToCents,
numericInputToInt,
toDateInput,
type PricingConfiguration,
type PricingRule,
} from './VehiclePricingManager'
const configuration: PricingConfiguration = {
id: 'cfg_1',
vehicleId: 'veh_1',
pricingMode: 'AUTOMATIC',
baseDailyRate: 50000,
weeklyRate: 280000,
weekendRate: 65000,
holidayRate: null,
monthlyRate: 950000,
longTermDailyRate: null,
minimumDailyRate: 40000,
maximumDailyRate: 80000,
maxDailyPriceMovementPct: 12,
automaticPricingEnabled: true,
approvalRequired: false,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
const rule: PricingRule = {
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01T00:00:00.000Z',
endDate: '2026-08-31T00:00:00.000Z',
dailyRate: 70000,
weeklyRate: null,
weekendRate: 90000,
monthlyRate: null,
minDailyRate: 60000,
maxDailyRate: 110000,
automaticAdjustmentPct: 15,
priceAdjustment: 5000,
isActive: false,
sortOrder: 4,
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-02T00:00:00.000Z',
}
describe('VehiclePricingManager helpers', () => {
it('converts cents to display input strings and empty nullable values', () => {
expect(centsToInput(123456)).toBe('1234.56')
expect(centsToInput(0)).toBe('0.00')
expect(centsToInput(null)).toBe('')
expect(centsToInput(undefined)).toBe('')
})
it('converts currency inputs to cents while rejecting blank or invalid values', () => {
expect(inputToCents('499.99')).toBe(49999)
expect(inputToCents(' 50 ')).toBe(5000)
expect(inputToCents('')).toBeNull()
expect(inputToCents('abc')).toBeNull()
})
it('parses integer-only fields without accepting blank values', () => {
expect(numericInputToInt('12')).toBe(12)
expect(numericInputToInt('12.8')).toBe(12)
expect(numericInputToInt('')).toBeNull()
expect(numericInputToInt('nope')).toBeNull()
})
it('builds a stable editable configuration form from API pricing data', () => {
expect(buildConfigForm(configuration)).toEqual({
pricingMode: 'AUTOMATIC',
baseDailyRate: '500.00',
weeklyRate: '2800.00',
weekendRate: '650.00',
holidayRate: '',
monthlyRate: '9500.00',
longTermDailyRate: '',
minimumDailyRate: '400.00',
maximumDailyRate: '800.00',
maxDailyPriceMovementPct: '12',
automaticPricingEnabled: true,
approvalRequired: false,
})
})
it('builds an editable rule form from an existing pricing rule', () => {
expect(buildRuleForm(rule)).toEqual({
id: 'rule_1',
name: 'Summer',
ruleType: 'SEASONAL',
startDate: '2026-07-01',
endDate: '2026-08-31',
dailyRate: '700.00',
weeklyRate: '',
weekendRate: '900.00',
monthlyRate: '',
minDailyRate: '600.00',
maxDailyRate: '1100.00',
automaticAdjustmentPct: '15',
priceAdjustment: '50.00',
isActive: false,
sortOrder: '4',
})
})
it('defaults new rules to today through six days later', () => {
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
expect(buildRuleForm()).toMatchObject({
name: '',
ruleType: 'DATE_RANGE',
startDate: '2026-06-09',
endDate: '2026-06-15',
isActive: true,
sortOrder: '0',
})
vi.useRealTimers()
})
it('trims ISO timestamps for date input controls', () => {
expect(toDateInput('2026-10-04T22:15:00.000Z')).toBe('2026-10-04')
})
})
@@ -6,7 +6,7 @@ import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface PricingConfiguration {
export interface PricingConfiguration {
id: string
vehicleId: string
pricingMode: 'MANUAL' | 'AUTOMATIC'
@@ -25,7 +25,7 @@ interface PricingConfiguration {
updatedAt: string
}
interface PricingRule {
export interface PricingRule {
id: string
name: string
ruleType: 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE'
@@ -45,7 +45,7 @@ interface PricingRule {
updatedAt: string
}
interface PricingHistoryEntry {
export interface PricingHistoryEntry {
id: string
source: 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE'
changedByEmployeeId: string | null
@@ -58,14 +58,14 @@ interface PricingHistoryEntry {
createdAt: string
}
interface PricingPreviewItem {
export interface PricingPreviewItem {
date: string
dailyRate: number
pricingType: 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE'
matchedRuleName: string | null
}
interface PricingResponse {
export interface PricingResponse {
configuration: PricingConfiguration
rules: PricingRule[]
history: PricingHistoryEntry[]
@@ -75,7 +75,7 @@ interface PricingResponse {
}
}
interface ConfigForm {
export interface ConfigForm {
pricingMode: 'MANUAL' | 'AUTOMATIC'
baseDailyRate: string
weeklyRate: string
@@ -90,7 +90,7 @@ interface ConfigForm {
approvalRequired: boolean
}
interface RuleForm {
export interface RuleForm {
id?: string
name: string
ruleType: PricingRule['ruleType']
@@ -108,28 +108,28 @@ interface RuleForm {
sortOrder: string
}
function centsToInput(value: number | null | undefined) {
export function centsToInput(value: number | null | undefined) {
return value == null ? '' : (value / 100).toFixed(2)
}
function inputToCents(value: string) {
export function inputToCents(value: string) {
if (!value.trim()) return null
const parsed = Number.parseFloat(value)
if (!Number.isFinite(parsed)) return null
return Math.round(parsed * 100)
}
function numericInputToInt(value: string) {
export function numericInputToInt(value: string) {
if (!value.trim()) return null
const parsed = Number.parseInt(value, 10)
return Number.isFinite(parsed) ? parsed : null
}
function toDateInput(value: string) {
export function toDateInput(value: string) {
return value.slice(0, 10)
}
function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
export function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
return {
pricingMode: configuration.pricingMode,
baseDailyRate: centsToInput(configuration.baseDailyRate),
@@ -146,7 +146,7 @@ function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
}
}
function buildRuleForm(rule?: PricingRule): RuleForm {
export function buildRuleForm(rule?: PricingRule): RuleForm {
const today = new Date().toISOString().slice(0, 10)
const nextWeek = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
@@ -0,0 +1,32 @@
import React, { isValidElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/components/layout/PublicHeader', () => ({ default: function MockPublicHeader() { return React.createElement('mock-header') } }))
vi.mock('@/components/layout/PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
import PublicShell from './PublicShell'
function childTypes(node: React.ReactElement): string[] {
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
const type = child.type as any
if (typeof type === 'string') return type
return type.displayName ?? type.name ?? 'anonymous'
})
}
describe('dashboard PublicShell', () => {
it('wraps public pages with header, content, and footer by default', () => {
const shell = PublicShell({ children: React.createElement('main', { id: 'content' }) })
expect(isValidElement(shell)).toBe(true)
expect(childTypes(shell)).toEqual(['MockPublicHeader', 'div', 'MockPublicFooter'])
})
it('omits chrome when embedded so partner surfaces do not get nested headers', () => {
const shell = PublicShell({ embedded: true, children: React.createElement('main', { id: 'content' }) })
expect(childTypes(shell)).toEqual(['div'])
const contentWrapper = React.Children.toArray(shell.props.children).find(isValidElement) as React.ReactElement
expect(contentWrapper.props.children).toEqual(React.createElement('main', { id: 'content' }))
})
})
@@ -25,7 +25,7 @@ import {
} from 'lucide-react'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
@@ -166,9 +166,6 @@ export default function Sidebar() {
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
let cancelled = false
async function loadBrand() {
@@ -202,16 +199,6 @@ export default function Sidebar() {
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
@@ -344,9 +331,8 @@ export default function Sidebar() {
}
function signOut() {
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-logout' })
window.location.href = marketplaceUrl
@@ -4,7 +4,7 @@ import { Bell } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { io } from 'socket.io-client'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
@@ -52,11 +52,6 @@ export default function TopBar() {
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
function getEmployeeToken() {
if (typeof window === 'undefined') return null
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
}
const title = (() => {
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
@@ -65,15 +60,12 @@ export default function TopBar() {
return dict.titles['/']
})()
async function refreshUnreadCount() {
if (!getEmployeeToken()) {
setUnreadCount(0)
return
}
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread)
} catch {}
} catch {
setUnreadCount(0)
}
}
useEffect(() => {
@@ -89,15 +81,12 @@ export default function TopBar() {
}, [])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) return
const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return
const socket = io(socketOrigin, {
autoConnect: false,
auth: { token },
withCredentials: true,
transports: ['polling', 'websocket'],
})
@@ -121,11 +110,6 @@ export default function TopBar() {
useEffect(() => {
if (!showNotifs) return
if (!getEmployeeToken()) {
setNotifications([])
setLoadingNotifs(false)
return
}
let cancelled = false
setLoadingNotifs(true)
@@ -157,12 +141,6 @@ export default function TopBar() {
}, [pathname])
useEffect(() => {
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
if (!token) {
setUserInitials(computeInitials(dict.workspaceUser))
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
@@ -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 (
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { bilingualPrimary, emptyBilingual } from './BilingualInput'
describe('bilingual field helpers', () => {
it('returns a stable empty field shape for French and Arabic labels', () => {
expect(emptyBilingual()).toEqual({ fr: '', ar: '' })
expect(emptyBilingual()).not.toBe(emptyBilingual())
})
it('prefers French as the primary display value when both translations exist', () => {
expect(bilingualPrimary({ fr: 'Assurance', ar: 'تأمين' })).toBe('Assurance')
})
it('falls back to Arabic when the French value is empty', () => {
expect(bilingualPrimary({ fr: '', ar: 'تأمين' })).toBe('تأمين')
})
it('does not trim values implicitly so form state remains lossless', () => {
expect(bilingualPrimary({ fr: ' Service premium ', ar: 'خدمة' })).toBe(' Service premium ')
expect(bilingualPrimary({ fr: '', ar: ' خدمة ' })).toBe(' خدمة ')
})
})
@@ -0,0 +1,72 @@
import React, { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import StatCard from './StatCard'
import type { LucideIcon } from 'lucide-react'
function collectText(node: unknown): string[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
if (Array.isArray(node)) return node.flatMap(collectText)
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
return []
}
function collectElements(node: unknown): React.ReactElement[] {
if (node === null || node === undefined || typeof node === 'boolean') return []
if (Array.isArray(node)) return node.flatMap(collectElements)
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
return [node, ...collectElements(node.props.children)]
}
function FakeIcon(props: { className?: string }) {
return React.createElement('svg', props)
}
const icon = FakeIcon as unknown as LucideIcon
describe('StatCard', () => {
it('renders title, numeric value, and positive changes with a plus sign', () => {
const element = StatCard({ title: 'Revenue', value: 12450, change: 8.238, icon })
const text = collectText(element)
const renderedText = text.join('')
expect(text).toContain('Revenue')
expect(text).toContain('12450')
expect(renderedText).toContain('+8.2%')
expect(text).toContain('vs last month')
})
it('renders negative changes without forcing a positive marker', () => {
const element = StatCard({ title: 'Cancellations', value: '12', change: -3.15, vsLastMonthLabel: 'vs previous period', icon })
const text = collectText(element)
const renderedText = text.join('')
expect(renderedText).toContain('-3.1%')
expect(text).toContain('vs previous period')
expect(renderedText).not.toContain('+-3.1')
})
it('omits the trend row when no change is provided', () => {
const element = StatCard({ title: 'Active cars', value: 31, icon })
const text = collectText(element)
expect(text).toContain('Active cars')
expect(text).toContain('31')
expect(text).not.toContain('vs last month')
expect(text.join('')).not.toContain('%')
})
it('passes custom icon color and background classes to the rendered icon shell', () => {
const element = StatCard({
title: 'Fleet',
value: 9,
icon,
iconColor: 'text-violet-700',
iconBg: 'bg-violet-50',
})
const elements = collectElements(element)
expect(elements.some((node) => String(node.props.className ?? '').includes('bg-violet-50'))).toBe(true)
expect(elements.some((node) => String(node.props.className ?? '').includes('text-violet-700'))).toBe(true)
})
})