Merge branch 'main' of github.com:ericgullickson/motovaultpro
This commit is contained in:
@@ -8,7 +8,7 @@ import { FuelLogsService } from '../domain/fuel-logs.service';
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import { pool } from '../../../core/config/database';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { FuelLogParams, VehicleParams, EnhancedCreateFuelLogRequest } from '../domain/fuel-logs.types';
|
||||
import { FuelLogParams, VehicleParams, EnhancedCreateFuelLogRequest, EnhancedUpdateFuelLogRequest } from '../domain/fuel-logs.types';
|
||||
|
||||
export class FuelLogsController {
|
||||
private fuelLogsService: FuelLogsService;
|
||||
@@ -124,13 +124,17 @@ export class FuelLogsController {
|
||||
}
|
||||
}
|
||||
|
||||
async updateFuelLog(_request: FastifyRequest<{ Params: FuelLogParams; Body: any }>, reply: FastifyReply) {
|
||||
async updateFuelLog(request: FastifyRequest<{ Params: FuelLogParams; Body: EnhancedUpdateFuelLogRequest }>, reply: FastifyReply) {
|
||||
try {
|
||||
// Update not implemented in enhanced flow
|
||||
return reply.code(501).send({ error: 'Not Implemented', message: 'Update fuel log not implemented' });
|
||||
const userId = (request as any).user.sub;
|
||||
const { id } = request.params;
|
||||
|
||||
const updatedFuelLog = await this.fuelLogsService.updateFuelLog(id, request.body, userId);
|
||||
|
||||
return reply.code(200).send(updatedFuelLog);
|
||||
} catch (error: any) {
|
||||
logger.error('Error updating fuel log', { error });
|
||||
|
||||
logger.error('Error updating fuel log', { error, fuelLogId: request.params.id, userId: (request as any).user?.sub });
|
||||
|
||||
if (error.message.includes('not found')) {
|
||||
return reply.code(404).send({
|
||||
error: 'Not Found',
|
||||
@@ -143,7 +147,13 @@ export class FuelLogsController {
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('No fields provided')) {
|
||||
return reply.code(400).send({
|
||||
error: 'Bad Request',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(500).send({
|
||||
error: 'Internal server error',
|
||||
message: 'Failed to update fuel log'
|
||||
|
||||
@@ -286,4 +286,90 @@ export class FuelLogsRepository {
|
||||
);
|
||||
return res.rows[0] || null;
|
||||
}
|
||||
|
||||
async updateEnhanced(id: string, data: {
|
||||
dateTime?: Date;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType?: string;
|
||||
fuelGrade?: string | null;
|
||||
fuelUnits?: number;
|
||||
costPerUnit?: number;
|
||||
locationData?: any;
|
||||
notes?: string;
|
||||
}): Promise<any | null> {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
// Build dynamic update query for enhanced schema
|
||||
if (data.dateTime !== undefined) {
|
||||
fields.push(`date_time = $${paramCount++}`);
|
||||
fields.push(`date = $${paramCount++}`);
|
||||
values.push(data.dateTime);
|
||||
values.push(data.dateTime.toISOString().slice(0, 10));
|
||||
}
|
||||
if (data.odometerReading !== undefined) {
|
||||
fields.push(`odometer = $${paramCount++}`);
|
||||
values.push(data.odometerReading);
|
||||
}
|
||||
if (data.tripDistance !== undefined) {
|
||||
fields.push(`trip_distance = $${paramCount++}`);
|
||||
values.push(data.tripDistance);
|
||||
}
|
||||
if (data.fuelType !== undefined) {
|
||||
fields.push(`fuel_type = $${paramCount++}`);
|
||||
values.push(data.fuelType);
|
||||
}
|
||||
if (data.fuelGrade !== undefined) {
|
||||
fields.push(`fuel_grade = $${paramCount++}`);
|
||||
values.push(data.fuelGrade);
|
||||
}
|
||||
if (data.fuelUnits !== undefined) {
|
||||
fields.push(`fuel_units = $${paramCount++}`);
|
||||
fields.push(`gallons = $${paramCount++}`); // legacy support
|
||||
values.push(data.fuelUnits);
|
||||
values.push(data.fuelUnits);
|
||||
}
|
||||
if (data.costPerUnit !== undefined) {
|
||||
fields.push(`cost_per_unit = $${paramCount++}`);
|
||||
fields.push(`price_per_gallon = $${paramCount++}`); // legacy support
|
||||
values.push(data.costPerUnit);
|
||||
values.push(data.costPerUnit);
|
||||
}
|
||||
if (data.locationData !== undefined) {
|
||||
fields.push(`location_data = $${paramCount++}`);
|
||||
values.push(data.locationData);
|
||||
}
|
||||
if (data.notes !== undefined) {
|
||||
fields.push(`notes = $${paramCount++}`);
|
||||
values.push(data.notes);
|
||||
}
|
||||
|
||||
// Recalculate total cost if both fuelUnits and costPerUnit are present
|
||||
if (data.fuelUnits !== undefined && data.costPerUnit !== undefined) {
|
||||
fields.push(`total_cost = $${paramCount++}`);
|
||||
values.push(data.fuelUnits * data.costPerUnit);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.findByIdEnhanced(id);
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const query = `
|
||||
UPDATE fuel_logs
|
||||
SET ${fields.join(', ')}, updated_at = NOW()
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await this.pool.query(query, values);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { FuelLogsRepository } from '../data/fuel-logs.repository';
|
||||
import { EnhancedCreateFuelLogRequest, EnhancedFuelLogResponse, FuelType } from './fuel-logs.types';
|
||||
import { EnhancedCreateFuelLogRequest, EnhancedUpdateFuelLogRequest, EnhancedFuelLogResponse, FuelType } from './fuel-logs.types';
|
||||
import { logger } from '../../../core/logging/logger';
|
||||
import { cacheService } from '../../../core/config/redis';
|
||||
import pool from '../../../core/config/database';
|
||||
@@ -109,7 +109,81 @@ export class FuelLogsService {
|
||||
return this.toEnhancedResponse(row, undefined, unitSystem);
|
||||
}
|
||||
|
||||
async updateFuelLog(): Promise<any> { throw new Error('Not Implemented'); }
|
||||
async updateFuelLog(id: string, data: EnhancedUpdateFuelLogRequest, userId: string): Promise<EnhancedFuelLogResponse> {
|
||||
logger.info('Updating enhanced fuel log', { id, userId });
|
||||
|
||||
// Verify the fuel log exists and belongs to the user
|
||||
const existing = await this.repository.findByIdEnhanced(id);
|
||||
if (!existing) throw new Error('Fuel log not found');
|
||||
if (existing.user_id !== userId) throw new Error('Unauthorized');
|
||||
|
||||
// Get user settings for unit conversion
|
||||
const userSettings = await UserSettingsService.getUserSettings(userId);
|
||||
|
||||
// Validate the update data
|
||||
if (Object.keys(data).length === 0) {
|
||||
throw new Error('No fields provided for update');
|
||||
}
|
||||
|
||||
// Prepare update data with proper type conversion
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.dateTime !== undefined) {
|
||||
updateData.dateTime = new Date(data.dateTime);
|
||||
}
|
||||
if (data.odometerReading !== undefined) {
|
||||
updateData.odometerReading = data.odometerReading;
|
||||
}
|
||||
if (data.tripDistance !== undefined) {
|
||||
updateData.tripDistance = data.tripDistance;
|
||||
}
|
||||
if (data.fuelType !== undefined) {
|
||||
updateData.fuelType = data.fuelType;
|
||||
}
|
||||
if (data.fuelGrade !== undefined) {
|
||||
updateData.fuelGrade = data.fuelGrade;
|
||||
}
|
||||
if (data.fuelUnits !== undefined) {
|
||||
updateData.fuelUnits = data.fuelUnits;
|
||||
}
|
||||
if (data.costPerUnit !== undefined) {
|
||||
updateData.costPerUnit = data.costPerUnit;
|
||||
}
|
||||
if (data.locationData !== undefined) {
|
||||
updateData.locationData = data.locationData;
|
||||
}
|
||||
if (data.notes !== undefined) {
|
||||
updateData.notes = data.notes;
|
||||
}
|
||||
|
||||
// Update the fuel log
|
||||
const updated = await this.repository.updateEnhanced(id, updateData);
|
||||
if (!updated) throw new Error('Failed to update fuel log');
|
||||
|
||||
// Update vehicle odometer if changed
|
||||
if (data.odometerReading !== undefined) {
|
||||
await pool.query(
|
||||
'UPDATE vehicles SET odometer_reading = $1 WHERE id = $2 AND user_id = $3 AND (odometer_reading IS NULL OR odometer_reading < $1)',
|
||||
[data.odometerReading, existing.vehicle_id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate caches
|
||||
await this.invalidateCaches(userId, existing.vehicle_id, userSettings.unitSystem);
|
||||
|
||||
// Calculate efficiency for response
|
||||
const efficiency = EfficiencyCalculationService.calculateEfficiency(
|
||||
{
|
||||
odometerReading: updated.odometer ?? undefined,
|
||||
tripDistance: updated.trip_distance ?? undefined,
|
||||
fuelUnits: updated.fuel_units ?? undefined
|
||||
},
|
||||
null, // Previous log efficiency calculation would require more complex logic for updates
|
||||
userSettings.unitSystem
|
||||
);
|
||||
|
||||
return this.toEnhancedResponse(updated, efficiency?.value ?? undefined, userSettings.unitSystem);
|
||||
}
|
||||
|
||||
async deleteFuelLog(id: string, userId: string): Promise<void> {
|
||||
const existing = await this.repository.findByIdEnhanced(id);
|
||||
|
||||
@@ -87,6 +87,18 @@ export interface EnhancedCreateFuelLogRequest {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EnhancedUpdateFuelLogRequest {
|
||||
dateTime?: string;
|
||||
odometerReading?: number;
|
||||
tripDistance?: number;
|
||||
fuelType?: FuelType;
|
||||
fuelGrade?: FuelGrade;
|
||||
fuelUnits?: number;
|
||||
costPerUnit?: number;
|
||||
locationData?: LocationData;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface EnhancedFuelLogResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useTransition, useCallback, lazy } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@@ -30,7 +31,10 @@ import { RouteSuspense } from './components/SuspenseWrappers';
|
||||
import { Vehicle } from './features/vehicles/types/vehicles.types';
|
||||
import { FuelLogForm } from './features/fuel-logs/components/FuelLogForm';
|
||||
import { FuelLogsList } from './features/fuel-logs/components/FuelLogsList';
|
||||
import { FuelLogEditDialog } from './features/fuel-logs/components/FuelLogEditDialog';
|
||||
import { useFuelLogs } from './features/fuel-logs/hooks/useFuelLogs';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest } from './features/fuel-logs/types/fuel-logs.types';
|
||||
import { fuelLogsApi } from './features/fuel-logs/api/fuel-logs.api';
|
||||
import { VehicleForm } from './features/vehicles/components/VehicleForm';
|
||||
import { useOptimisticVehicles } from './features/vehicles/hooks/useOptimisticVehicles';
|
||||
import { CreateVehicleRequest } from './features/vehicles/types/vehicles.types';
|
||||
@@ -161,7 +165,62 @@ function App() {
|
||||
);
|
||||
|
||||
const LogFuelScreen = () => {
|
||||
const { fuelLogs, isLoading, error } = useFuelLogs();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||
|
||||
// Safe hook usage with error boundary protection
|
||||
let fuelLogs, isLoading, error;
|
||||
|
||||
try {
|
||||
const hookResult = useFuelLogs();
|
||||
fuelLogs = hookResult.fuelLogs;
|
||||
isLoading = hookResult.isLoading;
|
||||
error = hookResult.error;
|
||||
} catch (hookError) {
|
||||
console.error('[LogFuelScreen] Hook error:', hookError);
|
||||
error = hookError;
|
||||
}
|
||||
|
||||
const handleEdit = (log: FuelLogResponse) => {
|
||||
// Defensive validation before setting editing log
|
||||
if (!log || !log.id) {
|
||||
console.error('[LogFuelScreen] Invalid log data for edit:', log);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setEditingLog(log);
|
||||
} catch (error) {
|
||||
console.error('[LogFuelScreen] Error setting editing log:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (_logId: string) => {
|
||||
try {
|
||||
// Invalidate queries to refresh the data
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh fuel logs after delete:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (id: string, data: UpdateFuelLogRequest) => {
|
||||
try {
|
||||
await fuelLogsApi.update(id, data);
|
||||
// Invalidate queries to refresh the data
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats'] });
|
||||
setEditingLog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update fuel log:', error);
|
||||
throw error; // Re-throw to let the dialog handle the error
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseEdit = () => {
|
||||
setEditingLog(null);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -181,20 +240,51 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// Add loading state for component initialization
|
||||
if (isLoading === undefined) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<GlassCard>
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
Initializing fuel logs...
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FuelLogForm />
|
||||
<GlassCard>
|
||||
<div className="py-2">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
Loading fuel logs...
|
||||
</div>
|
||||
) : (
|
||||
<FuelLogsList logs={fuelLogs || []} />
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
<MobileErrorBoundary screenName="FuelLogForm" key="fuel-form">
|
||||
<FuelLogForm />
|
||||
</MobileErrorBoundary>
|
||||
<MobileErrorBoundary screenName="FuelLogsSection" key="fuel-section">
|
||||
<GlassCard>
|
||||
<div className="py-2">
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
Loading fuel logs...
|
||||
</div>
|
||||
) : (
|
||||
<FuelLogsList
|
||||
logs={fuelLogs || []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</MobileErrorBoundary>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<MobileErrorBoundary screenName="FuelLogEditDialog" key="fuel-edit-dialog">
|
||||
<FuelLogEditDialog
|
||||
open={!!editingLog}
|
||||
log={editingLog}
|
||||
onClose={handleCloseEdit}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
</MobileErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,25 +3,70 @@
|
||||
* @ai-context Handles auth tokens and error responses
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
// Will be replaced by createQueuedAxios below
|
||||
|
||||
// Auth readiness flag to avoid noisy 401 toasts during mobile auth initialization
|
||||
let authReady = false;
|
||||
export const setAuthReady = (ready: boolean) => { authReady = ready; };
|
||||
export const isAuthReady = () => authReady;
|
||||
|
||||
// Request interceptor for auth token with mobile debugging
|
||||
// Create a wrapper around axios that queues requests until auth is ready
|
||||
const createQueuedAxios = () => {
|
||||
const queuedClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Store original methods
|
||||
const originalRequest = queuedClient.request.bind(queuedClient);
|
||||
const originalGet = queuedClient.get.bind(queuedClient);
|
||||
const originalPost = queuedClient.post.bind(queuedClient);
|
||||
const originalPut = queuedClient.put.bind(queuedClient);
|
||||
const originalDelete = queuedClient.delete.bind(queuedClient);
|
||||
const originalPatch = queuedClient.patch.bind(queuedClient);
|
||||
|
||||
// Create wrapper function for auth queue checking
|
||||
const wrapWithAuthQueue = (originalMethod: any, methodName: string) => {
|
||||
return async (...args: any[]) => {
|
||||
try {
|
||||
const { queueRequest, isAuthInitialized } = await import('../auth/auth-gate');
|
||||
|
||||
if (!isAuthInitialized()) {
|
||||
console.log(`[API Client] Queuing ${methodName} request until auth ready`);
|
||||
return queueRequest(() => originalMethod(...args));
|
||||
}
|
||||
|
||||
return originalMethod(...args);
|
||||
} catch (error) {
|
||||
console.warn(`[API Client] Auth gate import failed for ${methodName}, proceeding with request:`, error);
|
||||
return originalMethod(...args);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Override all HTTP methods
|
||||
queuedClient.request = wrapWithAuthQueue(originalRequest, 'REQUEST');
|
||||
queuedClient.get = wrapWithAuthQueue(originalGet, 'GET');
|
||||
queuedClient.post = wrapWithAuthQueue(originalPost, 'POST');
|
||||
queuedClient.put = wrapWithAuthQueue(originalPut, 'PUT');
|
||||
queuedClient.delete = wrapWithAuthQueue(originalDelete, 'DELETE');
|
||||
queuedClient.patch = wrapWithAuthQueue(originalPatch, 'PATCH');
|
||||
|
||||
return queuedClient;
|
||||
};
|
||||
|
||||
// Replace the basic axios instance with the queued version
|
||||
export const apiClient = createQueuedAxios();
|
||||
|
||||
// Request interceptor for token injection and logging
|
||||
apiClient.interceptors.request.use(
|
||||
async (config: InternalAxiosRequestConfig) => {
|
||||
// Token will be added by Auth0 wrapper
|
||||
|
||||
@@ -6,6 +6,8 @@ import React from 'react';
|
||||
import { Auth0Provider as BaseAuth0Provider, useAuth0 } from '@auth0/auth0-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiClient, setAuthReady } from '../api/client';
|
||||
import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
|
||||
import { setAuthInitialized } from './auth-gate';
|
||||
|
||||
interface Auth0ProviderProps {
|
||||
children: React.ReactNode;
|
||||
@@ -38,8 +40,8 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
|
||||
scope: 'openid profile email offline_access',
|
||||
}}
|
||||
onRedirectCallback={onRedirectCallback}
|
||||
// Mobile Safari/ITP: use localstorage + refresh tokens to avoid third‑party cookie silent auth failures
|
||||
cacheLocation="localstorage"
|
||||
// Mobile-optimized: use IndexedDB for better mobile compatibility
|
||||
cache={createIndexedDBAdapter()}
|
||||
useRefreshTokens={true}
|
||||
useRefreshTokensFallback={true}
|
||||
>
|
||||
@@ -162,8 +164,17 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
let interceptorId: number | undefined;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Enhanced pre-warm token cache for mobile devices
|
||||
// Enhanced pre-warm token cache for mobile devices with IndexedDB wait
|
||||
const initializeToken = async () => {
|
||||
// Wait for IndexedDB to be ready first
|
||||
try {
|
||||
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
|
||||
await indexedDBStorage.waitForReady();
|
||||
console.log('[Auth] IndexedDB storage is ready');
|
||||
} catch (error) {
|
||||
console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error);
|
||||
}
|
||||
|
||||
// Give Auth0 more time to fully initialize on mobile devices
|
||||
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
const initDelay = isMobile ? 500 : 100; // Longer delay for mobile
|
||||
@@ -177,6 +188,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
console.log('[Mobile Auth] Token pre-warming successful');
|
||||
setRetryCount(0);
|
||||
setAuthReady(true);
|
||||
setAuthInitialized(true); // Signal that auth is fully ready
|
||||
} else {
|
||||
console.error('[Mobile Auth] Failed to acquire token after retries - will retry on API calls');
|
||||
setRetryCount(prev => prev + 1);
|
||||
@@ -189,9 +201,13 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
|
||||
initializeToken();
|
||||
|
||||
// Add token to all API requests with enhanced error handling
|
||||
// Add token to all API requests with enhanced error handling and IndexedDB wait
|
||||
interceptorId = apiClient.interceptors.request.use(async (config) => {
|
||||
try {
|
||||
// Ensure IndexedDB is ready before getting tokens
|
||||
const { indexedDBStorage } = await import('../utils/indexeddb-storage');
|
||||
await indexedDBStorage.waitForReady();
|
||||
|
||||
const token = await getTokenWithRetry();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
@@ -209,6 +225,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
} else {
|
||||
setRetryCount(0);
|
||||
setAuthReady(false);
|
||||
setAuthInitialized(false); // Reset auth gate when not authenticated
|
||||
}
|
||||
|
||||
// Cleanup function to remove interceptor
|
||||
|
||||
102
frontend/src/core/auth/auth-gate.ts
Normal file
102
frontend/src/core/auth/auth-gate.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @ai-summary Authentication gate to ensure API requests wait for auth initialization
|
||||
* @ai-context Prevents race conditions between IndexedDB init and API calls
|
||||
*/
|
||||
|
||||
// Global authentication readiness state
|
||||
let authInitialized = false;
|
||||
let authInitPromise: Promise<void> | null = null;
|
||||
let resolveAuthInit: (() => void) | null = null;
|
||||
|
||||
// Debug logging
|
||||
console.log('[Auth Gate] Module loaded, authInitialized:', authInitialized);
|
||||
|
||||
// Request queue to hold requests until auth is ready
|
||||
interface QueuedRequest {
|
||||
resolve: (value: any) => void;
|
||||
reject: (error: any) => void;
|
||||
requestFn: () => Promise<any>;
|
||||
}
|
||||
|
||||
let requestQueue: QueuedRequest[] = [];
|
||||
let isProcessingQueue = false;
|
||||
|
||||
export const waitForAuthInit = (): Promise<void> => {
|
||||
if (authInitialized) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (!authInitPromise) {
|
||||
authInitPromise = new Promise((resolve) => {
|
||||
resolveAuthInit = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
return authInitPromise;
|
||||
};
|
||||
|
||||
export const setAuthInitialized = (initialized: boolean) => {
|
||||
authInitialized = initialized;
|
||||
|
||||
if (initialized) {
|
||||
console.log('[Auth Gate] Authentication fully initialized');
|
||||
|
||||
// Resolve the auth promise
|
||||
if (resolveAuthInit) {
|
||||
resolveAuthInit();
|
||||
resolveAuthInit = null;
|
||||
}
|
||||
|
||||
// Process any queued requests
|
||||
processRequestQueue();
|
||||
} else {
|
||||
// Reset state when auth becomes unavailable
|
||||
authInitPromise = null;
|
||||
resolveAuthInit = null;
|
||||
requestQueue = [];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAuthInitialized = () => authInitialized;
|
||||
|
||||
// Queue a request until auth is ready
|
||||
export const queueRequest = <T>(requestFn: () => Promise<T>): Promise<T> => {
|
||||
if (authInitialized) {
|
||||
return requestFn();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
requestQueue.push({
|
||||
resolve,
|
||||
reject,
|
||||
requestFn
|
||||
});
|
||||
|
||||
console.log(`[Auth Gate] Queued request, ${requestQueue.length} total in queue`);
|
||||
});
|
||||
};
|
||||
|
||||
// Process all queued requests
|
||||
const processRequestQueue = async () => {
|
||||
if (isProcessingQueue || requestQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessingQueue = true;
|
||||
console.log(`[Auth Gate] Processing ${requestQueue.length} queued requests`);
|
||||
|
||||
const queueToProcess = [...requestQueue];
|
||||
requestQueue = [];
|
||||
|
||||
for (const { resolve, reject, requestFn } of queueToProcess) {
|
||||
try {
|
||||
const result = await requestFn();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
isProcessingQueue = false;
|
||||
console.log('[Auth Gate] Finished processing queued requests');
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { safeStorage } from '../utils/safe-storage';
|
||||
|
||||
interface UserPreferences {
|
||||
unitSystem: 'imperial' | 'metric';
|
||||
@@ -90,7 +91,7 @@ export const useUserStore = create<UserState>()(
|
||||
}),
|
||||
{
|
||||
name: 'motovaultpro-user-context',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
storage: createJSONStorage(() => safeStorage),
|
||||
partialize: (state) => ({
|
||||
userProfile: state.userProfile,
|
||||
preferences: state.preferences,
|
||||
|
||||
211
frontend/src/core/utils/indexeddb-storage.ts
Normal file
211
frontend/src/core/utils/indexeddb-storage.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @ai-summary IndexedDB storage adapter for Auth0 and Zustand persistence
|
||||
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
|
||||
*/
|
||||
|
||||
interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
clear(): void;
|
||||
key(index: number): string | null;
|
||||
readonly length: number;
|
||||
}
|
||||
|
||||
interface Auth0Cache {
|
||||
get(key: string): Promise<any>;
|
||||
set(key: string, value: any): Promise<void>;
|
||||
remove(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
class IndexedDBStorage implements StorageAdapter, Auth0Cache {
|
||||
private dbName = 'motovaultpro-storage';
|
||||
private dbVersion = 1;
|
||||
private storeName = 'keyvalue';
|
||||
private db: IDBDatabase | null = null;
|
||||
private memoryCache = new Map<string, string>();
|
||||
private initPromise: Promise<void>;
|
||||
private isReady = false;
|
||||
|
||||
constructor() {
|
||||
this.initPromise = this.initialize();
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
this.db = await this.openDatabase();
|
||||
await this.loadCacheFromDB();
|
||||
this.isReady = true;
|
||||
console.log('[IndexedDB] Storage initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[IndexedDB] Initialization failed, using memory only:', error);
|
||||
this.isReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error(`IndexedDB open failed: ${request.error?.message}`);
|
||||
resolve(null as any); // Fallback to memory-only mode
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
db.createObjectStore(this.storeName);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async loadCacheFromDB(): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onsuccess = () => {
|
||||
const results = request.result;
|
||||
this.memoryCache.clear();
|
||||
|
||||
for (const item of results) {
|
||||
if (item.key && typeof item.value === 'string') {
|
||||
this.memoryCache.set(item.key, item.value);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
console.warn('[IndexedDB] Failed to load cache from DB:', request.error);
|
||||
resolve(); // Don't fail initialization
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async persistToDB(key: string, value: string | null): Promise<void> {
|
||||
if (!this.db) return;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
if (value === null) {
|
||||
const request = store.delete(key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => {
|
||||
console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error);
|
||||
resolve();
|
||||
};
|
||||
} else {
|
||||
const request = store.put(value, key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => {
|
||||
console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error);
|
||||
resolve();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Synchronous Storage interface (uses memory cache)
|
||||
getItem(key: string): string | null {
|
||||
return this.memoryCache.get(key) || null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.memoryCache.set(key, value);
|
||||
|
||||
// Async persist to IndexedDB (non-blocking)
|
||||
if (this.isReady) {
|
||||
this.persistToDB(key, value).catch(error => {
|
||||
console.warn(`[IndexedDB] Background persist failed for ${key}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.memoryCache.delete(key);
|
||||
|
||||
// Async remove from IndexedDB (non-blocking)
|
||||
if (this.isReady) {
|
||||
this.persistToDB(key, null).catch(error => {
|
||||
console.warn(`[IndexedDB] Background removal failed for ${key}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.memoryCache.clear();
|
||||
|
||||
// Async clear IndexedDB (non-blocking)
|
||||
if (this.db) {
|
||||
const transaction = this.db.transaction([this.storeName], 'readwrite');
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
store.clear();
|
||||
}
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
const keys = Array.from(this.memoryCache.keys());
|
||||
return keys[index] || null;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.memoryCache.size;
|
||||
}
|
||||
|
||||
// Auth0 Cache interface implementation
|
||||
async get(key: string): Promise<any> {
|
||||
await this.initPromise;
|
||||
const value = this.getItem(key);
|
||||
return value ? JSON.parse(value) : undefined;
|
||||
}
|
||||
|
||||
async set(key: string, value: any): Promise<void> {
|
||||
await this.initPromise;
|
||||
this.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.initPromise;
|
||||
this.removeItem(key);
|
||||
}
|
||||
|
||||
// Additional methods for enhanced functionality
|
||||
async waitForReady(): Promise<void> {
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
get isInitialized(): boolean {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
// For debugging
|
||||
getStats() {
|
||||
return {
|
||||
cacheSize: this.memoryCache.size,
|
||||
isReady: this.isReady,
|
||||
hasDB: !!this.db
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const indexedDBStorage = new IndexedDBStorage();
|
||||
|
||||
// For Auth0 compatibility - ensure storage is ready before use
|
||||
export const createIndexedDBAdapter = () => {
|
||||
return indexedDBStorage;
|
||||
};
|
||||
@@ -1,107 +1,9 @@
|
||||
/**
|
||||
* @ai-summary Safe localStorage wrapper for mobile browsers
|
||||
* @ai-context Prevents errors when localStorage is blocked in mobile browsers
|
||||
* @ai-summary IndexedDB storage wrapper for mobile browsers
|
||||
* @ai-context Replaces localStorage with IndexedDB for better mobile compatibility
|
||||
*/
|
||||
|
||||
// Safe localStorage wrapper that won't crash on mobile browsers
|
||||
const createSafeStorage = () => {
|
||||
let isAvailable = false;
|
||||
import { indexedDBStorage } from './indexeddb-storage';
|
||||
|
||||
// Test localStorage availability
|
||||
try {
|
||||
const testKey = '__motovaultpro_storage_test__';
|
||||
localStorage.setItem(testKey, 'test');
|
||||
localStorage.removeItem(testKey);
|
||||
isAvailable = true;
|
||||
} catch (error) {
|
||||
console.warn('[Storage] localStorage not available, using memory fallback:', error);
|
||||
isAvailable = false;
|
||||
}
|
||||
|
||||
// Memory fallback when localStorage is blocked
|
||||
const memoryStorage = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem: (key: string): string | null => {
|
||||
try {
|
||||
if (isAvailable) {
|
||||
return localStorage.getItem(key);
|
||||
} else {
|
||||
return memoryStorage.get(key) || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Storage] getItem failed, using memory fallback:', error);
|
||||
return memoryStorage.get(key) || null;
|
||||
}
|
||||
},
|
||||
|
||||
setItem: (key: string, value: string): void => {
|
||||
try {
|
||||
if (isAvailable) {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
memoryStorage.set(key, value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Storage] setItem failed, using memory fallback:', error);
|
||||
memoryStorage.set(key, value);
|
||||
}
|
||||
},
|
||||
|
||||
removeItem: (key: string): void => {
|
||||
try {
|
||||
if (isAvailable) {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
memoryStorage.delete(key);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Storage] removeItem failed, using memory fallback:', error);
|
||||
memoryStorage.delete(key);
|
||||
}
|
||||
},
|
||||
|
||||
// For zustand createJSONStorage compatibility
|
||||
key: (index: number): string | null => {
|
||||
try {
|
||||
if (isAvailable) {
|
||||
return localStorage.key(index);
|
||||
} else {
|
||||
const keys = Array.from(memoryStorage.keys());
|
||||
return keys[index] || null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Storage] key access failed:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
get length(): number {
|
||||
try {
|
||||
if (isAvailable) {
|
||||
return localStorage.length;
|
||||
} else {
|
||||
return memoryStorage.size;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Storage] length access failed:', error);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
clear: (): void => {
|
||||
try {
|
||||
if (isAvailable) {
|
||||
localStorage.clear();
|
||||
} else {
|
||||
memoryStorage.clear();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Storage] clear failed:', error);
|
||||
memoryStorage.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const safeStorage = createSafeStorage();
|
||||
// Export IndexedDB storage as the safe storage implementation
|
||||
export const safeStorage = indexedDBStorage;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from '../../../core/api/client';
|
||||
import { CreateFuelLogRequest, FuelLogResponse, EnhancedFuelStats, FuelType, FuelGradeOption } from '../types/fuel-logs.types';
|
||||
import { CreateFuelLogRequest, UpdateFuelLogRequest, FuelLogResponse, EnhancedFuelStats, FuelType, FuelGradeOption } from '../types/fuel-logs.types';
|
||||
|
||||
export const fuelLogsApi = {
|
||||
async create(data: CreateFuelLogRequest): Promise<FuelLogResponse> {
|
||||
@@ -30,6 +30,20 @@ export const fuelLogsApi = {
|
||||
async getFuelGrades(fuelType: FuelType): Promise<FuelGradeOption[]> {
|
||||
const res = await apiClient.get(`/fuel-logs/fuel-grades/${fuelType}`);
|
||||
return res.data.grades;
|
||||
},
|
||||
|
||||
async update(id: string, data: UpdateFuelLogRequest): Promise<FuelLogResponse> {
|
||||
const res = await apiClient.put(`/fuel-logs/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await apiClient.delete(`/fuel-logs/${id}`);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<FuelLogResponse> {
|
||||
const res = await apiClient.get(`/fuel-logs/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
291
frontend/src/features/fuel-logs/components/FuelLogEditDialog.tsx
Normal file
291
frontend/src/features/fuel-logs/components/FuelLogEditDialog.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
Box,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Typography,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest, FuelType } from '../types/fuel-logs.types';
|
||||
import { useFuelGrades } from '../hooks/useFuelGrades';
|
||||
|
||||
interface FuelLogEditDialogProps {
|
||||
open: boolean;
|
||||
log: FuelLogResponse | null;
|
||||
onClose: () => void;
|
||||
onSave: (id: string, data: UpdateFuelLogRequest) => Promise<void>;
|
||||
}
|
||||
|
||||
export const FuelLogEditDialog: React.FC<FuelLogEditDialogProps> = ({
|
||||
open,
|
||||
log,
|
||||
onClose,
|
||||
onSave
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<UpdateFuelLogRequest>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hookError, setHookError] = useState<Error | null>(null);
|
||||
|
||||
// Defensive hook usage with error handling
|
||||
let fuelGrades: any[] = [];
|
||||
try {
|
||||
const hookResult = useFuelGrades(formData.fuelType || log?.fuelType || FuelType.GASOLINE);
|
||||
fuelGrades = hookResult.fuelGrades || [];
|
||||
} catch (error) {
|
||||
console.error('[FuelLogEditDialog] Hook error:', error);
|
||||
setHookError(error as Error);
|
||||
}
|
||||
|
||||
// Reset form when log changes with defensive checks
|
||||
useEffect(() => {
|
||||
if (log && log.id) {
|
||||
try {
|
||||
setFormData({
|
||||
dateTime: log.dateTime || new Date().toISOString(),
|
||||
odometerReading: log.odometerReading || undefined,
|
||||
tripDistance: log.tripDistance || undefined,
|
||||
fuelType: log.fuelType || FuelType.GASOLINE,
|
||||
fuelGrade: log.fuelGrade || null,
|
||||
fuelUnits: log.fuelUnits || 0,
|
||||
costPerUnit: log.costPerUnit || 0,
|
||||
notes: log.notes || ''
|
||||
});
|
||||
setHookError(null); // Reset any previous errors
|
||||
} catch (error) {
|
||||
console.error('[FuelLogEditDialog] Error setting form data:', error);
|
||||
setHookError(error as Error);
|
||||
}
|
||||
}
|
||||
}, [log]);
|
||||
|
||||
const handleInputChange = (field: keyof UpdateFuelLogRequest, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!log || !log.id) {
|
||||
console.error('[FuelLogEditDialog] No valid log to save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Filter out unchanged fields with defensive checks
|
||||
const changedData: UpdateFuelLogRequest = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const typedKey = key as keyof UpdateFuelLogRequest;
|
||||
if (value !== log[typedKey as keyof FuelLogResponse]) {
|
||||
(changedData as any)[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Only send update if there are actual changes
|
||||
if (Object.keys(changedData).length > 0) {
|
||||
await onSave(log.id, changedData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('[FuelLogEditDialog] Failed to save fuel log:', error);
|
||||
setHookError(error as Error);
|
||||
// Don't close dialog on error, let user retry
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Early returns for error states
|
||||
if (!log) return null;
|
||||
|
||||
if (hookError) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Error Loading Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography color="error">
|
||||
Failed to load fuel log data. Please try again.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{hookError.message}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// Format datetime for input (datetime-local expects YYYY-MM-DDTHH:mm format)
|
||||
const formatDateTimeForInput = (isoString: string) => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
fullScreen={useMediaQuery('(max-width:600px)')}
|
||||
PaperProps={{
|
||||
sx: { maxHeight: '90vh' }
|
||||
}}
|
||||
>
|
||||
<DialogTitle>Edit Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Grid container spacing={2}>
|
||||
{/* Date and Time */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Date & Time"
|
||||
type="datetime-local"
|
||||
fullWidth
|
||||
value={formData.dateTime ? formatDateTimeForInput(formData.dateTime) : ''}
|
||||
onChange={(e) => handleInputChange('dateTime', new Date(e.target.value).toISOString())}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Distance Inputs */}
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Odometer Reading"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.odometerReading || ''}
|
||||
onChange={(e) => handleInputChange('odometerReading', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
helperText="Current odometer reading"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Trip Distance"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.tripDistance || ''}
|
||||
onChange={(e) => handleInputChange('tripDistance', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
helperText="Distance for this trip"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Fuel Type */}
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Fuel Type</InputLabel>
|
||||
<Select
|
||||
value={formData.fuelType || ''}
|
||||
onChange={(e) => handleInputChange('fuelType', e.target.value as FuelType)}
|
||||
label="Fuel Type"
|
||||
>
|
||||
<MenuItem value={FuelType.GASOLINE}>Gasoline</MenuItem>
|
||||
<MenuItem value={FuelType.DIESEL}>Diesel</MenuItem>
|
||||
<MenuItem value={FuelType.ELECTRIC}>Electric</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Fuel Grade */}
|
||||
<Grid item xs={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Fuel Grade</InputLabel>
|
||||
<Select
|
||||
value={formData.fuelGrade || ''}
|
||||
onChange={(e) => handleInputChange('fuelGrade', e.target.value || null)}
|
||||
label="Fuel Grade"
|
||||
disabled={!fuelGrades || fuelGrades.length === 0}
|
||||
>
|
||||
{fuelGrades?.map((grade) => (
|
||||
<MenuItem key={grade.value || 'none'} value={grade.value || ''}>
|
||||
{grade.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
|
||||
{/* Fuel Amount and Cost */}
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Fuel Amount"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.fuelUnits || ''}
|
||||
onChange={(e) => handleInputChange('fuelUnits', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
helperText="Gallons or liters"
|
||||
inputProps={{ step: 0.001 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<TextField
|
||||
label="Cost Per Unit"
|
||||
type="number"
|
||||
fullWidth
|
||||
value={formData.costPerUnit || ''}
|
||||
onChange={(e) => handleInputChange('costPerUnit', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
helperText="Price per gallon/liter"
|
||||
inputProps={{ step: 0.001 }}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* Total Cost Display */}
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Total Cost: ${((formData.fuelUnits || 0) * (formData.costPerUnit || 0)).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{/* Notes */}
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
label="Notes"
|
||||
multiline
|
||||
rows={3}
|
||||
fullWidth
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||
placeholder="Optional notes about this fuel-up..."
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="contained"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,27 +1,241 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, Typography, List, ListItem, ListItemText, Chip, Box } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Box,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
useTheme,
|
||||
useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { Edit, Delete } from '@mui/icons-material';
|
||||
import { FuelLogResponse } from '../types/fuel-logs.types';
|
||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||
|
||||
export const FuelLogsList: React.FC<{ logs?: FuelLogResponse[] }>= ({ logs }) => {
|
||||
if (!logs || logs.length === 0) {
|
||||
interface FuelLogsListProps {
|
||||
logs?: FuelLogResponse[];
|
||||
onEdit?: (log: FuelLogResponse) => void;
|
||||
onDelete?: (logId: string) => void;
|
||||
}
|
||||
|
||||
export const FuelLogsList: React.FC<FuelLogsListProps> = ({ logs, onEdit, onDelete }) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [logToDelete, setLogToDelete] = useState<FuelLogResponse | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDeleteClick = (log: FuelLogResponse) => {
|
||||
setLogToDelete(log);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!logToDelete) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await fuelLogsApi.delete(logToDelete.id);
|
||||
onDelete?.(logToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setLogToDelete(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete fuel log:', error);
|
||||
// TODO: Show error notification
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
setDeleteDialogOpen(false);
|
||||
setLogToDelete(null);
|
||||
};
|
||||
// Defensive check for logs data
|
||||
if (!Array.isArray(logs) || logs.length === 0) {
|
||||
return (
|
||||
<Card variant="outlined"><CardContent><Typography variant="body2" color="text.secondary">No fuel logs yet.</Typography></CardContent></Card>
|
||||
<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>
|
||||
<>
|
||||
<List>
|
||||
{logs.map((log) => {
|
||||
// Defensive checks for each log entry
|
||||
if (!log || !log.id) {
|
||||
console.warn('[FuelLogsList] Invalid log entry:', log);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Safe date formatting
|
||||
const dateText = log.dateTime
|
||||
? new Date(log.dateTime).toLocaleString()
|
||||
: 'Unknown date';
|
||||
|
||||
// Safe cost formatting
|
||||
const totalCost = typeof log.totalCost === 'number'
|
||||
? log.totalCost.toFixed(2)
|
||||
: '0.00';
|
||||
|
||||
// Safe fuel units and cost per unit
|
||||
const fuelUnits = typeof log.fuelUnits === 'number'
|
||||
? log.fuelUnits.toFixed(3)
|
||||
: '0.000';
|
||||
|
||||
const costPerUnit = typeof log.costPerUnit === 'number'
|
||||
? log.costPerUnit.toFixed(3)
|
||||
: '0.000';
|
||||
|
||||
// Safe distance display
|
||||
const distanceText = log.odometerReading
|
||||
? `Odo: ${log.odometerReading}`
|
||||
: log.tripDistance
|
||||
? `Trip: ${log.tripDistance}`
|
||||
: 'No distance';
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={log.id}
|
||||
divider
|
||||
sx={{
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'stretch' : 'center',
|
||||
gap: isMobile ? 1 : 0,
|
||||
py: isMobile ? 2 : 1
|
||||
}}
|
||||
>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center',
|
||||
flex: 1,
|
||||
gap: isMobile ? 0.5 : 1
|
||||
}}>
|
||||
<ListItemText
|
||||
primary={`${dateText} – $${totalCost}`}
|
||||
secondary={`${fuelUnits} @ $${costPerUnit} • ${distanceText}`}
|
||||
sx={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
{log.efficiency &&
|
||||
typeof log.efficiency === 'number' &&
|
||||
!isNaN(log.efficiency) &&
|
||||
log.efficiencyLabel && (
|
||||
<Box sx={{ mr: isMobile ? 0 : 1 }}>
|
||||
<Chip
|
||||
label={`${log.efficiency.toFixed(1)} ${log.efficiencyLabel}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
gap: isMobile ? 1 : 0.5,
|
||||
justifyContent: isMobile ? 'center' : 'flex-end',
|
||||
width: isMobile ? '100%' : 'auto'
|
||||
}}>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
onClick={() => onEdit(log)}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
'&:hover': { backgroundColor: 'primary.main', color: 'white' },
|
||||
minWidth: isMobile ? 48 : 'auto',
|
||||
minHeight: isMobile ? 48 : 'auto',
|
||||
...(isMobile && {
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.main',
|
||||
borderRadius: 2
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Edit fontSize={isMobile ? 'medium' : 'small'} />
|
||||
</IconButton>
|
||||
)}
|
||||
{onDelete && (
|
||||
<IconButton
|
||||
size={isMobile ? 'medium' : 'small'}
|
||||
onClick={() => handleDeleteClick(log)}
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
'&:hover': { backgroundColor: 'error.main', color: 'white' },
|
||||
minWidth: isMobile ? 48 : 'auto',
|
||||
minHeight: isMobile ? 48 : 'auto',
|
||||
...(isMobile && {
|
||||
border: '1px solid',
|
||||
borderColor: 'error.main',
|
||||
borderRadius: 2
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Delete fontSize={isMobile ? 'medium' : 'small'} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</ListItem>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[FuelLogsList] Error rendering log:', log, error);
|
||||
return (
|
||||
<ListItem key={log.id || Math.random()} divider>
|
||||
<ListItemText
|
||||
primary="Error displaying fuel log"
|
||||
secondary="Data formatting issue"
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
}).filter(Boolean)}
|
||||
</List>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={handleDeleteCancel}>
|
||||
<DialogTitle>Delete Fuel Log</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete this fuel log entry? This action cannot be undone.
|
||||
</Typography>
|
||||
{logToDelete && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{new Date(logToDelete.dateTime).toLocaleString()} - ${logToDelete.totalCost.toFixed(2)}
|
||||
</Typography>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDeleteCancel} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteConfirm}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,8 +7,17 @@ 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);
|
||||
|
||||
const totalUnits = logs.reduce((s, l) => {
|
||||
const fuelUnits = typeof l.fuelUnits === 'number' && !isNaN(l.fuelUnits) ? l.fuelUnits : 0;
|
||||
return s + fuelUnits;
|
||||
}, 0);
|
||||
|
||||
const totalCost = logs.reduce((s, l) => {
|
||||
const cost = typeof l.totalCost === 'number' && !isNaN(l.totalCost) ? l.totalCost : 0;
|
||||
return s + cost;
|
||||
}, 0);
|
||||
|
||||
return { count: logs.length, totalUnits, totalCost };
|
||||
}, [logs]);
|
||||
|
||||
@@ -24,11 +33,11 @@ export const FuelStatsCard: React.FC<{ logs?: FuelLogResponse[] }> = ({ logs })
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Typography variant="overline" color="text.secondary">Total Fuel</Typography>
|
||||
<Typography variant="h6">{(stats.totalUnits || 0).toFixed(2)} {unitLabel}</Typography>
|
||||
<Typography variant="h6">{(typeof stats.totalUnits === 'number' && !isNaN(stats.totalUnits) ? 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>
|
||||
<Typography variant="h6">${(typeof stats.totalCost === 'number' && !isNaN(stats.totalCost) ? stats.totalCost : 0).toFixed(2)}</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,24 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Grid, Typography } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Grid, Typography, Box } from '@mui/material';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { FuelLogForm } from '../components/FuelLogForm';
|
||||
import { FuelLogsList } from '../components/FuelLogsList';
|
||||
import { FuelLogEditDialog } from '../components/FuelLogEditDialog';
|
||||
import { useFuelLogs } from '../hooks/useFuelLogs';
|
||||
import { FuelStatsCard } from '../components/FuelStatsCard';
|
||||
import { FormSuspense } from '../../../components/SuspenseWrappers';
|
||||
import { FuelLogResponse, UpdateFuelLogRequest } from '../types/fuel-logs.types';
|
||||
import { fuelLogsApi } from '../api/fuel-logs.api';
|
||||
|
||||
export const FuelLogsPage: React.FC = () => {
|
||||
const { fuelLogs } = useFuelLogs();
|
||||
const { fuelLogs, isLoading, error } = useFuelLogs();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingLog, setEditingLog] = useState<FuelLogResponse | null>(null);
|
||||
|
||||
const handleEdit = (log: FuelLogResponse) => {
|
||||
setEditingLog(log);
|
||||
};
|
||||
|
||||
const handleDelete = async (_logId: string) => {
|
||||
try {
|
||||
// Invalidate queries to refresh the data
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats'] });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh fuel logs after delete:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (id: string, data: UpdateFuelLogRequest) => {
|
||||
try {
|
||||
await fuelLogsApi.update(id, data);
|
||||
// Invalidate queries to refresh the data
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['fuelLogsStats'] });
|
||||
setEditingLog(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update fuel log:', error);
|
||||
throw error; // Re-throw to let the dialog handle the error
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseEdit = () => {
|
||||
setEditingLog(null);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '50vh'
|
||||
}}>
|
||||
<Typography color="text.secondary">Loading fuel logs...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '50vh'
|
||||
}}>
|
||||
<Typography color="error">Failed to load fuel logs. Please try again.</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FuelLogForm />
|
||||
<FormSuspense>
|
||||
<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}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ mt: 3 }} gutterBottom>Summary</Typography>
|
||||
<FuelStatsCard logs={fuelLogs} />
|
||||
</Grid>
|
||||
</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>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<FuelLogEditDialog
|
||||
open={!!editingLog}
|
||||
log={editingLog}
|
||||
onClose={handleCloseEdit}
|
||||
onSave={handleSaveEdit}
|
||||
/>
|
||||
</FormSuspense>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,6 +34,18 @@ export interface CreateFuelLogRequest {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateFuelLogRequest {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user