feat: add TCO display component (refs #15)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 5m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 29s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 5m41s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 29s
Deploy to Staging / Verify Staging (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 5s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
- Create TCODisplay component showing lifetime cost and cost per distance - Display cost breakdown (purchase, insurance, registration, fuel, maintenance) - Integrate into VehicleDetailPage right-justified next to vehicle details - Responsive layout: stacks vertically on mobile, side-by-side on desktop - Only shows when tcoEnabled is true 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
134
frontend/src/features/vehicles/components/TCODisplay.tsx
Normal file
134
frontend/src/features/vehicles/components/TCODisplay.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary TCO (Total Cost of Ownership) display component
|
||||||
|
* Right-justified display showing lifetime cost and cost per mile/km
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { TCOResponse } from '../types/vehicles.types';
|
||||||
|
import { vehiclesApi } from '../api/vehicles.api';
|
||||||
|
|
||||||
|
interface TCODisplayProps {
|
||||||
|
vehicleId: string;
|
||||||
|
tcoEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currency symbol mapping
|
||||||
|
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||||
|
USD: '$',
|
||||||
|
EUR: '€',
|
||||||
|
GBP: '£',
|
||||||
|
CAD: 'CA$',
|
||||||
|
AUD: 'A$',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TCODisplay: React.FC<TCODisplayProps> = ({ vehicleId, tcoEnabled }) => {
|
||||||
|
const [tco, setTco] = useState<TCOResponse | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tcoEnabled) {
|
||||||
|
setTco(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchTCO = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await vehiclesApi.getTCO(vehicleId);
|
||||||
|
setTco(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch TCO:', err);
|
||||||
|
setError('Unable to load TCO data');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTCO();
|
||||||
|
}, [vehicleId, tcoEnabled]);
|
||||||
|
|
||||||
|
// Don't render if TCO is disabled
|
||||||
|
if (!tcoEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-right animate-pulse" role="region" aria-label="Total Cost of Ownership">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-silverstone rounded w-32 ml-auto mb-1"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-silverstone rounded w-24 ml-auto mb-2"></div>
|
||||||
|
<div className="h-6 bg-gray-200 dark:bg-silverstone rounded w-20 ml-auto mb-1"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-silverstone rounded w-24 ml-auto"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="text-right text-sm text-gray-500 dark:text-titanio" role="region" aria-label="Total Cost of Ownership">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No data
|
||||||
|
if (!tco) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencySymbol = CURRENCY_SYMBOLS[tco.currencyCode] || tco.currencyCode;
|
||||||
|
|
||||||
|
// Format currency with proper separators
|
||||||
|
const formatCurrency = (value: number): string => {
|
||||||
|
return value.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-right" role="region" aria-label="Total Cost of Ownership">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-avus">
|
||||||
|
{currencySymbol}{formatCurrency(tco.lifetimeTotal)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-titanio mb-2">
|
||||||
|
Lifetime Total
|
||||||
|
</div>
|
||||||
|
{tco.costPerDistance > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="text-lg text-gray-700 dark:text-canna">
|
||||||
|
{currencySymbol}{formatCurrency(tco.costPerDistance)}/{tco.distanceUnit}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-titanio">
|
||||||
|
Cost per {tco.distanceUnit}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cost breakdown tooltip/details */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-silverstone">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-titanio text-right space-y-1">
|
||||||
|
{tco.purchasePrice > 0 && (
|
||||||
|
<div>Purchase: {currencySymbol}{formatCurrency(tco.purchasePrice)}</div>
|
||||||
|
)}
|
||||||
|
{tco.insuranceCosts > 0 && (
|
||||||
|
<div>Insurance: {currencySymbol}{formatCurrency(tco.insuranceCosts)}</div>
|
||||||
|
)}
|
||||||
|
{tco.registrationCosts > 0 && (
|
||||||
|
<div>Registration: {currencySymbol}{formatCurrency(tco.registrationCosts)}</div>
|
||||||
|
)}
|
||||||
|
{tco.fuelCosts > 0 && (
|
||||||
|
<div>Fuel: {currencySymbol}{formatCurrency(tco.fuelCosts)}</div>
|
||||||
|
)}
|
||||||
|
{tco.maintenanceCosts > 0 && (
|
||||||
|
<div>Maintenance: {currencySymbol}{formatCurrency(tco.maintenanceCosts)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { vehiclesApi } from '../api/vehicles.api';
|
|||||||
import { Card } from '../../../shared-minimal/components/Card';
|
import { Card } from '../../../shared-minimal/components/Card';
|
||||||
import { VehicleForm } from '../components/VehicleForm';
|
import { VehicleForm } from '../components/VehicleForm';
|
||||||
import { VehicleImage } from '../components/VehicleImage';
|
import { VehicleImage } from '../components/VehicleImage';
|
||||||
|
import { TCODisplay } from '../components/TCODisplay';
|
||||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||||
@@ -300,8 +301,8 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Box sx={{ display: 'flex', gap: 3, mb: 3 }}>
|
<Box sx={{ display: 'flex', gap: 3, mb: 3, flexWrap: { xs: 'wrap', md: 'nowrap' } }}>
|
||||||
<Box sx={{ width: 200, flexShrink: 0 }}>
|
<Box sx={{ width: { xs: '100%', sm: 200 }, flexShrink: 0 }}>
|
||||||
<VehicleImage vehicle={vehicle} height={150} />
|
<VehicleImage vehicle={vehicle} height={150} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
@@ -318,6 +319,14 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* TCO Display - right-justified */}
|
||||||
|
<Box sx={{
|
||||||
|
width: { xs: '100%', md: 'auto' },
|
||||||
|
minWidth: { md: 200 },
|
||||||
|
mt: { xs: 2, md: 0 }
|
||||||
|
}}>
|
||||||
|
<TCODisplay vehicleId={vehicle.id} tcoEnabled={vehicle.tcoEnabled} />
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<form className="space-y-4">
|
<form className="space-y-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user