Compare commits
6 Commits
7a956aebec
...
df448a7245
| Author | SHA1 | Date | |
|---|---|---|---|
| df448a7245 | |||
| f042c910ee | |||
| b0cfa0ab1b | |||
| 4d684a9d74 | |||
| 1da92bac58 | |||
| 93f127f5e9 |
@@ -42,8 +42,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to Dokploy
|
- name: Deploy to Dokploy
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \
|
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
||||||
-d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}'
|
-d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||||
echo "Deploy triggered on Dokploy"
|
echo "Deploy triggered on Dokploy"
|
||||||
|
|||||||
10
nginx.conf
10
nginx.conf
@@ -4,6 +4,16 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
import Layout from '@/components/Layout';
|
import Layout from '@/components/Layout';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { ToastContainer, toast } from '@/components/Toast';
|
import { ToastContainer } from '@/components/Toast';
|
||||||
|
import { toast } from '@/lib/toast';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||||
@@ -24,6 +25,9 @@ const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
|||||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||||
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
|
const AuditLogPage = lazy(() => import('@/pages/AuditLogPage'));
|
||||||
const TagsPage = lazy(() => import('@/pages/TagsPage'));
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore();
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
@@ -81,6 +85,9 @@ export default function App() {
|
|||||||
<Route path="segments" element={<PageErrorBoundary><SegmentsPage /></PageErrorBoundary>} />
|
<Route path="segments" element={<PageErrorBoundary><SegmentsPage /></PageErrorBoundary>} />
|
||||||
<Route path="settings" element={<PageErrorBoundary><SettingsPage /></PageErrorBoundary>} />
|
<Route path="settings" element={<PageErrorBoundary><SettingsPage /></PageErrorBoundary>} />
|
||||||
<Route path="admin" element={<PageErrorBoundary><AdminPage /></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 path="audit-log" element={<PageErrorBoundary><AuditLogPage /></PageErrorBoundary>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { Client, BulkEmailResult } from '@/types';
|
import type { Client, BulkEmailResult } from '@/types';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
import { Sparkles, Send, CheckCircle2, XCircle, Search, X, Users, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Sparkles, Send, CheckCircle2, XCircle, Search, Users, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface BulkEmailModalProps {
|
interface BulkEmailModalProps {
|
||||||
@@ -230,7 +230,7 @@ export default function BulkEmailModal({ isOpen, onClose, clients, onComplete }:
|
|||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
||||||
<select
|
<select
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={e => setProvider(e.target.value as any)}
|
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"
|
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="anthropic">Anthropic (Claude)</option>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { ImportPreview, ImportResult } from '@/types';
|
import type { ImportPreview, ImportResult } from '@/types';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
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 = [
|
const CLIENT_FIELDS = [
|
||||||
{ value: '', label: '-- Skip --' },
|
{ value: '', label: '-- Skip --' },
|
||||||
@@ -71,8 +71,8 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
|||||||
setPreview(previewData);
|
setPreview(previewData);
|
||||||
setMapping(previewData.mapping);
|
setMapping(previewData.mapping);
|
||||||
setStep('mapping');
|
setStep('mapping');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to parse CSV');
|
setError(err instanceof Error ? err.message : 'Failed to parse CSV');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -109,8 +109,8 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
|||||||
if (importResult.imported > 0) {
|
if (importResult.imported > 0) {
|
||||||
onComplete();
|
onComplete();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Import failed');
|
setError(err instanceof Error ? err.message : 'Import failed');
|
||||||
setStep('mapping');
|
setStep('mapping');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
|||||||
const [tagInput, setTagInput] = useState('');
|
const [tagInput, setTagInput] = useState('');
|
||||||
const [interestInput, setInterestInput] = 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 = () => {
|
const addTag = () => {
|
||||||
if (tagInput.trim() && !form.tags?.includes(tagInput.trim())) {
|
if (tagInput.trim() && !form.tags?.includes(tagInput.trim())) {
|
||||||
@@ -57,7 +57,7 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const cleaned = Object.fromEntries(
|
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;
|
) as ClientCreate;
|
||||||
if (form.family?.spouse || (form.family?.children && form.family.children.length > 0)) {
|
if (form.family?.spouse || (form.family?.children && form.family.children.length > 0)) {
|
||||||
cleaned.family = form.family;
|
cleaned.family = form.family;
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 type { Client } from '@/types';
|
||||||
import {
|
import {
|
||||||
Search, LayoutDashboard, Users, Calendar, Mail, Settings,
|
Search, LayoutDashboard, Users, Calendar, Mail, Settings,
|
||||||
Network, BarChart3, Shield, User, ArrowRight, Command,
|
Network, BarChart3, Shield, User, ArrowRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn, getInitials } from '@/lib/utils';
|
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);
|
setEditSubject(email.subject);
|
||||||
setEditContent(email.content);
|
setEditContent(email.content);
|
||||||
onGenerated?.(email);
|
onGenerated?.(email);
|
||||||
} catch {}
|
} catch { /* generation failed silently */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateBirthday = async () => {
|
const handleGenerateBirthday = async () => {
|
||||||
@@ -40,7 +40,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
|||||||
setEditSubject(email.subject);
|
setEditSubject(email.subject);
|
||||||
setEditContent(email.content);
|
setEditContent(email.content);
|
||||||
onGenerated?.(email);
|
onGenerated?.(email);
|
||||||
} catch {}
|
} catch { /* generation failed silently */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -64,8 +64,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
|||||||
onClose();
|
onClose();
|
||||||
setGenerated(null);
|
setGenerated(null);
|
||||||
setPurpose('');
|
setPurpose('');
|
||||||
} catch {
|
} catch { /* send failed silently */ } finally {
|
||||||
} finally {
|
|
||||||
setSending(false);
|
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>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
||||||
<select
|
<select
|
||||||
value={provider}
|
value={provider}
|
||||||
onChange={(e) => setProvider(e.target.value as any)}
|
onChange={(e) => setProvider(e.target.value as 'anthropic' | 'openai')}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
>
|
||||||
<option value="anthropic">Anthropic (Claude)</option>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { cn } from '@/lib/utils';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
||||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search,
|
||||||
FileText, Bookmark, ScrollText, Tag,
|
FileText, Bookmark, ScrollText, Tag, Zap, Database,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import NotificationBell from './NotificationBell';
|
import NotificationBell from './NotificationBell';
|
||||||
import CommandPalette from './CommandPalette';
|
import CommandPalette from './CommandPalette';
|
||||||
@@ -22,7 +22,10 @@ const baseNavItems = [
|
|||||||
{ path: '/templates', label: 'Templates', icon: FileText },
|
{ path: '/templates', label: 'Templates', icon: FileText },
|
||||||
{ path: '/tags', label: 'Tags', icon: Tag },
|
{ path: '/tags', label: 'Tags', icon: Tag },
|
||||||
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
||||||
|
{ path: '/engagement', label: 'Engagement', icon: Zap },
|
||||||
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
{ path: '/reports', label: 'Reports', icon: BarChart3 },
|
||||||
|
{ path: '/search', label: 'Search', icon: Search },
|
||||||
|
{ path: '/export', label: 'Export', icon: Database },
|
||||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -36,15 +36,29 @@ export default function MeetingPrepModal({ isOpen, onClose, clientId, clientName
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && clientId) {
|
if (!isOpen || !clientId) return;
|
||||||
setLoading(true);
|
let cancelled = false;
|
||||||
setError('');
|
const fetchPrep = async () => {
|
||||||
setPrep(null);
|
try {
|
||||||
api.getMeetingPrep(clientId)
|
const data = await api.getMeetingPrep(clientId);
|
||||||
.then(setPrep)
|
if (!cancelled) {
|
||||||
.catch(err => setError(err.message || 'Failed to generate meeting prep'))
|
setPrep(data);
|
||||||
.finally(() => setLoading(false));
|
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]);
|
}, [isOpen, clientId]);
|
||||||
|
|
||||||
const handlePrint = () => {
|
const handlePrint = () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Notification } from '@/types';
|
import type { Notification } from '@/types';
|
||||||
import { Bell, Clock, X, CheckCheck, Trash2, User } from 'lucide-react';
|
import { Bell, Clock, X, CheckCheck, User } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const typeColors: Record<string, string> = {
|
const typeColors: Record<string, string> = {
|
||||||
@@ -28,7 +28,18 @@ export default function NotificationBell() {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial fetch on mount is intentional
|
||||||
fetchNotifications();
|
fetchNotifications();
|
||||||
const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
|
const interval = setInterval(fetchNotifications, 60 * 1000); // poll every minute
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
@@ -44,22 +55,12 @@ export default function NotificationBell() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClick);
|
return () => document.removeEventListener('mousedown', handleClick);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.getNotifications({ limit: 30 });
|
|
||||||
setNotifications(data.notifications || []);
|
|
||||||
setUnreadCount(data.unreadCount || 0);
|
|
||||||
} catch {
|
|
||||||
// Silently fail - API might not have notifications table yet
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markRead = async (id: string) => {
|
const markRead = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await api.markNotificationRead(id);
|
await api.markNotificationRead(id);
|
||||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n));
|
||||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const markAllRead = async () => {
|
const markAllRead = async () => {
|
||||||
@@ -67,7 +68,7 @@ export default function NotificationBell() {
|
|||||||
await api.markAllNotificationsRead();
|
await api.markAllNotificationsRead();
|
||||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
@@ -76,7 +77,7 @@ export default function NotificationBell() {
|
|||||||
const wasUnread = notifications.find(n => n.id === id && !n.read);
|
const wasUnread = notifications.find(n => n.id === id && !n.read);
|
||||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||||
if (wasUnread) setUnreadCount(prev => Math.max(0, prev - 1));
|
if (wasUnread) setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState } from 'react';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Sparkles, User, UserPlus, MessageSquare, Compass,
|
Sparkles, UserPlus, MessageSquare, Compass,
|
||||||
ChevronRight, ChevronLeft, X, Check,
|
ChevronRight, ChevronLeft, Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
@@ -112,8 +112,6 @@ export default function OnboardingWizard({ userName, onComplete }: OnboardingWiz
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentStep = STEPS[step];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/80 backdrop-blur-sm">
|
<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">
|
<div className="w-full max-w-2xl mx-4 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl overflow-hidden animate-fade-in">
|
||||||
|
|||||||
@@ -1,42 +1,11 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
import { X, CheckCircle2, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { toastListeners, toasts as globalToasts, notifyListeners } from '@/lib/toast';
|
||||||
|
import type { ToastItem } from '@/lib/toast';
|
||||||
|
|
||||||
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
// Types and functions re-exported from @/lib/toast for backward compat
|
||||||
|
// Import directly from @/lib/toast for non-component usage
|
||||||
interface ToastItem {
|
|
||||||
id: string;
|
|
||||||
type: ToastType;
|
|
||||||
message: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global toast state
|
|
||||||
let toastListeners: ((toasts: ToastItem[]) => void)[] = [];
|
|
||||||
let toasts: ToastItem[] = [];
|
|
||||||
|
|
||||||
function notifyListeners() {
|
|
||||||
toastListeners.forEach(fn => fn([...toasts]));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showToast(type: ToastType, message: string, duration = 5000) {
|
|
||||||
const id = Math.random().toString(36).slice(2);
|
|
||||||
toasts = [...toasts, { id, type, message, duration }];
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
if (duration > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
toasts = toasts.filter(t => t.id !== id);
|
|
||||||
notifyListeners();
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toast(message: string) { showToast('info', message); }
|
|
||||||
toast.success = (msg: string) => showToast('success', msg);
|
|
||||||
toast.error = (msg: string) => showToast('error', msg, 7000);
|
|
||||||
toast.warning = (msg: string) => showToast('warning', msg);
|
|
||||||
toast.info = (msg: string) => showToast('info', msg);
|
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
success: CheckCircle2,
|
success: CheckCircle2,
|
||||||
@@ -65,12 +34,14 @@ export function ToastContainer() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
toastListeners.push(setItems);
|
toastListeners.push(setItems);
|
||||||
return () => {
|
return () => {
|
||||||
toastListeners = toastListeners.filter(fn => fn !== setItems);
|
const idx = toastListeners.indexOf(setItems);
|
||||||
|
if (idx >= 0) toastListeners.splice(idx, 1);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const dismiss = useCallback((id: string) => {
|
const dismiss = useCallback((id: string) => {
|
||||||
toasts = toasts.filter(t => t.id !== id);
|
const idx = globalToasts.findIndex(t => t.id === id);
|
||||||
|
if (idx >= 0) globalToasts.splice(idx, 1);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
408
src/lib/api.ts
408
src/lib/api.ts
@@ -1,12 +1,10 @@
|
|||||||
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';
|
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
|
// Always use same-origin paths — nginx proxies /api/* to the backend
|
||||||
? 'https://api.thenetwork.donovankelly.xyz/api'
|
// This avoids cross-domain cookie issues in Brave and other privacy browsers
|
||||||
: '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
const AUTH_BASE = import.meta.env.PROD
|
const AUTH_BASE = '';
|
||||||
? 'https://api.thenetwork.donovankelly.xyz'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const TOKEN_KEY = 'network-auth-token';
|
const TOKEN_KEY = 'network-auth-token';
|
||||||
|
|
||||||
@@ -442,27 +440,27 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reports & Analytics
|
// Reports & Analytics
|
||||||
async getReportsOverview(): Promise<any> {
|
async getReportsOverview(): Promise<unknown> {
|
||||||
return this.fetch('/reports/overview');
|
return this.fetch('/reports/overview');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsGrowth(): Promise<any> {
|
async getReportsGrowth(): Promise<unknown> {
|
||||||
return this.fetch('/reports/growth');
|
return this.fetch('/reports/growth');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsIndustries(): Promise<any[]> {
|
async getReportsIndustries(): Promise<unknown[]> {
|
||||||
return this.fetch('/reports/industries');
|
return this.fetch('/reports/industries');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsTags(): Promise<any[]> {
|
async getReportsTags(): Promise<unknown[]> {
|
||||||
return this.fetch('/reports/tags');
|
return this.fetch('/reports/tags');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getReportsEngagement(): Promise<any> {
|
async getReportsEngagement(): Promise<unknown> {
|
||||||
return this.fetch('/reports/engagement');
|
return this.fetch('/reports/engagement');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNotificationsLegacy(): Promise<any> {
|
async getNotificationsLegacy(): Promise<unknown> {
|
||||||
return this.fetch('/reports/notifications');
|
return this.fetch('/reports/notifications');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -687,6 +685,392 @@ class ApiClient {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
window.URL.revokeObjectURL(url);
|
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();
|
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', () => {
|
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', () => {
|
it('merges tailwind conflicts', () => {
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { api } from '@/lib/api';
|
|||||||
import type { AuditLog, User } from '@/types';
|
import type { AuditLog, User } from '@/types';
|
||||||
import {
|
import {
|
||||||
Shield, Search, ChevronDown, ChevronRight, ChevronLeft,
|
Shield, Search, ChevronDown, ChevronRight, ChevronLeft,
|
||||||
Filter, Calendar, User as UserIcon, Activity,
|
Filter, User as UserIcon, Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import { formatDate } from '@/lib/utils';
|
|
||||||
|
|
||||||
const ACTION_COLORS: Record<string, string> = {
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
create: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
create: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300',
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useClientsStore } from '@/stores/clients';
|
import { useClientsStore } from '@/stores/clients';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Event, Email, ActivityItem } from '@/types';
|
import type { Event, Email, ActivityItem, ClientCreate } from '@/types';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
||||||
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
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';
|
} from 'lucide-react';
|
||||||
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
import { usePinnedClients } from '@/hooks/usePinnedClients';
|
||||||
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
@@ -18,6 +19,11 @@ import EmailComposeModal from '@/components/EmailComposeModal';
|
|||||||
import ClientNotes from '@/components/ClientNotes';
|
import ClientNotes from '@/components/ClientNotes';
|
||||||
import LogInteractionModal from '@/components/LogInteractionModal';
|
import LogInteractionModal from '@/components/LogInteractionModal';
|
||||||
import MeetingPrepModal from '@/components/MeetingPrepModal';
|
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';
|
import type { Interaction } from '@/types';
|
||||||
|
|
||||||
export default function ClientDetailPage() {
|
export default function ClientDetailPage() {
|
||||||
@@ -27,12 +33,13 @@ export default function ClientDetailPage() {
|
|||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [emails, setEmails] = useState<Email[]>([]);
|
const [emails, setEmails] = useState<Email[]>([]);
|
||||||
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
const [activities, setActivities] = useState<ActivityItem[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails'>('info');
|
const [activeTab, setActiveTab] = useState<'info' | 'notes' | 'activity' | 'events' | 'emails' | 'documents' | 'goals' | 'referrals'>('info');
|
||||||
const [interactions, setInteractions] = useState<Interaction[]>([]);
|
const [, setInteractions] = useState<Interaction[]>([]);
|
||||||
const [showEdit, setShowEdit] = useState(false);
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
const [showCompose, setShowCompose] = useState(false);
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
const [showLogInteraction, setShowLogInteraction] = useState(false);
|
const [showLogInteraction, setShowLogInteraction] = useState(false);
|
||||||
const [showMeetingPrep, setShowMeetingPrep] = useState(false);
|
const [showMeetingPrep, setShowMeetingPrep] = useState(false);
|
||||||
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const { togglePin, isPinned } = usePinnedClients();
|
const { togglePin, isPinned } = usePinnedClients();
|
||||||
|
|
||||||
@@ -65,7 +72,7 @@ export default function ClientDetailPage() {
|
|||||||
await markContacted(client.id);
|
await markContacted(client.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (data: any) => {
|
const handleUpdate = async (data: ClientCreate) => {
|
||||||
await updateClient(client.id, data);
|
await updateClient(client.id, data);
|
||||||
setShowEdit(false);
|
setShowEdit(false);
|
||||||
};
|
};
|
||||||
@@ -73,6 +80,9 @@ export default function ClientDetailPage() {
|
|||||||
const tabs: { key: typeof activeTab; label: string; count?: number; icon: typeof Users }[] = [
|
const tabs: { key: typeof activeTab; label: string; count?: number; icon: typeof Users }[] = [
|
||||||
{ key: 'info', label: 'Info', icon: Users },
|
{ key: 'info', label: 'Info', icon: Users },
|
||||||
{ key: 'notes', label: 'Notes', icon: FileText },
|
{ key: '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: 'activity', label: 'Timeline', count: activities.length, icon: Activity },
|
||||||
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
||||||
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
||||||
@@ -91,9 +101,12 @@ export default function ClientDetailPage() {
|
|||||||
{getInitials(client.firstName, client.lastName)}
|
{getInitials(client.firstName, client.lastName)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
<div className="flex items-center gap-3">
|
||||||
{client.firstName} {client.lastName}
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
</h1>
|
{client.firstName} {client.lastName}
|
||||||
|
</h1>
|
||||||
|
<EngagementBadge clientId={client.id} />
|
||||||
|
</div>
|
||||||
{client.company && (
|
{client.company && (
|
||||||
<p className="text-slate-500 dark:text-slate-400">
|
<p className="text-slate-500 dark:text-slate-400">
|
||||||
{client.role ? `${client.role} at ` : ''}{client.company}
|
{client.role ? `${client.role} at ` : ''}{client.company}
|
||||||
@@ -104,7 +117,7 @@ export default function ClientDetailPage() {
|
|||||||
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
|
const stages = ['lead', 'prospect', 'onboarding', 'active', 'inactive'];
|
||||||
const currentIdx = stages.indexOf(client.stage || 'lead');
|
const currentIdx = stages.indexOf(client.stage || 'lead');
|
||||||
const nextStage = stages[(currentIdx + 1) % stages.length];
|
const nextStage = stages[(currentIdx + 1) % stages.length];
|
||||||
await updateClient(client.id, { stage: nextStage } as any);
|
await updateClient(client.id, { stage: nextStage as ClientCreate['stage'] });
|
||||||
}} />
|
}} />
|
||||||
{client.tags && client.tags.length > 0 && (
|
{client.tags && client.tags.length > 0 && (
|
||||||
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
|
client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)
|
||||||
@@ -130,6 +143,10 @@ export default function ClientDetailPage() {
|
|||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Generate Email</span>
|
<span className="hidden sm:inline">Generate Email</span>
|
||||||
</button>
|
</button>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => togglePin(client.id)}
|
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'}`}
|
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'}`}
|
||||||
@@ -267,6 +284,18 @@ export default function ClientDetailPage() {
|
|||||||
<ClientNotes clientId={client.id} />
|
<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' && (
|
{activeTab === 'activity' && (
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
@@ -384,6 +413,17 @@ export default function ClientDetailPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Duplicates Modal */}
|
||||||
|
<DuplicatesModal
|
||||||
|
isOpen={showDuplicates}
|
||||||
|
onClose={() => setShowDuplicates(false)}
|
||||||
|
clientId={client.id}
|
||||||
|
clientName={`${client.firstName} ${client.lastName}`}
|
||||||
|
onMerged={() => {
|
||||||
|
if (id) fetchClient(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||||
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import { useClientsStore } from '@/stores/clients';
|
import { useClientsStore } from '@/stores/clients';
|
||||||
import { Search, Plus, Users, X, Upload, LayoutGrid, List, Kanban, ChevronLeft, ChevronRight } 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 { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
import Badge, { StageBadge } from '@/components/Badge';
|
import Badge, { StageBadge } from '@/components/Badge';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
@@ -121,7 +122,7 @@ export default function ClientsPage() {
|
|||||||
return Array.from(tags).sort();
|
return Array.from(tags).sort();
|
||||||
}, [clients]);
|
}, [clients]);
|
||||||
|
|
||||||
const handleCreate = async (data: any) => {
|
const handleCreate = async (data: ClientCreate) => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await createClient(data);
|
await createClient(data);
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
|
|||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Client, Event, Email, InsightsData } from '@/types';
|
import type { Client, Event, Email, InsightsData } from '@/types';
|
||||||
import type { Interaction } from '@/types';
|
import type { Interaction } from '@/types';
|
||||||
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock, AlertTriangle, Sparkles, UserCheck, PhoneForwarded, Star, Phone, FileText, MoreHorizontal } from 'lucide-react';
|
import 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 { formatDate, getDaysUntil, getInitials } from '@/lib/utils';
|
||||||
import { EventTypeBadge } from '@/components/Badge';
|
import { EventTypeBadge } from '@/components/Badge';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
@@ -15,6 +16,8 @@ export default function DashboardPage() {
|
|||||||
const [emails, setEmails] = useState<Email[]>([]);
|
const [emails, setEmails] = useState<Email[]>([]);
|
||||||
const [insights, setInsights] = useState<InsightsData | null>(null);
|
const [insights, setInsights] = useState<InsightsData | null>(null);
|
||||||
const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]);
|
const [recentInteractions, setRecentInteractions] = useState<Interaction[]>([]);
|
||||||
|
const [goalsOverview, setGoalsOverview] = useState<GoalsOverview | null>(null);
|
||||||
|
const [referralStats, setReferralStats] = useState<ReferralStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { pinnedIds, togglePin, isPinned } = usePinnedClients();
|
const { pinnedIds, togglePin, isPinned } = usePinnedClients();
|
||||||
|
|
||||||
@@ -25,12 +28,16 @@ export default function DashboardPage() {
|
|||||||
api.getEmails({ status: 'draft' }).catch(() => []),
|
api.getEmails({ status: 'draft' }).catch(() => []),
|
||||||
api.getInsights().catch(() => null),
|
api.getInsights().catch(() => null),
|
||||||
api.getRecentInteractions(5).catch(() => []),
|
api.getRecentInteractions(5).catch(() => []),
|
||||||
]).then(([c, e, em, ins, ri]) => {
|
api.getGoalsOverview().catch(() => null),
|
||||||
|
api.getReferralStats().catch(() => null),
|
||||||
|
]).then(([c, e, em, ins, ri, go, rs]) => {
|
||||||
setClients(c);
|
setClients(c);
|
||||||
setEvents(e);
|
setEvents(e);
|
||||||
setEmails(em);
|
setEmails(em);
|
||||||
setInsights(ins as InsightsData | null);
|
setInsights(ins as InsightsData | null);
|
||||||
setRecentInteractions(ri as Interaction[]);
|
setRecentInteractions(ri as Interaction[]);
|
||||||
|
setGoalsOverview(go as GoalsOverview | null);
|
||||||
|
setReferralStats(rs as ReferralStats | null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -296,6 +303,108 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
{/* Recent Clients */}
|
{/* Recent Clients */}
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function EmailsPage() {
|
|||||||
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
||||||
setShowCompose(false);
|
setShowCompose(false);
|
||||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGenerateBirthday = async () => {
|
const handleGenerateBirthday = async () => {
|
||||||
@@ -40,7 +40,7 @@ export default function EmailsPage() {
|
|||||||
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
||||||
setShowCompose(false);
|
setShowCompose(false);
|
||||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||||
} catch {}
|
} catch { /* silently handled */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const startEdit = (email: typeof emails[0]) => {
|
const startEdit = (email: typeof emails[0]) => {
|
||||||
@@ -223,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>
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Provider</label>
|
||||||
<select
|
<select
|
||||||
value={composeForm.provider}
|
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"
|
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>
|
<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 { useClientsStore } from '@/stores/clients';
|
||||||
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star, ChevronLeft, ChevronRight, List, Grid3X3 } from 'lucide-react';
|
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star, ChevronLeft, ChevronRight, List, Grid3X3 } from 'lucide-react';
|
||||||
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
||||||
import Badge, { EventTypeBadge } from '@/components/Badge';
|
import { EventTypeBadge } from '@/components/Badge';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import Modal from '@/components/Modal';
|
import Modal from '@/components/Modal';
|
||||||
@@ -305,7 +305,6 @@ export default function EventsPage() {
|
|||||||
{selectedDayEvents && (
|
{selectedDayEvents && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{selectedDayEvents.events.map((event) => {
|
{selectedDayEvents.events.map((event) => {
|
||||||
const days = getDaysUntil(event.date);
|
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
<div key={event.id} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
||||||
<div className={cn(
|
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
<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="custom">Custom</option>
|
||||||
<option value="birthday">Birthday</option>
|
<option value="birthday">Birthday</option>
|
||||||
<option value="anniversary">Anniversary</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 {
|
try {
|
||||||
await api.requestPasswordReset(email);
|
await api.requestPasswordReset(email);
|
||||||
setSubmitted(true);
|
setSubmitted(true);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to request password reset');
|
setError(err instanceof Error ? err.message : 'Failed to request password reset');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export default function InvitePage() {
|
|||||||
const data = await api.validateInvite(token);
|
const data = await api.validateInvite(token);
|
||||||
setInvite(data);
|
setInvite(data);
|
||||||
setName(data.name);
|
setName(data.name);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Invalid or expired invite');
|
setError(err instanceof Error ? err.message : 'Invalid or expired invite');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,8 @@ export default function InvitePage() {
|
|||||||
}
|
}
|
||||||
await checkSession();
|
await checkSession();
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setSubmitError(err.message || 'Failed to create account');
|
setSubmitError(err instanceof Error ? err.message : 'Failed to create account');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export default function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Login failed');
|
setError(err instanceof Error ? err.message : 'Login failed');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
BarChart3, Users, Mail, Calendar, TrendingUp, Download,
|
Users, Mail, Calendar, TrendingUp, Download,
|
||||||
Activity, Tag, Building2, AlertTriangle, Flame, Snowflake, ThermometerSun,
|
Activity, Tag, Building2, AlertTriangle, Flame, Snowflake, ThermometerSun,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
@@ -162,6 +162,8 @@ function AtRiskList({ title, clients: clientList }: {
|
|||||||
title: string;
|
title: string;
|
||||||
clients: { id: string; name: string; company: string | null; lastContacted: string | null }[];
|
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;
|
if (clientList.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
<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>
|
</div>
|
||||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
{c.lastContacted
|
{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'}
|
: 'Never'}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export default function ResetPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.validateResetToken(token);
|
const data = await api.validateResetToken(token);
|
||||||
setEmail(data.email || '');
|
setEmail(data.email || '');
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Invalid or expired reset link');
|
setError(err instanceof Error ? err.message : 'Invalid or expired reset link');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -49,8 +49,8 @@ export default function ResetPasswordPage() {
|
|||||||
try {
|
try {
|
||||||
await api.resetPassword(token!, password);
|
await api.resetPassword(token!, password);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setSubmitError(err.message || 'Failed to reset password');
|
setSubmitError(err instanceof Error ? err.message : 'Failed to reset password');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
// import { useNavigate } from 'react-router-dom';
|
// import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { ClientSegment, SegmentFilters, FilterOptions, Client } from '@/types';
|
import type { ClientSegment, SegmentFilters, FilterOptions, Client } from '@/types';
|
||||||
import { Plus, Filter, Users, Bookmark, Pin, Trash2, Pencil, Eye, Save, X, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Plus, Filter, Users, Bookmark, Pin, Trash2, Pencil, Eye, Save, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export default function SettingsPage() {
|
|||||||
setProfile(updated);
|
setProfile(updated);
|
||||||
setProfileStatus({ type: 'success', message: 'Profile saved' });
|
setProfileStatus({ type: 'success', message: 'Profile saved' });
|
||||||
setTimeout(() => setProfileStatus(null), 3000);
|
setTimeout(() => setProfileStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setProfileStatus({ type: 'error', message: err.message || 'Failed to save' });
|
setProfileStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to save' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -86,8 +86,8 @@ export default function SettingsPage() {
|
|||||||
setProfile(prev => prev ? { ...prev, email: newEmail } : prev);
|
setProfile(prev => prev ? { ...prev, email: newEmail } : prev);
|
||||||
setEmailStatus({ type: 'success', message: 'Email updated' });
|
setEmailStatus({ type: 'success', message: 'Email updated' });
|
||||||
setTimeout(() => setEmailStatus(null), 3000);
|
setTimeout(() => setEmailStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setEmailStatus({ type: 'error', message: err.message || 'Failed to update email' });
|
setEmailStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to update email' });
|
||||||
} finally {
|
} finally {
|
||||||
setEmailSaving(false);
|
setEmailSaving(false);
|
||||||
}
|
}
|
||||||
@@ -112,8 +112,8 @@ export default function SettingsPage() {
|
|||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
setPasswordStatus({ type: 'success', message: 'Password changed' });
|
setPasswordStatus({ type: 'success', message: 'Password changed' });
|
||||||
setTimeout(() => setPasswordStatus(null), 3000);
|
setTimeout(() => setPasswordStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setPasswordStatus({ type: 'error', message: err.message || 'Failed to change password' });
|
setPasswordStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to change password' });
|
||||||
} finally {
|
} finally {
|
||||||
setPasswordSaving(false);
|
setPasswordSaving(false);
|
||||||
}
|
}
|
||||||
@@ -324,8 +324,8 @@ export default function SettingsPage() {
|
|||||||
setCommStyle(updated);
|
setCommStyle(updated);
|
||||||
setStyleStatus({ type: 'success', message: 'Communication style saved' });
|
setStyleStatus({ type: 'success', message: 'Communication style saved' });
|
||||||
setTimeout(() => setStyleStatus(null), 3000);
|
setTimeout(() => setStyleStatus(null), 3000);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setStyleStatus({ type: 'error', message: err.message || 'Failed to save' });
|
setStyleStatus({ type: 'error', message: err instanceof Error ? err.message : 'Failed to save' });
|
||||||
} finally {
|
} finally {
|
||||||
setStyleSaving(false);
|
setStyleSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Tag, Pencil, Trash2, Merge, Plus, Users } from 'lucide-react';
|
import { Tag, Pencil, Trash2, Merge, Users } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { PageLoader } from '@/components/LoadingSpinner';
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
@@ -61,8 +61,8 @@ export default function TagsPage() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.getTags();
|
const data = await api.getTags();
|
||||||
setTags(data);
|
setTags(data);
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -78,8 +78,8 @@ export default function TagsPage() {
|
|||||||
setRenameTag(null);
|
setRenameTag(null);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
await fetchTags();
|
await fetchTags();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
}
|
}
|
||||||
@@ -92,8 +92,8 @@ export default function TagsPage() {
|
|||||||
await api.deleteTag(deleteTag.name);
|
await api.deleteTag(deleteTag.name);
|
||||||
setDeleteTag(null);
|
setDeleteTag(null);
|
||||||
await fetchTags();
|
await fetchTags();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
@@ -109,8 +109,8 @@ export default function TagsPage() {
|
|||||||
setMergeSelected(new Set());
|
setMergeSelected(new Set());
|
||||||
setMergeTarget('');
|
setMergeTarget('');
|
||||||
await fetchTags();
|
await fetchTags();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||||
} finally {
|
} finally {
|
||||||
setMerging(false);
|
setMerging(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { EmailTemplate, EmailTemplateCreate } from '@/types';
|
import type { EmailTemplate, EmailTemplateCreate } from '@/types';
|
||||||
import { Plus, Pencil, Trash2, Star, Copy, FileText, X, Save } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Star, Copy, FileText, Save } from 'lucide-react';
|
||||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export const useClientsStore = create<ClientsState>()((set, get) => ({
|
|||||||
tag: selectedTag || undefined,
|
tag: selectedTag || undefined,
|
||||||
});
|
});
|
||||||
set({ clients, isLoading: false });
|
set({ clients, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@ export const useClientsStore = create<ClientsState>()((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
const client = await api.getClient(id);
|
const client = await api.getClient(id);
|
||||||
set({ selectedClient: client, isLoading: false });
|
set({ selectedClient: client, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
|||||||
try {
|
try {
|
||||||
const emails = await api.getEmails(params);
|
const emails = await api.getEmails(params);
|
||||||
set({ emails, isLoading: false });
|
set({ emails, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
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 });
|
const email = await api.generateEmail({ clientId, purpose, provider });
|
||||||
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||||
return email;
|
return email;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isGenerating: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isGenerating: false });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -55,8 +55,8 @@ export const useEmailsStore = create<EmailsState>()((set) => ({
|
|||||||
const email = await api.generateBirthdayEmail(clientId, provider);
|
const email = await api.generateBirthdayEmail(clientId, provider);
|
||||||
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||||
return email;
|
return email;
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isGenerating: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isGenerating: false });
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export const useEventsStore = create<EventsState>()((set) => ({
|
|||||||
try {
|
try {
|
||||||
const events = await api.getEvents(params);
|
const events = await api.getEvents(params);
|
||||||
set({ events, isLoading: false });
|
set({ events, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
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();
|
await api.syncAllEvents();
|
||||||
const events = await api.getEvents();
|
const events = await api.getEvents();
|
||||||
set({ events, isLoading: false });
|
set({ events, isLoading: false });
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
set({ error: err.message, isLoading: false });
|
set({ error: (err instanceof Error ? err.message : 'An error occurred'), isLoading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export interface ActivityItem {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
date: string;
|
date: string;
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InsightsData {
|
export interface InsightsData {
|
||||||
|
|||||||
Reference in New Issue
Block a user