feat: add documents, goals, and referrals UI
- ClientDocuments tab with drag-and-drop upload, category filter, download/delete - ClientGoals tab with progress bars, status badges, add/edit/complete - ClientReferrals tab with given/received views, client search, status management - Dashboard widgets: goals overview and referral leaderboard - API client methods for all new endpoints
This commit is contained in:
203
src/components/ClientDocuments.tsx
Normal file
203
src/components/ClientDocuments.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
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 {}
|
||||
setLoading(false);
|
||||
}, [clientId, category]);
|
||||
|
||||
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: any) {
|
||||
alert(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 {}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user