Compare commits
12 Commits
67e524d9b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df448a7245 | |||
| f042c910ee | |||
| b0cfa0ab1b | |||
| 4d684a9d74 | |||
| 1da92bac58 | |||
| 93f127f5e9 | |||
| 7a956aebec | |||
| 1340893144 | |||
| 22bf4778fd | |||
| 691e8170f3 | |||
| b43bdf3c71 | |||
| 38761586e7 |
49
.gitea/workflows/ci.yml
Normal file
49
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- name: Deploy to Dokploy
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
||||
-d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||
echo "Deploy triggered on Dokploy"
|
||||
10
nginx.conf
10
nginx.conf
@@ -4,6 +4,16 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Proxy API requests to backend (same-origin = no cookie issues in Brave etc.)
|
||||
location /api/ {
|
||||
proxy_pass https://api.thenetwork.donovankelly.xyz/api/;
|
||||
proxy_set_header Host api.thenetwork.donovankelly.xyz;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
60
src/App.tsx
60
src/App.tsx
@@ -3,6 +3,10 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import Layout from '@/components/Layout';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||
import { ToastContainer } from '@/components/Toast';
|
||||
import { toast } from '@/lib/toast';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
const DashboardPage = lazy(() => import('@/pages/DashboardPage'));
|
||||
@@ -14,9 +18,16 @@ const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
|
||||
const AdminPage = lazy(() => import('@/pages/AdminPage'));
|
||||
const NetworkPage = lazy(() => import('@/pages/NetworkPage'));
|
||||
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 ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
|
||||
const TagsPage = lazy(() => import('@/pages/TagsPage'));
|
||||
const EngagementPage = lazy(() => import('@/pages/EngagementPage'));
|
||||
const SearchPage = lazy(() => import('@/pages/SearchPage'));
|
||||
const ExportPage = lazy(() => import('@/pages/ExportPage'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
@@ -25,6 +36,21 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
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() {
|
||||
const { checkSession, isAuthenticated } = useAuthStore();
|
||||
|
||||
@@ -37,28 +63,36 @@ export default function App() {
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <PageErrorBoundary><LoginPage /></PageErrorBoundary>
|
||||
} />
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
||||
<Route path="/invite/:token" element={<PageErrorBoundary><InvitePage /></PageErrorBoundary>} />
|
||||
<Route path="/forgot-password" element={<PageErrorBoundary><ForgotPasswordPage /></PageErrorBoundary>} />
|
||||
<Route path="/reset-password/:token" element={<PageErrorBoundary><ResetPasswordPage /></PageErrorBoundary>} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="emails" element={<EmailsPage />} />
|
||||
<Route path="network" element={<NetworkPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin" element={<AdminPage />} />
|
||||
<Route index element={<PageErrorBoundary><DashboardPage /></PageErrorBoundary>} />
|
||||
<Route path="clients" element={<PageErrorBoundary><ClientsPage /></PageErrorBoundary>} />
|
||||
<Route path="clients/:id" element={<PageErrorBoundary><ClientDetailPage /></PageErrorBoundary>} />
|
||||
<Route path="events" element={<PageErrorBoundary><EventsPage /></PageErrorBoundary>} />
|
||||
<Route path="emails" element={<PageErrorBoundary><EmailsPage /></PageErrorBoundary>} />
|
||||
<Route path="network" element={<PageErrorBoundary><NetworkPage /></PageErrorBoundary>} />
|
||||
<Route path="reports" element={<PageErrorBoundary><ReportsPage /></PageErrorBoundary>} />
|
||||
<Route path="templates" element={<PageErrorBoundary><TemplatesPage /></PageErrorBoundary>} />
|
||||
<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="engagement" element={<PageErrorBoundary><EngagementPage /></PageErrorBoundary>} />
|
||||
<Route path="search" element={<PageErrorBoundary><SearchPage /></PageErrorBoundary>} />
|
||||
<Route path="export" element={<PageErrorBoundary><ExportPage /></PageErrorBoundary>} />
|
||||
<Route path="audit-log" element={<PageErrorBoundary><AuditLogPage /></PageErrorBoundary>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<ToastContainer />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,3 +53,24 @@ export function EmailStatusBadge({ status }: { status: string }) {
|
||||
};
|
||||
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>;
|
||||
}
|
||||
|
||||
380
src/components/BulkEmailModal.tsx
Normal file
380
src/components/BulkEmailModal.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Client, BulkEmailResult } from '@/types';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { Sparkles, Send, CheckCircle2, XCircle, Search, 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 'anthropic' | 'openai')}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api';
|
||||
import type { ImportPreview, ImportResult } from '@/types';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, ArrowRight, X } from 'lucide-react';
|
||||
import { Upload, FileSpreadsheet, CheckCircle2, AlertCircle, ArrowRight } from 'lucide-react';
|
||||
|
||||
const CLIENT_FIELDS = [
|
||||
{ value: '', label: '-- Skip --' },
|
||||
@@ -71,8 +71,8 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
setPreview(previewData);
|
||||
setMapping(previewData.mapping);
|
||||
setStep('mapping');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to parse CSV');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to parse CSV');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -109,8 +109,8 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
if (importResult.imported > 0) {
|
||||
onComplete();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Import failed');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed');
|
||||
setStep('mapping');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
204
src/components/ClientDocuments.tsx
Normal file
204
src/components/ClientDocuments.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api, type ClientDocument } from '@/lib/api';
|
||||
import { FileText, Upload, Trash2, Download, File, FileImage, FileSpreadsheet, Filter } from 'lucide-react';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'contract', label: 'Contract' },
|
||||
{ value: 'agreement', label: 'Agreement' },
|
||||
{ value: 'id', label: 'ID Copy' },
|
||||
{ value: 'statement', label: 'Statement' },
|
||||
{ value: 'correspondence', label: 'Correspondence' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
contract: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
agreement: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
id: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
statement: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300',
|
||||
correspondence: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300',
|
||||
other: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
};
|
||||
|
||||
function fileIcon(mimeType: string) {
|
||||
if (mimeType.startsWith('image/')) return <FileImage className="w-5 h-5 text-pink-500" />;
|
||||
if (mimeType.includes('spreadsheet') || mimeType.includes('csv') || mimeType.includes('excel'))
|
||||
return <FileSpreadsheet className="w-5 h-5 text-green-500" />;
|
||||
if (mimeType.includes('pdf')) return <FileText className="w-5 h-5 text-red-500" />;
|
||||
return <File className="w-5 h-5 text-slate-400" />;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function ClientDocuments({ clientId }: { clientId: string }) {
|
||||
const [documents, setDocuments] = useState<ClientDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [category, setCategory] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [uploadCategory, setUploadCategory] = useState('other');
|
||||
|
||||
const fetchDocs = useCallback(async () => {
|
||||
try {
|
||||
const docs = await api.getClientDocuments(clientId, category || undefined);
|
||||
setDocuments(docs);
|
||||
} catch { /* silently handled */ }
|
||||
setLoading(false);
|
||||
}, [clientId, category]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchDocs(); }, [fetchDocs]);
|
||||
|
||||
const handleUpload = async (files: FileList | File[]) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
await api.uploadDocument(clientId, file, { category: uploadCategory });
|
||||
}
|
||||
await fetchDocs();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Upload failed');
|
||||
}
|
||||
setUploading(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (e.dataTransfer.files.length) handleUpload(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDelete = async (docId: string) => {
|
||||
if (!confirm('Delete this document?')) return;
|
||||
try {
|
||||
await api.deleteDocument(docId);
|
||||
setDocuments(prev => prev.filter(d => d.id !== docId));
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const handleDownload = (docId: string, name: string) => {
|
||||
const token = localStorage.getItem('network-auth-token');
|
||||
const url = api.getDocumentDownloadUrl(docId);
|
||||
// Use fetch with auth header then download
|
||||
fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'include',
|
||||
})
|
||||
.then(r => r.blob())
|
||||
.then(blob => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
dragOver
|
||||
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-slate-300 dark:border-slate-600 hover:border-blue-300 dark:hover:border-blue-500'
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-8 h-8 mx-auto text-slate-400 mb-2" />
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 mb-2">
|
||||
{uploading ? 'Uploading...' : 'Drag & drop files here, or click to browse'}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<select
|
||||
value={uploadCategory}
|
||||
onChange={e => setUploadCategory(e.target.value)}
|
||||
className="text-xs px-2 py-1 border border-slate-300 dark:border-slate-600 rounded bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{CATEGORIES.filter(c => c.value).map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="cursor-pointer px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Browse Files
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => e.target.files && handleUpload(e.target.files)}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-slate-400" />
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{CATEGORIES.map(c => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => setCategory(c.value)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
category === c.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{loading ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400">Loading...</p>
|
||||
) : documents.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400">No documents uploaded yet</p>
|
||||
) : (
|
||||
documents.map(doc => (
|
||||
<div key={doc.id} className="flex items-center gap-3 px-5 py-3">
|
||||
{fileIcon(doc.mimeType)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{doc.name}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${categoryColors[doc.category] || categoryColors.other}`}>
|
||||
{doc.category}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{formatSize(doc.size)}</span>
|
||||
<span className="text-xs text-slate-400">{formatDate(doc.createdAt)}</span>
|
||||
</div>
|
||||
{doc.notes && <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 truncate">{doc.notes}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDownload(doc.id, doc.name)}
|
||||
className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-blue-600 transition-colors"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className="p-2 rounded-lg text-slate-400 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,12 +28,13 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
family: initialData?.family || { spouse: '', children: [] },
|
||||
notes: initialData?.notes || '',
|
||||
tags: initialData?.tags || [],
|
||||
stage: initialData?.stage || 'lead',
|
||||
});
|
||||
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [interestInput, setInterestInput] = useState('');
|
||||
|
||||
const update = (field: string, value: any) => setForm({ ...form, [field]: value });
|
||||
const update = (field: string, value: unknown) => setForm({ ...form, [field]: value });
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !form.tags?.includes(tagInput.trim())) {
|
||||
@@ -56,7 +57,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(form).filter(([_, v]) => v !== '' && v !== undefined && !(Array.isArray(v) && v.length === 0))
|
||||
Object.entries(form).filter(([, v]) => v !== '' && v !== undefined && !(Array.isArray(v) && v.length === 0))
|
||||
) as ClientCreate;
|
||||
if (form.family?.spouse || (form.family?.children && form.family.children.length > 0)) {
|
||||
cleaned.family = form.family;
|
||||
@@ -109,6 +110,18 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
</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 */}
|
||||
<div>
|
||||
<label className={labelClass}>Street</label>
|
||||
|
||||
301
src/components/ClientGoals.tsx
Normal file
301
src/components/ClientGoals.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api, type ClientGoal, type ClientGoalCreate } from '@/lib/api';
|
||||
import { Target, Plus, Edit3, Trash2, CheckCircle2, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import Modal from './Modal';
|
||||
|
||||
const CATEGORIES = ['retirement', 'investment', 'savings', 'insurance', 'estate', 'education', 'debt', 'other'];
|
||||
const STATUSES = ['on-track', 'at-risk', 'behind', 'completed'];
|
||||
const PRIORITIES = ['high', 'medium', 'low'];
|
||||
|
||||
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string }> = {
|
||||
'on-track': { icon: CheckCircle2, color: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
|
||||
'at-risk': { icon: AlertTriangle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-100 dark:bg-amber-900/30' },
|
||||
'behind': { icon: Clock, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-100 dark:bg-red-900/30' },
|
||||
'completed': { icon: CheckCircle2, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-100 dark:bg-blue-900/30' },
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
medium: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
low: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
|
||||
};
|
||||
|
||||
function formatCurrency(val: string | null): string {
|
||||
if (!val) return '$0';
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n)) return '$0';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
|
||||
}
|
||||
|
||||
function progressPercent(current: string | null, target: string | null): number {
|
||||
const c = parseFloat(current || '0');
|
||||
const t = parseFloat(target || '0');
|
||||
if (t <= 0) return 0;
|
||||
return Math.min(100, Math.round((c / t) * 100));
|
||||
}
|
||||
|
||||
interface GoalFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
targetAmount: string;
|
||||
currentAmount: string;
|
||||
targetDate: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
const emptyForm: GoalFormData = {
|
||||
title: '', description: '', category: 'other',
|
||||
targetAmount: '', currentAmount: '', targetDate: '',
|
||||
status: 'on-track', priority: 'medium',
|
||||
};
|
||||
|
||||
export default function ClientGoals({ clientId }: { clientId: string }) {
|
||||
const [goals, setGoals] = useState<ClientGoal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingGoal, setEditingGoal] = useState<ClientGoal | null>(null);
|
||||
const [form, setForm] = useState<GoalFormData>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchGoals = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getClientGoals(clientId);
|
||||
setGoals(data);
|
||||
} catch { /* silently handled */ }
|
||||
setLoading(false);
|
||||
}, [clientId]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchGoals(); }, [fetchGoals]);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingGoal(null);
|
||||
setForm(emptyForm);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const openEdit = (goal: ClientGoal) => {
|
||||
setEditingGoal(goal);
|
||||
setForm({
|
||||
title: goal.title,
|
||||
description: goal.description || '',
|
||||
category: goal.category,
|
||||
targetAmount: goal.targetAmount || '',
|
||||
currentAmount: goal.currentAmount || '',
|
||||
targetDate: goal.targetDate ? goal.targetDate.split('T')[0] : '',
|
||||
status: goal.status,
|
||||
priority: goal.priority,
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const data: ClientGoalCreate = {
|
||||
title: form.title,
|
||||
description: form.description || undefined,
|
||||
category: form.category,
|
||||
targetAmount: form.targetAmount || undefined,
|
||||
currentAmount: form.currentAmount || undefined,
|
||||
targetDate: form.targetDate || undefined,
|
||||
status: form.status,
|
||||
priority: form.priority,
|
||||
};
|
||||
if (editingGoal) {
|
||||
await api.updateGoal(editingGoal.id, data);
|
||||
} else {
|
||||
await api.createGoal(clientId, data);
|
||||
}
|
||||
setShowForm(false);
|
||||
await fetchGoals();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to save goal');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleDelete = async (goalId: string) => {
|
||||
if (!confirm('Delete this goal?')) return;
|
||||
try {
|
||||
await api.deleteGoal(goalId);
|
||||
setGoals(prev => prev.filter(g => g.id !== goalId));
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const handleMarkComplete = async (goal: ClientGoal) => {
|
||||
try {
|
||||
await api.updateGoal(goal.id, { status: 'completed', currentAmount: goal.targetAmount || undefined });
|
||||
await fetchGoals();
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-blue-500" />
|
||||
Financial Goals
|
||||
</h3>
|
||||
<button
|
||||
onClick={openAdd}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Goal
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-center text-sm text-slate-400 py-8">Loading...</p>
|
||||
) : goals.length === 0 ? (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-8 text-center">
|
||||
<Target className="w-10 h-10 mx-auto text-slate-300 dark:text-slate-600 mb-3" />
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No goals set for this client yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{goals.map(goal => {
|
||||
const pct = progressPercent(goal.currentAmount, goal.targetAmount);
|
||||
const cfg = statusConfig[goal.status] || statusConfig['on-track'];
|
||||
const StatusIcon = cfg.icon;
|
||||
return (
|
||||
<div key={goal.id} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-semibold text-slate-900 dark:text-slate-100 text-sm">{goal.title}</h4>
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${priorityColors[goal.priority] || priorityColors.medium}`}>
|
||||
{goal.priority}
|
||||
</span>
|
||||
</div>
|
||||
{goal.description && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">{goal.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 ml-2">
|
||||
{goal.status !== 'completed' && (
|
||||
<button onClick={() => handleMarkComplete(goal)} className="p-1.5 rounded text-slate-400 hover:text-emerald-500 transition-colors" title="Mark complete">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => openEdit(goal)} className="p-1.5 rounded text-slate-400 hover:text-blue-500 transition-colors" title="Edit">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(goal.id)} className="p-1.5 rounded text-slate-400 hover:text-red-500 transition-colors" title="Delete">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{goal.targetAmount && parseFloat(goal.targetAmount) > 0 && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span className="text-slate-500 dark:text-slate-400">{formatCurrency(goal.currentAmount)} of {formatCurrency(goal.targetAmount)}</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
goal.status === 'completed' ? 'bg-blue-500' :
|
||||
goal.status === 'behind' ? 'bg-red-500' :
|
||||
goal.status === 'at-risk' ? 'bg-amber-500' : 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status & Category */}
|
||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full ${cfg.bg} ${cfg.color} font-medium`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{goal.status}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 font-medium">
|
||||
{goal.category}
|
||||
</span>
|
||||
{goal.targetDate && (
|
||||
<span className="text-slate-400">Target: {formatDate(goal.targetDate)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal isOpen={showForm} onClose={() => setShowForm(false)} title={editingGoal ? 'Edit Goal' : 'Add Goal'}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title *</label>
|
||||
<input type="text" value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} required
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Description</label>
|
||||
<textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
<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">Category</label>
|
||||
<select value={form.category} onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Priority</label>
|
||||
<select value={form.priority} onChange={e => setForm(f => ({ ...f, priority: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
|
||||
{PRIORITIES.map(p => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<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">Target Amount</label>
|
||||
<input type="number" step="0.01" value={form.targetAmount} onChange={e => setForm(f => ({ ...f, targetAmount: e.target.value }))} placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Current Amount</label>
|
||||
<input type="number" step="0.01" value={form.currentAmount} onChange={e => setForm(f => ({ ...f, currentAmount: e.target.value }))} placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<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">Target Date</label>
|
||||
<input type="date" value={form.targetDate} onChange={e => setForm(f => ({ ...f, targetDate: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Status</label>
|
||||
<select value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
|
||||
{STATUSES.map(s => <option key={s} value={s}>{s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowForm(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 type="submit" disabled={saving}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Saving...' : editingGoal ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
src/components/ClientNotes.tsx
Normal file
214
src/components/ClientNotes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
src/components/ClientReferrals.tsx
Normal file
253
src/components/ClientReferrals.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api, type Referral, type ReferralCreate } from '@/lib/api';
|
||||
import type { Client } from '@/types';
|
||||
import { UserPlus, Plus, Trash2, ArrowRight, Search } from 'lucide-react';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import Modal from './Modal';
|
||||
|
||||
const STATUSES = ['pending', 'contacted', 'converted', 'lost'];
|
||||
const TYPES = ['client', 'partner', 'event'];
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300',
|
||||
contacted: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
converted: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300',
|
||||
lost: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
};
|
||||
|
||||
function formatCurrency(val: string | null): string {
|
||||
if (!val) return '';
|
||||
const n = parseFloat(val);
|
||||
if (isNaN(n)) return '';
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n);
|
||||
}
|
||||
|
||||
export default function ClientReferrals({ clientId, clientName }: { clientId: string; clientName: string }) {
|
||||
const [referrals, setReferrals] = useState<Referral[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [form, setForm] = useState({ referredId: '', type: 'client', notes: '', value: '', status: 'pending' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchReferrals = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getClientReferrals(clientId);
|
||||
setReferrals(data);
|
||||
} catch { /* silently handled */ }
|
||||
setLoading(false);
|
||||
}, [clientId]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { fetchReferrals(); }, [fetchReferrals]);
|
||||
|
||||
const openAdd = async () => {
|
||||
setForm({ referredId: '', type: 'client', notes: '', value: '', status: 'pending' });
|
||||
setSearchQuery('');
|
||||
try {
|
||||
const allClients = await api.getClients();
|
||||
setClients(allClients.filter((c: Client) => c.id !== clientId));
|
||||
} catch { /* silently handled */ }
|
||||
setShowAdd(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.referredId) { alert('Please select a referred client'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const data: ReferralCreate = {
|
||||
referredId: form.referredId,
|
||||
type: form.type,
|
||||
notes: form.notes || undefined,
|
||||
value: form.value || undefined,
|
||||
status: form.status,
|
||||
};
|
||||
await api.createReferral(clientId, data);
|
||||
setShowAdd(false);
|
||||
await fetchReferrals();
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : 'Failed to create referral');
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (refId: string, status: string) => {
|
||||
try {
|
||||
await api.updateReferral(refId, { status });
|
||||
await fetchReferrals();
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (refId: string) => {
|
||||
if (!confirm('Delete this referral?')) return;
|
||||
try {
|
||||
await api.deleteReferral(refId);
|
||||
setReferrals(prev => prev.filter(r => r.id !== refId));
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const filteredClients = clients.filter(c => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return `${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
|
||||
(c.email || '').toLowerCase().includes(q) ||
|
||||
(c.company || '').toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const given = referrals.filter(r => r.referrerId === clientId);
|
||||
const received = referrals.filter(r => r.referredId === clientId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<UserPlus className="w-5 h-5 text-indigo-500" />
|
||||
Referrals
|
||||
</h3>
|
||||
<button onClick={openAdd}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Referral
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Given Referrals */}
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400 mb-2">
|
||||
Referrals Given ({given.length})
|
||||
</h4>
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{loading ? (
|
||||
<p className="px-5 py-6 text-center text-sm text-slate-400">Loading...</p>
|
||||
) : given.length === 0 ? (
|
||||
<p className="px-5 py-6 text-center text-sm text-slate-400">No referrals given yet</p>
|
||||
) : (
|
||||
given.map(ref => (
|
||||
<div key={ref.id} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{ref.referrer.firstName} {ref.referrer.lastName}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ref.referred.firstName} {ref.referred.lastName}
|
||||
</span>
|
||||
</div>
|
||||
{ref.value && (
|
||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{formatCurrency(ref.value)}</span>
|
||||
)}
|
||||
<select
|
||||
value={ref.status}
|
||||
onChange={e => handleUpdateStatus(ref.id, e.target.value)}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium border-0 ${statusColors[ref.status] || statusColors.pending}`}
|
||||
>
|
||||
{STATUSES.map(s => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-slate-400">{formatDate(ref.createdAt)}</span>
|
||||
<button onClick={() => handleDelete(ref.id)} className="p-1.5 rounded text-slate-400 hover:text-red-500 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Received Referrals */}
|
||||
{received.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase text-slate-500 dark:text-slate-400 mb-2">
|
||||
Referred By ({received.length})
|
||||
</h4>
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{received.map(ref => (
|
||||
<div key={ref.id} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-400 truncate">
|
||||
{ref.referrer.firstName} {ref.referrer.lastName}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">
|
||||
{ref.referred.firstName} {ref.referred.lastName}
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusColors[ref.status] || statusColors.pending}`}>
|
||||
{ref.status}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{formatDate(ref.createdAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Referral Modal */}
|
||||
<Modal isOpen={showAdd} onClose={() => setShowAdd(false)} title="Add Referral">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
Referring: <span className="font-bold">{clientName}</span> → Select who they referred:
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(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 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-40 overflow-y-auto border border-slate-200 dark:border-slate-600 rounded-lg">
|
||||
{filteredClients.slice(0, 20).map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, referredId: c.id }))}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors ${
|
||||
form.referredId === c.id ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'text-slate-900 dark:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{c.firstName} {c.lastName}
|
||||
{c.company && <span className="text-xs text-slate-400 ml-2">({c.company})</span>}
|
||||
</button>
|
||||
))}
|
||||
{filteredClients.length === 0 && (
|
||||
<p className="px-3 py-2 text-sm text-slate-400">No matching clients</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<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">Type</label>
|
||||
<select value={form.type} onChange={e => setForm(f => ({ ...f, type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm">
|
||||
{TYPES.map(t => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Est. Value</label>
|
||||
<input type="number" step="0.01" value={form.value} onChange={e => setForm(f => ({ ...f, value: e.target.value }))} placeholder="0.00"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Notes</label>
|
||||
<textarea value={form.notes} onChange={e => setForm(f => ({ ...f, notes: e.target.value }))} rows={2}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowAdd(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 type="submit" disabled={saving || !form.referredId}
|
||||
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Creating...' : 'Create Referral'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { api } from '@/lib/api';
|
||||
import type { Client } from '@/types';
|
||||
import {
|
||||
Search, LayoutDashboard, Users, Calendar, Mail, Settings,
|
||||
Network, BarChart3, Shield, User, ArrowRight, Command,
|
||||
Network, BarChart3, Shield, User, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { cn, getInitials } from '@/lib/utils';
|
||||
|
||||
|
||||
148
src/components/DuplicatesModal.tsx
Normal file
148
src/components/DuplicatesModal.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, type DuplicateClient } from '@/lib/api';
|
||||
import { X, Loader2, AlertTriangle, Merge, CheckCircle, Users } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
onMerged: () => void;
|
||||
}
|
||||
|
||||
export default function DuplicatesModal({ isOpen, onClose, clientId, clientName, onMerged }: Props) {
|
||||
const [duplicates, setDuplicates] = useState<DuplicateClient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [merging, setMerging] = useState<string | null>(null);
|
||||
const [merged, setMerged] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
setMerged(null);
|
||||
api.getClientDuplicates(clientId)
|
||||
.then(d => setDuplicates(d))
|
||||
.catch(e => console.error('Failed to find duplicates:', e))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isOpen, clientId]);
|
||||
|
||||
const handleMerge = async (dupId: string, dupName: string) => {
|
||||
if (!confirm(`Merge "${dupName}" into "${clientName}"?\n\nThis will:\n• Keep "${clientName}" as the primary record\n• Fill missing fields from "${dupName}"\n• Move all emails, events, interactions, and notes\n• Delete "${dupName}"\n\nThis cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
setMerging(dupId);
|
||||
try {
|
||||
await api.mergeClients(clientId, dupId);
|
||||
setMerged(dupId);
|
||||
setDuplicates(prev => prev.filter(d => d.id !== dupId));
|
||||
setTimeout(() => {
|
||||
onMerged();
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
console.error('Merge failed:', e);
|
||||
alert('Merge failed. Please try again.');
|
||||
} finally {
|
||||
setMerging(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 70) return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/30';
|
||||
if (score >= 40) return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-900/30';
|
||||
return 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900/30';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-indigo-500" />
|
||||
Find Duplicates
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Potential duplicates for {clientName}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-500" />
|
||||
<span className="ml-2 text-gray-500">Scanning for duplicates...</span>
|
||||
</div>
|
||||
) : duplicates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle className="w-12 h-12 text-green-400 mx-auto mb-3" />
|
||||
<p className="text-gray-600 dark:text-gray-300 font-medium">No duplicates found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">This client record appears to be unique</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-lg p-3">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Found {duplicates.length} potential duplicate{duplicates.length !== 1 ? 's' : ''}. Review and merge if appropriate.</span>
|
||||
</div>
|
||||
|
||||
{duplicates.map(dup => (
|
||||
<div key={dup.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{dup.firstName} {dup.lastName}
|
||||
</span>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getScoreColor(dup.duplicateScore)}`}>
|
||||
{dup.duplicateScore}% match
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
|
||||
{dup.email && <p>{dup.email}</p>}
|
||||
{dup.phone && <p>{dup.phone}</p>}
|
||||
{dup.company && <p>{dup.company}</p>}
|
||||
<p className="capitalize text-xs">Stage: {dup.stage || 'lead'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{dup.matchReasons.map(reason => (
|
||||
<span key={reason} className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleMerge(dup.id, `${dup.firstName} ${dup.lastName}`)}
|
||||
disabled={merging === dup.id || merged === dup.id}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors flex-shrink-0 ${
|
||||
merged === dup.id
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 text-white disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
{merging === dup.id ? (
|
||||
<><Loader2 className="w-3.5 h-3.5 animate-spin" /> Merging...</>
|
||||
) : merged === dup.id ? (
|
||||
<><CheckCircle className="w-3.5 h-3.5" /> Merged</>
|
||||
) : (
|
||||
<><Merge className="w-3.5 h-3.5" /> Merge</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
setEditSubject(email.subject);
|
||||
setEditContent(email.content);
|
||||
onGenerated?.(email);
|
||||
} catch {}
|
||||
} catch { /* generation failed silently */ }
|
||||
};
|
||||
|
||||
const handleGenerateBirthday = async () => {
|
||||
@@ -40,7 +40,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
setEditSubject(email.subject);
|
||||
setEditContent(email.content);
|
||||
onGenerated?.(email);
|
||||
} catch {}
|
||||
} catch { /* generation failed silently */ }
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -64,8 +64,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
onClose();
|
||||
setGenerated(null);
|
||||
setPurpose('');
|
||||
} catch {
|
||||
} finally {
|
||||
} catch { /* send failed silently */ } finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
@@ -98,7 +97,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
<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)}
|
||||
onChange={(e) => setProvider(e.target.value as 'anthropic' | 'openai')}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
|
||||
108
src/components/EngagementBadge.tsx
Normal file
108
src/components/EngagementBadge.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type ClientEngagement } from '@/lib/api';
|
||||
import { Zap } from 'lucide-react';
|
||||
|
||||
const scoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-emerald-500';
|
||||
if (score >= 60) return 'text-blue-500';
|
||||
if (score >= 40) return 'text-yellow-500';
|
||||
if (score >= 20) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
};
|
||||
|
||||
const scoreBgRing = (score: number) => {
|
||||
if (score >= 80) return 'ring-emerald-500/40';
|
||||
if (score >= 60) return 'ring-blue-500/40';
|
||||
if (score >= 40) return 'ring-yellow-500/40';
|
||||
if (score >= 20) return 'ring-orange-500/40';
|
||||
return 'ring-red-500/40';
|
||||
};
|
||||
|
||||
const labelText = (label: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
highly_engaged: 'Highly Engaged',
|
||||
engaged: 'Engaged',
|
||||
warm: 'Warm',
|
||||
cooling: 'Cooling',
|
||||
cold: 'Cold',
|
||||
};
|
||||
return labels[label] || label;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
clientId: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function EngagementBadge({ clientId, compact = false }: Props) {
|
||||
const [data, setData] = useState<ClientEngagement | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getClientEngagement(clientId).then(setData).catch(() => {});
|
||||
}, [clientId]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-sm font-semibold ${scoreColor(data.score)}`}>
|
||||
<Zap className="w-3.5 h-3.5" />
|
||||
{data.score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white dark:bg-gray-800 ring-2 ${scoreBgRing(data.score)} transition-all hover:shadow-md`}
|
||||
>
|
||||
<Zap className={`w-4 h-4 ${scoreColor(data.score)}`} />
|
||||
<span className={`font-bold text-lg ${scoreColor(data.score)}`}>{data.score}</span>
|
||||
<span className="text-xs text-gray-500">{labelText(data.label)}</span>
|
||||
</button>
|
||||
|
||||
{showDetails && (
|
||||
<div className="absolute top-full left-0 mt-2 w-80 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-4 z-50">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Engagement Score</span>
|
||||
<span className={`text-2xl font-bold ${scoreColor(data.score)}`}>{data.score}/100</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{[
|
||||
{ label: 'Recency', value: data.breakdown.recency, max: 40, color: 'bg-emerald-500' },
|
||||
{ label: 'Interactions', value: data.breakdown.interactions, max: 25, color: 'bg-blue-500' },
|
||||
{ label: 'Emails', value: data.breakdown.emails, max: 15, color: 'bg-purple-500' },
|
||||
{ label: 'Events', value: data.breakdown.events, max: 10, color: 'bg-amber-500' },
|
||||
{ label: 'Notes', value: data.breakdown.notes, max: 10, color: 'bg-pink-500' },
|
||||
].map(({ label, value, max, color }) => (
|
||||
<div key={label} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 text-gray-500">{label}</span>
|
||||
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${(value / max) * 100}%` }} />
|
||||
</div>
|
||||
<span className="w-8 text-right text-gray-400">{value}/{max}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{data.recommendations.length > 0 && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">Recommendations</div>
|
||||
<ul className="space-y-1">
|
||||
{data.recommendations.slice(0, 3).map((rec, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 dark:text-gray-400">
|
||||
💡 {rec}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
src/components/ErrorBoundary.tsx
Normal file
74
src/components/ErrorBoundary.tsx
Normal 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;
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search,
|
||||
FileText, Bookmark, ScrollText, Tag, Zap, Database,
|
||||
} from 'lucide-react';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import CommandPalette from './CommandPalette';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import OnboardingWizard from './OnboardingWizard';
|
||||
|
||||
const baseNavItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
@@ -16,18 +19,41 @@ const baseNavItems = [
|
||||
{ path: '/events', label: 'Events', icon: Calendar },
|
||||
{ path: '/emails', label: 'Emails', icon: Mail },
|
||||
{ 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: '/engagement', label: 'Engagement', icon: Zap },
|
||||
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
||||
{ path: '/search', label: 'Search', icon: Search },
|
||||
{ path: '/export', label: 'Export', icon: Database },
|
||||
{ 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() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const location = useLocation();
|
||||
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 () => {
|
||||
await logout();
|
||||
@@ -35,6 +61,12 @@ export default function Layout() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{showOnboarding && (
|
||||
<OnboardingWizard
|
||||
userName={user?.name || ''}
|
||||
onComplete={() => setShowOnboarding(false)}
|
||||
/>
|
||||
)}
|
||||
<CommandPalette />
|
||||
{/* Mobile overlay */}
|
||||
{mobileOpen && (
|
||||
|
||||
162
src/components/LogInteractionModal.tsx
Normal file
162
src/components/LogInteractionModal.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import { Phone, Users, Mail, FileText, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const interactionTypes = [
|
||||
{ value: 'call', label: 'Phone Call', icon: Phone, color: 'text-green-600 bg-green-50 dark:bg-green-900/30 dark:text-green-400' },
|
||||
{ value: 'meeting', label: 'Meeting', icon: Users, color: 'text-blue-600 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
{ value: 'email', label: 'Email', icon: Mail, color: 'text-purple-600 bg-purple-50 dark:bg-purple-900/30 dark:text-purple-400' },
|
||||
{ value: 'note', label: 'Note', icon: FileText, color: 'text-amber-600 bg-amber-50 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ value: 'other', label: 'Other', icon: MoreHorizontal, color: 'text-slate-600 bg-slate-50 dark:bg-slate-700 dark:text-slate-400' },
|
||||
];
|
||||
|
||||
interface LogInteractionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
onCreated?: () => void;
|
||||
}
|
||||
|
||||
export default function LogInteractionModal({ isOpen, onClose, clientId, clientName, onCreated }: LogInteractionModalProps) {
|
||||
const [type, setType] = useState('call');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [duration, setDuration] = useState('');
|
||||
const [contactedAt, setContactedAt] = useState(new Date().toISOString().slice(0, 16));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setType('call');
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setDuration('');
|
||||
setContactedAt(new Date().toISOString().slice(0, 16));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.createInteraction(clientId, {
|
||||
type,
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
duration: duration ? parseInt(duration) : undefined,
|
||||
contactedAt: new Date(contactedAt).toISOString(),
|
||||
});
|
||||
resetForm();
|
||||
onCreated?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to log interaction:', err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={`Log Interaction — ${clientName}`} size="md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Type</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{interactionTypes.map(t => {
|
||||
const Icon = t.icon;
|
||||
const selected = type === t.value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setType(t.value)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all border',
|
||||
selected
|
||||
? `${t.color} border-current ring-1 ring-current/20`
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title *</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="Brief summary of the interaction"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Details, key topics discussed, next steps..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Duration */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Duration (min)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={e => setDuration(e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date & Time</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={contactedAt}
|
||||
onChange={e => setContactedAt(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? <LoadingSpinner size="sm" className="text-white" /> : null}
|
||||
Log Interaction
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
244
src/components/MeetingPrepModal.tsx
Normal file
244
src/components/MeetingPrepModal.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
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) return;
|
||||
let cancelled = false;
|
||||
const fetchPrep = async () => {
|
||||
try {
|
||||
const data = await api.getMeetingPrep(clientId);
|
||||
if (!cancelled) {
|
||||
setPrep(data);
|
||||
setError('');
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate meeting prep');
|
||||
setPrep(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setPrep(null);
|
||||
fetchPrep();
|
||||
return () => { cancelled = true; };
|
||||
}, [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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,47 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { Bell, AlertTriangle, Calendar, Users, Mail, Clock, X } from 'lucide-react';
|
||||
import type { Notification } from '@/types';
|
||||
import { Bell, Clock, X, CheckCheck, User } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'overdue' | 'upcoming' | 'stale' | 'drafts';
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
link: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface NotifCounts {
|
||||
total: number;
|
||||
high: number;
|
||||
overdue: number;
|
||||
upcoming: number;
|
||||
stale: number;
|
||||
drafts: number;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Calendar> = {
|
||||
overdue: AlertTriangle,
|
||||
upcoming: Calendar,
|
||||
stale: Users,
|
||||
drafts: Mail,
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
overdue: 'text-red-500 bg-red-50 dark:bg-red-900/30',
|
||||
upcoming: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
|
||||
stale: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30',
|
||||
drafts: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
|
||||
event_reminder: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
|
||||
interaction: 'text-emerald-500 bg-emerald-50 dark:bg-emerald-900/30',
|
||||
system: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
|
||||
};
|
||||
|
||||
const priorityDot: Record<string, string> = {
|
||||
high: 'bg-red-500',
|
||||
medium: 'bg-amber-400',
|
||||
low: 'bg-slate-300 dark:bg-slate-500',
|
||||
};
|
||||
function timeAgo(dateStr: string) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [counts, setCounts] = useState<NotifCounts | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const data = await api.getNotifications({ limit: 30 });
|
||||
setNotifications(data.notifications || []);
|
||||
setUnreadCount(data.unreadCount || 0);
|
||||
} catch {
|
||||
/* silently handled - API might not have notifications table yet */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount is intentional
|
||||
fetchNotifications();
|
||||
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
||||
const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -66,23 +55,30 @@ export default function NotificationBell() {
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const markRead = async (id: string) => {
|
||||
try {
|
||||
const data = await api.getNotifications();
|
||||
setNotifications(data.notifications || []);
|
||||
setCounts(data.counts || null);
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
await api.markNotificationRead(id);
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
setDismissed(prev => new Set(prev).add(id));
|
||||
const markAllRead = async () => {
|
||||
try {
|
||||
await api.markAllNotificationsRead();
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
setUnreadCount(0);
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const visibleNotifs = notifications.filter(n => !dismissed.has(n.id));
|
||||
const activeCount = visibleNotifs.length;
|
||||
const highCount = visibleNotifs.filter(n => n.priority === 'high').length;
|
||||
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 { /* silently handled */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
@@ -94,12 +90,9 @@ export default function NotificationBell() {
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{activeCount > 0 && (
|
||||
<span className={cn(
|
||||
'absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-[10px] font-bold text-white px-1',
|
||||
highCount > 0 ? 'bg-red-500' : 'bg-blue-500'
|
||||
)}>
|
||||
{activeCount > 99 ? '99+' : activeCount}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full text-[10px] font-bold text-white px-1 bg-red-500">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -108,62 +101,73 @@ export default function NotificationBell() {
|
||||
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Notifications</h3>
|
||||
{counts && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
{counts.overdue > 0 && (
|
||||
<span className="text-red-500 dark:text-red-400 font-medium">{counts.overdue} overdue</span>
|
||||
)}
|
||||
{counts.upcoming > 0 && (
|
||||
<span>{counts.upcoming} upcoming</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
<CheckCheck className="w-3.5 h-3.5" />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{unreadCount > 0 ? `${unreadCount} unread` : 'All read'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{visibleNotifs.length === 0 ? (
|
||||
{notifications.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400 dark:text-slate-500">
|
||||
<Clock className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">All caught up!</p>
|
||||
<p className="text-sm">No notifications yet</p>
|
||||
</div>
|
||||
) : (
|
||||
visibleNotifs.map(n => {
|
||||
const Icon = typeIcons[n.type] || Calendar;
|
||||
return (
|
||||
<div key={n.id} className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-50 dark:border-slate-700 last:border-0">
|
||||
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 text-slate-500')}>
|
||||
<Icon className="w-4 h-4" />
|
||||
notifications.map(n => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3 border-b border-slate-50 dark:border-slate-700 last:border-0 transition-colors',
|
||||
!n.read && 'bg-blue-50/50 dark:bg-blue-900/10'
|
||||
)}
|
||||
>
|
||||
<div className={cn('p-1.5 rounded-lg mt-0.5 flex-shrink-0', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 text-slate-500')}>
|
||||
<Bell className="w-4 h-4" />
|
||||
</div>
|
||||
<Link to={n.link} onClick={() => setOpen(false)} className="flex-1 min-w-0">
|
||||
<div
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
onClick={() => !n.read && markRead(n.id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', priorityDot[n.priority])} />
|
||||
{!n.read && <div className="w-1.5 h-1.5 rounded-full bg-blue-500 flex-shrink-0" />}
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{n.title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{n.description}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">{n.message}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">{timeAgo(n.createdAt)}</span>
|
||||
{n.client && (
|
||||
<Link
|
||||
to={`/clients/${n.clientId}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
<User className="w-3 h-3" />
|
||||
{n.client.firstName} {n.client.lastName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(n.id)}
|
||||
className="p-1 rounded text-slate-300 dark:text-slate-500 hover:text-slate-500 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-600 flex-shrink-0"
|
||||
onClick={() => remove(n.id)}
|
||||
className="p-1 rounded text-slate-300 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-slate-100 dark:hover:bg-slate-600 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleNotifs.length > 0 && (
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-2.5">
|
||||
<Link
|
||||
to="/reports"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
View Reports →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
369
src/components/OnboardingWizard.tsx
Normal file
369
src/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Sparkles, UserPlus, MessageSquare, Compass,
|
||||
ChevronRight, ChevronLeft, 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();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
75
src/components/Toast.tsx
Normal file
75
src/components/Toast.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toastListeners, toasts as globalToasts, notifyListeners } from '@/lib/toast';
|
||||
import type { ToastItem } from '@/lib/toast';
|
||||
|
||||
// Types and functions re-exported from @/lib/toast for backward compat
|
||||
// Import directly from @/lib/toast for non-component usage
|
||||
|
||||
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 () => {
|
||||
const idx = toastListeners.indexOf(setItems);
|
||||
if (idx >= 0) toastListeners.splice(idx, 1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
const idx = globalToasts.findIndex(t => t.id === id);
|
||||
if (idx >= 0) globalToasts.splice(idx, 1);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -68,3 +68,11 @@ html.dark body {
|
||||
.animate-slide-up {
|
||||
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; }
|
||||
}
|
||||
|
||||
652
src/lib/api.ts
652
src/lib/api.ts
@@ -1,12 +1,10 @@
|
||||
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
|
||||
? 'https://api.thenetwork.donovankelly.xyz/api'
|
||||
: '/api';
|
||||
// Always use same-origin paths — nginx proxies /api/* to the backend
|
||||
// This avoids cross-domain cookie issues in Brave and other privacy browsers
|
||||
const API_BASE = '/api';
|
||||
|
||||
const AUTH_BASE = import.meta.env.PROD
|
||||
? 'https://api.thenetwork.donovankelly.xyz'
|
||||
: '';
|
||||
const AUTH_BASE = '';
|
||||
|
||||
const TOKEN_KEY = 'network-auth-token';
|
||||
|
||||
@@ -28,6 +26,13 @@ class ApiClient {
|
||||
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> {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -43,7 +48,14 @@ class ApiClient {
|
||||
|
||||
if (!response.ok) {
|
||||
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();
|
||||
@@ -404,31 +416,257 @@ class ApiClient {
|
||||
}
|
||||
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
|
||||
async getReportsOverview(): Promise<any> {
|
||||
async getReportsOverview(): Promise<unknown> {
|
||||
return this.fetch('/reports/overview');
|
||||
}
|
||||
|
||||
async getReportsGrowth(): Promise<any> {
|
||||
async getReportsGrowth(): Promise<unknown> {
|
||||
return this.fetch('/reports/growth');
|
||||
}
|
||||
|
||||
async getReportsIndustries(): Promise<any[]> {
|
||||
async getReportsIndustries(): Promise<unknown[]> {
|
||||
return this.fetch('/reports/industries');
|
||||
}
|
||||
|
||||
async getReportsTags(): Promise<any[]> {
|
||||
async getReportsTags(): Promise<unknown[]> {
|
||||
return this.fetch('/reports/tags');
|
||||
}
|
||||
|
||||
async getReportsEngagement(): Promise<any> {
|
||||
async getReportsEngagement(): Promise<unknown> {
|
||||
return this.fetch('/reports/engagement');
|
||||
}
|
||||
|
||||
async getNotifications(): Promise<any> {
|
||||
async getNotificationsLegacy(): Promise<unknown> {
|
||||
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> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
@@ -447,6 +685,392 @@ class ApiClient {
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
// ---- Global Search ----
|
||||
async globalSearch(q: string, types?: string[], limit?: number): Promise<SearchResults> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', q);
|
||||
if (types?.length) params.set('types', types.join(','));
|
||||
if (limit) params.set('limit', String(limit));
|
||||
return this.fetch<SearchResults>(`/search?${params.toString()}`);
|
||||
}
|
||||
|
||||
// ---- Client Merge / Duplicates ----
|
||||
async getClientDuplicates(clientId: string): Promise<DuplicateClient[]> {
|
||||
return this.fetch<DuplicateClient[]>(`/clients/${clientId}/duplicates`);
|
||||
}
|
||||
|
||||
async mergeClients(primaryId: string, mergeFromId: string): Promise<MergeResult> {
|
||||
return this.fetch<MergeResult>(`/clients/${primaryId}/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mergeFromId }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Data Export ----
|
||||
async getExportSummary(): Promise<ExportSummary> {
|
||||
return this.fetch<ExportSummary>('/export/summary');
|
||||
}
|
||||
|
||||
async exportFullJSON(): Promise<void> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const response = await fetch(`${API_BASE}/export/json`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `network-app-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async exportClientsCsv(): Promise<void> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const response = await fetch(`${API_BASE}/export/clients/csv`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `clients-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async exportInteractionsCsv(): Promise<void> {
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const response = await fetch(`${API_BASE}/export/interactions/csv`, {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `interactions-export-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---- Client Documents ----
|
||||
async getClientDocuments(clientId: string, category?: string): Promise<ClientDocument[]> {
|
||||
const params = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.fetch(`/clients/${clientId}/documents${params}`);
|
||||
}
|
||||
|
||||
async uploadDocument(clientId: string, file: File, opts?: { name?: string; category?: string; notes?: string }): Promise<ClientDocument> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (opts?.name) formData.append('name', opts.name);
|
||||
if (opts?.category) formData.append('category', opts.category);
|
||||
if (opts?.notes) formData.append('notes', opts.notes);
|
||||
|
||||
const token = this.getToken();
|
||||
const headers: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
const response = await fetch(`${API_BASE}/clients/${clientId}/documents`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(error.error || 'Upload failed');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async deleteDocument(documentId: string): Promise<void> {
|
||||
await this.fetch(`/documents/${documentId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
getDocumentDownloadUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/download`;
|
||||
}
|
||||
|
||||
async getDocumentCount(clientId: string): Promise<{ count: number }> {
|
||||
return this.fetch(`/clients/${clientId}/documents/count`);
|
||||
}
|
||||
|
||||
// ---- Client Goals ----
|
||||
async getClientGoals(clientId: string): Promise<ClientGoal[]> {
|
||||
return this.fetch(`/clients/${clientId}/goals`);
|
||||
}
|
||||
|
||||
async createGoal(clientId: string, data: ClientGoalCreate): Promise<ClientGoal> {
|
||||
return this.fetch(`/clients/${clientId}/goals`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateGoal(goalId: string, data: Partial<ClientGoalCreate>): Promise<ClientGoal> {
|
||||
return this.fetch(`/goals/${goalId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteGoal(goalId: string): Promise<void> {
|
||||
await this.fetch(`/goals/${goalId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async getGoalsOverview(): Promise<GoalsOverview> {
|
||||
return this.fetch('/goals/overview');
|
||||
}
|
||||
|
||||
// ---- Referrals ----
|
||||
async getClientReferrals(clientId: string): Promise<Referral[]> {
|
||||
return this.fetch(`/clients/${clientId}/referrals`);
|
||||
}
|
||||
|
||||
async createReferral(clientId: string, data: ReferralCreate): Promise<Referral> {
|
||||
return this.fetch(`/clients/${clientId}/referrals`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateReferral(referralId: string, data: Partial<ReferralCreate>): Promise<Referral> {
|
||||
return this.fetch(`/referrals/${referralId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteReferral(referralId: string): Promise<void> {
|
||||
await this.fetch(`/referrals/${referralId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async getReferralStats(): Promise<ReferralStats> {
|
||||
return this.fetch('/referrals/stats');
|
||||
}
|
||||
|
||||
// ---- Engagement Scoring ----
|
||||
|
||||
async getEngagementScores(): Promise<EngagementResponse> {
|
||||
return this.fetch<EngagementResponse>('/engagement');
|
||||
}
|
||||
|
||||
async getClientEngagement(clientId: string): Promise<ClientEngagement> {
|
||||
return this.fetch<ClientEngagement>(`/clients/${clientId}/engagement`);
|
||||
}
|
||||
|
||||
// ---- Stats Overview ----
|
||||
|
||||
async getStatsOverview(): Promise<StatsOverview> {
|
||||
return this.fetch<StatsOverview>('/stats/overview');
|
||||
}
|
||||
}
|
||||
|
||||
export interface EngagementScore {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
score: number;
|
||||
breakdown: {
|
||||
recency: number;
|
||||
interactions: number;
|
||||
emails: number;
|
||||
events: number;
|
||||
notes: number;
|
||||
};
|
||||
lastContactedAt: string | null;
|
||||
stage: string;
|
||||
trend: 'rising' | 'stable' | 'declining';
|
||||
}
|
||||
|
||||
export interface EngagementResponse {
|
||||
scores: EngagementScore[];
|
||||
summary: {
|
||||
totalClients: number;
|
||||
averageScore: number;
|
||||
distribution: Record<string, number>;
|
||||
topClients: { name: string; score: number }[];
|
||||
needsAttention: { name: string; score: number; lastContactedAt: string | null }[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClientEngagement {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
score: number;
|
||||
label: string;
|
||||
breakdown: {
|
||||
recency: number;
|
||||
interactions: number;
|
||||
emails: number;
|
||||
events: number;
|
||||
notes: number;
|
||||
};
|
||||
rawCounts: Record<string, number>;
|
||||
recentInteractions: { date: string; type: string }[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
clients: {
|
||||
total: number;
|
||||
newThisMonth: number;
|
||||
stageDistribution: Record<string, number>;
|
||||
};
|
||||
activity: {
|
||||
interactions30d: number;
|
||||
interactions7d: number;
|
||||
emailsSent30d: number;
|
||||
interactionsByType: Record<string, number>;
|
||||
};
|
||||
upcoming: {
|
||||
events: number;
|
||||
unreadNotifications: number;
|
||||
};
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
type: string;
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
clientId?: string;
|
||||
clientName?: string;
|
||||
matchField: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
results: SearchResult[];
|
||||
query: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DuplicateClient {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
company: string | null;
|
||||
stage: string;
|
||||
duplicateScore: number;
|
||||
matchReasons: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
success: boolean;
|
||||
client: unknown;
|
||||
merged: {
|
||||
fromId: string;
|
||||
fromName: string;
|
||||
fieldsUpdated: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExportSummary {
|
||||
clients: number;
|
||||
emails: number;
|
||||
events: number;
|
||||
interactions: number;
|
||||
notes: number;
|
||||
templates: number;
|
||||
segments: number;
|
||||
exportFormats: string[];
|
||||
}
|
||||
|
||||
export interface ClientDocument {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
category: string;
|
||||
path: string;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ClientGoal {
|
||||
id: string;
|
||||
clientId: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string;
|
||||
targetAmount: string | null;
|
||||
currentAmount: string | null;
|
||||
targetDate: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ClientGoalCreate {
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
targetAmount?: string;
|
||||
currentAmount?: string;
|
||||
targetDate?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
}
|
||||
|
||||
export interface GoalsOverview {
|
||||
total: number;
|
||||
byStatus: Record<string, number>;
|
||||
atRiskGoals: Array<ClientGoal & { clientFirstName: string; clientLastName: string }>;
|
||||
highPriorityGoals: Array<ClientGoal & { clientFirstName: string; clientLastName: string }>;
|
||||
}
|
||||
|
||||
export interface Referral {
|
||||
id: string;
|
||||
referrerId: string;
|
||||
referredId: string;
|
||||
type: string;
|
||||
notes: string | null;
|
||||
status: string;
|
||||
value: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
referrer: { id: string; firstName: string; lastName: string };
|
||||
referred: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
export interface ReferralCreate {
|
||||
referredId: string;
|
||||
type?: string;
|
||||
notes?: string;
|
||||
status?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface ReferralStats {
|
||||
total: number;
|
||||
converted: number;
|
||||
conversionRate: number;
|
||||
totalValue: number;
|
||||
convertedValue: number;
|
||||
byStatus: Record<string, number>;
|
||||
topReferrers: Array<{ id: string; name: string; count: number; convertedCount: number }>;
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
36
src/lib/toast.ts
Normal file
36
src/lib/toast.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// Global toast state
|
||||
export const toastListeners: ((toasts: ToastItem[]) => void)[] = [];
|
||||
export const toasts: ToastItem[] = [];
|
||||
|
||||
export 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.push({ id, type, message, duration });
|
||||
notifyListeners();
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
const idx = toasts.findIndex(t => t.id === id);
|
||||
if (idx >= 0) toasts.splice(idx, 1);
|
||||
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);
|
||||
@@ -7,7 +7,8 @@ describe('cn', () => {
|
||||
});
|
||||
|
||||
it('handles conditional classes', () => {
|
||||
expect(cn('base', false && 'hidden', 'visible')).toBe('base visible');
|
||||
const isHidden = false;
|
||||
expect(cn('base', isHidden && 'hidden', 'visible')).toBe('base visible');
|
||||
});
|
||||
|
||||
it('merges tailwind conflicts', () => {
|
||||
|
||||
253
src/pages/AuditLogPage.tsx
Normal file
253
src/pages/AuditLogPage.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AuditLog, User } from '@/types';
|
||||
import {
|
||||
Shield, Search, ChevronDown, ChevronRight, ChevronLeft,
|
||||
Filter, User as UserIcon, Activity,
|
||||
} from 'lucide-react';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,19 +2,29 @@ import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Event, Email, ActivityItem } from '@/types';
|
||||
import type { Event, Email, ActivityItem, ClientCreate } from '@/types';
|
||||
import {
|
||||
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
||||
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
||||
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw, Pin,
|
||||
CheckCircle2, Sparkles, Clock, Activity, FileText, UserPlus, RefreshCw,
|
||||
Paperclip, Target,
|
||||
} from 'lucide-react';
|
||||
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
||||
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 Modal from '@/components/Modal';
|
||||
import ClientForm from '@/components/ClientForm';
|
||||
import EmailComposeModal from '@/components/EmailComposeModal';
|
||||
import ClientNotes from '@/components/ClientNotes';
|
||||
import LogInteractionModal from '@/components/LogInteractionModal';
|
||||
import MeetingPrepModal from '@/components/MeetingPrepModal';
|
||||
import EngagementBadge from '@/components/EngagementBadge';
|
||||
import DuplicatesModal from '@/components/DuplicatesModal';
|
||||
import ClientDocuments from '@/components/ClientDocuments';
|
||||
import ClientGoals from '@/components/ClientGoals';
|
||||
import ClientReferrals from '@/components/ClientReferrals';
|
||||
import type { Interaction } from '@/types';
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -23,9 +33,13 @@ export default function ClientDetailPage() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'activity' | 'events' | 'emails'>('info');
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails' | 'documents' | 'goals' | 'referrals'>('info');
|
||||
const [, setInteractions] = useState<Interaction[]>([]);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [showLogInteraction, setShowLogInteraction] = useState(false);
|
||||
const [showMeetingPrep, setShowMeetingPrep] = useState(false);
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { togglePin, isPinned } = usePinnedClients();
|
||||
|
||||
@@ -35,6 +49,7 @@ export default function ClientDetailPage() {
|
||||
api.getEvents({ clientId: id }).then(setEvents).catch(() => {});
|
||||
api.getEmails({ clientId: id }).then(setEmails).catch(() => {});
|
||||
api.getClientActivity(id).then(setActivities).catch(() => {});
|
||||
api.getClientInteractions(id).then(setInteractions).catch(() => {});
|
||||
}
|
||||
}, [id, fetchClient]);
|
||||
|
||||
@@ -57,13 +72,17 @@ export default function ClientDetailPage() {
|
||||
await markContacted(client.id);
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: any) => {
|
||||
const handleUpdate = async (data: ClientCreate) => {
|
||||
await updateClient(client.id, data);
|
||||
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: 'notes', label: 'Notes', icon: FileText },
|
||||
{ key: 'documents', label: 'Documents', icon: Paperclip },
|
||||
{ key: 'goals', label: 'Goals', icon: Target },
|
||||
{ key: 'referrals', label: 'Referrals', icon: UserPlus },
|
||||
{ key: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
|
||||
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
||||
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
||||
@@ -82,31 +101,52 @@ export default function ClientDetailPage() {
|
||||
{getInitials(client.firstName, client.lastName)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{client.firstName} {client.lastName}
|
||||
</h1>
|
||||
<EngagementBadge clientId={client.id} />
|
||||
</div>
|
||||
{client.company && (
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{client.role ? `${client.role} at ` : ''}{client.company}
|
||||
</p>
|
||||
)}
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)}
|
||||
</div>
|
||||
<StageBadge stage={client.stage} onClick={async () => {
|
||||
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
|
||||
const currentIdx = stages.indexOf(client.stage || 'lead');
|
||||
const nextStage = stages[(currentIdx + 1) % stages.length];
|
||||
await updateClient(client.id, { stage: nextStage as ClientCreate['stage'] });
|
||||
}} />
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={handleMarkContacted} className="flex items-center gap-2 px-3 py-2 bg-emerald-50 text-emerald-700 rounded-lg text-sm font-medium hover:bg-emerald-100 transition-colors">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Contacted</span>
|
||||
</button>
|
||||
<button onClick={() => setShowCompose(true)} className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors">
|
||||
<button onClick={() => setShowLogInteraction(true)} className="flex items-center gap-2 px-3 py-2 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 rounded-lg text-sm font-medium hover:bg-indigo-100 dark:hover:bg-indigo-900/50 transition-colors">
|
||||
<Phone className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Log Interaction</span>
|
||||
</button>
|
||||
<button onClick={() => 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" />
|
||||
<span className="hidden sm:inline">Generate Email</span>
|
||||
</button>
|
||||
<button onClick={() => setShowDuplicates(true)} className="flex items-center gap-2 px-3 py-2 bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg text-sm font-medium hover:bg-amber-100 dark:hover:bg-amber-900/50 transition-colors">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Duplicates</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => togglePin(client.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${isPinned(client.id) ? 'text-amber-500 bg-amber-50 dark:bg-amber-900/30' : 'text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-amber-500'}`}
|
||||
@@ -240,6 +280,22 @@ export default function ClientDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notes' && (
|
||||
<ClientNotes clientId={client.id} />
|
||||
)}
|
||||
|
||||
{activeTab === 'documents' && (
|
||||
<ClientDocuments clientId={client.id} />
|
||||
)}
|
||||
|
||||
{activeTab === 'goals' && (
|
||||
<ClientGoals clientId={client.id} />
|
||||
)}
|
||||
|
||||
{activeTab === 'referrals' && (
|
||||
<ClientReferrals clientId={client.id} clientName={`${client.firstName} ${client.lastName}`} />
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||
{activities.length === 0 ? (
|
||||
@@ -248,12 +304,13 @@ export default function ClientDetailPage() {
|
||||
<div className="relative">
|
||||
{activities.map((item, index) => {
|
||||
const iconMap: Record<string, { icon: typeof Mail; color: string; bg: string }> = {
|
||||
email_sent: { icon: Send, color: 'text-emerald-600', bg: 'bg-emerald-100' },
|
||||
email_drafted: { icon: FileText, color: 'text-amber-600', bg: 'bg-amber-100' },
|
||||
event_created: { icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-100' },
|
||||
client_contacted: { icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-100' },
|
||||
client_created: { icon: UserPlus, color: 'text-purple-600', bg: 'bg-purple-100' },
|
||||
client_updated: { icon: RefreshCw, color: 'text-slate-600', bg: 'bg-slate-100' },
|
||||
email_sent: { icon: Send, color: 'text-emerald-600', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
|
||||
email_drafted: { icon: FileText, color: 'text-amber-600', bg: 'bg-amber-100 dark:bg-amber-900/30' },
|
||||
event_created: { icon: Calendar, color: 'text-blue-600', bg: 'bg-blue-100 dark:bg-blue-900/30' },
|
||||
client_contacted: { icon: CheckCircle2, color: 'text-emerald-600', bg: 'bg-emerald-100 dark:bg-emerald-900/30' },
|
||||
client_created: { icon: UserPlus, color: 'text-purple-600', bg: 'bg-purple-100 dark:bg-purple-900/30' },
|
||||
client_updated: { icon: RefreshCw, color: 'text-slate-600', bg: 'bg-slate-100 dark:bg-slate-700' },
|
||||
interaction: { icon: Phone, color: 'text-indigo-600 dark:text-indigo-400', bg: 'bg-indigo-100 dark:bg-indigo-900/30' },
|
||||
};
|
||||
const { icon: Icon, color, bg } = iconMap[item.type] || iconMap.client_updated;
|
||||
|
||||
@@ -332,6 +389,41 @@ export default function ClientDetailPage() {
|
||||
clientName={`${client.firstName} ${client.lastName}`}
|
||||
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
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Duplicates Modal */}
|
||||
<DuplicatesModal
|
||||
isOpen={showDuplicates}
|
||||
onClose={() => setShowDuplicates(false)}
|
||||
clientId={client.id}
|
||||
clientName={`${client.firstName} ${client.lastName}`}
|
||||
onMerged={() => {
|
||||
if (id) fetchClient(id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Search, Plus, Users, X, Upload } from 'lucide-react';
|
||||
import type { ClientCreate } from '@/types';
|
||||
import { Search, Plus, Users, X, Upload, LayoutGrid, Kanban, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||
import Badge from '@/components/Badge';
|
||||
import Badge, { StageBadge } from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import ClientForm from '@/components/ClientForm';
|
||||
import CSVImportModal from '@/components/CSVImportModal';
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export default function ClientsPage() {
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
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(() => {
|
||||
fetchClients();
|
||||
@@ -46,6 +78,43 @@ export default function ClientsPage() {
|
||||
return result;
|
||||
}, [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
|
||||
const allTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
@@ -53,7 +122,7 @@ export default function ClientsPage() {
|
||||
return Array.from(tags).sort();
|
||||
}, [clients]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
const handleCreate = async (data: ClientCreate) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await createClient(data);
|
||||
@@ -71,10 +140,34 @@ export default function ClientsPage() {
|
||||
<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">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>
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
@@ -131,7 +224,36 @@ export default function ClientsPage() {
|
||||
)}
|
||||
</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 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
@@ -139,9 +261,71 @@ export default function ClientsPage() {
|
||||
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}
|
||||
/>
|
||||
) : 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">
|
||||
{filteredClients.map((client) => (
|
||||
{paginatedClients.map((client) => (
|
||||
<Link
|
||||
key={client.id}
|
||||
to={`/clients/${client.id}`}
|
||||
@@ -161,18 +345,17 @@ export default function ClientsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{client.tags.slice(0, 3).map((tag) => (
|
||||
<StageBadge stage={client.stage} />
|
||||
{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">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{client.tags.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{client.tags.length - 3}</span>
|
||||
{client.tags && client.tags.length > 2 && (
|
||||
<span className="text-xs text-slate-400">+{client.tags.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)}
|
||||
@@ -182,6 +365,46 @@ export default function ClientsPage() {
|
||||
</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 */}
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg">
|
||||
<ClientForm onSubmit={handleCreate} loading={creating} />
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Client, Event, Email, InsightsData } from '@/types';
|
||||
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded, Star } from 'lucide-react';
|
||||
import type { Interaction } from '@/types';
|
||||
import type { GoalsOverview, ReferralStats } from '@/lib/api';
|
||||
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, PhoneForwarded, Star, Phone, FileText, MoreHorizontal, Target, UserPlus, TrendingUp } from 'lucide-react';
|
||||
import { formatDate, getDaysUntil, getInitials } from '@/lib/utils';
|
||||
import { EventTypeBadge } from '@/components/Badge';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
@@ -13,6 +15,9 @@ export default function DashboardPage() {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [insights, setInsights] = useState<InsightsData | null>(null);
|
||||
const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]);
|
||||
const [goalsOverview, setGoalsOverview] = useState<GoalsOverview | null>(null);
|
||||
const [referralStats, setReferralStats] = useState<ReferralStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { pinnedIds, togglePin, isPinned } = usePinnedClients();
|
||||
|
||||
@@ -22,11 +27,17 @@ export default function DashboardPage() {
|
||||
api.getEvents({ upcoming: 7 }).catch(() => []),
|
||||
api.getEmails({ status: 'draft' }).catch(() => []),
|
||||
api.getInsights().catch(() => null),
|
||||
]).then(([c, e, em, ins]) => {
|
||||
api.getRecentInteractions(5).catch(() => []),
|
||||
api.getGoalsOverview().catch(() => null),
|
||||
api.getReferralStats().catch(() => null),
|
||||
]).then(([c, e, em, ins, ri, go, rs]) => {
|
||||
setClients(c);
|
||||
setEvents(e);
|
||||
setEmails(em);
|
||||
setInsights(ins as InsightsData | null);
|
||||
setRecentInteractions(ri as Interaction[]);
|
||||
setGoalsOverview(go as GoalsOverview | null);
|
||||
setReferralStats(rs as ReferralStats | null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
@@ -248,6 +259,153 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Interactions */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-slate-100 dark:border-slate-700">
|
||||
<Phone className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Recent Interactions</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{recentInteractions.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400 dark:text-slate-500">No interactions logged yet</p>
|
||||
) : (
|
||||
recentInteractions.map((interaction) => {
|
||||
const typeIcons: Record<string, typeof Phone> = {
|
||||
call: Phone, meeting: Users, email: Mail, note: FileText, other: MoreHorizontal,
|
||||
};
|
||||
const typeColors: Record<string, string> = {
|
||||
call: 'text-green-600 dark:text-green-400', meeting: 'text-blue-600 dark:text-blue-400',
|
||||
email: 'text-purple-600 dark:text-purple-400', note: 'text-amber-600 dark:text-amber-400', other: 'text-slate-500',
|
||||
};
|
||||
const Icon = typeIcons[interaction.type] || MoreHorizontal;
|
||||
return (
|
||||
<Link
|
||||
key={interaction.id}
|
||||
to={`/clients/${interaction.clientId}`}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${typeColors[interaction.type] || 'text-slate-400'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{interaction.title}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||
{interaction.client ? `${interaction.client.firstName} ${interaction.client.lastName}` : ''}
|
||||
{interaction.duration ? ` · ${interaction.duration}min` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500 whitespace-nowrap">
|
||||
{formatDate(interaction.contactedAt)}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Goals & Referrals Widgets */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Goals Summary */}
|
||||
{goalsOverview && goalsOverview.total > 0 && (
|
||||
<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">
|
||||
<Target className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Goals Overview</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">{goalsOverview.byStatus['on-track'] || 0}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">On Track</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-amber-600 dark:text-amber-400">{goalsOverview.byStatus['at-risk'] || 0}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">At Risk</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-red-600 dark:text-red-400">{goalsOverview.byStatus['behind'] || 0}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Behind</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-blue-600 dark:text-blue-400">{goalsOverview.byStatus['completed'] || 0}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Completed</p>
|
||||
</div>
|
||||
</div>
|
||||
{goalsOverview.atRiskGoals.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> Needs Attention
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{goalsOverview.atRiskGoals.slice(0, 3).map(g => (
|
||||
<Link key={g.id} to={`/clients/${g.clientId}`} className="flex items-center gap-2 group">
|
||||
<div className="w-6 h-6 bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0">!</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">
|
||||
{g.title}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{g.clientFirstName} {g.clientLastName} · {g.status}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Referral Leaderboard */}
|
||||
{referralStats && referralStats.total > 0 && (
|
||||
<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">
|
||||
<UserPlus className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Referral Stats</h2>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">{referralStats.total}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Total</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">{referralStats.conversionRate}%</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Conversion</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-bold text-indigo-600 dark:text-indigo-400">
|
||||
{referralStats.convertedValue > 0 ? `$${Math.round(referralStats.convertedValue / 1000)}k` : '$0'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Value</p>
|
||||
</div>
|
||||
</div>
|
||||
{referralStats.topReferrers.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase text-indigo-600 dark:text-indigo-400 mb-2 flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" /> Top Referrers
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{referralStats.topReferrers.slice(0, 5).map((r, i) => (
|
||||
<Link key={r.id} to={`/clients/${r.id}`} className="flex items-center gap-2 group">
|
||||
<span className="w-5 text-xs font-bold text-slate-400 text-right">#{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate group-hover:text-blue-600 dark:group-hover:text-blue-400">{r.name}</p>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">{r.count} referral{r.count !== 1 ? 's' : ''}</span>
|
||||
{r.convertedCount > 0 && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">({r.convertedCount} converted)</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{/* Recent Clients */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 dark:border-slate-700">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEmailsStore } from '@/stores/emails';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Mail, Send, Trash2, Edit3, Sparkles, Gift } from 'lucide-react';
|
||||
import { Mail, Send, Trash2, Edit3, Sparkles, Gift, Users } from 'lucide-react';
|
||||
import { cn, formatDate } from '@/lib/utils';
|
||||
import { EmailStatusBadge } from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import BulkEmailModal from '@/components/BulkEmailModal';
|
||||
|
||||
export default function EmailsPage() {
|
||||
const { emails, isLoading, isGenerating, statusFilter, setStatusFilter, fetchEmails, generateEmail, generateBirthdayEmail, updateEmail, sendEmail, deleteEmail } = useEmailsStore();
|
||||
@@ -17,6 +18,7 @@ export default function EmailsPage() {
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [composeForm, setComposeForm] = useState({ clientId: '', purpose: '', provider: 'anthropic' as 'anthropic' | 'openai' });
|
||||
const [showBulk, setShowBulk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmails();
|
||||
@@ -30,7 +32,7 @@ export default function EmailsPage() {
|
||||
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
||||
setShowCompose(false);
|
||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||
} catch {}
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const handleGenerateBirthday = async () => {
|
||||
@@ -38,7 +40,7 @@ export default function EmailsPage() {
|
||||
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
||||
setShowCompose(false);
|
||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||
} catch {}
|
||||
} catch { /* silently handled */ }
|
||||
};
|
||||
|
||||
const startEdit = (email: typeof emails[0]) => {
|
||||
@@ -69,6 +71,14 @@ export default function EmailsPage() {
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Emails</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">AI-generated emails for your network</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowBulk(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Users className="w-4 h-4" />
|
||||
Bulk Compose
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCompose(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
@@ -77,6 +87,7 @@ export default function EmailsPage() {
|
||||
Compose
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
@@ -174,6 +185,14 @@ export default function EmailsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Email Modal */}
|
||||
<BulkEmailModal
|
||||
isOpen={showBulk}
|
||||
onClose={() => setShowBulk(false)}
|
||||
clients={clients}
|
||||
onComplete={() => fetchEmails()}
|
||||
/>
|
||||
|
||||
{/* Compose Modal */}
|
||||
<Modal isOpen={showCompose} onClose={() => setShowCompose(false)} title="Generate Email" size="md">
|
||||
<div className="space-y-4">
|
||||
@@ -204,7 +223,7 @@ export default function EmailsPage() {
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Provider</label>
|
||||
<select
|
||||
value={composeForm.provider}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, provider: e.target.value as any })}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, provider: e.target.value as 'anthropic' | 'openai' })}
|
||||
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"
|
||||
>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
|
||||
197
src/pages/EngagementPage.tsx
Normal file
197
src/pages/EngagementPage.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, type EngagementResponse } from '@/lib/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const scoreColor = (score: number) => {
|
||||
if (score >= 80) return 'text-emerald-400';
|
||||
if (score >= 60) return 'text-blue-400';
|
||||
if (score >= 40) return 'text-yellow-400';
|
||||
if (score >= 20) return 'text-orange-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
const scoreBg = (score: number) => {
|
||||
if (score >= 80) return 'bg-emerald-500/20 border-emerald-500/40';
|
||||
if (score >= 60) return 'bg-blue-500/20 border-blue-500/40';
|
||||
if (score >= 40) return 'bg-yellow-500/20 border-yellow-500/40';
|
||||
if (score >= 20) return 'bg-orange-500/20 border-orange-500/40';
|
||||
return 'bg-red-500/20 border-red-500/40';
|
||||
};
|
||||
|
||||
const trendIcon = (trend: string) => {
|
||||
if (trend === 'rising') return '📈';
|
||||
if (trend === 'declining') return '📉';
|
||||
return '➡️';
|
||||
};
|
||||
|
||||
const labelText = (score: number) => {
|
||||
if (score >= 80) return 'Highly Engaged';
|
||||
if (score >= 60) return 'Engaged';
|
||||
if (score >= 40) return 'Warm';
|
||||
if (score >= 20) return 'Cooling';
|
||||
return 'Cold';
|
||||
};
|
||||
|
||||
function ScoreBar({ label, value, max, color }: { label: string; value: number; max: number; color: string }) {
|
||||
const pct = Math.round((value / max) * 100);
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 text-gray-400 dark:text-gray-500">{label}</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-8 text-right text-gray-500">{value}/{max}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EngagementPage() {
|
||||
const [data, setData] = useState<EngagementResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'score' | 'name' | 'trend'>('score');
|
||||
|
||||
useEffect(() => {
|
||||
api.getEngagementScores()
|
||||
.then(setData)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="p-6 text-gray-400">Loading engagement scores...</div>;
|
||||
if (!data) return <div className="p-6 text-red-400">Failed to load engagement data</div>;
|
||||
|
||||
const { scores, summary } = data;
|
||||
|
||||
const filtered = filter === 'all'
|
||||
? scores
|
||||
: scores.filter(s => {
|
||||
if (filter === 'highly_engaged') return s.score >= 80;
|
||||
if (filter === 'engaged') return s.score >= 60 && s.score < 80;
|
||||
if (filter === 'warm') return s.score >= 40 && s.score < 60;
|
||||
if (filter === 'cooling') return s.score >= 20 && s.score < 40;
|
||||
if (filter === 'cold') return s.score < 20;
|
||||
return true;
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sortBy === 'score') return b.score - a.score;
|
||||
if (sortBy === 'name') return a.clientName.localeCompare(b.clientName);
|
||||
// trend: rising first, then stable, then declining
|
||||
const trendOrder = { rising: 0, stable: 1, declining: 2 };
|
||||
return (trendOrder[a.trend] || 1) - (trendOrder[b.trend] || 1);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Engagement Scores</h1>
|
||||
<span className="text-sm text-gray-500">
|
||||
Avg: <span className={`font-bold ${scoreColor(summary.averageScore)}`}>{summary.averageScore}</span> / 100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Distribution summary */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{(['highly_engaged', 'engaged', 'warm', 'cooling', 'cold'] as const).map(level => {
|
||||
const labels: Record<string, string> = {
|
||||
highly_engaged: '🔥 Highly Engaged',
|
||||
engaged: '💚 Engaged',
|
||||
warm: '🟡 Warm',
|
||||
cooling: '🟠 Cooling',
|
||||
cold: '❄️ Cold',
|
||||
};
|
||||
const count = summary.distribution[level] || 0;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => setFilter(filter === level ? 'all' : level)}
|
||||
className={`p-3 rounded-lg border text-center transition-all ${
|
||||
filter === level
|
||||
? 'bg-indigo-500/20 border-indigo-500'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{count}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{labels[level]}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Sort controls */}
|
||||
<div className="flex gap-2 items-center text-sm">
|
||||
<span className="text-gray-500">Sort by:</span>
|
||||
{(['score', 'name', 'trend'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSortBy(s)}
|
||||
className={`px-3 py-1 rounded-full ${
|
||||
sortBy === s
|
||||
? 'bg-indigo-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{filter !== 'all' && (
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className="ml-auto text-gray-400 hover:text-gray-600 text-xs"
|
||||
>
|
||||
Clear filter ✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client engagement cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{sorted.map(client => (
|
||||
<Link
|
||||
key={client.clientId}
|
||||
to={`/clients/${client.clientId}`}
|
||||
className={`block p-4 rounded-xl border transition-all hover:shadow-md ${scoreBg(client.score)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{client.clientName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 capitalize">{client.stage}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-2xl font-bold ${scoreColor(client.score)}`}>
|
||||
{client.score}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{trendIcon(client.trend)} {labelText(client.score)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<ScoreBar label="Recency" value={client.breakdown.recency} max={40} color="bg-emerald-500" />
|
||||
<ScoreBar label="Interactions" value={client.breakdown.interactions} max={25} color="bg-blue-500" />
|
||||
<ScoreBar label="Emails" value={client.breakdown.emails} max={15} color="bg-purple-500" />
|
||||
<ScoreBar label="Events" value={client.breakdown.events} max={10} color="bg-amber-500" />
|
||||
<ScoreBar label="Notes" value={client.breakdown.notes} max={10} color="bg-pink-500" />
|
||||
</div>
|
||||
|
||||
{client.lastContactedAt && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Last contact: {new Date(client.lastContactedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No clients match this filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useEventsStore } from '@/stores/events';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star, ChevronLeft, ChevronRight, List, Grid3X3 } from 'lucide-react';
|
||||
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
||||
import Badge, { EventTypeBadge } from '@/components/Badge';
|
||||
import { EventTypeBadge } from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
@@ -305,7 +305,6 @@ export default function EventsPage() {
|
||||
{selectedDayEvents && (
|
||||
<div className="space-y-3">
|
||||
{selectedDayEvents.events.map((event) => {
|
||||
const days = getDaysUntil(event.date);
|
||||
return (
|
||||
<div key={event.id} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
||||
<div className={cn(
|
||||
@@ -460,7 +459,7 @@ function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as any })} className={inputClass}>
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as EventCreate['type'] })} className={inputClass}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="birthday">Birthday</option>
|
||||
<option value="anniversary">Anniversary</option>
|
||||
|
||||
180
src/pages/ExportPage.tsx
Normal file
180
src/pages/ExportPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, type ExportSummary } from '@/lib/api';
|
||||
import { Download, FileJson, FileSpreadsheet, Database, Loader2, CheckCircle, Users, Mail, Calendar, Phone, FileText, Bookmark, Filter } from 'lucide-react';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
export default function ExportPage() {
|
||||
const [summary, setSummary] = useState<ExportSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState<string | null>(null);
|
||||
const [exported, setExported] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getExportSummary().then(s => {
|
||||
setSummary(s);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleExport = async (type: string) => {
|
||||
setExporting(type);
|
||||
try {
|
||||
switch (type) {
|
||||
case 'json':
|
||||
await api.exportFullJSON();
|
||||
break;
|
||||
case 'clients-csv':
|
||||
await api.exportClientsCsv();
|
||||
break;
|
||||
case 'interactions-csv':
|
||||
await api.exportInteractionsCsv();
|
||||
break;
|
||||
}
|
||||
setExported(type);
|
||||
setTimeout(() => setExported(null), 3000);
|
||||
} catch (e) {
|
||||
console.error('Export failed:', e);
|
||||
} finally {
|
||||
setExporting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
const stats = [
|
||||
{ label: 'Clients', count: summary?.clients || 0, icon: Users, color: 'text-blue-500' },
|
||||
{ label: 'Emails', count: summary?.emails || 0, icon: Mail, color: 'text-purple-500' },
|
||||
{ label: 'Events', count: summary?.events || 0, icon: Calendar, color: 'text-green-500' },
|
||||
{ label: 'Interactions', count: summary?.interactions || 0, icon: Phone, color: 'text-orange-500' },
|
||||
{ label: 'Notes', count: summary?.notes || 0, icon: FileText, color: 'text-amber-500' },
|
||||
{ label: 'Templates', count: summary?.templates || 0, icon: Bookmark, color: 'text-indigo-500' },
|
||||
{ label: 'Segments', count: summary?.segments || 0, icon: Filter, color: 'text-pink-500' },
|
||||
];
|
||||
|
||||
const totalRecords = stats.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
const exports = [
|
||||
{
|
||||
id: 'json',
|
||||
title: 'Full JSON Export',
|
||||
description: 'Complete data backup including all clients, emails, events, interactions, notes, templates, and segments.',
|
||||
icon: FileJson,
|
||||
color: 'text-emerald-500',
|
||||
bgColor: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
records: totalRecords,
|
||||
format: 'JSON',
|
||||
},
|
||||
{
|
||||
id: 'clients-csv',
|
||||
title: 'Clients CSV',
|
||||
description: 'Export all clients with contact info, company, stage, tags, and dates in spreadsheet format.',
|
||||
icon: FileSpreadsheet,
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
records: summary?.clients || 0,
|
||||
format: 'CSV',
|
||||
},
|
||||
{
|
||||
id: 'interactions-csv',
|
||||
title: 'Interactions CSV',
|
||||
description: 'Export all logged interactions (calls, meetings, emails, notes) with client details.',
|
||||
icon: FileSpreadsheet,
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-900/30',
|
||||
records: summary?.interactions || 0,
|
||||
format: 'CSV',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Database className="w-6 h-6 text-indigo-500" />
|
||||
Data Export & Backup
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Download your data for backup, compliance, or migration purposes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Data Summary */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
||||
{stats.map(s => {
|
||||
const Icon = s.icon;
|
||||
return (
|
||||
<div key={s.label} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
|
||||
<Icon className={`w-5 h-5 ${s.color} mx-auto mb-1`} />
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{s.count}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{s.label}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Export Options */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Export Formats</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{exports.map(exp => {
|
||||
const Icon = exp.icon;
|
||||
const isExporting = exporting === exp.id;
|
||||
const isExported = exported === exp.id;
|
||||
return (
|
||||
<div
|
||||
key={exp.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5 flex flex-col"
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className={`p-2.5 rounded-lg ${exp.bgColor}`}>
|
||||
<Icon className={`w-5 h-5 ${exp.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">{exp.title}</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
||||
{exp.format}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 flex-1">{exp.description}</p>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{exp.records.toLocaleString()} records
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleExport(exp.id)}
|
||||
disabled={isExporting || exp.records === 0}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isExported
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<><Loader2 className="w-4 h-4 animate-spin" /> Exporting...</>
|
||||
) : isExported ? (
|
||||
<><CheckCircle className="w-4 h-4" /> Downloaded</>
|
||||
) : (
|
||||
<><Download className="w-4 h-4" /> Download</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-amber-800 dark:text-amber-300 mb-1">About Data Exports</h3>
|
||||
<ul className="text-sm text-amber-700 dark:text-amber-400 space-y-1">
|
||||
<li>• All exports include only your data (multi-tenant safe)</li>
|
||||
<li>• JSON exports contain the complete dataset for full backup/restore</li>
|
||||
<li>• CSV exports are compatible with Excel, Google Sheets, and other CRMs</li>
|
||||
<li>• All exports are logged in the audit trail for compliance</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,8 +18,8 @@ export default function ForgotPasswordPage() {
|
||||
try {
|
||||
await api.requestPasswordReset(email);
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to request password reset');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to request password reset');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ export default function InvitePage() {
|
||||
const data = await api.validateInvite(token);
|
||||
setInvite(data);
|
||||
setName(data.name);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid or expired invite');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Invalid or expired invite');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,8 +61,8 @@ export default function InvitePage() {
|
||||
}
|
||||
await checkSession();
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Failed to create account');
|
||||
} catch (err: unknown) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Failed to create account');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export default function LoginPage() {
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import {
|
||||
BarChart3, Users, Mail, Calendar, TrendingUp, Download,
|
||||
Users, Mail, Calendar, TrendingUp, Download,
|
||||
Activity, Tag, Building2, AlertTriangle, Flame, Snowflake, ThermometerSun,
|
||||
} from 'lucide-react';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
@@ -162,6 +162,8 @@ function AtRiskList({ title, clients: clientList }: {
|
||||
title: string;
|
||||
clients: { id: string; name: string; company: string | null; lastContacted: string | null }[];
|
||||
}) {
|
||||
// eslint-disable-next-line react-hooks/purity -- Date.now() is needed for relative time display
|
||||
const now = Date.now();
|
||||
if (clientList.length === 0) return null;
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
@@ -182,7 +184,7 @@ function AtRiskList({ title, clients: clientList }: {
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{c.lastContacted
|
||||
? `${Math.floor((Date.now() - new Date(c.lastContacted).getTime()) / (1000 * 60 * 60 * 24))}d ago`
|
||||
? `${Math.floor((now - new Date(c.lastContacted).getTime()) / (1000 * 60 * 60 * 24))}d ago`
|
||||
: 'Never'}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -24,8 +24,8 @@ export default function ResetPasswordPage() {
|
||||
try {
|
||||
const data = await api.validateResetToken(token);
|
||||
setEmail(data.email || '');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Invalid or expired reset link');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Invalid or expired reset link');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -49,8 +49,8 @@ export default function ResetPasswordPage() {
|
||||
try {
|
||||
await api.resetPassword(token!, password);
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Failed to reset password');
|
||||
} catch (err: unknown) {
|
||||
setSubmitError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
210
src/pages/SearchPage.tsx
Normal file
210
src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, type SearchResult } from '@/lib/api';
|
||||
import { Search, Users, Mail, Calendar, Phone, StickyNote, X, Loader2 } from 'lucide-react';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
|
||||
const TYPE_CONFIG: Record<string, { icon: typeof Users; label: string; color: string; bgColor: string }> = {
|
||||
client: { icon: Users, label: 'Client', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-100 dark:bg-blue-900/30' },
|
||||
email: { icon: Mail, label: 'Email', color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-100 dark:bg-purple-900/30' },
|
||||
event: { icon: Calendar, label: 'Event', color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-100 dark:bg-green-900/30' },
|
||||
interaction: { icon: Phone, label: 'Interaction', color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-100 dark:bg-orange-900/30' },
|
||||
note: { icon: StickyNote, label: 'Note', color: 'text-amber-600 dark:text-amber-400', bgColor: 'bg-amber-100 dark:bg-amber-900/30' },
|
||||
};
|
||||
|
||||
const ALL_TYPES = ['clients', 'emails', 'events', 'interactions', 'notes'];
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTypes, setActiveTypes] = useState<string[]>(ALL_TYPES);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<number>(0);
|
||||
|
||||
const doSearch = useCallback(async (q: string, types: string[]) => {
|
||||
if (q.length < 2) {
|
||||
setResults([]);
|
||||
setTotal(0);
|
||||
setHasSearched(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.globalSearch(q, types, 50);
|
||||
setResults(data.results);
|
||||
setTotal(data.total);
|
||||
setHasSearched(true);
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
doSearch(query, activeTypes);
|
||||
}, 300);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [query, activeTypes, doSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const toggleType = (type: string) => {
|
||||
setActiveTypes(prev => {
|
||||
if (prev.includes(type)) {
|
||||
const next = prev.filter(t => t !== type);
|
||||
return next.length === 0 ? ALL_TYPES : next;
|
||||
}
|
||||
return [...prev, type];
|
||||
});
|
||||
};
|
||||
|
||||
const getLink = (result: SearchResult): string => {
|
||||
switch (result.type) {
|
||||
case 'client': return `/clients/${result.id}`;
|
||||
case 'email': return result.clientId ? `/clients/${result.clientId}` : '/emails';
|
||||
case 'event': return result.clientId ? `/clients/${result.clientId}` : '/events';
|
||||
case 'interaction': return result.clientId ? `/clients/${result.clientId}` : '/';
|
||||
case 'note': return result.clientId ? `/clients/${result.clientId}` : '/';
|
||||
default: return '/';
|
||||
}
|
||||
};
|
||||
|
||||
// Group results by type
|
||||
const grouped = results.reduce<Record<string, SearchResult[]>>((acc, r) => {
|
||||
if (!acc[r.type]) acc[r.type] = [];
|
||||
acc[r.type].push(r);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Search</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Search across clients, emails, events, interactions, and notes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search everything..."
|
||||
className="w-full pl-12 pr-12 py-3 text-lg border border-gray-300 dark:border-gray-600 rounded-xl bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{loading && (
|
||||
<Loader2 className="absolute right-12 top-1/2 -translate-y-1/2 w-5 h-5 text-indigo-500 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ALL_TYPES.map(type => {
|
||||
const config = TYPE_CONFIG[type.replace(/s$/, '')] || TYPE_CONFIG.client;
|
||||
const isActive = activeTypes.includes(type);
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleType(type)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? `${config.bgColor} ${config.color} ring-1 ring-current`
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
{hasSearched && grouped[type.replace(/s$/, '')]?.length ? (
|
||||
<span className="ml-1 text-xs opacity-70">({grouped[type.replace(/s$/, '')].length})</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{hasSearched && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{total} result{total !== 1 ? 's' : ''} for "{query}"
|
||||
</p>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Search className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">No results found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">Try a different search term or broaden your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{results.map(result => {
|
||||
const config = TYPE_CONFIG[result.type] || TYPE_CONFIG.client;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<Link
|
||||
key={`${result.type}-${result.id}`}
|
||||
to={getLink(result)}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${config.bgColor}`}>
|
||||
<Icon className={`w-4 h-4 ${config.color}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{result.title}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${config.bgColor} ${config.color}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
{result.subtitle && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate">{result.subtitle}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>Matched: {result.matchField}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(result.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasSearched && !query && (
|
||||
<div className="text-center py-16">
|
||||
<Search className="w-16 h-16 text-gray-200 dark:text-gray-700 mx-auto mb-4" />
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">Search across all your data</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
|
||||
Type at least 2 characters to start searching
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
src/pages/SegmentsPage.tsx
Normal file
352
src/pages/SegmentsPage.tsx
Normal 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, 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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
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 type { CommunicationStyle } from '@/types';
|
||||
|
||||
function StatusMessage({ type, message }: { type: 'success' | 'error'; message: string }) {
|
||||
return (
|
||||
@@ -33,10 +34,26 @@ export default function SettingsPage() {
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
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(() => {
|
||||
api.getProfile().then((p) => {
|
||||
Promise.all([
|
||||
api.getProfile(),
|
||||
api.getCommunicationStyle(),
|
||||
]).then(([p, style]) => {
|
||||
setProfile(p);
|
||||
setNewEmail(p.email || '');
|
||||
setCommStyle(style);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
@@ -51,8 +68,8 @@ export default function SettingsPage() {
|
||||
setProfile(updated);
|
||||
setProfileStatus({ type: 'success', message: 'Profile saved' });
|
||||
setTimeout(() => setProfileStatus(null), 3000);
|
||||
} catch (err: any) {
|
||||
setProfileStatus({ type: 'error', message: err.message || 'Failed to save' });
|
||||
} catch (err: unknown) {
|
||||
setProfileStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to save' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -69,8 +86,8 @@ export default function SettingsPage() {
|
||||
setProfile(prev => prev ? { ...prev, email: newEmail } : prev);
|
||||
setEmailStatus({ type: 'success', message: 'Email updated' });
|
||||
setTimeout(() => setEmailStatus(null), 3000);
|
||||
} catch (err: any) {
|
||||
setEmailStatus({ type: 'error', message: err.message || 'Failed to update email' });
|
||||
} catch (err: unknown) {
|
||||
setEmailStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to update email' });
|
||||
} finally {
|
||||
setEmailSaving(false);
|
||||
}
|
||||
@@ -95,8 +112,8 @@ export default function SettingsPage() {
|
||||
setConfirmPassword('');
|
||||
setPasswordStatus({ type: 'success', message: 'Password changed' });
|
||||
setTimeout(() => setPasswordStatus(null), 3000);
|
||||
} catch (err: any) {
|
||||
setPasswordStatus({ type: 'error', message: err.message || 'Failed to change password' });
|
||||
} catch (err: unknown) {
|
||||
setPasswordStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to change password' });
|
||||
} finally {
|
||||
setPasswordSaving(false);
|
||||
}
|
||||
@@ -296,6 +313,167 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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: unknown) {
|
||||
setStyleStatus({ type: 'error', message: err instanceof Error ? 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>
|
||||
);
|
||||
}
|
||||
|
||||
329
src/pages/TagsPage.tsx
Normal file
329
src/pages/TagsPage.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { Tag, Pencil, Trash2, Merge, 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: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} 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: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTag) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteTag(deleteTag.name);
|
||||
setDeleteTag(null);
|
||||
await fetchTags();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} 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: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} 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
247
src/pages/TemplatesPage.tsx
Normal 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, 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>
|
||||
);
|
||||
}
|
||||
@@ -40,8 +40,8 @@ export const useClientsStore = create<ClientsState>()((set, get) => ({
|
||||
tag: selectedTag || undefined,
|
||||
});
|
||||
set({ clients, isLoading: false });
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,8 +50,8 @@ export const useClientsStore = create<ClientsState>()((set, get) => ({
|
||||
try {
|
||||
const client = await api.getClient(id);
|
||||
set({ selectedClient: client, isLoading: false });
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
||||
try {
|
||||
const emails = await api.getEmails(params);
|
||||
set({ emails, isLoading: false });
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -43,8 +43,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
||||
const email = await api.generateEmail({ clientId, purpose, provider });
|
||||
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||
return email;
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isGenerating: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isGenerating: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
@@ -55,8 +55,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
||||
const email = await api.generateBirthdayEmail(clientId, provider);
|
||||
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||
return email;
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isGenerating: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isGenerating: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,8 +30,8 @@ export const useEventsStore = create<EventsState>()((set) => ({
|
||||
try {
|
||||
const events = await api.getEvents(params);
|
||||
set({ events, isLoading: false });
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -61,8 +61,8 @@ export const useEventsStore = create<EventsState>()((set) => ({
|
||||
await api.syncAllEvents();
|
||||
const events = await api.getEvents();
|
||||
set({ events, isLoading: false });
|
||||
} catch (err: any) {
|
||||
set({ error: err.message, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface Client {
|
||||
};
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
stage?: 'lead' | 'prospect' | 'onboarding' | 'active' | 'inactive';
|
||||
lastContacted?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
@@ -66,6 +67,17 @@ export interface ClientCreate {
|
||||
};
|
||||
notes?: 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 {
|
||||
@@ -137,7 +149,7 @@ export interface ActivityItem {
|
||||
title: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
metadata?: Record<string, any>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface InsightsData {
|
||||
@@ -194,6 +206,157 @@ export interface ImportResult {
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string; // 'event_reminder' | 'interaction' | 'system'
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
clientId?: string;
|
||||
eventId?: string;
|
||||
client?: { id: string; firstName: string; lastName: string } | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Interaction {
|
||||
id: string;
|
||||
userId: string;
|
||||
clientId: string;
|
||||
type: 'call' | 'meeting' | 'email' | 'note' | 'other';
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number; // minutes
|
||||
contactedAt: string;
|
||||
createdAt: string;
|
||||
client?: { id: string; firstName: string; lastName: string };
|
||||
}
|
||||
|
||||
export interface BulkEmailResult {
|
||||
batchId: string;
|
||||
results: Array<{
|
||||
clientId: string;
|
||||
email?: Email;
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}>;
|
||||
total: number;
|
||||
generated: number;
|
||||
}
|
||||
|
||||
export interface 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 {
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
Reference in New Issue
Block a user