308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import { motion } from 'framer-motion';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Upload, FileText, Eye, X, Plus } from 'lucide-react';
|
|
|
|
interface DocumentPolicy {
|
|
maxFileSizeMB: number;
|
|
allowedFileTypes: string[];
|
|
}
|
|
|
|
interface DocumentsStepProps {
|
|
documentPolicy: DocumentPolicy;
|
|
isEditing: boolean;
|
|
documents: File[];
|
|
existingDocuments: any[];
|
|
documentsToDelete: string[];
|
|
onDocumentsChange: (documents: File[]) => void;
|
|
onExistingDocumentsChange: (documents: any[]) => void;
|
|
onDocumentsToDeleteChange: (ids: string[]) => void;
|
|
onPreviewDocument: (doc: any, isExisting: boolean) => void;
|
|
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
|
|
fileInputRef: React.RefObject<HTMLInputElement>;
|
|
}
|
|
|
|
/**
|
|
* Component: DocumentsStep
|
|
*
|
|
* Purpose: Step 5 - Document upload and management
|
|
*
|
|
* Features:
|
|
* - File upload with validation
|
|
* - Preview existing documents
|
|
* - Delete documents
|
|
* - Test IDs for testing
|
|
*/
|
|
export function DocumentsStep({
|
|
documentPolicy,
|
|
isEditing,
|
|
documents,
|
|
existingDocuments,
|
|
documentsToDelete,
|
|
onDocumentsChange,
|
|
onExistingDocumentsChange: _onExistingDocumentsChange,
|
|
onDocumentsToDeleteChange,
|
|
onPreviewDocument,
|
|
onDocumentErrors,
|
|
fileInputRef
|
|
}: DocumentsStepProps) {
|
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(event.target.files || []);
|
|
if (files.length === 0) return;
|
|
|
|
// Validate files
|
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
|
const validFiles: File[] = [];
|
|
|
|
files.forEach(file => {
|
|
// Check file size
|
|
if (file.size > maxSizeBytes) {
|
|
validationErrors.push({
|
|
fileName: file.name,
|
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check file extension
|
|
const fileName = file.name.toLowerCase();
|
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
|
|
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
|
validationErrors.push({
|
|
fileName: file.name,
|
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
|
});
|
|
return;
|
|
}
|
|
|
|
validFiles.push(file);
|
|
});
|
|
|
|
// Update parent with valid files
|
|
if (validFiles.length > 0) {
|
|
onDocumentsChange([...documents, ...validFiles]);
|
|
}
|
|
|
|
// Show errors if any
|
|
if (validationErrors.length > 0 && onDocumentErrors) {
|
|
onDocumentErrors(validationErrors);
|
|
}
|
|
|
|
// Reset file input
|
|
if (event.target) {
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
const handleRemove = (index: number) => {
|
|
const newDocs = documents.filter((_, i) => i !== index);
|
|
onDocumentsChange(newDocs);
|
|
};
|
|
|
|
const handleDeleteExisting = (docId: string) => {
|
|
onDocumentsToDeleteChange([...documentsToDelete, docId]);
|
|
};
|
|
|
|
const canPreview = (doc: any, isExisting: boolean = false): boolean => {
|
|
if (isExisting) {
|
|
const type = (doc.fileType || doc.file_type || '').toLowerCase();
|
|
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
|
|
return type.includes('image') || type.includes('pdf') ||
|
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
|
name.endsWith('.pdf');
|
|
} else {
|
|
const type = (doc.type || '').toLowerCase();
|
|
const name = (doc.name || '').toLowerCase();
|
|
return type.includes('image') || type.includes('pdf') ||
|
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
|
name.endsWith('.pdf');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="space-y-6"
|
|
data-testid="documents-step"
|
|
>
|
|
<div className="text-center mb-8" data-testid="documents-header">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<Upload className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2" data-testid="documents-title">
|
|
Documents & Attachments
|
|
</h2>
|
|
<p className="text-gray-600" data-testid="documents-description">
|
|
Upload supporting documents, files, and any additional materials for your request.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-2xl mx-auto space-y-6" data-testid="documents-content">
|
|
<Card data-testid="documents-upload-card">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2" data-testid="documents-upload-title">
|
|
<FileText className="w-5 h-5" />
|
|
File Upload
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
|
|
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Drag and drop files here, or click to browse
|
|
</p>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
id="file-upload"
|
|
ref={fileInputRef}
|
|
data-testid="documents-file-input"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="lg"
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
data-testid="documents-browse-button"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Browse Files
|
|
</Button>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
Supported formats: {documentPolicy.allowedFileTypes.map(ext => ext.toUpperCase()).join(', ')} (Max {documentPolicy.maxFileSizeMB}MB per file)
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Existing Documents */}
|
|
{isEditing && existingDocuments.length > 0 && (
|
|
<Card data-testid="documents-existing-card">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between" data-testid="documents-existing-title">
|
|
<span>Existing Documents</span>
|
|
<Badge variant="secondary" data-testid="documents-existing-count">
|
|
{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length} file{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3" data-testid="documents-existing-list">
|
|
{existingDocuments.map((doc: any) => {
|
|
const docId = doc.documentId || doc.document_id || '';
|
|
const isDeleted = documentsToDelete.includes(docId);
|
|
if (isDeleted) return null;
|
|
|
|
return (
|
|
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<FileText className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{doc.originalFileName || doc.fileName || 'Document'}</p>
|
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
<span>{doc.fileSize ? (Number(doc.fileSize) / (1024 * 1024)).toFixed(2) + ' MB' : 'Unknown size'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canPreview(doc, true) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPreviewDocument(doc, true)}
|
|
data-testid={`documents-existing-${docId}-preview`}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteExisting(docId)}
|
|
data-testid={`documents-existing-${docId}-delete`}
|
|
>
|
|
<X className="h-4 w-4 text-red-600" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* New Documents */}
|
|
{documents.length > 0 && (
|
|
<Card data-testid="documents-new-card">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between" data-testid="documents-new-title">
|
|
<span>New Files to Upload</span>
|
|
<Badge variant="secondary" data-testid="documents-new-count">
|
|
{documents.length} file{documents.length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3" data-testid="documents-new-list">
|
|
{documents.map((file, index) => (
|
|
<div key={index} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border" data-testid={`documents-new-${index}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<FileText className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{file.name}</p>
|
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
<span>{(file.size / (1024 * 1024)).toFixed(2)} MB</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canPreview(file, false) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onPreviewDocument(file, false)}
|
|
data-testid={`documents-new-${index}-preview`}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRemove(index)}
|
|
data-testid={`documents-new-${index}-remove`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|