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
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>
337 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|