diff --git a/src/components/ClientDocuments.tsx b/src/components/ClientDocuments.tsx new file mode 100644 index 0000000..bffc8b4 --- /dev/null +++ b/src/components/ClientDocuments.tsx @@ -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 = { + 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 ; + if (mimeType.includes('spreadsheet') || mimeType.includes('csv') || mimeType.includes('excel')) + return ; + if (mimeType.includes('pdf')) return ; + return ; +} + +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([]); + 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 ( +
+ {/* Upload Area */} +
{ 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' + }`} + > + +

+ {uploading ? 'Uploading...' : 'Drag & drop files here, or click to browse'} +

+
+ + +
+
+ + {/* Category Filter */} +
+ +
+ {CATEGORIES.map(c => ( + + ))} +
+
+ + {/* Document List */} +
+ {loading ? ( +

Loading...

+ ) : documents.length === 0 ? ( +

No documents uploaded yet

+ ) : ( + documents.map(doc => ( +
+ {fileIcon(doc.mimeType)} +
+

{doc.name}

+
+ + {doc.category} + + {formatSize(doc.size)} + {formatDate(doc.createdAt)} +
+ {doc.notes &&

{doc.notes}

} +
+ + +
+ )) + )} +
+
+ ); +} diff --git a/src/components/ClientGoals.tsx b/src/components/ClientGoals.tsx new file mode 100644 index 0000000..67772a0 --- /dev/null +++ b/src/components/ClientGoals.tsx @@ -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 = { + '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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingGoal, setEditingGoal] = useState(null); + const [form, setForm] = useState(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 ( +
+
+

+ + Financial Goals +

+ +
+ + {loading ? ( +

Loading...

+ ) : goals.length === 0 ? ( +
+ +

No goals set for this client yet

+
+ ) : ( +
+ {goals.map(goal => { + const pct = progressPercent(goal.currentAmount, goal.targetAmount); + const cfg = statusConfig[goal.status] || statusConfig['on-track']; + const StatusIcon = cfg.icon; + return ( +
+
+
+
+

{goal.title}

+ + {goal.priority} + +
+ {goal.description && ( +

{goal.description}

+ )} +
+
+ {goal.status !== 'completed' && ( + + )} + + +
+
+ + {/* Progress Bar */} + {goal.targetAmount && parseFloat(goal.targetAmount) > 0 && ( +
+
+ {formatCurrency(goal.currentAmount)} of {formatCurrency(goal.targetAmount)} + {pct}% +
+
+
+
+
+ )} + + {/* Status & Category */} +
+ + + {goal.status} + + + {goal.category} + + {goal.targetDate && ( + Target: {formatDate(goal.targetDate)} + )} +
+
+ ); + })} +
+ )} + + {/* Add/Edit Modal */} + setShowForm(false)} title={editingGoal ? 'Edit Goal' : 'Add Goal'}> +
+
+ + 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" /> +
+
+ +