- 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
253 lines
12 KiB
TypeScript
253 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|