From 1da92bac58c23a0609cf3bedd7730eb35a9c3099 Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 04:10:46 +0000 Subject: [PATCH] feat: global search page, data export page, client duplicates/merge modal --- src/App.tsx | 4 + src/components/DuplicatesModal.tsx | 148 ++++++++++++++++++++ src/components/Layout.tsx | 4 +- src/lib/api.ts | 134 ++++++++++++++++++ src/pages/ClientDetailPage.tsx | 17 +++ src/pages/ExportPage.tsx | 180 ++++++++++++++++++++++++ src/pages/SearchPage.tsx | 211 +++++++++++++++++++++++++++++ 7 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 src/components/DuplicatesModal.tsx create mode 100644 src/pages/ExportPage.tsx create mode 100644 src/pages/SearchPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 4047669..55ba129 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,8 @@ const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage')); const AuditLogPage = lazy(() => import('@/pages/AuditLogPage')); const TagsPage = lazy(() => import('@/pages/TagsPage')); const EngagementPage = lazy(() => import('@/pages/EngagementPage')); +const SearchPage = lazy(() => import('@/pages/SearchPage')); +const ExportPage = lazy(() => import('@/pages/ExportPage')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore(); @@ -83,6 +85,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> diff --git a/src/components/DuplicatesModal.tsx b/src/components/DuplicatesModal.tsx new file mode 100644 index 0000000..f0a5ba9 --- /dev/null +++ b/src/components/DuplicatesModal.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [merging, setMerging] = useState(null); + const [merged, setMerged] = useState(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 ( +
+
+ {/* Header */} +
+
+

+ + Find Duplicates +

+

+ Potential duplicates for {clientName} +

+
+ +
+ + {/* Body */} +
+ {loading ? ( +
+ + Scanning for duplicates... +
+ ) : duplicates.length === 0 ? ( +
+ +

No duplicates found

+

This client record appears to be unique

+
+ ) : ( +
+
+ + Found {duplicates.length} potential duplicate{duplicates.length !== 1 ? 's' : ''}. Review and merge if appropriate. +
+ + {duplicates.map(dup => ( +
+
+
+
+ + {dup.firstName} {dup.lastName} + + + {dup.duplicateScore}% match + +
+
+ {dup.email &&

{dup.email}

} + {dup.phone &&

{dup.phone}

} + {dup.company &&

{dup.company}

} +

Stage: {dup.stage || 'lead'}

+
+
+ {dup.matchReasons.map(reason => ( + + {reason} + + ))} +
+
+ +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index b72be43..a3129c4 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,7 +6,7 @@ import { api } from '@/lib/api'; import { LayoutDashboard, Users, Calendar, Mail, Settings, Shield, LogOut, Menu, X, ChevronLeft, Network, BarChart3, Search, Command, - FileText, Bookmark, ScrollText, Tag, Zap, + FileText, Bookmark, ScrollText, Tag, Zap, Database, } from 'lucide-react'; import NotificationBell from './NotificationBell'; import CommandPalette from './CommandPalette'; @@ -24,6 +24,8 @@ const baseNavItems = [ { path: '/segments', label: 'Segments', icon: Bookmark }, { path: '/engagement', label: 'Engagement', icon: Zap }, { path: '/reports', label: 'Reports', icon: BarChart3 }, + { path: '/search', label: 'Search', icon: Search }, + { path: '/export', label: 'Export', icon: Database }, { path: '/settings', label: 'Settings', icon: Settings }, ]; diff --git a/src/lib/api.ts b/src/lib/api.ts index 65372dd..a38ab75 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -687,6 +687,89 @@ class ApiClient { document.body.removeChild(a); window.URL.revokeObjectURL(url); } + // ---- Global Search ---- + async globalSearch(q: string, types?: string[], limit?: number): Promise { + 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(`/search?${params.toString()}`); + } + + // ---- Client Merge / Duplicates ---- + async getClientDuplicates(clientId: string): Promise { + return this.fetch(`/clients/${clientId}/duplicates`); + } + + async mergeClients(primaryId: string, mergeFromId: string): Promise { + return this.fetch(`/clients/${primaryId}/merge`, { + method: 'POST', + body: JSON.stringify({ mergeFromId }), + }); + } + + // ---- Data Export ---- + async getExportSummary(): Promise { + return this.fetch('/export/summary'); + } + + async exportFullJSON(): Promise { + 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 { + 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 { + 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 ---- async getEngagementScores(): Promise { @@ -767,4 +850,55 @@ export interface StatsOverview { 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(); diff --git a/src/pages/ClientDetailPage.tsx b/src/pages/ClientDetailPage.tsx index 1fa4bd9..4081e40 100644 --- a/src/pages/ClientDetailPage.tsx +++ b/src/pages/ClientDetailPage.tsx @@ -19,6 +19,7 @@ import ClientNotes from '@/components/ClientNotes'; import LogInteractionModal from '@/components/LogInteractionModal'; import MeetingPrepModal from '@/components/MeetingPrepModal'; import EngagementBadge from '@/components/EngagementBadge'; +import DuplicatesModal from '@/components/DuplicatesModal'; import type { Interaction } from '@/types'; export default function ClientDetailPage() { @@ -34,6 +35,7 @@ export default function ClientDetailPage() { const [showCompose, setShowCompose] = useState(false); const [showLogInteraction, setShowLogInteraction] = useState(false); const [showMeetingPrep, setShowMeetingPrep] = useState(false); + const [showDuplicates, setShowDuplicates] = useState(false); const [deleting, setDeleting] = useState(false); const { togglePin, isPinned } = usePinnedClients(); @@ -134,6 +136,10 @@ export default function ClientDetailPage() { Generate Email + + + + ); + })} + + + + {/* Info */} +
+

About Data Exports

+
    +
  • • All exports include only your data (multi-tenant safe)
  • +
  • • JSON exports contain the complete dataset for full backup/restore
  • +
  • • CSV exports are compatible with Excel, Google Sheets, and other CRMs
  • +
  • • All exports are logged in the audit trail for compliance
  • +
+
+ + ); +} diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx new file mode 100644 index 0000000..c8aef7b --- /dev/null +++ b/src/pages/SearchPage.tsx @@ -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 = { + 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([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [activeTypes, setActiveTypes] = useState(ALL_TYPES); + const [hasSearched, setHasSearched] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef(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>((acc, r) => { + if (!acc[r.type]) acc[r.type] = []; + acc[r.type].push(r); + return acc; + }, {}); + + return ( +
+
+

Search

+

+ Search across clients, emails, events, interactions, and notes +

+
+ + {/* Search Input */} +
+ + 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 && ( + + )} + {loading && ( + + )} +
+ + {/* Type Filters */} +
+ {ALL_TYPES.map(type => { + const config = TYPE_CONFIG[type.replace(/s$/, '')] || TYPE_CONFIG.client; + const isActive = activeTypes.includes(type); + const Icon = config.icon; + return ( + + ); + })} +
+ + {/* Results */} + {hasSearched && ( +
+

+ {total} result{total !== 1 ? 's' : ''} for "{query}" +

+ + {results.length === 0 ? ( +
+ +

No results found

+

Try a different search term or broaden your filters

+
+ ) : ( +
+ {results.map(result => { + const config = TYPE_CONFIG[result.type] || TYPE_CONFIG.client; + const Icon = config.icon; + return ( + +
+ +
+
+
+ + {result.title} + + + {config.label} + +
+ {result.subtitle && ( +

{result.subtitle}

+ )} +
+ Matched: {result.matchField} + · + {formatDate(result.createdAt)} +
+
+ + ); + })} +
+ )} +
+ )} + + {!hasSearched && !query && ( +
+ +

Search across all your data

+

+ Type at least 2 characters to start searching +

+
+ )} +
+ ); +}