feat: real notifications, interaction logging, bulk email compose
This commit is contained in:
380
src/components/BulkEmailModal.tsx
Normal file
380
src/components/BulkEmailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/LogInteractionModal.tsx
Normal file
162
src/components/LogInteractionModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,36 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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';
|
||||
|
||||
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> = {
|
||||
overdue: 'text-red-500 bg-red-50 dark:bg-red-900/30',
|
||||
upcoming: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
|
||||
stale: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30',
|
||||
drafts: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
|
||||
event_reminder: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
|
||||
interaction: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30',
|
||||
system: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
|
||||
};
|
||||
|
||||
const priorityDot: Record<string, string> = {
|
||||
high: 'bg-red-500',
|
||||
medium: 'bg-amber-400',
|
||||
low: 'bg-slate-300 dark:bg-slate-500',
|
||||
};
|
||||
function timeAgo(dateStr: string) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
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() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [counts, setCounts] = useState<NotifCounts | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
||||
const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -68,21 +46,38 @@ export default function NotificationBell() {
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const data = await api.getNotifications();
|
||||
const data = await api.getNotifications({ limit: 30 });
|
||||
setNotifications(data.notifications || []);
|
||||
setCounts(data.counts || null);
|
||||
setUnreadCount(data.unreadCount || 0);
|
||||
} catch {
|
||||
// Silently fail
|
||||
// Silently fail - API might not have notifications table yet
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setDismissed(prev => new Set(prev).add(id));
|
||||
const markRead = async (id: string) => {
|
||||
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 activeCount = visibleNotifs.length;
|
||||
const highCount = visibleNotifs.filter(n => n.priority === 'high').length;
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
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 (
|
||||
<div ref={ref} className="relative">
|
||||
@@ -94,12 +89,9 @@ export default function NotificationBell() {
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{activeCount > 0 && (
|
||||
<span className={cn(
|
||||
'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',
|
||||
highCount > 0 ? 'bg-red-500' : 'bg-blue-500'
|
||||
)}>
|
||||
{activeCount > 99 ? '99+' : activeCount}
|
||||
{unreadCount > 0 && (
|
||||
<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">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</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="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>
|
||||
{counts && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
{counts.overdue > 0 && (
|
||||
<span className="text-red-500 dark:text-red-400 font-medium">{counts.overdue} overdue</span>
|
||||
)}
|
||||
{counts.upcoming > 0 && (
|
||||
<span>{counts.upcoming} upcoming</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All read'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
) : (
|
||||
visibleNotifs.map(n => {
|
||||
const Icon = typeIcons[n.type] || Calendar;
|
||||
return (
|
||||
<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">
|
||||
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 text-slate-500')}>
|
||||
<Icon className="w-4 h-4" />
|
||||
notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3 border-b border-slate-50 dark:border-slate-700 last:border-0 transition-colors',
|
||||
!n.read && 'bg-blue-50/50 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<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')}>
|
||||
<Bell className="w-4 h-4" />
|
||||
</div>
|
||||
<Link to={n.link} onClick={() => setOpen(false)} className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => !n.read && markRead(n.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', priorityDot[n.priority])} />
|
||||
{!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-400 dark:text-slate-500 mt-0.5">{n.description}</p>
|
||||
<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={() => 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"
|
||||
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>
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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
|
||||
? 'https://api.thenetwork.donovankelly.xyz/api'
|
||||
@@ -448,10 +448,78 @@ class ApiClient {
|
||||
return this.fetch('/reports/engagement');
|
||||
}
|
||||
|
||||
async getNotifications(): Promise<any> {
|
||||
async getNotificationsLegacy(): Promise<any> {
|
||||
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> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,6 +71,14 @@ 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>
|
||||
<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"
|
||||
@@ -77,6 +87,7 @@ export default function EmailsPage() {
|
||||
Compose
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
@@ -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">
|
||||
|
||||
@@ -206,6 +206,44 @@ export interface ImportResult {
|
||||
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 {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
Reference in New Issue
Block a user