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