Files
motovaultpro/frontend/src/features/vehicles/mobile/VehicleDetailMobile.tsx
Eric Gullickson 7140c7e8d4
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m24s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
fix: wire up Add Maintenance button on vehicle detail page (refs #194)
Rename "Schedule Maintenance" to "Add Maintenance", match contained
button style to "Add Fuel Log", and open inline MaintenanceRecordForm
dialog on click. Applied to both desktop and mobile views.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 10:01:33 -06:00

337 lines
12 KiB
TypeScript

/**
* @ai-summary Mobile vehicle detail screen with Material Design 3
*/
import React, { useMemo, useState } from 'react';
import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemButton, Dialog, DialogTitle, DialogContent } from '@mui/material';
import { useQueryClient } from '@tanstack/react-query';
import { Vehicle } from '../types/vehicles.types';
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 { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
import { MaintenanceRecordForm } from '../../maintenance/components/MaintenanceRecordForm';
import { VehicleImage } from '../components/VehicleImage';
import { OwnershipCostsList } from '../../ownership-costs';
import { getVehicleLabel } from '@/core/utils/vehicleDisplay';
interface VehicleDetailMobileProps {
vehicle: Vehicle;
onBack: () => void;
onLogFuel?: () => void;
onEdit?: () => void;
}
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({
title,
children
}) => (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
{title}
</Typography>
{children}
</Box>
);
export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
vehicle,
onBack,
onLogFuel,
onEdit
}) => {
const displayName = getVehicleLabel(vehicle);
const displayModel = vehicle.model || 'Unknown Model';
const [recordFilter, setRecordFilter] = useState<'All' | 'Fuel Logs' | 'Maintenance' | 'Documents'>('All');
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
const queryClient = useQueryClient();
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
const [showMaintenanceDialog, setShowMaintenanceDialog] = useState(false);
// Unit conversions are now handled by the backend
type VehicleRecord = {
id: string;
type: 'Fuel Logs' | 'Maintenance' | 'Documents';
date: string; // ISO
summary: string;
amount?: string;
secondary?: string;
};
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[]) {
// Use efficiency from API response (backend calculates this properly)
let efficiency = '';
if (typeof log.efficiency === 'number' && log.efficiency > 0) {
// DEBUG: Log what the backend is actually returning
console.log('🔍 Fuel Log API Response:', {
efficiency: log.efficiency,
efficiencyLabel: log.efficiencyLabel,
logId: log.id
});
// Backend returns efficiency in correct units based on user preference
efficiency = `${log.efficiencyLabel || 'MPG'}: ${log.efficiency.toFixed(3)}`;
}
// Secondary line components
const secondaryParts: string[] = [];
// Grade label (prefer grade only)
if (log.fuelGrade) {
secondaryParts.push(`Grade: ${log.fuelGrade}`);
} else if (log.fuelType) {
const ft = String(log.fuelType);
secondaryParts.push(ft.charAt(0).toUpperCase() + ft.slice(1));
}
// Date
secondaryParts.push(new Date(log.dateTime).toLocaleDateString());
// Type
secondaryParts.push('Fuel Log');
const summary = efficiency; // Primary line shows MPG/km/L from backend
const secondary = secondaryParts.join(' • '); // Secondary line shows Grade • Date • Type
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
// DEBUG: Log the final display values
console.log('🎯 Display Summary:', { summary, efficiency, logId: log.id });
list.push({
id: log.id,
type: 'Fuel Logs',
date: log.dateTime,
summary,
amount,
secondary
});
}
}
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]);
const openEditLog = (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', vehicle.id] });
await queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
setEditingLog(null);
};
return (
<Box sx={{ pb: 10 }}>
<Button variant="text" onClick={onBack}>
Back
</Button>
<Typography variant="h4" sx={{ mt: 1, mb: 2 }}>
{displayName}
</Typography>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 3 }}>
<Box sx={{ width: 112 }}>
<VehicleImage vehicle={vehicle} height={96} />
</Box>
<Box>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{displayName}
</Typography>
<Typography color="text.secondary">{displayModel}</Typography>
{vehicle.licensePlate && (
<Typography variant="body2" color="text.secondary">
{vehicle.licensePlate}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1.5, mb: 3, flexWrap: 'wrap' }}>
<Button
variant="contained"
onClick={onEdit}
>
Edit Vehicle
</Button>
<Button
variant="outlined"
onClick={onLogFuel}
>
Add Fuel
</Button>
<Button
variant="outlined"
onClick={() => setShowMaintenanceDialog(true)}
>
Add Maintenance
</Button>
</Box>
<Section title="Vehicle Details">
<Card>
<CardContent>
{vehicle.vin && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography color="text.secondary">VIN</Typography>
<Typography sx={{ fontFamily: 'monospace', fontSize: 'small' }}>
{vehicle.vin}
</Typography>
</Box>
)}
{vehicle.year && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography color="text.secondary">Year</Typography>
<Typography>{vehicle.year}</Typography>
</Box>
)}
{vehicle.make && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography color="text.secondary">Make</Typography>
<Typography>{vehicle.make}</Typography>
</Box>
)}
{vehicle.model && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography color="text.secondary">Model</Typography>
<Typography>{vehicle.model}</Typography>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography color="text.secondary">Odometer</Typography>
<Typography>{vehicle.odometerReading.toLocaleString()} mi</Typography>
</Box>
</CardContent>
</Card>
</Section>
<Section title="Recurring Costs">
<Card>
<CardContent>
<OwnershipCostsList vehicleId={vehicle.id} />
</CardContent>
</Card>
</Section>
<Section title="Vehicle Records">
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
{isFuelLoading ? 'Loading…' : `${filteredRecords.length} record${filteredRecords.length === 1 ? '' : 's'}`}
</Typography>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="vehicle-records-filter">Filter</InputLabel>
<Select
labelId="vehicle-records-filter"
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>
<Card>
<CardContent sx={{ p: 0 }}>
{isFuelLoading ? (
<Box sx={{ p: 2 }}>
<Typography color="text.secondary" variant="body2">Loading records</Typography>
</Box>
) : filteredRecords.length === 0 ? (
<Box sx={{ p: 2 }}>
<Typography color="text.secondary" variant="body2">No records found for this filter.</Typography>
</Box>
) : (
<List disablePadding>
{filteredRecords.map(rec => (
<ListItem key={rec.id} divider disablePadding>
<ListItemButton onClick={() => openEditLog(rec.id, rec.type)}>
<Box sx={{ flexGrow: 1 }}>
{/* Primary line: MPG/km-L and amount */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1" color="text.primary">
{rec.summary || 'MPG: —'}
</Typography>
<Typography variant="body1" color="text.primary">
{rec.amount || '—'}
</Typography>
</Box>
{/* Secondary line: Grade • Date • Type */}
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
{rec.secondary || `${new Date(rec.date).toLocaleDateString()}${rec.type}`}
</Typography>
</Box>
</ListItemButton>
</ListItem>
))}
</List>
)}
</CardContent>
</Card>
</Section>
{/* Edit Dialog for Fuel Logs */}
<FuelLogEditDialog
open={!!editingLog}
log={editingLog}
onClose={handleCloseEdit}
onSave={handleSaveEdit}
/>
{/* Add Maintenance Dialog */}
<Dialog
open={showMaintenanceDialog}
onClose={() => setShowMaintenanceDialog(false)}
maxWidth="md"
fullWidth
fullScreen
PaperProps={{
sx: { maxHeight: '90vh' }
}}
>
<DialogTitle>Add Maintenance</DialogTitle>
<DialogContent>
<Box sx={{ mt: 1 }}>
<MaintenanceRecordForm
vehicleId={vehicle.id}
onSuccess={() => {
setShowMaintenanceDialog(false);
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords', vehicle.id] });
queryClient.invalidateQueries({ queryKey: ['maintenanceRecords'] });
}}
/>
</Box>
</DialogContent>
</Dialog>
</Box>
);
};