Files
motovaultpro/frontend/src/features/vehicles/components/TCODisplay.tsx
Eric Gullickson a8c4eba8d1 feat: add ownership-costs feature capsule (refs #15)
- Create ownership_costs table for recurring vehicle costs
- Add backend feature capsule with types, repository, service, routes
- Update TCO calculation to use ownership_costs (with fallback to legacy vehicle fields)
- Add taxCosts and otherCosts to TCO response
- Create frontend ownership-costs feature with form, list, API, hooks
- Update TCODisplay to show all cost types

This implements a more flexible approach to tracking recurring ownership costs
(insurance, registration, tax, other) with explicit date ranges and optional
document association.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:28:25 -06:00

141 lines
4.3 KiB
TypeScript

/**
* @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.taxCosts > 0 && (
<div>Tax: {currencySymbol}{formatCurrency(tco.taxCosts)}</div>
)}
{tco.otherCosts > 0 && (
<div>Other: {currencySymbol}{formatCurrency(tco.otherCosts)}</div>
)}
{tco.fuelCosts > 0 && (
<div>Fuel: {currencySymbol}{formatCurrency(tco.fuelCosts)}</div>
)}
{tco.maintenanceCosts > 0 && (
<div>Maintenance: {currencySymbol}{formatCurrency(tco.maintenanceCosts)}</div>
)}
</div>
</div>
</div>
);
};