From e558fdf8f99bc04554f2a0de0bbbbd2caf960fb1 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 14 Jan 2026 19:31:03 -0600 Subject: [PATCH] feat: add frontend document-vehicle API client and hooks (refs #31) - Update DocumentRecord interface to include sharedVehicleIds array - Add optional sharedVehicleIds to Create/UpdateDocumentRequest types - Add documentsApi.listByVehicle() method for fetching by vehicle - Add documentsApi.addSharedVehicle() for linking vehicles - Add documentsApi.removeVehicleFromDocument() for unlinking - Add useDocumentsByVehicle() query hook with vehicle filter - Add useAddSharedVehicle() mutation with optimistic updates - Add useRemoveVehicleFromDocument() mutation with optimistic updates - Ensure query invalidation includes both documents and documents-by-vehicle keys - Update test mocks to include sharedVehicleIds field - Fix optimistic update in useCreateDocument to include new fields Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/documents/api/documents.api.ts | 11 ++ .../components/DocumentPreview.test.tsx | 3 + .../features/documents/hooks/useDocuments.ts | 123 ++++++++++++++++++ .../mobile/DocumentsMobileScreen.test.tsx | 2 + .../documents/types/documents.types.ts | 3 + 5 files changed, 142 insertions(+) diff --git a/frontend/src/features/documents/api/documents.api.ts b/frontend/src/features/documents/api/documents.api.ts index a6d7ef8..4913d67 100644 --- a/frontend/src/features/documents/api/documents.api.ts +++ b/frontend/src/features/documents/api/documents.api.ts @@ -47,5 +47,16 @@ export const documentsApi = { // Return a blob for inline preview / download const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' }); return res.data as Blob; + }, + async listByVehicle(vehicleId: string) { + const res = await apiClient.get(`/documents/by-vehicle/${vehicleId}`); + return res.data; + }, + async addSharedVehicle(docId: string, vehicleId: string) { + const res = await apiClient.post(`/documents/${docId}/vehicles/${vehicleId}`); + return res.data; + }, + async removeVehicleFromDocument(docId: string, vehicleId: string) { + await apiClient.delete(`/documents/${docId}/vehicles/${vehicleId}`); } }; diff --git a/frontend/src/features/documents/components/DocumentPreview.test.tsx b/frontend/src/features/documents/components/DocumentPreview.test.tsx index bf0a215..d0ac8d1 100644 --- a/frontend/src/features/documents/components/DocumentPreview.test.tsx +++ b/frontend/src/features/documents/components/DocumentPreview.test.tsx @@ -30,6 +30,7 @@ describe('DocumentPreview', () => { documentType: 'insurance', title: 'Insurance Document', contentType: 'application/pdf', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -41,6 +42,7 @@ describe('DocumentPreview', () => { documentType: 'registration', title: 'Registration Photo', contentType: 'image/jpeg', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -52,6 +54,7 @@ describe('DocumentPreview', () => { documentType: 'insurance', title: 'Text Document', contentType: 'text/plain', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; diff --git a/frontend/src/features/documents/hooks/useDocuments.ts b/frontend/src/features/documents/hooks/useDocuments.ts index 17c1ba4..6f12797 100644 --- a/frontend/src/features/documents/hooks/useDocuments.ts +++ b/frontend/src/features/documents/hooks/useDocuments.ts @@ -50,6 +50,9 @@ export function useCreateDocument() { fileHash: null, issuedDate: newDocument.issuedDate || null, expirationDate: newDocument.expirationDate || null, + emailNotifications: newDocument.emailNotifications, + scanForMaintenance: newDocument.scanForMaintenance, + sharedVehicleIds: newDocument.sharedVehicleIds || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), deletedAt: null, @@ -225,3 +228,123 @@ export function useUploadDocument(id: string) { networkMode: 'offlineFirst', }); } + +export function useDocumentsByVehicle(vehicleId?: string) { + const query = useQuery({ + queryKey: ['documents-by-vehicle', vehicleId], + queryFn: () => documentsApi.listByVehicle(vehicleId!), + enabled: !!vehicleId, + networkMode: 'offlineFirst', + }); + return query; +} + +export function useAddSharedVehicle() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ docId, vehicleId }: { docId: string; vehicleId: string }) => + documentsApi.addSharedVehicle(docId, vehicleId), + onMutate: async ({ docId, vehicleId }) => { + // Cancel outgoing refetches + await qc.cancelQueries({ queryKey: ['document', docId] }); + await qc.cancelQueries({ queryKey: ['documents'] }); + await qc.cancelQueries({ queryKey: ['documents-by-vehicle'] }); + + // Snapshot previous values + const previousDocument = qc.getQueryData(['document', docId]); + const previousDocuments = qc.getQueryData(['documents']); + + // Optimistically update individual document + qc.setQueryData(['document', docId], (old: DocumentRecord | undefined) => { + if (!old) return old; + return { + ...old, + sharedVehicleIds: [...old.sharedVehicleIds, vehicleId], + updatedAt: new Date().toISOString(), + }; + }); + + // Optimistically update documents list + qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => { + if (!old) return old; + return old.map(doc => + doc.id === docId + ? { ...doc, sharedVehicleIds: [...doc.sharedVehicleIds, vehicleId], updatedAt: new Date().toISOString() } + : doc + ); + }); + + return { previousDocument, previousDocuments }; + }, + onError: (_err, { docId }, context) => { + // Rollback on error + if (context?.previousDocument) { + qc.setQueryData(['document', docId], context.previousDocument); + } + if (context?.previousDocuments) { + qc.setQueryData(['documents'], context.previousDocuments); + } + }, + onSettled: () => { + // Refetch to ensure consistency + qc.invalidateQueries({ queryKey: ['documents'] }); + qc.invalidateQueries({ queryKey: ['documents-by-vehicle'] }); + }, + networkMode: 'offlineFirst', + }); +} + +export function useRemoveVehicleFromDocument() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ docId, vehicleId }: { docId: string; vehicleId: string }) => + documentsApi.removeVehicleFromDocument(docId, vehicleId), + onMutate: async ({ docId, vehicleId }) => { + // Cancel outgoing refetches + await qc.cancelQueries({ queryKey: ['document', docId] }); + await qc.cancelQueries({ queryKey: ['documents'] }); + await qc.cancelQueries({ queryKey: ['documents-by-vehicle'] }); + + // Snapshot previous values + const previousDocument = qc.getQueryData(['document', docId]); + const previousDocuments = qc.getQueryData(['documents']); + + // Optimistically update individual document + qc.setQueryData(['document', docId], (old: DocumentRecord | undefined) => { + if (!old) return old; + return { + ...old, + sharedVehicleIds: old.sharedVehicleIds.filter(id => id !== vehicleId), + updatedAt: new Date().toISOString(), + }; + }); + + // Optimistically update documents list + qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => { + if (!old) return old; + return old.map(doc => + doc.id === docId + ? { ...doc, sharedVehicleIds: doc.sharedVehicleIds.filter(id => id !== vehicleId), updatedAt: new Date().toISOString() } + : doc + ); + }); + + return { previousDocument, previousDocuments }; + }, + onError: (_err, { docId }, context) => { + // Rollback on error + if (context?.previousDocument) { + qc.setQueryData(['document', docId], context.previousDocument); + } + if (context?.previousDocuments) { + qc.setQueryData(['documents'], context.previousDocuments); + } + }, + onSettled: () => { + // Refetch to ensure consistency + qc.invalidateQueries({ queryKey: ['documents'] }); + qc.invalidateQueries({ queryKey: ['documents-by-vehicle'] }); + }, + networkMode: 'offlineFirst', + }); +} diff --git a/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx b/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx index 0cfd5c8..d2b3766 100644 --- a/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx +++ b/frontend/src/features/documents/mobile/DocumentsMobileScreen.test.tsx @@ -31,6 +31,7 @@ describe('DocumentsMobileScreen', () => { vehicleId: 'vehicle-1', documentType: 'insurance', title: 'Car Insurance', + sharedVehicleIds: [], createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }, @@ -40,6 +41,7 @@ describe('DocumentsMobileScreen', () => { vehicleId: 'vehicle-2', documentType: 'registration', title: 'Vehicle Registration', + sharedVehicleIds: [], createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }, diff --git a/frontend/src/features/documents/types/documents.types.ts b/frontend/src/features/documents/types/documents.types.ts index 71effdf..cefd38a 100644 --- a/frontend/src/features/documents/types/documents.types.ts +++ b/frontend/src/features/documents/types/documents.types.ts @@ -18,6 +18,7 @@ export interface DocumentRecord { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds: string[]; createdAt: string; updatedAt: string; deletedAt?: string | null; @@ -33,6 +34,7 @@ export interface CreateDocumentRequest { expirationDate?: string; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds?: string[]; } export interface UpdateDocumentRequest { @@ -43,5 +45,6 @@ export interface UpdateDocumentRequest { expirationDate?: string | null; emailNotifications?: boolean; scanForMaintenance?: boolean; + sharedVehicleIds?: string[]; }