Added Documents Feature

This commit is contained in:
Eric Gullickson
2025-09-28 20:35:46 -05:00
parent 2e1b588270
commit 775a1ff69e
66 changed files with 5655 additions and 944 deletions

View File

@@ -2,7 +2,7 @@
* @ai-summary Main app component with routing and mobile navigation
*/
import { useState, useEffect, useTransition, useCallback, lazy } from 'react';
import React, { 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';
@@ -13,6 +13,7 @@ import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import { md3Theme } from './shared-minimal/theme/md3Theme';
import { Layout } from './components/Layout';
import { UnitsProvider } from './core/units/UnitsContext';
@@ -22,8 +23,11 @@ const VehiclesPage = lazy(() => import('./features/vehicles/pages/VehiclesPage')
const VehicleDetailPage = lazy(() => import('./features/vehicles/pages/VehicleDetailPage').then(m => ({ default: m.VehicleDetailPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
const FuelLogsPage = lazy(() => import('./features/fuel-logs/pages/FuelLogsPage').then(m => ({ default: m.FuelLogsPage })));
const DocumentsPage = lazy(() => import('./features/documents/pages/DocumentsPage').then(m => ({ default: m.DocumentsPage })));
const DocumentDetailPage = lazy(() => import('./features/documents/pages/DocumentDetailPage').then(m => ({ default: m.DocumentDetailPage })));
const VehiclesMobileScreen = lazy(() => import('./features/vehicles/mobile/VehiclesMobileScreen').then(m => ({ default: m.VehiclesMobileScreen })));
const VehicleDetailMobile = lazy(() => import('./features/vehicles/mobile/VehicleDetailMobile').then(m => ({ default: m.VehicleDetailMobile })));
const DocumentsMobileScreen = lazy(() => import('./features/documents/mobile/DocumentsMobileScreen'));
import { BottomNavigation, NavigationItem } from './shared-minimal/components/mobile/BottomNavigation';
import { GlassCard } from './shared-minimal/components/mobile/GlassCard';
import { Button } from './shared-minimal/components/Button';
@@ -303,6 +307,7 @@ function App() {
{ key: "Dashboard", label: "Dashboard", icon: <HomeRoundedIcon /> },
{ key: "Vehicles", label: "Vehicles", icon: <DirectionsCarRoundedIcon /> },
{ key: "Log Fuel", label: "Log Fuel", icon: <LocalGasStationRoundedIcon /> },
{ key: "Documents", label: "Documents", icon: <DescriptionRoundedIcon /> },
{ key: "Settings", label: "Settings", icon: <SettingsRoundedIcon /> },
];
@@ -475,6 +480,34 @@ function App() {
</MobileErrorBoundary>
</motion.div>
)}
{activeScreen === "Documents" && (
<motion.div
key="documents"
initial={{opacity:0, y:8}}
animate={{opacity:1, y:0}}
exit={{opacity:0, y:-8}}
transition={{ duration: 0.2, ease: "easeOut" }}
>
<MobileErrorBoundary screenName="Documents">
<React.Suspense fallback={
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<div className="text-slate-500 py-6 text-center">
{(() => {
console.log('[App] Documents Suspense fallback triggered');
return 'Loading documents screen...';
})()}
</div>
</div>
</GlassCard>
</div>
}>
<DocumentsMobileScreen />
</React.Suspense>
</MobileErrorBoundary>
</motion.div>
)}
</AnimatePresence>
<DebugInfo />
</Layout>
@@ -516,6 +549,8 @@ function App() {
<Route path="/vehicles" element={<VehiclesPage />} />
<Route path="/vehicles/:id" element={<VehicleDetailPage />} />
<Route path="/fuel-logs" element={<FuelLogsPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/maintenance" element={<div>Maintenance (TODO)</div>} />
<Route path="/stations" element={<div>Stations (TODO)</div>} />
<Route path="/settings" element={<SettingsPage />} />

View File

@@ -12,6 +12,7 @@ import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRound
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close';
import { useAppStore } from '../core/store';
@@ -25,14 +26,23 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
const { user, logout } = useAuth0();
const { sidebarOpen, toggleSidebar } = useAppStore();
const { setSidebarOpen } = useAppStore.getState();
const location = useLocation();
const theme = useTheme();
// Ensure desktop has a visible navigation by default
React.useEffect(() => {
if (!mobileMode && !sidebarOpen) {
setSidebarOpen(true);
}
}, [mobileMode, sidebarOpen]);
const navigation = [
{ name: 'Vehicles', href: '/vehicles', icon: <DirectionsCarRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Fuel Logs', href: '/fuel-logs', icon: <LocalGasStationRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Maintenance', href: '/maintenance', icon: <BuildRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Gas Stations', href: '/stations', icon: <PlaceRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Documents', href: '/documents', icon: <DescriptionRoundedIcon sx={{ fontSize: 20 }} /> },
{ name: 'Settings', href: '/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
];

View File

@@ -33,8 +33,13 @@ export class MobileErrorBoundary extends React.Component<MobileErrorBoundaryProp
errorInfo
});
// Log error for debugging
console.error(`Mobile screen error in ${this.props.screenName}:`, error, errorInfo);
// Enhanced logging for debugging (temporary)
console.error(`[Mobile Error Boundary] Screen: ${this.props.screenName}`);
console.error(`[Mobile Error Boundary] Error message:`, error.message);
console.error(`[Mobile Error Boundary] Error stack:`, error.stack);
console.error(`[Mobile Error Boundary] Component stack:`, errorInfo.componentStack);
console.error(`[Mobile Error Boundary] Full error object:`, error);
console.error(`[Mobile Error Boundary] Full errorInfo object:`, errorInfo);
}
handleRetry = () => {

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { safeStorage } from '../utils/safe-storage';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Settings';
export type MobileScreen = 'Dashboard' | 'Vehicles' | 'Log Fuel' | 'Documents' | 'Settings';
export type VehicleSubScreen = 'list' | 'detail' | 'add' | 'edit';
interface NavigationHistory {
@@ -210,4 +210,4 @@ export const useNavigationStore = create<NavigationState>()(
},
}
)
);
);

View File

@@ -0,0 +1,51 @@
import { apiClient } from '../../../core/api/client';
import type { CreateDocumentRequest, DocumentRecord, UpdateDocumentRequest } from '../types/documents.types';
export const documentsApi = {
async list(params?: { vehicleId?: string; type?: string; expiresBefore?: string }) {
const res = await apiClient.get<DocumentRecord[]>('/documents', { params });
return res.data;
},
async get(id: string) {
const res = await apiClient.get<DocumentRecord>(`/documents/${id}`);
return res.data;
},
async create(payload: CreateDocumentRequest) {
const res = await apiClient.post<DocumentRecord>('/documents', payload);
return res.data;
},
async update(id: string, payload: UpdateDocumentRequest) {
const res = await apiClient.put<DocumentRecord>(`/documents/${id}`, payload);
return res.data;
},
async remove(id: string) {
await apiClient.delete(`/documents/${id}`);
},
async upload(id: string, file: File) {
const form = new FormData();
form.append('file', file);
const res = await apiClient.post<DocumentRecord>(`/documents/${id}/upload`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
},
async uploadWithProgress(id: string, file: File, onProgress?: (percent: number) => void) {
const form = new FormData();
form.append('file', file);
const res = await apiClient.post<DocumentRecord>(`/documents/${id}/upload`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (evt) => {
if (evt.total) {
const pct = Math.round((evt.loaded / evt.total) * 100);
onProgress?.(pct);
}
},
});
return res.data;
},
async download(id: string) {
// Return a blob for inline preview / download
const res = await apiClient.get(`/documents/${id}/download`, { responseType: 'blob' });
return res.data as Blob;
}
};

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { Dialog, DialogTitle, DialogContent, useMediaQuery } from '@mui/material';
import { DocumentForm } from './DocumentForm';
interface AddDocumentDialogProps {
open: boolean;
onClose: () => void;
}
export const AddDocumentDialog: React.FC<AddDocumentDialogProps> = ({ open, onClose }) => {
const isSmall = useMediaQuery('(max-width:600px)');
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth fullScreen={isSmall} PaperProps={{ sx: { maxHeight: '90vh' } }}>
<DialogTitle>Add Document</DialogTitle>
<DialogContent>
<div className="mt-2">
<DocumentForm onSuccess={onClose} onCancel={onClose} />
</div>
</DialogContent>
</Dialog>
);
};
export default AddDocumentDialog;

View File

@@ -0,0 +1,342 @@
import React from 'react';
import { Button } from '../../../shared-minimal/components/Button';
import { useCreateDocument } from '../hooks/useDocuments';
import { documentsApi } from '../api/documents.api';
import type { DocumentType } from '../types/documents.types';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
interface DocumentFormProps {
onSuccess?: () => void;
onCancel?: () => void;
}
export const DocumentForm: React.FC<DocumentFormProps> = ({ onSuccess, onCancel }) => {
const [documentType, setDocumentType] = React.useState<DocumentType>('insurance');
const [vehicleID, setVehicleID] = React.useState<string>('');
const [title, setTitle] = React.useState<string>('');
const [notes, setNotes] = React.useState<string>('');
// Insurance fields
const [insuranceCompany, setInsuranceCompany] = React.useState<string>('');
const [policyNumber, setPolicyNumber] = React.useState<string>('');
const [effectiveDate, setEffectiveDate] = React.useState<string>('');
const [expirationDate, setExpirationDate] = React.useState<string>('');
const [bodilyInjuryPerson, setBodilyInjuryPerson] = React.useState<string>('');
const [bodilyInjuryIncident, setBodilyInjuryIncident] = React.useState<string>('');
const [propertyDamage, setPropertyDamage] = React.useState<string>('');
const [premium, setPremium] = React.useState<string>('');
// Registration fields
const [licensePlate, setLicensePlate] = React.useState<string>('');
const [registrationExpirationDate, setRegistrationExpirationDate] = React.useState<string>('');
const [registrationCost, setRegistrationCost] = React.useState<string>('');
const [file, setFile] = React.useState<File | null>(null);
const [uploadProgress, setUploadProgress] = React.useState<number>(0);
const [error, setError] = React.useState<string | null>(null);
const { data: vehicles } = useVehicles();
const create = useCreateDocument();
const resetForm = () => {
setTitle('');
setNotes('');
setInsuranceCompany('');
setPolicyNumber('');
setEffectiveDate('');
setExpirationDate('');
setBodilyInjuryPerson('');
setBodilyInjuryIncident('');
setPropertyDamage('');
setPremium('');
setLicensePlate('');
setRegistrationExpirationDate('');
setRegistrationCost('');
setFile(null);
setUploadProgress(0);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!vehicleID) {
setError('Please select a vehicle.');
return;
}
if (!title.trim()) {
setError('Please enter a title.');
return;
}
try {
const details: Record<string, any> = {};
let issued_date: string | undefined;
let expiration_date: string | undefined;
if (documentType === 'insurance') {
details.insuranceCompany = insuranceCompany || undefined;
details.policyNumber = policyNumber || undefined;
details.bodilyInjuryPerson = bodilyInjuryPerson || undefined;
details.bodilyInjuryIncident = bodilyInjuryIncident || undefined;
details.propertyDamage = propertyDamage || undefined;
details.premium = premium ? parseFloat(premium) : undefined;
issued_date = effectiveDate || undefined;
expiration_date = expirationDate || undefined;
} else if (documentType === 'registration') {
details.licensePlate = licensePlate || undefined;
details.cost = registrationCost ? parseFloat(registrationCost) : undefined;
expiration_date = registrationExpirationDate || undefined;
}
const created = await create.mutateAsync({
vehicle_id: vehicleID,
document_type: documentType,
title: title.trim(),
notes: notes.trim() || undefined,
details,
issued_date,
expiration_date,
});
if (file) {
const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']);
if (!file.type || !allowed.has(file.type)) {
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
try {
await documentsApi.uploadWithProgress(created.id, file, (pct) => setUploadProgress(pct));
} catch (uploadErr: any) {
const status = uploadErr?.response?.status;
if (status === 415) {
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
setError(uploadErr?.message || 'Failed to upload file');
return;
}
}
resetForm();
onSuccess?.();
} catch (err: any) {
const status = err?.response?.status;
if (status === 415) {
setError('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
} else {
setError(err?.message || 'Failed to create document');
}
} finally {
setUploadProgress(0);
}
};
const vehicleLabel = (v: Vehicle) => {
if (v.nickname && v.nickname.trim().length > 0) return v.nickname.trim();
const parts = [v.year, v.make, v.model, v.trimLevel].filter(Boolean);
const primary = parts.join(' ').trim();
if (primary.length > 0) return primary;
if (v.vin && v.vin.length > 0) return v.vin;
return v.id.slice(0, 8) + '...';
};
return (
<form onSubmit={handleSubmit} className="w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Vehicle</label>
<select
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
value={vehicleID}
onChange={(e) => setVehicleID(e.target.value)}
required
>
<option value="">Select vehicle...</option>
{(vehicles || []).map((v: Vehicle) => (
<option key={v.id} value={v.id}>{vehicleLabel(v)}</option>
))}
</select>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Document Type</label>
<select
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
value={documentType}
onChange={(e) => setDocumentType(e.target.value as DocumentType)}
>
<option value="insurance">Insurance</option>
<option value="registration">Registration</option>
</select>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Title</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={title}
placeholder={documentType === 'insurance' ? 'e.g., Progressive Policy 2025' : 'e.g., Registration 2025'}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
{documentType === 'insurance' && (
<>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Insurance company</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={insuranceCompany}
onChange={(e) => setInsuranceCompany(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Policy number</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={policyNumber}
onChange={(e) => setPolicyNumber(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Effective Date</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Expiration Date</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Bodily Injury (Person)</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
placeholder="$25,000"
value={bodilyInjuryPerson}
onChange={(e) => setBodilyInjuryPerson(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Bodily Injury (Incident)</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
placeholder="$50,000"
value={bodilyInjuryIncident}
onChange={(e) => setBodilyInjuryIncident(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Property Damage</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
placeholder="$25,000"
value={propertyDamage}
onChange={(e) => setPropertyDamage(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Premium</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="number"
step="0.01"
placeholder="0.00"
value={premium}
onChange={(e) => setPremium(e.target.value)}
/>
</div>
</>
)}
{documentType === 'registration' && (
<>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">License Plate</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="text"
value={licensePlate}
onChange={(e) => setLicensePlate(e.target.value)}
/>
</div>
<div className="flex flex-col">
<label className="text-sm font-medium text-slate-700 mb-1">Expiration Date</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="date"
value={registrationExpirationDate}
onChange={(e) => setRegistrationExpirationDate(e.target.value)}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Cost</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="number"
step="0.01"
placeholder="0.00"
value={registrationCost}
onChange={(e) => setRegistrationCost(e.target.value)}
/>
</div>
</>
)}
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Notes</label>
<textarea
className="min-h-[88px] rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
<div className="flex flex-col md:col-span-2">
<label className="text-sm font-medium text-slate-700 mb-1">Upload image/PDF</label>
<input
className="h-11 min-h-[44px] rounded-lg border border-slate-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
type="file"
accept="image/jpeg,image/png,application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
/>
{uploadProgress > 0 && uploadProgress < 100 && (
<div className="text-sm text-slate-600 mt-1">Uploading... {uploadProgress}%</div>
)}
</div>
</div>
{error && (
<div className="text-red-600 text-sm mt-3">{error}</div>
)}
<div className="flex flex-col sm:flex-row gap-2 mt-4">
<Button type="submit" className="min-h-[44px]">Create Document</Button>
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
</div>
</form>
);
};
export default DocumentForm;

View File

@@ -0,0 +1,258 @@
/**
* @ai-summary Unit tests for DocumentPreview component
* @ai-context Tests image/PDF preview with mocked API calls
*/
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { DocumentPreview } from './DocumentPreview';
import { documentsApi } from '../api/documents.api';
import type { DocumentRecord } from '../types/documents.types';
// Mock the documents API
jest.mock('../api/documents.api');
const mockDocumentsApi = jest.mocked(documentsApi);
// Mock URL.createObjectURL and revokeObjectURL
const mockCreateObjectURL = jest.fn();
const mockRevokeObjectURL = jest.fn();
Object.defineProperty(global.URL, 'createObjectURL', {
value: mockCreateObjectURL,
});
Object.defineProperty(global.URL, 'revokeObjectURL', {
value: mockRevokeObjectURL,
});
describe('DocumentPreview', () => {
const mockPdfDocument: DocumentRecord = {
id: 'doc-1',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'insurance',
title: 'Insurance Document',
content_type: 'application/pdf',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const mockImageDocument: DocumentRecord = {
id: 'doc-2',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'registration',
title: 'Registration Photo',
content_type: 'image/jpeg',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
const mockNonPreviewableDocument: DocumentRecord = {
id: 'doc-3',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'insurance',
title: 'Text Document',
content_type: 'text/plain',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
jest.clearAllMocks();
mockCreateObjectURL.mockReturnValue('blob:http://localhost/test-blob');
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('PDF Preview', () => {
it('should render PDF preview for PDF documents', async () => {
const mockBlob = new Blob(['fake pdf content'], { type: 'application/pdf' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockPdfDocument} />);
// Should show loading initially
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
// Wait for the PDF object to appear
await waitFor(() => {
const pdfObject = screen.getByRole('application', { name: 'PDF Preview' });
expect(pdfObject).toBeInTheDocument();
expect(pdfObject).toHaveAttribute('data', 'blob:http://localhost/test-blob');
expect(pdfObject).toHaveAttribute('type', 'application/pdf');
});
expect(mockDocumentsApi.download).toHaveBeenCalledWith('doc-1');
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob);
});
it('should provide fallback link for PDF when object fails', async () => {
const mockBlob = new Blob(['fake pdf content'], { type: 'application/pdf' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
// Check that fallback link exists within the object element
const fallbackLink = screen.getByRole('link', { name: 'Open PDF' });
expect(fallbackLink).toBeInTheDocument();
expect(fallbackLink).toHaveAttribute('href', 'blob:http://localhost/test-blob');
expect(fallbackLink).toHaveAttribute('target', '_blank');
});
});
});
describe('Image Preview', () => {
it('should render image preview for image documents', async () => {
const mockBlob = new Blob(['fake image content'], { type: 'image/jpeg' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockImageDocument} />);
// Should show loading initially
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
// Wait for the image to appear
await waitFor(() => {
const image = screen.getByRole('img', { name: 'Registration Photo' });
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('src', 'blob:http://localhost/test-blob');
});
expect(mockDocumentsApi.download).toHaveBeenCalledWith('doc-2');
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob);
});
it('should have proper image styling', async () => {
const mockBlob = new Blob(['fake image content'], { type: 'image/jpeg' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={mockImageDocument} />);
await waitFor(() => {
const image = screen.getByRole('img');
expect(image).toHaveClass('max-w-full', 'h-auto', 'rounded-lg', 'border');
});
});
});
describe('Non-previewable Documents', () => {
it('should show no preview message for non-previewable documents', () => {
render(<DocumentPreview doc={mockNonPreviewableDocument} />);
expect(screen.getByText('No preview available.')).toBeInTheDocument();
expect(mockDocumentsApi.download).not.toHaveBeenCalled();
});
it('should not create blob URL for non-previewable documents', () => {
render(<DocumentPreview doc={mockNonPreviewableDocument} />);
expect(mockCreateObjectURL).not.toHaveBeenCalled();
});
});
describe('Error Handling', () => {
it('should show error message when download fails', async () => {
mockDocumentsApi.download.mockRejectedValue(new Error('Download failed'));
render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
expect(screen.getByText('Failed to load preview')).toBeInTheDocument();
});
expect(mockDocumentsApi.download).toHaveBeenCalledWith('doc-1');
expect(mockCreateObjectURL).not.toHaveBeenCalled();
});
it('should handle network errors gracefully', async () => {
mockDocumentsApi.download.mockRejectedValue(new Error('Network error'));
render(<DocumentPreview doc={mockImageDocument} />);
await waitFor(() => {
expect(screen.getByText('Failed to load preview')).toBeInTheDocument();
});
});
});
describe('Content Type Detection', () => {
it('should detect PDF from content type', () => {
render(<DocumentPreview doc={mockPdfDocument} />);
// PDF should be considered previewable
expect(screen.queryByText('No preview available.')).not.toBeInTheDocument();
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
});
it('should detect images from content type', () => {
render(<DocumentPreview doc={mockImageDocument} />);
// Image should be considered previewable
expect(screen.queryByText('No preview available.')).not.toBeInTheDocument();
expect(screen.getByText('Loading preview...')).toBeInTheDocument();
});
it('should handle PNG images', async () => {
const pngDocument = { ...mockImageDocument, content_type: 'image/png' };
const mockBlob = new Blob(['fake png content'], { type: 'image/png' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
render(<DocumentPreview doc={pngDocument} />);
await waitFor(() => {
const image = screen.getByRole('img');
expect(image).toBeInTheDocument();
});
});
it('should handle documents with undefined content type', () => {
const undefinedTypeDoc = { ...mockPdfDocument, content_type: undefined };
render(<DocumentPreview doc={undefinedTypeDoc} />);
expect(screen.getByText('No preview available.')).toBeInTheDocument();
});
});
describe('Memory Management', () => {
it('should clean up blob URL on unmount', async () => {
const mockBlob = new Blob(['fake content'], { type: 'application/pdf' });
mockDocumentsApi.download.mockResolvedValue(mockBlob);
const { unmount } = render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalled();
});
unmount();
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/test-blob');
});
it('should clean up blob URL when document changes', async () => {
const mockBlob1 = new Blob(['fake content 1'], { type: 'application/pdf' });
const mockBlob2 = new Blob(['fake content 2'], { type: 'image/jpeg' });
mockDocumentsApi.download
.mockResolvedValueOnce(mockBlob1)
.mockResolvedValueOnce(mockBlob2);
const { rerender } = render(<DocumentPreview doc={mockPdfDocument} />);
await waitFor(() => {
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob1);
});
// Change to different document
rerender(<DocumentPreview doc={mockImageDocument} />);
await waitFor(() => {
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/test-blob');
expect(mockCreateObjectURL).toHaveBeenCalledWith(mockBlob2);
});
});
});
});

View File

@@ -0,0 +1,60 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { DocumentRecord } from '../types/documents.types';
import { documentsApi } from '../api/documents.api';
import { GestureImageViewer } from './GestureImageViewer';
interface Props {
doc: DocumentRecord;
}
export const DocumentPreview: React.FC<Props> = ({ doc }) => {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const previewable = useMemo(() => {
return doc.content_type === 'application/pdf' || doc.content_type?.startsWith('image/');
}, [doc.content_type]);
useEffect(() => {
let revoked: string | null = null;
(async () => {
try {
if (!previewable) return;
const data = await documentsApi.download(doc.id);
const url = URL.createObjectURL(data);
setBlobUrl(url);
revoked = url;
} catch (e) {
setError('Failed to load preview');
}
})();
return () => {
if (revoked) URL.revokeObjectURL(revoked);
};
}, [doc.id, previewable]);
if (!previewable) return <div className="text-slate-500 text-sm">No preview available.</div>;
if (error) return <div className="text-red-600 text-sm">{error}</div>;
if (!blobUrl) return <div className="text-slate-500 text-sm">Loading preview...</div>;
if (doc.content_type === 'application/pdf') {
return (
<object data={blobUrl} type="application/pdf" className="w-full h-[60vh] rounded-lg border" aria-label="PDF Preview">
<a href={blobUrl} target="_blank" rel="noopener noreferrer">Open PDF</a>
</object>
);
}
return (
<div className="rounded-lg border overflow-hidden bg-gray-50">
<GestureImageViewer
src={blobUrl}
alt={doc.title}
className="max-w-full h-auto min-h-[300px] max-h-[80vh]"
/>
</div>
);
};
export default DocumentPreview;

View File

@@ -0,0 +1,282 @@
import React, { useRef, useState, useCallback, useEffect } from 'react';
interface Props {
src: string;
alt: string;
className?: string;
}
interface Transform {
scale: number;
translateX: number;
translateY: number;
}
export const GestureImageViewer: React.FC<Props> = ({ src, alt, className = '' }) => {
const imageRef = useRef<HTMLImageElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [transform, setTransform] = useState<Transform>({
scale: 1,
translateX: 0,
translateY: 0,
});
const [isGestureActive, setIsGestureActive] = useState(false);
const lastTouchRef = useRef<{ touches: React.TouchList; time: number } | null>(null);
const initialTransformRef = useRef<Transform>({ scale: 1, translateX: 0, translateY: 0 });
const initialDistanceRef = useRef<number>(0);
const initialCenterRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
// Calculate distance between two touch points
const getDistance = useCallback((touches: React.TouchList): number => {
if (touches.length < 2) return 0;
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}, []);
// Calculate center point between two touches
const getCenter = useCallback((touches: React.TouchList): { x: number; y: number } => {
if (touches.length === 1) {
return { x: touches[0].clientX, y: touches[0].clientY };
}
if (touches.length >= 2) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
}
return { x: 0, y: 0 };
}, []);
// Get bounding box relative coordinates
const getRelativeCoordinates = useCallback((clientX: number, clientY: number) => {
if (!containerRef.current) return { x: 0, y: 0 };
const rect = containerRef.current.getBoundingClientRect();
return {
x: clientX - rect.left,
y: clientY - rect.top,
};
}, []);
// Constrain transform to prevent image from moving too far out of bounds
const constrainTransform = useCallback((newTransform: Transform): Transform => {
if (!containerRef.current || !imageRef.current) return newTransform;
const containerRect = containerRef.current.getBoundingClientRect();
const imageRect = imageRef.current.getBoundingClientRect();
const scaledWidth = imageRect.width * newTransform.scale;
const scaledHeight = imageRect.height * newTransform.scale;
// Calculate maximum allowed translation
const maxTranslateX = Math.max(0, (scaledWidth - containerRect.width) / 2);
const maxTranslateY = Math.max(0, (scaledHeight - containerRect.height) / 2);
return {
scale: Math.max(0.5, Math.min(5, newTransform.scale)), // Constrain scale between 0.5x and 5x
translateX: Math.max(-maxTranslateX, Math.min(maxTranslateX, newTransform.translateX)),
translateY: Math.max(-maxTranslateY, Math.min(maxTranslateY, newTransform.translateY)),
};
}, []);
// Handle touch start
const handleTouchStart = useCallback((e: React.TouchEvent) => {
e.preventDefault();
setIsGestureActive(true);
const touches = e.touches;
initialTransformRef.current = transform;
if (touches.length === 2) {
// Pinch gesture
initialDistanceRef.current = getDistance(touches);
const center = getCenter(touches);
initialCenterRef.current = getRelativeCoordinates(center.x, center.y);
} else if (touches.length === 1) {
// Pan gesture or tap
const center = getCenter(touches);
initialCenterRef.current = getRelativeCoordinates(center.x, center.y);
// Track for double-tap detection
const now = Date.now();
const lastTouch = lastTouchRef.current;
if (lastTouch && now - lastTouch.time < 300 && touches.length === 1) {
// Double tap - toggle zoom
const isZoomedIn = transform.scale > 1.1;
const newScale = isZoomedIn ? 1 : 2;
if (isZoomedIn) {
// Reset to original
setTransform({ scale: 1, translateX: 0, translateY: 0 });
} else {
// Zoom to double-tap location
const center = getRelativeCoordinates(touches[0].clientX, touches[0].clientY);
setTransform({
scale: newScale,
translateX: (containerRef.current!.clientWidth / 2 - center.x) * (newScale - 1),
translateY: (containerRef.current!.clientHeight / 2 - center.y) * (newScale - 1),
});
}
lastTouchRef.current = null;
return;
}
lastTouchRef.current = { touches, time: now };
}
}, [transform, getDistance, getCenter, getRelativeCoordinates]);
// Handle touch move
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isGestureActive) return;
e.preventDefault();
const touches = e.touches;
if (touches.length === 2) {
// Pinch zoom
const currentDistance = getDistance(touches);
const center = getCenter(touches);
const currentCenter = getRelativeCoordinates(center.x, center.y);
if (initialDistanceRef.current > 0) {
const scaleChange = currentDistance / initialDistanceRef.current;
const newScale = initialTransformRef.current.scale * scaleChange;
// Calculate translation to keep zoom centered on pinch point
const scaleDiff = newScale - initialTransformRef.current.scale;
const centerOffsetX = currentCenter.x - containerRef.current!.clientWidth / 2;
const centerOffsetY = currentCenter.y - containerRef.current!.clientHeight / 2;
const newTransform: Transform = {
scale: newScale,
translateX: initialTransformRef.current.translateX - centerOffsetX * scaleDiff,
translateY: initialTransformRef.current.translateY - centerOffsetY * scaleDiff,
};
setTransform(constrainTransform(newTransform));
}
} else if (touches.length === 1 && transform.scale > 1) {
// Pan when zoomed in
const currentCenter = getRelativeCoordinates(touches[0].clientX, touches[0].clientY);
const deltaX = currentCenter.x - initialCenterRef.current.x;
const deltaY = currentCenter.y - initialCenterRef.current.y;
const newTransform: Transform = {
scale: transform.scale,
translateX: initialTransformRef.current.translateX + deltaX,
translateY: initialTransformRef.current.translateY + deltaY,
};
setTransform(constrainTransform(newTransform));
}
}, [isGestureActive, transform.scale, getDistance, getCenter, getRelativeCoordinates, constrainTransform]);
// Handle touch end
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 0) {
setIsGestureActive(false);
}
}, []);
// Handle wheel zoom for desktop
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const centerX = e.clientX - rect.left;
const centerY = e.clientY - rect.top;
const newScale = transform.scale * delta;
const scaleDiff = newScale - transform.scale;
const centerOffsetX = centerX - rect.width / 2;
const centerOffsetY = centerY - rect.height / 2;
const newTransform: Transform = {
scale: newScale,
translateX: transform.translateX - centerOffsetX * scaleDiff,
translateY: transform.translateY - centerOffsetY * scaleDiff,
};
setTransform(constrainTransform(newTransform));
}, [transform, constrainTransform]);
// Reset transform when image changes
useEffect(() => {
setTransform({ scale: 1, translateX: 0, translateY: 0 });
}, [src]);
// Set appropriate touch-action CSS to manage touch behavior
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Use CSS touch-action instead of global preventDefault
// This allows normal scrolling when not actively gesturing
if (isGestureActive) {
container.style.touchAction = 'none';
} else {
container.style.touchAction = 'manipulation';
}
return () => {
if (container) {
container.style.touchAction = '';
}
};
}, [isGestureActive]);
const transformStyle = {
transform: `scale(${transform.scale}) translate(${transform.translateX}px, ${transform.translateY}px)`,
transformOrigin: 'center center',
transition: isGestureActive ? 'none' : 'transform 0.2s ease-out',
};
return (
<div
ref={containerRef}
className={`relative overflow-hidden touch-none select-none ${className}`}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onWheel={handleWheel}
style={{
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
touchAction: isGestureActive ? 'none' : 'manipulation',
}}
>
<img
ref={imageRef}
src={src}
alt={alt}
style={transformStyle}
className="w-full h-auto object-contain pointer-events-none"
draggable={false}
/>
{/* Reset button for zoomed images */}
{transform.scale > 1.1 && (
<button
onClick={() => setTransform({ scale: 1, translateX: 0, translateY: 0 })}
className="absolute top-4 right-4 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm font-medium backdrop-blur-sm hover:bg-opacity-70 transition-colors"
aria-label="Reset zoom"
>
Reset
</button>
)}
{/* Instructions overlay for first-time users */}
{transform.scale === 1 && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-xs backdrop-blur-sm pointer-events-none">
Pinch to zoom Double-tap to zoom Drag to pan
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,227 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { documentsApi } from '../api/documents.api';
import type { CreateDocumentRequest, UpdateDocumentRequest, DocumentRecord } from '../types/documents.types';
export function useDocumentsList(filters?: { vehicleId?: string; type?: string; expiresBefore?: string }) {
const queryKey = ['documents', filters];
const query = useQuery({
queryKey,
queryFn: () => documentsApi.list(filters),
networkMode: 'offlineFirst',
});
return query;
}
export function useDocument(id?: string) {
const query = useQuery({
queryKey: ['document', id],
queryFn: () => documentsApi.get(id!),
enabled: !!id,
networkMode: 'offlineFirst',
});
return query;
}
export function useCreateDocument() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateDocumentRequest) => documentsApi.create(payload),
onMutate: async (newDocument) => {
// Cancel any outgoing refetches to avoid overwriting optimistic update
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous value
const previousDocuments = qc.getQueryData(['documents']);
// Create optimistic document record
const optimisticDocument: DocumentRecord = {
id: `temp-${Date.now()}`, // Temporary ID
user_id: '', // Will be filled by server
vehicle_id: newDocument.vehicle_id,
document_type: newDocument.document_type,
title: newDocument.title,
notes: newDocument.notes || null,
details: newDocument.details || null,
storage_bucket: null,
storage_key: null,
file_name: null,
content_type: null,
file_size: null,
file_hash: null,
issued_date: newDocument.issued_date || null,
expiration_date: newDocument.expiration_date || null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
};
// Optimistically update cache
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
return old ? [optimisticDocument, ...old] : [optimisticDocument];
});
// Return context object with rollback data
return { previousDocuments };
},
onError: (_err, _newDocument, context) => {
// Rollback to previous state on error
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Always refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}
export function useUpdateDocument(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: UpdateDocumentRequest) => documentsApi.update(id, payload),
onMutate: async (updateData) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', id] });
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', id]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update individual document
qc.setQueryData(['document', id], (old: DocumentRecord | undefined) => {
if (!old) return old;
return {
...old,
...updateData,
updated_at: new Date().toISOString(),
};
});
// Optimistically update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === id
? { ...doc, ...updateData, updated_at: new Date().toISOString() }
: doc
);
});
return { previousDocument, previousDocuments };
},
onError: (_err, _updateData, context) => {
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', id], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['document', id] });
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}
export function useDeleteDocument() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => documentsApi.remove(id),
onMutate: async (id) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['documents'] });
await qc.cancelQueries({ queryKey: ['document', id] });
// Snapshot previous values
const previousDocuments = qc.getQueryData(['documents']);
const previousDocument = qc.getQueryData(['document', id]);
// Optimistically remove from documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.filter(doc => doc.id !== id);
});
// Remove individual document from cache
qc.removeQueries({ queryKey: ['document', id] });
return { previousDocuments, previousDocument, deletedId: id };
},
onError: (_err, _id, context) => {
// Rollback on error
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
if (context?.previousDocument && context.deletedId) {
qc.setQueryData(['document', context.deletedId], context.previousDocument);
}
},
onSettled: () => {
// Refetch to ensure consistency
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}
export function useUploadDocument(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (file: File) => documentsApi.upload(id, file),
onMutate: async (file) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', id] });
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', id]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update with upload in progress state
const optimisticUpdate = {
file_name: file.name,
content_type: file.type,
file_size: file.size,
updated_at: new Date().toISOString(),
};
// Update individual document
qc.setQueryData(['document', id], (old: DocumentRecord | undefined) => {
if (!old) return old;
return { ...old, ...optimisticUpdate };
});
// Update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === id ? { ...doc, ...optimisticUpdate } : doc
);
});
return { previousDocument, previousDocuments };
},
onError: (_err, _file, context) => {
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', id], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
onSettled: () => {
// Refetch to get server state (including storage_bucket, storage_key, etc.)
qc.invalidateQueries({ queryKey: ['document', id] });
qc.invalidateQueries({ queryKey: ['documents'] });
},
networkMode: 'offlineFirst',
});
}

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { documentsApi } from '../api/documents.api';
import type { DocumentRecord } from '../types/documents.types';
export function useUploadWithProgress(documentId: string) {
const qc = useQueryClient();
const [progress, setProgress] = useState<number>(0);
const mutation = useMutation({
mutationFn: (file: File) => documentsApi.uploadWithProgress(documentId, file, setProgress),
onMutate: async (file) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['document', documentId] });
await qc.cancelQueries({ queryKey: ['documents'] });
// Snapshot previous values
const previousDocument = qc.getQueryData(['document', documentId]);
const previousDocuments = qc.getQueryData(['documents']);
// Optimistically update with upload in progress state
const optimisticUpdate = {
file_name: file.name,
content_type: file.type,
file_size: file.size,
updated_at: new Date().toISOString(),
};
// Update individual document
qc.setQueryData(['document', documentId], (old: DocumentRecord | undefined) => {
if (!old) return old;
return { ...old, ...optimisticUpdate };
});
// Update documents list
qc.setQueryData(['documents'], (old: DocumentRecord[] | undefined) => {
if (!old) return old;
return old.map(doc =>
doc.id === documentId ? { ...doc, ...optimisticUpdate } : doc
);
});
return { previousDocument, previousDocuments };
},
onSuccess: () => {
setProgress(0);
// Refetch to get complete server state
qc.invalidateQueries({ queryKey: ['document', documentId] });
qc.invalidateQueries({ queryKey: ['documents'] });
},
onError: (_err, _file, context) => {
setProgress(0);
// Rollback on error
if (context?.previousDocument) {
qc.setQueryData(['document', documentId], context.previousDocument);
}
if (context?.previousDocuments) {
qc.setQueryData(['documents'], context.previousDocuments);
}
},
networkMode: 'offlineFirst',
});
return {
...mutation,
progress,
resetProgress: () => setProgress(0),
};
}

View File

@@ -0,0 +1,409 @@
/**
* @ai-summary Unit tests for DocumentsMobileScreen component
* @ai-context Tests mobile UI with mocked hooks and navigation
*/
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { DocumentsMobileScreen } from './DocumentsMobileScreen';
import { useDocumentsList } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { useNavigate } from 'react-router-dom';
import type { DocumentRecord } from '../types/documents.types';
// Mock dependencies
jest.mock('../hooks/useDocuments');
jest.mock('../hooks/useUploadWithProgress');
jest.mock('react-router-dom');
const mockUseDocumentsList = jest.mocked(useDocumentsList);
const mockUseUploadWithProgress = jest.mocked(useUploadWithProgress);
const mockUseNavigate = jest.mocked(useNavigate);
describe('DocumentsMobileScreen', () => {
const mockNavigate = jest.fn();
const mockUploadMutate = jest.fn();
const mockDocuments: DocumentRecord[] = [
{
id: 'doc-1',
user_id: 'user-1',
vehicle_id: 'vehicle-1',
document_type: 'insurance',
title: 'Car Insurance',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: 'doc-2',
user_id: 'user-1',
vehicle_id: 'vehicle-2',
document_type: 'registration',
title: 'Vehicle Registration',
created_at: '2024-01-02T00:00:00Z',
updated_at: '2024-01-02T00:00:00Z',
},
];
beforeEach(() => {
jest.clearAllMocks();
mockUseNavigate.mockReturnValue(mockNavigate);
mockUseUploadWithProgress.mockReturnValue({
mutate: mockUploadMutate,
isPending: false,
progress: 0,
isSuccess: false,
isError: false,
error: null,
resetProgress: jest.fn(),
data: undefined,
variables: undefined,
isIdle: true,
status: 'idle',
mutateAsync: jest.fn(),
reset: jest.fn(),
} as any);
mockUseDocumentsList.mockReturnValue({
data: mockDocuments,
isLoading: false,
error: null,
refetch: jest.fn(),
isError: false,
isPending: false,
isSuccess: true,
status: 'success',
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
failureReason: null,
errorUpdateCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isInitialLoading: false,
isPlaceholderData: false,
isPaused: false,
isRefetching: false,
isRefetchError: false,
isLoadingError: false,
isStale: false,
} as any);
});
describe('Document List Display', () => {
it('should render documents list', () => {
render(<DocumentsMobileScreen />);
expect(screen.getByText('Documents')).toBeInTheDocument();
expect(screen.getByText('Car Insurance')).toBeInTheDocument();
expect(screen.getByText('Vehicle Registration')).toBeInTheDocument();
});
it('should display document metadata', () => {
render(<DocumentsMobileScreen />);
// Check document types and vehicle IDs are displayed
expect(screen.getByText(/insurance/)).toBeInTheDocument();
expect(screen.getByText(/registration/)).toBeInTheDocument();
expect(screen.getByText(/vehicle-1/)).toBeInTheDocument();
expect(screen.getByText(/vehicle-2/)).toBeInTheDocument();
});
it('should truncate long vehicle IDs', () => {
const longVehicleId = 'very-long-vehicle-id-that-should-be-truncated';
const documentsWithLongId = [
{
...mockDocuments[0],
vehicle_id: longVehicleId,
},
];
mockUseDocumentsList.mockReturnValue({
data: documentsWithLongId,
isLoading: false,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
// Should show truncated version
expect(screen.getByText(/very-lon\.\.\./)).toBeInTheDocument();
expect(screen.queryByText(longVehicleId)).not.toBeInTheDocument();
});
});
describe('Loading States', () => {
it('should show loading message', () => {
mockUseDocumentsList.mockReturnValue({
data: undefined,
isLoading: true,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should show error message', () => {
mockUseDocumentsList.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error('Failed to load'),
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('Failed to load documents')).toBeInTheDocument();
});
it('should handle empty documents list', () => {
mockUseDocumentsList.mockReturnValue({
data: [],
isLoading: false,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('Documents')).toBeInTheDocument();
// Should not crash with empty list
});
});
describe('Navigation', () => {
it('should navigate to document detail when Open is clicked', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByText('Open');
await user.click(openButtons[0]);
expect(mockNavigate).toHaveBeenCalledWith('/documents/doc-1');
});
it('should navigate to correct document for each Open button', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByText('Open');
await user.click(openButtons[0]);
expect(mockNavigate).toHaveBeenCalledWith('/documents/doc-1');
await user.click(openButtons[1]);
expect(mockNavigate).toHaveBeenCalledWith('/documents/doc-2');
});
});
describe('File Upload', () => {
let mockFileInput: HTMLInputElement;
beforeEach(() => {
// Mock file input element
mockFileInput = document.createElement('input');
mockFileInput.type = 'file';
mockFileInput.click = jest.fn();
jest.spyOn(document, 'createElement').mockReturnValue(mockFileInput as any);
});
it('should trigger file upload when Upload button is clicked', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const uploadButtons = screen.getAllByText('Upload');
await user.click(uploadButtons[0]);
// Should clear and click the hidden file input
expect(mockFileInput.value).toBe('');
expect(mockFileInput.click).toHaveBeenCalled();
});
it('should set correct document ID when upload button is clicked', async () => {
const user = userEvent.setup();
render(<DocumentsMobileScreen />);
const uploadButtons = screen.getAllByText('Upload');
await user.click(uploadButtons[1]); // Click second document's upload
// Verify the component tracks the current document ID
// This is tested indirectly through the file change handler
});
it('should handle file selection and upload', async () => {
render(<DocumentsMobileScreen />);
const uploadButtons = screen.getAllByText('Upload');
fireEvent.click(uploadButtons[0]);
// Simulate file selection
const file = new File(['test content'], 'test.pdf', { type: 'application/pdf' });
const fileInput = screen.getByRole('textbox', { hidden: true }) ||
document.querySelector('input[type="file"]') as HTMLInputElement;
if (fileInput) {
Object.defineProperty(fileInput, 'files', {
value: [file],
writable: false,
});
fireEvent.change(fileInput);
expect(mockUploadMutate).toHaveBeenCalledWith(file);
}
});
it('should show upload progress during upload', () => {
mockUseUploadWithProgress.mockReturnValue({
mutate: mockUploadMutate,
isPending: true,
progress: 45,
isSuccess: false,
isError: false,
error: null,
resetProgress: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
expect(screen.getByText('45%')).toBeInTheDocument();
});
it('should show progress only for the uploading document', async () => {
const user = userEvent.setup();
// Mock upload in progress for first document
mockUseUploadWithProgress.mockImplementation((docId: string) => ({
mutate: mockUploadMutate,
isPending: docId === 'doc-1',
progress: docId === 'doc-1' ? 75 : 0,
isSuccess: false,
isError: false,
error: null,
resetProgress: jest.fn(),
} as any));
render(<DocumentsMobileScreen />);
// Click upload for first document
const uploadButtons = screen.getAllByText('Upload');
await user.click(uploadButtons[0]);
// Should show progress for first document only
expect(screen.getByText('75%')).toBeInTheDocument();
// Should not show progress for other documents
const progressElements = screen.getAllByText(/\d+%/);
expect(progressElements).toHaveLength(1);
});
});
describe('File Input Configuration', () => {
it('should configure file input with correct accept types', () => {
render(<DocumentsMobileScreen />);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).toBeTruthy();
expect(fileInput!).toHaveAttribute('accept', 'image/*,application/pdf');
});
it('should hide file input from UI', () => {
render(<DocumentsMobileScreen />);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).toBeTruthy();
expect(fileInput!).toHaveClass('hidden');
});
});
describe('Document Cards Layout', () => {
it('should render documents in individual cards', () => {
render(<DocumentsMobileScreen />);
// Each document should be in its own bordered container
const documentCards = screen.getAllByRole('generic').filter(el =>
el.className.includes('border') && el.className.includes('rounded-xl')
);
expect(documentCards.length).toBeGreaterThan(0);
});
it('should display action buttons for each document', () => {
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByText('Open');
const uploadButtons = screen.getAllByText('Upload');
expect(openButtons).toHaveLength(mockDocuments.length);
expect(uploadButtons).toHaveLength(mockDocuments.length);
});
});
describe('Error Handling', () => {
it('should handle missing vehicle_id gracefully', () => {
const documentsWithMissingVehicle = [
{
...mockDocuments[0],
vehicle_id: null as any,
},
];
mockUseDocumentsList.mockReturnValue({
data: documentsWithMissingVehicle,
isLoading: false,
error: null,
refetch: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
// Should show placeholder for missing vehicle ID
expect(screen.getByText('—')).toBeInTheDocument();
});
it('should handle upload errors gracefully', () => {
mockUseUploadWithProgress.mockReturnValue({
mutate: mockUploadMutate,
isPending: false,
progress: 0,
isSuccess: false,
isError: true,
error: new Error('Upload failed'),
resetProgress: jest.fn(),
} as any);
render(<DocumentsMobileScreen />);
// Component should still render without crashing
expect(screen.getByText('Documents')).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have proper heading structure', () => {
render(<DocumentsMobileScreen />);
const heading = screen.getByRole('heading', { name: 'Documents' });
expect(heading).toBeInTheDocument();
expect(heading.tagName).toBe('H2');
});
it('should have accessible buttons', () => {
render(<DocumentsMobileScreen />);
const openButtons = screen.getAllByRole('button', { name: 'Open' });
const uploadButtons = screen.getAllByRole('button', { name: 'Upload' });
expect(openButtons).toHaveLength(mockDocuments.length);
expect(uploadButtons).toHaveLength(mockDocuments.length);
});
});
});

View File

@@ -0,0 +1,211 @@
import React, { useRef } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { isAxiosError } from 'axios';
import { useNavigate } from 'react-router-dom';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { useDocumentsList } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { Button } from '../../../shared-minimal/components/Button';
import { AddDocumentDialog } from '../components/AddDocumentDialog';
export const DocumentsMobileScreen: React.FC = () => {
console.log('[DocumentsMobileScreen] Component initializing');
// Auth is managed at App level; keep hook to support session-expired UI.
// In test environments without provider, fall back gracefully.
let auth = { isAuthenticated: true, isLoading: false, loginWithRedirect: () => {} } as any;
try {
auth = useAuth0();
} catch {
// Tests render without Auth0Provider; assume authenticated for unit tests.
}
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = auth;
// Data hooks (unconditional per React rules)
const { data, isLoading, error } = useDocumentsList();
const inputRef = useRef<HTMLInputElement | null>(null);
const [currentId, setCurrentId] = React.useState<string | null>(null);
const upload = useUploadWithProgress(currentId || '');
const navigate = useNavigate();
const [isAddOpen, setIsAddOpen] = React.useState(false);
const triggerUpload = (docId: string) => {
try {
setCurrentId(docId);
if (!inputRef.current) return;
inputRef.current.value = '';
inputRef.current.click();
} catch (error) {
console.error('[Documents Mobile] Upload trigger error:', error);
}
};
const onFileChange = () => {
try {
const file = inputRef.current?.files?.[0];
if (file && currentId) {
const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']);
if (!file.type || !allowed.has(file.type)) {
alert('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
upload.mutate(file);
}
} catch (error) {
console.error('[Documents Mobile] File change error:', error);
}
};
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="space-y-4">
<GlassCard>
<div className="p-4">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2>
<div className="text-slate-500 py-6 text-center">Loading...</div>
</div>
</GlassCard>
</div>
);
}
// Show login prompt when not authenticated
if (!isAuthenticated) {
return (
<div className="space-y-4">
<GlassCard>
<div className="p-6 text-center">
<div className="mb-4">
<div className="mx-auto w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Login Required</h3>
<p className="text-slate-600 text-sm mb-4">Please log in to view your documents</p>
<button
onClick={() => loginWithRedirect()}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Login to Continue
</button>
</div>
</GlassCard>
</div>
);
}
// Check for authentication error (401)
const isAuthError = error && isAxiosError(error) && error.response?.status === 401;
const hasError = !!error;
if (isAuthError) {
return (
<div className="space-y-4">
<GlassCard>
<div className="p-6 text-center">
<div className="mb-4">
<div className="mx-auto w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
<p className="text-slate-600 text-sm mb-4">Your session has expired. Please log in again.</p>
<button
onClick={() => loginWithRedirect()}
className="w-full px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors"
>
Login Again
</button>
</div>
</GlassCard>
</div>
);
}
return (
<div className="space-y-4">
<input ref={inputRef} type="file" accept="image/jpeg,image/png,application/pdf" capture="environment" className="hidden" onChange={onFileChange} />
<AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<GlassCard>
<div className="p-4">
<h2 className="text-lg font-semibold text-slate-800 mb-2">Documents</h2>
<div className="flex justify-end mb-2">
<Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button>
</div>
{isLoading && <div className="text-slate-500 py-6 text-center">Loading...</div>}
{hasError && !isAuthError && (
<div className="py-6 text-center">
<div className="mb-4">
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
</div>
<p className="text-red-600 text-sm mb-3">Failed to load documents</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm"
>
Retry
</button>
</div>
)}
{!isLoading && !hasError && data && data.length === 0 && (
<div className="py-8 text-center">
<div className="mb-4">
<div className="mx-auto w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
</div>
<p className="text-slate-600 text-sm mb-3">No documents yet</p>
<p className="text-slate-500 text-xs">Documents will appear here once you create them</p>
</div>
)}
{!isLoading && !hasError && data && data.length > 0 && (
<div className="space-y-3">
{data.map((doc) => {
const vehicleLabel = doc.vehicle_id ? `${doc.vehicle_id.slice(0, 8)}...` : '—';
return (
<div key={doc.id} className="flex items-center justify-between border rounded-xl p-3">
<div>
<div className="font-medium text-slate-800">{doc.title}</div>
<div className="text-xs text-slate-500">{doc.document_type} {vehicleLabel}</div>
</div>
<div className="flex gap-2 items-center">
<Button onClick={() => navigate(`/documents/${doc.id}`)}>Open</Button>
<Button onClick={() => triggerUpload(doc.id)}>Upload</Button>
{upload.isPending && currentId === doc.id && (
<span className="text-xs text-slate-500">{upload.progress}%</span>
)}
{upload.isError && currentId === doc.id && (
<span className="text-xs text-red-600">
{((upload.error as any)?.response?.status === 415)
? 'Unsupported file type. Use PDF, JPG/JPEG, PNG.'
: 'Upload failed'}
</span>
)}
</div>
</div>
);})}
</div>
)}
</div>
</GlassCard>
</div>
);
};
export default DocumentsMobileScreen;

View File

@@ -0,0 +1,168 @@
import React, { useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react';
import { isAxiosError } from 'axios';
import { Card } from '../../../shared-minimal/components/Card';
import { Button } from '../../../shared-minimal/components/Button';
import { useDocument } from '../hooks/useDocuments';
import { useUploadWithProgress } from '../hooks/useUploadWithProgress';
import { documentsApi } from '../api/documents.api';
import { DocumentPreview } from '../components/DocumentPreview';
export const DocumentDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
const { data: doc, isLoading, error } = useDocument(id);
const inputRef = useRef<HTMLInputElement | null>(null);
const upload = useUploadWithProgress(id!);
const handleDownload = async () => {
if (!id) return;
const blob = await documentsApi.download(id);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
};
const handleUpload = () => {
if (!inputRef.current) return;
inputRef.current.onchange = () => {
const file = inputRef.current?.files?.[0];
if (file && id) {
const allowed = new Set(['application/pdf', 'image/jpeg', 'image/png']);
if (!file.type || !allowed.has(file.type)) {
alert('Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.');
return;
}
upload.mutate(file);
}
};
inputRef.current.click();
};
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="container mx-auto p-4">
<div className="text-slate-500">Checking authentication...</div>
</div>
);
}
// Show login prompt when not authenticated
if (!isAuthenticated) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Authentication Required</h3>
<p className="text-slate-600 mb-6">Please log in to view this document</p>
<div className="space-x-3">
<Button onClick={() => loginWithRedirect()}>Login to Continue</Button>
<Button variant="secondary" onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</div>
</Card>
</div>
);
}
// Check for authentication error (401)
const isAuthError = error && isAxiosError(error) && error.response?.status === 401;
if (isAuthError) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
<p className="text-slate-600 mb-6">Your session has expired. Please log in again to continue.</p>
<div className="space-x-3">
<Button onClick={() => loginWithRedirect()}>Login Again</Button>
<Button variant="secondary" onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</div>
</Card>
</div>
);
}
if (isLoading) return <div className="container mx-auto p-4">Loading document...</div>;
if (error && !isAuthError) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Document Not Found</h3>
<p className="text-slate-600 mb-6">The document you're looking for could not be found.</p>
<div className="space-x-3">
<Button onClick={() => window.location.reload()}>Retry</Button>
<Button variant="secondary" onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</div>
</Card>
</div>
);
}
if (!doc) {
return (
<div className="container mx-auto p-4">
<Card>
<div className="p-8 text-center">
<h3 className="text-lg font-semibold text-slate-800 mb-2">Document Not Found</h3>
<p className="text-slate-600 mb-6">The document you're looking for does not exist.</p>
<Button onClick={() => navigate('/documents')}>Back to Documents</Button>
</div>
</Card>
</div>
);
}
return (
<div className="container mx-auto p-4">
<input ref={inputRef} type="file" accept="image/jpeg,image/png,application/pdf" capture="environment" className="hidden" />
<Card>
<div className="p-4 space-y-2">
<h2 className="text-xl font-semibold">{doc.title}</h2>
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
<div className="pt-2">
<DocumentPreview doc={doc} />
</div>
<div className="flex gap-2 pt-2">
<Button onClick={handleDownload}>Download</Button>
<Button onClick={handleUpload}>Upload/Replace</Button>
</div>
{upload.isPending && (
<div className="text-sm text-slate-600">Uploading... {upload.progress}%</div>
)}
{upload.isError && (
<div className="text-sm text-red-600">
{((upload.error as any)?.response?.status === 415)
? 'Unsupported file type. Allowed: PDF, JPG/JPEG, PNG.'
: 'Failed to upload file. Please try again.'}
</div>
)}
</div>
</Card>
</div>
);
};
export default DocumentDetailPage;

View File

@@ -0,0 +1,146 @@
import React from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { useDocumentsList, useDeleteDocument } from '../hooks/useDocuments';
import { Card } from '../../../shared-minimal/components/Card';
import { Button } from '../../../shared-minimal/components/Button';
import { useNavigate } from 'react-router-dom';
import { AddDocumentDialog } from '../components/AddDocumentDialog';
export const DocumentsPage: React.FC = () => {
const { isAuthenticated, isLoading: authLoading, loginWithRedirect } = useAuth0();
const { data, isLoading, error } = useDocumentsList();
const navigate = useNavigate();
const removeDoc = useDeleteDocument();
const [isAddOpen, setIsAddOpen] = React.useState(false);
// Show loading while auth is initializing
if (authLoading) {
return (
<div className="container mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Documents</h1>
<div className="flex items-center justify-center py-12">
<div className="text-slate-500">Checking authentication...</div>
</div>
</div>
);
}
// Show login prompt when not authenticated
if (!isAuthenticated) {
return (
<div className="container mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Documents</h1>
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Authentication Required</h3>
<p className="text-slate-600 mb-6">Please log in to view your documents</p>
<Button onClick={() => loginWithRedirect()}>
Login to Continue
</Button>
</div>
</Card>
</div>
);
}
// Check for authentication error (401)
const isAuthError = error && (error as any).response?.status === 401;
if (isAuthError) {
return (
<div className="container mx-auto p-4 space-y-4">
<h1 className="text-2xl font-semibold">Documents</h1>
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Session Expired</h3>
<p className="text-slate-600 mb-6">Your session has expired. Please log in again to continue.</p>
<Button onClick={() => loginWithRedirect()}>
Login Again
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="container mx-auto p-4 space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h1 className="text-2xl font-semibold">Documents</h1>
<div className="flex gap-2">
<Button onClick={() => setIsAddOpen(true)} className="min-h-[44px]">Add Document</Button>
</div>
</div>
<AddDocumentDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-slate-500">Loading documents...</div>
</div>
)}
{error && !isAuthError && (
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">Error Loading Documents</h3>
<p className="text-slate-600 mb-6">Failed to load documents. Please try again.</p>
<Button onClick={() => window.location.reload()}>
Retry
</Button>
</div>
</Card>
)}
{!isLoading && !error && data && data.length === 0 && (
<Card>
<div className="p-8 text-center">
<div className="mb-4">
<svg className="mx-auto w-16 h-16 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2">No Documents Yet</h3>
<p className="text-slate-600 mb-6">You haven't added any documents yet. Documents will appear here once you create them.</p>
<Button onClick={() => navigate('/vehicles')}>
Go to Vehicles
</Button>
</div>
</Card>
)}
{!isLoading && !error && data && data.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.map((doc) => (
<Card key={doc.id}>
<div className="p-4 space-y-2">
<div className="font-medium">{doc.title}</div>
<div className="text-sm text-slate-500">Type: {doc.document_type}</div>
<div className="text-sm text-slate-500">Vehicle: {doc.vehicle_id}</div>
<div className="flex gap-2 pt-2">
<Button onClick={() => navigate(`/documents/${doc.id}`)}>Open</Button>
<Button variant="danger" onClick={() => removeDoc.mutate(doc.id)}>Delete</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
};
export default DocumentsPage;

View File

@@ -0,0 +1,41 @@
export type DocumentType = 'insurance' | 'registration';
export interface DocumentRecord {
id: string;
user_id: string;
vehicle_id: string;
document_type: DocumentType;
title: string;
notes?: string | null;
details?: Record<string, any> | null;
storage_bucket?: string | null;
storage_key?: string | null;
file_name?: string | null;
content_type?: string | null;
file_size?: number | null;
file_hash?: string | null;
issued_date?: string | null;
expiration_date?: string | null;
created_at: string;
updated_at: string;
deleted_at?: string | null;
}
export interface CreateDocumentRequest {
vehicle_id: string;
document_type: DocumentType;
title: string;
notes?: string;
details?: Record<string, any>;
issued_date?: string;
expiration_date?: string;
}
export interface UpdateDocumentRequest {
title?: string;
notes?: string | null;
details?: Record<string, any>;
issued_date?: string | null;
expiration_date?: string | null;
}