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 <noreply@anthropic.com>
This commit is contained in:
@@ -47,5 +47,16 @@ export const documentsApi = {
|
|||||||
// Return a blob for inline preview / download
|
// Return a blob for inline preview / download
|
||||||
const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' });
|
const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' });
|
||||||
return res.data as Blob;
|
return res.data as Blob;
|
||||||
|
},
|
||||||
|
async listByVehicle(vehicleId: string) {
|
||||||
|
const res = await apiClient.get<DocumentRecord[]>(`/documents/by-vehicle/${vehicleId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
async addSharedVehicle(docId: string, vehicleId: string) {
|
||||||
|
const res = await apiClient.post<DocumentRecord>(`/documents/${docId}/vehicles/${vehicleId}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
async removeVehicleFromDocument(docId: string, vehicleId: string) {
|
||||||
|
await apiClient.delete(`/documents/${docId}/vehicles/${vehicleId}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('DocumentPreview', () => {
|
|||||||
documentType: 'insurance',
|
documentType: 'insurance',
|
||||||
title: 'Insurance Document',
|
title: 'Insurance Document',
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
@@ -41,6 +42,7 @@ describe('DocumentPreview', () => {
|
|||||||
documentType: 'registration',
|
documentType: 'registration',
|
||||||
title: 'Registration Photo',
|
title: 'Registration Photo',
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
@@ -52,6 +54,7 @@ describe('DocumentPreview', () => {
|
|||||||
documentType: 'insurance',
|
documentType: 'insurance',
|
||||||
title: 'Text Document',
|
title: 'Text Document',
|
||||||
contentType: 'text/plain',
|
contentType: 'text/plain',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ export function useCreateDocument() {
|
|||||||
fileHash: null,
|
fileHash: null,
|
||||||
issuedDate: newDocument.issuedDate || null,
|
issuedDate: newDocument.issuedDate || null,
|
||||||
expirationDate: newDocument.expirationDate || null,
|
expirationDate: newDocument.expirationDate || null,
|
||||||
|
emailNotifications: newDocument.emailNotifications,
|
||||||
|
scanForMaintenance: newDocument.scanForMaintenance,
|
||||||
|
sharedVehicleIds: newDocument.sharedVehicleIds || [],
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
@@ -225,3 +228,123 @@ export function useUploadDocument(id: string) {
|
|||||||
networkMode: 'offlineFirst',
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ describe('DocumentsMobileScreen', () => {
|
|||||||
vehicleId: 'vehicle-1',
|
vehicleId: 'vehicle-1',
|
||||||
documentType: 'insurance',
|
documentType: 'insurance',
|
||||||
title: 'Car Insurance',
|
title: 'Car Insurance',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-01T00:00:00Z',
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
updatedAt: '2024-01-01T00:00:00Z',
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
},
|
},
|
||||||
@@ -40,6 +41,7 @@ describe('DocumentsMobileScreen', () => {
|
|||||||
vehicleId: 'vehicle-2',
|
vehicleId: 'vehicle-2',
|
||||||
documentType: 'registration',
|
documentType: 'registration',
|
||||||
title: 'Vehicle Registration',
|
title: 'Vehicle Registration',
|
||||||
|
sharedVehicleIds: [],
|
||||||
createdAt: '2024-01-02T00:00:00Z',
|
createdAt: '2024-01-02T00:00:00Z',
|
||||||
updatedAt: '2024-01-02T00:00:00Z',
|
updatedAt: '2024-01-02T00:00:00Z',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface DocumentRecord {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt?: string | null;
|
deletedAt?: string | null;
|
||||||
@@ -33,6 +34,7 @@ export interface CreateDocumentRequest {
|
|||||||
expirationDate?: string;
|
expirationDate?: string;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDocumentRequest {
|
export interface UpdateDocumentRequest {
|
||||||
@@ -43,5 +45,6 @@ export interface UpdateDocumentRequest {
|
|||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
emailNotifications?: boolean;
|
emailNotifications?: boolean;
|
||||||
scanForMaintenance?: boolean;
|
scanForMaintenance?: boolean;
|
||||||
|
sharedVehicleIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user