From e7c2e396c0c701c3cd26939c5410ae4d9fb3efec Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 12:43:30 +0000 Subject: [PATCH] feat: add CSV import, activity timeline, and AI insights widget - CSV Import: modal with file picker, auto column mapping, preview table, import progress - Activity Timeline: new tab on client detail showing all communications, events, status changes - AI Insights Widget: dashboard card showing stale clients, upcoming birthdays, suggested follow-ups - Import button on Clients page header --- src/components/CSVImportModal.tsx | 303 ++++++++++++++++++++++++++++++ src/lib/api.ts | 50 ++++- src/pages/ClientDetailPage.tsx | 54 +++++- src/pages/ClientsPage.tsx | 34 +++- src/pages/DashboardPage.tsx | 114 ++++++++++- src/types/index.ts | 63 +++++++ 6 files changed, 602 insertions(+), 16 deletions(-) create mode 100644 src/components/CSVImportModal.tsx diff --git a/src/components/CSVImportModal.tsx b/src/components/CSVImportModal.tsx new file mode 100644 index 0000000..2d2f2a7 --- /dev/null +++ b/src/components/CSVImportModal.tsx @@ -0,0 +1,303 @@ +import { useState, useRef } from 'react'; +import { api } from '@/lib/api'; +import type { ImportPreview, ImportResult } from '@/types'; +import Modal from '@/components/Modal'; +import LoadingSpinner from '@/components/LoadingSpinner'; +import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, ArrowRight, X } from 'lucide-react'; + +const CLIENT_FIELDS = [ + { value: '', label: '-- Skip --' }, + { value: 'firstName', label: 'First Name' }, + { value: 'lastName', label: 'Last Name' }, + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone' }, + { value: 'company', label: 'Company' }, + { value: 'role', label: 'Role/Title' }, + { value: 'industry', label: 'Industry' }, + { value: 'street', label: 'Street' }, + { value: 'city', label: 'City' }, + { value: 'state', label: 'State' }, + { value: 'zip', label: 'ZIP Code' }, + { value: 'birthday', label: 'Birthday' }, + { value: 'anniversary', label: 'Anniversary' }, + { value: 'notes', label: 'Notes' }, + { value: 'tags', label: 'Tags (comma-separated)' }, + { value: 'interests', label: 'Interests (comma-separated)' }, +]; + +interface CSVImportModalProps { + isOpen: boolean; + onClose: () => void; + onComplete: () => void; +} + +type Step = 'upload' | 'mapping' | 'importing' | 'results'; + +export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImportModalProps) { + const [step, setStep] = useState('upload'); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [mapping, setMapping] = useState>({}); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const fileRef = useRef(null); + + const reset = () => { + setStep('upload'); + setFile(null); + setPreview(null); + setMapping({}); + setResult(null); + setError(null); + setLoading(false); + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (!f) return; + + setFile(f); + setError(null); + setLoading(true); + + try { + const previewData = await api.importPreview(f); + setPreview(previewData); + setMapping(previewData.mapping); + setStep('mapping'); + } catch (err: any) { + setError(err.message || 'Failed to parse CSV'); + } finally { + setLoading(false); + } + }; + + const updateMapping = (colIndex: number, field: string) => { + setMapping(prev => { + const next = { ...prev }; + if (field === '') { + delete next[colIndex]; + } else { + next[colIndex] = field; + } + return next; + }); + }; + + const hasRequiredFields = () => { + const values = Object.values(mapping); + return values.includes('firstName') && values.includes('lastName'); + }; + + const handleImport = async () => { + if (!file || !hasRequiredFields()) return; + + setStep('importing'); + setLoading(true); + setError(null); + + try { + const importResult = await api.importClients(file, mapping); + setResult(importResult); + setStep('results'); + if (importResult.imported > 0) { + onComplete(); + } + } catch (err: any) { + setError(err.message || 'Import failed'); + setStep('mapping'); + } finally { + setLoading(false); + } + }; + + return ( + +
+ {/* Step: Upload */} + {step === 'upload' && ( +
+
fileRef.current?.click()} + className="border-2 border-dashed border-slate-300 rounded-xl p-8 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50/50 transition-all" + > + +

+ Click to select a CSV file +

+

+ Must include at least First Name and Last Name columns +

+
+ + {loading && ( +
+ + Parsing CSV... +
+ )} + {error && ( +
+ + {error} +
+ )} +
+ )} + + {/* Step: Column Mapping */} + {step === 'mapping' && preview && ( +
+
+ + {preview.totalRows} rows found in {file?.name} +
+ + {/* Column mapping */} +
+

Map columns to client fields:

+
+ {preview.headers.map((header, index) => ( +
+ + {header} + + + +
+ ))} +
+
+ + {/* Preview table */} + {preview.sampleRows.length > 0 && ( +
+

Preview (first {preview.sampleRows.length} rows):

+
+ + + + {preview.headers.map((h, i) => ( + + ))} + + + + {preview.sampleRows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {mapping[i] ? ( + {CLIENT_FIELDS.find(f => f.value === mapping[i])?.label || mapping[i]} + ) : ( + {h} + )} +
+ {cell || '—'} +
+
+
+ )} + + {!hasRequiredFields() && ( +
+ + You must map both "First Name" and "Last Name" columns +
+ )} + + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+ )} + + {/* Step: Importing */} + {step === 'importing' && ( +
+ +

Importing clients...

+

This may take a moment for large files

+
+ )} + + {/* Step: Results */} + {step === 'results' && result && ( +
+
+ +
+

Import Complete

+

+ Successfully imported {result.imported} client{result.imported !== 1 ? 's' : ''} + {result.skipped > 0 && `, ${result.skipped} skipped`} +

+
+
+ + {result.errors.length > 0 && ( +
+

Issues ({result.errors.length}):

+
+ {result.errors.map((err, i) => ( +

{err}

+ ))} +
+
+ )} + +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b1f5e91..317ea9a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { Profile, Client, ClientCreate, Event, EventCreate, Email, EmailGenerate, User, Invite } from '@/types'; +import type { Profile, Client, ClientCreate, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats } from '@/types'; const API_BASE = import.meta.env.PROD ? 'https://api.thenetwork.donovankelly.xyz/api' @@ -157,6 +157,54 @@ class ApiClient { return this.fetch(`/clients/${id}/contacted`, { method: 'POST' }); } + // CSV Import + async importPreview(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + const token = this.getToken(); + const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; + const response = await fetch(`${API_BASE}/clients/import/preview`, { + method: 'POST', + headers, + body: formData, + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Preview failed' })); + throw new Error(error.error || 'Preview failed'); + } + return response.json(); + } + + async importClients(file: File, mapping: Record): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('mapping', JSON.stringify(mapping)); + const token = this.getToken(); + const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; + const response = await fetch(`${API_BASE}/clients/import`, { + method: 'POST', + headers, + body: formData, + credentials: 'include', + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Import failed' })); + throw new Error(error.error || 'Import failed'); + } + return response.json(); + } + + // Activity Timeline + async getClientActivity(clientId: string): Promise { + return this.fetch(`/clients/${clientId}/activity`); + } + + // Insights + async getInsights(): Promise { + return this.fetch('/insights'); + } + // Events async getEvents(params?: { clientId?: string; type?: string; upcoming?: number }): Promise { const searchParams = new URLSearchParams(); diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index adccedf..7c950ce 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -2,11 +2,11 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; import { useClientsStore } from '@/stores/clients'; import { api } from '@/lib/api'; -import type { Event, Email } from '@/types'; +import type { Event, Email, ActivityItem } from '@/types'; import { ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2, Briefcase, Gift, Heart, Star, Users, Calendar, Send, - CheckCircle2, Sparkles, Clock, + CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, } from 'lucide-react'; import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils'; import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge'; @@ -21,7 +21,8 @@ export default function ClientDetailPage() { const { selectedClient, isLoading, fetchClient, updateClient, deleteClient, markContacted } = useClientsStore(); const [events, setEvents] = useState([]); const [emails, setEmails] = useState([]); - const [activeTab, setActiveTab] = useState<'info' | 'events' | 'emails'>('info'); + const [activities, setActivities] = useState([]); + const [activeTab, setActiveTab] = useState<'info' | 'activity' | 'events' | 'emails'>('info'); const [showEdit, setShowEdit] = useState(false); const [showCompose, setShowCompose] = useState(false); const [deleting, setDeleting] = useState(false); @@ -31,6 +32,7 @@ export default function ClientDetailPage() { fetchClient(id); api.getEvents({ clientId: id }).then(setEvents).catch(() => {}); api.getEmails({ clientId: id }).then(setEmails).catch(() => {}); + api.getClientActivity(id).then(setActivities).catch(() => {}); } }, [id, fetchClient]); @@ -58,8 +60,9 @@ export default function ClientDetailPage() { setShowEdit(false); }; - const tabs: { key: 'info' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [ + const tabs: { key: 'info' | 'activity' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [ { key: 'info', label: 'Info', icon: Users }, + { 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 }, ]; @@ -228,6 +231,49 @@ export default function ClientDetailPage() { )} + {activeTab === 'activity' && ( +
+ {activities.length === 0 ? ( +

No activity recorded yet

+ ) : ( +
+ {activities.map((item, index) => { + const iconMap: Record = { + email_sent: { icon: Send, color: 'text-emerald-600', bg: 'bg-emerald-100' }, + email_drafted: { icon: FileText, color: 'text-amber-600', bg: 'bg-amber-100' }, + event_created: { icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-100' }, + client_contacted: { icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-100' }, + client_created: { icon: UserPlus, color: 'text-purple-600', bg: 'bg-purple-100' }, + client_updated: { icon: RefreshCw, color: 'text-slate-600', bg: 'bg-slate-100' }, + }; + const { icon: Icon, color, bg } = iconMap[item.type] || iconMap.client_updated; + + return ( +
+ {/* Timeline line */} + {index < activities.length - 1 && ( +
+ )} + {/* Icon */} +
+ +
+ {/* Content */} +
+

{item.title}

+ {item.description && ( +

{item.description}

+ )} +

{formatDate(item.date)}

+
+
+ ); + })} +
+ )} +
+ )} + {activeTab === 'events' && (
{events.length === 0 ? ( diff --git a/src/pages/ClientsPage.tsx b/src/pages/ClientsPage.tsx index 4b37957..fbcc805 100644 --- a/src/pages/ClientsPage.tsx +++ b/src/pages/ClientsPage.tsx @@ -1,18 +1,20 @@ import { useEffect, useState, useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useClientsStore } from '@/stores/clients'; -import { Search, Plus, Users, X } from 'lucide-react'; +import { Search, Plus, Users, X, Upload } from 'lucide-react'; import { cn, getRelativeTime, getInitials } from '@/lib/utils'; import Badge from '@/components/Badge'; import EmptyState from '@/components/EmptyState'; import { PageLoader } from '@/components/LoadingSpinner'; import Modal from '@/components/Modal'; import ClientForm from '@/components/ClientForm'; +import CSVImportModal from '@/components/CSVImportModal'; export default function ClientsPage() { const location = useLocation(); const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore(); const [showCreate, setShowCreate] = useState(false); + const [showImport, setShowImport] = useState(false); const [creating, setCreating] = useState(false); useEffect(() => { @@ -72,13 +74,22 @@ export default function ClientsPage() {

Clients

{clients.length} contacts in your network

- +
+ + +
{/* Search + Tags */} @@ -175,6 +186,13 @@ export default function ClientsPage() { setShowCreate(false)} title="Add Client" size="lg"> + + {/* Import CSV Modal */} + setShowImport(false)} + onComplete={() => fetchClients()} + /> ); } diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 3bdcc34..323ff5d 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '@/lib/api'; -import type { Client, Event, Email } from '@/types'; -import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock } from 'lucide-react'; +import type { Client, Event, Email, InsightsData } from '@/types'; +import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded } from 'lucide-react'; import { formatDate, getDaysUntil } from '@/lib/utils'; import { EventTypeBadge } from '@/components/Badge'; import { PageLoader } from '@/components/LoadingSpinner'; @@ -11,6 +11,7 @@ export default function DashboardPage() { const [clients, setClients] = useState([]); const [events, setEvents] = useState([]); const [emails, setEmails] = useState([]); + const [insights, setInsights] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { @@ -18,10 +19,12 @@ export default function DashboardPage() { api.getClients().catch(() => []), api.getEvents({ upcoming: 7 }).catch(() => []), api.getEmails({ status: 'draft' }).catch(() => []), - ]).then(([c, e, em]) => { + api.getInsights().catch(() => null), + ]).then(([c, e, em, ins]) => { setClients(c); setEvents(e); setEmails(em); + setInsights(ins as InsightsData | null); setLoading(false); }); }, []); @@ -78,6 +81,111 @@ export default function DashboardPage() { ))} + {/* AI Insights */} + {insights && (insights.staleClients.length > 0 || insights.upcomingBirthdays.length > 0 || insights.suggestedFollowups.length > 0) && ( +
+
+ +

AI Insights

+
+ +
+ {/* Needs Attention */} + {insights.staleClients.length > 0 && ( +
+
+ + Needs Attention +
+

+ {insights.summary.staleCount} client{insights.summary.staleCount !== 1 ? 's' : ''} not contacted in 30+ days + {insights.summary.neverContacted > 0 && `, ${insights.summary.neverContacted} never contacted`} +

+
+ {insights.staleClients.slice(0, 3).map(c => ( + +
+ {c.firstName[0]}{c.lastName[0]} +
+
+

+ {c.firstName} {c.lastName} +

+

+ {c.daysSinceContact ? `${c.daysSinceContact}d ago` : 'Never contacted'} +

+
+ + ))} + {insights.staleClients.length > 3 && ( + + +{insights.staleClients.length - 3} more + + )} +
+
+ )} + + {/* Upcoming Birthdays */} + {insights.upcomingBirthdays.length > 0 && ( +
+
+ + Birthdays This Week +
+
+ {insights.upcomingBirthdays.map(c => ( + +
+ 🎂 +
+
+

+ {c.firstName} {c.lastName} +

+

+ {c.daysUntil === 0 ? 'Today!' : c.daysUntil === 1 ? 'Tomorrow' : `In ${c.daysUntil} days`} +

+
+ + ))} +
+
+ )} + + {/* Suggested Follow-ups */} + {insights.suggestedFollowups.length > 0 && ( +
+
+ + Suggested Follow-ups +
+

+ Contacted 14-30 days ago — good time to reach out +

+
+ {insights.suggestedFollowups.slice(0, 3).map(c => ( + +
+ {c.firstName[0]}{c.lastName[0]} +
+
+

+ {c.firstName} {c.lastName} +

+

+ {c.daysSinceContact}d since last contact +

+
+ + ))} +
+
+ )} +
+
+ )} +
{/* Upcoming Events */}
diff --git a/src/types/index.ts b/src/types/index.ts index ca25a6f..dc41982 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -131,6 +131,69 @@ export interface NetworkStats { topConnectors: { id: string; name: string; matchCount: number }[]; } +export interface ActivityItem { + id: string; + type: 'email_sent' | 'email_drafted' | 'event_created' | 'client_contacted' | 'client_created' | 'client_updated'; + title: string; + description?: string; + date: string; + metadata?: Record; +} + +export interface InsightsData { + staleClients: { + id: string; + firstName: string; + lastName: string; + email?: string | null; + company?: string | null; + lastContactedAt: string | null; + daysSinceContact: number | null; + }[]; + upcomingBirthdays: { + id: string; + firstName: string; + lastName: string; + birthday: string; + daysUntil: number; + }[]; + suggestedFollowups: { + id: string; + firstName: string; + lastName: string; + email?: string | null; + company?: string | null; + lastContactedAt: string | null; + daysSinceContact: number | null; + }[]; + upcomingEvents: { + id: string; + title: string; + type: string; + date: string; + clientId: string; + }[]; + summary: { + totalClients: number; + neverContacted: number; + staleCount: number; + birthdaysThisWeek: number; + }; +} + +export interface ImportPreview { + headers: string[]; + mapping: Record; + sampleRows: string[][]; + totalRows: number; +} + +export interface ImportResult { + imported: number; + skipped: number; + errors: string[]; +} + export interface Invite { id: string; email: string;