import { useEffect, useMemo, useState } from 'react'; import { AlertTriangle, CheckCircle2, Clock, Megaphone, RefreshCw, RotateCcw, Send, Users, XCircle } from 'lucide-react'; import RefreshStatus from '../components/RefreshStatus'; import type { CampaignGroup, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CampaignStatus } from '../types'; import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService'; const statusLabels: Record = { pending: 'Pendente', processing: 'Processando', sent: 'Enviada', failed: 'Falhou', skipped: 'Ignorada' }; type StatusVisual = { accent: string; bg: string; border: string; text: string; }; type PreviewFilter = 'all' | 'ready' | 'near' | 'below'; const statusStyles: Record = { pending: { accent: '#FFC247', bg: 'rgba(255, 194, 71, 0.10)', border: 'rgba(255, 194, 71, 0.28)', text: '#FFC247' }, processing: { accent: '#25C2FF', bg: 'rgba(37, 194, 255, 0.10)', border: 'rgba(37, 194, 255, 0.28)', text: '#25C2FF' }, sent: { accent: '#52DFA0', bg: 'rgba(82, 223, 160, 0.10)', border: 'rgba(82, 223, 160, 0.28)', text: '#52DFA0' }, failed: { accent: '#FF7A9B', bg: 'rgba(255, 122, 155, 0.11)', border: 'rgba(255, 122, 155, 0.32)', text: '#FF7A9B' }, skipped: { accent: '#8c9298', bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' } }; const statusIcons: Record = { pending: Clock, processing: RefreshCw, sent: CheckCircle2, failed: XCircle, skipped: AlertTriangle }; const formatDate = (value?: string | null) => { if (!value) return '-'; return new Date(value).toLocaleString('pt-BR'); }; const formatDelta = (value: number) => `${value} un.`; const NEAR_THRESHOLD_PERCENT = 80; const statusPillStyle = (status: CampaignStatus) => { const style = statusStyles[status]; return { backgroundColor: style.bg, borderColor: style.border, color: style.text }; }; const CampaignsSkeleton = () => (
{[0, 1, 2, 3, 4].map(item => (
))}
{[0, 1].map(section => (
{[0, 1, 2, 3].map(item => (
))}
))}
{[0, 1, 2, 3, 4, 5, 6].map(item => (
))}
{[0, 1, 2, 3, 4, 5].map(row => (
{[0, 1, 2, 3, 4, 5, 6].map(column => (
))}
))}
); const Campaigns = () => { const [summary, setSummary] = useState(null); const [preview, setPreview] = useState(null); const [processResult, setProcessResult] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isProcessing, setIsProcessing] = useState(false); const [previewFilter, setPreviewFilter] = useState('all'); const loadCampaigns = async () => { setIsLoading(true); const [campaignsData, previewData] = await Promise.all([ fetchCampaigns(), fetchCampaignPreview() ]); setSummary(campaignsData); setPreview(previewData); setIsLoading(false); }; useEffect(() => { // Campaign state is loaded from the backend after the protected route mounts. // eslint-disable-next-line react-hooks/set-state-in-effect void loadCampaigns(); }, []); const groupedCounts = useMemo(() => { const counts: Record = { pending: 0, processing: 0, sent: 0, failed: 0, skipped: 0 }; summary?.groups.forEach(group => { counts[group.status] += 1; }); return counts; }, [summary]); const threshold = summary?.threshold ?? preview?.threshold ?? 100; const previewProducts = useMemo(() => { const ready = (preview?.readyProducts || []).map(product => ({ ...product, isReady: true })); const below = (preview?.belowThresholdProducts || []).map(product => ({ ...product, isReady: false })); return [...ready, ...below].sort((a, b) => Number(b.isReady) - Number(a.isReady) || b.total_delta - a.total_delta); }, [preview]); const previewProductRows = useMemo(() => { return previewProducts.map(product => { const progress = threshold ? Math.min(100, (product.total_delta / threshold) * 100) : 0; return { ...product, progress, isNear: !product.isReady && progress >= NEAR_THRESHOLD_PERCENT }; }); }, [previewProducts, threshold]); const filteredPreviewProducts = useMemo(() => { switch (previewFilter) { case 'ready': return previewProductRows.filter(product => product.isReady); case 'near': return previewProductRows.filter(product => product.isNear); case 'below': return previewProductRows.filter(product => !product.isReady); default: return previewProductRows; } }, [previewFilter, previewProductRows]); const previewFilterOptions = useMemo(() => [ { key: 'all' as const, label: 'Todos', count: previewProductRows.length }, { key: 'ready' as const, label: 'Prontos', count: previewProductRows.filter(product => product.isReady).length }, { key: 'near' as const, label: 'Quase prontos', count: previewProductRows.filter(product => product.isNear).length }, { key: 'below' as const, label: 'Abaixo', count: previewProductRows.filter(product => !product.isReady).length } ], [previewProductRows]); const resultStats = processResult ? [ { label: 'Processados', value: processResult.claimed, color: '#25C2FF' }, { label: 'Enviados', value: processResult.sentGroups, color: '#52DFA0' }, { label: 'Falhas', value: processResult.failedGroups, color: '#FF7A9B' }, { label: 'Abaixo do limite', value: processResult.pendingBelowThresholdGroups, color: '#FFC247' } ] : []; const statusSummary = (Object.keys(statusLabels) as CampaignStatus[]).map(status => ({ status, label: statusLabels[status], count: groupedCounts[status], Icon: statusIcons[status], style: statusStyles[status] })); const shouldShowSkeleton = isLoading && !summary && !preview; const isRefreshing = isLoading && Boolean(summary || preview); const handleProcessNow = async () => { setIsProcessing(true); const result = await processCampaignsNow(); setProcessResult(result); await loadCampaigns(); setIsProcessing(false); }; const handleRetry = async (group: CampaignGroup) => { setIsProcessing(true); await retryCampaignGroup(group.baseProductName); await loadCampaigns(); setIsProcessing(false); }; return (

Campanhas

Fila de reposição, prévia de envio e histórico das campanhas do WhatsApp.

{processResult && (
{resultStats.map(item => (
{item.label} {item.value}
))}
)} {shouldShowSkeleton ? ( ) : (
{statusSummary.map(({ status, label, count, Icon, style }) => { return (
{label}

{count}

); })}

Prévia do próximo envio

Produtos prontos

{preview?.productsText || 'Nenhum produto atingiu o limite ainda.'}

Clientes alvo

{preview?.customerCount ?? 0}

Limite por produto

{summary?.threshold ?? preview?.threshold ?? 100}

{previewFilterOptions.map(option => ( ))}
{filteredPreviewProducts.map(product => { const accent = product.isReady ? statusStyles.sent.accent : statusStyles.pending.accent; return (

{product.baseProduct}

{formatDelta(product.total_delta)} de {formatDelta(threshold)}

{product.isReady ? 'Pronto' : `${Math.round(product.progress)}%`}
); })} {!filteredPreviewProducts.length && (
Nenhum produto nesta visualização.
)}

Top clientes da campanha

{(preview?.customersPreview || []).map((customer, index) => (
{index + 1}

{customer.nome}

{customer.fone}

{customer.total_comprado || 0} un.
))} {!preview?.customersPreview.length && (
Nenhum cliente com telefone válido encontrado.
)}
{(summary?.groups || []).map(group => { const Icon = statusIcons[group.status]; return ( ); })}
Produto Status Delta Itens Tentativas Atualizado Ações

{group.baseProductName}

{group.lastError &&

{group.lastError}

}
{statusLabels[group.status]} {formatDelta(group.totalDelta)} {group.rowCount} {group.attempts} {formatDate(group.updatedAt)} {(group.status === 'failed' || group.status === 'skipped') && ( )}
{!summary?.groups.length && !isLoading && (
Nenhuma campanha registrada ainda.
)}
)}
); }; export default Campaigns;