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:
252
src/components/ClientReferrals.tsx
Normal file
252
src/components/ClientReferrals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user