284 lines
9.8 KiB
TypeScript
284 lines
9.8 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
};
|