240 lines
8.7 KiB
TypeScript
240 lines
8.7 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, ListItemText } 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';
|
|
|
|
// Theme colors now defined in Tailwind config
|
|
|
|
interface VehicleDetailMobileProps {
|
|
vehicle: Vehicle;
|
|
onBack: () => void;
|
|
onLogFuel?: () => 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>
|
|
);
|
|
|
|
const CarThumb: React.FC<{ color?: string }> = ({ color = "#F2EAEA" }) => (
|
|
<Box
|
|
sx={{
|
|
height: 96,
|
|
bgcolor: color,
|
|
borderRadius: 3,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center'
|
|
}}
|
|
/>
|
|
);
|
|
|
|
export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|
vehicle,
|
|
onBack,
|
|
onLogFuel
|
|
}) => {
|
|
const displayName = vehicle.nickname ||
|
|
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || '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);
|
|
|
|
type VehicleRecord = {
|
|
id: string;
|
|
type: 'Fuel Logs' | 'Maintenance' | 'Documents';
|
|
date: string; // ISO
|
|
summary: string;
|
|
amount?: string;
|
|
};
|
|
|
|
const records: VehicleRecord[] = useMemo(() => {
|
|
const list: VehicleRecord[] = [];
|
|
if (fuelLogs && Array.isArray(fuelLogs)) {
|
|
for (const log of fuelLogs as FuelLogResponse[]) {
|
|
const parts: string[] = [];
|
|
if (log.fuelUnits) parts.push(`${Number(log.fuelUnits).toFixed(3)} units`);
|
|
if (log.fuelType) parts.push(`${log.fuelType}${log.fuelGrade ? ' ' + log.fuelGrade : ''}`);
|
|
if (log.efficiencyLabel) parts.push(log.efficiencyLabel);
|
|
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]);
|
|
|
|
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 }}>
|
|
<CarThumb color={vehicle.color || "#F2EAEA"} />
|
|
</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 }}>
|
|
<Button
|
|
variant="contained"
|
|
onClick={onLogFuel}
|
|
>
|
|
Add Fuel
|
|
</Button>
|
|
<Button variant="outlined">
|
|
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="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 button onClick={() => openEditLog(rec.id, rec.type)}>
|
|
<ListItemText
|
|
primary={rec.summary || new Date(rec.date).toLocaleString()}
|
|
secondary={`${new Date(rec.date).toLocaleString()} • ${rec.type}`}
|
|
/>
|
|
<Typography sx={{ ml: 2 }} color="text.primary">
|
|
{rec.amount || '—'}
|
|
</Typography>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</Section>
|
|
|
|
{/* Edit Dialog for Fuel Logs */}
|
|
<FuelLogEditDialog
|
|
open={!!editingLog}
|
|
log={editingLog}
|
|
onClose={handleCloseEdit}
|
|
onSave={handleSaveEdit}
|
|
/>
|
|
</Box>
|
|
);
|
|
};
|