'use client'; import { useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { createClient } from '@/lib/supabase/client'; import { Broadcast, BroadcastRecipient, RecipientStatus } from '@/types'; import { Button } from '@/components/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ArrowLeft, Loader2, Users, Send, CheckCheck, Eye, AlertCircle, MessageCircle, Filter, Download, ChevronDown, Trash2, } from 'lucide-react'; import { toast } from 'sonner'; import { getBroadcastStatus, getRecipientStatus, } from '@/lib/broadcast-status'; interface StatCardProps { label: string; value: number; total: number; icon: React.ReactNode; color: string; } function StatCard({ label, value, total, icon, color }: StatCardProps) { const pct = total > 0 ? Math.round((value / total) * 100) : 0; return (
{icon}
{pct}%

{value.toLocaleString()}

{label}

); } interface FunnelStep { label: string; value: number; color: string; } /** * Pure-CSS funnel chart: decreasing-width rounded bars. * Width is relative to the largest step (typically Sent) so we * always render a full bar at the top and proportional tails. */ function FunnelChart({ steps }: { steps: FunnelStep[] }) { const max = Math.max(...steps.map((s) => s.value), 1); return (

Funnel

{steps.map((step) => { const pctOfMax = Math.max(5, Math.round((step.value / max) * 100)); const pctOfSent = steps[0].value > 0 ? Math.round((step.value / steps[0].value) * 100) : 0; return (
{step.label}
{step.value.toLocaleString()} ({pctOfSent}%)
); })}
); } const RECIPIENT_STATUSES: readonly RecipientStatus[] = [ 'pending', 'sent', 'delivered', 'read', 'replied', 'failed', ]; /** * CSV export helper — RFC 4180 quoting. Quote every field so * commas/newlines/quotes round-trip cleanly. */ function toCsv(rows: string[][]): string { const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; return rows.map((r) => r.map(escape).join(',')).join('\n'); } function downloadBlob(filename: string, content: string) { const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } export default function BroadcastDetailPage() { const params = useParams(); const router = useRouter(); const broadcastId = params.id as string; const [broadcast, setBroadcast] = useState(null); const [recipients, setRecipients] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [statusFilter, setStatusFilter] = useState( 'all', ); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); useEffect(() => { async function fetchData() { try { const supabase = createClient(); const { data: bc, error: bcError } = await supabase .from('broadcasts') .select('*') .eq('id', broadcastId) .single(); if (bcError) throw bcError; setBroadcast(bc); const { data: recs, error: recsError } = await supabase .from('broadcast_recipients') .select('*, contact:contacts(*)') .eq('broadcast_id', broadcastId) .order('created_at', { ascending: false }); if (recsError) throw recsError; setRecipients(recs ?? []); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load broadcast'); } finally { setLoading(false); } } fetchData(); }, [broadcastId]); const filteredRecipients = useMemo( () => statusFilter === 'all' ? recipients : recipients.filter((r) => r.status === statusFilter), [recipients, statusFilter], ); function handleExport() { if (!broadcast) return; const header = [ 'Contact', 'Phone', 'Status', 'Sent At', 'Delivered At', 'Read At', 'Replied At', 'Error', ]; const rows = recipients.map((r) => [ r.contact?.name ?? '', r.contact?.phone ?? '', r.status, r.sent_at ?? '', r.delivered_at ?? '', r.read_at ?? '', r.replied_at ?? '', r.error_message ?? '', ]); const csv = toCsv([header, ...rows]); const safeName = broadcast.name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase(); downloadBlob(`broadcast-${safeName}-${broadcastId.slice(0, 8)}.csv`, csv); } async function handleDelete() { setDeleting(true); const supabase = createClient(); // broadcast_recipients cascades on broadcasts.id (migration 001), so a // single delete is sufficient — the aggregate trigger in migration 003 // is defined on broadcast_recipients but fires only on its own row // changes, not on a cascaded drop of the parent row. const { error: delErr } = await supabase .from('broadcasts') .delete() .eq('id', broadcastId); setDeleting(false); if (delErr) { toast.error(`Failed to delete: ${delErr.message}`); return; } toast.success('Broadcast deleted'); router.push('/broadcasts'); } if (loading) { return (
); } if (error || !broadcast) { return (

{error ?? 'Broadcast not found'}

); } const status = getBroadcastStatus(broadcast.status); const funnelSteps: FunnelStep[] = [ { label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' }, { label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' }, { label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' }, { label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' }, ]; return (
{/* Header */}

{broadcast.name}

{status.label}
Template: {broadcast.template_name} - Created {new Date(broadcast.created_at).toLocaleDateString()}
{/* Delete — inline-confirm pattern matches the pipeline-settings "Delete Pipeline" flow. Mid-send broadcasts can't be deleted because orphaning in-flight Meta messages would leave the funnel inconsistent. */} {confirmDelete ? (
Delete this broadcast?
) : ( )}
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
} color="bg-slate-800 text-slate-300" /> } color="bg-primary/10 text-primary" /> } color="bg-teal-500/10 text-teal-400" /> } color="bg-blue-500/10 text-blue-400" /> } color="bg-indigo-500/10 text-indigo-400" /> } color="bg-red-500/10 text-red-400" />
{/* Recipients Table */}

Recipients ({filteredRecipients.length} {statusFilter !== 'all' ? ` of ${recipients.length}` : ''})

} > {statusFilter === 'all' ? 'All statuses' : getRecipientStatus(statusFilter).label} setStatusFilter('all')} className={ statusFilter === 'all' ? 'text-primary' : 'text-slate-300' } > All statuses {RECIPIENT_STATUSES.map((s) => ( setStatusFilter(s)} className={ statusFilter === s ? 'text-primary' : 'text-slate-300' } > {getRecipientStatus(s).label} ))}
{filteredRecipients.length === 0 ? (

{recipients.length === 0 ? 'No recipients found.' : 'No recipients match this filter.'}

) : (
Contact Phone Status Sent Delivered Read Error {filteredRecipients.map((recipient) => { const rStatus = getRecipientStatus(recipient.status); return ( {recipient.contact?.name ?? 'Unknown'} {recipient.contact?.phone ?? '-'} {rStatus.label} {recipient.sent_at ? new Date(recipient.sent_at).toLocaleString() : '-'} {recipient.delivered_at ? new Date(recipient.delivered_at).toLocaleString() : '-'} {recipient.read_at ? new Date(recipient.read_at).toLocaleString() : '-'} {recipient.error_message ?? '-'} ); })}
)}
); }