All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 1m11s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 42s
Deploy to Staging / Verify Staging (pull_request) Successful in 4s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 3s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Postgres numeric columns come back as strings via node-postgres, so typeof rec.cost === 'number' was false and the amount column rendered as '—'. Coerce with Number() (matching the pattern in MaintenanceRecordsList) so the cost displays as a dollar amount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
370 lines
14 KiB
TypeScript
370 lines
14 KiB
TypeScript
/**
|
|
* @ai-summary Mobile vehicle detail screen with Material Design 3
|
|
*/
|
|
|
|
import React, { useMemo, useState } from 'react';
|
|
import dayjs from 'dayjs';
|
|
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 { useMaintenanceRecords } from '../../maintenance/hooks/useMaintenanceRecords';
|
|
import { getCategoryDisplayName } from '../../maintenance/types/maintenance.types';
|
|
import type { MaintenanceRecordResponse } from '../../maintenance/types/maintenance.types';
|
|
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 { records: maintenanceRecords, isRecordsLoading: isMaintenanceLoading } = useMaintenanceRecords(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
|
|
});
|
|
}
|
|
}
|
|
|
|
if (maintenanceRecords && Array.isArray(maintenanceRecords)) {
|
|
for (const rec of maintenanceRecords as MaintenanceRecordResponse[]) {
|
|
const catLabel = getCategoryDisplayName(rec.category);
|
|
const subtypes = Array.isArray(rec.subtypes) ? rec.subtypes : [];
|
|
const subtypeText = subtypes.length
|
|
? `${subtypes.slice(0, 3).join(', ')}${subtypes.length > 3 ? ` +${subtypes.length - 3}` : ''}`
|
|
: '';
|
|
const summary = subtypeText ? `${catLabel} — ${subtypeText}` : catLabel;
|
|
const secondaryParts: string[] = [];
|
|
if (rec.shopName) secondaryParts.push(rec.shopName);
|
|
secondaryParts.push(new Date(rec.date).toLocaleDateString());
|
|
secondaryParts.push('Maintenance');
|
|
const secondary = secondaryParts.join(' • ');
|
|
// Backend returns numeric/decimal columns as strings via node-postgres; coerce.
|
|
const costNum = rec.cost != null ? Number(rec.cost) : NaN;
|
|
const amount = Number.isFinite(costNum) ? `$${costNum.toFixed(2)}` : undefined;
|
|
list.push({
|
|
id: rec.id,
|
|
type: 'Maintenance',
|
|
date: rec.date,
|
|
summary,
|
|
amount,
|
|
secondary
|
|
});
|
|
}
|
|
}
|
|
|
|
return list.sort((a, b) => b.date.localeCompare(a.date));
|
|
}, [fuelLogs, maintenanceRecords]);
|
|
|
|
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 || isMaintenanceLoading) ? '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 || isMaintenanceLoading) ? (
|
|
<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 || `${dayjs(rec.date).format('M/D/YYYY')} • ${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>
|
|
);
|
|
};
|