Compare commits

..

6 Commits

Author SHA1 Message Date
7a956aebec feat: production hardening UI - tags page, onboarding wizard, pagination
Some checks failed
CI/CD / test (push) Failing after 21s
CI/CD / deploy (push) Has been skipped
- Tags management page: grid cards, rename/delete/merge modals, color-coded
- Onboarding wizard: 4-step full-screen flow for new users (welcome, client, style, tour)
- Client list pagination: page controls, page size selector, URL query params
- Pipeline view unaffected (shows all clients)
- Tags added to sidebar navigation
- All components support dark mode
2026-01-30 01:37:40 +00:00
1340893144 feat: audit log page, meeting prep modal, communication style, error boundaries + toast
- AuditLogPage: filterable table with expandable details (admin only)
- MeetingPrepModal: AI-generated meeting briefs with health score, talking points, conversation starters
- Communication Style section in Settings: tone, greeting, signoff, writing samples, avoid words
- ErrorBoundary wrapping all page routes with Try Again button
- Global toast system with API error interceptor (401/403/500)
- ToastContainer with success/error/warning/info variants
- Print CSS for meeting prep
- Audit Log added to sidebar nav for admins
- All 80 frontend tests pass, clean build
2026-01-30 01:21:26 +00:00
22bf4778fd feat: email templates page + client segments page with advanced filters
- Templates page: create/edit/delete/duplicate templates, category filters, placeholder insertion buttons, usage tracking
- Segments page: create/edit/delete segments with multi-criteria filter builder, preview matching clients, color picker, pin favorites
- Filter panel: multi-select dropdowns for stage/industry/tags/city/state, date range pickers, contact info toggles, search
- Added Templates + Segments to sidebar nav
- Both pages support dark mode
2026-01-30 01:07:41 +00:00
691e8170f3 feat: real notifications, interaction logging, bulk email compose 2026-01-30 00:48:13 +00:00
b43bdf3c71 feat: client pipeline view + notes tab + stage badges
- Pipeline/kanban view on Clients page (toggle grid/pipeline)
- Pipeline summary bar showing client distribution across stages
- Stage badge on client cards and detail page (click to cycle)
- Notes tab on ClientDetailPage with add/edit/pin/delete
- StageBadge component with color-coded labels
- Stage selector in ClientForm
- API client methods for notes CRUD
2026-01-30 00:35:50 +00:00
38761586e7 ci: add Gitea Actions CI/CD workflow 2026-01-29 23:18:10 +00:00
24 changed files with 3905 additions and 164 deletions

View File

@@ -3,6 +3,9 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import Layout from '@/components/Layout'; import Layout from '@/components/Layout';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
import ErrorBoundary from '@/components/ErrorBoundary';
import { ToastContainer, toast } from '@/components/Toast';
import { api } from '@/lib/api';
const LoginPage = lazy(() => import('@/pages/LoginPage')); const LoginPage = lazy(() => import('@/pages/LoginPage'));
const DashboardPage = lazy(() => import('@/pages/DashboardPage')); const DashboardPage = lazy(() => import('@/pages/DashboardPage'));
@@ -14,9 +17,13 @@ const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
const AdminPage = lazy(() => import('@/pages/AdminPage')); const AdminPage = lazy(() => import('@/pages/AdminPage'));
const NetworkPage = lazy(() => import('@/pages/NetworkPage')); const NetworkPage = lazy(() => import('@/pages/NetworkPage'));
const ReportsPage = lazy(() => import('@/pages/ReportsPage')); const ReportsPage = lazy(() => import('@/pages/ReportsPage'));
const TemplatesPage = lazy(() => import('@/pages/TemplatesPage'));
const SegmentsPage = lazy(() => import('@/pages/SegmentsPage'));
const InvitePage = lazy(() => import('@/pages/InvitePage')); const InvitePage = lazy(() => import('@/pages/InvitePage'));
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage')); const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')); const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
const TagsPage = lazy(() => import('@/pages/TagsPage'));
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore(); const { isAuthenticated, isLoading } = useAuthStore();
@@ -25,6 +32,21 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
// Setup global API error interceptor
api.setErrorHandler((status, message) => {
if (status === 401) {
toast.error('Session expired. Please log in again.');
} else if (status === 403) {
toast.error('Access denied: ' + message);
} else if (status >= 500) {
toast.error('Server error: ' + message);
}
});
function PageErrorBoundary({ children }: { children: React.ReactNode }) {
return <ErrorBoundary>{children}</ErrorBoundary>;
}
export default function App() { export default function App() {
const { checkSession, isAuthenticated } = useAuthStore(); const { checkSession, isAuthenticated } = useAuthStore();
@@ -37,28 +59,33 @@ export default function App() {
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<Routes> <Routes>
<Route path="/login" element={ <Route path="/login" element={
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage /> isAuthenticated ? <Navigate to="/" replace /> : <PageErrorBoundary><LoginPage /></PageErrorBoundary>
} /> } />
<Route path="/invite/:token" element={<InvitePage />} /> <Route path="/invite/:token" element={<PageErrorBoundary><InvitePage /></PageErrorBoundary>} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/forgot-password" element={<PageErrorBoundary><ForgotPasswordPage /></PageErrorBoundary>} />
<Route path="/reset-password/:token" element={<ResetPasswordPage />} /> <Route path="/reset-password/:token" element={<PageErrorBoundary><ResetPasswordPage /></PageErrorBoundary>} />
<Route path="/" element={ <Route path="/" element={
<ProtectedRoute> <ProtectedRoute>
<Layout /> <Layout />
</ProtectedRoute> </ProtectedRoute>
}> }>
<Route index element={<DashboardPage />} /> <Route index element={<PageErrorBoundary><DashboardPage /></PageErrorBoundary>} />
<Route path="clients" element={<ClientsPage />} /> <Route path="clients" element={<PageErrorBoundary><ClientsPage /></PageErrorBoundary>} />
<Route path="clients/:id" element={<ClientDetailPage />} /> <Route path="clients/:id" element={<PageErrorBoundary><ClientDetailPage /></PageErrorBoundary>} />
<Route path="events" element={<EventsPage />} /> <Route path="events" element={<PageErrorBoundary><EventsPage /></PageErrorBoundary>} />
<Route path="emails" element={<EmailsPage />} /> <Route path="emails" element={<PageErrorBoundary><EmailsPage /></PageErrorBoundary>} />
<Route path="network" element={<NetworkPage />} /> <Route path="network" element={<PageErrorBoundary><NetworkPage /></PageErrorBoundary>} />
<Route path="reports" element={<ReportsPage />} /> <Route path="reports" element={<PageErrorBoundary><ReportsPage /></PageErrorBoundary>} />
<Route path="settings" element={<SettingsPage />} /> <Route path="templates" element={<PageErrorBoundary><TemplatesPage /></PageErrorBoundary>} />
<Route path="admin" element={<AdminPage />} /> <Route path="tags" element={<PageErrorBoundary><TagsPage /></PageErrorBoundary>} />
<Route path="segments" element={<PageErrorBoundary><SegmentsPage /></PageErrorBoundary>} />
<Route path="settings" element={<PageErrorBoundary><SettingsPage /></PageErrorBoundary>} />
<Route path="admin" element={<PageErrorBoundary><AdminPage /></PageErrorBoundary>} />
<Route path="audit-log" element={<PageErrorBoundary><AuditLogPage /></PageErrorBoundary>} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>
<ToastContainer />
</BrowserRouter> </BrowserRouter>
); );
} }

View File

@@ -53,3 +53,24 @@ export function EmailStatusBadge({ status }: { status: string }) {
}; };
return <Badge color={colors[status] || 'gray'}>{status}</Badge>; return <Badge color={colors[status] || 'gray'}>{status}</Badge>;
} }
const stageLabels: Record<string, string> = {
lead: 'Lead',
prospect: 'Prospect',
onboarding: 'Onboarding',
active: 'Active',
inactive: 'Inactive',
};
const stageColors: Record<string, keyof typeof colorMap> = {
lead: 'gray',
prospect: 'blue',
onboarding: 'yellow',
active: 'green',
inactive: 'red',
};
export function StageBadge({ stage, onClick }: { stage?: string; onClick?: () => void }) {
const s = stage || 'lead';
return <Badge color={stageColors[s] || 'gray'} onClick={onClick}>{stageLabels[s] || s}</Badge>;
}

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

@@ -28,6 +28,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
family: initialData?.family || { spouse: '', children: [] }, family: initialData?.family || { spouse: '', children: [] },
notes: initialData?.notes || '', notes: initialData?.notes || '',
tags: initialData?.tags || [], tags: initialData?.tags || [],
stage: initialData?.stage || 'lead',
}); });
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
@@ -109,6 +110,18 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
</div> </div>
</div> </div>
{/* Stage */}
<div>
<label className={labelClass}>Pipeline Stage</label>
<select value={form.stage || 'lead'} onChange={(e) => update('stage', e.target.value)} className={inputClass}>
<option value="lead">Lead</option>
<option value="prospect">Prospect</option>
<option value="onboarding">Onboarding</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
{/* Address */} {/* Address */}
<div> <div>
<label className={labelClass}>Street</label> <label className={labelClass}>Street</label>

View File

@@ -0,0 +1,214 @@
import { useEffect, useState, useRef } from 'react';
import { api } from '@/lib/api';
import type { ClientNote } from '@/types';
import { Pin, Trash2, Edit3, Check, X, Plus, StickyNote } from 'lucide-react';
import { cn, getRelativeTime } from '@/lib/utils';
interface ClientNotesProps {
clientId: string;
}
export default function ClientNotes({ clientId }: ClientNotesProps) {
const [notes, setNotes] = useState<ClientNote[]>([]);
const [loading, setLoading] = useState(true);
const [newNote, setNewNote] = useState('');
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fetchNotes = async () => {
try {
const data = await api.getClientNotes(clientId);
setNotes(data);
} catch {
// ignore
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchNotes();
}, [clientId]);
const handleAdd = async () => {
if (!newNote.trim()) return;
setAdding(true);
try {
const note = await api.createClientNote(clientId, newNote.trim());
setNotes(prev => [note, ...prev]);
setNewNote('');
} catch {
// ignore
} finally {
setAdding(false);
}
};
const handleDelete = async (noteId: string) => {
if (!confirm('Delete this note?')) return;
try {
await api.deleteClientNote(clientId, noteId);
setNotes(prev => prev.filter(n => n.id !== noteId));
} catch {
// ignore
}
};
const handleTogglePin = async (note: ClientNote) => {
try {
const updated = await api.updateClientNote(clientId, note.id, { pinned: !note.pinned });
setNotes(prev => prev.map(n => n.id === note.id ? updated : n)
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}));
} catch {
// ignore
}
};
const handleStartEdit = (note: ClientNote) => {
setEditingId(note.id);
setEditContent(note.content);
};
const handleSaveEdit = async (noteId: string) => {
if (!editContent.trim()) return;
try {
const updated = await api.updateClientNote(clientId, noteId, { content: editContent.trim() });
setNotes(prev => prev.map(n => n.id === noteId ? updated : n));
setEditingId(null);
} catch {
// ignore
}
};
if (loading) {
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-8 text-center">
<p className="text-sm text-slate-400">Loading notes...</p>
</div>
);
}
return (
<div className="space-y-4">
{/* New note input */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<textarea
ref={textareaRef}
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleAdd();
}
}}
placeholder="Add a note... (⌘+Enter to save)"
className="w-full bg-transparent text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 resize-none outline-none min-h-[80px]"
rows={3}
/>
<div className="flex justify-end mt-2">
<button
onClick={handleAdd}
disabled={adding || !newNote.trim()}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
newNote.trim()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400 cursor-not-allowed'
)}
>
<Plus className="w-4 h-4" />
Add Note
</button>
</div>
</div>
{/* Notes list */}
{notes.length === 0 ? (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-8 text-center">
<StickyNote className="w-8 h-8 text-slate-300 dark:text-slate-600 mx-auto mb-2" />
<p className="text-sm text-slate-400">No notes yet. Add your first note above.</p>
</div>
) : (
<div className="space-y-3">
{notes.map(note => (
<div
key={note.id}
className={cn(
'bg-white dark:bg-slate-800 border rounded-xl p-4 group',
note.pinned
? 'border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/10'
: 'border-slate-200 dark:border-slate-700'
)}
>
{editingId === note.id ? (
<div>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
className="w-full bg-transparent text-sm text-slate-900 dark:text-slate-100 resize-none outline-none min-h-[60px] border border-slate-200 dark:border-slate-600 rounded-lg p-2"
rows={3}
autoFocus
/>
<div className="flex gap-2 mt-2 justify-end">
<button
onClick={() => setEditingId(null)}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 rounded transition-colors"
>
<X className="w-3.5 h-3.5" /> Cancel
</button>
<button
onClick={() => handleSaveEdit(note.id)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
<Check className="w-3.5 h-3.5" /> Save
</button>
</div>
</div>
) : (
<>
<p className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap">{note.content}</p>
<div className="flex items-center justify-between mt-3">
<span className="text-xs text-slate-400">{getRelativeTime(note.createdAt)}</span>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleTogglePin(note)}
className={cn(
'p-1 rounded transition-colors',
note.pinned ? 'text-amber-500 hover:text-amber-600' : 'text-slate-400 hover:text-amber-500'
)}
title={note.pinned ? 'Unpin' : 'Pin'}
>
<Pin className={cn('w-3.5 h-3.5', note.pinned && 'fill-amber-500')} />
</button>
<button
onClick={() => handleStartEdit(note)}
className="p-1 rounded text-slate-400 hover:text-blue-500 transition-colors"
title="Edit"
>
<Edit3 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(note.id)}
className="p-1 rounded text-slate-400 hover:text-red-500 transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Component, type ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center min-h-[300px] p-8">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<AlertTriangle className="w-8 h-8 text-red-600 dark:text-red-400" />
</div>
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
Something went wrong
</h2>
<p className="text-sm text-slate-500 dark:text-slate-400 text-center mb-4 max-w-md">
An unexpected error occurred. Please try again or refresh the page.
</p>
{this.state.error && (
<details className="mb-4 max-w-md w-full">
<summary className="text-xs text-slate-400 cursor-pointer hover:text-slate-600 dark:hover:text-slate-300">
Error details
</summary>
<pre className="mt-2 p-3 bg-slate-100 dark:bg-slate-800 rounded-lg text-xs text-red-600 dark:text-red-400 overflow-auto max-h-32">
{this.state.error.message}
</pre>
</details>
)}
<button
onClick={this.handleReset}
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"
>
<RefreshCw className="w-4 h-4" />
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -1,14 +1,17 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Link, useLocation, Outlet } from 'react-router-dom'; import { Link, useLocation, Outlet } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { api } from '@/lib/api';
import { import {
LayoutDashboard, Users, Calendar, Mail, Settings, Shield, LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command, LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
FileText, Bookmark, ScrollText, Tag,
} from 'lucide-react'; } from 'lucide-react';
import NotificationBell from './NotificationBell'; import NotificationBell from './NotificationBell';
import CommandPalette from './CommandPalette'; import CommandPalette from './CommandPalette';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
import OnboardingWizard from './OnboardingWizard';
const baseNavItems = [ const baseNavItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard }, { path: '/', label: 'Dashboard', icon: LayoutDashboard },
@@ -16,18 +19,38 @@ const baseNavItems = [
{ path: '/events', label: 'Events', icon: Calendar }, { path: '/events', label: 'Events', icon: Calendar },
{ path: '/emails', label: 'Emails', icon: Mail }, { path: '/emails', label: 'Emails', icon: Mail },
{ path: '/network', label: 'Network', icon: Network }, { path: '/network', label: 'Network', icon: Network },
{ path: '/templates', label: 'Templates', icon: FileText },
{ path: '/tags', label: 'Tags', icon: Tag },
{ path: '/segments', label: 'Segments', icon: Bookmark },
{ path: '/reports', label: 'Reports', icon: BarChart3 }, { path: '/reports', label: 'Reports', icon: BarChart3 },
{ path: '/settings', label: 'Settings', icon: Settings }, { path: '/settings', label: 'Settings', icon: Settings },
]; ];
const adminNavItem = { path: '/admin', label: 'Admin', icon: Shield }; const adminNavItems = [
{ path: '/admin', label: 'Admin', icon: Shield },
{ path: '/audit-log', label: 'Audit Log', icon: ScrollText },
];
export default function Layout() { export default function Layout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const navItems = user?.role === 'admin' ? [...baseNavItems, adminNavItem] : baseNavItems; const navItems = user?.role === 'admin' ? [...baseNavItems, ...adminNavItems] : baseNavItems;
// Check onboarding status on mount
useEffect(() => {
let cancelled = false;
api.getOnboardingStatus()
.then((status) => {
if (!cancelled && !status.onboardingComplete) {
setShowOnboarding(true);
}
})
.catch(() => { /* ignore - user might not have profile yet */ });
return () => { cancelled = true; };
}, []);
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
@@ -35,6 +58,12 @@ export default function Layout() {
return ( return (
<div className="flex h-screen bg-slate-50 dark:bg-slate-900"> <div className="flex h-screen bg-slate-50 dark:bg-slate-900">
{showOnboarding && (
<OnboardingWizard
userName={user?.name || ''}
onComplete={() => setShowOnboarding(false)}
/>
)}
<CommandPalette /> <CommandPalette />
{/* Mobile overlay */} {/* Mobile overlay */}
{mobileOpen && ( {mobileOpen && (

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

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import { api } from '@/lib/api';
import type { MeetingPrep } from '@/types';
import Modal from './Modal';
import {
Briefcase, Heart, MessageSquare, CheckSquare, Calendar,
FileText, Star, Printer, Sparkles, TrendingUp, Clock, AlertCircle,
} from 'lucide-react';
import LoadingSpinner from './LoadingSpinner';
import { cn, formatDate } from '@/lib/utils';
interface Props {
isOpen: boolean;
onClose: () => void;
clientId: string;
clientName: string;
}
function HealthScoreBadge({ score }: { score: number }) {
const color = score >= 80 ? 'text-emerald-600 bg-emerald-100 dark:bg-emerald-900/30 dark:text-emerald-400'
: score >= 50 ? 'text-amber-600 bg-amber-100 dark:bg-amber-900/30 dark:text-amber-400'
: 'text-red-600 bg-red-100 dark:bg-red-900/30 dark:text-red-400';
const label = score >= 80 ? 'Strong' : score >= 50 ? 'Moderate' : 'Needs Attention';
return (
<div className={cn('flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium', color)}>
<TrendingUp className="w-4 h-4" />
{score}/100 · {label}
</div>
);
}
export default function MeetingPrepModal({ isOpen, onClose, clientId, clientName }: Props) {
const [prep, setPrep] = useState<MeetingPrep | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (isOpen && clientId) {
setLoading(true);
setError('');
setPrep(null);
api.getMeetingPrep(clientId)
.then(setPrep)
.catch(err => setError(err.message || 'Failed to generate meeting prep'))
.finally(() => setLoading(false));
}
}, [isOpen, clientId]);
const handlePrint = () => {
window.print();
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={`Meeting Prep: ${clientName}`} size="xl">
{loading ? (
<div className="flex flex-col items-center justify-center py-12">
<LoadingSpinner size="lg" />
<p className="text-sm text-slate-500 dark:text-slate-400 mt-4">
<Sparkles className="w-4 h-4 inline mr-1" />
Preparing your meeting brief...
</p>
</div>
) : error ? (
<div className="flex flex-col items-center py-8 text-red-600 dark:text-red-400">
<AlertCircle className="w-8 h-8 mb-2" />
<p className="text-sm">{error}</p>
</div>
) : prep ? (
<div className="space-y-6 print:space-y-4" id="meeting-prep-content">
{/* Print button */}
<div className="flex justify-end print:hidden">
<button
onClick={handlePrint}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
<Printer className="w-4 h-4" />
Print
</button>
</div>
{/* Client Summary + Health */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-5">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{prep.client.name}</h3>
<p className="text-sm text-slate-600 dark:text-slate-400">
{prep.client.role !== 'N/A' && `${prep.client.role} at `}
{prep.client.company !== 'N/A' ? prep.client.company : ''}
{prep.client.industry !== 'N/A' && ` · ${prep.client.industry}`}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
<Clock className="w-3 h-3 inline mr-1" />
Last contact: {prep.client.daysSinceLastContact === 999 ? 'Never' : `${prep.client.daysSinceLastContact} days ago`}
</p>
</div>
<HealthScoreBadge score={prep.healthScore} />
</div>
{prep.aiTalkingPoints.summary && (
<p className="text-sm text-slate-700 dark:text-slate-300 mt-3 leading-relaxed">
{prep.aiTalkingPoints.summary}
</p>
)}
</div>
{/* AI Talking Points */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Suggested Topics */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h4 className="flex items-center gap-2 font-medium text-slate-900 dark:text-slate-100 mb-3">
<MessageSquare className="w-4 h-4 text-blue-600" />
Suggested Topics
</h4>
<ul className="space-y-2">
{prep.aiTalkingPoints.suggestedTopics.map((topic, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
<span className="mt-0.5 w-5 h-5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center text-xs font-medium flex-shrink-0">
{i + 1}
</span>
{topic}
</li>
))}
</ul>
</div>
{/* Conversation Starters */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h4 className="flex items-center gap-2 font-medium text-slate-900 dark:text-slate-100 mb-3">
<Sparkles className="w-4 h-4 text-purple-600" />
Conversation Starters
</h4>
<ul className="space-y-2">
{prep.aiTalkingPoints.conversationStarters.map((starter, i) => (
<li key={i} className="text-sm text-slate-700 dark:text-slate-300 italic border-l-2 border-purple-300 dark:border-purple-600 pl-3">
"{starter}"
</li>
))}
</ul>
</div>
{/* Follow-up Items */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h4 className="flex items-center gap-2 font-medium text-slate-900 dark:text-slate-100 mb-3">
<CheckSquare className="w-4 h-4 text-emerald-600" />
Follow-up Items
</h4>
<ul className="space-y-2">
{prep.aiTalkingPoints.followUpItems.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300">
<span className="mt-1 w-3 h-3 border-2 border-emerald-400 rounded flex-shrink-0" />
{item}
</li>
))}
</ul>
</div>
{/* Important Dates */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h4 className="flex items-center gap-2 font-medium text-slate-900 dark:text-slate-100 mb-3">
<Calendar className="w-4 h-4 text-amber-600" />
Important Dates
</h4>
{prep.importantDates.length > 0 ? (
<ul className="space-y-2">
{prep.importantDates.map((d, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
{d.type === 'birthday' ? <Star className="w-4 h-4 text-pink-500" /> : <Heart className="w-4 h-4 text-purple-500" />}
{d.label}
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-400">No notable upcoming dates</p>
)}
{prep.upcomingEvents.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Upcoming Events</p>
{prep.upcomingEvents.map(e => (
<div key={e.id} className="text-sm text-slate-600 dark:text-slate-300">
{e.title} · {formatDate(e.date)}
</div>
))}
</div>
)}
</div>
</div>
{/* Recent Notes */}
{prep.notes.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h4 className="flex items-center gap-2 font-medium text-slate-900 dark:text-slate-100 mb-3">
<FileText className="w-4 h-4 text-slate-600" />
Recent Notes
</h4>
<div className="space-y-2">
{prep.notes.map(note => (
<div key={note.id} className="text-sm text-slate-700 dark:text-slate-300 p-2 bg-slate-50 dark:bg-slate-900/50 rounded-lg">
{note.content}
<div className="text-xs text-slate-400 mt-1">{formatDate(note.createdAt)}</div>
</div>
))}
</div>
</div>
)}
{/* Recent Interactions */}
{prep.recentInteractions.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h4 className="flex items-center gap-2 font-medium text-slate-900 dark:text-slate-100 mb-3">
<Briefcase className="w-4 h-4 text-indigo-600" />
Recent Interactions
</h4>
<div className="space-y-2">
{prep.recentInteractions.map((interaction, i) => (
<div key={i} className="flex items-center gap-3 text-sm">
<span className="px-2 py-0.5 bg-indigo-100 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 rounded text-xs font-medium">
{interaction.type}
</span>
<span className="text-slate-700 dark:text-slate-300">{interaction.title}</span>
<span className="text-slate-400 text-xs ml-auto">{formatDate(interaction.date)}</span>
</div>
))}
</div>
</div>
)}
</div>
) : null}
</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

@@ -0,0 +1,371 @@
import { useState } from 'react';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
import {
Sparkles, User, UserPlus, MessageSquare, Compass,
ChevronRight, ChevronLeft, X, Check,
} from 'lucide-react';
import LoadingSpinner from '@/components/LoadingSpinner';
interface OnboardingWizardProps {
userName: string;
onComplete: () => void;
}
const STEPS = [
{ id: 'welcome', label: 'Welcome', icon: Sparkles },
{ id: 'client', label: 'First Client', icon: UserPlus },
{ id: 'style', label: 'Communication', icon: MessageSquare },
{ id: 'tour', label: 'Quick Tour', icon: Compass },
] as const;
const TONES = [
{ value: 'formal' as const, label: 'Formal', desc: 'Professional and polished' },
{ value: 'friendly' as const, label: 'Friendly', desc: 'Warm and approachable' },
{ value: 'casual' as const, label: 'Casual', desc: 'Relaxed and conversational' },
];
const TOUR_FEATURES = [
{ icon: '👥', title: 'Clients', desc: 'Manage your contacts with detailed profiles, tags, and pipeline stages.' },
{ icon: '📧', title: 'AI Emails', desc: 'Generate personalized emails with AI that matches your writing style.' },
{ icon: '📅', title: 'Events', desc: 'Never miss a birthday, anniversary, or follow-up reminder.' },
{ icon: '🔗', title: 'Network', desc: 'Discover connections between your clients for intro opportunities.' },
{ icon: '📊', title: 'Reports', desc: 'Track engagement metrics and grow your network intentionally.' },
];
export default function OnboardingWizard({ userName, onComplete }: OnboardingWizardProps) {
const [step, setStep] = useState(0);
const [saving, setSaving] = useState(false);
// Step 1: Profile
const [name, setName] = useState(userName || '');
const [title, setTitle] = useState('');
const [company, setCompany] = useState('');
// Step 2: First client
const [clientFirstName, setClientFirstName] = useState('');
const [clientLastName, setClientLastName] = useState('');
const [clientEmail, setClientEmail] = useState('');
const [clientCompany, setClientCompany] = useState('');
const [clientAdded, setClientAdded] = useState(false);
// Step 3: Communication style
const [tone, setTone] = useState<'formal' | 'friendly' | 'casual'>('friendly');
const [greeting, setGreeting] = useState('');
const [signoff, setSignoff] = useState('');
const handleSkip = async () => {
setSaving(true);
try {
await api.completeOnboarding();
onComplete();
} catch (err) {
console.error('Failed to complete onboarding:', err);
onComplete();
} finally {
setSaving(false);
}
};
const handleNext = async () => {
if (step === 0) {
// Save profile
if (name || title || company) {
try {
await api.updateProfile({ name: name || undefined, title: title || undefined, company: company || undefined });
} catch (err) {
console.error(err);
}
}
setStep(1);
} else if (step === 1) {
// Add client if filled
if (clientFirstName && clientLastName && !clientAdded) {
try {
await api.createClient({
firstName: clientFirstName,
lastName: clientLastName,
email: clientEmail || undefined,
company: clientCompany || undefined,
});
setClientAdded(true);
} catch (err) {
console.error(err);
}
}
setStep(2);
} else if (step === 2) {
// Save communication style
try {
await api.updateCommunicationStyle({
tone,
greeting: greeting || undefined,
signoff: signoff || undefined,
});
} catch (err) {
console.error(err);
}
setStep(3);
} else if (step === 3) {
// Complete
await handleSkip();
}
};
const currentStep = STEPS[step];
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80 backdrop-blur-sm">
<div className="w-full max-w-2xl mx-4 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
{/* Progress bar */}
<div className="h-1.5 bg-slate-100 dark:bg-slate-700">
<div
className="h-full bg-blue-600 transition-all duration-500 ease-out"
style={{ width: `${((step + 1) / STEPS.length) * 100}%` }}
/>
</div>
{/* Step indicators */}
<div className="flex items-center justify-center gap-2 px-6 pt-6">
{STEPS.map((s, i) => {
const Icon = s.icon;
return (
<div
key={s.id}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-colors',
i === step
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300'
: i < step
? 'text-emerald-600 dark:text-emerald-400'
: 'text-slate-400 dark:text-slate-500'
)}
>
{i < step ? <Check className="w-3.5 h-3.5" /> : <Icon className="w-3.5 h-3.5" />}
<span className="hidden sm:inline">{s.label}</span>
</div>
);
})}
</div>
{/* Content */}
<div className="px-6 sm:px-10 py-8 min-h-[360px]">
{step === 0 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-blue-100 dark:bg-blue-900/40 rounded-2xl mb-4">
<Sparkles className="w-8 h-8 text-blue-600 dark:text-blue-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Welcome to NetworkCRM!</h2>
<p className="text-slate-500 dark:text-slate-400 mt-2">Let's get you set up. This only takes a minute.</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Your Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Full name"
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Financial Advisor"
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 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">Company</label>
<input
type="text"
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="e.g., ABC Financial"
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-emerald-100 dark:bg-emerald-900/40 rounded-2xl mb-4">
<UserPlus className="w-8 h-8 text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Add Your First Client</h2>
<p className="text-slate-500 dark:text-slate-400 mt-2">Get started by adding someone from your network.</p>
</div>
{clientAdded ? (
<div className="bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4 text-center">
<Check className="w-8 h-8 text-emerald-600 dark:text-emerald-400 mx-auto mb-2" />
<p className="font-medium text-emerald-700 dark:text-emerald-300">
{clientFirstName} {clientLastName} added!
</p>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">First Name *</label>
<input
type="text"
value={clientFirstName}
onChange={(e) => setClientFirstName(e.target.value)}
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 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">Last Name *</label>
<input
type="text"
value={clientLastName}
onChange={(e) => setClientLastName(e.target.value)}
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
<input
type="email"
value={clientEmail}
onChange={(e) => setClientEmail(e.target.value)}
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 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">Company</label>
<input
type="text"
value={clientCompany}
onChange={(e) => setClientCompany(e.target.value)}
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)}
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-100 dark:bg-purple-900/40 rounded-2xl mb-4">
<MessageSquare className="w-8 h-8 text-purple-600 dark:text-purple-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Communication Style</h2>
<p className="text-slate-500 dark:text-slate-400 mt-2">Tell us how you communicate so AI emails match your voice.</p>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Tone</label>
<div className="grid grid-cols-3 gap-3">
{TONES.map((t) => (
<button
key={t.value}
onClick={() => setTone(t.value)}
className={cn(
'p-3 rounded-xl border text-left transition-all',
tone === t.value
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 ring-2 ring-blue-200 dark:ring-blue-800'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'
)}
>
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">{t.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{t.desc}</p>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Greeting</label>
<input
type="text"
value={greeting}
onChange={(e) => setGreeting(e.target.value)}
placeholder="e.g., Hi, Hello, Dear"
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 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">Sign-off</label>
<input
type="text"
value={signoff}
onChange={(e) => setSignoff(e.target.value)}
placeholder="e.g., Best regards, Cheers, Warm wishes"
className="w-full px-3 py-2.5 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-amber-100 dark:bg-amber-900/40 rounded-2xl mb-4">
<Compass className="w-8 h-8 text-amber-600 dark:text-amber-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100">You're All Set!</h2>
<p className="text-slate-500 dark:text-slate-400 mt-2">Here's what you can do with NetworkCRM.</p>
</div>
<div className="space-y-3">
{TOUR_FEATURES.map((feature) => (
<div key={feature.title} className="flex items-start gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
<span className="text-2xl flex-shrink-0">{feature.icon}</span>
<div>
<p className="font-semibold text-sm text-slate-900 dark:text-slate-100">{feature.title}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{feature.desc}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 sm:px-10 py-4 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<button
onClick={handleSkip}
disabled={saving}
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
>
Skip
</button>
<div className="flex items-center gap-2">
{step > 0 && (
<button
onClick={() => setStep(step - 1)}
className="flex items-center gap-1 px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
<ChevronLeft className="w-4 h-4" />
Back
</button>
)}
<button
onClick={handleNext}
disabled={saving}
className="flex items-center gap-1 px-5 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 font-medium"
>
{saving && <LoadingSpinner />}
{step === STEPS.length - 1 ? 'Get Started' : 'Next'}
{step < STEPS.length - 1 && <ChevronRight className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
);
}

104
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { useEffect, useState, useCallback } from 'react';
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
interface ToastItem {
id: string;
type: ToastType;
message: string;
duration?: number;
}
// Global toast state
let toastListeners: ((toasts: ToastItem[]) => void)[] = [];
let toasts: ToastItem[] = [];
function notifyListeners() {
toastListeners.forEach(fn => fn([...toasts]));
}
export function showToast(type: ToastType, message: string, duration = 5000) {
const id = Math.random().toString(36).slice(2);
toasts = [...toasts, { id, type, message, duration }];
notifyListeners();
if (duration > 0) {
setTimeout(() => {
toasts = toasts.filter(t => t.id !== id);
notifyListeners();
}, duration);
}
}
export function toast(message: string) { showToast('info', message); }
toast.success = (msg: string) => showToast('success', msg);
toast.error = (msg: string) => showToast('error', msg, 7000);
toast.warning = (msg: string) => showToast('warning', msg);
toast.info = (msg: string) => showToast('info', msg);
const iconMap = {
success: CheckCircle2,
error: AlertCircle,
warning: AlertTriangle,
info: Info,
};
const colorMap = {
success: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200',
error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
warning: 'bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200',
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
};
const iconColorMap = {
success: 'text-emerald-500',
error: 'text-red-500',
warning: 'text-amber-500',
info: 'text-blue-500',
};
export function ToastContainer() {
const [items, setItems] = useState<ToastItem[]>([]);
useEffect(() => {
toastListeners.push(setItems);
return () => {
toastListeners = toastListeners.filter(fn => fn !== setItems);
};
}, []);
const dismiss = useCallback((id: string) => {
toasts = toasts.filter(t => t.id !== id);
notifyListeners();
}, []);
if (items.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
{items.map(item => {
const Icon = iconMap[item.type];
return (
<div
key={item.id}
className={cn(
'flex items-start gap-3 p-3 rounded-lg border shadow-lg animate-fade-in',
colorMap[item.type]
)}
>
<Icon className={cn('w-5 h-5 flex-shrink-0 mt-0.5', iconColorMap[item.type])} />
<p className="text-sm font-medium flex-1">{item.message}</p>
<button
onClick={() => dismiss(item.id)}
className="p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
);
})}
</div>
);
}

View File

@@ -68,3 +68,11 @@ html.dark body {
.animate-slide-up { .animate-slide-up {
animation: slideUp 0.15s ease-out; animation: slideUp 0.15s ease-out;
} }
/* Print styles for meeting prep */
@media print {
body { background: white !important; color: black !important; }
[data-print-hide] { display: none !important; }
aside, header, nav { display: none !important; }
main { padding: 0 !important; overflow: visible !important; }
}

View File

@@ -1,4 +1,4 @@
import type { Profile, Client, ClientCreate, 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, EmailTemplate, EmailTemplateCreate, ClientSegment, SegmentFilters, FilterOptions, AuditLogsResponse, MeetingPrep, CommunicationStyle } 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'
@@ -28,6 +28,13 @@ class ApiClient {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
} }
// Global error handler callback
private onApiError: ((status: number, message: string) => void) | null = null;
setErrorHandler(handler: (status: number, message: string) => void) {
this.onApiError = handler;
}
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> { private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: HeadersInit = { const headers: HeadersInit = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -43,7 +50,14 @@ class ApiClient {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' })); const error = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || error.message || 'Request failed'); const message = error.error || error.message || 'Request failed';
// Fire global error handler
if (this.onApiError) {
this.onApiError(response.status, message);
}
throw new Error(message);
} }
const text = await response.text(); const text = await response.text();
@@ -404,6 +418,29 @@ class ApiClient {
} }
return response.json(); return response.json();
} }
// Client Notes
async getClientNotes(clientId: string): Promise<ClientNote[]> {
return this.fetch(`/clients/${clientId}/notes`);
}
async createClientNote(clientId: string, content: string): Promise<ClientNote> {
return this.fetch(`/clients/${clientId}/notes`, {
method: 'POST',
body: JSON.stringify({ content }),
});
}
async updateClientNote(clientId: string, noteId: string, data: { content?: string; pinned?: boolean }): Promise<ClientNote> {
return this.fetch(`/clients/${clientId}/notes/${noteId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteClientNote(clientId: string, noteId: string): Promise<void> {
await this.fetch(`/clients/${clientId}/notes/${noteId}`, { method: 'DELETE' });
}
// Reports & Analytics // Reports & Analytics
async getReportsOverview(): Promise<any> { async getReportsOverview(): Promise<any> {
return this.fetch('/reports/overview'); return this.fetch('/reports/overview');
@@ -425,10 +462,213 @@ 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 }),
});
}
// Email Templates
async getTemplates(category?: string): Promise<EmailTemplate[]> {
const params = category ? `?category=${encodeURIComponent(category)}` : '';
return this.fetch(`/templates${params}`);
}
async getTemplate(id: string): Promise<EmailTemplate> {
return this.fetch(`/templates/${id}`);
}
async createTemplate(data: EmailTemplateCreate): Promise<EmailTemplate> {
return this.fetch('/templates', { method: 'POST', body: JSON.stringify(data) });
}
async updateTemplate(id: string, data: Partial<EmailTemplateCreate>): Promise<EmailTemplate> {
return this.fetch(`/templates/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
async useTemplate(id: string, variables?: Record<string, string>): Promise<{ subject: string; content: string; templateId: string; templateName: string }> {
return this.fetch(`/templates/${id}/use`, { method: 'POST', body: JSON.stringify({ variables }) });
}
async deleteTemplate(id: string): Promise<{ success: boolean }> {
return this.fetch(`/templates/${id}`, { method: 'DELETE' });
}
// Client Segments
async getSegments(): Promise<ClientSegment[]> {
return this.fetch('/segments');
}
async getSegment(id: string): Promise<ClientSegment> {
return this.fetch(`/segments/${id}`);
}
async previewSegment(filters: SegmentFilters): Promise<{ count: number; clients: Client[] }> {
return this.fetch('/segments/preview', { method: 'POST', body: JSON.stringify({ filters }) });
}
async getFilterOptions(): Promise<FilterOptions> {
return this.fetch('/segments/filter-options');
}
async createSegment(data: { name: string; description?: string; filters: SegmentFilters; color?: string; pinned?: boolean }): Promise<ClientSegment> {
return this.fetch('/segments', { method: 'POST', body: JSON.stringify(data) });
}
async updateSegment(id: string, data: Partial<{ name: string; description: string; filters: SegmentFilters; color: string; pinned: boolean }>): Promise<ClientSegment> {
return this.fetch(`/segments/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
async deleteSegment(id: string): Promise<{ success: boolean }> {
return this.fetch(`/segments/${id}`, { method: 'DELETE' });
}
// Audit Logs (admin)
async getAuditLogs(params?: {
entityType?: string;
action?: string;
userId?: string;
startDate?: string;
endDate?: string;
search?: string;
page?: number;
limit?: number;
}): Promise<AuditLogsResponse> {
const searchParams = new URLSearchParams();
if (params?.entityType) searchParams.set('entityType', params.entityType);
if (params?.action) searchParams.set('action', params.action);
if (params?.userId) searchParams.set('userId', params.userId);
if (params?.startDate) searchParams.set('startDate', params.startDate);
if (params?.endDate) searchParams.set('endDate', params.endDate);
if (params?.search) searchParams.set('search', params.search);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.fetch(`/audit-logs${query ? `?${query}` : ''}`);
}
// Meeting Prep
async getMeetingPrep(clientId: string, provider?: string): Promise<MeetingPrep> {
const query = provider ? `?provider=${provider}` : '';
return this.fetch(`/clients/${clientId}/meeting-prep${query}`);
}
// Communication Style
async getCommunicationStyle(): Promise<CommunicationStyle> {
return this.fetch('/profile/communication-style');
}
async updateCommunicationStyle(style: Partial<CommunicationStyle>): Promise<CommunicationStyle> {
return this.fetch('/profile/communication-style', {
method: 'PATCH',
body: JSON.stringify(style),
});
}
// Tags Management
async getTags(): Promise<{ name: string; count: number }[]> {
return this.fetch('/tags');
}
async renameTag(oldName: string, newName: string): Promise<{ success: boolean; updated: number }> {
return this.fetch('/tags/rename', {
method: 'PUT',
body: JSON.stringify({ oldName, newName }),
});
}
async deleteTag(name: string): Promise<{ success: boolean; removed: number }> {
return this.fetch(`/tags/${encodeURIComponent(name)}`, { method: 'DELETE' });
}
async mergeTags(sourceTags: string[], targetTag: string): Promise<{ success: boolean; updated: number }> {
return this.fetch('/tags/merge', {
method: 'POST',
body: JSON.stringify({ sourceTags, targetTag }),
});
}
// Onboarding
async getOnboardingStatus(): Promise<{ onboardingComplete: boolean }> {
return this.fetch('/profile/onboarding-status');
}
async completeOnboarding(): Promise<{ success: boolean }> {
return this.fetch('/profile/complete-onboarding', { method: 'POST' });
}
// Paginated clients
async getClientsPaginated(params?: { search?: string; tag?: string; page?: number; limit?: number }): Promise<{
data: Client[];
total: number;
page: number;
limit: number;
totalPages: number;
}> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.set('search', params.search);
if (params?.tag) searchParams.set('tag', params.tag);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.limit) searchParams.set('limit', String(params.limit));
const query = searchParams.toString();
return this.fetch(`/clients${query ? `?${query}` : ''}`);
}
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}` } : {};

254
src/pages/AuditLogPage.tsx Normal file
View File

@@ -0,0 +1,254 @@
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import type { AuditLog, User } from '@/types';
import {
Shield, Search, ChevronDown, ChevronRight, ChevronLeft,
Filter, Calendar, User as UserIcon, Activity,
} from 'lucide-react';
import { PageLoader } from '@/components/LoadingSpinner';
import { formatDate } from '@/lib/utils';
const ACTION_COLORS: Record<string, string> = {
create: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
update: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300',
delete: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300',
view: 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
send: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300',
login: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300',
logout: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
password_change: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300',
};
const ENTITY_TYPES = ['client', 'email', 'event', 'template', 'segment', 'user', 'auth', 'interaction', 'note', 'notification', 'invite', 'profile'];
const ACTIONS = ['create', 'update', 'delete', 'view', 'send', 'login', 'logout', 'password_change'];
export default function AuditLogPage() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Filters
const [entityType, setEntityType] = useState('');
const [action, setAction] = useState('');
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [userId, setUserId] = useState('');
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const data = await api.getAuditLogs({
entityType: entityType || undefined,
action: action || undefined,
userId: userId || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
search: search || undefined,
page,
limit: 25,
});
setLogs(data.logs);
setTotal(data.total);
setTotalPages(data.totalPages);
} catch (err) {
console.error('Failed to fetch audit logs:', err);
} finally {
setLoading(false);
}
}, [entityType, action, userId, startDate, endDate, search, page]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
api.getUsers().then(setUsers).catch(() => {});
}, []);
// Reset page on filter change
useEffect(() => {
setPage(1);
}, [entityType, action, userId, startDate, endDate, search]);
const inputClass = 'px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500';
const selectClass = `${inputClass} appearance-none pr-8`;
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-3">
<Shield className="w-7 h-7 text-blue-600" />
Audit Log
</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{total} total entries · Compliance audit trail
</p>
</div>
</div>
{/* Filters */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3 text-sm font-medium text-slate-700 dark:text-slate-300">
<Filter className="w-4 h-4" />
Filters
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search details..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className={`${inputClass} pl-9 w-full`}
/>
</div>
<select value={entityType} onChange={(e) => setEntityType(e.target.value)} className={`${selectClass} w-full`}>
<option value="">All entity types</option>
{ENTITY_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select value={action} onChange={(e) => setAction(e.target.value)} className={`${selectClass} w-full`}>
<option value="">All actions</option>
{ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
</select>
<select value={userId} onChange={(e) => setUserId(e.target.value)} className={`${selectClass} w-full`}>
<option value="">All users</option>
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
placeholder="Start date"
className={`${inputClass} w-full`}
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
placeholder="End date"
className={`${inputClass} w-full`}
/>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
{loading ? (
<div className="p-8"><PageLoader /></div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">
<Activity className="w-8 h-8 mx-auto mb-2 opacity-50" />
No audit logs found
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
<th className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400 w-8"></th>
<th className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Time</th>
<th className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400">User</th>
<th className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Action</th>
<th className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400">Entity</th>
<th className="px-4 py-3 text-left font-medium text-slate-500 dark:text-slate-400">IP Address</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{logs.map(log => (
<>
<tr
key={log.id}
className="hover:bg-slate-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors"
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
>
<td className="px-4 py-3">
{log.details ? (
expandedId === log.id
? <ChevronDown className="w-4 h-4 text-slate-400" />
: <ChevronRight className="w-4 h-4 text-slate-400" />
) : <span className="w-4 h-4 inline-block" />}
</td>
<td className="px-4 py-3 text-slate-600 dark:text-slate-300 whitespace-nowrap">
{new Date(log.createdAt).toLocaleString()}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
<UserIcon className="w-3 h-3 text-slate-500" />
</div>
<span className="text-slate-900 dark:text-slate-100">{log.userName || 'System'}</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ACTION_COLORS[log.action] || 'bg-slate-100 text-slate-600'}`}>
{log.action}
</span>
</td>
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">
<span className="font-medium">{log.entityType}</span>
{log.entityId && (
<span className="text-slate-400 ml-1 text-xs">{log.entityId.slice(0, 8)}...</span>
)}
</td>
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-xs font-mono">
{log.ipAddress || '—'}
</td>
</tr>
{expandedId === log.id && log.details && (
<tr key={`${log.id}-details`}>
<td colSpan={6} className="px-8 py-4 bg-slate-50 dark:bg-slate-900/50">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Details</div>
<pre className="text-xs text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 p-3 rounded-lg border border-slate-200 dark:border-slate-700 overflow-auto max-h-48">
{JSON.stringify(log.details, null, 2)}
</pre>
{log.userAgent && (
<div className="mt-2 text-xs text-slate-400 truncate">
UA: {log.userAgent}
</div>
)}
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 dark:border-slate-700">
<span className="text-sm text-slate-500 dark:text-slate-400">
Page {page} of {totalPages} ({total} entries)
</span>
<div className="flex gap-2">
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-slate-300 dark:border-slate-600 rounded-lg disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300"
>
<ChevronLeft className="w-4 h-4" /> Previous
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-slate-300 dark:border-slate-600 rounded-lg disabled:opacity-50 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors text-slate-700 dark:text-slate-300"
>
Next <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -10,11 +10,15 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { usePinnedClients } from '@/hooks/usePinnedClients'; import { usePinnedClients } from '@/hooks/usePinnedClients';
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils'; import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge'; import Badge, { EventTypeBadge, EmailStatusBadge, StageBadge } from '@/components/Badge';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
import Modal from '@/components/Modal'; 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 LogInteractionModal from '@/components/LogInteractionModal';
import MeetingPrepModal from '@/components/MeetingPrepModal';
import type { Interaction } from '@/types';
export default function ClientDetailPage() { export default function ClientDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -23,9 +27,12 @@ export default function ClientDetailPage() {
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
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' | '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 [showMeetingPrep, setShowMeetingPrep] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const { togglePin, isPinned } = usePinnedClients(); const { togglePin, isPinned } = usePinnedClients();
@@ -35,6 +42,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]);
@@ -62,8 +70,9 @@ export default function ClientDetailPage() {
setShowEdit(false); setShowEdit(false);
}; };
const tabs: { key: 'info' | 'activity' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [ const tabs: { key: typeof activeTab; label: string; count?: number; icon: typeof Users }[] = [
{ key: 'info', label: 'Info', icon: Users }, { key: 'info', label: 'Info', icon: Users },
{ key: 'notes', label: 'Notes', icon: FileText },
{ key: 'activity', label: 'Timeline', count: activities.length, icon: Activity }, { key: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
{ key: 'events', label: 'Events', count: events.length, icon: Calendar }, { key: 'events', label: 'Events', count: events.length, icon: Calendar },
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail }, { key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
@@ -90,11 +99,17 @@ export default function ClientDetailPage() {
{client.role ? `${client.role} at ` : ''}{client.company} {client.role ? `${client.role} at ` : ''}{client.company}
</p> </p>
)} )}
{client.tags && client.tags.length > 0 && ( <div className="flex flex-wrap gap-1.5 mt-2">
<div className="flex flex-wrap gap-1.5 mt-2"> <StageBadge stage={client.stage} onClick={async () => {
{client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)} const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
</div> const currentIdx = stages.indexOf(client.stage || 'lead');
)} const nextStage = stages[(currentIdx + 1) % stages.length];
await updateClient(client.id, { stage: nextStage } as any);
}} />
{client.tags && client.tags.length > 0 && (
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +118,15 @@ 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={() => setShowMeetingPrep(true)} className="flex items-center gap-2 px-3 py-2 bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 rounded-lg text-sm font-medium hover:bg-purple-100 dark:hover:bg-purple-900/50 transition-colors">
<Briefcase className="w-4 h-4" />
<span className="hidden sm:inline">Meeting Prep</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>
@@ -240,6 +263,10 @@ export default function ClientDetailPage() {
</div> </div>
)} )}
{activeTab === 'notes' && (
<ClientNotes clientId={client.id} />
)}
{activeTab === 'activity' && ( {activeTab === 'activity' && (
<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">
{activities.length === 0 ? ( {activities.length === 0 ? (
@@ -248,12 +275,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;
@@ -332,6 +360,30 @@ 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])}
/> />
{/* Meeting Prep Modal */}
<MeetingPrepModal
isOpen={showMeetingPrep}
onClose={() => setShowMeetingPrep(false)}
clientId={client.id}
clientName={`${client.firstName} ${client.lastName}`}
/>
{/* 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

@@ -1,21 +1,52 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo, useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { useClientsStore } from '@/stores/clients'; import { useClientsStore } from '@/stores/clients';
import { Search, Plus, Users, X, Upload } from 'lucide-react'; import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban, ChevronLeft, ChevronRight } from 'lucide-react';
import { cn, getRelativeTime, getInitials } from '@/lib/utils'; import { cn, getRelativeTime, getInitials } from '@/lib/utils';
import Badge from '@/components/Badge'; import Badge, { StageBadge } from '@/components/Badge';
import EmptyState from '@/components/EmptyState'; import EmptyState from '@/components/EmptyState';
import { PageLoader } from '@/components/LoadingSpinner'; import { PageLoader } from '@/components/LoadingSpinner';
import Modal from '@/components/Modal'; import Modal from '@/components/Modal';
import ClientForm from '@/components/ClientForm'; import ClientForm from '@/components/ClientForm';
import CSVImportModal from '@/components/CSVImportModal'; import CSVImportModal from '@/components/CSVImportModal';
const PAGE_SIZES = [25, 50, 100];
export default function ClientsPage() { export default function ClientsPage() {
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore(); const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'pipeline'>(() =>
(localStorage.getItem('clients-view') as 'grid' | 'pipeline') || 'grid'
);
// Pagination state from URL
const currentPage = parseInt(searchParams.get('page') || '1', 10) || 1;
const pageSize = parseInt(searchParams.get('pageSize') || '50', 10) || 50;
const setPage = useCallback((page: number) => {
const params = new URLSearchParams(searchParams);
params.set('page', String(page));
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
const setPageSize = useCallback((size: number) => {
const params = new URLSearchParams(searchParams);
params.set('pageSize', String(size));
params.set('page', '1'); // reset to first page
setSearchParams(params, { replace: true });
}, [searchParams, setSearchParams]);
// Initialize selected tag from URL
useEffect(() => {
const urlTag = searchParams.get('tag');
if (urlTag && urlTag !== selectedTag) {
setSelectedTag(urlTag);
}
}, []);
useEffect(() => { useEffect(() => {
fetchClients(); fetchClients();
@@ -46,6 +77,43 @@ export default function ClientsPage() {
return result; return result;
}, [clients, searchQuery, selectedTag]); }, [clients, searchQuery, selectedTag]);
// Pagination for grid view
const totalClients = filteredClients.length;
const totalPages = Math.ceil(totalClients / pageSize);
const paginatedClients = useMemo(() => {
if (viewMode === 'pipeline') return filteredClients; // pipeline shows all
const start = (currentPage - 1) * pageSize;
return filteredClients.slice(start, start + pageSize);
}, [filteredClients, currentPage, pageSize, viewMode]);
// Reset page when filters change
useEffect(() => {
if (currentPage > 1) {
setPage(1);
}
}, [searchQuery, selectedTag]);
// Pipeline columns
const pipelineStages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'] as const;
const pipelineColumns = useMemo(() => {
const cols: Record<string, typeof filteredClients> = {};
pipelineStages.forEach(s => { cols[s] = []; });
filteredClients.forEach(c => {
const stage = c.stage || 'lead';
if (cols[stage]) cols[stage].push(c);
});
return cols;
}, [filteredClients]);
const stageCounts = useMemo(() => {
const counts: Record<string, number> = {};
clients.forEach(c => {
const s = c.stage || 'lead';
counts[s] = (counts[s] || 0) + 1;
});
return counts;
}, [clients]);
// All unique tags // All unique tags
const allTags = useMemo(() => { const allTags = useMemo(() => {
const tags = new Set<string>(); const tags = new Set<string>();
@@ -71,10 +139,34 @@ export default function ClientsPage() {
<div className="max-w-6xl mx-auto space-y-5 animate-fade-in"> <div className="max-w-6xl mx-auto space-y-5 animate-fade-in">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Clients</h1> <h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Clients{clients.length > 0 && <span className="text-slate-400 dark:text-slate-500 ml-2 text-lg font-normal">({clients.length})</span>}
</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">{clients.length} contacts in your network</p> <p className="text-slate-500 dark:text-slate-400 text-sm mt-1">{clients.length} contacts in your network</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5">
<button
onClick={() => { setViewMode('grid'); localStorage.setItem('clients-view', 'grid'); }}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'grid' ? 'bg-white dark:bg-slate-700 shadow-sm text-slate-900 dark:text-slate-100' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
)}
title="Grid view"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => { setViewMode('pipeline'); localStorage.setItem('clients-view', 'pipeline'); }}
className={cn(
'p-1.5 rounded-md transition-colors',
viewMode === 'pipeline' ? 'bg-white dark:bg-slate-700 shadow-sm text-slate-900 dark:text-slate-100' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
)}
title="Pipeline view"
>
<Kanban className="w-4 h-4" />
</button>
</div>
<button <button
onClick={() => setShowImport(true)} onClick={() => setShowImport(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors" className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
@@ -131,7 +223,36 @@ export default function ClientsPage() {
)} )}
</div> </div>
{/* Client Grid */} {/* Pipeline Summary Bar */}
{viewMode === 'grid' && clients.length > 0 && (
<div className="flex items-center gap-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 overflow-x-auto">
{pipelineStages.map((stage) => {
const count = stageCounts[stage] || 0;
const total = clients.length;
const pct = total > 0 ? Math.max((count / total) * 100, count > 0 ? 4 : 0) : 0;
const colors: Record<string, string> = {
lead: 'bg-slate-300 dark:bg-slate-600',
prospect: 'bg-blue-400 dark:bg-blue-500',
onboarding: 'bg-amber-400 dark:bg-amber-500',
active: 'bg-emerald-400 dark:bg-emerald-500',
inactive: 'bg-red-400 dark:bg-red-500',
};
return (
<div key={stage} className="flex-1 min-w-[60px]" title={`${stage}: ${count}`}>
<div className="text-center mb-1">
<span className="text-xs font-medium text-slate-600 dark:text-slate-300 capitalize">{stage}</span>
<span className="text-xs text-slate-400 ml-1">({count})</span>
</div>
<div className="h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
<div className={cn('h-full rounded-full transition-all', colors[stage])} style={{ width: `${pct}%` }} />
</div>
</div>
);
})}
</div>
)}
{/* Client Grid / Pipeline View */}
{filteredClients.length === 0 ? ( {filteredClients.length === 0 ? (
<EmptyState <EmptyState
icon={Users} icon={Users}
@@ -139,9 +260,71 @@ export default function ClientsPage() {
description={searchQuery || selectedTag ? 'Try adjusting your search or filters' : 'Add your first client to get started'} description={searchQuery || selectedTag ? 'Try adjusting your search or filters' : 'Add your first client to get started'}
action={!searchQuery && !selectedTag ? { label: 'Add Client', onClick: () => setShowCreate(true) } : undefined} action={!searchQuery && !selectedTag ? { label: 'Add Client', onClick: () => setShowCreate(true) } : undefined}
/> />
) : viewMode === 'pipeline' ? (
/* Pipeline / Kanban View */
<div className="flex gap-4 overflow-x-auto pb-4">
{pipelineStages.map((stage) => {
const stageClients = pipelineColumns[stage] || [];
const headerColors: Record<string, string> = {
lead: 'border-t-slate-400',
prospect: 'border-t-blue-500',
onboarding: 'border-t-amber-500',
active: 'border-t-emerald-500',
inactive: 'border-t-red-500',
};
return (
<div key={stage} className={cn(
'flex-1 min-w-[220px] max-w-[300px] bg-slate-50 dark:bg-slate-800/50 rounded-xl border-t-4',
headerColors[stage]
)}>
<div className="px-3 py-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 capitalize">{stage}</h3>
<span className="text-xs bg-white dark:bg-slate-700 text-slate-500 dark:text-slate-400 px-2 py-0.5 rounded-full font-medium">{stageClients.length}</span>
</div>
<div className="px-2 pb-2 space-y-2 max-h-[600px] overflow-y-auto">
{stageClients.map((client) => (
<Link
key={client.id}
to={`/clients/${client.id}`}
className="block bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg p-3 hover:shadow-md dark:hover:shadow-slate-900/50 transition-all"
>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">
{getInitials(client.firstName, client.lastName)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
{client.firstName} {client.lastName}
</p>
{client.company && (
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">{client.company}</p>
)}
</div>
</div>
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{client.tags.slice(0, 2).map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400 rounded text-[10px]">{tag}</span>
))}
</div>
)}
<p className="text-[10px] text-slate-400 mt-2">
{getRelativeTime(client.lastContacted)}
</p>
</Link>
))}
{stageClients.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">No clients</p>
)}
</div>
</div>
);
})}
</div>
) : ( ) : (
/* Grid View */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredClients.map((client) => ( {paginatedClients.map((client) => (
<Link <Link
key={client.id} key={client.id}
to={`/clients/${client.id}`} to={`/clients/${client.id}`}
@@ -161,18 +344,17 @@ export default function ClientsPage() {
</div> </div>
</div> </div>
{client.tags && client.tags.length > 0 && ( <div className="flex flex-wrap gap-1.5 mt-3">
<div className="flex flex-wrap gap-1.5 mt-3"> <StageBadge stage={client.stage} />
{client.tags.slice(0, 3).map((tag) => ( {client.tags && client.tags.slice(0, 2).map((tag) => (
<span key={tag} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-full text-xs"> <span key={tag} className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-full text-xs">
{tag} {tag}
</span> </span>
))} ))}
{client.tags.length > 3 && ( {client.tags && client.tags.length > 2 && (
<span className="text-xs text-slate-400">+{client.tags.length - 3}</span> <span className="text-xs text-slate-400">+{client.tags.length - 2}</span>
)} )}
</div> </div>
)}
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-xs text-slate-400"> <div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 text-xs text-slate-400">
Last contacted: {getRelativeTime(client.lastContacted)} Last contacted: {getRelativeTime(client.lastContacted)}
@@ -182,6 +364,46 @@ export default function ClientsPage() {
</div> </div>
)} )}
{/* Pagination Controls (grid view only) */}
{viewMode === 'grid' && totalPages > 1 && (
<div className="flex items-center justify-between bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3">
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<span>Show</span>
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="px-2 py-1 bg-white dark:bg-slate-700 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{PAGE_SIZES.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span>per page</span>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
Page {currentPage} of {totalPages}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(currentPage - 1)}
disabled={currentPage <= 1}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={() => setPage(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
{/* Create Modal */} {/* Create Modal */}
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg"> <Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg">
<ClientForm onSubmit={handleCreate} loading={creating} /> <ClientForm onSubmit={handleCreate} loading={creating} />

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">

352
src/pages/SegmentsPage.tsx Normal file
View File

@@ -0,0 +1,352 @@
import { useEffect, useState } from 'react';
// import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { ClientSegment, SegmentFilters, FilterOptions, Client } from '@/types';
import { Plus, Filter, Users, Bookmark, Pin, Trash2, Pencil, Eye, Save, X, ChevronDown, ChevronUp } from 'lucide-react';
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
import EmptyState from '@/components/EmptyState';
import Modal from "@/components/Modal";
const SEGMENT_COLORS = [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
'#ec4899', '#06b6d4', '#f97316', '#6366f1', '#14b8a6',
];
function MultiSelect({ label, options, selected, onChange }: {
label: string; options: string[]; selected: string[]; onChange: (v: string[]) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">{label}</label>
<button onClick={() => setOpen(!open)}
className="w-full flex items-center justify-between px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100">
<span className="truncate">{selected.length ? `${selected.length} selected` : `Any ${label.toLowerCase()}`}</span>
{open ? <ChevronUp className="w-3.5 h-3.5 ml-1" /> : <ChevronDown className="w-3.5 h-3.5 ml-1" />}
</button>
{open && (
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{options.map(opt => (
<label key={opt} className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-50 dark:hover:bg-slate-700 cursor-pointer text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={selected.includes(opt)}
onChange={() => onChange(selected.includes(opt) ? selected.filter(s => s !== opt) : [...selected, opt])}
className="rounded border-slate-300 dark:border-slate-600" />
{opt}
</label>
))}
{options.length === 0 && <p className="px-3 py-2 text-xs text-slate-400">No options</p>}
</div>
)}
</div>
);
}
function FilterPanel({ filters, onChange, options }: {
filters: SegmentFilters; onChange: (f: SegmentFilters) => void; options: FilterOptions;
}) {
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Filter className="w-4 h-4" /> Filters
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<MultiSelect label="Stage" options={options.stages} selected={filters.stages || []}
onChange={v => onChange({ ...filters, stages: v.length ? v : undefined })} />
<MultiSelect label="Industry" options={options.industries} selected={filters.industries || []}
onChange={v => onChange({ ...filters, industries: v.length ? v : undefined })} />
<MultiSelect label="Tags" options={options.tags} selected={filters.tags || []}
onChange={v => onChange({ ...filters, tags: v.length ? v : undefined })} />
<MultiSelect label="State" options={options.states} selected={filters.states || []}
onChange={v => onChange({ ...filters, states: v.length ? v : undefined })} />
<MultiSelect label="City" options={options.cities} selected={filters.cities || []}
onChange={v => onChange({ ...filters, cities: v.length ? v : undefined })} />
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Last Contacted After</label>
<input type="date" value={filters.lastContactedAfter?.split('T')[0] || ''}
onChange={e => onChange({ ...filters, lastContactedAfter: e.target.value || undefined })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Last Contacted Before</label>
<input type="date" value={filters.lastContactedBefore?.split('T')[0] || ''}
onChange={e => onChange({ ...filters, lastContactedBefore: e.target.value || undefined })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
</div>
<div className="space-y-2">
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400">Contact Info</label>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={filters.hasEmail === true}
onChange={e => onChange({ ...filters, hasEmail: e.target.checked ? true : undefined })}
className="rounded border-slate-300" /> Has email
</label>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={filters.hasPhone === true}
onChange={e => onChange({ ...filters, hasPhone: e.target.checked ? true : undefined })}
className="rounded border-slate-300" /> Has phone
</label>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Search</label>
<input type="text" value={filters.search || ''} placeholder="Search by name, email, company..."
onChange={e => onChange({ ...filters, search: e.target.value || undefined })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
</div>
</div>
);
}
export default function SegmentsPage() {
const [segments, setSegments] = useState<ClientSegment[]>([]);
const [options, setOptions] = useState<FilterOptions>({ industries: [], cities: [], states: [], tags: [], stages: [] });
const [loading, setLoading] = useState(true);
// Builder state
const [showBuilder, setShowBuilder] = useState(false);
const [editingSegment, setEditingSegment] = useState<ClientSegment | null>(null);
const [filters, setFilters] = useState<SegmentFilters>({});
const [previewClients, setPreviewClients] = useState<Client[]>([]);
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewing, setPreviewing] = useState(false);
const [segmentName, setSegmentName] = useState('');
const [segmentDesc, setSegmentDesc] = useState('');
const [segmentColor, setSegmentColor] = useState('#3b82f6');
const [saving, setSaving] = useState(false);
useEffect(() => {
Promise.all([
api.getSegments().then(setSegments),
api.getFilterOptions().then(setOptions),
]).finally(() => setLoading(false));
}, []);
const handlePreview = async () => {
setPreviewing(true);
try {
const result = await api.previewSegment(filters);
setPreviewClients(result.clients);
setPreviewCount(result.count);
} finally {
setPreviewing(false);
}
};
const openNew = () => {
setEditingSegment(null);
setFilters({});
setSegmentName('');
setSegmentDesc('');
setSegmentColor('#3b82f6');
setPreviewClients([]);
setPreviewCount(null);
setShowBuilder(true);
};
const openEdit = (s: ClientSegment) => {
setEditingSegment(s);
setFilters(s.filters);
setSegmentName(s.name);
setSegmentDesc(s.description || '');
setSegmentColor(s.color);
setPreviewClients([]);
setPreviewCount(null);
setShowBuilder(true);
};
const handleSave = async () => {
setSaving(true);
try {
if (editingSegment) {
await api.updateSegment(editingSegment.id, { name: segmentName, description: segmentDesc, filters, color: segmentColor });
} else {
await api.createSegment({ name: segmentName, description: segmentDesc, filters, color: segmentColor });
}
setShowBuilder(false);
const updated = await api.getSegments();
setSegments(updated);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this segment?')) return;
await api.deleteSegment(id);
setSegments(segments.filter(s => s.id !== id));
};
const togglePin = async (s: ClientSegment) => {
const updated = await api.updateSegment(s.id, { pinned: !s.pinned });
setSegments(segments.map(seg => seg.id === s.id ? updated : seg));
};
const activeFilterCount = Object.values(filters).filter(v => v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0)).length;
if (loading) return <PageLoader />;
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Client Segments</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Save filtered views of your client base for quick access
</p>
</div>
<button onClick={openNew}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
<Plus className="w-4 h-4" /> New Segment
</button>
</div>
{/* Saved Segments */}
{segments.length === 0 ? (
<EmptyState
icon={Bookmark}
title="No segments saved"
description="Create segments to quickly filter your client list by stage, tags, location, and more"
action={{ label: 'Create Segment', onClick: openNew }}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{segments.map(s => (
<div key={s.id} className="group bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: s.color }} />
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{s.name}</h3>
{s.pinned && <Pin className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => togglePin(s)} title={s.pinned ? 'Unpin' : 'Pin'}
className="p-1.5 text-slate-400 hover:text-amber-500">
<Pin className="w-3.5 h-3.5" />
</button>
<button onClick={() => openEdit(s)} title="Edit"
className="p-1.5 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDelete(s.id)} title="Delete"
className="p-1.5 text-slate-400 hover:text-red-600 dark:hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{s.description && <p className="text-sm text-slate-500 dark:text-slate-400 mb-3">{s.description}</p>}
{/* Filter badges */}
<div className="flex flex-wrap gap-1 mb-3">
{s.filters.stages?.map(st => (
<span key={st} className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded text-xs">{st}</span>
))}
{s.filters.tags?.map(tg => (
<span key={tg} className="px-2 py-0.5 bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 rounded text-xs">#{tg}</span>
))}
{s.filters.industries?.map(ind => (
<span key={ind} className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/40 text-purple-700 dark:text-purple-300 rounded text-xs">{ind}</span>
))}
{s.filters.hasEmail && <span className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded text-xs">📧 Has email</span>}
{s.filters.hasPhone && <span className="px-2 py-0.5 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded text-xs">📞 Has phone</span>}
</div>
<button onClick={async () => {
const result = await api.getSegment(s.id);
// Navigate to clients page — for now show count
alert(`${result.clientCount} clients match this segment`);
}}
className="flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline">
<Eye className="w-3.5 h-3.5" /> View clients
</button>
</div>
))}
</div>
)}
{/* Segment Builder Modal */}
<Modal isOpen={showBuilder} onClose={() => setShowBuilder(false)} title={editingSegment ? 'Edit Segment' : 'New Segment'} size="xl">
<div className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Segment Name</label>
<input value={segmentName} onChange={e => setSegmentName(e.target.value)}
placeholder="e.g., High-value active clients"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Description</label>
<input value={segmentDesc} onChange={e => setSegmentDesc(e.target.value)}
placeholder="Optional description"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Color</label>
<div className="flex gap-2">
{SEGMENT_COLORS.map(c => (
<button key={c} onClick={() => setSegmentColor(c)}
className={`w-7 h-7 rounded-full border-2 transition-transform ${segmentColor === c ? 'border-slate-900 dark:border-white scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }} />
))}
</div>
</div>
</div>
<FilterPanel filters={filters} onChange={setFilters} options={options} />
<div className="flex items-center gap-3">
<button onClick={handlePreview} disabled={previewing}
className="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors">
{previewing ? <LoadingSpinner size="sm" /> : <Eye className="w-4 h-4" />}
Preview ({activeFilterCount} filter{activeFilterCount !== 1 ? 's' : ''})
</button>
{previewCount !== null && (
<span className="text-sm font-medium text-slate-600 dark:text-slate-400">
<Users className="w-4 h-4 inline mr-1" />{previewCount} client{previewCount !== 1 ? 's' : ''} match
</span>
)}
</div>
{/* Preview results */}
{previewClients.length > 0 && (
<div className="max-h-60 overflow-y-auto border border-slate-200 dark:border-slate-700 rounded-lg">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Email</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Company</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-500 dark:text-slate-400">Stage</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{previewClients.slice(0, 20).map(c => (
<tr key={c.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-3 py-2 text-slate-900 dark:text-slate-100">{c.firstName} {c.lastName}</td>
<td className="px-3 py-2 text-slate-500 dark:text-slate-400">{c.email || '—'}</td>
<td className="px-3 py-2 text-slate-500 dark:text-slate-400">{c.company || '—'}</td>
<td className="px-3 py-2">
<span className="px-2 py-0.5 rounded text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300">{c.stage || 'lead'}</span>
</td>
</tr>
))}
</tbody>
</table>
{previewClients.length > 20 && (
<p className="px-3 py-2 text-xs text-slate-400 text-center">Showing 20 of {previewClients.length}</p>
)}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowBuilder(false)}
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 onClick={handleSave} disabled={saving || !segmentName}
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" /> : <Save className="w-4 h-4" />}
{editingSegment ? 'Update' : 'Save'} Segment
</button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -2,8 +2,9 @@ import { useEffect, useState } from 'react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import type { Profile } from '@/types'; import type { Profile } from '@/types';
import { Save, User, Lock, Mail, CheckCircle2, AlertCircle } from 'lucide-react'; import { Save, User, Lock, Mail, CheckCircle2, AlertCircle, MessageSquare, Plus, X } from 'lucide-react';
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner'; import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
import type { CommunicationStyle } from '@/types';
function StatusMessage({ type, message }: { type: 'success' | 'error'; message: string }) { function StatusMessage({ type, message }: { type: 'success' | 'error'; message: string }) {
return ( return (
@@ -33,10 +34,26 @@ export default function SettingsPage() {
const [passwordSaving, setPasswordSaving] = useState(false); const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordStatus, setPasswordStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null); const [passwordStatus, setPasswordStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
// Communication style
const [commStyle, setCommStyle] = useState<CommunicationStyle>({
tone: 'friendly',
greeting: '',
signoff: '',
writingSamples: [],
avoidWords: [],
});
const [styleSaving, setStyleSaving] = useState(false);
const [styleStatus, setStyleStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [newAvoidWord, setNewAvoidWord] = useState('');
useEffect(() => { useEffect(() => {
api.getProfile().then((p) => { Promise.all([
api.getProfile(),
api.getCommunicationStyle(),
]).then(([p, style]) => {
setProfile(p); setProfile(p);
setNewEmail(p.email || ''); setNewEmail(p.email || '');
setCommStyle(style);
setLoading(false); setLoading(false);
}).catch(() => setLoading(false)); }).catch(() => setLoading(false));
}, []); }, []);
@@ -296,6 +313,167 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
</form> </form>
{/* Communication Style */}
<form onSubmit={async (e) => {
e.preventDefault();
setStyleSaving(true);
setStyleStatus(null);
try {
const updated = await api.updateCommunicationStyle(commStyle);
setCommStyle(updated);
setStyleStatus({ type: 'success', message: 'Communication style saved' });
setTimeout(() => setStyleStatus(null), 3000);
} catch (err: any) {
setStyleStatus({ type: 'error', message: err.message || 'Failed to save' });
} finally {
setStyleSaving(false);
}
}}>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-6 space-y-5">
<div className="flex items-center gap-3 pb-4 border-b border-slate-100 dark:border-slate-700">
<div className="w-10 h-10 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded-lg flex items-center justify-center">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Communication Style</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">Customize how AI writes emails for you</p>
</div>
</div>
{/* Tone */}
<div>
<label className={labelClass}>Tone</label>
<div className="flex gap-3">
{(['formal', 'friendly', 'casual'] as const).map(tone => (
<button
key={tone}
type="button"
onClick={() => setCommStyle({ ...commStyle, tone })}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
commStyle.tone === tone
? 'bg-purple-100 dark:bg-purple-900/30 border-purple-300 dark:border-purple-700 text-purple-700 dark:text-purple-300'
: 'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-purple-300 dark:hover:border-purple-600'
}`}
>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</button>
))}
</div>
</div>
{/* Greeting & Sign-off */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className={labelClass}>Greeting</label>
<input
value={commStyle.greeting}
onChange={(e) => setCommStyle({ ...commStyle, greeting: e.target.value })}
placeholder="e.g., Hi, Hello, Dear"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Sign-off</label>
<input
value={commStyle.signoff}
onChange={(e) => setCommStyle({ ...commStyle, signoff: e.target.value })}
placeholder="e.g., Best regards, Cheers, Warm regards"
className={inputClass}
/>
</div>
</div>
{/* Writing Samples */}
<div>
<label className={labelClass}>Writing Samples <span className="font-normal text-slate-400">(up to 3)</span></label>
<p className="text-xs text-slate-400 mb-2">Paste examples of your actual emails so AI can match your style</p>
{[0, 1, 2].map(i => (
<textarea
key={i}
value={commStyle.writingSamples[i] || ''}
onChange={(e) => {
const samples = [...commStyle.writingSamples];
samples[i] = e.target.value;
setCommStyle({ ...commStyle, writingSamples: samples.filter(Boolean) });
}}
rows={3}
placeholder={`Writing sample ${i + 1}...`}
className={`${inputClass} mb-2 text-sm`}
/>
))}
</div>
{/* Avoid Words */}
<div>
<label className={labelClass}>Words to Avoid</label>
<div className="flex flex-wrap gap-2 mb-2">
{commStyle.avoidWords.map((word, i) => (
<span key={i} className="flex items-center gap-1 px-2.5 py-1 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 rounded-full text-sm">
{word}
<button
type="button"
onClick={() => setCommStyle({
...commStyle,
avoidWords: commStyle.avoidWords.filter((_, j) => j !== i),
})}
className="hover:text-red-900 dark:hover:text-red-100"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={newAvoidWord}
onChange={(e) => setNewAvoidWord(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (newAvoidWord.trim() && !commStyle.avoidWords.includes(newAvoidWord.trim())) {
setCommStyle({
...commStyle,
avoidWords: [...commStyle.avoidWords, newAvoidWord.trim()],
});
setNewAvoidWord('');
}
}
}}
placeholder="Type a word and press Enter..."
className={`${inputClass} flex-1`}
/>
<button
type="button"
onClick={() => {
if (newAvoidWord.trim() && !commStyle.avoidWords.includes(newAvoidWord.trim())) {
setCommStyle({
...commStyle,
avoidWords: [...commStyle.avoidWords, newAvoidWord.trim()],
});
setNewAvoidWord('');
}
}}
className="px-3 py-2 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="submit"
disabled={styleSaving}
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{styleSaving ? <LoadingSpinner size="sm" className="text-white" /> : <MessageSquare className="w-4 h-4" />}
Save Communication Style
</button>
{styleStatus && <StatusMessage {...styleStatus} />}
</div>
</div>
</form>
</div> </div>
); );
} }

329
src/pages/TagsPage.tsx Normal file
View File

@@ -0,0 +1,329 @@
import { useEffect, useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { Tag, Pencil, Trash2, Merge, Plus, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import EmptyState from '@/components/EmptyState';
import { PageLoader } from '@/components/LoadingSpinner';
import LoadingSpinner from '@/components/LoadingSpinner';
import Modal from '@/components/Modal';
interface TagInfo {
name: string;
count: number;
}
// Hash-based color palette
const TAG_COLORS = [
{ bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300', border: 'border-blue-200 dark:border-blue-800' },
{ bg: 'bg-emerald-100 dark:bg-emerald-900/40', text: 'text-emerald-700 dark:text-emerald-300', border: 'border-emerald-200 dark:border-emerald-800' },
{ bg: 'bg-purple-100 dark:bg-purple-900/40', text: 'text-purple-700 dark:text-purple-300', border: 'border-purple-200 dark:border-purple-800' },
{ bg: 'bg-amber-100 dark:bg-amber-900/40', text: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-800' },
{ bg: 'bg-pink-100 dark:bg-pink-900/40', text: 'text-pink-700 dark:text-pink-300', border: 'border-pink-200 dark:border-pink-800' },
{ bg: 'bg-cyan-100 dark:bg-cyan-900/40', text: 'text-cyan-700 dark:text-cyan-300', border: 'border-cyan-200 dark:border-cyan-800' },
{ bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', border: 'border-orange-200 dark:border-orange-800' },
{ bg: 'bg-indigo-100 dark:bg-indigo-900/40', text: 'text-indigo-700 dark:text-indigo-300', border: 'border-indigo-200 dark:border-indigo-800' },
{ bg: 'bg-rose-100 dark:bg-rose-900/40', text: 'text-rose-700 dark:text-rose-300', border: 'border-rose-200 dark:border-rose-800' },
{ bg: 'bg-teal-100 dark:bg-teal-900/40', text: 'text-teal-700 dark:text-teal-300', border: 'border-teal-200 dark:border-teal-800' },
];
function hashColor(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length];
}
export default function TagsPage() {
const navigate = useNavigate();
const [tags, setTags] = useState<TagInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Rename modal
const [renameTag, setRenameTag] = useState<TagInfo | null>(null);
const [newName, setNewName] = useState('');
const [renaming, setRenaming] = useState(false);
// Delete confirm
const [deleteTag, setDeleteTag] = useState<TagInfo | null>(null);
const [deleting, setDeleting] = useState(false);
// Merge modal
const [showMerge, setShowMerge] = useState(false);
const [mergeSelected, setMergeSelected] = useState<Set<string>>(new Set());
const [mergeTarget, setMergeTarget] = useState('');
const [merging, setMerging] = useState(false);
const fetchTags = async () => {
setIsLoading(true);
try {
const data = await api.getTags();
setTags(data);
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => { fetchTags(); }, []);
const handleRename = async () => {
if (!renameTag || !newName.trim()) return;
setRenaming(true);
try {
await api.renameTag(renameTag.name, newName.trim());
setRenameTag(null);
setNewName('');
await fetchTags();
} catch (err: any) {
setError(err.message);
} finally {
setRenaming(false);
}
};
const handleDelete = async () => {
if (!deleteTag) return;
setDeleting(true);
try {
await api.deleteTag(deleteTag.name);
setDeleteTag(null);
await fetchTags();
} catch (err: any) {
setError(err.message);
} finally {
setDeleting(false);
}
};
const handleMerge = async () => {
if (mergeSelected.size < 2 || !mergeTarget.trim()) return;
setMerging(true);
try {
const sourceTags = Array.from(mergeSelected).filter(t => t !== mergeTarget.trim());
await api.mergeTags(sourceTags, mergeTarget.trim());
setShowMerge(false);
setMergeSelected(new Set());
setMergeTarget('');
await fetchTags();
} catch (err: any) {
setError(err.message);
} finally {
setMerging(false);
}
};
if (isLoading) return <PageLoader />;
return (
<div className="max-w-6xl mx-auto space-y-5 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Tags</h1>
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
{tags.length} tag{tags.length !== 1 ? 's' : ''} across your clients
</p>
</div>
{tags.length >= 2 && (
<button
onClick={() => setShowMerge(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"
>
<Merge className="w-4 h-4" />
Merge Tags
</button>
)}
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">dismiss</button>
</div>
)}
{tags.length === 0 ? (
<EmptyState
icon={Tag}
title="No tags yet"
description="Add tags to your clients to organize them. Tags will appear here."
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tags.map((tag) => {
const color = hashColor(tag.name);
return (
<div
key={tag.name}
className={cn(
'rounded-xl border p-4 transition-all hover:shadow-md dark:hover:shadow-slate-900/50 cursor-pointer group',
color.bg, color.border
)}
>
<div
className="flex items-start justify-between"
onClick={() => navigate(`/clients?tag=${encodeURIComponent(tag.name)}`)}
>
<div className="flex-1 min-w-0">
<h3 className={cn('font-semibold truncate', color.text)}>{tag.name}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1 flex items-center gap-1">
<Users className="w-3.5 h-3.5" />
{tag.count} client{tag.count !== 1 ? 's' : ''}
</p>
</div>
</div>
<div className="flex items-center gap-1 mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); setRenameTag(tag); setNewName(tag.name); }}
className="p-1.5 rounded-lg text-slate-400 hover:bg-white/60 dark:hover:bg-slate-700/60 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
title="Rename"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); setDeleteTag(tag); }}
className="p-1.5 rounded-lg text-slate-400 hover:bg-red-50 dark:hover:bg-red-900/30 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
)}
{/* Rename Modal */}
<Modal isOpen={!!renameTag} onClose={() => setRenameTag(null)} title="Rename Tag">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Renaming: <span className="font-semibold">{renameTag?.name}</span>
</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="New tag name"
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
This will rename the tag across all {renameTag?.count} client{renameTag?.count !== 1 ? 's' : ''}.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setRenameTag(null)}
className="px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renaming || !newName.trim() || newName.trim() === renameTag?.name}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{renaming && <LoadingSpinner />}
Rename
</button>
</div>
</div>
</Modal>
{/* Delete Confirm Modal */}
<Modal isOpen={!!deleteTag} onClose={() => setDeleteTag(null)} title="Delete Tag">
<div className="space-y-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
Are you sure you want to delete the tag <span className="font-semibold">"{deleteTag?.name}"</span>?
It will be removed from {deleteTag?.count} client{deleteTag?.count !== 1 ? 's' : ''}.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeleteTag(null)}
className="px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center gap-2"
>
{deleting && <LoadingSpinner />}
Delete
</button>
</div>
</div>
</Modal>
{/* Merge Modal */}
<Modal isOpen={showMerge} onClose={() => { setShowMerge(false); setMergeSelected(new Set()); setMergeTarget(''); }} title="Merge Tags" size="lg">
<div className="space-y-4">
<p className="text-sm text-slate-700 dark:text-slate-300">
Select tags to merge, then choose or type the target tag name.
</p>
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto">
{tags.map((tag) => {
const selected = mergeSelected.has(tag.name);
const color = hashColor(tag.name);
return (
<button
key={tag.name}
onClick={() => {
const next = new Set(mergeSelected);
if (selected) next.delete(tag.name); else next.add(tag.name);
setMergeSelected(next);
}}
className={cn(
'px-3 py-1.5 rounded-lg text-sm font-medium border transition-all',
selected
? 'bg-blue-600 text-white border-blue-600'
: cn(color.bg, color.text, color.border, 'hover:opacity-80')
)}
>
{tag.name} ({tag.count})
</button>
);
})}
</div>
{mergeSelected.size >= 2 && (
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
Merge into tag name:
</label>
<input
type="text"
value={mergeTarget}
onChange={(e) => setMergeTarget(e.target.value)}
placeholder="Target tag name"
className="w-full px-3 py-2 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowMerge(false); setMergeSelected(new Set()); setMergeTarget(''); }}
className="px-4 py-2 text-sm text-slate-700 dark:text-slate-300 bg-slate-100 dark:bg-slate-700 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-600"
>
Cancel
</button>
<button
onClick={handleMerge}
disabled={merging || mergeSelected.size < 2 || !mergeTarget.trim()}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
{merging && <LoadingSpinner />}
Merge {mergeSelected.size} Tags
</button>
</div>
</div>
</Modal>
</div>
);
}

247
src/pages/TemplatesPage.tsx Normal file
View File

@@ -0,0 +1,247 @@
import { useEffect, useState } from 'react';
import { api } from '@/lib/api';
import type { EmailTemplate, EmailTemplateCreate } from '@/types';
import { Plus, Pencil, Trash2, Star, Copy, FileText, X, Save } from 'lucide-react';
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
import EmptyState from '@/components/EmptyState';
import Modal from "@/components/Modal";
const CATEGORIES = [
{ value: 'follow-up', label: 'Follow-up', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' },
{ value: 'birthday', label: 'Birthday', color: 'bg-pink-100 text-pink-700 dark:bg-pink-900/40 dark:text-pink-300' },
{ value: 'introduction', label: 'Introduction', color: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' },
{ value: 'check-in', label: 'Check-in', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
{ value: 'thank-you', label: 'Thank You', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' },
{ value: 'custom', label: 'Custom', color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300' },
];
function getCategoryStyle(category: string) {
return CATEGORIES.find(c => c.value === category)?.color || CATEGORIES[5].color;
}
function getCategoryLabel(category: string) {
return CATEGORIES.find(c => c.value === category)?.label || category;
}
const PLACEHOLDERS = [
{ token: '{{firstName}}', desc: "Client's first name" },
{ token: '{{lastName}}', desc: "Client's last name" },
{ token: '{{company}}', desc: "Client's company" },
{ token: '{{role}}', desc: "Client's role/title" },
];
export default function TemplatesPage() {
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState<string>('');
const [showEditor, setShowEditor] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
const [saving, setSaving] = useState(false);
// Form state
const [form, setForm] = useState<EmailTemplateCreate>({
name: '', category: 'follow-up', subject: '', content: '', isDefault: false,
});
useEffect(() => {
loadTemplates();
}, [filterCategory]);
const loadTemplates = async () => {
try {
const data = await api.getTemplates(filterCategory || undefined);
setTemplates(data);
} finally {
setLoading(false);
}
};
const openNew = () => {
setEditingTemplate(null);
setForm({ name: '', category: 'follow-up', subject: '', content: '', isDefault: false });
setShowEditor(true);
};
const openEdit = (t: EmailTemplate) => {
setEditingTemplate(t);
setForm({ name: t.name, category: t.category, subject: t.subject, content: t.content, isDefault: t.isDefault });
setShowEditor(true);
};
const handleSave = async () => {
setSaving(true);
try {
if (editingTemplate) {
await api.updateTemplate(editingTemplate.id, form);
} else {
await api.createTemplate(form);
}
setShowEditor(false);
await loadTemplates();
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Delete this template?')) return;
await api.deleteTemplate(id);
loadTemplates();
};
const handleDuplicate = async (t: EmailTemplate) => {
await api.createTemplate({
name: `${t.name} (Copy)`,
category: t.category,
subject: t.subject,
content: t.content,
});
loadTemplates();
};
const insertPlaceholder = (token: string) => {
setForm(prev => ({ ...prev, content: prev.content + token }));
};
if (loading) return <PageLoader />;
return (
<div className="space-y-6 animate-fade-in">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Email Templates</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
Reusable templates with placeholders for quick email drafting
</p>
</div>
<button onClick={openNew}
className="flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
<Plus className="w-4 h-4" /> New Template
</button>
</div>
{/* Category Filter */}
<div className="flex gap-2 flex-wrap">
<button onClick={() => setFilterCategory('')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
!filterCategory ? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
}`}>All</button>
{CATEGORIES.map(cat => (
<button key={cat.value} onClick={() => setFilterCategory(cat.value)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filterCategory === cat.value ? 'bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900' : `${cat.color} hover:opacity-80`
}`}>{cat.label}</button>
))}
</div>
{/* Templates Grid */}
{templates.length === 0 ? (
<EmptyState
icon={FileText}
title="No templates yet"
description="Create reusable email templates to speed up your workflow"
action={{ label: 'Create Template', onClick: openNew }}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map(t => (
<div key={t.id} className="group bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getCategoryStyle(t.category)}`}>
{getCategoryLabel(t.category)}
</span>
{t.isDefault && <Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleDuplicate(t)} title="Duplicate"
className="p-1.5 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400">
<Copy className="w-3.5 h-3.5" />
</button>
<button onClick={() => openEdit(t)} title="Edit"
className="p-1.5 text-slate-400 hover:text-blue-600 dark:hover:text-blue-400">
<Pencil className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleDelete(t.id)} title="Delete"
className="p-1.5 text-slate-400 hover:text-red-600 dark:hover:text-red-400">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-1">{t.name}</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2 font-medium">Subject: {t.subject}</p>
<p className="text-sm text-slate-600 dark:text-slate-300 line-clamp-3">{t.content}</p>
<div className="mt-3 text-xs text-slate-400">
Used {t.usageCount} time{t.usageCount !== 1 ? 's' : ''}
</div>
</div>
))}
</div>
)}
{/* Template Editor Modal */}
<Modal isOpen={showEditor} onClose={() => setShowEditor(false)} title={editingTemplate ? 'Edit Template' : 'New Template'} size="lg">
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Name</label>
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
placeholder="e.g., Monthly Check-in"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Category</label>
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none">
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Subject</label>
<input value={form.subject} onChange={e => setForm({ ...form, subject: e.target.value })}
placeholder="e.g., Checking in, {{firstName}}"
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label>
<div className="flex gap-1">
{PLACEHOLDERS.map(p => (
<button key={p.token} onClick={() => insertPlaceholder(p.token)} title={p.desc}
className="px-2 py-0.5 text-xs bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 rounded hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors">
{p.token}
</button>
))}
</div>
</div>
<textarea value={form.content} onChange={e => setForm({ ...form, content: e.target.value })}
rows={10} placeholder="Write your template content here. Use {{firstName}}, {{lastName}}, etc."
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono" />
</div>
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={form.isDefault || false}
onChange={e => setForm({ ...form, isDefault: e.target.checked })}
className="rounded border-slate-300 dark:border-slate-600" />
Set as default for this category
</label>
<div className="flex justify-end gap-3 pt-2">
<button onClick={() => setShowEditor(false)}
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 onClick={handleSave} disabled={saving || !form.name || !form.subject || !form.content}
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" /> : <Save className="w-4 h-4" />}
{editingTemplate ? 'Update' : 'Create'}
</button>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -40,6 +40,7 @@ export interface Client {
}; };
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
stage?: 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive';
lastContacted?: string; lastContacted?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -66,6 +67,17 @@ export interface ClientCreate {
}; };
notes?: string; notes?: string;
tags?: string[]; tags?: string[];
stage?: 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive';
}
export interface ClientNote {
id: string;
clientId: string;
userId: string;
content: string;
pinned: boolean;
createdAt: string;
updatedAt: string;
} }
export interface Event { export interface Event {
@@ -194,6 +206,157 @@ 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 EmailTemplate {
id: string;
userId: string;
name: string;
category: string;
subject: string;
content: string;
isDefault: boolean;
usageCount: number;
createdAt: string;
updatedAt: string;
}
export interface EmailTemplateCreate {
name: string;
category: string;
subject: string;
content: string;
isDefault?: boolean;
}
export interface SegmentFilters {
stages?: string[];
tags?: string[];
industries?: string[];
cities?: string[];
states?: string[];
lastContactedBefore?: string;
lastContactedAfter?: string;
createdBefore?: string;
createdAfter?: string;
hasEmail?: boolean;
hasPhone?: boolean;
search?: string;
}
export interface ClientSegment {
id: string;
userId: string;
name: string;
description?: string;
filters: SegmentFilters;
color: string;
pinned: boolean;
createdAt: string;
updatedAt: string;
clientCount?: number;
clients?: Client[];
}
export interface FilterOptions {
industries: string[];
cities: string[];
states: string[];
tags: string[];
stages: string[];
}
export interface AuditLog {
id: string;
userId: string | null;
action: string;
entityType: string;
entityId: string | null;
details: Record<string, unknown> | null;
ipAddress: string | null;
userAgent: string | null;
createdAt: string;
userName?: string;
userEmail?: string;
}
export interface AuditLogsResponse {
logs: AuditLog[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface MeetingPrep {
client: {
name: string;
company: string;
role: string;
industry: string;
stage: string;
interests: string[];
family?: { spouse?: string; children?: string[] } | null;
daysSinceLastContact: number;
};
healthScore: number;
importantDates: { type: string; date: string; label: string }[];
recentInteractions: { type: string; title: string; description?: string; date: string }[];
recentEmails: { subject?: string; status?: string; date: string }[];
upcomingEvents: { id: string; type: string; title: string; date: string }[];
notes: { id: string; content: string; pinned: boolean; createdAt: string }[];
aiTalkingPoints: {
summary: string;
suggestedTopics: string[];
conversationStarters: string[];
followUpItems: string[];
};
}
export interface CommunicationStyle {
tone: 'formal' | 'friendly' | 'casual';
greeting: string;
signoff: string;
writingSamples: string[];
avoidWords: string[];
}
export interface Invite { export interface Invite {
id: string; id: string;
email: string; email: string;