Create shared getVehicleLabel/getVehicleSubtitle in core/utils with VehicleLike interface. Replace all direct year/make/model concatenation across 17 consumer files to prevent null values in vehicle names. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
560 lines
20 KiB
TypeScript
560 lines
20 KiB
TypeScript
/**
|
|
* @ai-summary Vehicle detail page matching VehicleForm styling
|
|
*/
|
|
|
|
import React, { useMemo, useState, useEffect } from 'react';
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
|
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery, IconButton } from '@mui/material';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
|
import BuildIcon from '@mui/icons-material/Build';
|
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
import { Vehicle } from '../types/vehicles.types';
|
|
import { getVehicleLabel, getVehicleSubtitle } from '@/core/utils/vehicleDisplay';
|
|
import { vehiclesApi } from '../api/vehicles.api';
|
|
import { Card } from '../../../shared-minimal/components/Card';
|
|
import { VehicleForm } from '../components/VehicleForm';
|
|
import { VehicleImage } from '../components/VehicleImage';
|
|
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
|
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
|
import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
|
// Unit conversions now handled by backend
|
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
|
import { OwnershipCostsList } from '../../ownership-costs';
|
|
import { useDocumentsByVehicle, useDeleteDocument, useRemoveVehicleFromDocument } from '../../documents/hooks/useDocuments';
|
|
import { DeleteDocumentConfirmDialog } from '../../documents/components/DeleteDocumentConfirmDialog';
|
|
import type { DocumentRecord } from '../../documents/types/documents.types';
|
|
|
|
const DetailField: React.FC<{
|
|
label: string;
|
|
value?: string | number;
|
|
isRequired?: boolean;
|
|
className?: string;
|
|
}> = ({ label, value, isRequired, className = "" }) => (
|
|
<div className={`space-y-1 ${className}`}>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-avus">
|
|
{label} {isRequired && <span className="text-red-500">*</span>}
|
|
</label>
|
|
<div className="px-3 py-2 bg-gray-50 dark:bg-scuro border border-gray-200 dark:border-silverstone rounded-md">
|
|
<span className="text-gray-900 dark:text-avus">
|
|
{value || <span className="text-gray-400 dark:text-titanio italic">Not provided</span>}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
export const VehicleDetailPage: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isEditing, setIsEditing] = useState(() => searchParams.get('edit') === 'true');
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
|
|
|
|
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
|
const { data: documents, isLoading: isDocumentsLoading } = useDocumentsByVehicle(id);
|
|
const { mutateAsync: deleteDocument } = useDeleteDocument();
|
|
const { mutateAsync: removeVehicleFromDocument } = useRemoveVehicleFromDocument();
|
|
const queryClient = useQueryClient();
|
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentRecord | null>(null);
|
|
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
|
// Unit conversions now handled by backend
|
|
|
|
// Define records list hooks BEFORE any early returns to keep hooks order stable
|
|
type VehicleRecord = {
|
|
id: string;
|
|
type: 'Fuel Logs' | 'Maintenance' | 'Documents';
|
|
date: string; // ISO
|
|
summary: string;
|
|
amount?: string; // formatted
|
|
};
|
|
|
|
const records: VehicleRecord[] = useMemo(() => {
|
|
const list: VehicleRecord[] = [];
|
|
if (fuelLogs && Array.isArray(fuelLogs)) {
|
|
// Build a map of prior odometer readings to compute trip distance when missing
|
|
const logsAsc = [...(fuelLogs as FuelLogResponse[])].sort(
|
|
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
|
);
|
|
const prevOdoById = new Map<string, number | undefined>();
|
|
let lastOdo: number | undefined = undefined;
|
|
for (const l of logsAsc) {
|
|
prevOdoById.set(l.id, lastOdo);
|
|
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
|
lastOdo = l.odometerReading;
|
|
}
|
|
}
|
|
|
|
for (const log of fuelLogs as FuelLogResponse[]) {
|
|
const parts: string[] = [];
|
|
|
|
// Efficiency: Use backend calculation (primary display)
|
|
if (typeof log.efficiency === 'number' && log.efficiency > 0) {
|
|
parts.push(`${log.efficiencyLabel || 'MPG'}: ${log.efficiency.toFixed(3)}`);
|
|
}
|
|
|
|
// Grade label (secondary display)
|
|
if (log.fuelGrade) {
|
|
parts.push(`Grade: ${log.fuelGrade}`);
|
|
} else if (log.fuelType) {
|
|
const ft = String(log.fuelType);
|
|
parts.push(ft.charAt(0).toUpperCase() + ft.slice(1));
|
|
}
|
|
|
|
const summary = parts.join(' • ');
|
|
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
|
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
|
}
|
|
}
|
|
|
|
// Add documents to records
|
|
if (documents && Array.isArray(documents)) {
|
|
for (const doc of documents) {
|
|
const parts: string[] = [];
|
|
parts.push(doc.title);
|
|
parts.push(doc.documentType.charAt(0).toUpperCase() + doc.documentType.slice(1));
|
|
if (doc.expirationDate) {
|
|
parts.push(`Expires: ${new Date(doc.expirationDate).toLocaleDateString()}`);
|
|
}
|
|
const summary = parts.join(' • ');
|
|
const date = doc.issuedDate || doc.createdAt;
|
|
list.push({ id: doc.id, type: 'Documents', date, summary, amount: undefined });
|
|
}
|
|
}
|
|
|
|
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
}, [fuelLogs, documents]);
|
|
|
|
const filteredRecords = useMemo(() => {
|
|
if (recordFilter === 'All') return records;
|
|
return records.filter(r => r.type === recordFilter);
|
|
}, [records, recordFilter]);
|
|
|
|
useEffect(() => {
|
|
const loadVehicle = async () => {
|
|
if (!id) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const vehicleData = await vehiclesApi.getById(id);
|
|
setVehicle(vehicleData);
|
|
} catch (err) {
|
|
setError('Failed to load vehicle details');
|
|
console.error('Error loading vehicle:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
loadVehicle();
|
|
}, [id]);
|
|
|
|
const handleBack = () => {
|
|
navigate('/garage/vehicles');
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
setIsEditing(true);
|
|
};
|
|
|
|
const handleUpdateVehicle = async (data: any) => {
|
|
if (!vehicle) return;
|
|
|
|
try {
|
|
const updatedVehicle = await vehiclesApi.update(vehicle.id, data);
|
|
setVehicle(updatedVehicle);
|
|
setIsEditing(false);
|
|
// Clear the edit query param from URL
|
|
if (searchParams.has('edit')) {
|
|
searchParams.delete('edit');
|
|
setSearchParams(searchParams, { replace: true });
|
|
}
|
|
} catch (err) {
|
|
console.error('Error updating vehicle:', err);
|
|
}
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setIsEditing(false);
|
|
// Clear the edit query param from URL
|
|
if (searchParams.has('edit')) {
|
|
searchParams.delete('edit');
|
|
setSearchParams(searchParams, { replace: true });
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
height: '50vh'
|
|
}}>
|
|
<Typography color="text.secondary">Loading vehicle details...</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (error || !vehicle) {
|
|
return (
|
|
<Box sx={{ py: 2 }}>
|
|
<Card>
|
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
|
<Typography color="error.main" sx={{ mb: 3 }}>
|
|
{error || 'Vehicle not found'}
|
|
</Typography>
|
|
<MuiButton
|
|
variant="outlined"
|
|
onClick={handleBack}
|
|
startIcon={<ArrowBackIcon />}
|
|
>
|
|
Back to Vehicles
|
|
</MuiButton>
|
|
</Box>
|
|
</Card>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const displayName = getVehicleLabel(vehicle);
|
|
|
|
const handleRowClick = (recId: string, type: VehicleRecord['type']) => {
|
|
if (type === 'Fuel Logs') {
|
|
const log = (fuelLogs as FuelLogResponse[] | undefined)?.find(l => l.id === recId) || null;
|
|
setEditingLog(log);
|
|
}
|
|
// Documents are handled via delete button, not row click
|
|
};
|
|
|
|
const handleDeleteDocumentClick = (docId: string, event: React.MouseEvent) => {
|
|
event.stopPropagation(); // Prevent row click
|
|
const doc = documents?.find(d => d.id === docId) || null;
|
|
if (doc) {
|
|
setDocumentToDelete(doc);
|
|
setDeleteDialogOpen(true);
|
|
}
|
|
};
|
|
|
|
const handleDeleteConfirm = async (fullDelete: boolean) => {
|
|
if (!documentToDelete || !id) return;
|
|
|
|
try {
|
|
if (fullDelete) {
|
|
// Full delete
|
|
await deleteDocument(documentToDelete.id);
|
|
} else {
|
|
// Remove vehicle association only
|
|
await removeVehicleFromDocument({ docId: documentToDelete.id, vehicleId: id });
|
|
}
|
|
|
|
// Invalidate queries to refresh data
|
|
queryClient.invalidateQueries({ queryKey: ['documents-by-vehicle', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['documents'] });
|
|
|
|
setDeleteDialogOpen(false);
|
|
setDocumentToDelete(null);
|
|
} catch (err) {
|
|
console.error('Error deleting/removing document:', err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteCancel = () => {
|
|
setDeleteDialogOpen(false);
|
|
setDocumentToDelete(null);
|
|
};
|
|
|
|
const handleCloseEdit = () => setEditingLog(null);
|
|
const handleSaveEdit = async (id: string, data: UpdateFuelLogRequest) => {
|
|
await fuelLogsApi.update(id, data);
|
|
await queryClient.invalidateQueries({ queryKey: ['fuelLogs', id] });
|
|
await queryClient.invalidateQueries({ queryKey: ['fuelLogs', vehicle?.id] });
|
|
await queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
|
setEditingLog(null);
|
|
};
|
|
|
|
if (isEditing) {
|
|
return (
|
|
<Box sx={{ py: 2 }}>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
mb: 4
|
|
}}>
|
|
<MuiButton
|
|
variant="text"
|
|
startIcon={<ArrowBackIcon />}
|
|
onClick={handleCancelEdit}
|
|
sx={{ mr: 2 }}
|
|
>
|
|
Cancel
|
|
</MuiButton>
|
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
|
Edit {displayName}
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Card>
|
|
<VehicleForm
|
|
initialData={vehicle}
|
|
onSubmit={handleUpdateVehicle}
|
|
onCancel={handleCancelEdit}
|
|
onImageUpdate={(updated) => setVehicle(updated)}
|
|
/>
|
|
</Card>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ py: 2 }}>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
mb: 4
|
|
}}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
<MuiButton
|
|
variant="text"
|
|
startIcon={<ArrowBackIcon />}
|
|
onClick={handleBack}
|
|
sx={{ mr: 2 }}
|
|
>
|
|
Back
|
|
</MuiButton>
|
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
|
{displayName}
|
|
</Typography>
|
|
</Box>
|
|
<MuiButton
|
|
variant="contained"
|
|
startIcon={<EditIcon />}
|
|
onClick={handleEdit}
|
|
sx={{ borderRadius: '999px' }}
|
|
>
|
|
Edit Vehicle
|
|
</MuiButton>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
|
|
<MuiButton
|
|
variant="contained"
|
|
startIcon={<LocalGasStationIcon />}
|
|
sx={{ borderRadius: '999px' }}
|
|
onClick={() => setShowAddDialog(true)}
|
|
>
|
|
Add Fuel Log
|
|
</MuiButton>
|
|
<MuiButton
|
|
variant="outlined"
|
|
startIcon={<BuildIcon />}
|
|
sx={{ borderRadius: '999px' }}
|
|
>
|
|
Schedule Maintenance
|
|
</MuiButton>
|
|
</Box>
|
|
|
|
<Card>
|
|
<Box sx={{ display: 'flex', gap: 3, mb: 3, flexWrap: { xs: 'wrap', md: 'nowrap' } }}>
|
|
<Box sx={{ width: { xs: '100%', sm: 200 }, flexShrink: 0 }}>
|
|
<VehicleImage vehicle={vehicle} height={150} />
|
|
</Box>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
|
Vehicle Details
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{getVehicleSubtitle(vehicle) || 'Unknown Vehicle'}
|
|
</Typography>
|
|
{vehicle.vin && (
|
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
|
VIN: {vehicle.vin}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
|
|
<form className="space-y-4">
|
|
<DetailField
|
|
label="VIN Number"
|
|
value={vehicle.vin}
|
|
/>
|
|
|
|
{/* Vehicle Specification Section */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<DetailField label="Year" value={vehicle.year} />
|
|
<DetailField label="Make" value={vehicle.make} />
|
|
<DetailField label="Model" value={vehicle.model} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<DetailField label="Trim" value={vehicle.trimLevel} />
|
|
<DetailField label="Engine" value={vehicle.engine} />
|
|
<DetailField label="Transmission" value={vehicle.transmission} />
|
|
</div>
|
|
|
|
<DetailField label="Nickname" value={vehicle.nickname} />
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<DetailField label="Color" value={vehicle.color} />
|
|
<DetailField label="License Plate" value={vehicle.licensePlate} />
|
|
</div>
|
|
|
|
<DetailField
|
|
label="Current Odometer Reading"
|
|
value={vehicle.odometerReading ? `${vehicle.odometerReading.toLocaleString()} mi` : undefined}
|
|
/>
|
|
|
|
{/* Purchase Information Section */}
|
|
<div className="border-t border-gray-200 dark:border-silverstone pt-4 mt-4">
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-avus mb-4">
|
|
Purchase Information
|
|
</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<DetailField
|
|
label="Purchase Price"
|
|
value={vehicle.purchasePrice ? `$${vehicle.purchasePrice.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : undefined}
|
|
/>
|
|
<DetailField
|
|
label="Purchase Date"
|
|
value={vehicle.purchaseDate ? new Date(vehicle.purchaseDate).toLocaleDateString() : undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<Divider sx={{ my: 4 }} />
|
|
|
|
{/* Recurring Ownership Costs */}
|
|
<OwnershipCostsList vehicleId={vehicle.id} />
|
|
|
|
<Divider sx={{ my: 4 }} />
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
|
Vehicle Records
|
|
</Typography>
|
|
<FormControl size="small" sx={{ minWidth: 220 }}>
|
|
<InputLabel id="record-filter-label">Filter</InputLabel>
|
|
<Select
|
|
labelId="record-filter-label"
|
|
value={recordFilter}
|
|
label="Filter"
|
|
onChange={(e) => setRecordFilter(e.target.value as any)}
|
|
>
|
|
<MenuItem value="All">All</MenuItem>
|
|
<MenuItem value="Fuel Logs">Fuel Logs</MenuItem>
|
|
<MenuItem value="Maintenance">Maintenance</MenuItem>
|
|
<MenuItem value="Documents">Documents</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
|
|
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell sx={{ width: 200 }}>Date</TableCell>
|
|
<TableCell>Type</TableCell>
|
|
<TableCell>Summary</TableCell>
|
|
<TableCell align="right">Amount</TableCell>
|
|
<TableCell align="right" sx={{ width: 80 }}>Actions</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{(isFuelLoading || isDocumentsLoading) && (
|
|
<TableRow>
|
|
<TableCell colSpan={5}>
|
|
<Typography color="text.secondary">Loading records…</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!isFuelLoading && !isDocumentsLoading && filteredRecords.length === 0 && (
|
|
<TableRow>
|
|
<TableCell colSpan={5}>
|
|
<Typography color="text.secondary">No records found for this filter.</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!isFuelLoading && !isDocumentsLoading && filteredRecords.map((rec) => (
|
|
<TableRow key={rec.id} hover sx={{ cursor: rec.type === 'Documents' ? 'default' : 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
|
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell>
|
|
<TableCell>{rec.type}</TableCell>
|
|
<TableCell>{rec.summary}</TableCell>
|
|
<TableCell align="right">{rec.amount || '—'}</TableCell>
|
|
<TableCell align="right">
|
|
{rec.type === 'Documents' && (
|
|
<IconButton
|
|
size="small"
|
|
color="error"
|
|
onClick={(e) => handleDeleteDocumentClick(rec.id, e)}
|
|
sx={{ minWidth: 44, minHeight: 44 }}
|
|
aria-label="Delete document"
|
|
>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
|
|
{/* Edit Dialog for Fuel Logs */}
|
|
<FuelLogEditDialog
|
|
open={!!editingLog}
|
|
log={editingLog}
|
|
onClose={handleCloseEdit}
|
|
onSave={handleSaveEdit}
|
|
/>
|
|
|
|
{/* Add Fuel Log Dialog */}
|
|
<Dialog
|
|
open={showAddDialog}
|
|
onClose={() => setShowAddDialog(false)}
|
|
maxWidth="md"
|
|
fullWidth
|
|
fullScreen={isSmallScreen}
|
|
PaperProps={{
|
|
sx: { maxHeight: '90vh' }
|
|
}}
|
|
>
|
|
<DialogTitle>Add Fuel Log</DialogTitle>
|
|
<DialogContent>
|
|
<Box sx={{ mt: 1 }}>
|
|
<FuelLogForm
|
|
initial={{ vehicleId: vehicle?.id }}
|
|
onSuccess={() => {
|
|
setShowAddDialog(false);
|
|
// Refresh fuel logs data
|
|
queryClient.invalidateQueries({ queryKey: ['fuelLogs', vehicle?.id] });
|
|
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
|
}}
|
|
/>
|
|
</Box>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Document Confirmation Dialog */}
|
|
<DeleteDocumentConfirmDialog
|
|
open={deleteDialogOpen}
|
|
onClose={handleDeleteCancel}
|
|
onConfirm={handleDeleteConfirm}
|
|
document={documentToDelete}
|
|
vehicleId={id || null}
|
|
/>
|
|
</Card>
|
|
</Box>
|
|
);
|
|
};
|