add campaign observability page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
This commit is contained in:
253
src/pages/Campaigns.tsx
Normal file
253
src/pages/Campaigns.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Clock, Megaphone, RefreshCw, RotateCcw, Send, XCircle } from 'lucide-react';
|
||||
import type { CampaignGroup, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CampaignStatus } from '../types';
|
||||
import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService';
|
||||
|
||||
const statusLabels: Record<CampaignStatus, string> = {
|
||||
pending: 'Pendente',
|
||||
processing: 'Processando',
|
||||
sent: 'Enviada',
|
||||
failed: 'Falhou',
|
||||
skipped: 'Ignorada'
|
||||
};
|
||||
|
||||
const statusStyles: Record<CampaignStatus, string> = {
|
||||
pending: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
||||
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
sent: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
||||
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
|
||||
skipped: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
|
||||
};
|
||||
|
||||
const statusIcons: Record<CampaignStatus, typeof Clock> = {
|
||||
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 Campaigns = () => {
|
||||
const [summary, setSummary] = useState<CampaignQueueSummary | null>(null);
|
||||
const [preview, setPreview] = useState<CampaignPreview | null>(null);
|
||||
const [processResult, setProcessResult] = useState<CampaignProcessSummary | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
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<CampaignStatus, number> = {
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
skipped: 0
|
||||
};
|
||||
summary?.groups.forEach(group => {
|
||||
counts[group.status] += 1;
|
||||
});
|
||||
return counts;
|
||||
}, [summary]);
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-dark-text">Campanhas</h1>
|
||||
<p className="text-dark-muted font-medium">Fila de reposição, prévia de envio e histórico das campanhas do WhatsApp.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={() => void loadCampaigns()}
|
||||
disabled={isLoading || isProcessing}
|
||||
className="inline-flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl hover:border-brand-primary transition-colors text-sm font-medium text-dark-text disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
|
||||
Atualizar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProcessNow}
|
||||
disabled={isProcessing || !preview?.readyProducts.length}
|
||||
className="inline-flex items-center gap-2 bg-brand-primary text-black px-4 py-2.5 rounded-xl hover:opacity-90 transition-opacity text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<Send size={16} />
|
||||
Processar agora
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{processResult && (
|
||||
<div className="bg-dark-card border border-dark-border rounded-2xl p-4 text-sm text-dark-muted">
|
||||
Resultado: {processResult.claimed} itens processados, {processResult.sentGroups} grupos enviados, {processResult.failedGroups} falhas, {processResult.pendingBelowThresholdGroups} abaixo do limite.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{Object.entries(groupedCounts).map(([status, count]) => {
|
||||
const typedStatus = status as CampaignStatus;
|
||||
const Icon = statusIcons[typedStatus];
|
||||
return (
|
||||
<div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{statusLabels[typedStatus]}</span>
|
||||
<Icon className="w-4 h-4 text-brand-primary" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-dark-text">{count}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Megaphone className="w-5 h-5 text-brand-primary" />
|
||||
<h2 className="text-lg font-bold text-dark-text">Prévia do próximo envio</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Produtos prontos</p>
|
||||
<p className="text-dark-text font-semibold">{preview?.productsText || 'Nenhum produto atingiu o limite ainda.'}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-dark-input rounded-xl p-3 border border-dark-border">
|
||||
<p className="text-xs text-dark-muted mb-1">Clientes alvo</p>
|
||||
<p className="text-xl font-bold text-dark-text">{preview?.customerCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="bg-dark-input rounded-xl p-3 border border-dark-border">
|
||||
<p className="text-xs text-dark-muted mb-1">Limite por produto</p>
|
||||
<p className="text-xl font-bold text-dark-text">{summary?.threshold ?? preview?.threshold ?? 100}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(preview?.readyProducts || []).map(product => (
|
||||
<div key={product.baseProduct} className="flex justify-between gap-4 border border-emerald-500/20 bg-emerald-500/5 rounded-xl p-3">
|
||||
<span className="font-semibold text-dark-text">{product.baseProduct}</span>
|
||||
<span className="text-emerald-400 font-bold">{formatDelta(product.total_delta)}</span>
|
||||
</div>
|
||||
))}
|
||||
{(preview?.belowThresholdProducts || []).map(product => (
|
||||
<div key={product.baseProduct} className="flex justify-between gap-4 border border-dark-border bg-dark-input rounded-xl p-3">
|
||||
<span className="font-semibold text-dark-muted">{product.baseProduct}</span>
|
||||
<span className="text-yellow-400 font-bold">{formatDelta(product.total_delta)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
|
||||
<h2 className="text-lg font-bold text-dark-text mb-4">Top clientes da campanha</h2>
|
||||
<div className="space-y-2">
|
||||
{(preview?.customersPreview || []).map(customer => (
|
||||
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 border border-dark-border rounded-xl p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-dark-text truncate">{customer.nome}</p>
|
||||
<p className="text-xs text-dark-muted">{customer.fone}</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
|
||||
</div>
|
||||
))}
|
||||
{!preview?.customersPreview.length && <p className="text-dark-muted text-sm">Nenhum cliente com telefone válido encontrado.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-dark-header border-b border-dark-border text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Produto</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Delta</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Itens</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Tentativas</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Atualizado</th>
|
||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{(summary?.groups || []).map(group => {
|
||||
const Icon = statusIcons[group.status];
|
||||
return (
|
||||
<tr key={group.key} className="hover:bg-dark-input/40 transition-colors">
|
||||
<td className="px-6 py-3">
|
||||
<p className="font-semibold text-dark-text">{group.baseProductName}</p>
|
||||
{group.lastError && <p className="text-xs text-red-400 mt-1 max-w-md truncate">{group.lastError}</p>}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 border px-2.5 py-1 rounded-full text-xs font-bold ${statusStyles[group.status]}`}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{statusLabels[group.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 font-bold text-dark-text">{formatDelta(group.totalDelta)}</td>
|
||||
<td className="px-6 py-3 text-dark-muted">{group.rowCount}</td>
|
||||
<td className="px-6 py-3 text-dark-muted">{group.attempts}</td>
|
||||
<td className="px-6 py-3 text-dark-muted whitespace-nowrap">{formatDate(group.updatedAt)}</td>
|
||||
<td className="px-6 py-3 text-right">
|
||||
{(group.status === 'failed' || group.status === 'skipped') && (
|
||||
<button
|
||||
onClick={() => void handleRetry(group)}
|
||||
disabled={isProcessing}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-brand-primary hover:opacity-80 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reprocessar
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!summary?.groups.length && !isLoading && (
|
||||
<div className="p-8 text-center text-dark-muted">Nenhuma campanha registrada ainda.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Campaigns;
|
||||
Reference in New Issue
Block a user