1080 lines
32 KiB
Markdown
1080 lines
32 KiB
Markdown
# Phase 4: Frontend Implementation
|
||
|
||
## Overview
|
||
Create comprehensive React components for the enhanced fuel logs feature with dynamic forms, real-time calculations, Imperial/Metric support, and mobile-optimized UI.
|
||
|
||
## Prerequisites
|
||
- ✅ Phase 1-3 completed (database, business logic, API)
|
||
- All backend services tested and functional
|
||
- API endpoints available and documented
|
||
|
||
## Component Architecture
|
||
|
||
### Main Components Structure
|
||
```
|
||
frontend/src/features/fuel-logs/
|
||
├── components/
|
||
│ ├── FuelLogForm.tsx # Main form component
|
||
│ ├── VehicleSelector.tsx # Vehicle dropdown
|
||
│ ├── DistanceInput.tsx # Trip/odometer toggle
|
||
│ ├── FuelTypeSelector.tsx # Fuel type with cascading grades
|
||
│ ├── UnitSystemDisplay.tsx # Imperial/Metric formatting
|
||
│ ├── LocationInput.tsx # Future Google Maps integration
|
||
│ ├── CostCalculator.tsx # Real-time cost calculation
|
||
│ └── FuelLogsList.tsx # Enhanced logs display
|
||
├── hooks/
|
||
│ ├── useFuelLogs.tsx # Fuel logs API integration
|
||
│ ├── useFuelGrades.tsx # Dynamic fuel grades
|
||
│ ├── useUserSettings.tsx # Unit system preferences
|
||
│ └── useFormValidation.tsx # Enhanced form validation
|
||
├── utils/
|
||
│ ├── unitConversion.ts # Frontend unit utilities
|
||
│ ├── fuelGradeUtils.ts # Fuel grade helpers
|
||
│ └── formValidation.ts # Client-side validation
|
||
├── types/
|
||
│ └── fuel-logs.types.ts # Frontend type definitions
|
||
└── pages/
|
||
├── FuelLogsPage.tsx # Main fuel logs page
|
||
└── FuelLogDetailPage.tsx # Individual log details
|
||
```
|
||
|
||
## Core Components
|
||
|
||
### Enhanced Fuel Log Form
|
||
|
||
**File**: `frontend/src/features/fuel-logs/components/FuelLogForm.tsx`
|
||
|
||
```tsx
|
||
import React, { useState, useEffect } from 'react';
|
||
import { useForm, Controller } from 'react-hook-form';
|
||
import { yupResolver } from '@hookform/resolvers/yup';
|
||
import * as yup from 'yup';
|
||
import {
|
||
Box,
|
||
Button,
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
Typography,
|
||
Alert,
|
||
Grid,
|
||
Switch,
|
||
FormControlLabel
|
||
} from '@mui/material';
|
||
|
||
import { VehicleSelector } from './VehicleSelector';
|
||
import { DistanceInput } from './DistanceInput';
|
||
import { FuelTypeSelector } from './FuelTypeSelector';
|
||
import { UnitSystemDisplay } from './UnitSystemDisplay';
|
||
import { LocationInput } from './LocationInput';
|
||
import { CostCalculator } from './CostCalculator';
|
||
|
||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||
import { useUserSettings } from '../hooks/useUserSettings';
|
||
import { CreateFuelLogRequest, FuelType, DistanceType } from '../types/fuel-logs.types';
|
||
|
||
// Validation schema
|
||
const createFuelLogSchema = yup.object({
|
||
vehicleId: yup.string().required('Vehicle is required'),
|
||
dateTime: yup.date().max(new Date(), 'Cannot create logs in the future').required('Date/time is required'),
|
||
distanceType: yup.string().oneOf(['odometer', 'trip']).required(),
|
||
odometerReading: yup.number().when('distanceType', {
|
||
is: 'odometer',
|
||
then: yup.number().min(1, 'Odometer must be positive').required('Odometer reading is required'),
|
||
otherwise: yup.number().nullable()
|
||
}),
|
||
tripDistance: yup.number().when('distanceType', {
|
||
is: 'trip',
|
||
then: yup.number().min(0.1, 'Trip distance must be positive').required('Trip distance is required'),
|
||
otherwise: yup.number().nullable()
|
||
}),
|
||
fuelType: yup.string().oneOf(Object.values(FuelType)).required('Fuel type is required'),
|
||
fuelGrade: yup.string().nullable(),
|
||
fuelUnits: yup.number().min(0.01, 'Fuel amount must be positive').required('Fuel amount is required'),
|
||
costPerUnit: yup.number().min(0.01, 'Cost per unit must be positive').required('Cost per unit is required'),
|
||
locationData: yup.object().nullable(),
|
||
notes: yup.string().max(500, 'Notes cannot exceed 500 characters')
|
||
});
|
||
|
||
interface FuelLogFormProps {
|
||
onSuccess?: () => void;
|
||
onCancel?: () => void;
|
||
initialData?: Partial<CreateFuelLogRequest>;
|
||
}
|
||
|
||
export const FuelLogForm: React.FC<FuelLogFormProps> = ({
|
||
onSuccess,
|
||
onCancel,
|
||
initialData
|
||
}) => {
|
||
const { createFuelLog, isLoading } = useFuelLogs();
|
||
const { userSettings } = useUserSettings();
|
||
const [distanceType, setDistanceType] = useState<DistanceType>('trip');
|
||
const [calculatedCost, setCalculatedCost] = useState<number>(0);
|
||
|
||
const {
|
||
control,
|
||
handleSubmit,
|
||
watch,
|
||
setValue,
|
||
formState: { errors, isValid }
|
||
} = useForm<CreateFuelLogRequest>({
|
||
resolver: yupResolver(createFuelLogSchema),
|
||
defaultValues: {
|
||
dateTime: new Date().toISOString().slice(0, 16), // Current datetime
|
||
distanceType: 'trip',
|
||
fuelType: FuelType.GASOLINE,
|
||
...initialData
|
||
},
|
||
mode: 'onChange'
|
||
});
|
||
|
||
// Watch form values for real-time calculations
|
||
const watchedValues = watch(['fuelUnits', 'costPerUnit', 'fuelType']);
|
||
const [fuelUnits, costPerUnit, fuelType] = watchedValues;
|
||
|
||
// Real-time cost calculation
|
||
useEffect(() => {
|
||
if (fuelUnits && costPerUnit) {
|
||
const cost = fuelUnits * costPerUnit;
|
||
setCalculatedCost(cost);
|
||
}
|
||
}, [fuelUnits, costPerUnit]);
|
||
|
||
const onSubmit = async (data: CreateFuelLogRequest) => {
|
||
try {
|
||
// Prepare submission data
|
||
const submitData = {
|
||
...data,
|
||
totalCost: calculatedCost,
|
||
// Clear unused distance field
|
||
odometerReading: distanceType === 'odometer' ? data.odometerReading : undefined,
|
||
tripDistance: distanceType === 'trip' ? data.tripDistance : undefined
|
||
};
|
||
|
||
await createFuelLog(submitData);
|
||
onSuccess?.();
|
||
} catch (error) {
|
||
console.error('Failed to create fuel log:', error);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader
|
||
title="Add Fuel Log"
|
||
subheader={
|
||
<UnitSystemDisplay
|
||
unitSystem={userSettings?.unitSystem}
|
||
showLabel="Displaying in"
|
||
/>
|
||
}
|
||
/>
|
||
<CardContent>
|
||
<form onSubmit={handleSubmit(onSubmit)}>
|
||
<Grid container spacing={3}>
|
||
|
||
{/* Vehicle Selection */}
|
||
<Grid item xs={12}>
|
||
<Controller
|
||
name="vehicleId"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<VehicleSelector
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
error={errors.vehicleId?.message}
|
||
required
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Date/Time */}
|
||
<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 }}
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Distance Type Toggle */}
|
||
<Grid item xs={12} sm={6}>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={distanceType === 'odometer'}
|
||
onChange={(e) => {
|
||
const newType = e.target.checked ? 'odometer' : 'trip';
|
||
setDistanceType(newType);
|
||
setValue('distanceType', newType);
|
||
// Clear the unused field
|
||
if (newType === 'odometer') {
|
||
setValue('tripDistance', undefined);
|
||
} else {
|
||
setValue('odometerReading', undefined);
|
||
}
|
||
}}
|
||
/>
|
||
}
|
||
label={`Use ${distanceType === 'odometer' ? 'Odometer Reading' : 'Trip Distance'}`}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Distance Input */}
|
||
<Grid item xs={12} sm={6}>
|
||
<Controller
|
||
name={distanceType === 'odometer' ? 'odometerReading' : 'tripDistance'}
|
||
control={control}
|
||
render={({ field }) => (
|
||
<DistanceInput
|
||
type={distanceType}
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
unitSystem={userSettings?.unitSystem}
|
||
error={distanceType === 'odometer' ? errors.odometerReading?.message : errors.tripDistance?.message}
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Fuel Type & Grade */}
|
||
<Grid item xs={12} sm={6}>
|
||
<Controller
|
||
name="fuelType"
|
||
control={control}
|
||
render={({ field: fuelTypeField }) => (
|
||
<Controller
|
||
name="fuelGrade"
|
||
control={control}
|
||
render={({ field: fuelGradeField }) => (
|
||
<FuelTypeSelector
|
||
fuelType={fuelTypeField.value}
|
||
fuelGrade={fuelGradeField.value}
|
||
onFuelTypeChange={fuelTypeField.onChange}
|
||
onFuelGradeChange={fuelGradeField.onChange}
|
||
error={errors.fuelType?.message || errors.fuelGrade?.message}
|
||
/>
|
||
)}
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Fuel Amount */}
|
||
<Grid item xs={12} sm={6}>
|
||
<Controller
|
||
name="fuelUnits"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<TextField
|
||
{...field}
|
||
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>
|
||
|
||
{/* Cost Per Unit */}
|
||
<Grid item xs={12} sm={6}>
|
||
<Controller
|
||
name="costPerUnit"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<TextField
|
||
{...field}
|
||
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}
|
||
InputProps={{
|
||
startAdornment: <InputAdornment position="start">$</InputAdornment>
|
||
}}
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Real-time Total Cost Display */}
|
||
<Grid item xs={12}>
|
||
<CostCalculator
|
||
fuelUnits={fuelUnits}
|
||
costPerUnit={costPerUnit}
|
||
calculatedCost={calculatedCost}
|
||
unitSystem={userSettings?.unitSystem}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Location */}
|
||
<Grid item xs={12}>
|
||
<Controller
|
||
name="locationData"
|
||
control={control}
|
||
render={({ field }) => (
|
||
<LocationInput
|
||
value={field.value}
|
||
onChange={field.onChange}
|
||
placeholder="Station location (optional)"
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Notes */}
|
||
<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}
|
||
placeholder="Additional notes about this fuel log..."
|
||
/>
|
||
)}
|
||
/>
|
||
</Grid>
|
||
|
||
{/* Form Actions */}
|
||
<Grid item xs={12}>
|
||
<Box display="flex" gap={2} justifyContent="flex-end">
|
||
{onCancel && (
|
||
<Button variant="outlined" onClick={onCancel}>
|
||
Cancel
|
||
</Button>
|
||
)}
|
||
<Button
|
||
type="submit"
|
||
variant="contained"
|
||
disabled={!isValid || isLoading}
|
||
startIcon={isLoading ? <CircularProgress size={20} /> : undefined}
|
||
>
|
||
{isLoading ? 'Adding...' : 'Add Fuel Log'}
|
||
</Button>
|
||
</Box>
|
||
</Grid>
|
||
</Grid>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
```
|
||
|
||
### Dynamic Fuel Type Selector
|
||
|
||
**File**: `frontend/src/features/fuel-logs/components/FuelTypeSelector.tsx`
|
||
|
||
```tsx
|
||
import React, { useEffect } from 'react';
|
||
import {
|
||
FormControl,
|
||
InputLabel,
|
||
Select,
|
||
MenuItem,
|
||
Grid,
|
||
FormHelperText
|
||
} from '@mui/material';
|
||
import { FuelType, FuelGrade } from '../types/fuel-logs.types';
|
||
import { useFuelGrades } from '../hooks/useFuelGrades';
|
||
|
||
interface FuelTypeSelectorProps {
|
||
fuelType: FuelType;
|
||
fuelGrade?: FuelGrade;
|
||
onFuelTypeChange: (fuelType: FuelType) => void;
|
||
onFuelGradeChange: (fuelGrade?: FuelGrade) => void;
|
||
error?: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export const FuelTypeSelector: React.FC<FuelTypeSelectorProps> = ({
|
||
fuelType,
|
||
fuelGrade,
|
||
onFuelTypeChange,
|
||
onFuelGradeChange,
|
||
error,
|
||
disabled = false
|
||
}) => {
|
||
const { fuelGrades, isLoading } = useFuelGrades(fuelType);
|
||
|
||
// Clear fuel grade when fuel type changes and grades don't include current grade
|
||
useEffect(() => {
|
||
if (fuelGrade && fuelGrades && !fuelGrades.some(grade => grade.value === fuelGrade)) {
|
||
onFuelGradeChange(undefined);
|
||
}
|
||
}, [fuelType, fuelGrades, fuelGrade, onFuelGradeChange]);
|
||
|
||
// Auto-select default grade when fuel type changes
|
||
useEffect(() => {
|
||
if (!fuelGrade && fuelGrades && fuelGrades.length > 0) {
|
||
// Auto-select first grade (typically the most common)
|
||
onFuelGradeChange(fuelGrades[0].value);
|
||
}
|
||
}, [fuelGrades, fuelGrade, onFuelGradeChange]);
|
||
|
||
return (
|
||
<Grid container spacing={2}>
|
||
{/* Fuel Type */}
|
||
<Grid item xs={12} sm={6}>
|
||
<FormControl fullWidth error={!!error}>
|
||
<InputLabel>Fuel Type</InputLabel>
|
||
<Select
|
||
value={fuelType || ''}
|
||
onChange={(e) => onFuelTypeChange(e.target.value as FuelType)}
|
||
label="Fuel Type"
|
||
disabled={disabled}
|
||
>
|
||
<MenuItem value={FuelType.GASOLINE}>Gasoline</MenuItem>
|
||
<MenuItem value={FuelType.DIESEL}>Diesel</MenuItem>
|
||
<MenuItem value={FuelType.ELECTRIC}>Electric</MenuItem>
|
||
</Select>
|
||
{error && <FormHelperText>{error}</FormHelperText>}
|
||
</FormControl>
|
||
</Grid>
|
||
|
||
{/* Fuel Grade (conditional) */}
|
||
<Grid item xs={12} sm={6}>
|
||
<FormControl fullWidth disabled={disabled || isLoading || fuelType === FuelType.ELECTRIC}>
|
||
<InputLabel>
|
||
Fuel Grade {fuelType === FuelType.ELECTRIC ? '(N/A for Electric)' : ''}
|
||
</InputLabel>
|
||
<Select
|
||
value={fuelGrade || ''}
|
||
onChange={(e) => onFuelGradeChange(e.target.value as FuelGrade)}
|
||
label="Fuel Grade"
|
||
>
|
||
{fuelGrades?.map((grade) => (
|
||
<MenuItem key={grade.value} value={grade.value}>
|
||
{grade.label}
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
{fuelType !== FuelType.ELECTRIC && (
|
||
<FormHelperText>
|
||
{isLoading ? 'Loading grades...' : 'Select appropriate fuel grade'}
|
||
</FormHelperText>
|
||
)}
|
||
</FormControl>
|
||
</Grid>
|
||
</Grid>
|
||
);
|
||
};
|
||
```
|
||
|
||
### Vehicle Selector Component
|
||
|
||
**File**: `frontend/src/features/fuel-logs/components/VehicleSelector.tsx`
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import {
|
||
FormControl,
|
||
InputLabel,
|
||
Select,
|
||
MenuItem,
|
||
FormHelperText,
|
||
Box,
|
||
Typography
|
||
} from '@mui/material';
|
||
import { DirectionsCar } from '@mui/icons-material';
|
||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||
import { Vehicle } from '../../vehicles/types/vehicle.types';
|
||
|
||
interface VehicleSelectorProps {
|
||
value: string;
|
||
onChange: (vehicleId: string) => void;
|
||
error?: string;
|
||
required?: boolean;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export const VehicleSelector: React.FC<VehicleSelectorProps> = ({
|
||
value,
|
||
onChange,
|
||
error,
|
||
required = false,
|
||
disabled = false
|
||
}) => {
|
||
const { vehicles, isLoading } = useVehicles();
|
||
|
||
const formatVehicleDisplay = (vehicle: Vehicle): string => {
|
||
const parts = [vehicle.year, vehicle.make, vehicle.model, vehicle.nickname]
|
||
.filter(Boolean);
|
||
return parts.join(' ');
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<FormControl fullWidth disabled>
|
||
<InputLabel>Loading vehicles...</InputLabel>
|
||
<Select value="" label="Loading vehicles...">
|
||
<MenuItem value="">
|
||
<em>Loading...</em>
|
||
</MenuItem>
|
||
</Select>
|
||
</FormControl>
|
||
);
|
||
}
|
||
|
||
if (!vehicles || vehicles.length === 0) {
|
||
return (
|
||
<Box
|
||
sx={{
|
||
p: 2,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
borderRadius: 1,
|
||
bgcolor: 'background.paper'
|
||
}}
|
||
>
|
||
<Box display="flex" alignItems="center" gap={1} mb={1}>
|
||
<DirectionsCar color="action" />
|
||
<Typography variant="body2" color="text.secondary">
|
||
No vehicles found
|
||
</Typography>
|
||
</Box>
|
||
<Typography variant="caption" color="text.secondary">
|
||
You need to add a vehicle before creating fuel logs.{' '}
|
||
<Link href="/vehicles/add" color="primary">
|
||
Add your first vehicle
|
||
</Link>
|
||
</Typography>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<FormControl fullWidth error={!!error} required={required}>
|
||
<InputLabel>Select Vehicle</InputLabel>
|
||
<Select
|
||
value={value || ''}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
label="Select Vehicle"
|
||
disabled={disabled}
|
||
renderValue={(selected) => {
|
||
const selectedVehicle = vehicles.find(v => v.id === selected);
|
||
return selectedVehicle ? (
|
||
<Box display="flex" alignItems="center" gap={1}>
|
||
<DirectionsCar fontSize="small" />
|
||
{formatVehicleDisplay(selectedVehicle)}
|
||
</Box>
|
||
) : '';
|
||
}}
|
||
>
|
||
{vehicles.map((vehicle) => (
|
||
<MenuItem key={vehicle.id} value={vehicle.id}>
|
||
<Box display="flex" alignItems="center" gap={1}>
|
||
<DirectionsCar fontSize="small" />
|
||
<Box>
|
||
<Typography variant="body2">
|
||
{formatVehicleDisplay(vehicle)}
|
||
</Typography>
|
||
{vehicle.licensePlate && (
|
||
<Typography variant="caption" color="text.secondary">
|
||
{vehicle.licensePlate}
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
</MenuItem>
|
||
))}
|
||
</Select>
|
||
{error && <FormHelperText>{error}</FormHelperText>}
|
||
</FormControl>
|
||
);
|
||
};
|
||
```
|
||
|
||
### Distance Input Component
|
||
|
||
**File**: `frontend/src/features/fuel-logs/components/DistanceInput.tsx`
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import {
|
||
TextField,
|
||
InputAdornment,
|
||
FormHelperText,
|
||
Box,
|
||
Typography
|
||
} from '@mui/material';
|
||
import { UnitSystem, DistanceType } from '../types/fuel-logs.types';
|
||
|
||
interface DistanceInputProps {
|
||
type: DistanceType;
|
||
value?: number;
|
||
onChange: (value: number) => void;
|
||
unitSystem?: UnitSystem;
|
||
error?: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
export const DistanceInput: React.FC<DistanceInputProps> = ({
|
||
type,
|
||
value,
|
||
onChange,
|
||
unitSystem = UnitSystem.IMPERIAL,
|
||
error,
|
||
disabled = false
|
||
}) => {
|
||
const getUnits = () => {
|
||
return unitSystem === UnitSystem.IMPERIAL ? 'miles' : 'kilometers';
|
||
};
|
||
|
||
const getLabel = () => {
|
||
if (type === 'odometer') {
|
||
return `Odometer Reading (${getUnits()})`;
|
||
}
|
||
return `Trip Distance (${getUnits()})`;
|
||
};
|
||
|
||
const getHelperText = () => {
|
||
if (error) return error;
|
||
|
||
if (type === 'odometer') {
|
||
return 'Current odometer reading on your vehicle';
|
||
}
|
||
return 'Distance traveled since last fuel log';
|
||
};
|
||
|
||
const getPlaceholder = () => {
|
||
if (type === 'odometer') {
|
||
return unitSystem === UnitSystem.IMPERIAL ? '125,000' : '201,168';
|
||
}
|
||
return unitSystem === UnitSystem.IMPERIAL ? '300' : '483';
|
||
};
|
||
|
||
return (
|
||
<Box>
|
||
<TextField
|
||
label={getLabel()}
|
||
type="number"
|
||
value={value || ''}
|
||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||
fullWidth
|
||
error={!!error}
|
||
disabled={disabled}
|
||
placeholder={getPlaceholder()}
|
||
inputProps={{
|
||
step: type === 'trip' ? 0.1 : 1,
|
||
min: 0
|
||
}}
|
||
InputProps={{
|
||
endAdornment: (
|
||
<InputAdornment position="end">
|
||
{getUnits()}
|
||
</InputAdornment>
|
||
)
|
||
}}
|
||
/>
|
||
<FormHelperText error={!!error}>
|
||
{getHelperText()}
|
||
</FormHelperText>
|
||
|
||
{type === 'odometer' && (
|
||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||
💡 Tip: Use trip distance if you don't want to track odometer readings
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
);
|
||
};
|
||
```
|
||
|
||
### Real-time Cost Calculator
|
||
|
||
**File**: `frontend/src/features/fuel-logs/components/CostCalculator.tsx`
|
||
|
||
```tsx
|
||
import React from 'react';
|
||
import {
|
||
Box,
|
||
Typography,
|
||
Card,
|
||
CardContent,
|
||
Divider,
|
||
Chip
|
||
} from '@mui/material';
|
||
import { UnitSystem } from '../types/fuel-logs.types';
|
||
|
||
interface CostCalculatorProps {
|
||
fuelUnits?: number;
|
||
costPerUnit?: number;
|
||
calculatedCost: number;
|
||
unitSystem?: UnitSystem;
|
||
}
|
||
|
||
export const CostCalculator: React.FC<CostCalculatorProps> = ({
|
||
fuelUnits,
|
||
costPerUnit,
|
||
calculatedCost,
|
||
unitSystem = UnitSystem.IMPERIAL
|
||
}) => {
|
||
const unitLabel = unitSystem === UnitSystem.IMPERIAL ? 'gallons' : 'liters';
|
||
|
||
if (!fuelUnits || !costPerUnit) {
|
||
return (
|
||
<Card variant="outlined" sx={{ bgcolor: 'background.default' }}>
|
||
<CardContent sx={{ py: 2 }}>
|
||
<Typography variant="body2" color="text.secondary" align="center">
|
||
Enter fuel amount and cost per unit to see total cost
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card variant="outlined" sx={{ bgcolor: 'primary.50', borderColor: 'primary.200' }}>
|
||
<CardContent sx={{ py: 2 }}>
|
||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Cost Calculation
|
||
</Typography>
|
||
<Chip
|
||
label="Real-time"
|
||
size="small"
|
||
color="primary"
|
||
variant="outlined"
|
||
/>
|
||
</Box>
|
||
|
||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||
<Typography variant="body2">
|
||
{fuelUnits.toFixed(3)} {unitLabel} × ${costPerUnit.toFixed(3)}
|
||
</Typography>
|
||
<Typography variant="h6" color="primary.main" fontWeight="bold">
|
||
${calculatedCost.toFixed(2)}
|
||
</Typography>
|
||
</Box>
|
||
|
||
<Divider sx={{ my: 1 }} />
|
||
|
||
<Typography variant="caption" color="text.secondary">
|
||
Total cost will be automatically calculated
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
};
|
||
```
|
||
|
||
## Custom Hooks
|
||
|
||
### Fuel Logs API Hook
|
||
|
||
**File**: `frontend/src/features/fuel-logs/hooks/useFuelLogs.tsx`
|
||
|
||
```tsx
|
||
import { useState } from 'react';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||
import {
|
||
CreateFuelLogRequest,
|
||
FuelLogResponse,
|
||
EnhancedFuelStats
|
||
} from '../types/fuel-logs.types';
|
||
|
||
export const useFuelLogs = (vehicleId?: string) => {
|
||
const queryClient = useQueryClient();
|
||
|
||
// Fetch user's fuel logs
|
||
const {
|
||
data: fuelLogs,
|
||
isLoading,
|
||
error
|
||
} = useQuery({
|
||
queryKey: ['fuelLogs', vehicleId],
|
||
queryFn: () => vehicleId ?
|
||
fuelLogsApi.getFuelLogsByVehicle(vehicleId) :
|
||
fuelLogsApi.getUserFuelLogs(),
|
||
enabled: true
|
||
});
|
||
|
||
// Create fuel log mutation
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: CreateFuelLogRequest) => fuelLogsApi.createFuelLog(data),
|
||
onSuccess: () => {
|
||
// Invalidate relevant queries
|
||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||
queryClient.invalidateQueries({ queryKey: ['fuelStats'] });
|
||
queryClient.invalidateQueries({ queryKey: ['vehicles'] }); // For odometer updates
|
||
}
|
||
});
|
||
|
||
// Update fuel log mutation
|
||
const updateMutation = useMutation({
|
||
mutationFn: ({ id, data }: { id: string; data: UpdateFuelLogRequest }) =>
|
||
fuelLogsApi.updateFuelLog(id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||
queryClient.invalidateQueries({ queryKey: ['fuelStats'] });
|
||
}
|
||
});
|
||
|
||
// Delete fuel log mutation
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => fuelLogsApi.deleteFuelLog(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||
queryClient.invalidateQueries({ queryKey: ['fuelStats'] });
|
||
}
|
||
});
|
||
|
||
return {
|
||
fuelLogs,
|
||
isLoading,
|
||
error,
|
||
createFuelLog: createMutation.mutateAsync,
|
||
updateFuelLog: updateMutation.mutateAsync,
|
||
deleteFuelLog: deleteMutation.mutateAsync,
|
||
isCreating: createMutation.isPending,
|
||
isUpdating: updateMutation.isPending,
|
||
isDeleting: deleteMutation.isPending
|
||
};
|
||
};
|
||
|
||
export const useFuelStats = (vehicleId: string) => {
|
||
return useQuery({
|
||
queryKey: ['fuelStats', vehicleId],
|
||
queryFn: () => fuelLogsApi.getVehicleStats(vehicleId),
|
||
enabled: !!vehicleId
|
||
});
|
||
};
|
||
```
|
||
|
||
### Dynamic Fuel Grades Hook
|
||
|
||
**File**: `frontend/src/features/fuel-logs/hooks/useFuelGrades.tsx`
|
||
|
||
```tsx
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||
import { FuelType } from '../types/fuel-logs.types';
|
||
|
||
export const useFuelGrades = (fuelType?: FuelType) => {
|
||
const {
|
||
data: fuelGrades,
|
||
isLoading,
|
||
error
|
||
} = useQuery({
|
||
queryKey: ['fuelGrades', fuelType],
|
||
queryFn: () => fuelLogsApi.getFuelGrades(fuelType!),
|
||
enabled: !!fuelType,
|
||
staleTime: 1000 * 60 * 60, // 1 hour (grades don't change often)
|
||
});
|
||
|
||
return {
|
||
fuelGrades: fuelGrades?.grades || [],
|
||
isLoading,
|
||
error
|
||
};
|
||
};
|
||
|
||
export const useFuelTypes = () => {
|
||
return useQuery({
|
||
queryKey: ['fuelTypes'],
|
||
queryFn: () => fuelLogsApi.getAllFuelTypes(),
|
||
staleTime: 1000 * 60 * 60 * 24, // 24 hours (fuel types are static)
|
||
});
|
||
};
|
||
```
|
||
|
||
## Main Pages
|
||
|
||
### Fuel Logs Page
|
||
|
||
**File**: `frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx`
|
||
|
||
```tsx
|
||
import React, { useState } from 'react';
|
||
import {
|
||
Container,
|
||
Typography,
|
||
Box,
|
||
Button,
|
||
Tabs,
|
||
Tab,
|
||
Grid,
|
||
Card,
|
||
CardContent
|
||
} from '@mui/material';
|
||
import { Add as AddIcon, Analytics as AnalyticsIcon } from '@mui/icons-material';
|
||
|
||
import { FuelLogForm } from '../components/FuelLogForm';
|
||
import { FuelLogsList } from '../components/FuelLogsList';
|
||
import { FuelStatsCard } from '../components/FuelStatsCard';
|
||
import { VehicleSelector } from '../components/VehicleSelector';
|
||
|
||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||
|
||
export const FuelLogsPage: React.FC = () => {
|
||
const [selectedVehicleId, setSelectedVehicleId] = useState<string>('');
|
||
const [activeTab, setActiveTab] = useState(0);
|
||
const [showAddForm, setShowAddForm] = useState(false);
|
||
|
||
const { vehicles } = useVehicles();
|
||
const { fuelLogs, isLoading } = useFuelLogs(selectedVehicleId || undefined);
|
||
|
||
// Auto-select first vehicle if only one exists
|
||
React.useEffect(() => {
|
||
if (vehicles && vehicles.length === 1 && !selectedVehicleId) {
|
||
setSelectedVehicleId(vehicles[0].id);
|
||
}
|
||
}, [vehicles, selectedVehicleId]);
|
||
|
||
const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
|
||
setActiveTab(newValue);
|
||
};
|
||
|
||
if (showAddForm) {
|
||
return (
|
||
<Container maxWidth="md">
|
||
<Box py={3}>
|
||
<FuelLogForm
|
||
initialData={{ vehicleId: selectedVehicleId }}
|
||
onSuccess={() => setShowAddForm(false)}
|
||
onCancel={() => setShowAddForm(false)}
|
||
/>
|
||
</Box>
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Container maxWidth="lg">
|
||
<Box py={3}>
|
||
{/* Header */}
|
||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||
<Typography variant="h4" component="h1">
|
||
Fuel Logs
|
||
</Typography>
|
||
<Button
|
||
variant="contained"
|
||
startIcon={<AddIcon />}
|
||
onClick={() => setShowAddForm(true)}
|
||
disabled={!selectedVehicleId}
|
||
>
|
||
Add Fuel Log
|
||
</Button>
|
||
</Box>
|
||
|
||
{/* Vehicle Selection */}
|
||
<Card sx={{ mb: 3 }}>
|
||
<CardContent>
|
||
<Grid container spacing={2} alignItems="center">
|
||
<Grid item xs={12} md={8}>
|
||
<VehicleSelector
|
||
value={selectedVehicleId}
|
||
onChange={setSelectedVehicleId}
|
||
required
|
||
/>
|
||
</Grid>
|
||
<Grid item xs={12} md={4}>
|
||
<Box display="flex" gap={1} alignItems="center">
|
||
<AnalyticsIcon color="action" />
|
||
<Typography variant="body2" color="text.secondary">
|
||
{fuelLogs?.length || 0} fuel logs recorded
|
||
</Typography>
|
||
</Box>
|
||
</Grid>
|
||
</Grid>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Content Tabs */}
|
||
{selectedVehicleId && (
|
||
<>
|
||
<Box borderBottom={1} borderColor="divider" mb={3}>
|
||
<Tabs value={activeTab} onChange={handleTabChange}>
|
||
<Tab label="Fuel Logs" />
|
||
<Tab label="Statistics" />
|
||
</Tabs>
|
||
</Box>
|
||
|
||
{/* Tab Panels */}
|
||
{activeTab === 0 && (
|
||
<FuelLogsList
|
||
vehicleId={selectedVehicleId}
|
||
fuelLogs={fuelLogs}
|
||
isLoading={isLoading}
|
||
onEdit={(logId) => {
|
||
// Navigate to edit form or open modal
|
||
console.log('Edit fuel log:', logId);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 1 && (
|
||
<FuelStatsCard vehicleId={selectedVehicleId} />
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Empty State */}
|
||
{!selectedVehicleId && vehicles && vehicles.length > 1 && (
|
||
<Card>
|
||
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||
<Typography variant="h6" gutterBottom>
|
||
Select a Vehicle
|
||
</Typography>
|
||
<Typography color="text.secondary">
|
||
Choose a vehicle to view and manage its fuel logs
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
</Box>
|
||
</Container>
|
||
);
|
||
};
|
||
```
|
||
|
||
## Success Criteria
|
||
|
||
### Phase 4 Complete When:
|
||
- ✅ Enhanced fuel log form fully functional
|
||
- ✅ Dynamic fuel type/grade selection working
|
||
- ✅ Imperial/Metric units display correctly
|
||
- ✅ Real-time cost calculation working
|
||
- ✅ Trip distance vs odometer toggle functional
|
||
- ✅ Vehicle selection integrated
|
||
- ✅ Mobile-responsive design
|
||
- ✅ Form validation comprehensive
|
||
- ✅ API integration complete
|
||
- ✅ Error handling robust
|
||
|
||
### Ready for Phase 5 When:
|
||
- All React components tested and functional
|
||
- User interface intuitive and mobile-friendly
|
||
- Form validation catching all user errors
|
||
- API integration stable and performant
|
||
- Ready for location service integration
|
||
|
||
---
|
||
|
||
**Next Phase**: [Phase 5 - Future Integration Preparation](FUEL-LOGS-PHASE-5.md) |