Initial Commit
This commit is contained in:
35
frontend/src/features/fuel-logs/api/fuel-logs.api.ts
Normal file
35
frontend/src/features/fuel-logs/api/fuel-logs.api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { CreateFuelLogRequest, FuelLogResponse, EnhancedFuelStats, FuelType, FuelGradeOption } from '../types/fuel-logs.types';
|
||||
|
||||
export const fuelLogsApi = {
|
||||
async create(data: CreateFuelLogRequest): Promise<FuelLogResponse> {
|
||||
const res = await apiClient.post('/fuel-logs', data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getUserFuelLogs(): Promise<FuelLogResponse[]> {
|
||||
const res = await apiClient.get('/fuel-logs');
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getFuelLogsByVehicle(vehicleId: string): Promise<FuelLogResponse[]> {
|
||||
const res = await apiClient.get(`/fuel-logs/vehicle/${vehicleId}`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getVehicleStats(vehicleId: string): Promise<EnhancedFuelStats> {
|
||||
const res = await apiClient.get(`/fuel-logs/vehicle/${vehicleId}/stats`);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getFuelTypes(): Promise<{ value: FuelType; label: string; grades: FuelGradeOption[] }[]> {
|
||||
const res = await apiClient.get('/fuel-logs/fuel-types');
|
||||
return res.data.fuelTypes;
|
||||
},
|
||||
|
||||
async getFuelGrades(fuelType: FuelType): Promise<FuelGradeOption[]> {
|
||||
const res = await apiClient.get(`/fuel-logs/fuel-grades/${fuelType}`);
|
||||
return res.data.grades;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
|
||||
import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
interface Props {
|
||||
fuelUnits?: number;
|
||||
costPerUnit?: number;
|
||||
calculatedCost: number;
|
||||
unitSystem?: UnitSystem;
|
||||
}
|
||||
|
||||
export const CostCalculator: React.FC<Props> = ({ fuelUnits, costPerUnit, calculatedCost, unitSystem = 'imperial' }) => {
|
||||
const unitLabel = unitSystem === 'imperial' ? 'gallons' : 'liters';
|
||||
|
||||
// Ensure we have valid numbers
|
||||
const safeUnits = typeof fuelUnits === 'number' && !isNaN(fuelUnits) ? fuelUnits : 0;
|
||||
const safeCostPerUnit = typeof costPerUnit === 'number' && !isNaN(costPerUnit) ? costPerUnit : 0;
|
||||
const safeCost = typeof calculatedCost === 'number' && !isNaN(calculatedCost) ? calculatedCost : 0;
|
||||
|
||||
if (!fuelUnits || !costPerUnit || safeUnits <= 0 || safeCostPerUnit <= 0) {
|
||||
return (
|
||||
<Card variant="outlined"><CardContent><Typography variant="body2" color="text.secondary">Enter fuel amount and cost per unit to see total cost.</Typography></CardContent></Card>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" 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" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2">{safeUnits.toFixed(3)} {unitLabel} × ${safeCostPerUnit.toFixed(3)}</Typography>
|
||||
<Typography variant="h6" color="primary.main" fontWeight={700}>${safeCost.toFixed(2)}</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
34
frontend/src/features/fuel-logs/components/DistanceInput.tsx
Normal file
34
frontend/src/features/fuel-logs/components/DistanceInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { TextField, InputAdornment, FormHelperText, Box } from '@mui/material';
|
||||
import { UnitSystem, DistanceType } from '../types/fuel-logs.types';
|
||||
|
||||
interface Props {
|
||||
type: DistanceType;
|
||||
value?: number;
|
||||
onChange: (value: number) => void;
|
||||
unitSystem?: UnitSystem;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DistanceInput: React.FC<Props> = ({ type, value, onChange, unitSystem = 'imperial', error, disabled }) => {
|
||||
const units = unitSystem === 'imperial' ? 'miles' : 'kilometers';
|
||||
const label = type === 'odometer' ? `Odometer (${units})` : `Trip Distance (${units})`;
|
||||
return (
|
||||
<Box>
|
||||
<TextField
|
||||
label={label}
|
||||
type="number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
fullWidth
|
||||
error={!!error}
|
||||
disabled={disabled}
|
||||
inputProps={{ step: type === 'trip' ? 0.1 : 1, min: 0 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">{units}</InputAdornment> }}
|
||||
/>
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
161
frontend/src/features/fuel-logs/components/FuelLogForm.tsx
Normal file
161
frontend/src/features/fuel-logs/components/FuelLogForm.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useMemo, useState } 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 { 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 } 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'
|
||||
});
|
||||
|
||||
export const FuelLogForm: React.FC<{ onSuccess?: () => void; initial?: Partial<CreateFuelLogRequest> }> = ({ onSuccess, initial }) => {
|
||||
const { userSettings } = useUserSettings();
|
||||
const { createFuelLog, isLoading } = useFuelLogs();
|
||||
const [useOdometer, setUseOdometer] = useState(false);
|
||||
|
||||
const { control, handleSubmit, watch, setValue, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
|
||||
resolver: zodResolver(schema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
dateTime: new Date().toISOString().slice(0, 16),
|
||||
fuelType: FuelType.GASOLINE,
|
||||
...initial
|
||||
} as any
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
const onSubmit = async (data: CreateFuelLogRequest) => {
|
||||
const payload: CreateFuelLogRequest = {
|
||||
...data,
|
||||
odometerReading: useOdometer ? data.odometerReading : undefined,
|
||||
tripDistance: useOdometer ? undefined : data.tripDistance,
|
||||
};
|
||||
await createFuelLog(payload);
|
||||
onSuccess?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (useOdometer) setValue('tripDistance', undefined as any);
|
||||
else setValue('odometerReading', undefined as any);
|
||||
}, [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}>
|
||||
<Grid item xs={12}>
|
||||
<Controller name="vehicleId" control={control} render={({ field }) => (
|
||||
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
|
||||
)} />
|
||||
</Grid>
|
||||
<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>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControlLabel control={<Switch checked={useOdometer} onChange={(e) => setUseOdometer(e.target.checked)} />} label={`Use ${useOdometer ? 'Odometer' : 'Trip Distance'}`} />
|
||||
</Grid>
|
||||
<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}>
|
||||
<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 }) => (
|
||||
<LocationInput value={field.value as any} onChange={field.onChange as any} 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>
|
||||
);
|
||||
};
|
||||
|
||||
27
frontend/src/features/fuel-logs/components/FuelLogsList.tsx
Normal file
27
frontend/src/features/fuel-logs/components/FuelLogsList.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, List, ListItem, ListItemText, Chip, Box } from '@mui/material';
|
||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||
|
||||
export const FuelLogsList: React.FC<{ logs?: FuelLogResponse[] }>= ({ logs }) => {
|
||||
if (!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) => (
|
||||
<ListItem key={log.id} divider>
|
||||
<ListItemText
|
||||
primary={`${new Date(log.dateTime).toLocaleString()} – $${(log.totalCost || 0).toFixed(2)}`}
|
||||
secondary={`${(log.fuelUnits || 0).toFixed(3)} @ $${(log.costPerUnit || 0).toFixed(3)} • ${log.odometerReading ? `Odo: ${log.odometerReading}` : `Trip: ${log.tripDistance}`}`}
|
||||
/>
|
||||
{log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && (
|
||||
<Box><Chip label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`} size="small" color="primary" /></Box>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
38
frontend/src/features/fuel-logs/components/FuelStatsCard.tsx
Normal file
38
frontend/src/features/fuel-logs/components/FuelStatsCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card, CardContent, Grid, Typography } from '@mui/material';
|
||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
|
||||
export const FuelStatsCard: React.FC<{ logs?: FuelLogResponse[] }> = ({ logs }) => {
|
||||
const { unitSystem } = useUnits();
|
||||
const stats = useMemo(() => {
|
||||
if (!logs || logs.length === 0) return { count: 0, totalUnits: 0, totalCost: 0 };
|
||||
const totalUnits = logs.reduce((s, l) => s + (l.fuelUnits || 0), 0);
|
||||
const totalCost = logs.reduce((s, l) => s + (l.totalCost || 0), 0);
|
||||
return { count: logs.length, totalUnits, totalCost };
|
||||
}, [logs]);
|
||||
|
||||
const unitLabel = unitSystem === 'imperial' ? 'gallons' : 'liters';
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Logs</Typography>
|
||||
<Typography variant="h6">{stats.count}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Total Fuel</Typography>
|
||||
<Typography variant="h6">{(stats.totalUnits || 0).toFixed(2)} {unitLabel}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Total Cost</Typography>
|
||||
<Typography variant="h6">${(stats.totalCost || 0).toFixed(2)}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 Props {
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
onFuelTypeChange: (fuelType: FuelType) => void;
|
||||
onFuelGradeChange: (fuelGrade?: FuelGrade) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const FuelTypeSelector: React.FC<Props> = ({ fuelType, fuelGrade, onFuelTypeChange, onFuelGradeChange, error, disabled }) => {
|
||||
const { fuelGrades, isLoading } = useFuelGrades(fuelType);
|
||||
|
||||
useEffect(() => {
|
||||
if (fuelGrade && fuelGrades && !fuelGrades.some(g => g.value === fuelGrade)) {
|
||||
onFuelGradeChange(undefined);
|
||||
}
|
||||
}, [fuelType, fuelGrades, fuelGrade, onFuelGradeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fuelGrade && fuelGrades && fuelGrades.length > 0) {
|
||||
onFuelGradeChange(fuelGrades[0].value as FuelGrade);
|
||||
}
|
||||
}, [fuelGrades, fuelGrade, onFuelGradeChange]);
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth error={!!error}>
|
||||
<InputLabel>Fuel Type</InputLabel>
|
||||
<Select value={fuelType} label="Fuel Type" onChange={(e) => onFuelTypeChange(e.target.value as FuelType)} 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>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<FormControl fullWidth disabled={disabled || isLoading || fuelType === FuelType.ELECTRIC}>
|
||||
<InputLabel>Fuel Grade</InputLabel>
|
||||
<Select value={fuelGrade || ''} label="Fuel Grade" onChange={(e) => onFuelGradeChange(e.target.value as FuelGrade)}>
|
||||
{fuelGrades?.map((g) => (
|
||||
<MenuItem key={g.value || 'none'} value={g.value || ''}>{g.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{fuelType !== FuelType.ELECTRIC && <FormHelperText>{isLoading ? 'Loading grades…' : 'Select a grade'}</FormHelperText>}
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
22
frontend/src/features/fuel-logs/components/LocationInput.tsx
Normal file
22
frontend/src/features/fuel-logs/components/LocationInput.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { LocationData } from '../types/fuel-logs.types';
|
||||
|
||||
interface Props {
|
||||
value?: LocationData;
|
||||
onChange: (value?: LocationData) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const LocationInput: React.FC<Props> = ({ value, onChange, placeholder }) => {
|
||||
return (
|
||||
<TextField
|
||||
label="Location (optional)"
|
||||
placeholder={placeholder}
|
||||
fullWidth
|
||||
value={value?.stationName || value?.address || ''}
|
||||
onChange={(e) => onChange({ ...(value || {}), stationName: e.target.value })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Typography } from '@mui/material';
|
||||
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)';
|
||||
return (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{showLabel ? `${showLabel} ` : ''}{label}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { FormControl, InputLabel, Select, MenuItem, FormHelperText, Box, Typography } from '@mui/material';
|
||||
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
|
||||
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange: (vehicleId: string) => void;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const VehicleSelector: React.FC<Props> = ({ value, onChange, error, required, disabled }) => {
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
|
||||
if (!isLoading && (vehicles?.length || 0) === 0) {
|
||||
return (
|
||||
<Box p={2} borderRadius={1} bgcolor={'background.default'}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
You need to add a vehicle before creating fuel logs.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl fullWidth error={!!error} required={required}>
|
||||
<InputLabel>Select Vehicle</InputLabel>
|
||||
<Select value={value || ''} onChange={(e) => onChange(e.target.value as string)} label="Select Vehicle" disabled={disabled}>
|
||||
{vehicles?.map((v: Vehicle) => (
|
||||
<MenuItem key={v.id} value={v.id}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<DirectionsCarIcon fontSize="small" />
|
||||
<Typography variant="body2">{`${v.year || ''} ${v.make || ''} ${v.model || ''}`.trim()}</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{error && <FormHelperText>{error}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
12
frontend/src/features/fuel-logs/hooks/useFuelGrades.tsx
Normal file
12
frontend/src/features/fuel-logs/hooks/useFuelGrades.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||
import { FuelType, FuelGradeOption } from '../types/fuel-logs.types';
|
||||
|
||||
export const useFuelGrades = (fuelType: FuelType) => {
|
||||
const { data, isLoading, error } = useQuery<FuelGradeOption[]>({
|
||||
queryKey: ['fuelGrades', fuelType],
|
||||
queryFn: () => fuelLogsApi.getFuelGrades(fuelType),
|
||||
});
|
||||
return { fuelGrades: data || [], isLoading, error };
|
||||
};
|
||||
|
||||
36
frontend/src/features/fuel-logs/hooks/useFuelLogs.tsx
Normal file
36
frontend/src/features/fuel-logs/hooks/useFuelLogs.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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();
|
||||
|
||||
const logsQuery = useQuery<FuelLogResponse[]>({
|
||||
queryKey: ['fuelLogs', vehicleId || 'all'],
|
||||
queryFn: () => (vehicleId ? fuelLogsApi.getFuelLogsByVehicle(vehicleId) : fuelLogsApi.getUserFuelLogs()),
|
||||
});
|
||||
|
||||
const statsQuery = useQuery<EnhancedFuelStats>({
|
||||
queryKey: ['fuelLogsStats', vehicleId],
|
||||
queryFn: () => fuelLogsApi.getVehicleStats(vehicleId!),
|
||||
enabled: !!vehicleId,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateFuelLogRequest) => fuelLogsApi.create(data),
|
||||
onSuccess: (_res, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs', variables.vehicleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats', variables.vehicleId] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
fuelLogs: logsQuery.data,
|
||||
isLoading: logsQuery.isLoading || createMutation.isPending,
|
||||
error: logsQuery.error,
|
||||
stats: statsQuery.data,
|
||||
isStatsLoading: statsQuery.isLoading,
|
||||
createFuelLog: createMutation.mutateAsync,
|
||||
};
|
||||
};
|
||||
|
||||
15
frontend/src/features/fuel-logs/hooks/useUserSettings.tsx
Normal file
15
frontend/src/features/fuel-logs/hooks/useUserSettings.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
import { UnitSystem } from '../types/fuel-logs.types';
|
||||
|
||||
export const useUserSettings = () => {
|
||||
const { unitSystem } = useUnits();
|
||||
// Placeholder for future: fetch currency/timezone from a settings API
|
||||
return {
|
||||
userSettings: {
|
||||
unitSystem: unitSystem as UnitSystem,
|
||||
currencyCode: 'USD',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
24
frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx
Normal file
24
frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Grid, Typography } from '@mui/material';
|
||||
import { FuelLogForm } from '../components/FuelLogForm';
|
||||
import { FuelLogsList } from '../components/FuelLogsList';
|
||||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||||
import { FuelStatsCard } from '../components/FuelStatsCard';
|
||||
|
||||
export const FuelLogsPage: React.FC = () => {
|
||||
const { fuelLogs } = useFuelLogs();
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FuelLogForm />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" gutterBottom>Recent Fuel Logs</Typography>
|
||||
<FuelLogsList logs={fuelLogs} />
|
||||
<Typography variant="h6" sx={{ mt: 3 }} gutterBottom>Summary</Typography>
|
||||
<FuelStatsCard logs={fuelLogs} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
72
frontend/src/features/fuel-logs/types/fuel-logs.types.ts
Normal file
72
frontend/src/features/fuel-logs/types/fuel-logs.types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @ai-summary Types for enhanced fuel logs UI
|
||||
*/
|
||||
|
||||
export type UnitSystem = 'imperial' | 'metric';
|
||||
|
||||
export enum FuelType {
|
||||
GASOLINE = 'gasoline',
|
||||
DIESEL = 'diesel',
|
||||
ELECTRIC = 'electric'
|
||||
}
|
||||
|
||||
export type FuelGrade = '87' | '88' | '89' | '91' | '93' | '#1' | '#2' | null;
|
||||
|
||||
export interface LocationData {
|
||||
address?: string;
|
||||
coordinates?: { latitude: number; longitude: number };
|
||||
googlePlaceId?: string;
|
||||
stationName?: string;
|
||||
}
|
||||
|
||||
export type DistanceType = 'odometer' | 'trip';
|
||||
|
||||
export interface CreateFuelLogRequest {
|
||||
vehicleId: string;
|
||||
dateTime: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
locationData?: LocationData;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface FuelLogResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
vehicleId: string;
|
||||
dateTime: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits: number;
|
||||
costPerUnit: number;
|
||||
totalCost: number;
|
||||
locationData?: LocationData;
|
||||
efficiency?: number;
|
||||
efficiencyLabel: string;
|
||||
notes?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EnhancedFuelStats {
|
||||
logCount: number;
|
||||
totalFuelUnits: number;
|
||||
totalCost: number;
|
||||
averageCostPerUnit: number;
|
||||
totalDistance: number;
|
||||
averageEfficiency: number;
|
||||
unitLabels: { fuelUnits: string; distanceUnits: string; efficiencyUnits: string };
|
||||
}
|
||||
|
||||
export interface FuelGradeOption {
|
||||
value: FuelGrade;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
58
frontend/src/features/settings/hooks/useSettings.ts
Normal file
58
frontend/src/features/settings/hooks/useSettings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSettingsPersistence, SettingsState } from './useSettingsPersistence';
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
darkMode: false,
|
||||
unitSystem: 'imperial',
|
||||
notifications: {
|
||||
email: true,
|
||||
push: true,
|
||||
maintenance: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const { loadSettings, saveSettings } = useSettingsPersistence();
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const savedSettings = loadSettings();
|
||||
if (savedSettings) {
|
||||
setSettings(savedSettings);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [loadSettings]);
|
||||
|
||||
const updateSetting = <K extends keyof SettingsState>(
|
||||
key: K,
|
||||
value: SettingsState[K]
|
||||
) => {
|
||||
try {
|
||||
setError(null);
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
setSettings(newSettings);
|
||||
saveSettings(newSettings);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
settings,
|
||||
updateSetting,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export type { SettingsState } from './useSettingsPersistence';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export interface SettingsState {
|
||||
darkMode: boolean;
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
notifications: {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
maintenance: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'motovaultpro-mobile-settings';
|
||||
|
||||
export const useSettingsPersistence = () => {
|
||||
const loadSettings = useCallback((): SettingsState | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveSettings = useCallback((settings: SettingsState) => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
};
|
||||
};
|
||||
323
frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
Normal file
323
frontend/src/features/settings/mobile/MobileSettingsScreen.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
|
||||
import { MobileContainer } from '../../../shared-minimal/components/mobile/MobileContainer';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
label,
|
||||
description
|
||||
}) => (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{label}</p>
|
||||
{description && (
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-4">{title}</h3>
|
||||
{children}
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSettingsScreen: React.FC = () => {
|
||||
const { user, logout } = useAuth0();
|
||||
const { settings, updateSetting, isLoading, error } = useSettings();
|
||||
const [showDataExport, setShowDataExport] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout({
|
||||
logoutParams: {
|
||||
returnTo: window.location.origin
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportData = () => {
|
||||
// TODO: Implement data export functionality
|
||||
console.log('Exporting user data...');
|
||||
setShowDataExport(false);
|
||||
};
|
||||
|
||||
const handleDeleteAccount = () => {
|
||||
// TODO: Implement account deletion
|
||||
console.log('Deleting account...');
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="text-slate-500 mb-2">Loading settings...</div>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
<GlassCard padding="md">
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 mb-4">Failed to load settings</p>
|
||||
<p className="text-sm text-slate-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileContainer>
|
||||
<div className="space-y-4 pb-20 p-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Settings</h1>
|
||||
<p className="text-slate-500 mt-2">Manage your account and preferences</p>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account</h2>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.picture && (
|
||||
<img
|
||||
src={user.picture}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">{user?.name}</p>
|
||||
<p className="text-sm text-slate-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-3 mt-3 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-600">
|
||||
Member since {user?.updated_at ? new Date(user.updated_at).toLocaleDateString() : 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Notifications Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Notifications</h2>
|
||||
<div className="space-y-3">
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.email}
|
||||
onChange={() => updateSetting('notifications', {
|
||||
...settings.notifications,
|
||||
email: !settings.notifications.email
|
||||
})}
|
||||
label="Email Notifications"
|
||||
description="Receive updates via email"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.push}
|
||||
onChange={() => updateSetting('notifications', {
|
||||
...settings.notifications,
|
||||
push: !settings.notifications.push
|
||||
})}
|
||||
label="Push Notifications"
|
||||
description="Receive mobile push notifications"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
enabled={settings.notifications.maintenance}
|
||||
onChange={() => updateSetting('notifications', {
|
||||
...settings.notifications,
|
||||
maintenance: !settings.notifications.maintenance
|
||||
})}
|
||||
label="Maintenance Reminders"
|
||||
description="Get reminded about vehicle maintenance"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Appearance & Units Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Appearance & Units</h2>
|
||||
<div className="space-y-4">
|
||||
<ToggleSwitch
|
||||
enabled={settings.darkMode}
|
||||
onChange={() => updateSetting('darkMode', !settings.darkMode)}
|
||||
label="Dark Mode"
|
||||
description="Switch to dark theme"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-800">Unit System</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Currently using {settings.unitSystem === 'imperial' ? 'Miles & Gallons' : 'Kilometers & Liters'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateSetting('unitSystem', settings.unitSystem === 'imperial' ? 'metric' : 'imperial')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-200 transition-colors"
|
||||
>
|
||||
{settings.unitSystem === 'imperial' ? 'Switch to Metric' : 'Switch to Imperial'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Data Management Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Data Management</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(true)}
|
||||
className="w-full text-left p-3 bg-blue-50 text-blue-700 rounded-lg font-medium hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
Export My Data
|
||||
</button>
|
||||
<p className="text-sm text-slate-500">
|
||||
Download a copy of all your vehicle and fuel data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Account Actions Section */}
|
||||
<GlassCard padding="md">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 mb-4">Account Actions</h2>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full py-3 px-4 bg-gray-100 text-gray-700 rounded-lg text-left font-medium hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="w-full py-3 px-4 bg-red-50 text-red-600 rounded-lg text-left font-medium hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Data Export Modal */}
|
||||
<Modal
|
||||
isOpen={showDataExport}
|
||||
onClose={() => setShowDataExport(false)}
|
||||
title="Export Data"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
This will create a downloadable file containing all your vehicle data, fuel logs, and preferences.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDataExport(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportData}
|
||||
className="flex-1 py-2 px-4 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Account Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title="Delete Account"
|
||||
>
|
||||
<p className="text-slate-600 mb-4">
|
||||
This action cannot be undone. All your data will be permanently deleted.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-200 text-gray-700 rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="flex-1 py-2 px-4 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</MobileContainer>
|
||||
);
|
||||
};
|
||||
@@ -3,18 +3,9 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import axios from 'axios';
|
||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption } from '../types/vehicles.types';
|
||||
import { Vehicle, CreateVehicleRequest, UpdateVehicleRequest, DropdownOption, VINDecodeResponse } from '../types/vehicles.types';
|
||||
|
||||
// Unauthenticated client for dropdown data
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
const dropdownClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
// All requests (including dropdowns) use authenticated apiClient
|
||||
|
||||
export const vehiclesApi = {
|
||||
getAll: async (): Promise<Vehicle[]> => {
|
||||
@@ -41,29 +32,40 @@ export const vehiclesApi = {
|
||||
await apiClient.delete(`/vehicles/${id}`);
|
||||
},
|
||||
|
||||
// Dropdown API methods (unauthenticated)
|
||||
getMakes: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/makes');
|
||||
// Dropdown API methods (authenticated)
|
||||
getYears: async (): Promise<number[]> => {
|
||||
const response = await apiClient.get('/vehicles/dropdown/years');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getModels: async (make: string): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get(`/vehicles/dropdown/models/${encodeURIComponent(make)}`);
|
||||
getMakes: async (year: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/makes?year=${year}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTransmissions: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/transmissions');
|
||||
getModels: async (year: number, makeId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/models?year=${year}&make_id=${makeId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getEngines: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/engines');
|
||||
getTransmissions: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/transmissions?year=${year}&make_id=${makeId}&model_id=${modelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrims: async (): Promise<DropdownOption[]> => {
|
||||
const response = await dropdownClient.get('/vehicles/dropdown/trims');
|
||||
getEngines: async (year: number, makeId: number, modelId: number, trimId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/engines?year=${year}&make_id=${makeId}&model_id=${modelId}&trim_id=${trimId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
getTrims: async (year: number, makeId: number, modelId: number): Promise<DropdownOption[]> => {
|
||||
const response = await apiClient.get(`/vehicles/dropdown/trims?year=${year}&make_id=${makeId}&model_id=${modelId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// VIN decode method
|
||||
decodeVIN: async (vin: string): Promise<VINDecodeResponse> => {
|
||||
const response = await apiClient.post('/vehicles/decode-vin', { vin });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, CardActionArea, Box, Typography, IconButton } from '
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { useUnits } from '../../../core/units/UnitsContext';
|
||||
|
||||
interface VehicleCardProps {
|
||||
vehicle: Vehicle;
|
||||
@@ -35,8 +36,9 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
onDelete,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { formatDistance } = useUnits();
|
||||
const displayName = vehicle.nickname ||
|
||||
`${vehicle.year} ${vehicle.make} ${vehicle.model}`;
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -72,7 +74,7 @@ export const VehicleCard: React.FC<VehicleCardProps> = ({
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="text.primary" sx={{ mt: 1, fontWeight: 500 }}>
|
||||
Odometer: {vehicle.odometerReading.toLocaleString()} miles
|
||||
Odometer: {formatDistance(vehicle.odometerReading)}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
@@ -10,20 +10,49 @@ import { Button } from '../../../shared-minimal/components/Button';
|
||||
import { CreateVehicleRequest, DropdownOption } from '../types/vehicles.types';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
|
||||
const vehicleSchema = z.object({
|
||||
vin: z.string().length(17, 'VIN must be exactly 17 characters'),
|
||||
make: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
engine: z.string().optional(),
|
||||
transmission: z.string().optional(),
|
||||
trimLevel: z.string().optional(),
|
||||
driveType: z.string().optional(),
|
||||
fuelType: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
licensePlate: z.string().optional(),
|
||||
odometerReading: z.number().min(0).optional(),
|
||||
});
|
||||
const vehicleSchema = z
|
||||
.object({
|
||||
vin: z.string().optional(),
|
||||
year: z.number().min(1980).max(new Date().getFullYear() + 1).optional(),
|
||||
make: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
engine: z.string().optional(),
|
||||
transmission: z.string().optional(),
|
||||
trimLevel: z.string().optional(),
|
||||
driveType: z.string().optional(),
|
||||
fuelType: z.string().optional(),
|
||||
nickname: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
licensePlate: z.string().optional(),
|
||||
odometerReading: z.number().min(0).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const vin = (data.vin || '').trim();
|
||||
const plate = (data.licensePlate || '').trim();
|
||||
// Must have either a valid 17-char VIN or a non-empty license plate
|
||||
if (vin.length === 17) return true;
|
||||
if (plate.length > 0) return true;
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message: 'Either a valid 17-character VIN or a license plate is required',
|
||||
path: ['vin'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
const vin = (data.vin || '').trim();
|
||||
const plate = (data.licensePlate || '').trim();
|
||||
// If VIN provided but not 17 and no plate, fail; if plate exists, allow any VIN (or empty)
|
||||
if (plate.length > 0) return true;
|
||||
return vin.length === 17 || vin.length === 0;
|
||||
},
|
||||
{
|
||||
message: 'VIN must be exactly 17 characters when license plate is not provided',
|
||||
path: ['vin'],
|
||||
}
|
||||
);
|
||||
|
||||
interface VehicleFormProps {
|
||||
onSubmit: (data: CreateVehicleRequest) => void;
|
||||
@@ -38,13 +67,18 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
initialData,
|
||||
loading,
|
||||
}) => {
|
||||
const [years, setYears] = useState<number[]>([]);
|
||||
const [makes, setMakes] = useState<DropdownOption[]>([]);
|
||||
const [models, setModels] = useState<DropdownOption[]>([]);
|
||||
const [transmissions, setTransmissions] = useState<DropdownOption[]>([]);
|
||||
const [engines, setEngines] = useState<DropdownOption[]>([]);
|
||||
const [trims, setTrims] = useState<DropdownOption[]>([]);
|
||||
const [selectedMake, setSelectedMake] = useState<string>('');
|
||||
const [selectedYear, setSelectedYear] = useState<number | undefined>();
|
||||
const [selectedMake, setSelectedMake] = useState<DropdownOption | undefined>();
|
||||
const [selectedModel, setSelectedModel] = useState<DropdownOption | undefined>();
|
||||
const [loadingDropdowns, setLoadingDropdowns] = useState(false);
|
||||
const [selectedTrim, setSelectedTrim] = useState<DropdownOption | undefined>();
|
||||
const [decodingVIN, setDecodingVIN] = useState(false);
|
||||
const [decodeSuccess, setDecodeSuccess] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -57,73 +91,226 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
defaultValues: initialData,
|
||||
});
|
||||
|
||||
const watchedYear = watch('year');
|
||||
const watchedMake = watch('make');
|
||||
const watchedModel = watch('model');
|
||||
const watchedVIN = watch('vin');
|
||||
|
||||
// Load dropdown data on component mount
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const [makesData, transmissionsData, enginesData, trimsData] = await Promise.all([
|
||||
vehiclesApi.getMakes(),
|
||||
vehiclesApi.getTransmissions(),
|
||||
vehiclesApi.getEngines(),
|
||||
vehiclesApi.getTrims(),
|
||||
]);
|
||||
// VIN decode handler
|
||||
const handleDecodeVIN = async () => {
|
||||
const vin = watchedVIN;
|
||||
if (!vin || vin.length !== 17) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDecodingVIN(true);
|
||||
setDecodeSuccess(false);
|
||||
|
||||
try {
|
||||
const result = await vehiclesApi.decodeVIN(vin);
|
||||
if (result.success) {
|
||||
// Auto-populate fields with decoded values
|
||||
if (result.year) setValue('year', result.year);
|
||||
if (result.make) setValue('make', result.make);
|
||||
if (result.model) setValue('model', result.model);
|
||||
if (result.transmission) setValue('transmission', result.transmission);
|
||||
if (result.engine) setValue('engine', result.engine);
|
||||
if (result.trimLevel) setValue('trimLevel', result.trimLevel);
|
||||
|
||||
setMakes(makesData);
|
||||
setTransmissions(transmissionsData);
|
||||
setEngines(enginesData);
|
||||
setTrims(trimsData);
|
||||
setDecodeSuccess(true);
|
||||
setTimeout(() => setDecodeSuccess(false), 3000); // Hide success after 3 seconds
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('VIN decode failed:', error);
|
||||
} finally {
|
||||
setDecodingVIN(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load years on component mount
|
||||
useEffect(() => {
|
||||
const loadYears = async () => {
|
||||
try {
|
||||
const yearsData = await vehiclesApi.getYears();
|
||||
setYears(yearsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dropdown data:', error);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
console.error('Failed to load years:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
loadYears();
|
||||
}, []);
|
||||
|
||||
// Load models when make changes
|
||||
// Load makes when year changes
|
||||
useEffect(() => {
|
||||
if (watchedMake && watchedMake !== selectedMake) {
|
||||
const loadModels = async () => {
|
||||
if (watchedYear && watchedYear !== selectedYear) {
|
||||
const loadMakes = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const modelsData = await vehiclesApi.getModels(watchedMake);
|
||||
setModels(modelsData);
|
||||
setSelectedMake(watchedMake);
|
||||
const makesData = await vehiclesApi.getMakes(watchedYear);
|
||||
setMakes(makesData);
|
||||
setSelectedYear(watchedYear);
|
||||
|
||||
// Clear model selection when make changes
|
||||
setValue('model', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
// Clear dependent selections
|
||||
setModels([]);
|
||||
setEngines([]);
|
||||
setTrims([]);
|
||||
setSelectedMake(undefined);
|
||||
setSelectedModel(undefined);
|
||||
setValue('make', '');
|
||||
setValue('model', '');
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load makes:', error);
|
||||
setMakes([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
loadMakes();
|
||||
}
|
||||
}, [watchedMake, selectedMake, setValue]);
|
||||
}, [watchedYear, selectedYear, setValue]);
|
||||
|
||||
// Load models when make changes
|
||||
useEffect(() => {
|
||||
if (watchedMake && watchedYear && watchedMake !== selectedMake?.name) {
|
||||
const makeOption = makes.find(make => make.name === watchedMake);
|
||||
if (makeOption) {
|
||||
const loadModels = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const modelsData = await vehiclesApi.getModels(watchedYear, makeOption.id);
|
||||
setModels(modelsData);
|
||||
setSelectedMake(makeOption);
|
||||
|
||||
// Clear dependent selections
|
||||
setEngines([]);
|
||||
setTrims([]);
|
||||
setSelectedModel(undefined);
|
||||
setValue('model', '');
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
setModels([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}
|
||||
}
|
||||
}, [watchedMake, watchedYear, selectedMake, makes, setValue]);
|
||||
|
||||
// Load trims when model changes
|
||||
useEffect(() => {
|
||||
if (watchedModel && watchedYear && selectedMake && watchedModel !== selectedModel?.name) {
|
||||
const modelOption = models.find(model => model.name === watchedModel);
|
||||
if (modelOption) {
|
||||
const loadTrims = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const trimsData = await vehiclesApi.getTrims(watchedYear, selectedMake.id, modelOption.id);
|
||||
setTrims(trimsData);
|
||||
setSelectedModel(modelOption);
|
||||
// Clear deeper selections
|
||||
setEngines([]);
|
||||
setSelectedTrim(undefined);
|
||||
setValue('transmission', '');
|
||||
setValue('engine', '');
|
||||
setValue('trimLevel', '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load detailed data:', error);
|
||||
setTrims([]);
|
||||
setEngines([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTrims();
|
||||
}
|
||||
}
|
||||
}, [watchedModel, watchedYear, selectedMake, selectedModel, models, setValue]);
|
||||
|
||||
// Load engines when trim changes
|
||||
useEffect(() => {
|
||||
const trimName = watch('trimLevel');
|
||||
if (trimName && watchedYear && selectedMake && selectedModel) {
|
||||
const trimOption = trims.find(t => t.name === trimName);
|
||||
if (trimOption) {
|
||||
const loadEngines = async () => {
|
||||
setLoadingDropdowns(true);
|
||||
try {
|
||||
const enginesData = await vehiclesApi.getEngines(watchedYear, selectedMake.id, selectedModel.id, trimOption.id);
|
||||
setEngines(enginesData);
|
||||
setSelectedTrim(trimOption);
|
||||
} catch (error) {
|
||||
console.error('Failed to load engines:', error);
|
||||
setEngines([]);
|
||||
} finally {
|
||||
setLoadingDropdowns(false);
|
||||
}
|
||||
};
|
||||
loadEngines();
|
||||
}
|
||||
}
|
||||
}, [trims, selectedMake, selectedModel, watchedYear, setValue, watch('trimLevel')]);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
VIN <span className="text-red-500">*</span>
|
||||
VIN or License Plate <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter 17-character VIN"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
{...register('vin')}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="Enter 17-character VIN (optional if License Plate provided)"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDecodeVIN}
|
||||
loading={decodingVIN}
|
||||
disabled={!watchedVIN || watchedVIN.length !== 17}
|
||||
variant="secondary"
|
||||
>
|
||||
Decode
|
||||
</Button>
|
||||
</div>
|
||||
{decodeSuccess && (
|
||||
<p className="mt-1 text-sm text-green-600">VIN decoded successfully! Fields populated.</p>
|
||||
)}
|
||||
{errors.vin && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.vin.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Specification Dropdowns */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Year
|
||||
</label>
|
||||
<select
|
||||
{...register('year', { valueAsNumber: true })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Select Year</option>
|
||||
{years.map((year) => (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Make
|
||||
@@ -131,7 +318,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('make')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
disabled={loadingDropdowns || !watchedYear}
|
||||
>
|
||||
<option value="">Select Make</option>
|
||||
{makes.map((make) => (
|
||||
@@ -149,7 +336,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('model')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={!watchedMake || models.length === 0}
|
||||
disabled={loadingDropdowns || !watchedMake || models.length === 0}
|
||||
>
|
||||
<option value="">Select Model</option>
|
||||
{models.map((model) => (
|
||||
@@ -162,6 +349,26 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Trim (left) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Trim
|
||||
</label>
|
||||
<select
|
||||
{...register('trimLevel')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns || !watchedModel || trims.length === 0}
|
||||
>
|
||||
<option value="">Select Trim</option>
|
||||
{trims.map((trim) => (
|
||||
<option key={trim.id} value={trim.name}>
|
||||
{trim.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Engine (middle) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Engine
|
||||
@@ -169,7 +376,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('engine')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
disabled={loadingDropdowns || !watchedModel || !selectedTrim || engines.length === 0}
|
||||
>
|
||||
<option value="">Select Engine</option>
|
||||
{engines.map((engine) => (
|
||||
@@ -180,6 +387,7 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Transmission (right, static options) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Transmission
|
||||
@@ -187,32 +395,10 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<select
|
||||
{...register('transmission')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
>
|
||||
<option value="">Select Transmission</option>
|
||||
{transmissions.map((transmission) => (
|
||||
<option key={transmission.id} value={transmission.name}>
|
||||
{transmission.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Trim Level
|
||||
</label>
|
||||
<select
|
||||
{...register('trimLevel')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
disabled={loadingDropdowns}
|
||||
>
|
||||
<option value="">Select Trim</option>
|
||||
{trims.map((trim) => (
|
||||
<option key={trim.id} value={trim.name}>
|
||||
{trim.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="Automatic">Automatic</option>
|
||||
<option value="Manual">Manual</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,8 +433,11 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
<input
|
||||
{...register('licensePlate')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="e.g., ABC-123"
|
||||
placeholder="e.g., ABC-123 (required if VIN omitted)"
|
||||
/>
|
||||
{errors.licensePlate && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.licensePlate.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,4 +463,4 @@ export const VehicleForm: React.FC<VehicleFormProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
||||
onLogFuel
|
||||
}) => {
|
||||
const displayName = vehicle.nickname ||
|
||||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
|
||||
const displayModel = vehicle.model || 'Unknown Model';
|
||||
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,7 @@ export const VehicleMobileCard: React.FC<VehicleMobileCardProps> = ({
|
||||
compact = false
|
||||
}) => {
|
||||
const displayName = vehicle.nickname ||
|
||||
(vehicle.year && vehicle.make ? `${vehicle.year} ${vehicle.make}` : 'Vehicle');
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
|
||||
const displayModel = vehicle.model || 'Unknown Model';
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import React, { useTransition, useEffect } from 'react';
|
||||
import { Box, Typography, Grid } from '@mui/material';
|
||||
import { Box, Typography, Grid, Fab } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useVehicles } from '../hooks/useVehicles';
|
||||
import { useOptimisticVehicles } from '../hooks/useOptimisticVehicles';
|
||||
import { useVehicleSearch } from '../hooks/useVehicleTransitions';
|
||||
@@ -14,6 +15,7 @@ import { Vehicle } from '../types/vehicles.types';
|
||||
|
||||
interface VehiclesMobileScreenProps {
|
||||
onVehicleSelect?: (vehicle: Vehicle) => void;
|
||||
onAddVehicle?: () => void;
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode; right?: React.ReactNode }> = ({
|
||||
@@ -33,7 +35,8 @@ const Section: React.FC<{ title: string; children: React.ReactNode; right?: Reac
|
||||
);
|
||||
|
||||
export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
onVehicleSelect
|
||||
onVehicleSelect,
|
||||
onAddVehicle
|
||||
}) => {
|
||||
const { data: vehicles, isLoading } = useVehicles();
|
||||
const [_isPending, startTransition] = useTransition();
|
||||
@@ -66,7 +69,12 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
return (
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography color="text.secondary">Loading vehicles...</Typography>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||
Loading your vehicles...
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Please wait a moment
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -74,7 +82,7 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
|
||||
if (!optimisticVehicles.length) {
|
||||
return (
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
||||
<Section title="Vehicles">
|
||||
<Box sx={{ textAlign: 'center', py: 12 }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>
|
||||
@@ -85,13 +93,27 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80, // Above bottom navigation
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileVehiclesSuspense>
|
||||
<Box sx={{ pb: 10 }}>
|
||||
<Box sx={{ pb: 10, position: 'relative' }}>
|
||||
<Section title={`Vehicles ${isOptimisticPending ? '(Updating...)' : ''}`}>
|
||||
<Grid container spacing={2}>
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
@@ -104,6 +126,20 @@ export const VehiclesMobileScreen: React.FC<VehiclesMobileScreenProps> = ({
|
||||
))}
|
||||
</Grid>
|
||||
</Section>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80, // Above bottom navigation
|
||||
right: 16,
|
||||
zIndex: 1000
|
||||
}}
|
||||
onClick={() => onAddVehicle?.()}
|
||||
>
|
||||
<AddIcon />
|
||||
</Fab>
|
||||
</Box>
|
||||
</MobileVehiclesSuspense>
|
||||
);
|
||||
|
||||
255
frontend/src/features/vehicles/pages/VehicleDetailPage.tsx
Normal file
255
frontend/src/features/vehicles/pages/VehicleDetailPage.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @ai-summary Vehicle detail page matching VehicleForm styling
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Box, Typography, Button as MuiButton, Divider } from '@mui/material';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import LocalGasStationIcon from '@mui/icons-material/LocalGasStation';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import { Vehicle } from '../types/vehicles.types';
|
||||
import { vehiclesApi } from '../api/vehicles.api';
|
||||
import { Card } from '../../../shared-minimal/components/Card';
|
||||
import { VehicleForm } from '../components/VehicleForm';
|
||||
|
||||
const DetailField: React.FC<{
|
||||
label: string;
|
||||
value?: string | number;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
}> = ({ label, value, isRequired, className = "" }) => (
|
||||
<div className={`space-y-1 ${className}`}>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{label} {isRequired && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<div className="px-3 py-2 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<span className="text-gray-900">
|
||||
{value || <span className="text-gray-400 italic">Not provided</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const VehicleDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [vehicle, setVehicle] = useState<Vehicle | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadVehicle = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const vehicleData = await vehiclesApi.getById(id);
|
||||
setVehicle(vehicleData);
|
||||
} catch (err) {
|
||||
setError('Failed to load vehicle details');
|
||||
console.error('Error loading vehicle:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadVehicle();
|
||||
}, [id]);
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/vehicles');
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleUpdateVehicle = async (data: any) => {
|
||||
if (!vehicle) return;
|
||||
|
||||
try {
|
||||
const updatedVehicle = await vehiclesApi.update(vehicle.id, data);
|
||||
setVehicle(updatedVehicle);
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
console.error('Error updating vehicle:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '50vh'
|
||||
}}>
|
||||
<Typography color="text.secondary">Loading vehicle details...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !vehicle) {
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Card>
|
||||
<Box sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Typography color="error.main" sx={{ mb: 3 }}>
|
||||
{error || 'Vehicle not found'}
|
||||
</Typography>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
onClick={handleBack}
|
||||
startIcon={<ArrowBackIcon />}
|
||||
>
|
||||
Back to Vehicles
|
||||
</MuiButton>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = vehicle.nickname ||
|
||||
[vehicle.year, vehicle.make, vehicle.model, vehicle.trimLevel].filter(Boolean).join(' ') || 'Vehicle';
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
mb: 4
|
||||
}}>
|
||||
<MuiButton
|
||||
variant="text"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleCancelEdit}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Cancel
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
Edit {displayName}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<VehicleForm
|
||||
initialData={vehicle}
|
||||
onSubmit={handleUpdateVehicle}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mb: 4
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<MuiButton
|
||||
variant="text"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Back
|
||||
</MuiButton>
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
</Box>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={handleEdit}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Edit Vehicle
|
||||
</MuiButton>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
|
||||
<MuiButton
|
||||
variant="contained"
|
||||
startIcon={<LocalGasStationIcon />}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Add Fuel Log
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
variant="outlined"
|
||||
startIcon={<BuildIcon />}
|
||||
sx={{ borderRadius: '999px' }}
|
||||
>
|
||||
Schedule Maintenance
|
||||
</MuiButton>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
|
||||
Vehicle Details
|
||||
</Typography>
|
||||
|
||||
<form className="space-y-4">
|
||||
<DetailField
|
||||
label="VIN or License Plate"
|
||||
value={vehicle.vin || vehicle.licensePlate}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
{/* Vehicle Specification Section */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DetailField label="Year" value={vehicle.year} />
|
||||
<DetailField label="Make" value={vehicle.make} />
|
||||
<DetailField label="Model" value={vehicle.model} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<DetailField label="Trim" value={vehicle.trimLevel} />
|
||||
<DetailField label="Engine" value={vehicle.engine} />
|
||||
<DetailField label="Transmission" value={vehicle.transmission} />
|
||||
</div>
|
||||
|
||||
<DetailField label="Nickname" value={vehicle.nickname} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<DetailField label="Color" value={vehicle.color} />
|
||||
<DetailField label="License Plate" value={vehicle.licensePlate} />
|
||||
</div>
|
||||
|
||||
<DetailField
|
||||
label="Current Odometer Reading"
|
||||
value={vehicle.odometerReading ? `${vehicle.odometerReading.toLocaleString()} mi` : undefined}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<Divider sx={{ my: 4 }} />
|
||||
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2 }}>
|
||||
Vehicle Information
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 4, color: 'text.secondary', fontSize: '0.875rem' }}>
|
||||
<span>Added: {new Date(vehicle.createdAt).toLocaleDateString()}</span>
|
||||
{vehicle.updatedAt !== vehicle.createdAt && (
|
||||
<span>Last updated: {new Date(vehicle.updatedAt).toLocaleDateString()}</span>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -51,7 +51,8 @@ export const VehiclesPage: React.FC = () => {
|
||||
const handleSelectVehicle = (id: string) => {
|
||||
// Use transition for navigation to avoid blocking UI
|
||||
startTransition(() => {
|
||||
setSelectedVehicle(id);
|
||||
const vehicle = optimisticVehicles.find(v => v.id === id);
|
||||
setSelectedVehicle(vehicle || null);
|
||||
navigate(`/vehicles/${id}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface Vehicle {
|
||||
|
||||
export interface CreateVehicleRequest {
|
||||
vin: string;
|
||||
year?: number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
engine?: string;
|
||||
@@ -55,4 +56,17 @@ export interface UpdateVehicleRequest {
|
||||
export interface DropdownOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VINDecodeResponse {
|
||||
vin: string;
|
||||
success: boolean;
|
||||
year?: number;
|
||||
make?: string;
|
||||
model?: string;
|
||||
trimLevel?: string;
|
||||
engine?: string;
|
||||
transmission?: string;
|
||||
confidence?: number;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user