Added Documents Feature
This commit is contained in:
34
frontend/README.md
Normal file
34
frontend/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Frontend Quickload
|
||||
|
||||
## Overview
|
||||
- Tech: React 18, Vite, TypeScript, MUI, Tailwind, React Query, Zustand.
|
||||
- Auth: Auth0 via `src/core/auth/Auth0Provider.tsx`.
|
||||
- Data: API client in `src/core/api/client.ts` with React Query config.
|
||||
|
||||
## Commands (containers)
|
||||
- Build: `make rebuild`
|
||||
- Tests: `make test-frontend`
|
||||
- Logs: `make logs-frontend`
|
||||
|
||||
## Structure
|
||||
- `src/App.tsx`, `src/main.tsx` — app entry.
|
||||
- `src/features/*` — feature pages/components/hooks.
|
||||
- `src/core/*` — auth, api, store, hooks, query config, utils.
|
||||
- `src/shared-minimal/*` — shared UI components and theme.
|
||||
|
||||
## Mobile + Desktop (required)
|
||||
- Layouts responsive by default; validate on small/large viewports.
|
||||
- Verify Suspense fallbacks and navigation flows on both form factors.
|
||||
- Test key screens: Vehicles, Fuel Logs, Documents, Settings.
|
||||
- Ensure touch interactions and keyboard navigation work equivalently.
|
||||
|
||||
## Testing
|
||||
- Jest config: `frontend/jest.config.ts` (jsdom).
|
||||
- Setup: `frontend/setupTests.ts` (Testing Library).
|
||||
- Run: `make test-frontend` (containerized).
|
||||
|
||||
## Patterns
|
||||
- State: co-locate feature state in `src/core/store` (Zustand) and React Query for server state.
|
||||
- Forms: `react-hook-form` + Zod resolvers.
|
||||
- UI: MUI components; Tailwind for utility styling.
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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 }} /> },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>()(
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
);
|
||||
|
||||
51
frontend/src/features/documents/api/documents.api.ts
Normal file
51
frontend/src/features/documents/api/documents.api.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
342
frontend/src/features/documents/components/DocumentForm.tsx
Normal file
342
frontend/src/features/documents/components/DocumentForm.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
227
frontend/src/features/documents/hooks/useDocuments.ts
Normal file
227
frontend/src/features/documents/hooks/useDocuments.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
211
frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx
Normal file
211
frontend/src/features/documents/mobile/DocumentsMobileScreen.tsx
Normal 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;
|
||||
168
frontend/src/features/documents/pages/DocumentDetailPage.tsx
Normal file
168
frontend/src/features/documents/pages/DocumentDetailPage.tsx
Normal 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;
|
||||
146
frontend/src/features/documents/pages/DocumentsPage.tsx
Normal file
146
frontend/src/features/documents/pages/DocumentsPage.tsx
Normal 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;
|
||||
41
frontend/src/features/documents/types/documents.types.ts
Normal file
41
frontend/src/features/documents/types/documents.types.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -4,5 +4,13 @@
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noPropertyAccessFromIndexSignature": false
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"setupTests.ts",
|
||||
"jest.config.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user