- Fix catch blocks with inline comments that broke parsing - Remove unused Edit3 import from ClientReferrals - Add eslint-disable for set-state-in-effect (intentional fetch-on-mount pattern) - Fix 'e' is of type 'unknown' errors with instanceof checks
205 lines
8.3 KiB
TypeScript
205 lines
8.3 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { api, type ClientDocument } from '@/lib/api';
|
|
import { FileText, Upload, Trash2, Download, File, FileImage, FileSpreadsheet, Filter } from 'lucide-react';
|
|
import { formatDate } from '@/lib/utils';
|
|
|
|
const CATEGORIES = [
|
|
{ value: '', label: 'All' },
|
|
{ value: 'contract', label: 'Contract' },
|
|
{ value: 'agreement', label: 'Agreement' },
|
|
{ value: 'id', label: 'ID Copy' },
|
|
{ value: 'statement', label: 'Statement' },
|
|
{ value: 'correspondence', label: 'Correspondence' },
|
|
{ value: 'other', label: 'Other' },
|
|
];
|
|
|
|
const categoryColors: Record<string, string> = {
|
|
contract: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
|
agreement: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
|
id: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
|
statement: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
|
correspondence: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
|
other: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
|
};
|
|
|
|
function fileIcon(mimeType: string) {
|
|
if (mimeType.startsWith('image/')) return <FileImage className="w-5 h-5 text-pink-500" />;
|
|
if (mimeType.includes('spreadsheet') || mimeType.includes('csv') || mimeType.includes('excel'))
|
|
return <FileSpreadsheet className="w-5 h-5 text-green-500" />;
|
|
if (mimeType.includes('pdf')) return <FileText className="w-5 h-5 text-red-500" />;
|
|
return <File className="w-5 h-5 text-slate-400" />;
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export default function ClientDocuments({ clientId }: { clientId: string }) {
|
|
const [documents, setDocuments] = useState<ClientDocument[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [category, setCategory] = useState('');
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const [uploadCategory, setUploadCategory] = useState('other');
|
|
|
|
const fetchDocs = useCallback(async () => {
|
|
try {
|
|
const docs = await api.getClientDocuments(clientId, category || undefined);
|
|
setDocuments(docs);
|
|
} catch { /* silently handled */ }
|
|
setLoading(false);
|
|
}, [clientId, category]);
|
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
useEffect(() => { fetchDocs(); }, [fetchDocs]);
|
|
|
|
const handleUpload = async (files: FileList | File[]) => {
|
|
setUploading(true);
|
|
try {
|
|
for (const file of Array.from(files)) {
|
|
await api.uploadDocument(clientId, file, { category: uploadCategory });
|
|
}
|
|
await fetchDocs();
|
|
} catch (e: unknown) {
|
|
alert(e instanceof Error ? e.message : 'Upload failed');
|
|
}
|
|
setUploading(false);
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files);
|
|
};
|
|
|
|
const handleDelete = async (docId: string) => {
|
|
if (!confirm('Delete this document?')) return;
|
|
try {
|
|
await api.deleteDocument(docId);
|
|
setDocuments(prev => prev.filter(d => d.id !== docId));
|
|
} catch { /* silently handled */ }
|
|
};
|
|
|
|
const handleDownload = (docId: string, name: string) => {
|
|
const token = localStorage.getItem('network-auth-token');
|
|
const url = api.getDocumentDownloadUrl(docId);
|
|
// Use fetch with auth header then download
|
|
fetch(url, {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
credentials: 'include',
|
|
})
|
|
.then(r => r.blob())
|
|
.then(blob => {
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = name;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Upload Area */}
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
|
dragOver
|
|
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
|
: 'border-slate-300 dark:border-slate-600 hover:border-blue-300 dark:hover:border-blue-500'
|
|
}`}
|
|
>
|
|
<Upload className="w-8 h-8 mx-auto text-slate-400 mb-2" />
|
|
<p className="text-sm text-slate-600 dark:text-slate-300 mb-2">
|
|
{uploading ? 'Uploading...' : 'Drag & drop files here, or click to browse'}
|
|
</p>
|
|
<div className="flex items-center justify-center gap-3">
|
|
<select
|
|
value={uploadCategory}
|
|
onChange={e => setUploadCategory(e.target.value)}
|
|
className="text-xs px-2 py-1 border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300"
|
|
>
|
|
{CATEGORIES.filter(c => c.value).map(c => (
|
|
<option key={c.value} value={c.value}>{c.label}</option>
|
|
))}
|
|
</select>
|
|
<label className="cursor-pointer px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors">
|
|
Browse Files
|
|
<input
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={e => e.target.files && handleUpload(e.target.files)}
|
|
disabled={uploading}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-4 h-4 text-slate-400" />
|
|
<div className="flex gap-1.5 flex-wrap">
|
|
{CATEGORIES.map(c => (
|
|
<button
|
|
key={c.value}
|
|
onClick={() => setCategory(c.value)}
|
|
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
|
category === c.value
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
|
}`}
|
|
>
|
|
{c.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Document List */}
|
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
|
{loading ? (
|
|
<p className="px-5 py-8 text-center text-sm text-slate-400">Loading...</p>
|
|
) : documents.length === 0 ? (
|
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No documents uploaded yet</p>
|
|
) : (
|
|
documents.map(doc => (
|
|
<div key={doc.id} className="flex items-center gap-3 px-5 py-3">
|
|
{fileIcon(doc.mimeType)}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{doc.name}</p>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${categoryColors[doc.category] || categoryColors.other}`}>
|
|
{doc.category}
|
|
</span>
|
|
<span className="text-xs text-slate-400">{formatSize(doc.size)}</span>
|
|
<span className="text-xs text-slate-400">{formatDate(doc.createdAt)}</span>
|
|
</div>
|
|
{doc.notes && <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 truncate">{doc.notes}</p>}
|
|
</div>
|
|
<button
|
|
onClick={() => handleDownload(doc.id, doc.name)}
|
|
className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-blue-600 transition-colors"
|
|
title="Download"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(doc.id)}
|
|
className="p-2 rounded-lg text-slate-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 transition-colors"
|
|
title="Delete"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|