Files
motovaultpro/frontend/src/features/vehicles/pages/VehicleDetailPage.tsx
Eric Gullickson 2e1b588270 UX Improvements
2025-09-26 14:45:03 -05:00

424 lines
14 KiB
TypeScript

/**
* @ai-summary Vehicle detail page matching VehicleForm styling
*/
import React, { useMemo, useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery } 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 { Vehicle } from '../types/vehicles.types';
import { vehiclesApi } from '../api/vehicles.api';
import { Card } from '../../../shared-minimal/components/Card';
import { VehicleForm } from '../components/VehicleForm';
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';
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">
{label} {isRequired && <span className="text-red-500">*</span>}
</label>
<div className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
<span className="text-gray-900">
{value || <span className="text-gray-400 italic">Not provided</span>}
</span>
</div>
</div>
);
export const VehicleDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
const queryClient = useQueryClient();
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
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 });
}
}
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}, [fuelLogs]);
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('/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);
} catch (err) {
console.error('Error updating vehicle:', err);
}
};
const handleCancelEdit = () => {
setIsEditing(false);
};
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 = vehicle.nickname ||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || '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);
}
};
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}
/>
</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>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
Vehicle Details
</Typography>
<form className="space-y-4">
<DetailField
label="VIN or License Plate"
value={vehicle.vin || vehicle.licensePlate}
isRequired
/>
{/* 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}
/>
</form>
<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>
</TableRow>
</TableHead>
<TableBody>
{isFuelLoading && (
<TableRow>
<TableCell colSpan={4}>
<Typography color="text.secondary">Loading records</Typography>
</TableCell>
</TableRow>
)}
{!isFuelLoading && filteredRecords.length === 0 && (
<TableRow>
<TableCell colSpan={4}>
<Typography color="text.secondary">No records found for this filter.</Typography>
</TableCell>
</TableRow>
)}
{!isFuelLoading && filteredRecords.map((rec) => (
<TableRow key={rec.id} hover sx={{ cursor: '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>
</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>
</Card>
</Box>
);
};