316 lines
13 KiB
TypeScript
316 lines
13 KiB
TypeScript
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, 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';
|
|
import { UnitSystemDisplay } from './UnitSystemDisplay';
|
|
import { StationPicker } from './StationPicker';
|
|
import { CostCalculator } from './CostCalculator';
|
|
import { useFuelLogs } from '../hooks/useFuelLogs';
|
|
import { useUserSettings } from '../hooks/useUserSettings';
|
|
import { useGeolocation } from '../../stations/hooks/useGeolocation';
|
|
import { CreateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
|
|
|
const schema = z.object({
|
|
vehicleId: z.string().uuid(),
|
|
dateTime: z.string().min(1),
|
|
odometerReading: z.coerce.number().positive().optional(),
|
|
tripDistance: z.coerce.number().positive().optional(),
|
|
fuelType: z.nativeEnum(FuelType),
|
|
fuelGrade: z.union([z.string(), z.null()]).optional(),
|
|
fuelUnits: z.coerce.number().positive(),
|
|
costPerUnit: z.coerce.number().positive(),
|
|
locationData: z.any().optional(),
|
|
notes: z.string().max(500).optional(),
|
|
}).refine((d) => (d.odometerReading && d.odometerReading > 0) || (d.tripDistance && d.tripDistance > 0), {
|
|
message: 'Either odometer reading or trip distance is required'
|
|
}).refine((d) => !(d.odometerReading && d.tripDistance), {
|
|
message: 'Cannot specify both odometer reading and trip distance'
|
|
});
|
|
|
|
const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial<CreateFuelLogRequest> }> = ({ onSuccess, initial }) => {
|
|
const { userSettings } = useUserSettings();
|
|
const { createFuelLog, isLoading } = useFuelLogs();
|
|
const [useOdometer, setUseOdometer] = useState(false);
|
|
const formInitialized = useRef(false);
|
|
|
|
// Get user location for nearby station search
|
|
const { coordinates: userLocation } = useGeolocation();
|
|
|
|
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
|
resolver: zodResolver(schema),
|
|
mode: 'onChange',
|
|
defaultValues: {
|
|
dateTime: new Date().toISOString().slice(0, 16),
|
|
fuelType: FuelType.GASOLINE,
|
|
...initial
|
|
} as any
|
|
});
|
|
|
|
// DEBUG: Log component renders and form state
|
|
console.log('[FuelLogForm] Render', {
|
|
at: new Date().toISOString(),
|
|
formInitialized: formInitialized.current,
|
|
isLoading,
|
|
});
|
|
|
|
// Prevent form reset after initial load
|
|
useEffect(() => {
|
|
console.log('[FuelLogForm] Mounted');
|
|
if (!formInitialized.current) {
|
|
formInitialized.current = true;
|
|
console.log('[FuelLogForm] Form initialized');
|
|
}
|
|
return () => {
|
|
console.log('[FuelLogForm] Unmounted');
|
|
};
|
|
}, []);
|
|
|
|
// DEBUG: Watch for form value changes
|
|
const vehicleId = watch('vehicleId');
|
|
useEffect(() => {
|
|
console.log('[FuelLogForm] Vehicle ID changed:', vehicleId);
|
|
}, [vehicleId]);
|
|
|
|
// DEBUG: Track dirty-state changes to detect resets
|
|
useEffect(() => {
|
|
const subscription = watch((_, info) => {
|
|
if (info.name) {
|
|
console.log('[FuelLogForm] Field change', { name: info.name, type: info.type });
|
|
}
|
|
});
|
|
return () => subscription.unsubscribe();
|
|
}, [watch]);
|
|
|
|
const watched = watch(['fuelUnits', 'costPerUnit']);
|
|
const [fuelUnitsRaw, costPerUnitRaw] = watched as [string | number | undefined, string | number | undefined];
|
|
|
|
// Convert to numbers for calculation
|
|
const fuelUnits = typeof fuelUnitsRaw === 'string' ? parseFloat(fuelUnitsRaw) : fuelUnitsRaw;
|
|
const costPerUnit = typeof costPerUnitRaw === 'string' ? parseFloat(costPerUnitRaw) : costPerUnitRaw;
|
|
|
|
const calculatedCost = useMemo(() => {
|
|
const units = fuelUnits && !isNaN(fuelUnits) ? fuelUnits : 0;
|
|
const cost = costPerUnit && !isNaN(costPerUnit) ? costPerUnit : 0;
|
|
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,
|
|
odometerReading: useOdometer ? data.odometerReading : undefined,
|
|
tripDistance: useOdometer ? undefined : data.tripDistance,
|
|
};
|
|
await createFuelLog(payload);
|
|
// Reset form to initial defaults after successful create
|
|
reset({
|
|
vehicleId: undefined as any,
|
|
dateTime: new Date().toISOString().slice(0, 16),
|
|
odometerReading: undefined as any,
|
|
tripDistance: undefined as any,
|
|
fuelType: FuelType.GASOLINE,
|
|
fuelGrade: undefined as any,
|
|
fuelUnits: undefined as any,
|
|
costPerUnit: undefined as any,
|
|
locationData: undefined as any,
|
|
notes: undefined as any,
|
|
} as any);
|
|
onSuccess?.();
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (useOdometer) setValue('tripDistance', undefined as any);
|
|
else setValue('odometerReading', undefined as any);
|
|
}, [useOdometer, setValue]);
|
|
|
|
return (
|
|
<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 }) => (
|
|
<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}>
|
|
<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 }) => (
|
|
<FuelTypeSelector fuelType={fuelTypeField.value} fuelGrade={fuelGradeField.value as any} onFuelTypeChange={fuelTypeField.onChange} onFuelGradeChange={fuelGradeField.onChange as any} error={(errors.fuelType?.message as any) || (errors.fuelGrade?.message as any)} />
|
|
)} />
|
|
)} />
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<Controller name="fuelUnits" control={control} render={({ field }) => (
|
|
<TextField
|
|
{...field}
|
|
value={field.value ?? ''}
|
|
onChange={(e) => field.onChange(e.target.value)}
|
|
label={`Fuel Amount (${userSettings?.unitSystem === 'imperial' ? 'gallons' : 'liters'})`}
|
|
type="number"
|
|
inputProps={{ step: 0.001, min: 0.001 }}
|
|
fullWidth
|
|
error={!!errors.fuelUnits}
|
|
helperText={errors.fuelUnits?.message}
|
|
/>
|
|
)} />
|
|
</Grid>
|
|
<Grid item xs={12} sm={6}>
|
|
<Controller name="costPerUnit" control={control} render={({ field }) => (
|
|
<TextField
|
|
{...field}
|
|
value={field.value ?? ''}
|
|
onChange={(e) => field.onChange(e.target.value)}
|
|
label={`Cost Per ${userSettings?.unitSystem === 'imperial' ? 'Gallon' : 'Liter'}`}
|
|
type="number"
|
|
inputProps={{ step: 0.001, min: 0.001 }}
|
|
fullWidth
|
|
error={!!errors.costPerUnit}
|
|
helperText={errors.costPerUnit?.message}
|
|
/>
|
|
)} />
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<CostCalculator fuelUnits={fuelUnits} costPerUnit={costPerUnit} calculatedCost={calculatedCost} unitSystem={userSettings?.unitSystem} />
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Controller name="locationData" control={control} render={({ field }) => (
|
|
<StationPicker
|
|
value={field.value as any}
|
|
onChange={field.onChange as any}
|
|
userLocation={userLocation}
|
|
placeholder="Station location (optional)"
|
|
/>
|
|
)} />
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Controller name="notes" control={control} render={({ field }) => (
|
|
<TextField {...field} label="Notes (optional)" multiline rows={3} fullWidth error={!!errors.notes} helperText={errors.notes?.message} />
|
|
)} />
|
|
</Grid>
|
|
<Grid item xs={12}>
|
|
<Box display="flex" gap={2} justifyContent="flex-end">
|
|
<Button type="submit" variant="contained" disabled={!isValid || isLoading} startIcon={isLoading ? <CircularProgress size={18} /> : undefined}>Add Fuel Log</Button>
|
|
</Box>
|
|
</Grid>
|
|
</Grid>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</LocalizationProvider>
|
|
);
|
|
};
|
|
|
|
export const FuelLogForm = memo(FuelLogFormComponent);
|