UX Improvements
This commit is contained in:
@@ -53,7 +53,8 @@
|
|||||||
"Bash(for feature in vehicles fuel-logs maintenance stations)",
|
"Bash(for feature in vehicles fuel-logs maintenance stations)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(cp:*)",
|
"Bash(cp:*)",
|
||||||
"Bash(openssl:*)"
|
"Bash(openssl:*)",
|
||||||
|
"Bash(npm run type-check:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class EfficiencyCalculationService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = UnitConversionService.calculateEfficiency(distance, currentLog.fuelUnits, unitSystem);
|
const value = UnitConversionService.calculateEfficiency(distance, currentLog.fuelUnits);
|
||||||
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
const labels = UnitConversionService.getUnitLabels(unitSystem);
|
||||||
return { value, unitSystem, label: labels.efficiencyUnits, calculationMethod: method };
|
return { value, unitSystem, label: labels.efficiencyUnits, calculationMethod: method };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,17 @@ export class FuelLogsService {
|
|||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
const rows = await this.repository.findByVehicleIdEnhanced(vehicleId);
|
||||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
const response = rows.map(r => {
|
||||||
|
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||||
|
{
|
||||||
|
tripDistance: r.trip_distance ?? undefined,
|
||||||
|
fuelUnits: r.fuel_units ?? undefined
|
||||||
|
},
|
||||||
|
null, // No previous odometer needed for trip distance calculation
|
||||||
|
unitSystem
|
||||||
|
);
|
||||||
|
return this.toEnhancedResponse(r, efficiency?.value ?? undefined, unitSystem);
|
||||||
|
});
|
||||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -96,7 +106,17 @@ export class FuelLogsService {
|
|||||||
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
const cached = await cacheService.get<EnhancedFuelLogResponse[]>(cacheKey);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const rows = await this.repository.findByUserIdEnhanced(userId);
|
const rows = await this.repository.findByUserIdEnhanced(userId);
|
||||||
const response = rows.map(r => this.toEnhancedResponse(r, undefined, unitSystem));
|
const response = rows.map(r => {
|
||||||
|
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||||
|
{
|
||||||
|
tripDistance: r.trip_distance ?? undefined,
|
||||||
|
fuelUnits: r.fuel_units ?? undefined
|
||||||
|
},
|
||||||
|
null, // No previous odometer needed for trip distance calculation
|
||||||
|
unitSystem
|
||||||
|
);
|
||||||
|
return this.toEnhancedResponse(r, efficiency?.value ?? undefined, unitSystem);
|
||||||
|
});
|
||||||
await cacheService.set(cacheKey, response, this.cacheTTL);
|
await cacheService.set(cacheKey, response, this.cacheTTL);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -106,7 +126,17 @@ export class FuelLogsService {
|
|||||||
if (!row) throw new Error('Fuel log not found');
|
if (!row) throw new Error('Fuel log not found');
|
||||||
if (row.user_id !== userId) throw new Error('Unauthorized');
|
if (row.user_id !== userId) throw new Error('Unauthorized');
|
||||||
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
const { unitSystem } = await UserSettingsService.getUserSettings(userId);
|
||||||
return this.toEnhancedResponse(row, undefined, unitSystem);
|
|
||||||
|
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||||
|
{
|
||||||
|
tripDistance: row.trip_distance ?? undefined,
|
||||||
|
fuelUnits: row.fuel_units ?? undefined
|
||||||
|
},
|
||||||
|
null, // No previous odometer needed for trip distance calculation
|
||||||
|
unitSystem
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.toEnhancedResponse(row, efficiency?.value ?? undefined, unitSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFuelLog(id: string, data: EnhancedUpdateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
|
async updateFuelLog(id: string, data: EnhancedUpdateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||||
|
|||||||
@@ -3,28 +3,29 @@ import { UnitSystem as CoreUnitSystem } from '../../../shared-minimal/utils/unit
|
|||||||
export type UnitSystem = CoreUnitSystem;
|
export type UnitSystem = CoreUnitSystem;
|
||||||
|
|
||||||
export class UnitConversionService {
|
export class UnitConversionService {
|
||||||
private static readonly MPG_TO_L100KM = 235.214;
|
private static readonly KM_PER_MILE = 1.60934;
|
||||||
|
private static readonly GALLONS_TO_LITERS = 3.78541;
|
||||||
|
|
||||||
static getUnitLabels(unitSystem: UnitSystem) {
|
static getUnitLabels(unitSystem: UnitSystem) {
|
||||||
return unitSystem === 'metric'
|
return unitSystem === 'metric'
|
||||||
? { fuelUnits: 'liters', distanceUnits: 'kilometers', efficiencyUnits: 'L/100km' }
|
? { fuelUnits: 'liters', distanceUnits: 'kilometers', efficiencyUnits: 'km/L' }
|
||||||
: { fuelUnits: 'gallons', distanceUnits: 'miles', efficiencyUnits: 'mpg' };
|
: { fuelUnits: 'gallons', distanceUnits: 'miles', efficiencyUnits: 'MPG' };
|
||||||
}
|
}
|
||||||
|
|
||||||
static calculateEfficiency(distance: number, fuelUnits: number, unitSystem: UnitSystem): number {
|
static calculateEfficiency(distance: number, fuelUnits: number): number {
|
||||||
if (fuelUnits <= 0 || distance <= 0) return 0;
|
if (fuelUnits <= 0 || distance <= 0) return 0;
|
||||||
return unitSystem === 'metric'
|
return distance / fuelUnits; // Both km/L and MPG use distance/fuel formula
|
||||||
? (fuelUnits / distance) * 100
|
|
||||||
: distance / fuelUnits;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static convertEfficiency(efficiency: number, from: UnitSystem, to: UnitSystem): number {
|
static convertEfficiency(efficiency: number, from: UnitSystem, to: UnitSystem): number {
|
||||||
if (from === to) return efficiency;
|
if (from === to) return efficiency;
|
||||||
if (from === 'imperial' && to === 'metric') {
|
if (from === 'imperial' && to === 'metric') {
|
||||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
// MPG to km/L: MPG * (gallons_to_liters / km_per_mile)
|
||||||
|
return efficiency * (this.GALLONS_TO_LITERS / this.KM_PER_MILE);
|
||||||
}
|
}
|
||||||
if (from === 'metric' && to === 'imperial') {
|
if (from === 'metric' && to === 'imperial') {
|
||||||
return efficiency > 0 ? this.MPG_TO_L100KM / efficiency : 0;
|
// km/L to MPG: km/L * (km_per_mile / gallons_to_liters)
|
||||||
|
return efficiency * (this.KM_PER_MILE / this.GALLONS_TO_LITERS);
|
||||||
}
|
}
|
||||||
return efficiency;
|
return efficiency;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,11 +22,12 @@
|
|||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
"@hookform/resolvers": "^3.3.2",
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"date-fns": "^3.0.0",
|
"date-fns": "^2.30.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"framer-motion": "^11.0.0",
|
"framer-motion": "^11.0.0",
|
||||||
"@mui/material": "^5.15.0",
|
"@mui/material": "^5.15.0",
|
||||||
|
"@mui/x-date-pickers": "^6.19.0",
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@emotion/cache": "^11.11.0",
|
"@emotion/cache": "^11.11.0",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
export type UnitSystem = 'imperial' | 'metric';
|
export type UnitSystem = 'imperial' | 'metric';
|
||||||
export type DistanceUnit = 'miles' | 'km';
|
export type DistanceUnit = 'miles' | 'km';
|
||||||
export type VolumeUnit = 'gallons' | 'liters';
|
export type VolumeUnit = 'gallons' | 'liters';
|
||||||
export type FuelEfficiencyUnit = 'mpg' | 'l100km';
|
export type FuelEfficiencyUnit = 'mpg' | 'kml';
|
||||||
|
|
||||||
export interface UnitPreferences {
|
export interface UnitPreferences {
|
||||||
system: UnitSystem;
|
system: UnitSystem;
|
||||||
@@ -21,4 +21,4 @@ export interface UserPreferences {
|
|||||||
unitSystem: UnitSystem;
|
unitSystem: UnitSystem;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const MILES_TO_KM = 1.60934;
|
|||||||
const KM_TO_MILES = 0.621371;
|
const KM_TO_MILES = 0.621371;
|
||||||
const GALLONS_TO_LITERS = 3.78541;
|
const GALLONS_TO_LITERS = 3.78541;
|
||||||
const LITERS_TO_GALLONS = 0.264172;
|
const LITERS_TO_GALLONS = 0.264172;
|
||||||
const MPG_TO_L100KM_FACTOR = 235.214;
|
// For km/L conversion
|
||||||
|
const MPG_TO_KML = 1.60934 / 3.78541; // ≈ 0.425144
|
||||||
|
const KML_TO_MPG = 3.78541 / 1.60934; // ≈ 2.352145
|
||||||
|
|
||||||
// Distance Conversions
|
// Distance Conversions
|
||||||
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
|
export function convertDistance(value: number, fromUnit: DistanceUnit, toUnit: DistanceUnit): number {
|
||||||
@@ -60,12 +62,12 @@ export function convertVolumeBySystem(gallons: number, toSystem: UnitSystem): nu
|
|||||||
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
|
export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUnit, toUnit: FuelEfficiencyUnit): number {
|
||||||
if (fromUnit === toUnit) return value;
|
if (fromUnit === toUnit) return value;
|
||||||
|
|
||||||
if (fromUnit === 'mpg' && toUnit === 'l100km') {
|
if (fromUnit === 'mpg' && toUnit === 'kml') {
|
||||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
return value * MPG_TO_KML;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fromUnit === 'l100km' && toUnit === 'mpg') {
|
if (fromUnit === 'kml' && toUnit === 'mpg') {
|
||||||
return value === 0 ? 0 : MPG_TO_L100KM_FACTOR / value;
|
return value * KML_TO_MPG;
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
@@ -73,7 +75,7 @@ export function convertFuelEfficiency(value: number, fromUnit: FuelEfficiencyUni
|
|||||||
|
|
||||||
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
|
export function convertFuelEfficiencyBySystem(mpg: number, toSystem: UnitSystem): number {
|
||||||
if (toSystem === 'metric') {
|
if (toSystem === 'metric') {
|
||||||
return convertFuelEfficiency(mpg, 'mpg', 'l100km');
|
return convertFuelEfficiency(mpg, 'mpg', 'kml');
|
||||||
}
|
}
|
||||||
return mpg;
|
return mpg;
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,7 @@ export function formatVolume(value: number, unit: VolumeUnit, precision = 2): st
|
|||||||
|
|
||||||
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
|
export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, precision = 1): string {
|
||||||
if (typeof value !== 'number' || isNaN(value)) {
|
if (typeof value !== 'number' || isNaN(value)) {
|
||||||
return unit === 'mpg' ? '0 MPG' : '0 L/100km';
|
return unit === 'mpg' ? '0 MPG' : '0 km/L';
|
||||||
}
|
}
|
||||||
|
|
||||||
const rounded = parseFloat(value.toFixed(precision));
|
const rounded = parseFloat(value.toFixed(precision));
|
||||||
@@ -117,7 +119,7 @@ export function formatFuelEfficiency(value: number, unit: FuelEfficiencyUnit, pr
|
|||||||
if (unit === 'mpg') {
|
if (unit === 'mpg') {
|
||||||
return `${rounded} MPG`;
|
return `${rounded} MPG`;
|
||||||
} else {
|
} else {
|
||||||
return `${rounded} L/100km`;
|
return `${rounded} km/L`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,8 +168,8 @@ export function formatVolumeBySystem(gallons: number, system: UnitSystem, precis
|
|||||||
|
|
||||||
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
|
export function formatFuelEfficiencyBySystem(mpg: number, system: UnitSystem, precision = 1): string {
|
||||||
if (system === 'metric') {
|
if (system === 'metric') {
|
||||||
const l100km = convertFuelEfficiencyBySystem(mpg, system);
|
const kml = convertFuelEfficiencyBySystem(mpg, system);
|
||||||
return formatFuelEfficiency(l100km, 'l100km', precision);
|
return formatFuelEfficiency(kml, 'kml', precision);
|
||||||
}
|
}
|
||||||
return formatFuelEfficiency(mpg, 'mpg', precision);
|
return formatFuelEfficiency(mpg, 'mpg', precision);
|
||||||
}
|
}
|
||||||
@@ -190,5 +192,5 @@ export function getVolumeUnit(system: UnitSystem): VolumeUnit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
|
export function getFuelEfficiencyUnit(system: UnitSystem): FuelEfficiencyUnit {
|
||||||
return system === 'metric' ? 'l100km' : 'mpg';
|
return system === 'metric' ? 'kml' : 'mpg';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
useMediaQuery
|
useMediaQuery
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||||
import { FuelLogResponse, UpdateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
import { FuelLogResponse, UpdateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||||
import { useFuelGrades } from '../hooks/useFuelGrades';
|
import { useFuelGrades } from '../hooks/useFuelGrades';
|
||||||
|
|
||||||
@@ -130,41 +133,40 @@ export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format datetime for input (datetime-local expects YYYY-MM-DDTHH:mm format)
|
|
||||||
const formatDateTimeForInput = (isoString: string) => {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
open={open}
|
<Dialog
|
||||||
onClose={handleCancel}
|
open={open}
|
||||||
maxWidth="sm"
|
onClose={handleCancel}
|
||||||
fullWidth
|
maxWidth="sm"
|
||||||
fullScreen={isSmallScreen}
|
fullWidth
|
||||||
PaperProps={{
|
fullScreen={isSmallScreen}
|
||||||
sx: { maxHeight: '90vh' }
|
PaperProps={{
|
||||||
}}
|
sx: { maxHeight: '90vh' }
|
||||||
>
|
}}
|
||||||
<DialogTitle>Edit Fuel Log</DialogTitle>
|
>
|
||||||
<DialogContent>
|
<DialogTitle>Edit Fuel Log</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
<Box sx={{ mt: 1 }}>
|
<Box sx={{ mt: 1 }}>
|
||||||
<Grid container spacing={2}>
|
<Grid container spacing={2}>
|
||||||
{/* Date and Time */}
|
{/* Date and Time */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<TextField
|
<DateTimePicker
|
||||||
label="Date & Time"
|
label="Date & Time"
|
||||||
type="datetime-local"
|
value={formData.dateTime ? new Date(formData.dateTime) : null}
|
||||||
fullWidth
|
onChange={(newValue) => handleInputChange('dateTime', newValue?.toISOString() || '')}
|
||||||
value={formData.dateTime ? formatDateTimeForInput(formData.dateTime) : ''}
|
format="MM/dd/yyyy hh:mm a"
|
||||||
onChange={(e) => handleInputChange('dateTime', new Date(e.target.value).toISOString())}
|
slotProps={{
|
||||||
InputLabelProps={{ shrink: true }}
|
textField: {
|
||||||
|
fullWidth: true,
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
minHeight: '56px', // Ensure consistent height for mobile touch targets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -284,5 +286,6 @@ export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React, { useEffect, useMemo, useState, useRef, memo } from 'react';
|
|||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Grid, Card, CardHeader, CardContent, TextField, Switch, FormControlLabel, Box, Button, CircularProgress } from '@mui/material';
|
import { Grid, Card, CardHeader, CardContent, TextField, Box, Button, CircularProgress, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
|
||||||
import { VehicleSelector } from './VehicleSelector';
|
import { VehicleSelector } from './VehicleSelector';
|
||||||
import { DistanceInput } from './DistanceInput';
|
import { DistanceInput } from './DistanceInput';
|
||||||
import { FuelTypeSelector } from './FuelTypeSelector';
|
import { FuelTypeSelector } from './FuelTypeSelector';
|
||||||
@@ -94,6 +97,19 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
return units > 0 && cost > 0 ? units * cost : 0;
|
return units > 0 && cost > 0 ? units * cost : 0;
|
||||||
}, [fuelUnits, costPerUnit]);
|
}, [fuelUnits, costPerUnit]);
|
||||||
|
|
||||||
|
// Watch for distance and fuel units to calculate efficiency
|
||||||
|
const watchedDistance = watch(useOdometer ? 'odometerReading' : 'tripDistance');
|
||||||
|
const distanceValue = typeof watchedDistance === 'string' ? parseFloat(watchedDistance) : watchedDistance;
|
||||||
|
|
||||||
|
const calculatedEfficiency = useMemo(() => {
|
||||||
|
const distance = distanceValue && !isNaN(distanceValue) ? distanceValue : 0;
|
||||||
|
const units = fuelUnits && !isNaN(fuelUnits) ? fuelUnits : 0;
|
||||||
|
if (distance > 0 && units > 0) {
|
||||||
|
return distance / units;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [distanceValue, fuelUnits]);
|
||||||
|
|
||||||
const onSubmit = async (data: CreateFuelLogRequest) => {
|
const onSubmit = async (data: CreateFuelLogRequest) => {
|
||||||
const payload: CreateFuelLogRequest = {
|
const payload: CreateFuelLogRequest = {
|
||||||
...data,
|
...data,
|
||||||
@@ -123,29 +139,107 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
}, [useOdometer, setValue]);
|
}, [useOdometer, setValue]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
<Card>
|
||||||
<CardContent>
|
<CardHeader title="Add Fuel Log" subheader={<UnitSystemDisplay unitSystem={userSettings?.unitSystem} showLabel="Displaying in" />} />
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<CardContent>
|
||||||
<Grid container spacing={2}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{/* Row 1: Select Vehicle */}
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Controller name="vehicleId" control={control} render={({ field }) => (
|
<Controller name="vehicleId" control={control} render={({ field }) => (
|
||||||
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
|
<VehicleSelector value={field.value} onChange={field.onChange} error={errors.vehicleId?.message} required />
|
||||||
)} />
|
)} />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Row 2: Date/Time | MPG/km/L */}
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<Controller name="dateTime" control={control} render={({ field }) => (
|
<Controller name="dateTime" control={control} render={({ field }) => (
|
||||||
<TextField {...field} label="Date & Time" type="datetime-local" fullWidth error={!!errors.dateTime} helperText={errors.dateTime?.message} InputLabelProps={{ shrink: true }} />
|
<DateTimePicker
|
||||||
|
label="Date & Time"
|
||||||
|
value={field.value ? new Date(field.value) : null}
|
||||||
|
onChange={(newValue) => field.onChange(newValue?.toISOString() || '')}
|
||||||
|
format="MM/dd/yyyy hh:mm a"
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
fullWidth: true,
|
||||||
|
error: !!errors.dateTime,
|
||||||
|
helperText: errors.dateTime?.message,
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
minHeight: '56px',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)} />
|
)} />
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<FormControlLabel control={<Switch checked={useOdometer} onChange={(e) => setUseOdometer(e.target.checked)} />} label={`Use ${useOdometer ? 'Odometer' : 'Trip Distance'}`} />
|
<TextField
|
||||||
|
label={`${userSettings?.unitSystem === 'metric' ? 'km/L' : 'MPG'}`}
|
||||||
|
value={calculatedEfficiency > 0 ? calculatedEfficiency.toFixed(3) : ''}
|
||||||
|
fullWidth
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
sx: {
|
||||||
|
backgroundColor: 'grey.50',
|
||||||
|
'& .MuiOutlinedInput-input': {
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
helperText="Calculated from distance ÷ fuel amount"
|
||||||
|
sx={{
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
minHeight: '56px',
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Row 3: Odometer | Distance Input Method */}
|
||||||
<Grid item xs={12} sm={6}>
|
<Grid item xs={12} sm={6}>
|
||||||
<Controller name={useOdometer ? 'odometerReading' : 'tripDistance'} control={control} render={({ field }) => (
|
<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)} />
|
<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>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={useOdometer ? 'odometer' : 'trip'}
|
||||||
|
exclusive
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (newValue !== null) {
|
||||||
|
setUseOdometer(newValue === 'odometer');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label="distance input method"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
height: '56px', // Match input field height
|
||||||
|
'& .MuiToggleButton-root': {
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: '8px',
|
||||||
|
height: '56px', // Ensure button height matches
|
||||||
|
'&.Mui-selected': {
|
||||||
|
backgroundColor: 'primary.main',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'primary.dark',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="trip" aria-label="trip distance">
|
||||||
|
Trip Distance
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="odometer" aria-label="odometer reading">
|
||||||
|
Odometer Reading
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
|
<Controller name="fuelType" control={control} render={({ field: fuelTypeField }) => (
|
||||||
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
|
<Controller name="fuelGrade" control={control} render={({ field: fuelGradeField }) => (
|
||||||
@@ -205,6 +299,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</LocalizationProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { Edit, Delete } from '@mui/icons-material';
|
import { Edit, Delete } from '@mui/icons-material';
|
||||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||||
|
import { useUnits } from '../../../core/units/UnitsContext';
|
||||||
|
|
||||||
interface FuelLogsListProps {
|
interface FuelLogsListProps {
|
||||||
logs?: FuelLogResponse[];
|
logs?: FuelLogResponse[];
|
||||||
@@ -30,10 +31,26 @@ interface FuelLogsListProps {
|
|||||||
export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDelete }) => {
|
export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDelete }) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const { unitSystem, convertDistance, convertVolume } = useUnits();
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [logToDelete, setLogToDelete] = useState<FuelLogResponse | null>(null);
|
const [logToDelete, setLogToDelete] = useState<FuelLogResponse | null>(null);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Precompute previous odometer per log for delta calculations
|
||||||
|
const prevOdoById = useMemo(() => {
|
||||||
|
const map = new Map<string, number | undefined>();
|
||||||
|
if (!Array.isArray(logs)) return map;
|
||||||
|
const asc = [...logs].sort((a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime());
|
||||||
|
let lastOdo: number | undefined = undefined;
|
||||||
|
for (const l of asc) {
|
||||||
|
map.set(l.id, lastOdo);
|
||||||
|
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
||||||
|
lastOdo = l.odometerReading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
const handleDeleteClick = (log: FuelLogResponse) => {
|
const handleDeleteClick = (log: FuelLogResponse) => {
|
||||||
setLogToDelete(log);
|
setLogToDelete(log);
|
||||||
setDeleteDialogOpen(true);
|
setDeleteDialogOpen(true);
|
||||||
@@ -110,6 +127,31 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
|||||||
? `Trip: ${log.tripDistance}`
|
? `Trip: ${log.tripDistance}`
|
||||||
: 'No distance';
|
: 'No distance';
|
||||||
|
|
||||||
|
// Compute local efficiency if not provided
|
||||||
|
let localEffLabel: string | null = null;
|
||||||
|
try {
|
||||||
|
const gallons = typeof log.fuelUnits === 'number' ? log.fuelUnits : undefined;
|
||||||
|
let miles: number | undefined =
|
||||||
|
typeof log.tripDistance === 'number' && !isNaN(log.tripDistance)
|
||||||
|
? log.tripDistance
|
||||||
|
: undefined;
|
||||||
|
if (miles === undefined && typeof log.odometerReading === 'number') {
|
||||||
|
const prev = prevOdoById.get(log.id);
|
||||||
|
if (typeof prev === 'number' && log.odometerReading > prev) {
|
||||||
|
miles = log.odometerReading - prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof miles === 'number' && typeof gallons === 'number' && gallons > 0) {
|
||||||
|
if (unitSystem === 'metric') {
|
||||||
|
const km = convertDistance(miles);
|
||||||
|
const liters = convertVolume(gallons);
|
||||||
|
if (liters > 0) localEffLabel = `${(km / liters).toFixed(1)} km/L`;
|
||||||
|
} else {
|
||||||
|
localEffLabel = `${(miles / gallons).toFixed(1)} MPG`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={log.id}
|
key={log.id}
|
||||||
@@ -133,18 +175,19 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
|||||||
secondary={`${fuelUnits} @ $${costPerUnit} • ${distanceText}`}
|
secondary={`${fuelUnits} @ $${costPerUnit} • ${distanceText}`}
|
||||||
sx={{ flex: 1, minWidth: 0 }}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
/>
|
/>
|
||||||
{log.efficiency &&
|
{(log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && log.efficiencyLabel) || localEffLabel ? (
|
||||||
typeof log.efficiency === 'number' &&
|
|
||||||
!isNaN(log.efficiency) &&
|
|
||||||
log.efficiencyLabel && (
|
|
||||||
<Box sx={{ mr: isMobile ? 0 : 1 }}>
|
<Box sx={{ mr: isMobile ? 0 : 1 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`}
|
label={
|
||||||
|
log.efficiency && log.efficiencyLabel
|
||||||
|
? `${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`
|
||||||
|
: localEffLabel || ''
|
||||||
|
}
|
||||||
size="small"
|
size="small"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
@@ -238,4 +281,3 @@ export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDele
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import { UnitSystem } from '../types/fuel-logs.types';
|
|||||||
|
|
||||||
export const UnitSystemDisplay: React.FC<{ unitSystem?: UnitSystem; showLabel?: string }> = ({ unitSystem, showLabel }) => {
|
export const UnitSystemDisplay: React.FC<{ unitSystem?: UnitSystem; showLabel?: string }> = ({ unitSystem, showLabel }) => {
|
||||||
if (!unitSystem) return null;
|
if (!unitSystem) return null;
|
||||||
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, L/100km)';
|
const label = unitSystem === 'imperial' ? 'Imperial (miles, gallons, MPG)' : 'Metric (km, liters, km/L)';
|
||||||
return (
|
return (
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{showLabel ? `${showLabel} ` : ''}{label}
|
{showLabel ? `${showLabel} ` : ''}{label}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem, ListItemText } from '@mui/material';
|
import { Box, Typography, Button, Card, CardContent, Divider, FormControl, InputLabel, Select, MenuItem, List, ListItem } from '@mui/material';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Vehicle } from '../types/vehicles.types';
|
import { Vehicle } from '../types/vehicles.types';
|
||||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||||
@@ -57,6 +57,7 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
|
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(vehicle.id);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||||
|
// Unit conversions are now handled by the backend
|
||||||
|
|
||||||
type VehicleRecord = {
|
type VehicleRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,19 +65,71 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
date: string; // ISO
|
date: string; // ISO
|
||||||
summary: string;
|
summary: string;
|
||||||
amount?: string;
|
amount?: string;
|
||||||
|
secondary?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const records: VehicleRecord[] = useMemo(() => {
|
const records: VehicleRecord[] = useMemo(() => {
|
||||||
const list: VehicleRecord[] = [];
|
const list: VehicleRecord[] = [];
|
||||||
if (fuelLogs && Array.isArray(fuelLogs)) {
|
if (fuelLogs && Array.isArray(fuelLogs)) {
|
||||||
|
// Build a map of prior odometer readings to compute trip distance when missing
|
||||||
|
const logsAsc = [...(fuelLogs as FuelLogResponse[])].sort(
|
||||||
|
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||||
|
);
|
||||||
|
const prevOdoById = new Map<string, number | undefined>();
|
||||||
|
let lastOdo: number | undefined = undefined;
|
||||||
|
for (const l of logsAsc) {
|
||||||
|
prevOdoById.set(l.id, lastOdo);
|
||||||
|
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
||||||
|
lastOdo = l.odometerReading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const log of fuelLogs as FuelLogResponse[]) {
|
for (const log of fuelLogs as FuelLogResponse[]) {
|
||||||
const parts: string[] = [];
|
// Use efficiency from API response (backend calculates this properly)
|
||||||
if (log.fuelUnits) parts.push(`${Number(log.fuelUnits).toFixed(3)} units`);
|
let efficiency = '';
|
||||||
if (log.fuelType) parts.push(`${log.fuelType}${log.fuelGrade ? ' ' + log.fuelGrade : ''}`);
|
if (typeof log.efficiency === 'number' && log.efficiency > 0) {
|
||||||
if (log.efficiencyLabel) parts.push(log.efficiencyLabel);
|
// DEBUG: Log what the backend is actually returning
|
||||||
const summary = parts.join(' • ');
|
console.log('🔍 Fuel Log API Response:', {
|
||||||
|
efficiency: log.efficiency,
|
||||||
|
efficiencyLabel: log.efficiencyLabel,
|
||||||
|
logId: log.id
|
||||||
|
});
|
||||||
|
// Backend returns efficiency in correct units based on user preference
|
||||||
|
efficiency = `${log.efficiencyLabel || 'MPG'}: ${log.efficiency.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary line components
|
||||||
|
const secondaryParts: string[] = [];
|
||||||
|
|
||||||
|
// Grade label (prefer grade only)
|
||||||
|
if (log.fuelGrade) {
|
||||||
|
secondaryParts.push(`Grade: ${log.fuelGrade}`);
|
||||||
|
} else if (log.fuelType) {
|
||||||
|
const ft = String(log.fuelType);
|
||||||
|
secondaryParts.push(ft.charAt(0).toUpperCase() + ft.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date
|
||||||
|
secondaryParts.push(new Date(log.dateTime).toLocaleDateString());
|
||||||
|
|
||||||
|
// Type
|
||||||
|
secondaryParts.push('Fuel Log');
|
||||||
|
|
||||||
|
const summary = efficiency; // Primary line shows MPG/km/L from backend
|
||||||
|
const secondary = secondaryParts.join(' • '); // Secondary line shows Grade • Date • Type
|
||||||
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
||||||
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
|
||||||
|
// DEBUG: Log the final display values
|
||||||
|
console.log('🎯 Display Summary:', { summary, efficiency, logId: log.id });
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
id: log.id,
|
||||||
|
type: 'Fuel Logs',
|
||||||
|
date: log.dateTime,
|
||||||
|
summary,
|
||||||
|
amount,
|
||||||
|
secondary
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
return list.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
@@ -212,13 +265,21 @@ export const VehicleDetailMobile: React.FC<VehicleDetailMobileProps> = ({
|
|||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{filteredRecords.map(rec => (
|
{filteredRecords.map(rec => (
|
||||||
<ListItem key={rec.id} divider button onClick={() => openEditLog(rec.id, rec.type)}>
|
<ListItem key={rec.id} divider button onClick={() => openEditLog(rec.id, rec.type)}>
|
||||||
<ListItemText
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
primary={rec.summary || new Date(rec.date).toLocaleString()}
|
{/* Primary line: MPG/km-L and amount */}
|
||||||
secondary={`${new Date(rec.date).toLocaleString()} • ${rec.type}`}
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
/>
|
<Typography variant="body1" color="text.primary">
|
||||||
<Typography sx={{ ml: 2 }} color="text.primary">
|
{rec.summary || 'MPG: —'}
|
||||||
{rec.amount || '—'}
|
</Typography>
|
||||||
</Typography>
|
<Typography variant="body1" color="text.primary">
|
||||||
|
{rec.amount || '—'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{/* Secondary line: Grade • Date • Type */}
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.25 }}>
|
||||||
|
{rec.secondary || `${new Date(rec.date).toLocaleDateString()} • ${rec.type}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState, useEffect } from 'react';
|
import React, { useMemo, useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody } from '@mui/material';
|
import { Box, Typography, Button as MuiButton, Divider, FormControl, InputLabel, Select, MenuItem, Table, TableHead, TableRow, TableCell, TableBody, Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
@@ -17,6 +17,8 @@ import { VehicleForm } from '../components/VehicleForm';
|
|||||||
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
import { useFuelLogs } from '../../fuel-logs/hooks/useFuelLogs';
|
||||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
import { FuelLogResponse, UpdateFuelLogRequest } from '../../fuel-logs/types/fuel-logs.types';
|
||||||
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
import { FuelLogEditDialog } from '../../fuel-logs/components/FuelLogEditDialog';
|
||||||
|
import { FuelLogForm } from '../../fuel-logs/components/FuelLogForm';
|
||||||
|
// Unit conversions now handled by backend
|
||||||
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
import { fuelLogsApi } from '../../fuel-logs/api/fuel-logs.api';
|
||||||
|
|
||||||
const DetailField: React.FC<{
|
const DetailField: React.FC<{
|
||||||
@@ -49,6 +51,9 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
const { fuelLogs, isLoading: isFuelLoading } = useFuelLogs(id);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width:600px)');
|
||||||
|
// Unit conversions now handled by backend
|
||||||
|
|
||||||
// Define records list hooks BEFORE any early returns to keep hooks order stable
|
// Define records list hooks BEFORE any early returns to keep hooks order stable
|
||||||
type VehicleRecord = {
|
type VehicleRecord = {
|
||||||
@@ -62,11 +67,35 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
const records: VehicleRecord[] = useMemo(() => {
|
const records: VehicleRecord[] = useMemo(() => {
|
||||||
const list: VehicleRecord[] = [];
|
const list: VehicleRecord[] = [];
|
||||||
if (fuelLogs && Array.isArray(fuelLogs)) {
|
if (fuelLogs && Array.isArray(fuelLogs)) {
|
||||||
|
// Build a map of prior odometer readings to compute trip distance when missing
|
||||||
|
const logsAsc = [...(fuelLogs as FuelLogResponse[])].sort(
|
||||||
|
(a, b) => new Date(a.dateTime).getTime() - new Date(b.dateTime).getTime()
|
||||||
|
);
|
||||||
|
const prevOdoById = new Map<string, number | undefined>();
|
||||||
|
let lastOdo: number | undefined = undefined;
|
||||||
|
for (const l of logsAsc) {
|
||||||
|
prevOdoById.set(l.id, lastOdo);
|
||||||
|
if (typeof l.odometerReading === 'number' && !isNaN(l.odometerReading)) {
|
||||||
|
lastOdo = l.odometerReading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const log of fuelLogs as FuelLogResponse[]) {
|
for (const log of fuelLogs as FuelLogResponse[]) {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (log.fuelUnits) parts.push(`${Number(log.fuelUnits).toFixed(3)} units`);
|
|
||||||
if (log.fuelType) parts.push(`${log.fuelType}${log.fuelGrade ? ' ' + log.fuelGrade : ''}`);
|
// Efficiency: Use backend calculation (primary display)
|
||||||
if (log.efficiencyLabel) parts.push(log.efficiencyLabel);
|
if (typeof log.efficiency === 'number' && log.efficiency > 0) {
|
||||||
|
parts.push(`${log.efficiencyLabel || 'MPG'}: ${log.efficiency.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grade label (secondary display)
|
||||||
|
if (log.fuelGrade) {
|
||||||
|
parts.push(`Grade: ${log.fuelGrade}`);
|
||||||
|
} else if (log.fuelType) {
|
||||||
|
const ft = String(log.fuelType);
|
||||||
|
parts.push(ft.charAt(0).toUpperCase() + ft.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
const summary = parts.join(' • ');
|
const summary = parts.join(' • ');
|
||||||
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
const amount = (typeof log.totalCost === 'number') ? `$${log.totalCost.toFixed(2)}` : undefined;
|
||||||
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
list.push({ id: log.id, type: 'Fuel Logs', date: log.dateTime, summary, amount });
|
||||||
@@ -240,10 +269,11 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 4 }}>
|
||||||
<MuiButton
|
<MuiButton
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<LocalGasStationIcon />}
|
startIcon={<LocalGasStationIcon />}
|
||||||
sx={{ borderRadius: '999px' }}
|
sx={{ borderRadius: '999px' }}
|
||||||
|
onClick={() => setShowAddDialog(true)}
|
||||||
>
|
>
|
||||||
Add Fuel Log
|
Add Fuel Log
|
||||||
</MuiButton>
|
</MuiButton>
|
||||||
@@ -343,7 +373,7 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
{!isFuelLoading && filteredRecords.map((rec) => (
|
{!isFuelLoading && filteredRecords.map((rec) => (
|
||||||
<TableRow key={rec.id} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
<TableRow key={rec.id} hover sx={{ cursor: 'pointer' }} onClick={() => handleRowClick(rec.id, rec.type)}>
|
||||||
<TableCell>{new Date(rec.date).toLocaleString()}</TableCell>
|
<TableCell>{new Date(rec.date).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>{rec.type}</TableCell>
|
<TableCell>{rec.type}</TableCell>
|
||||||
<TableCell>{rec.summary}</TableCell>
|
<TableCell>{rec.summary}</TableCell>
|
||||||
<TableCell align="right">{rec.amount || '—'}</TableCell>
|
<TableCell align="right">{rec.amount || '—'}</TableCell>
|
||||||
@@ -360,6 +390,33 @@ export const VehicleDetailPage: React.FC = () => {
|
|||||||
onClose={handleCloseEdit}
|
onClose={handleCloseEdit}
|
||||||
onSave={handleSaveEdit}
|
onSave={handleSaveEdit}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Fuel Log Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={showAddDialog}
|
||||||
|
onClose={() => setShowAddDialog(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={isSmallScreen}
|
||||||
|
PaperProps={{
|
||||||
|
sx: { maxHeight: '90vh' }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle>Add Fuel Log</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<FuelLogForm
|
||||||
|
initial={{ vehicleId: vehicle?.id }}
|
||||||
|
onSuccess={() => {
|
||||||
|
setShowAddDialog(false);
|
||||||
|
// Refresh fuel logs data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fuelLogs', vehicle?.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Card>
|
</Card>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user