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:
203
src/components/ClientDocuments.tsx
Normal file
203
src/components/ClientDocuments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
src/components/ClientGoals.tsx
Normal file
300
src/components/ClientGoals.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
174
src/lib/api.ts
174
src/lib/api.ts
@@ -768,6 +768,100 @@ class ApiClient {
|
||||
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 ----
|
||||
|
||||
async getEngagementScores(): Promise<EngagementResponse> {
|
||||
@@ -899,4 +993,84 @@ export interface ExportSummary {
|
||||
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();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
||||
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
||||
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw,
|
||||
Paperclip, Target,
|
||||
} from 'lucide-react';
|
||||
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
||||
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
||||
@@ -20,6 +21,9 @@ import LogInteractionModal from '@/components/LogInteractionModal';
|
||||
import MeetingPrepModal from '@/components/MeetingPrepModal';
|
||||
import EngagementBadge from '@/components/EngagementBadge';
|
||||
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';
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
@@ -29,7 +33,7 @@ export default function ClientDetailPage() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
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 [showEdit, setShowEdit] = 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 }[] = [
|
||||
{ key: 'info', label: 'Info', icon: Users },
|
||||
{ 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: 'events', label: 'Events', count: events.length, icon: Calendar },
|
||||
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
||||
@@ -277,6 +284,18 @@ export default function ClientDetailPage() {
|
||||
<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' && (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||
{activities.length === 0 ? (
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Client, Event, Email, InsightsData } 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 { EventTypeBadge } from '@/components/Badge';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
@@ -15,6 +16,8 @@ export default function DashboardPage() {
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [insights, setInsights] = useState<InsightsData | null>(null);
|
||||
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 { pinnedIds, togglePin, isPinned } = usePinnedClients();
|
||||
|
||||
@@ -25,12 +28,16 @@ export default function DashboardPage() {
|
||||
api.getEmails({ status: 'draft' }).catch(() => []),
|
||||
api.getInsights().catch(() => null),
|
||||
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);
|
||||
setEvents(e);
|
||||
setEmails(em);
|
||||
setInsights(ins as InsightsData | null);
|
||||
setRecentInteractions(ri as Interaction[]);
|
||||
setGoalsOverview(go as GoalsOverview | null);
|
||||
setReferralStats(rs as ReferralStats | null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
@@ -296,6 +303,108 @@ export default function DashboardPage() {
|
||||
</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">
|
||||
{/* Recent Clients */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||
|
||||
Reference in New Issue
Block a user