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