import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { format } from 'date-fns'; import { Mail, Clock, CheckCircle2, XCircle, Filter, Search, ChevronDown, AlertCircle, Send, ArrowUpRight, ArrowDownLeft, Loader2, RefreshCw, Reply, Copy, Check, User, Award, TrendingUp, Archive, ArchiveRestore, Trash2, AlertTriangle, Merge, } from 'lucide-react'; import { supabase } from '@/integrations/supabase/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Checkbox } from '@/components/ui/checkbox'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { DialogFooter } from '@/components/ui/dialog'; import { useTheme } from '@/components/theme/ThemeProvider'; import { useUserRole } from '@/hooks/useUserRole'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; import { handleError, handleSuccess } from '@/lib/errorHandler'; import { logger } from '@/lib/logger'; import { contactCategories } from '@/lib/contactValidation'; import { invokeWithTracking } from '@/lib/edgeFunctionTracking'; import { AdminLayout } from '@/components/layout/AdminLayout'; interface ContactSubmission { id: string; created_at: string; updated_at: string; user_id: string | null; submitter_username: string | null; submitter_reputation: number | null; submitter_profile_data: { display_name?: string; member_since?: string; stats?: { rides: number; coasters: number; parks: number; reviews: number; }; reputation?: number; avatar_url?: string; } | null; name: string; email: string; subject: string; message: string; category: string; status: 'pending' | 'in_progress' | 'resolved' | 'closed'; assigned_to: string | null; admin_notes: string | null; resolved_at: string | null; resolved_by: string | null; thread_id: string; last_admin_response_at: string | null; response_count: number; ticket_number: string; archived_at: string | null; archived_by: string | null; merged_ticket_numbers: string[] | null; } interface EmailThread { id: string; created_at: string; message_id: string; from_email: string; to_email: string; subject: string; body_text: string; direction: 'inbound' | 'outbound'; sent_by: string | null; } export default function AdminContact() { useDocumentTitle('Contact Submissions - Admin'); const queryClient = useQueryClient(); const { theme } = useTheme(); const { isAdmin, loading: rolesLoading } = useUserRole(); const [statusFilter, setStatusFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [selectedSubmission, setSelectedSubmission] = useState(null); const [adminNotes, setAdminNotes] = useState(''); const [replyBody, setReplyBody] = useState(''); const [showReplyForm, setShowReplyForm] = useState(false); const [emailThreads, setEmailThreads] = useState([]); const [loadingThreads, setLoadingThreads] = useState(false); const [copiedTicket, setCopiedTicket] = useState(null); const [activeTab, setActiveTab] = useState('details'); const [replyStatus, setReplyStatus] = useState(''); const [showArchived, setShowArchived] = useState(false); // Merge mode state const [selectedForMerge, setSelectedForMerge] = useState([]); const [mergeMode, setMergeMode] = useState(false); const [showMergeDialog, setShowMergeDialog] = useState(false); const [primaryTicketId, setPrimaryTicketId] = useState(null); const [mergeReason, setMergeReason] = useState(''); // Fetch contact submissions const { data: submissions, isLoading } = useQuery({ queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery, showArchived], queryFn: async () => { let query = supabase .from('contact_submissions') .select('*') .order('created_at', { ascending: false }); // Filter archived based on toggle if (showArchived) { query = query.not('archived_at', 'is', null); } else { query = query.is('archived_at', null); } if (statusFilter !== 'all') { query = query.eq('status', statusFilter); } if (categoryFilter !== 'all') { query = query.eq('category', categoryFilter); } if (searchQuery) { query = query.or( `name.ilike.%${searchQuery}%,email.ilike.%${searchQuery}%,subject.ilike.%${searchQuery}%` ); } const { data, error } = await query; if (error) { logger.error('Failed to fetch contact submissions', { error: error.message }); throw error; } return data as ContactSubmission[]; }, }); // Fetch email threads when submission selected useEffect(() => { if (selectedSubmission) { setLoadingThreads(true); // Force fresh fetch - no caching supabase .from('contact_email_threads') .select('*') .eq('submission_id', selectedSubmission.id) .order('created_at', { ascending: true }) .then(({ data, error }) => { if (error) { logger.error('Failed to fetch email threads', { error }); setEmailThreads([]); } else { setEmailThreads((data as EmailThread[]) || []); } setLoadingThreads(false); }); setAdminNotes(selectedSubmission.admin_notes || ''); setReplyBody(''); setShowReplyForm(false); setReplyStatus(selectedSubmission.status); // Initialize reply status } else { // Reset tab to details when dialog closes setActiveTab('details'); } }, [selectedSubmission]); // Send reply mutation const sendReplyMutation = useMutation({ mutationFn: async ({ submissionId, body, newStatus }: { submissionId: string, body: string, newStatus?: string }) => { const { data, error } = await invokeWithTracking( 'send-admin-email-reply', { submissionId, replyBody: body }, undefined ); if (error) throw error; // Update status if changed if (newStatus && selectedSubmission && newStatus !== selectedSubmission.status) { const updateData: Record = { status: newStatus }; if (newStatus === 'resolved' || newStatus === 'closed') { const { data: { user } } = await supabase.auth.getUser(); updateData.resolved_at = new Date().toISOString(); updateData.resolved_by = user?.id; } const { error: statusError } = await supabase .from('contact_submissions') .update(updateData) .eq('id', submissionId); if (statusError) { logger.error('Failed to update status', { error: statusError }); throw statusError; } } return data; }, onSuccess: (_data, variables) => { const submission = submissions?.find(s => s.id === variables.submissionId); const ticketNumber = submission?.ticket_number || 'Unknown'; let message = `Your email response has been sent for ticket ${ticketNumber}`; if (variables.newStatus && selectedSubmission && variables.newStatus !== selectedSubmission.status) { message += ` and status updated to ${variables.newStatus.replace('_', ' ')}`; } handleSuccess('Reply Sent', message); setReplyBody(''); setShowReplyForm(false); // Refetch threads if (selectedSubmission) { supabase .from('contact_email_threads') .select('*') .eq('submission_id', selectedSubmission.id) .order('created_at', { ascending: true }) .then(({ data }) => setEmailThreads((data as EmailThread[]) || [])); } queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); }, onError: (error: Error) => { handleError(error, { action: 'Send Email Reply' }); } }); // Update submission status const updateStatusMutation = useMutation({ mutationFn: async ({ id, status, notes, }: { id: string; status: string; notes?: string; }) => { const updateData: Record = { status }; if (notes) { updateData.admin_notes = notes; } if (status === 'resolved' || status === 'closed') { const { data: { user } } = await supabase.auth.getUser(); updateData.resolved_at = new Date().toISOString(); updateData.resolved_by = user?.id; } const { error } = await supabase .from('contact_submissions') .update(updateData) .eq('id', id); if (error) throw error; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Status Updated', 'Contact submission status has been updated'); setSelectedSubmission(null); setAdminNotes(''); }, onError: (error) => { handleError(error, { action: 'update_contact_status' }); }, }); // Archive submission mutation const archiveSubmissionMutation = useMutation({ mutationFn: async (id: string) => { const { data: { user } } = await supabase.auth.getUser(); const { error } = await supabase .from('contact_submissions') .update({ archived_at: new Date().toISOString(), archived_by: user?.id }) .eq('id', id); if (error) throw error; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Archived', 'Contact submission has been archived'); setSelectedSubmission(null); }, onError: (error) => { handleError(error, { action: 'archive_contact_submission' }); } }); // Unarchive submission mutation const unarchiveSubmissionMutation = useMutation({ mutationFn: async (id: string) => { const { error } = await supabase .from('contact_submissions') .update({ archived_at: null, archived_by: null }) .eq('id', id); if (error) throw error; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Restored', 'Contact submission has been restored from archive'); setSelectedSubmission(null); }, onError: (error) => { handleError(error, { action: 'unarchive_contact_submission' }); } }); // Delete submission mutation const deleteSubmissionMutation = useMutation({ mutationFn: async (id: string) => { const { error } = await supabase .from('contact_submissions') .delete() .eq('id', id); if (error) throw error; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); handleSuccess('Deleted', 'Contact submission has been permanently deleted'); setSelectedSubmission(null); }, onError: (error) => { handleError(error, { action: 'delete_contact_submission' }); } }); const handleUpdateStatus = (status: string) => { if (!selectedSubmission) return; updateStatusMutation.mutate({ id: selectedSubmission.id, status, notes: adminNotes || undefined, }); }; const getStatusBadge = (status: string) => { const variants: Record = { pending: 'default', in_progress: 'secondary', resolved: 'outline', closed: 'outline', }; return ( {status.replace('_', ' ').toUpperCase()} ); }; const getCategoryLabel = (category: string) => { const cat = contactCategories.find((c) => c.value === category); return cat?.label || category; }; const handleRefreshSubmissions = () => { queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); }; const handleCopyTicket = (ticketNumber: string) => { navigator.clipboard.writeText(ticketNumber); setCopiedTicket(ticketNumber); setTimeout(() => setCopiedTicket(null), 2000); }; const handleQuickReply = (e: React.MouseEvent, submission: ContactSubmission) => { e.stopPropagation(); setSelectedSubmission(submission); setAdminNotes(submission.admin_notes || ''); setActiveTab('thread'); setShowReplyForm(true); setReplyStatus(submission.status); }; const handleRefreshThreads = () => { if (!selectedSubmission) return; setLoadingThreads(true); supabase .from('contact_email_threads') .select('*') .eq('submission_id', selectedSubmission.id) .order('created_at', { ascending: true }) .then(({ data, error }) => { if (error) { logger.error('Failed to refresh email threads', { error }); handleError(error, { action: 'Refresh Email Threads' }); } else { setEmailThreads((data as EmailThread[]) || []); handleSuccess('Refreshed', 'Email thread updated'); } setLoadingThreads(false); }); }; // Merge tickets mutation const mergeTicketsMutation = useMutation({ mutationFn: async ({ primaryId, mergeIds, reason }: { primaryId: string; mergeIds: string[]; reason?: string; }) => { const { data, error } = await invokeWithTracking( 'merge-contact-tickets', { primaryTicketId: primaryId, mergeTicketIds: mergeIds, mergeReason: reason }, undefined ); if (error) throw error; return data; }, onSuccess: (data: any) => { handleSuccess( 'Tickets Merged Successfully', `Merged ${data.mergedCount} tickets into ${data.primaryTicketNumber}. ${data.threadsConsolidated} email threads consolidated.` ); // Reset merge mode setMergeMode(false); setSelectedForMerge([]); setShowMergeDialog(false); setPrimaryTicketId(null); setMergeReason(''); // Refresh data queryClient.invalidateQueries({ queryKey: ['admin-contact-submissions'] }); }, onError: (error: Error) => { handleError(error, { action: 'Merge Tickets' }); } }); const handleConfirmMerge = () => { if (!primaryTicketId || selectedForMerge.length < 2) return; // Ensure primary is actually in the selected list if (!selectedForMerge.includes(primaryTicketId)) { handleError( new Error('Primary ticket must be one of the selected tickets'), { action: 'Merge Tickets' } ); return; } const mergeIds = selectedForMerge.filter(id => id !== primaryTicketId); // Additional validation: ensure we have tickets to merge if (mergeIds.length === 0) { handleError( new Error('No tickets to merge. Please select at least 2 tickets.'), { action: 'Merge Tickets' } ); return; } mergeTicketsMutation.mutate({ primaryId: primaryTicketId, mergeIds, reason: mergeReason || undefined }); }; // Show loading state while roles are being fetched if (rolesLoading) { return (

Loading...

); } // Admin-only access check (after loading complete) if (!isAdmin()) { return (

Access Denied

Email response features are only available to administrators.

); } // Theme-aware EmailThreadItem component function EmailThreadItem({ thread }: { thread: EmailThread }) { const isOutbound = thread.direction === 'outbound'; return (
{isOutbound ? ( ) : ( )} {isOutbound ? 'Admin Reply' : thread.from_email} {isOutbound && ( Sent )}
{format(new Date(thread.created_at), 'MMM d, yyyy h:mm a')}
{thread.body_text}
); } const stats = { pending: submissions?.filter((s) => s.status === 'pending' && !s.archived_at).length || 0, inProgress: submissions?.filter((s) => s.status === 'in_progress' && !s.archived_at).length || 0, resolved: submissions?.filter((s) => s.status === 'resolved' && !s.archived_at).length || 0, archived: submissions?.filter((s) => s.archived_at !== null).length || 0, total: submissions?.length || 0, }; return ( {/* Header */}

Contact Submissions

Manage and respond to user contact form submissions

{/* Stats Cards */}
Pending {stats.pending} In Progress {stats.inProgress} Resolved {stats.resolved} Archived {stats.archived} Total {stats.total}
{/* Filters */}
{/* Search */}
setSearchQuery(e.target.value)} className="pl-9" />
{/* Status Filter */}
{/* Category Filter */}
{/* Submissions List */} {isLoading ? (

Loading submissions...

) : submissions && submissions.length > 0 ? (
{submissions.map((submission) => ( { if (!mergeMode) { setSelectedSubmission(submission); setAdminNotes(submission.admin_notes || ''); } }} >
{mergeMode && (
{ setSelectedForMerge(prev => checked ? [...prev, submission.id] : prev.filter(id => id !== submission.id) ); }} onClick={(e) => e.stopPropagation()} />
)}

{submission.subject}

{ e.stopPropagation(); handleCopyTicket(submission.ticket_number); }} > {copiedTicket === submission.ticket_number ? ( <> Copied! ) : ( <> {submission.ticket_number} )} {getStatusBadge(submission.status)} {getCategoryLabel(submission.category)} {submission.archived_at && ( Archived )} {submission.merged_ticket_numbers && submission.merged_ticket_numbers.length > 0 && ( Consolidated ({submission.merged_ticket_numbers.length}) )} {submission.response_count > 0 && ( 📧 {submission.response_count} {submission.response_count === 1 ? 'reply' : 'replies'} )}
{submission.name} ({submission.email}) {format(new Date(submission.created_at), 'MMM d, yyyy h:mm a')} {submission.last_admin_response_at && ( Last reply {format(new Date(submission.last_admin_response_at), 'MMM d')} )}

{submission.message}

{!mergeMode && (
)}
))}
) : (

No submissions found

)} {/* Floating Merge Button */} {mergeMode && selectedForMerge.length >= 2 && (
)} {/* Merge Confirmation Dialog */} Merge Contact Tickets Choose a primary ticket to keep. All others will be permanently deleted after their data is merged. {/* List selected tickets with radio buttons */}
{selectedForMerge.map(ticketId => { const ticket = submissions?.find(s => s.id === ticketId); if (!ticket) return null; return (
setPrimaryTicketId(ticketId)} className="mt-1" />
{ticket.ticket_number}
{ticket.subject}
{ticket.email} • {ticket.response_count} admin replies
); })}
{/* Optional merge reason */}