feat: real notifications, interaction logging, bulk email compose

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

@@ -16,6 +16,8 @@ import Modal from '@/components/Modal';
import ClientForm from '@/components/ClientForm';
import EmailComposeModal from '@/components/EmailComposeModal';
import ClientNotes from '@/components/ClientNotes';
import LogInteractionModal from '@/components/LogInteractionModal';
import type { Interaction } from '@/types';
export default function ClientDetailPage() {
const { id } = useParams<{ id: string }>();
@@ -25,8 +27,10 @@ export default function ClientDetailPage() {
const [emails, setEmails] = useState<Email[]>([]);
const [activities, setActivities] = useState<ActivityItem[]>([]);
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info');
const [interactions, setInteractions] = useState<Interaction[]>([]);
const [showEdit, setShowEdit] = useState(false);
const [showCompose, setShowCompose] = useState(false);
const [showLogInteraction, setShowLogInteraction] = useState(false);
const [deleting, setDeleting] = useState(false);
const { togglePin, isPinned } = usePinnedClients();
@@ -36,6 +40,7 @@ export default function ClientDetailPage() {
api.getEvents({ clientId: id }).then(setEvents).catch(() => {});
api.getEmails({ clientId: id }).then(setEmails).catch(() => {});
api.getClientActivity(id).then(setActivities).catch(() => {});
api.getClientInteractions(id).then(setInteractions).catch(() => {});
}
}, [id, fetchClient]);
@@ -111,7 +116,11 @@ export default function ClientDetailPage() {
<CheckCircle2 className="w-4 h-4" />
<span className="hidden sm:inline">Contacted</span>
</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" />
<span className="hidden sm:inline">Generate Email</span>
</button>
@@ -260,12 +269,13 @@ export default function ClientDetailPage() {
<div className="relative">
{activities.map((item, index) => {
const iconMap: Record<string, { icon: typeof Mail; color: string; bg: string }> = {
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' },
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 dark:bg-amber-900/30' },
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 dark:bg-emerald-900/30' },
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 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;
@@ -344,6 +354,22 @@ export default function ClientDetailPage() {
clientName={`${client.firstName} ${client.lastName}`}
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>
);
}

View File

@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
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 { EventTypeBadge } from '@/components/Badge';
import { PageLoader } from '@/components/LoadingSpinner';
@@ -13,6 +14,7 @@ export default function DashboardPage() {
const [events, setEvents] = useState<Event[]>([]);
const [emails, setEmails] = useState<Email[]>([]);
const [insights, setInsights] = useState<InsightsData | null>(null);
const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]);
const [loading, setLoading] = useState(true);
const { pinnedIds, togglePin, isPinned } = usePinnedClients();
@@ -22,11 +24,13 @@ export default function DashboardPage() {
api.getEvents({ upcoming: 7 }).catch(() => []),
api.getEmails({ status: 'draft' }).catch(() => []),
api.getInsights().catch(() => null),
]).then(([c, e, em, ins]) => {
api.getRecentInteractions(5).catch(() => []),
]).then(([c, e, em, ins, ri]) => {
setClients(c);
setEvents(e);
setEmails(em);
setInsights(ins as InsightsData | null);
setRecentInteractions(ri as Interaction[]);
setLoading(false);
});
}, []);
@@ -248,6 +252,51 @@ export default function DashboardPage() {
</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 */}
<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">

View File

@@ -1,13 +1,14 @@
import { useEffect, useState } from 'react';
import { useEmailsStore } from '@/stores/emails';
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 { EmailStatusBadge } from '@/components/Badge';
import EmptyState from '@/components/EmptyState';
import { PageLoader } from '@/components/LoadingSpinner';
import LoadingSpinner from '@/components/LoadingSpinner';
import Modal from '@/components/Modal';
import BulkEmailModal from '@/components/BulkEmailModal';
export default function EmailsPage() {
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 [editContent, setEditContent] = useState('');
const [composeForm, setComposeForm] = useState({ clientId: '', purpose: '', provider: 'anthropic' as 'anthropic' | 'openai' });
const [showBulk, setShowBulk] = useState(false);
useEffect(() => {
fetchEmails();
@@ -69,13 +71,22 @@ export default function EmailsPage() {
<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>
</div>
<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 className="flex gap-2">
<button
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"
>
<Users className="w-4 h-4" />
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>
{/* Filters */}
@@ -174,6 +185,14 @@ export default function EmailsPage() {
</div>
)}
{/* Bulk Email Modal */}
<BulkEmailModal
isOpen={showBulk}
onClose={() => setShowBulk(false)}
clients={clients}
onComplete={() => fetchEmails()}
/>
{/* Compose Modal */}
<Modal isOpen={showCompose} onClose={() => setShowCompose(false)} title="Generate Email" size="md">
<div className="space-y-4">