feat: add documents, goals, and referrals UI
Some checks failed
CI/CD / test (push) Failing after 26s
CI/CD / deploy (push) Has been skipped

- 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:
2026-01-30 04:41:29 +00:00
parent b0cfa0ab1b
commit f042c910ee
6 changed files with 1060 additions and 3 deletions

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