'use client'; import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { formatDistanceToNow, format } from 'date-fns'; import { PageHeader } from '@/components/shared/page-header'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; interface SendRow { id: string; portId: string; recipientEmail: string; documentKind: 'berth_pdf' | 'brochure' | string; fromAddress: string; bodyMarkdown: string | null; sentAt: string; failedAt: string | null; errorReason: string | null; fallbackToLinkReason: string | null; messageId: string | null; berthId: string | null; brochureId: string | null; clientId: string | null; interestId: string | null; /** Phase 6 — populated by the IMAP bounce poller when a delivery * failure for this send was matched in the configured mailbox. */ bounceStatus: 'hard' | 'soft' | 'ooo' | null; bounceReason: string | null; bounceDetectedAt: string | null; /** Phase 4b email-open tracking. `openCount` is denormalised on every * pixel hit; `firstOpenedAt` stamps the first time the recipient * loaded the email. Both stay 0 / null when `trackOpens` is off. */ trackOpens: boolean; openCount: number; firstOpenedAt: string | null; } interface ListResponse { data: SendRow[]; pagination: { nextCursor: { sentAt: string; id: string } | null }; counts: { sent: number; failed: number; all: number }; } export function SendsLog() { const [status, setStatus] = useState<'all' | 'sent' | 'failed'>('all'); const [expanded, setExpanded] = useState(null); const { data, isLoading, error } = useQuery({ queryKey: ['document-sends', status], queryFn: () => apiFetch(`/api/v1/admin/document-sends?status=${status}`), }); const counts = data?.counts ?? { sent: 0, failed: 0, all: 0 }; const rows = data?.data ?? []; return (
setStatus('all')} /> setStatus('sent')} /> setStatus('failed')} accent={counts.failed > 0 ? 'danger' : undefined} />
{isLoading ? (

Loading…

) : error ? (

Failed to load sends: {error instanceof Error ? error.message : 'unknown error'}

) : rows.length === 0 ? ( No sends recorded for this filter yet. ) : (
{rows.map((row) => { const sent = new Date(row.sentAt); const ago = formatDistanceToNow(sent, { addSuffix: true }); const isOpen = expanded === row.id; const failed = !!row.failedAt; return (
{failed ? 'Failed' : 'Sent'} {row.documentKind === 'berth_pdf' ? 'Berth PDF' : row.documentKind === 'brochure' ? 'Brochure' : row.documentKind} {row.fallbackToLinkReason ? ( Switched to download link ) : null} {row.bounceStatus ? ( {row.bounceStatus === 'hard' ? 'Hard bounce' : row.bounceStatus === 'soft' ? 'Soft bounce' : 'Out of office'} ) : null} {row.trackOpens ? ( row.openCount > 0 ? ( Opened {row.openCount > 1 ? `× ${row.openCount}` : ''} ) : ( Not opened ) ) : null} {ago} · {format(sent, 'PP p')}
{row.recipientEmail}
From {row.fromAddress} {row.messageId ? ( {row.messageId} ) : null}
{failed && row.errorReason ? (
{row.errorReason}
) : null} {row.fallbackToLinkReason ? (
Attachment dropped → sent as link. Reason: {row.fallbackToLinkReason}
) : null} {row.bounceStatus && row.bounceReason ? (
Bounced {row.bounceDetectedAt ? ` ${formatDistanceToNow(new Date(row.bounceDetectedAt), { addSuffix: true, })}` : ''} : {row.bounceReason}
) : null}
{row.bodyMarkdown ? ( ) : null}
{isOpen && row.bodyMarkdown ? (
                        {row.bodyMarkdown}
                      
) : null}
); })}
)}
); } function FilterChip({ label, active, onClick, accent, }: { label: string; active: boolean; onClick: () => void; accent?: 'danger'; }) { const base = active ? 'bg-primary text-primary-foreground border-primary' : 'bg-background text-foreground border-border hover:bg-muted'; const dangerActive = accent === 'danger' && active ? 'bg-red-600 text-white border-red-600' : null; return ( ); }