mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
936 lines
36 KiB
TypeScript
936 lines
36 KiB
TypeScript
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,
|
|
} 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 { 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 { useTheme } from '@/components/theme/ThemeProvider';
|
|
import { useUserRole } from '@/hooks/useUserRole';
|
|
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;
|
|
}
|
|
|
|
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() {
|
|
const queryClient = useQueryClient();
|
|
const { theme } = useTheme();
|
|
const { isAdmin, loading: rolesLoading } = useUserRole();
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedSubmission, setSelectedSubmission] = useState<ContactSubmission | null>(null);
|
|
const [adminNotes, setAdminNotes] = useState('');
|
|
const [replyBody, setReplyBody] = useState('');
|
|
const [showReplyForm, setShowReplyForm] = useState(false);
|
|
const [emailThreads, setEmailThreads] = useState<EmailThread[]>([]);
|
|
const [loadingThreads, setLoadingThreads] = useState(false);
|
|
const [copiedTicket, setCopiedTicket] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState<string>('details');
|
|
const [replyStatus, setReplyStatus] = useState<string>('');
|
|
|
|
// Fetch contact submissions
|
|
const { data: submissions, isLoading } = useQuery({
|
|
queryKey: ['admin-contact-submissions', statusFilter, categoryFilter, searchQuery],
|
|
queryFn: async () => {
|
|
let query = supabase
|
|
.from('contact_submissions')
|
|
.select('*')
|
|
.order('created_at', { ascending: false });
|
|
|
|
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<string, unknown> = { 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<string, unknown> = { 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' });
|
|
},
|
|
});
|
|
|
|
const handleUpdateStatus = (status: string) => {
|
|
if (!selectedSubmission) return;
|
|
|
|
updateStatusMutation.mutate({
|
|
id: selectedSubmission.id,
|
|
status,
|
|
notes: adminNotes || undefined,
|
|
});
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
pending: 'default',
|
|
in_progress: 'secondary',
|
|
resolved: 'outline',
|
|
closed: 'outline',
|
|
};
|
|
|
|
return (
|
|
<Badge variant={variants[status] || 'default'}>
|
|
{status.replace('_', ' ').toUpperCase()}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
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);
|
|
});
|
|
};
|
|
|
|
// Show loading state while roles are being fetched
|
|
if (rolesLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<p className="text-muted-foreground">Loading...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Admin-only access check (after loading complete)
|
|
if (!isAdmin()) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-screen">
|
|
<Card className="max-w-md">
|
|
<CardContent className="pt-6 text-center">
|
|
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
|
<h2 className="text-xl font-semibold mb-2">Access Denied</h2>
|
|
<p className="text-muted-foreground">
|
|
Email response features are only available to administrators.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Theme-aware EmailThreadItem component
|
|
function EmailThreadItem({ thread }: { thread: EmailThread }) {
|
|
const isOutbound = thread.direction === 'outbound';
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
p-4 mb-3 rounded-lg border transition-all duration-200
|
|
${isOutbound
|
|
? 'bg-primary/5 border-primary/20 dark:bg-primary/10 dark:border-primary/30'
|
|
: 'bg-muted/50 border-border dark:bg-muted/30 dark:border-border/50'
|
|
}
|
|
`}
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
{isOutbound ? (
|
|
<ArrowUpRight className="h-4 w-4 text-primary" />
|
|
) : (
|
|
<ArrowDownLeft className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<span className="font-medium text-sm">
|
|
{isOutbound ? 'Admin Reply' : thread.from_email}
|
|
</span>
|
|
{isOutbound && (
|
|
<Badge variant="secondary" className="text-xs">Sent</Badge>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{format(new Date(thread.created_at), 'MMM d, yyyy h:mm a')}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words">
|
|
{thread.body_text}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const stats = {
|
|
pending: submissions?.filter((s) => s.status === 'pending').length || 0,
|
|
inProgress: submissions?.filter((s) => s.status === 'in_progress').length || 0,
|
|
resolved: submissions?.filter((s) => s.status === 'resolved').length || 0,
|
|
total: submissions?.length || 0,
|
|
};
|
|
|
|
return (
|
|
<AdminLayout>
|
|
{/* Header */}
|
|
<div className="mb-8 flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-4xl font-bold mb-2">Contact Submissions</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage and respond to user contact form submissions
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleRefreshSubmissions}
|
|
disabled={isLoading}
|
|
title="Refresh submissions"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Pending</CardDescription>
|
|
<CardTitle className="text-3xl">{stats.pending}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>In Progress</CardDescription>
|
|
<CardTitle className="text-3xl">{stats.inProgress}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Resolved</CardDescription>
|
|
<CardTitle className="text-3xl">{stats.resolved}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardDescription>Total</CardDescription>
|
|
<CardTitle className="text-3xl">{stats.total}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="mb-6">
|
|
<CardContent className="pt-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{/* Search */}
|
|
<div className="space-y-2">
|
|
<Label>Search</Label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Name, email, or subject..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div className="space-y-2">
|
|
<Label>Status</Label>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Statuses</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
<SelectItem value="resolved">Resolved</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Category Filter */}
|
|
<div className="space-y-2">
|
|
<Label>Category</Label>
|
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Categories</SelectItem>
|
|
{contactCategories.map((cat) => (
|
|
<SelectItem key={cat.value} value={cat.value}>
|
|
{cat.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Submissions List */}
|
|
{isLoading ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-muted-foreground">Loading submissions...</p>
|
|
</div>
|
|
) : submissions && submissions.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{submissions.map((submission) => (
|
|
<Card
|
|
key={submission.id}
|
|
className="cursor-pointer hover:border-primary transition-colors"
|
|
onClick={() => {
|
|
setSelectedSubmission(submission);
|
|
setAdminNotes(submission.admin_notes || '');
|
|
}}
|
|
>
|
|
<CardContent className="p-6">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<h3 className="font-semibold text-lg">{submission.subject}</h3>
|
|
<Badge
|
|
variant="outline"
|
|
className="font-mono cursor-pointer hover:bg-accent transition-colors"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCopyTicket(submission.ticket_number);
|
|
}}
|
|
>
|
|
{copiedTicket === submission.ticket_number ? (
|
|
<><Check className="h-3 w-3 mr-1" /> Copied!</>
|
|
) : (
|
|
<><Copy className="h-3 w-3 mr-1" /> {submission.ticket_number}</>
|
|
)}
|
|
</Badge>
|
|
{getStatusBadge(submission.status)}
|
|
<Badge variant="outline">{getCategoryLabel(submission.category)}</Badge>
|
|
{submission.response_count > 0 && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
📧 {submission.response_count} {submission.response_count === 1 ? 'reply' : 'replies'}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
|
|
<span className="flex items-center gap-1">
|
|
<Mail className="h-3 w-3" />
|
|
{submission.name} ({submission.email})
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{format(new Date(submission.created_at), 'MMM d, yyyy h:mm a')}
|
|
</span>
|
|
{submission.last_admin_response_at && (
|
|
<span className="flex items-center gap-1 text-primary">
|
|
<ArrowUpRight className="h-3 w-3" />
|
|
Last reply {format(new Date(submission.last_admin_response_at), 'MMM d')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm line-clamp-2">{submission.message}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={(e) => handleQuickReply(e, submission)}
|
|
disabled={sendReplyMutation.isPending}
|
|
className="flex items-center gap-1"
|
|
>
|
|
<Reply className="h-4 w-4" />
|
|
Reply
|
|
</Button>
|
|
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="py-12">
|
|
<div className="text-center">
|
|
<Mail className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
|
<p className="text-muted-foreground">No submissions found</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Submission Detail Dialog */}
|
|
<Dialog
|
|
open={!!selectedSubmission}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setSelectedSubmission(null);
|
|
setAdminNotes('');
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
|
{selectedSubmission && (
|
|
<>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 flex-wrap">
|
|
<Mail className="h-5 w-5" />
|
|
{selectedSubmission.subject}
|
|
<Badge
|
|
variant="outline"
|
|
className="font-mono cursor-pointer hover:bg-accent transition-colors"
|
|
onClick={() => handleCopyTicket(selectedSubmission.ticket_number)}
|
|
>
|
|
{copiedTicket === selectedSubmission.ticket_number ? (
|
|
<><Check className="h-3 w-3 mr-1" /> Copied!</>
|
|
) : (
|
|
<><Copy className="h-3 w-3 mr-1" /> {selectedSubmission.ticket_number}</>
|
|
)}
|
|
</Badge>
|
|
{selectedSubmission.response_count > 0 && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
📧 {selectedSubmission.response_count} {selectedSubmission.response_count === 1 ? 'reply' : 'replies'}
|
|
</Badge>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Submitted {format(new Date(selectedSubmission.created_at), 'PPpp')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 overflow-hidden flex flex-col">
|
|
<TabsList className="w-full justify-start">
|
|
<TabsTrigger value="details" className="flex-1">
|
|
Details
|
|
</TabsTrigger>
|
|
<TabsTrigger value="thread" className="flex-1 flex items-center gap-2">
|
|
Email Thread ({emailThreads.length})
|
|
</TabsTrigger>
|
|
{activeTab === 'thread' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRefreshThreads}
|
|
disabled={loadingThreads}
|
|
className="ml-auto"
|
|
>
|
|
<RefreshCw className={`h-3 w-3 ${loadingThreads ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
)}
|
|
</TabsList>
|
|
|
|
<TabsContent value="details" className="flex-1 overflow-y-auto">
|
|
<div className="space-y-4 p-4">
|
|
{/* Sender Info */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>Name</Label>
|
|
<p className="mt-1">{selectedSubmission.name}</p>
|
|
</div>
|
|
<div>
|
|
<Label>Email</Label>
|
|
<p className="mt-1">{selectedSubmission.email}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Context Section */}
|
|
{selectedSubmission.submitter_profile_data && (
|
|
<div className="border rounded-lg p-4 bg-muted/30">
|
|
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
|
<User className="h-4 w-4" />
|
|
Submitter Context
|
|
</h4>
|
|
<div className="flex items-start gap-4">
|
|
<Avatar className="h-12 w-12">
|
|
{selectedSubmission.submitter_profile_data.avatar_url && (
|
|
<AvatarImage src={selectedSubmission.submitter_profile_data.avatar_url} />
|
|
)}
|
|
<AvatarFallback>
|
|
{selectedSubmission.submitter_username?.[0]?.toUpperCase() || 'U'}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-medium">
|
|
@{selectedSubmission.submitter_username}
|
|
</span>
|
|
{selectedSubmission.submitter_profile_data.display_name && (
|
|
<span className="text-muted-foreground">
|
|
({selectedSubmission.submitter_profile_data.display_name})
|
|
</span>
|
|
)}
|
|
<Badge variant="secondary" className="gap-1">
|
|
<Award className="h-3 w-3" />
|
|
{selectedSubmission.submitter_reputation} rep
|
|
</Badge>
|
|
</div>
|
|
{selectedSubmission.submitter_profile_data.member_since && (
|
|
<div className="text-sm text-muted-foreground">
|
|
Member since {format(new Date(selectedSubmission.submitter_profile_data.member_since), 'MMM d, yyyy')}
|
|
</div>
|
|
)}
|
|
{selectedSubmission.submitter_profile_data.stats && (
|
|
<div className="flex items-center gap-3 text-sm flex-wrap">
|
|
<span className="flex items-center gap-1">
|
|
<TrendingUp className="h-3 w-3" />
|
|
{selectedSubmission.submitter_profile_data.stats.rides} rides
|
|
</span>
|
|
<span>•</span>
|
|
<span>{selectedSubmission.submitter_profile_data.stats.coasters} coasters</span>
|
|
<span>•</span>
|
|
<span>{selectedSubmission.submitter_profile_data.stats.parks} parks</span>
|
|
<span>•</span>
|
|
<span>{selectedSubmission.submitter_profile_data.stats.reviews} reviews</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Subject & Category */}
|
|
<div className="space-y-2">
|
|
<Label>Category</Label>
|
|
<div className="mt-1">
|
|
<Badge>{getCategoryLabel(selectedSubmission.category)}</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message */}
|
|
<div>
|
|
<Label>Message</Label>
|
|
<ScrollArea className="h-40 mt-1 p-3 rounded-md border bg-muted/30">
|
|
<p className="whitespace-pre-wrap text-sm">{selectedSubmission.message}</p>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Admin Notes */}
|
|
<div>
|
|
<Label htmlFor="admin-notes">Admin Notes (Internal)</Label>
|
|
<Textarea
|
|
id="admin-notes"
|
|
value={adminNotes}
|
|
onChange={(e) => setAdminNotes(e.target.value)}
|
|
placeholder="Add internal notes about this submission..."
|
|
rows={4}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
{/* Status Update */}
|
|
<div className="flex items-center gap-4 pt-4 border-t">
|
|
<div className="flex-1">
|
|
<Label>Status</Label>
|
|
<Select
|
|
value={selectedSubmission.status}
|
|
onValueChange={(value) => {
|
|
handleUpdateStatus(value as 'pending' | 'in_progress' | 'resolved' | 'closed');
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
<SelectItem value="resolved">Resolved</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={() => handleUpdateStatus(selectedSubmission.status)}
|
|
disabled={updateStatusMutation.isPending}
|
|
>
|
|
{updateStatusMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
Save Notes & Status
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="thread" className="flex-1 overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
{loadingThreads ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : emailThreads.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<Mail className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
|
<p>No email thread yet. Send the first reply below.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{emailThreads.map(thread => (
|
|
<EmailThreadItem key={thread.id} thread={thread} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reply Form */}
|
|
<div className="border-t p-4 bg-background">
|
|
{!showReplyForm ? (
|
|
<Button
|
|
onClick={() => setShowReplyForm(true)}
|
|
className="w-full"
|
|
variant="default"
|
|
>
|
|
<Send className="mr-2 h-4 w-4" />
|
|
Reply via Email
|
|
</Button>
|
|
) : (
|
|
<Card className="border-primary/20">
|
|
<CardContent className="pt-4">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<Label className="text-sm font-medium">
|
|
Reply to {selectedSubmission.name} ({selectedSubmission.email})
|
|
</Label>
|
|
<Badge variant="outline" className="font-mono">
|
|
{selectedSubmission.ticket_number}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mb-3">
|
|
Subject: Re: [{selectedSubmission.ticket_number}] {selectedSubmission.subject}
|
|
</div>
|
|
<Textarea
|
|
value={replyBody}
|
|
onChange={(e) => setReplyBody(e.target.value)}
|
|
placeholder="Type your response here... (Min 10 characters)"
|
|
rows={6}
|
|
className="mb-3 resize-none"
|
|
/>
|
|
|
|
{/* Status Update Dropdown */}
|
|
<div className="mb-3">
|
|
<Label className="text-sm mb-2 block">Update Status (Optional)</Label>
|
|
<Select value={replyStatus} onValueChange={setReplyStatus}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
<SelectItem value="resolved">Resolved</SelectItem>
|
|
<SelectItem value="closed">Closed</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{replyStatus !== selectedSubmission.status && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Status will be updated to <span className="font-medium">{replyStatus.replace('_', ' ')}</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
onClick={() => sendReplyMutation.mutate({
|
|
submissionId: selectedSubmission.id,
|
|
body: replyBody,
|
|
newStatus: replyStatus
|
|
})}
|
|
disabled={sendReplyMutation.isPending || replyBody.length < 10}
|
|
className="flex-1"
|
|
>
|
|
{sendReplyMutation.isPending ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="mr-2 h-4 w-4" />
|
|
)}
|
|
Send Reply
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowReplyForm(false);
|
|
setReplyBody('');
|
|
}}
|
|
disabled={sendReplyMutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</AdminLayout>
|
|
);
|
|
}
|