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

View File

@@ -0,0 +1,300 @@
import { useEffect, useState, useCallback } from 'react';
import { api, type ClientGoal, type ClientGoalCreate } from '@/lib/api';
import { Target, Plus, Edit3, Trash2, CheckCircle2, AlertTriangle, Clock, TrendingUp } from 'lucide-react';
import { formatDate } from '@/lib/utils';
import Modal from './Modal';
const CATEGORIES = ['retirement', 'investment', 'savings', 'insurance', 'estate', 'education', 'debt', 'other'];
const STATUSES = ['on-track', 'at-risk', 'behind', 'completed'];
const PRIORITIES = ['high', 'medium', 'low'];
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string }> = {
'on-track': { icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
'at-risk': { icon: AlertTriangle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-100 dark:bg-amber-900/30' },
'behind': { icon: Clock, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-100 dark:bg-red-900/30' },
'completed': { icon: CheckCircle2, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-100 dark:bg-blue-900/30' },
};
const priorityColors: Record<string, string> = {
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
medium: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
low: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
};
function formatCurrency(val: string | null): string {
if (!val) return '$0';
const n = parseFloat(val);
if (isNaN(n)) return '$0';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
function progressPercent(current: string | null, target: string | null): number {
const c = parseFloat(current || '0');
const t = parseFloat(target || '0');
if (t <= 0) return 0;
return Math.min(100, Math.round((c / t) * 100));
}
interface GoalFormData {
title: string;
description: string;
category: string;
targetAmount: string;
currentAmount: string;
targetDate: string;
status: string;
priority: string;
}
const emptyForm: GoalFormData = {
title: '', description: '', category: 'other',
targetAmount: '', currentAmount: '', targetDate: '',
status: 'on-track', priority: 'medium',
};
export default function ClientGoals({ clientId }: { clientId: string }) {
const [goals, setGoals] = useState<ClientGoal[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingGoal, setEditingGoal] = useState<ClientGoal | null>(null);
const [form, setForm] = useState<GoalFormData>(emptyForm);
const [saving, setSaving] = useState(false);
const fetchGoals = useCallback(async () => {
try {
const data = await api.getClientGoals(clientId);
setGoals(data);
} catch {}
setLoading(false);
}, [clientId]);
useEffect(() => { fetchGoals(); }, [fetchGoals]);
const openAdd = () => {
setEditingGoal(null);
setForm(emptyForm);
setShowForm(true);
};
const openEdit = (goal: ClientGoal) => {
setEditingGoal(goal);
setForm({
title: goal.title,
description: goal.description || '',
category: goal.category,
targetAmount: goal.targetAmount || '',
currentAmount: goal.currentAmount || '',
targetDate: goal.targetDate ? goal.targetDate.split('T')[0] : '',
status: goal.status,
priority: goal.priority,
});
setShowForm(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
const data: ClientGoalCreate = {
title: form.title,
description: form.description || undefined,
category: form.category,
targetAmount: form.targetAmount || undefined,
currentAmount: form.currentAmount || undefined,
targetDate: form.targetDate || undefined,
status: form.status,
priority: form.priority,
};
if (editingGoal) {
await api.updateGoal(editingGoal.id, data);
} else {
await api.createGoal(clientId, data);
}
setShowForm(false);
await fetchGoals();
} catch (e: any) {
alert(e.message || 'Failed to save goal');
}
setSaving(false);
};
const handleDelete = async (goalId: string) => {
if (!confirm('Delete this goal?')) return;
try {
await api.deleteGoal(goalId);
setGoals(prev => prev.filter(g => g.id !== goalId));
} catch {}
};
const handleMarkComplete = async (goal: ClientGoal) => {
try {
await api.updateGoal(goal.id, { status: 'completed', currentAmount: goal.targetAmount || undefined });
await fetchGoals();
} catch {}
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Target className="w-5 h-5 text-blue-500" />
Financial Goals
</h3>
<button
onClick={openAdd}
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Add Goal
</button>
</div>
{loading ? (
<p className="text-center text-sm text-slate-400 py-8">Loading...</p>
) : goals.length === 0 ? (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-8 text-center">
<Target className="w-10 h-10 mx-auto text-slate-300 dark:text-slate-600 mb-3" />
<p className="text-sm text-slate-500 dark:text-slate-400">No goals set for this client yet</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{goals.map(goal => {
const pct = progressPercent(goal.currentAmount, goal.targetAmount);
const cfg = statusConfig[goal.status] || statusConfig['on-track'];
const StatusIcon = cfg.icon;
return (
<div key={goal.id} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 space-y-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">{goal.title}</h4>
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${priorityColors[goal.priority] || priorityColors.medium}`}>
{goal.priority}
</span>
</div>
{goal.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">{goal.description}</p>
)}
</div>
<div className="flex gap-1 ml-2">
{goal.status !== 'completed' && (
<button onClick={() => handleMarkComplete(goal)} className="p-1.5 rounded text-slate-400 hover:text-emerald-500 transition-colors" title="Mark complete">
<CheckCircle2 className="w-4 h-4" />
</button>
)}
<button onClick={() => openEdit(goal)} className="p-1.5 rounded text-slate-400 hover:text-blue-500 transition-colors" title="Edit">
<Edit3 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(goal.id)} className="p-1.5 rounded text-slate-400 hover:text-red-500 transition-colors" title="Delete">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Progress Bar */}
{goal.targetAmount && parseFloat(goal.targetAmount) > 0 && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-slate-500 dark:text-slate-400">{formatCurrency(goal.currentAmount)} of {formatCurrency(goal.targetAmount)}</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{pct}%</span>
</div>
<div className="w-full h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
goal.status === 'completed' ? 'bg-blue-500' :
goal.status === 'behind' ? 'bg-red-500' :
goal.status === 'at-risk' ? 'bg-amber-500' : 'bg-emerald-500'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)}
{/* Status & Category */}
<div className="flex items-center gap-2 text-xs flex-wrap">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full ${cfg.bg} ${cfg.color} font-medium`}>
<StatusIcon className="w-3 h-3" />
{goal.status}
</span>
<span className="px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 font-medium">
{goal.category}
</span>
{goal.targetDate && (
<span className="text-slate-400">Target: {formatDate(goal.targetDate)}</span>
)}
</div>
</div>
);
})}
</div>
)}
{/* Add/Edit Modal */}
<Modal isOpen={showForm} onClose={() => setShowForm(false)} title={editingGoal ? 'Edit Goal' : 'Add Goal'}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title *</label>
<input type="text" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Description</label>
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={2}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Category</label>
<select value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Priority</label>
<select value={form.priority} onChange={e => setForm(f => ({ ...f, priority: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
{PRIORITIES.map(p => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Target Amount</label>
<input type="number" step="0.01" value={form.targetAmount} onChange={e => setForm(f => ({ ...f, targetAmount: e.target.value }))} placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Current Amount</label>
<input type="number" step="0.01" value={form.currentAmount} onChange={e => setForm(f => ({ ...f, currentAmount: e.target.value }))} placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Target Date</label>
<input type="date" value={form.targetDate} onChange={e => setForm(f => ({ ...f, targetDate: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Status</label>
<select value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
{STATUSES.map(s => <option key={s} value={s}>{s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={() => setShowForm(false)}
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">Cancel</button>
<button type="submit" disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{saving ? 'Saving...' : editingGoal ? 'Update' : 'Create'}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useEffect, useState, useCallback } from 'react';
import { api, type Referral, type ReferralCreate } from '@/lib/api';
import type { Client } from '@/types';
import { UserPlus, Plus, Trash2, ArrowRight, Edit3, Search } from 'lucide-react';
import { formatDate } from '@/lib/utils';
import Modal from './Modal';
const STATUSES = ['pending', 'contacted', 'converted', 'lost'];
const TYPES = ['client', 'partner', 'event'];
const statusColors: Record<string, string> = {
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
contacted: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
converted: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
lost: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
};
function formatCurrency(val: string | null): string {
if (!val) return '';
const n = parseFloat(val);
if (isNaN(n)) return '';
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
}
export default function ClientReferrals({ clientId, clientName }: { clientId: string; clientName: string }) {
const [referrals, setReferrals] = useState<Referral[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [clients, setClients] = useState<Client[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [form, setForm] = useState({ referredId: '', type: 'client', notes: '', value: '', status: 'pending' });
const [saving, setSaving] = useState(false);
const fetchReferrals = useCallback(async () => {
try {
const data = await api.getClientReferrals(clientId);
setReferrals(data);
} catch {}
setLoading(false);
}, [clientId]);
useEffect(() => { fetchReferrals(); }, [fetchReferrals]);
const openAdd = async () => {
setForm({ referredId: '', type: 'client', notes: '', value: '', status: 'pending' });
setSearchQuery('');
try {
const allClients = await api.getClients();
setClients(allClients.filter((c: Client) => c.id !== clientId));
} catch {}
setShowAdd(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.referredId) { alert('Please select a referred client'); return; }
setSaving(true);
try {
const data: ReferralCreate = {
referredId: form.referredId,
type: form.type,
notes: form.notes || undefined,
value: form.value || undefined,
status: form.status,
};
await api.createReferral(clientId, data);
setShowAdd(false);
await fetchReferrals();
} catch (e: any) {
alert(e.message || 'Failed to create referral');
}
setSaving(false);
};
const handleUpdateStatus = async (refId: string, status: string) => {
try {
await api.updateReferral(refId, { status });
await fetchReferrals();
} catch {}
};
const handleDelete = async (refId: string) => {
if (!confirm('Delete this referral?')) return;
try {
await api.deleteReferral(refId);
setReferrals(prev => prev.filter(r => r.id !== refId));
} catch {}
};
const filteredClients = clients.filter(c => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return `${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
(c.email || '').toLowerCase().includes(q) ||
(c.company || '').toLowerCase().includes(q);
});
const given = referrals.filter(r => r.referrerId === clientId);
const received = referrals.filter(r => r.referredId === clientId);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<UserPlus className="w-5 h-5 text-indigo-500" />
Referrals
</h3>
<button onClick={openAdd}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors">
<Plus className="w-4 h-4" />
Add Referral
</button>
</div>
{/* Given Referrals */}
<div>
<h4 className="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400 mb-2">
Referrals Given ({given.length})
</h4>
<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-6 text-center text-sm text-slate-400">Loading...</p>
) : given.length === 0 ? (
<p className="px-5 py-6 text-center text-sm text-slate-400">No referrals given yet</p>
) : (
given.map(ref => (
<div key={ref.id} className="flex items-center gap-3 px-5 py-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{ref.referrer.firstName} {ref.referrer.lastName}
</span>
<ArrowRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ref.referred.firstName} {ref.referred.lastName}
</span>
</div>
{ref.value && (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(ref.value)}</span>
)}
<select
value={ref.status}
onChange={e => handleUpdateStatus(ref.id, e.target.value)}
className={`px-2 py-0.5 rounded text-xs font-medium border-0 ${statusColors[ref.status] || statusColors.pending}`}
>
{STATUSES.map(s => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
</select>
<span className="text-xs text-slate-400">{formatDate(ref.createdAt)}</span>
<button onClick={() => handleDelete(ref.id)} className="p-1.5 rounded text-slate-400 hover:text-red-500 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
</div>
{/* Received Referrals */}
{received.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400 mb-2">
Referred By ({received.length})
</h4>
<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">
{received.map(ref => (
<div key={ref.id} className="flex items-center gap-3 px-5 py-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
{ref.referrer.firstName} {ref.referrer.lastName}
</span>
<ArrowRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{ref.referred.firstName} {ref.referred.lastName}
</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[ref.status] || statusColors.pending}`}>
{ref.status}
</span>
<span className="text-xs text-slate-400">{formatDate(ref.createdAt)}</span>
</div>
))}
</div>
</div>
)}
{/* Add Referral Modal */}
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add Referral">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Referring: <span className="font-bold">{clientName}</span> Select who they referred:
</label>
<div className="relative">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search clients..."
className="w-full pl-9 pr-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm"
/>
</div>
<div className="mt-2 max-h-40 overflow-y-auto border border-slate-200 dark:border-slate-600 rounded-lg">
{filteredClients.slice(0, 20).map(c => (
<button
key={c.id}
type="button"
onClick={() => setForm(f => ({ ...f, referredId: c.id }))}
className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors ${
form.referredId === c.id ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'text-slate-900 dark:text-slate-100'
}`}
>
{c.firstName} {c.lastName}
{c.company && <span className="text-xs text-slate-400 ml-2">({c.company})</span>}
</button>
))}
{filteredClients.length === 0 && (
<p className="px-3 py-2 text-sm text-slate-400">No matching clients</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
{TYPES.map(t => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Est. Value</label>
<input type="number" step="0.01" value={form.value} onChange={e => setForm(f => ({ ...f, value: e.target.value }))} placeholder="0.00"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} rows={2}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={() => setShowAdd(false)}
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors">Cancel</button>
<button type="submit" disabled={saving || !form.referredId}
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{saving ? 'Creating...' : 'Create Referral'}
</button>
</div>
</form>
</Modal>
</div>
);
}

View File

@@ -768,6 +768,100 @@ class ApiClient {
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
} }
// ---- Client Documents ----
async getClientDocuments(clientId: string, category?: string): Promise<ClientDocument[]> {
const params = category ? `?category=${encodeURIComponent(category)}` : '';
return this.fetch(`/clients/${clientId}/documents${params}`);
}
async uploadDocument(clientId: string, file: File, opts?: { name?: string; category?: string; notes?: string }): Promise<ClientDocument> {
const formData = new FormData();
formData.append('file', file);
if (opts?.name) formData.append('name', opts.name);
if (opts?.category) formData.append('category', opts.category);
if (opts?.notes) formData.append('notes', opts.notes);
const token = this.getToken();
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
const response = await fetch(`${API_BASE}/clients/${clientId}/documents`, {
method: 'POST',
headers,
body: formData,
credentials: 'include',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(error.error || 'Upload failed');
}
return response.json();
}
async deleteDocument(documentId: string): Promise<void> {
await this.fetch(`/documents/${documentId}`, { method: 'DELETE' });
}
getDocumentDownloadUrl(documentId: string): string {
return `${API_BASE}/documents/${documentId}/download`;
}
async getDocumentCount(clientId: string): Promise<{ count: number }> {
return this.fetch(`/clients/${clientId}/documents/count`);
}
// ---- Client Goals ----
async getClientGoals(clientId: string): Promise<ClientGoal[]> {
return this.fetch(`/clients/${clientId}/goals`);
}
async createGoal(clientId: string, data: ClientGoalCreate): Promise<ClientGoal> {
return this.fetch(`/clients/${clientId}/goals`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateGoal(goalId: string, data: Partial<ClientGoalCreate>): Promise<ClientGoal> {
return this.fetch(`/goals/${goalId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteGoal(goalId: string): Promise<void> {
await this.fetch(`/goals/${goalId}`, { method: 'DELETE' });
}
async getGoalsOverview(): Promise<GoalsOverview> {
return this.fetch('/goals/overview');
}
// ---- Referrals ----
async getClientReferrals(clientId: string): Promise<Referral[]> {
return this.fetch(`/clients/${clientId}/referrals`);
}
async createReferral(clientId: string, data: ReferralCreate): Promise<Referral> {
return this.fetch(`/clients/${clientId}/referrals`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateReferral(referralId: string, data: Partial<ReferralCreate>): Promise<Referral> {
return this.fetch(`/referrals/${referralId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteReferral(referralId: string): Promise<void> {
await this.fetch(`/referrals/${referralId}`, { method: 'DELETE' });
}
async getReferralStats(): Promise<ReferralStats> {
return this.fetch('/referrals/stats');
}
// ---- Engagement Scoring ---- // ---- Engagement Scoring ----
async getEngagementScores(): Promise<EngagementResponse> { async getEngagementScores(): Promise<EngagementResponse> {
@@ -899,4 +993,84 @@ export interface ExportSummary {
exportFormats: string[]; exportFormats: string[];
} }
export interface ClientDocument {
id: string;
clientId: string;
userId: string;
name: string;
filename: string;
mimeType: string;
size: number;
category: string;
path: string;
notes: string | null;
createdAt: string;
}
export interface ClientGoal {
id: string;
clientId: string;
userId: string;
title: string;
description: string | null;
category: string;
targetAmount: string | null;
currentAmount: string | null;
targetDate: string | null;
status: string;
priority: string;
createdAt: string;
updatedAt: string;
}
export interface ClientGoalCreate {
title: string;
description?: string;
category?: string;
targetAmount?: string;
currentAmount?: string;
targetDate?: string;
status?: string;
priority?: string;
}
export interface GoalsOverview {
total: number;
byStatus: Record<string, number>;
atRiskGoals: Array<ClientGoal & { clientFirstName: string; clientLastName: string }>;
highPriorityGoals: Array<ClientGoal & { clientFirstName: string; clientLastName: string }>;
}
export interface Referral {
id: string;
referrerId: string;
referredId: string;
type: string;
notes: string | null;
status: string;
value: string | null;
createdAt: string;
updatedAt: string;
referrer: { id: string; firstName: string; lastName: string };
referred: { id: string; firstName: string; lastName: string };
}
export interface ReferralCreate {
referredId: string;
type?: string;
notes?: string;
status?: string;
value?: string;
}
export interface ReferralStats {
total: number;
converted: number;
conversionRate: number;
totalValue: number;
convertedValue: number;
byStatus: Record<string, number>;
topReferrers: Array<{ id: string; name: string; count: number; convertedCount: number }>;
}
export const api = new ApiClient(); export const api = new ApiClient();

View File

@@ -7,6 +7,7 @@ import {
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2, ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
Briefcase, Gift, Heart, Star, Users, Calendar, Send, Briefcase, Gift, Heart, Star, Users, Calendar, Send,
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw,
Paperclip, Target,
} from 'lucide-react'; } from 'lucide-react';
import { usePinnedClients } from '@/hooks/usePinnedClients'; import { usePinnedClients } from '@/hooks/usePinnedClients';
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils'; import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
@@ -20,6 +21,9 @@ import LogInteractionModal from '@/components/LogInteractionModal';
import MeetingPrepModal from '@/components/MeetingPrepModal'; import MeetingPrepModal from '@/components/MeetingPrepModal';
import EngagementBadge from '@/components/EngagementBadge'; import EngagementBadge from '@/components/EngagementBadge';
import DuplicatesModal from '@/components/DuplicatesModal'; import DuplicatesModal from '@/components/DuplicatesModal';
import ClientDocuments from '@/components/ClientDocuments';
import ClientGoals from '@/components/ClientGoals';
import ClientReferrals from '@/components/ClientReferrals';
import type { Interaction } from '@/types'; import type { Interaction } from '@/types';
export default function ClientDetailPage() { export default function ClientDetailPage() {
@@ -29,7 +33,7 @@ export default function ClientDetailPage() {
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [emails, setEmails] = useState<Email[]>([]); const [emails, setEmails] = useState<Email[]>([]);
const [activities, setActivities] = useState<ActivityItem[]>([]); const [activities, setActivities] = useState<ActivityItem[]>([]);
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info'); const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails' | 'documents' | 'goals' | 'referrals'>('info');
const [, setInteractions] = useState<Interaction[]>([]); const [, setInteractions] = useState<Interaction[]>([]);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showCompose, setShowCompose] = useState(false); const [showCompose, setShowCompose] = useState(false);
@@ -76,6 +80,9 @@ export default function ClientDetailPage() {
const tabs: { key: typeof activeTab; label: string; count?: number; icon: typeof Users }[] = [ const tabs: { key: typeof activeTab; label: string; count?: number; icon: typeof Users }[] = [
{ key: 'info', label: 'Info', icon: Users }, { key: 'info', label: 'Info', icon: Users },
{ key: 'notes', label: 'Notes', icon: FileText }, { key: 'notes', label: 'Notes', icon: FileText },
{ key: 'documents', label: 'Documents', icon: Paperclip },
{ key: 'goals', label: 'Goals', icon: Target },
{ key: 'referrals', label: 'Referrals', icon: UserPlus },
{ key: 'activity', label: 'Timeline', count: activities.length, icon: Activity }, { key: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
{ key: 'events', label: 'Events', count: events.length, icon: Calendar }, { key: 'events', label: 'Events', count: events.length, icon: Calendar },
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail }, { key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
@@ -277,6 +284,18 @@ export default function ClientDetailPage() {
<ClientNotes clientId={client.id} /> <ClientNotes clientId={client.id} />
)} )}
{activeTab === 'documents' && (
<ClientDocuments clientId={client.id} />
)}
{activeTab === 'goals' && (
<ClientGoals clientId={client.id} />
)}
{activeTab === 'referrals' && (
<ClientReferrals clientId={client.id} clientName={`${client.firstName} ${client.lastName}`} />
)}
{activeTab === 'activity' && ( {activeTab === 'activity' && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl"> <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
{activities.length === 0 ? ( {activities.length === 0 ? (

View File

@@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { Client, Event, Email, InsightsData } from '@/types'; import type { Client, Event, Email, InsightsData } from '@/types';
import type { Interaction } from '@/types'; import type { Interaction } from '@/types';
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react'; import type { GoalsOverview, ReferralStats } from '@/lib/api';
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, PhoneForwarded, Star, Phone, FileText, MoreHorizontal, Target, UserPlus, TrendingUp } from 'lucide-react';
import { formatDate, getDaysUntil, getInitials } from '@/lib/utils'; import { formatDate, getDaysUntil, getInitials } from '@/lib/utils';
import { EventTypeBadge } from '@/components/Badge'; import { EventTypeBadge } from '@/components/Badge';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
@@ -15,6 +16,8 @@ export default function DashboardPage() {
const [emails, setEmails] = useState<Email[]>([]); const [emails, setEmails] = useState<Email[]>([]);
const [insights, setInsights] = useState<InsightsData | null>(null); const [insights, setInsights] = useState<InsightsData | null>(null);
const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]); const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]);
const [goalsOverview, setGoalsOverview] = useState<GoalsOverview | null>(null);
const [referralStats, setReferralStats] = useState<ReferralStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { pinnedIds, togglePin, isPinned } = usePinnedClients(); const { pinnedIds, togglePin, isPinned } = usePinnedClients();
@@ -25,12 +28,16 @@ export default function DashboardPage() {
api.getEmails({ status: 'draft' }).catch(() => []), api.getEmails({ status: 'draft' }).catch(() => []),
api.getInsights().catch(() => null), api.getInsights().catch(() => null),
api.getRecentInteractions(5).catch(() => []), api.getRecentInteractions(5).catch(() => []),
]).then(([c, e, em, ins, ri]) => { api.getGoalsOverview().catch(() => null),
api.getReferralStats().catch(() => null),
]).then(([c, e, em, ins, ri, go, rs]) => {
setClients(c); setClients(c);
setEvents(e); setEvents(e);
setEmails(em); setEmails(em);
setInsights(ins as InsightsData | null); setInsights(ins as InsightsData | null);
setRecentInteractions(ri as Interaction[]); setRecentInteractions(ri as Interaction[]);
setGoalsOverview(go as GoalsOverview | null);
setReferralStats(rs as ReferralStats | null);
setLoading(false); setLoading(false);
}); });
}, []); }, []);
@@ -296,6 +303,108 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Goals & Referrals Widgets */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Goals Summary */}
{goalsOverview && goalsOverview.total > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
<div className="flex items-center gap-2 px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<Target className="w-4 h-4 text-blue-500" />
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Goals Overview</h2>
</div>
<div className="p-5 space-y-4">
<div className="grid grid-cols-4 gap-3 text-center">
<div>
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">{goalsOverview.byStatus['on-track'] || 0}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">On Track</p>
</div>
<div>
<p className="text-lg font-bold text-amber-600 dark:text-amber-400">{goalsOverview.byStatus['at-risk'] || 0}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">At Risk</p>
</div>
<div>
<p className="text-lg font-bold text-red-600 dark:text-red-400">{goalsOverview.byStatus['behind'] || 0}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Behind</p>
</div>
<div>
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">{goalsOverview.byStatus['completed'] || 0}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Completed</p>
</div>
</div>
{goalsOverview.atRiskGoals.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> Needs Attention
</p>
<div className="space-y-2">
{goalsOverview.atRiskGoals.slice(0, 3).map(g => (
<Link key={g.id} to={`/clients/${g.clientId}`} className="flex items-center gap-2 group">
<div className="w-6 h-6 bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">!</div>
<div className="min-w-0">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">
{g.title}
</p>
<p className="text-xs text-slate-400">{g.clientFirstName} {g.clientLastName} · {g.status}</p>
</div>
</Link>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Referral Leaderboard */}
{referralStats && referralStats.total > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
<div className="flex items-center gap-2 px-5 py-4 border-b border-slate-100 dark:border-slate-700">
<UserPlus className="w-4 h-4 text-indigo-500" />
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Referral Stats</h2>
</div>
<div className="p-5 space-y-4">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">{referralStats.total}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Total</p>
</div>
<div>
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">{referralStats.conversionRate}%</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Conversion</p>
</div>
<div>
<p className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
{referralStats.convertedValue > 0 ? `$${Math.round(referralStats.convertedValue / 1000)}k` : '$0'}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Value</p>
</div>
</div>
{referralStats.topReferrers.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase text-indigo-600 dark:text-indigo-400 mb-2 flex items-center gap-1">
<TrendingUp className="w-3 h-3" /> Top Referrers
</p>
<div className="space-y-2">
{referralStats.topReferrers.slice(0, 5).map((r, i) => (
<Link key={r.id} to={`/clients/${r.id}`} className="flex items-center gap-2 group">
<span className="w-5 text-xs font-bold text-slate-400 text-right">#{i + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">{r.name}</p>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">{r.count} referral{r.count !== 1 ? 's' : ''}</span>
{r.convertedCount > 0 && (
<span className="text-xs text-emerald-600 dark:text-emerald-400">({r.convertedCount} converted)</span>
)}
</Link>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 gap-6">
{/* Recent Clients */} {/* Recent Clients */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl"> <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">