UX Improvements
This commit is contained in:
@@ -15,6 +15,9 @@ import {
|
||||
Typography,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||
import { useFuelGrades } from '../hooks/useFuelGrades';
|
||||
|
||||
@@ -130,41 +133,40 @@ export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Format datetime for input (datetime-local expects YYYY-MM-DDTHH:mm format)
|
||||
const formatDateTimeForInput = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={isSmallScreen}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Date and Time */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
<DateTimePicker
|
||||
label="Date & Time"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={formData.dateTime ? formatDateTimeForInput(formData.dateTime) : ''}
|
||||
onChange={(e) => handleInputChange('dateTime', new Date(e.target.value).toISOString())}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={formData.dateTime ? new Date(formData.dateTime) : null}
|
||||
onChange={(newValue) => handleInputChange('dateTime', newValue?.toISOString() || '')}
|
||||
format="MM/dd/yyyy hh:mm a"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px', // Ensure consistent height for mobile touch targets
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -284,5 +286,6 @@ export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, { useEffect, useMemo, useState, useRef, memo } from 'react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Grid, Card, CardHeader, CardContent, TextField, Switch, FormControlLabel, Box, Button, CircularProgress } from '@mui/material';
|
||||
import { Grid, Card, CardHeader, CardContent, TextField, Box, Button, CircularProgress, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||
import { VehicleSelector } from './VehicleSelector';
|
||||
import { DistanceInput } from './DistanceInput';
|
||||
import { FuelTypeSelector } from './FuelTypeSelector';
|
||||
@@ -94,6 +97,19 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
return units > 0 && cost > 0 ? units * cost : 0;
|
||||
}, [fuelUnits, costPerUnit]);
|
||||
|
||||
// Watch for distance and fuel units to calculate efficiency
|
||||
const watchedDistance = watch(useOdometer ? 'odometerReading' : 'tripDistance');
|
||||
const distanceValue = typeof watchedDistance === 'string' ? parseFloat(watchedDistance) : watchedDistance;
|
||||
|
||||
const calculatedEfficiency = useMemo(() => {
|
||||
const distance = distanceValue && !isNaN(distanceValue) ? distanceValue : 0;
|
||||
const units = fuelUnits && !isNaN(fuelUnits) ? fuelUnits : 0;
|
||||
if (distance > 0 && units > 0) {
|
||||
return distance / units;
|
||||
}
|
||||
return 0;
|
||||
}, [distanceValue, fuelUnits]);
|
||||
|
||||
const onSubmit = async (data: CreateFuelLogRequest) => {
|
||||
const payload: CreateFuelLogRequest = {
|
||||
...data,
|
||||
@@ -123,29 +139,107 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
}, [useOdometer, setValue]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Card>
|
||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Row 1: Select Vehicle */}
|
||||
<Grid item xs={12}>
|
||||
<Controller name="vehicleId" control={control} render={({ field }) => (
|
||||
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
|
||||
)} />
|
||||
</Grid>
|
||||
|
||||
{/* Row 2: Date/Time | MPG/km/L */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name="dateTime" control={control} render={({ field }) => (
|
||||
<TextField {...field} label="Date & Time" type="datetime-local" fullWidth error={!!errors.dateTime} helperText={errors.dateTime?.message} InputLabelProps={{ shrink: true }} />
|
||||
<DateTimePicker
|
||||
label="Date & Time"
|
||||
value={field.value ? new Date(field.value) : null}
|
||||
onChange={(newValue) => field.onChange(newValue?.toISOString() || '')}
|
||||
format="MM/dd/yyyy hh:mm a"
|
||||
slotProps={{
|
||||
textField: {
|
||||
fullWidth: true,
|
||||
error: !!errors.dateTime,
|
||||
helperText: errors.dateTime?.message,
|
||||
sx: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px',
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControlLabel control={<Switch checked={useOdometer} onChange={(e) => setUseOdometer(e.target.checked)} />} label={`Use ${useOdometer ? 'Odometer' : 'Trip Distance'}`} />
|
||||
<TextField
|
||||
label={`${userSettings?.unitSystem === 'metric' ? 'km/L' : 'MPG'}`}
|
||||
value={calculatedEfficiency > 0 ? calculatedEfficiency.toFixed(3) : ''}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
sx: {
|
||||
backgroundColor: 'grey.50',
|
||||
'& .MuiOutlinedInput-input': {
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
}}
|
||||
helperText="Calculated from distance ÷ fuel amount"
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
minHeight: '56px',
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Row 3: Odometer | Distance Input Method */}
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => (
|
||||
<DistanceInput type={useOdometer ? 'odometer' : 'trip'} value={field.value as any} onChange={field.onChange as any} unitSystem={userSettings?.unitSystem} error={useOdometer ? (errors.odometerReading?.message as any) : (errors.tripDistance?.message as any)} />
|
||||
)} />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<ToggleButtonGroup
|
||||
value={useOdometer ? 'odometer' : 'trip'}
|
||||
exclusive
|
||||
onChange={(_, newValue) => {
|
||||
if (newValue !== null) {
|
||||
setUseOdometer(newValue === 'odometer');
|
||||
}
|
||||
}}
|
||||
aria-label="distance input method"
|
||||
fullWidth
|
||||
sx={{
|
||||
height: '56px', // Match input field height
|
||||
'& .MuiToggleButton-root': {
|
||||
textTransform: 'none',
|
||||
fontWeight: 500,
|
||||
borderRadius: '8px',
|
||||
height: '56px', // Ensure button height matches
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'primary.contrastText',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ToggleButton value="trip" aria-label="trip distance">
|
||||
Trip Distance
|
||||
</ToggleButton>
|
||||
<ToggleButton value="odometer" aria-label="odometer reading">
|
||||
Odometer Reading
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
|
||||
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
|
||||
@@ -205,6 +299,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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[];
|
||||
@@ -30,10 +31,26 @@ interface FuelLogsListProps {
|
||||
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);
|
||||
@@ -110,6 +127,31 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
? `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}
|
||||
@@ -133,18 +175,19 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
secondary={`${fuelUnits} @ $${costPerUnit} • ${distanceText}`}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
{log.efficiency &&
|
||||
typeof log.efficiency === 'number' &&
|
||||
!isNaN(log.efficiency) &&
|
||||
log.efficiencyLabel && (
|
||||
{(log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && log.efficiencyLabel) || localEffLabel ? (
|
||||
<Box sx={{ mr: isMobile ? 0 : 1 }}>
|
||||
<Chip
|
||||
label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`}
|
||||
label={
|
||||
log.efficiency && log.efficiencyLabel
|
||||
? `${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`
|
||||
: localEffLabel || ''
|
||||
}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
@@ -238,4 +281,3 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@ import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
export const UnitSystemDisplay: React.FC<{ unitSystem?: UnitSystem; showLabel?: string }> = ({ unitSystem, showLabel }) => {
|
||||
if (!unitSystem) return null;
|
||||
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, L/100km)';
|
||||
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, km/L)';
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{showLabel ? `${showLabel} ` : ''}{label}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user