feat: real notifications, interaction logging, bulk email compose
Some checks failed
CI/CD / test (push) Failing after 20s
CI/CD / deploy (push) Has been skipped

This commit is contained in:
2026-01-30 00:48:13 +00:00
parent b43bdf3c71
commit 691e8170f3
8 changed files with 863 additions and 118 deletions

View File

@@ -0,0 +1,380 @@
import { useState, useEffect, useMemo } from 'react';
import { api } from '@/lib/api';
import type { Client, BulkEmailResult } from '@/types';
import Modal from '@/components/Modal';
import LoadingSpinner from '@/components/LoadingSpinner';
import { Sparkles, Send, CheckCircle2, XCircle, Search, X, Users, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface BulkEmailModalProps {
isOpen: boolean;
onClose: () => void;
clients: Client[];
onComplete?: () => void;
}
type Step = 'select' | 'configure' | 'preview' | 'done';
export default function BulkEmailModal({ isOpen, onClose, clients, onComplete }: BulkEmailModalProps) {
const [step, setStep] = useState<Step>('select');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('');
const [stageFilter, setStageFilter] = useState<string>('');
const [purpose, setPurpose] = useState('');
const [provider, setProvider] = useState<'anthropic' | 'openai'>('anthropic');
const [generating, setGenerating] = useState(false);
const [result, setResult] = useState<BulkEmailResult | null>(null);
const [activePreview, setActivePreview] = useState(0);
const [sending, setSending] = useState(false);
const [sendResult, setSendResult] = useState<{ sent: number; failed: number } | null>(null);
useEffect(() => {
if (isOpen) {
setStep('select');
setSelectedIds(new Set());
setSearch('');
setStageFilter('');
setPurpose('');
setResult(null);
setActivePreview(0);
setSendResult(null);
}
}, [isOpen]);
const filteredClients = useMemo(() => {
let filtered = clients;
if (search) {
const q = search.toLowerCase();
filtered = filtered.filter(c =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.email?.toLowerCase().includes(q) ||
c.company?.toLowerCase().includes(q)
);
}
if (stageFilter) {
filtered = filtered.filter(c => c.stage === stageFilter);
}
return filtered;
}, [clients, search, stageFilter]);
const toggleClient = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
setSelectedIds(new Set(filteredClients.map(c => c.id)));
};
const clearAll = () => setSelectedIds(new Set());
const handleGenerate = async () => {
setGenerating(true);
try {
const res = await api.bulkGenerateEmails(Array.from(selectedIds), purpose, provider);
setResult(res);
setStep('preview');
} catch (err) {
console.error('Bulk generate failed:', err);
} finally {
setGenerating(false);
}
};
const handleSendAll = async () => {
if (!result) return;
setSending(true);
try {
const res = await api.bulkSendEmails(result.batchId);
setSendResult({ sent: res.sent, failed: res.failed });
setStep('done');
onComplete?.();
} catch (err) {
console.error('Bulk send failed:', err);
} finally {
setSending(false);
}
};
const handleSendSingle = async (emailId: string) => {
try {
await api.sendEmail(emailId);
// Update result to reflect sent
setResult(prev => prev ? {
...prev,
results: prev.results.map(r =>
r.email?.id === emailId ? { ...r, email: { ...r.email!, status: 'sent' as const } } : r
),
} : null);
} catch (err) {
console.error('Send failed:', err);
}
};
const successResults = result?.results.filter(r => r.success && r.email) || [];
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
return (
<Modal isOpen={isOpen} onClose={onClose} title="Bulk Email Compose" size="xl">
<div className="min-h-[400px]">
{/* Steps indicator */}
<div className="flex items-center gap-2 mb-6">
{(['select', 'configure', 'preview'] as Step[]).map((s, i) => (
<div key={s} className="flex items-center gap-2">
<div className={cn(
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold',
step === s ? 'bg-blue-600 text-white' :
(['select', 'configure', 'preview'].indexOf(step) > i ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400' : 'bg-slate-100 dark:bg-slate-700 text-slate-400')
)}>
{i + 1}
</div>
<span className={cn('text-sm font-medium', step === s ? 'text-slate-800 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500')}>
{s === 'select' ? 'Select Clients' : s === 'configure' ? 'Configure' : 'Preview & Send'}
</span>
{i < 2 && <div className="w-8 h-px bg-slate-200 dark:bg-slate-600" />}
</div>
))}
</div>
{/* Step 1: Select Clients */}
{step === 'select' && (
<div className="space-y-4">
<div className="flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-slate-400" />
<input
value={search}
onChange={e => setSearch(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 text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={stageFilter}
onChange={e => setStageFilter(e.target.value)}
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800"
>
<option value="">All Stages</option>
{stages.map(s => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
</select>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500 dark:text-slate-400">
{selectedIds.size} of {filteredClients.length} selected
</span>
<div className="flex gap-3">
<button onClick={selectAll} className="text-blue-600 dark:text-blue-400 hover:underline text-xs font-medium">Select all</button>
{selectedIds.size > 0 && <button onClick={clearAll} className="text-red-500 hover:underline text-xs font-medium">Clear</button>}
</div>
</div>
<div className="max-h-[280px] overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg divide-y divide-slate-100 dark:divide-slate-700">
{filteredClients.map(c => (
<label key={c.id} className="flex items-center gap-3 px-4 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer">
<input
type="checkbox"
checked={selectedIds.has(c.id)}
onChange={() => toggleClient(c.id)}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{c.firstName} {c.lastName}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{c.email || 'No email'} {c.company ? `· ${c.company}` : ''}</p>
</div>
{!c.email && <span className="text-xs text-red-400">No email</span>}
</label>
))}
{filteredClients.length === 0 && (
<p className="px-4 py-6 text-center text-sm text-slate-400">No clients found</p>
)}
</div>
<div className="flex justify-end">
<button
onClick={() => setStep('configure')}
disabled={selectedIds.size === 0}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
Next
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Step 2: Configure */}
{step === 'configure' && (
<div className="space-y-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm text-blue-700 dark:text-blue-300">{selectedIds.size} client{selectedIds.size !== 1 ? 's' : ''} selected</span>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Purpose / Email Topic *</label>
<textarea
value={purpose}
onChange={e => setPurpose(e.target.value)}
rows={4}
placeholder="What is this email about? E.g., 'Quarterly portfolio review check-in', 'Holiday greeting', 'New investment opportunity update'..."
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
<select
value={provider}
onChange={e => setProvider(e.target.value as any)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800"
>
<option value="anthropic">Anthropic (Claude)</option>
<option value="openai">OpenAI (GPT)</option>
</select>
</div>
<div className="flex justify-between pt-2">
<button
onClick={() => setStep('select')}
className="flex items-center gap-2 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"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
<button
onClick={handleGenerate}
disabled={!purpose.trim() || generating}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{generating ? (
<>
<LoadingSpinner size="sm" className="text-white" />
Generating {selectedIds.size} emails...
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Generate Emails
</>
)}
</button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 'preview' && result && (
<div className="space-y-4">
<div className="flex items-center gap-3 text-sm">
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
<CheckCircle2 className="w-4 h-4 inline mr-1" />
{result.generated}/{result.total} generated
</span>
{result.total - result.generated > 0 && (
<span className="text-red-500">
<XCircle className="w-4 h-4 inline mr-1" />
{result.total - result.generated} failed
</span>
)}
</div>
{successResults.length > 0 && (
<>
{/* Tab bar for each email */}
<div className="flex gap-1 overflow-x-auto pb-1">
{successResults.map((r, i) => {
const client = clients.find(c => c.id === r.clientId);
return (
<button
key={r.clientId}
onClick={() => setActivePreview(i)}
className={cn(
'flex-shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors',
activePreview === i
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
)}
>
{client ? `${client.firstName} ${client.lastName}` : r.clientId.slice(0, 8)}
</button>
);
})}
</div>
{/* Preview content */}
{successResults[activePreview]?.email && (
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<div className="px-4 py-2 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Subject: {successResults[activePreview].email!.subject}
</p>
</div>
<div className="px-4 py-3 max-h-[200px] overflow-y-auto">
<pre className="text-sm text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans">
{successResults[activePreview].email!.content}
</pre>
</div>
<div className="px-4 py-2 border-t border-slate-100 dark:border-slate-700 flex justify-end">
<button
onClick={() => handleSendSingle(successResults[activePreview].email!.id)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/50"
>
<Send className="w-3 h-3" /> Send this one
</button>
</div>
</div>
)}
</>
)}
<div className="flex justify-between pt-2">
<button
onClick={() => { setStep('configure'); setResult(null); }}
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
>
<ChevronLeft className="w-4 h-4" />
Regenerate
</button>
<button
onClick={handleSendAll}
disabled={sending || successResults.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors"
>
{sending ? (
<><LoadingSpinner size="sm" className="text-white" /> Sending...</>
) : (
<><Send className="w-4 h-4" /> Send All ({successResults.length})</>
)}
</button>
</div>
</div>
)}
{/* Step 4: Done */}
{step === 'done' && sendResult && (
<div className="flex flex-col items-center justify-center py-12 space-y-4">
<CheckCircle2 className="w-16 h-16 text-emerald-500" />
<h3 className="text-lg font-semibold text-slate-800 dark:text-slate-200">Bulk Send Complete</h3>
<div className="flex gap-4 text-sm">
<span className="text-emerald-600 dark:text-emerald-400 font-medium">{sendResult.sent} sent</span>
{sendResult.failed > 0 && (
<span className="text-red-500 font-medium">{sendResult.failed} failed</span>
)}
</div>
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Close
</button>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react';
import { api } from '@/lib/api';
import Modal from '@/components/Modal';
import LoadingSpinner from '@/components/LoadingSpinner';
import { Phone, Users, Mail, FileText, MoreHorizontal } from 'lucide-react';
import { cn } from '@/lib/utils';
const interactionTypes = [
{ value: 'call', label: 'Phone Call', icon: Phone, color: 'text-green-600 bg-green-50 dark:bg-green-900/30 dark:text-green-400' },
{ value: 'meeting', label: 'Meeting', icon: Users, color: 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400' },
{ value: 'email', label: 'Email', icon: Mail, color: 'text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-400' },
{ value: 'note', label: 'Note', icon: FileText, color: 'text-amber-600 bg-amber-50 dark:bg-amber-900/30 dark:text-amber-400' },
{ value: 'other', label: 'Other', icon: MoreHorizontal, color: 'text-slate-600 bg-slate-50 dark:bg-slate-700 dark:text-slate-400' },
];
interface LogInteractionModalProps {
isOpen: boolean;
onClose: () => void;
clientId: string;
clientName: string;
onCreated?: () => void;
}
export default function LogInteractionModal({ isOpen, onClose, clientId, clientName, onCreated }: LogInteractionModalProps) {
const [type, setType] = useState('call');
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [duration, setDuration] = useState('');
const [contactedAt, setContactedAt] = useState(new Date().toISOString().slice(0, 16));
const [saving, setSaving] = useState(false);
const resetForm = () => {
setType('call');
setTitle('');
setDescription('');
setDuration('');
setContactedAt(new Date().toISOString().slice(0, 16));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSaving(true);
try {
await api.createInteraction(clientId, {
type,
title: title.trim(),
description: description.trim() || undefined,
duration: duration ? parseInt(duration) : undefined,
contactedAt: new Date(contactedAt).toISOString(),
});
resetForm();
onCreated?.();
onClose();
} catch (err) {
console.error('Failed to log interaction:', err);
} finally {
setSaving(false);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Log Interaction — ${clientName}`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Type</label>
<div className="flex flex-wrap gap-2">
{interactionTypes.map(t => {
const Icon = t.icon;
const selected = type === t.value;
return (
<button
key={t.value}
type="button"
onClick={() => setType(t.value)}
className={cn(
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all border',
selected
? `${t.color} border-current ring-1 ring-current/20`
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
)}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
);
})}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title *</label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Brief summary of the interaction"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Details, key topics discussed, next steps..."
rows={3}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Duration */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Duration (min)</label>
<input
type="number"
value={duration}
onChange={e => setDuration(e.target.value)}
placeholder="0"
min="0"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date & Time</label>
<input
type="datetime-local"
value={contactedAt}
onChange={e => setContactedAt(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
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={!title.trim() || saving}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving ? <LoadingSpinner size="sm" className="text-white" /> : null}
Log Interaction
</button>
</div>
</form>
</Modal>
);
}

View File

@@ -1,58 +1,36 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Bell, AlertTriangle, Calendar, Users, Mail, Clock, X } from 'lucide-react'; import type { Notification } from '@/types';
import { Bell, Clock, X, CheckCheck, Trash2, User } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Notification {
id: string;
type: 'overdue' | 'upcoming' | 'stale' | 'drafts';
title: string;
description: string;
date: string;
link: string;
priority: 'high' | 'medium' | 'low';
}
interface NotifCounts {
total: number;
high: number;
overdue: number;
upcoming: number;
stale: number;
drafts: number;
}
const typeIcons: Record<string, typeof Calendar> = {
overdue: AlertTriangle,
upcoming: Calendar,
stale: Users,
drafts: Mail,
};
const typeColors: Record<string, string> = { const typeColors: Record<string, string> = {
overdue: 'text-red-500 bg-red-50 dark:bg-red-900/30', event_reminder: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
upcoming: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30', interaction: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30',
stale: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30', system: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
drafts: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
}; };
const priorityDot: Record<string, string> = { function timeAgo(dateStr: string) {
high: 'bg-red-500', const diff = Date.now() - new Date(dateStr).getTime();
medium: 'bg-amber-400', const mins = Math.floor(diff / 60000);
low: 'bg-slate-300 dark:bg-slate-500', if (mins < 1) return 'Just now';
}; if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export default function NotificationBell() { export default function NotificationBell() {
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [counts, setCounts] = useState<NotifCounts | null>(null); const [unreadCount, setUnreadCount] = useState(0);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
fetchNotifications(); fetchNotifications();
const interval = setInterval(fetchNotifications, 5 * 60 * 1000); const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
@@ -68,21 +46,38 @@ export default function NotificationBell() {
const fetchNotifications = async () => { const fetchNotifications = async () => {
try { try {
const data = await api.getNotifications(); const data = await api.getNotifications({ limit: 30 });
setNotifications(data.notifications || []); setNotifications(data.notifications || []);
setCounts(data.counts || null); setUnreadCount(data.unreadCount || 0);
} catch { } catch {
// Silently fail // Silently fail - API might not have notifications table yet
} }
}; };
const dismiss = (id: string) => { const markRead = async (id: string) => {
setDismissed(prev => new Set(prev).add(id)); try {
await api.markNotificationRead(id);
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
setUnreadCount(prev => Math.max(0, prev - 1));
} catch {}
}; };
const visibleNotifs = notifications.filter(n => !dismissed.has(n.id)); const markAllRead = async () => {
const activeCount = visibleNotifs.length; try {
const highCount = visibleNotifs.filter(n => n.priority === 'high').length; await api.markAllNotificationsRead();
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
setUnreadCount(0);
} catch {}
};
const remove = async (id: string) => {
try {
await api.deleteNotification(id);
const wasUnread = notifications.find(n => n.id === id && !n.read);
setNotifications(prev => prev.filter(n => n.id !== id));
if (wasUnread) setUnreadCount(prev => Math.max(0, prev - 1));
} catch {}
};
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="relative">
@@ -94,12 +89,9 @@ export default function NotificationBell() {
)} )}
> >
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
{activeCount > 0 && ( {unreadCount > 0 && (
<span className={cn( <span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-[10px] font-bold text-white px-1 bg-red-500">
'absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-[10px] font-bold text-white px-1', {unreadCount > 99 ? '99+' : unreadCount}
highCount > 0 ? 'bg-red-500' : 'bg-blue-500'
)}>
{activeCount > 99 ? '99+' : activeCount}
</span> </span>
)} )}
</button> </button>
@@ -108,62 +100,73 @@ export default function NotificationBell() {
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl z-50 overflow-hidden"> <div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl z-50 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-700"> <div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Notifications</h3> <h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Notifications</h3>
{counts && ( <div className="flex items-center gap-2">
<div className="flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500"> {unreadCount > 0 && (
{counts.overdue > 0 && ( <button
<span className="text-red-500 dark:text-red-400 font-medium">{counts.overdue} overdue</span> onClick={markAllRead}
)} className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
{counts.upcoming > 0 && ( >
<span>{counts.upcoming} upcoming</span> <CheckCheck className="w-3.5 h-3.5" />
)} Mark all read
</div> </button>
)} )}
<span className="text-xs text-slate-400 dark:text-slate-500">
{unreadCount > 0 ? `${unreadCount} unread` : 'All read'}
</span>
</div>
</div> </div>
<div className="max-h-[400px] overflow-y-auto"> <div className="max-h-[400px] overflow-y-auto">
{visibleNotifs.length === 0 ? ( {notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-slate-400 dark:text-slate-500"> <div className="flex flex-col items-center justify-center py-8 text-slate-400 dark:text-slate-500">
<Clock className="w-8 h-8 mb-2" /> <Clock className="w-8 h-8 mb-2" />
<p className="text-sm">All caught up!</p> <p className="text-sm">No notifications yet</p>
</div> </div>
) : ( ) : (
visibleNotifs.map(n => { notifications.map(n => (
const Icon = typeIcons[n.type] || Calendar; <div
return ( key={n.id}
<div key={n.id} className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-50 dark:border-slate-700 last:border-0"> className={cn(
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 text-slate-500')}> 'flex items-start gap-3 px-4 py-3 border-b border-slate-50 dark:border-slate-700 last:border-0 transition-colors',
<Icon className="w-4 h-4" /> !n.read && 'bg-blue-50/50 dark:bg-blue-900/10'
</div> )}
<Link to={n.link} onClick={() => setOpen(false)} className="flex-1 min-w-0"> >
<div className="flex items-center gap-1.5"> <div className={cn('p-1.5 rounded-lg mt-0.5 flex-shrink-0', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 text-slate-500')}>
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', priorityDot[n.priority])} /> <Bell className="w-4 h-4" />
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{n.title}</p>
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{n.description}</p>
</Link>
<button
onClick={() => dismiss(n.id)}
className="p-1 rounded text-slate-300 dark:text-slate-500 hover:text-slate-500 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-600 flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div> </div>
); <div
}) className="flex-1 min-w-0 cursor-pointer"
onClick={() => !n.read && markRead(n.id)}
>
<div className="flex items-center gap-1.5">
{!n.read && <div className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />}
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{n.title}</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">{n.message}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-400 dark:text-slate-500">{timeAgo(n.createdAt)}</span>
{n.client && (
<Link
to={`/clients/${n.clientId}`}
onClick={() => setOpen(false)}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
<User className="w-3 h-3" />
{n.client.firstName} {n.client.lastName}
</Link>
)}
</div>
</div>
<button
onClick={() => remove(n.id)}
className="p-1 rounded text-slate-300 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-slate-100 dark:hover:bg-slate-600 flex-shrink-0"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))
)} )}
</div> </div>
{visibleNotifs.length > 0 && (
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-2.5">
<Link
to="/reports"
onClick={() => setOpen(false)}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
>
View Reports
</Link>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -1,4 +1,4 @@
import type { Profile, Client, ClientCreate, ClientNote, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats } from '@/types'; import type { Profile, Client, ClientCreate, ClientNote, Event, EventCreate, Email, EmailGenerate, User, Invite, ActivityItem, InsightsData, ImportPreview, ImportResult, NetworkMatch, NetworkStats, Notification, Interaction, BulkEmailResult } from '@/types';
const API_BASE = import.meta.env.PROD const API_BASE = import.meta.env.PROD
? 'https://api.thenetwork.donovankelly.xyz/api' ? 'https://api.thenetwork.donovankelly.xyz/api'
@@ -448,10 +448,78 @@ class ApiClient {
return this.fetch('/reports/engagement'); return this.fetch('/reports/engagement');
} }
async getNotifications(): Promise<any> { async getNotificationsLegacy(): Promise<any> {
return this.fetch('/reports/notifications'); return this.fetch('/reports/notifications');
} }
// Real notifications (from notifications table)
async getNotifications(params?: { unreadOnly?: boolean; limit?: number }): Promise<{ notifications: Notification[]; unreadCount: number }> {
const searchParams = new URLSearchParams();
if (params?.unreadOnly) searchParams.set('unreadOnly', 'true');
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.fetch(`/notifications${query ? `?${query}` : ''}`);
}
async markNotificationRead(id: string): Promise<Notification> {
return this.fetch(`/notifications/${id}/read`, { method: 'PUT' });
}
async markAllNotificationsRead(): Promise<void> {
await this.fetch('/notifications/mark-all-read', { method: 'POST' });
}
async deleteNotification(id: string): Promise<void> {
await this.fetch(`/notifications/${id}`, { method: 'DELETE' });
}
// Interactions
async getClientInteractions(clientId: string): Promise<Interaction[]> {
return this.fetch(`/clients/${clientId}/interactions`);
}
async createInteraction(clientId: string, data: {
type: string; title: string; description?: string; duration?: number; contactedAt: string;
}): Promise<Interaction> {
return this.fetch(`/clients/${clientId}/interactions`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateInteraction(id: string, data: Partial<{
type: string; title: string; description?: string; duration?: number; contactedAt: string;
}>): Promise<Interaction> {
return this.fetch(`/interactions/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteInteraction(id: string): Promise<void> {
await this.fetch(`/interactions/${id}`, { method: 'DELETE' });
}
async getRecentInteractions(limit?: number): Promise<Interaction[]> {
const query = limit ? `?limit=${limit}` : '';
return this.fetch(`/interactions/recent${query}`);
}
// Bulk Email
async bulkGenerateEmails(clientIds: string[], purpose: string, provider?: 'anthropic' | 'openai'): Promise<BulkEmailResult> {
return this.fetch('/emails/bulk-generate', {
method: 'POST',
body: JSON.stringify({ clientIds, purpose, provider }),
});
}
async bulkSendEmails(batchId: string): Promise<{ batchId: string; total: number; sent: number; failed: number }> {
return this.fetch('/emails/bulk-send', {
method: 'POST',
body: JSON.stringify({ batchId }),
});
}
async exportClientsCSV(): Promise<void> { async exportClientsCSV(): Promise<void> {
const token = this.getToken(); const token = this.getToken();
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};

View File

@@ -16,6 +16,8 @@ import Modal from '@/components/Modal';
import ClientForm from '@/components/ClientForm'; import ClientForm from '@/components/ClientForm';
import EmailComposeModal from '@/components/EmailComposeModal'; import EmailComposeModal from '@/components/EmailComposeModal';
import ClientNotes from '@/components/ClientNotes'; import ClientNotes from '@/components/ClientNotes';
import LogInteractionModal from '@/components/LogInteractionModal';
import type { Interaction } from '@/types';
export default function ClientDetailPage() { export default function ClientDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -25,8 +27,10 @@ export default function ClientDetailPage() {
const [emails, setEmails] = useState<Email[]>([]); const [emails, setEmails] = useState<Email[]>([]);
const [activities, setActivities] = useState<ActivityItem[]>([]); const [activities, setActivities] = useState<ActivityItem[]>([]);
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info'); const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info');
const [interactions, setInteractions] = useState<Interaction[]>([]);
const [showEdit, setShowEdit] = useState(false); const [showEdit, setShowEdit] = useState(false);
const [showCompose, setShowCompose] = useState(false); const [showCompose, setShowCompose] = useState(false);
const [showLogInteraction, setShowLogInteraction] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const { togglePin, isPinned } = usePinnedClients(); const { togglePin, isPinned } = usePinnedClients();
@@ -36,6 +40,7 @@ export default function ClientDetailPage() {
api.getEvents({ clientId: id }).then(setEvents).catch(() => {}); api.getEvents({ clientId: id }).then(setEvents).catch(() => {});
api.getEmails({ clientId: id }).then(setEmails).catch(() => {}); api.getEmails({ clientId: id }).then(setEmails).catch(() => {});
api.getClientActivity(id).then(setActivities).catch(() => {}); api.getClientActivity(id).then(setActivities).catch(() => {});
api.getClientInteractions(id).then(setInteractions).catch(() => {});
} }
}, [id, fetchClient]); }, [id, fetchClient]);
@@ -111,7 +116,11 @@ export default function ClientDetailPage() {
<CheckCircle2 className="w-4 h-4" /> <CheckCircle2 className="w-4 h-4" />
<span className="hidden sm:inline">Contacted</span> <span className="hidden sm:inline">Contacted</span>
</button> </button>
<button onClick={() => setShowCompose(true)} className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors"> <button onClick={() => setShowLogInteraction(true)} className="flex items-center gap-2 px-3 py-2 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 rounded-lg text-sm font-medium hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
<Phone className="w-4 h-4" />
<span className="hidden sm:inline">Log Interaction</span>
</button>
<button onClick={() => setShowCompose(true)} className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded-lg text-sm font-medium hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors">
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
<span className="hidden sm:inline">Generate Email</span> <span className="hidden sm:inline">Generate Email</span>
</button> </button>
@@ -260,12 +269,13 @@ export default function ClientDetailPage() {
<div className="relative"> <div className="relative">
{activities.map((item, index) => { {activities.map((item, index) => {
const iconMap: Record<string, { icon: typeof Mail; color: string; bg: string }> = { const iconMap: Record<string, { icon: typeof Mail; color: string; bg: string }> = {
email_sent: { icon: Send, color: 'text-emerald-600', bg: 'bg-emerald-100' }, email_sent: { icon: Send, color: 'text-emerald-600', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
email_drafted: { icon: FileText, color: 'text-amber-600', bg: 'bg-amber-100' }, email_drafted: { icon: FileText, color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/30' },
event_created: { icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-100' }, event_created: { icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-100 dark:bg-blue-900/30' },
client_contacted: { icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-100' }, client_contacted: { icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
client_created: { icon: UserPlus, color: 'text-purple-600', bg: 'bg-purple-100' }, client_created: { icon: UserPlus, color: 'text-purple-600', bg: 'bg-purple-100 dark:bg-purple-900/30' },
client_updated: { icon: RefreshCw, color: 'text-slate-600', bg: 'bg-slate-100' }, client_updated: { icon: RefreshCw, color: 'text-slate-600', bg: 'bg-slate-100 dark:bg-slate-700' },
interaction: { icon: Phone, color: 'text-indigo-600 dark:text-indigo-400', bg: 'bg-indigo-100 dark:bg-indigo-900/30' },
}; };
const { icon: Icon, color, bg } = iconMap[item.type] || iconMap.client_updated; const { icon: Icon, color, bg } = iconMap[item.type] || iconMap.client_updated;
@@ -344,6 +354,22 @@ export default function ClientDetailPage() {
clientName={`${client.firstName} ${client.lastName}`} clientName={`${client.firstName} ${client.lastName}`}
onGenerated={(email) => setEmails((prev) => [email, ...prev])} onGenerated={(email) => setEmails((prev) => [email, ...prev])}
/> />
{/* Log Interaction Modal */}
<LogInteractionModal
isOpen={showLogInteraction}
onClose={() => setShowLogInteraction(false)}
clientId={client.id}
clientName={`${client.firstName} ${client.lastName}`}
onCreated={() => {
// Refresh interactions and activity
if (id) {
api.getClientInteractions(id).then(setInteractions).catch(() => {});
api.getClientActivity(id).then(setActivities).catch(() => {});
fetchClient(id); // refresh lastContactedAt
}
}}
/>
</div> </div>
); );
} }

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { Client, Event, Email, InsightsData } from '@/types'; import type { Client, Event, Email, InsightsData } from '@/types';
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded, Star } from 'lucide-react'; import type { Interaction } from '@/types';
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react';
import { formatDate, getDaysUntil, getInitials } from '@/lib/utils'; import { formatDate, getDaysUntil, getInitials } from '@/lib/utils';
import { EventTypeBadge } from '@/components/Badge'; import { EventTypeBadge } from '@/components/Badge';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
@@ -13,6 +14,7 @@ export default function DashboardPage() {
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [emails, setEmails] = useState<Email[]>([]); const [emails, setEmails] = useState<Email[]>([]);
const [insights, setInsights] = useState<InsightsData | null>(null); const [insights, setInsights] = useState<InsightsData | null>(null);
const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { pinnedIds, togglePin, isPinned } = usePinnedClients(); const { pinnedIds, togglePin, isPinned } = usePinnedClients();
@@ -22,11 +24,13 @@ export default function DashboardPage() {
api.getEvents({ upcoming: 7 }).catch(() => []), api.getEvents({ upcoming: 7 }).catch(() => []),
api.getEmails({ status: 'draft' }).catch(() => []), api.getEmails({ status: 'draft' }).catch(() => []),
api.getInsights().catch(() => null), api.getInsights().catch(() => null),
]).then(([c, e, em, ins]) => { api.getRecentInteractions(5).catch(() => []),
]).then(([c, e, em, ins, ri]) => {
setClients(c); setClients(c);
setEvents(e); setEvents(e);
setEmails(em); setEmails(em);
setInsights(ins as InsightsData | null); setInsights(ins as InsightsData | null);
setRecentInteractions(ri as Interaction[]);
setLoading(false); setLoading(false);
}); });
}, []); }, []);
@@ -248,6 +252,51 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Recent Interactions */}
<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">
<Phone className="w-4 h-4 text-indigo-500" />
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Recent Interactions</h2>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{recentInteractions.length === 0 ? (
<p className="px-5 py-8 text-center text-sm text-slate-400 dark:text-slate-500">No interactions logged yet</p>
) : (
recentInteractions.map((interaction) => {
const typeIcons: Record<string, typeof Phone> = {
call: Phone, meeting: Users, email: Mail, note: FileText, other: MoreHorizontal,
};
const typeColors: Record<string, string> = {
call: 'text-green-600 dark:text-green-400', meeting: 'text-blue-600 dark:text-blue-400',
email: 'text-purple-600 dark:text-purple-400', note: 'text-amber-600 dark:text-amber-400', other: 'text-slate-500',
};
const Icon = typeIcons[interaction.type] || MoreHorizontal;
return (
<Link
key={interaction.id}
to={`/clients/${interaction.clientId}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
<Icon className={`w-4 h-4 ${typeColors[interaction.type] || 'text-slate-400'}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{interaction.title}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
{interaction.client ? `${interaction.client.firstName} ${interaction.client.lastName}` : ''}
{interaction.duration ? ` · ${interaction.duration}min` : ''}
</p>
</div>
<span className="text-xs text-slate-400 dark:text-slate-500 whitespace-nowrap">
{formatDate(interaction.contactedAt)}
</span>
</Link>
);
})
)}
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6">
{/* Recent Clients */} {/* Recent Clients */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl"> <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-slate-700"> <div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-slate-700">

View File

@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useEmailsStore } from '@/stores/emails'; import { useEmailsStore } from '@/stores/emails';
import { useClientsStore } from '@/stores/clients'; import { useClientsStore } from '@/stores/clients';
import { Mail, Send, Trash2, Edit3, Sparkles, Gift } from 'lucide-react'; import { Mail, Send, Trash2, Edit3, Sparkles, Gift, Users } from 'lucide-react';
import { cn, formatDate } from '@/lib/utils'; import { cn, formatDate } from '@/lib/utils';
import { EmailStatusBadge } from '@/components/Badge'; import { EmailStatusBadge } from '@/components/Badge';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
import LoadingSpinner from '@/components/LoadingSpinner'; import LoadingSpinner from '@/components/LoadingSpinner';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import BulkEmailModal from '@/components/BulkEmailModal';
export default function EmailsPage() { export default function EmailsPage() {
const { emails, isLoading, isGenerating, statusFilter, setStatusFilter, fetchEmails, generateEmail, generateBirthdayEmail, updateEmail, sendEmail, deleteEmail } = useEmailsStore(); const { emails, isLoading, isGenerating, statusFilter, setStatusFilter, fetchEmails, generateEmail, generateBirthdayEmail, updateEmail, sendEmail, deleteEmail } = useEmailsStore();
@@ -17,6 +18,7 @@ export default function EmailsPage() {
const [editSubject, setEditSubject] = useState(''); const [editSubject, setEditSubject] = useState('');
const [editContent, setEditContent] = useState(''); const [editContent, setEditContent] = useState('');
const [composeForm, setComposeForm] = useState({ clientId: '', purpose: '', provider: 'anthropic' as 'anthropic' | 'openai' }); const [composeForm, setComposeForm] = useState({ clientId: '', purpose: '', provider: 'anthropic' as 'anthropic' | 'openai' });
const [showBulk, setShowBulk] = useState(false);
useEffect(() => { useEffect(() => {
fetchEmails(); fetchEmails();
@@ -69,13 +71,22 @@ export default function EmailsPage() {
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Emails</h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Emails</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">AI-generated emails for your network</p> <p className="text-slate-500 dark:text-slate-400 text-sm mt-1">AI-generated emails for your network</p>
</div> </div>
<button <div className="flex gap-2">
onClick={() => setShowCompose(true)} <button
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors" onClick={() => setShowBulk(true)}
> className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
<Sparkles className="w-4 h-4" /> >
Compose <Users className="w-4 h-4" />
</button> Bulk Compose
</button>
<button
onClick={() => setShowCompose(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
<Sparkles className="w-4 h-4" />
Compose
</button>
</div>
</div> </div>
{/* Filters */} {/* Filters */}
@@ -174,6 +185,14 @@ export default function EmailsPage() {
</div> </div>
)} )}
{/* Bulk Email Modal */}
<BulkEmailModal
isOpen={showBulk}
onClose={() => setShowBulk(false)}
clients={clients}
onComplete={() => fetchEmails()}
/>
{/* Compose Modal */} {/* Compose Modal */}
<Modal isOpen={showCompose} onClose={() => setShowCompose(false)} title="Generate Email" size="md"> <Modal isOpen={showCompose} onClose={() => setShowCompose(false)} title="Generate Email" size="md">
<div className="space-y-4"> <div className="space-y-4">

View File

@@ -206,6 +206,44 @@ export interface ImportResult {
errors: string[]; errors: string[];
} }
export interface Notification {
id: string;
userId: string;
type: string; // 'event_reminder' | 'interaction' | 'system'
title: string;
message: string;
read: boolean;
clientId?: string;
eventId?: string;
client?: { id: string; firstName: string; lastName: string } | null;
createdAt: string;
}
export interface Interaction {
id: string;
userId: string;
clientId: string;
type: 'call' | 'meeting' | 'email' | 'note' | 'other';
title: string;
description?: string;
duration?: number; // minutes
contactedAt: string;
createdAt: string;
client?: { id: string; firstName: string; lastName: string };
}
export interface BulkEmailResult {
batchId: string;
results: Array<{
clientId: string;
email?: Email;
error?: string;
success: boolean;
}>;
total: number;
generated: number;
}
export interface Invite { export interface Invite {
id: string; id: string;
email: string; email: string;