feat: global search page, data export page, client duplicates/merge modal
This commit is contained in:
@@ -25,6 +25,8 @@ 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 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();
|
||||||
@@ -83,6 +85,8 @@ export default function App() {
|
|||||||
<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="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>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { api } from '@/lib/api';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
LayoutDashboard, Users, Calendar, Mail, Settings, Shield,
|
||||||
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command,
|
||||||
FileText, Bookmark, ScrollText, Tag, Zap,
|
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';
|
||||||
@@ -24,6 +24,8 @@ const baseNavItems = [
|
|||||||
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
{ path: '/segments', label: 'Segments', icon: Bookmark },
|
||||||
{ path: '/engagement', label: 'Engagement', icon: Zap },
|
{ 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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
134
src/lib/api.ts
134
src/lib/api.ts
@@ -687,6 +687,89 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Engagement Scoring ----
|
// ---- Engagement Scoring ----
|
||||||
|
|
||||||
async getEngagementScores(): Promise<EngagementResponse> {
|
async getEngagementScores(): Promise<EngagementResponse> {
|
||||||
@@ -767,4 +850,55 @@ export interface StatsOverview {
|
|||||||
generatedAt: string;
|
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: any;
|
||||||
|
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 const api = new ApiClient();
|
export const api = new ApiClient();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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 EngagementBadge from '@/components/EngagementBadge';
|
||||||
|
import DuplicatesModal from '@/components/DuplicatesModal';
|
||||||
import type { Interaction } from '@/types';
|
import type { Interaction } from '@/types';
|
||||||
|
|
||||||
export default function ClientDetailPage() {
|
export default function ClientDetailPage() {
|
||||||
@@ -34,6 +35,7 @@ export default function ClientDetailPage() {
|
|||||||
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();
|
||||||
|
|
||||||
@@ -134,6 +136,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'}`}
|
||||||
@@ -388,6 +394,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
src/pages/SearchPage.tsx
Normal file
211
src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
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, FileText, StickyNote, X, Loader2 } from 'lucide-react';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user