Files
motovaultpro/frontend/src/features/fuel-logs/components/FuelLogsList.tsx
Eric Gullickson 2e1b588270 UX Improvements
2025-09-26 14:45:03 -05:00

284 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useMemo, useState } from 'react';
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemText,
Chip,
Box,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
useTheme,
useMediaQuery
} from '@mui/material';
import { Edit, Delete } from '@mui/icons-material';
import { FuelLogResponse } from '../types/fuel-logs.types';
import { fuelLogsApi } from '../api/fuel-logs.api';
import { useUnits } from '../../../core/units/UnitsContext';
interface FuelLogsListProps {
logs?: FuelLogResponse[];
onEdit?: (log: FuelLogResponse) => void;
onDelete?: (logId: string) => void;
}
export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDelete }) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const { unitSystem, convertDistance, convertVolume } = useUnits();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [logToDelete, setLogToDelete] = useState<FuelLogResponse | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Precompute previous odometer per log for delta calculations
const prevOdoById = useMemo(() => {
const map = new Map<string, number | undefined>();
if (!Array.isArray(logs)) return map;
const asc = [...logs].sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime());
let lastOdo: number | undefined = undefined;
for (const l of asc) {
map.set(l.id, lastOdo);
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
lastOdo = l.odometerReading;
}
}
return map;
}, [logs]);
const handleDeleteClick = (log: FuelLogResponse) => {
setLogToDelete(log);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (!logToDelete) return;
try {
setIsDeleting(true);
await fuelLogsApi.delete(logToDelete.id);
onDelete?.(logToDelete.id);
setDeleteDialogOpen(false);
setLogToDelete(null);
} catch (error) {
console.error('Failed to delete fuel log:', error);
// TODO: Show error notification
} finally {
setIsDeleting(false);
}
};
const handleDeleteCancel = () => {
setDeleteDialogOpen(false);
setLogToDelete(null);
};
// Defensive check for logs data
if (!Array.isArray(logs) || logs.length === 0) {
return (
<Card variant="outlined">
<CardContent>
<Typography variant="body2" color="text.secondary">
No fuel logs yet.
</Typography>
</CardContent>
</Card>
);
}
return (
<>
<List>
{logs.map((log) => {
// Defensive checks for each log entry
if (!log || !log.id) {
console.warn('[FuelLogsList] Invalid log entry:', log);
return null;
}
try {
// Safe date formatting
const dateText = log.dateTime
? new Date(log.dateTime).toLocaleString()
: 'Unknown date';
// Safe cost formatting
const totalCost = typeof log.totalCost === 'number'
? log.totalCost.toFixed(2)
: '0.00';
// Safe fuel units and cost per unit
const fuelUnits = typeof log.fuelUnits === 'number'
? log.fuelUnits.toFixed(3)
: '0.000';
const costPerUnit = typeof log.costPerUnit === 'number'
? log.costPerUnit.toFixed(3)
: '0.000';
// Safe distance display
const distanceText = log.odometerReading
? `Odo: ${log.odometerReading}`
: log.tripDistance
? `Trip: ${log.tripDistance}`
: 'No distance';
// Compute local efficiency if not provided
let localEffLabel: string | null = null;
try {
const gallons = typeof log.fuelUnits === 'number' ? log.fuelUnits : undefined;
let miles: number | undefined =
typeof log.tripDistance === 'number' && !isNaN(log.tripDistance)
? log.tripDistance
: undefined;
if (miles === undefined && typeof log.odometerReading === 'number') {
const prev = prevOdoById.get(log.id);
if (typeof prev === 'number' && log.odometerReading > prev) {
miles = log.odometerReading - prev;
}
}
if (typeof miles === 'number' && typeof gallons === 'number' && gallons > 0) {
if (unitSystem === 'metric') {
const km = convertDistance(miles);
const liters = convertVolume(gallons);
if (liters > 0) localEffLabel = `${(km / liters).toFixed(1)} km/L`;
} else {
localEffLabel = `${(miles / gallons).toFixed(1)} MPG`;
}
}
} catch {}
return (
<ListItem
key={log.id}
divider
sx={{
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center',
gap: isMobile ? 1 : 0,
py: isMobile ? 2 : 1
}}
>
<Box sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
flex: 1,
gap: isMobile ? 0.5 : 1
}}>
<ListItemText
primary={`${dateText} $${totalCost}`}
secondary={`${fuelUnits} @ $${costPerUnit}${distanceText}`}
sx={{ flex: 1, minWidth: 0 }}
/>
{(log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && log.efficiencyLabel) || localEffLabel ? (
<Box sx={{ mr: isMobile ? 0 : 1 }}>
<Chip
label={
log.efficiency && log.efficiencyLabel
? `${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`
: localEffLabel || ''
}
size="small"
color="primary"
/>
</Box>
) : null}
</Box>
<Box sx={{
display: 'flex',
gap: isMobile ? 1 : 0.5,
justifyContent: isMobile ? 'center' : 'flex-end',
width: isMobile ? '100%' : 'auto'
}}>
{onEdit && (
<IconButton
size={isMobile ? 'medium' : 'small'}
onClick={() => onEdit(log)}
sx={{
color: 'primary.main',
'&:hover': { backgroundColor: 'primary.main', color: 'white' },
minWidth: isMobile ? 48 : 'auto',
minHeight: isMobile ? 48 : 'auto',
...(isMobile && {
border: '1px solid',
borderColor: 'primary.main',
borderRadius: 2
})
}}
>
<Edit fontSize={isMobile ? 'medium' : 'small'} />
</IconButton>
)}
{onDelete && (
<IconButton
size={isMobile ? 'medium' : 'small'}
onClick={() => handleDeleteClick(log)}
sx={{
color: 'error.main',
'&:hover': { backgroundColor: 'error.main', color: 'white' },
minWidth: isMobile ? 48 : 'auto',
minHeight: isMobile ? 48 : 'auto',
...(isMobile && {
border: '1px solid',
borderColor: 'error.main',
borderRadius: 2
})
}}
>
<Delete fontSize={isMobile ? 'medium' : 'small'} />
</IconButton>
)}
</Box>
</ListItem>
);
} catch (error) {
console.error('[FuelLogsList] Error rendering log:', log, error);
return (
<ListItem key={log.id || Math.random()} divider>
<ListItemText
primary="Error displaying fuel log"
secondary="Data formatting issue"
/>
</ListItem>
);
}
}).filter(Boolean)}
</List>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onClose={handleDeleteCancel}>
<DialogTitle>Delete Fuel Log</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete this fuel log entry? This action cannot be undone.
</Typography>
{logToDelete && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{new Date(logToDelete.dateTime).toLocaleString()} - ${logToDelete.totalCost.toFixed(2)}
</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={isDeleting}>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</>
);
};