From 8fd797365625294018e80dc70c3dd83b551e9b47 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:27:10 -0500 Subject: [PATCH] Fix Auth Errors --- .../fuel-logs/api/fuel-logs.controller.ts | 24 +- .../fuel-logs/data/fuel-logs.repository.ts | 86 ++++++ .../fuel-logs/domain/fuel-logs.service.ts | 78 ++++- .../fuel-logs/domain/fuel-logs.types.ts | 12 + .../changes/K8S-REDESIGN.md | 0 K8S-STATUS.md => docs/changes/K8S-STATUS.md | 0 frontend/src/App.tsx | 116 ++++++- frontend/src/core/api/client.ts | 63 +++- frontend/src/core/auth/Auth0Provider.tsx | 25 +- frontend/src/core/auth/auth-gate.ts | 102 ++++++ frontend/src/core/store/user.ts | 3 +- frontend/src/core/utils/indexeddb-storage.ts | 211 +++++++++++++ frontend/src/core/utils/safe-storage.ts | 108 +------ .../features/fuel-logs/api/fuel-logs.api.ts | 16 +- .../components/FuelLogEditDialog.tsx | 291 ++++++++++++++++++ .../fuel-logs/components/FuelLogsList.tsx | 248 ++++++++++++++- .../fuel-logs/components/FuelStatsCard.tsx | 17 +- .../features/fuel-logs/pages/FuelLogsPage.tsx | 104 ++++++- .../fuel-logs/types/fuel-logs.types.ts | 12 + 19 files changed, 1342 insertions(+), 174 deletions(-) rename K8S-REDESIGN.md => docs/changes/K8S-REDESIGN.md (100%) rename K8S-STATUS.md => docs/changes/K8S-STATUS.md (100%) create mode 100644 frontend/src/core/auth/auth-gate.ts create mode 100644 frontend/src/core/utils/indexeddb-storage.ts create mode 100644 frontend/src/features/fuel-logs/components/FuelLogEditDialog.tsx diff --git a/backend/src/features/fuel-logs/api/fuel-logs.controller.ts b/backend/src/features/fuel-logs/api/fuel-logs.controller.ts index 8d8d136..370e253 100644 --- a/backend/src/features/fuel-logs/api/fuel-logs.controller.ts +++ b/backend/src/features/fuel-logs/api/fuel-logs.controller.ts @@ -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' diff --git a/backend/src/features/fuel-logs/data/fuel-logs.repository.ts b/backend/src/features/fuel-logs/data/fuel-logs.repository.ts index 42810dd..9c79c68 100644 --- a/backend/src/features/fuel-logs/data/fuel-logs.repository.ts +++ b/backend/src/features/fuel-logs/data/fuel-logs.repository.ts @@ -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 { + 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]; + } } diff --git a/backend/src/features/fuel-logs/domain/fuel-logs.service.ts b/backend/src/features/fuel-logs/domain/fuel-logs.service.ts index c7ac039..700b722 100644 --- a/backend/src/features/fuel-logs/domain/fuel-logs.service.ts +++ b/backend/src/features/fuel-logs/domain/fuel-logs.service.ts @@ -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 { throw new Error('Not Implemented'); } + async updateFuelLog(id: string, data: EnhancedUpdateFuelLogRequest, userId: string): Promise { + 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 { const existing = await this.repository.findByIdEnhanced(id); diff --git a/backend/src/features/fuel-logs/domain/fuel-logs.types.ts b/backend/src/features/fuel-logs/domain/fuel-logs.types.ts index 3815143..be8dcd5 100644 --- a/backend/src/features/fuel-logs/domain/fuel-logs.types.ts +++ b/backend/src/features/fuel-logs/domain/fuel-logs.types.ts @@ -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; diff --git a/K8S-REDESIGN.md b/docs/changes/K8S-REDESIGN.md similarity index 100% rename from K8S-REDESIGN.md rename to docs/changes/K8S-REDESIGN.md diff --git a/K8S-STATUS.md b/docs/changes/K8S-STATUS.md similarity index 100% rename from K8S-STATUS.md rename to docs/changes/K8S-STATUS.md diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 64783f0..c1fe8cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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 ( +
+ +
+ Initializing fuel logs... +
+
+
+ ); + } + return (
- - -
- {isLoading ? ( -
- Loading fuel logs... -
- ) : ( - - )} -
-
+ + + + + +
+ {isLoading ? ( +
+ Loading fuel logs... +
+ ) : ( + + )} +
+
+
+ + {/* Edit Dialog */} + + +
); }; diff --git a/frontend/src/core/api/client.ts b/frontend/src/core/api/client.ts index 8daee98..82e6b12 100644 --- a/frontend/src/core/api/client.ts +++ b/frontend/src/core/api/client.ts @@ -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 diff --git a/frontend/src/core/auth/Auth0Provider.tsx b/frontend/src/core/auth/Auth0Provider.tsx index 0b30189..9909227 100644 --- a/frontend/src/core/auth/Auth0Provider.tsx +++ b/frontend/src/core/auth/Auth0Provider.tsx @@ -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 = ({ 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 diff --git a/frontend/src/core/auth/auth-gate.ts b/frontend/src/core/auth/auth-gate.ts new file mode 100644 index 0000000..aa074a2 --- /dev/null +++ b/frontend/src/core/auth/auth-gate.ts @@ -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 | 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; +} + +let requestQueue: QueuedRequest[] = []; +let isProcessingQueue = false; + +export const waitForAuthInit = (): Promise => { + 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 = (requestFn: () => Promise): Promise => { + 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'); +}; \ No newline at end of file diff --git a/frontend/src/core/store/user.ts b/frontend/src/core/store/user.ts index 2612037..c916f70 100644 --- a/frontend/src/core/store/user.ts +++ b/frontend/src/core/store/user.ts @@ -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()( }), { name: 'motovaultpro-user-context', - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => safeStorage), partialize: (state) => ({ userProfile: state.userProfile, preferences: state.preferences, diff --git a/frontend/src/core/utils/indexeddb-storage.ts b/frontend/src/core/utils/indexeddb-storage.ts new file mode 100644 index 0000000..6ce8dd6 --- /dev/null +++ b/frontend/src/core/utils/indexeddb-storage.ts @@ -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; + set(key: string, value: any): Promise; + remove(key: string): Promise; +} + +class IndexedDBStorage implements StorageAdapter, Auth0Cache { + private dbName = 'motovaultpro-storage'; + private dbVersion = 1; + private storeName = 'keyvalue'; + private db: IDBDatabase | null = null; + private memoryCache = new Map(); + private initPromise: Promise; + private isReady = false; + + constructor() { + this.initPromise = this.initialize(); + } + + private async initialize(): Promise { + 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 { + 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 { + 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 { + 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 { + await this.initPromise; + const value = this.getItem(key); + return value ? JSON.parse(value) : undefined; + } + + async set(key: string, value: any): Promise { + await this.initPromise; + this.setItem(key, JSON.stringify(value)); + } + + async remove(key: string): Promise { + await this.initPromise; + this.removeItem(key); + } + + // Additional methods for enhanced functionality + async waitForReady(): Promise { + 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; +}; \ No newline at end of file diff --git a/frontend/src/core/utils/safe-storage.ts b/frontend/src/core/utils/safe-storage.ts index 3d1fc7d..91c1b98 100644 --- a/frontend/src/core/utils/safe-storage.ts +++ b/frontend/src/core/utils/safe-storage.ts @@ -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(); - - 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(); \ No newline at end of file +// Export IndexedDB storage as the safe storage implementation +export const safeStorage = indexedDBStorage; \ No newline at end of file diff --git a/frontend/src/features/fuel-logs/api/fuel-logs.api.ts b/frontend/src/features/fuel-logs/api/fuel-logs.api.ts index 2b883cd..7bc1498 100644 --- a/frontend/src/features/fuel-logs/api/fuel-logs.api.ts +++ b/frontend/src/features/fuel-logs/api/fuel-logs.api.ts @@ -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 { @@ -30,6 +30,20 @@ export const fuelLogsApi = { async getFuelGrades(fuelType: FuelType): Promise { const res = await apiClient.get(`/fuel-logs/fuel-grades/${fuelType}`); return res.data.grades; + }, + + async update(id: string, data: UpdateFuelLogRequest): Promise { + const res = await apiClient.put(`/fuel-logs/${id}`, data); + return res.data; + }, + + async delete(id: string): Promise { + await apiClient.delete(`/fuel-logs/${id}`); + }, + + async getById(id: string): Promise { + const res = await apiClient.get(`/fuel-logs/${id}`); + return res.data; } }; diff --git a/frontend/src/features/fuel-logs/components/FuelLogEditDialog.tsx b/frontend/src/features/fuel-logs/components/FuelLogEditDialog.tsx new file mode 100644 index 0000000..edd48e8 --- /dev/null +++ b/frontend/src/features/fuel-logs/components/FuelLogEditDialog.tsx @@ -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; +} + +export const FuelLogEditDialog: React.FC = ({ + open, + log, + onClose, + onSave +}) => { + const [formData, setFormData] = useState({}); + const [isSaving, setIsSaving] = useState(false); + const [hookError, setHookError] = useState(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 ( + + Error Loading Fuel Log + + + Failed to load fuel log data. Please try again. + + + {hookError.message} + + + + + + + ); + } + + // 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 ( + + Edit Fuel Log + + + + {/* Date and Time */} + + handleInputChange('dateTime', new Date(e.target.value).toISOString())} + InputLabelProps={{ shrink: true }} + /> + + + {/* Distance Inputs */} + + handleInputChange('odometerReading', e.target.value ? parseFloat(e.target.value) : undefined)} + helperText="Current odometer reading" + /> + + + handleInputChange('tripDistance', e.target.value ? parseFloat(e.target.value) : undefined)} + helperText="Distance for this trip" + /> + + + {/* Fuel Type */} + + + Fuel Type + + + + + {/* Fuel Grade */} + + + Fuel Grade + + + + + {/* Fuel Amount and Cost */} + + handleInputChange('fuelUnits', e.target.value ? parseFloat(e.target.value) : undefined)} + helperText="Gallons or liters" + inputProps={{ step: 0.001 }} + /> + + + handleInputChange('costPerUnit', e.target.value ? parseFloat(e.target.value) : undefined)} + helperText="Price per gallon/liter" + inputProps={{ step: 0.001 }} + /> + + + {/* Total Cost Display */} + + + Total Cost: ${((formData.fuelUnits || 0) * (formData.costPerUnit || 0)).toFixed(2)} + + + + {/* Notes */} + + handleInputChange('notes', e.target.value)} + placeholder="Optional notes about this fuel-up..." + /> + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/features/fuel-logs/components/FuelLogsList.tsx b/frontend/src/features/fuel-logs/components/FuelLogsList.tsx index 38d31f8..2853461 100644 --- a/frontend/src/features/fuel-logs/components/FuelLogsList.tsx +++ b/frontend/src/features/fuel-logs/components/FuelLogsList.tsx @@ -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 = ({ logs, onEdit, onDelete }) => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [logToDelete, setLogToDelete] = useState(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 ( - No fuel logs yet. + + + + No fuel logs yet. + + + ); } + return ( - - {logs.map((log) => ( - - - {log.efficiency && typeof log.efficiency === 'number' && !isNaN(log.efficiency) && ( - + <> + + {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 ( + + + + {log.efficiency && + typeof log.efficiency === 'number' && + !isNaN(log.efficiency) && + log.efficiencyLabel && ( + + + + )} + + + + {onEdit && ( + 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 + }) + }} + > + + + )} + {onDelete && ( + 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 + }) + }} + > + + + )} + + + ); + } catch (error) { + console.error('[FuelLogsList] Error rendering log:', log, error); + return ( + + + + ); + } + }).filter(Boolean)} + + + {/* Delete Confirmation Dialog */} + + Delete Fuel Log + + + Are you sure you want to delete this fuel log entry? This action cannot be undone. + + {logToDelete && ( + + {new Date(logToDelete.dateTime).toLocaleString()} - ${logToDelete.totalCost.toFixed(2)} + )} - - ))} - + + + + + + + ); }; diff --git a/frontend/src/features/fuel-logs/components/FuelStatsCard.tsx b/frontend/src/features/fuel-logs/components/FuelStatsCard.tsx index 33aaf65..747878c 100644 --- a/frontend/src/features/fuel-logs/components/FuelStatsCard.tsx +++ b/frontend/src/features/fuel-logs/components/FuelStatsCard.tsx @@ -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 }) Total Fuel - {(stats.totalUnits || 0).toFixed(2)} {unitLabel} + {(typeof stats.totalUnits === 'number' && !isNaN(stats.totalUnits) ? stats.totalUnits : 0).toFixed(2)} {unitLabel} Total Cost - ${(stats.totalCost || 0).toFixed(2)} + ${(typeof stats.totalCost === 'number' && !isNaN(stats.totalCost) ? stats.totalCost : 0).toFixed(2)} diff --git a/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx b/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx index c41c916..d6ba691 100644 --- a/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx +++ b/frontend/src/features/fuel-logs/pages/FuelLogsPage.tsx @@ -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(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 ( + + Loading fuel logs... + + ); + } + + if (error) { + return ( + + Failed to load fuel logs. Please try again. + + ); + } return ( - - - + + + + + + + Recent Fuel Logs + + Summary + + - - Recent Fuel Logs - - Summary - - - + + {/* Edit Dialog */} + + ); }; diff --git a/frontend/src/features/fuel-logs/types/fuel-logs.types.ts b/frontend/src/features/fuel-logs/types/fuel-logs.types.ts index a2915a8..131532c 100644 --- a/frontend/src/features/fuel-logs/types/fuel-logs.types.ts +++ b/frontend/src/features/fuel-logs/types/fuel-logs.types.ts @@ -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;