Re_Figma_Code/src/components/workflow/CreateRequest/DocumentsStep.tsx

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>
);
}