Polish campaigns page UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, CheckCircle2, Clock, Megaphone, RefreshCw, RotateCcw, Send, XCircle } from 'lucide-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 type { CampaignGroup, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CampaignStatus } from '../types';
|
||||||
import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService';
|
import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService';
|
||||||
|
|
||||||
@@ -11,12 +12,19 @@ const statusLabels: Record<CampaignStatus, string> = {
|
|||||||
skipped: 'Ignorada'
|
skipped: 'Ignorada'
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusStyles: Record<CampaignStatus, string> = {
|
type StatusVisual = {
|
||||||
pending: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
|
accent: string;
|
||||||
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
bg: string;
|
||||||
sent: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
|
border: string;
|
||||||
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
|
text: string;
|
||||||
skipped: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
|
};
|
||||||
|
|
||||||
|
const statusStyles: Record<CampaignStatus, StatusVisual> = {
|
||||||
|
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<CampaignStatus, typeof Clock> = {
|
const statusIcons: Record<CampaignStatus, typeof Clock> = {
|
||||||
@@ -34,6 +42,66 @@ const formatDate = (value?: string | null) => {
|
|||||||
|
|
||||||
const formatDelta = (value: number) => `${value} un.`;
|
const formatDelta = (value: number) => `${value} un.`;
|
||||||
|
|
||||||
|
const statusPillStyle = (status: CampaignStatus) => {
|
||||||
|
const style = statusStyles[status];
|
||||||
|
return {
|
||||||
|
backgroundColor: style.bg,
|
||||||
|
borderColor: style.border,
|
||||||
|
color: style.text
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const CampaignsSkeleton = () => (
|
||||||
|
<div className="space-y-6" aria-label="Carregando campanhas">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
{[0, 1, 2, 3, 4].map(item => (
|
||||||
|
<div key={`campaign-kpi-skeleton-${item}`} className="bg-dark-card border border-dark-border rounded-2xl p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="skeleton h-3 w-24" />
|
||||||
|
<div className="skeleton h-4 w-4 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="skeleton mt-4 h-7 w-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
{[0, 1].map(section => (
|
||||||
|
<div key={`campaign-card-skeleton-${section}`} className="bg-dark-card border border-dark-border rounded-2xl p-6">
|
||||||
|
<div className="skeleton h-5 w-48" />
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{[0, 1, 2, 3].map(item => (
|
||||||
|
<div key={`campaign-card-row-skeleton-${section}-${item}`} className="rounded-xl border border-dark-border p-3">
|
||||||
|
<div className="skeleton h-4 w-3/5" />
|
||||||
|
<div className="mt-3 skeleton h-2 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden">
|
||||||
|
<div className="border-b border-dark-border p-4">
|
||||||
|
<div className="grid grid-cols-[1.5fr_130px_90px_80px_90px_150px_110px] gap-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(item => (
|
||||||
|
<div key={`campaign-table-head-skeleton-${item}`} className="skeleton h-3" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||||
|
<div key={`campaign-table-row-skeleton-${row}`} className="grid grid-cols-[1.5fr_130px_90px_80px_90px_150px_110px] gap-5">
|
||||||
|
{[0, 1, 2, 3, 4, 5, 6].map(column => (
|
||||||
|
<div key={`campaign-table-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const Campaigns = () => {
|
const Campaigns = () => {
|
||||||
const [summary, setSummary] = useState<CampaignQueueSummary | null>(null);
|
const [summary, setSummary] = useState<CampaignQueueSummary | null>(null);
|
||||||
const [preview, setPreview] = useState<CampaignPreview | null>(null);
|
const [preview, setPreview] = useState<CampaignPreview | null>(null);
|
||||||
@@ -72,6 +140,31 @@ const Campaigns = () => {
|
|||||||
return counts;
|
return counts;
|
||||||
}, [summary]);
|
}, [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 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 () => {
|
const handleProcessNow = async () => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
const result = await processCampaignsNow();
|
const result = await processCampaignsNow();
|
||||||
@@ -114,21 +207,35 @@ const Campaigns = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RefreshStatus isRefreshing={isRefreshing || isProcessing} label={isProcessing ? 'Processando campanhas' : 'Atualizando campanhas'} />
|
||||||
|
|
||||||
{processResult && (
|
{processResult && (
|
||||||
<div className="bg-dark-card border border-dark-border rounded-2xl p-4 text-sm text-dark-muted">
|
<div className="bg-dark-card border border-dark-border rounded-2xl p-3">
|
||||||
Resultado: {processResult.claimed} itens processados, {processResult.sentGroups} grupos enviados, {processResult.failedGroups} falhas, {processResult.pendingBelowThresholdGroups} abaixo do limite.
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{resultStats.map(item => (
|
||||||
|
<div key={item.label} className="flex items-center justify-between gap-3 rounded-xl bg-dark-input/55 px-3 py-2">
|
||||||
|
<span className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-dark-muted">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-dark-text">{item.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{shouldShowSkeleton ? (
|
||||||
|
<CampaignsSkeleton />
|
||||||
|
) : (
|
||||||
|
<div className={isRefreshing || isProcessing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing || isProcessing}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
{Object.entries(groupedCounts).map(([status, count]) => {
|
{statusSummary.map(({ status, label, count, Icon, style }) => {
|
||||||
const typedStatus = status as CampaignStatus;
|
|
||||||
const Icon = statusIcons[typedStatus];
|
|
||||||
return (
|
return (
|
||||||
<div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4">
|
<div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{statusLabels[typedStatus]}</span>
|
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{label}</span>
|
||||||
<Icon className="w-4 h-4 text-brand-primary" />
|
<Icon className="w-4 h-4" style={{ color: style.accent }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-dark-text">{count}</p>
|
<p className="text-2xl font-bold text-dark-text">{count}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,35 +265,67 @@ const Campaigns = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{(preview?.readyProducts || []).map(product => (
|
{previewProducts.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">
|
const progress = threshold ? Math.min(100, (product.total_delta / threshold) * 100) : 0;
|
||||||
<span className="font-semibold text-dark-text">{product.baseProduct}</span>
|
const accent = product.isReady ? statusStyles.sent.accent : statusStyles.pending.accent;
|
||||||
<span className="text-emerald-400 font-bold">{formatDelta(product.total_delta)}</span>
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={product.baseProduct}
|
||||||
|
className="rounded-xl border border-dark-border bg-dark-input/45 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className={`truncate text-sm font-bold ${product.isReady ? 'text-dark-text' : 'text-dark-muted'}`}>
|
||||||
|
{product.baseProduct}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] font-semibold text-dark-muted">
|
||||||
|
{formatDelta(product.total_delta)} de {formatDelta(threshold)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="rounded-full border px-2.5 py-1 text-[11px] font-bold"
|
||||||
|
style={product.isReady ? statusPillStyle('sent') : statusPillStyle('pending')}
|
||||||
|
>
|
||||||
|
{product.isReady ? 'Pronto' : `${Math.round(progress)}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-dark-border/70">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${progress}%`, backgroundColor: accent }} />
|
||||||
|
</div>
|
||||||
</div>
|
</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">
|
{!previewProducts.length && (
|
||||||
<span className="font-semibold text-dark-muted">{product.baseProduct}</span>
|
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-4 text-sm font-semibold text-dark-muted">
|
||||||
<span className="text-yellow-400 font-bold">{formatDelta(product.total_delta)}</span>
|
Nenhum produto para campanha no momento.
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
|
<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="mb-4 flex items-center gap-2">
|
||||||
<div className="space-y-2">
|
<Users className="w-5 h-5 text-brand-primary" />
|
||||||
{(preview?.customersPreview || []).map(customer => (
|
<h2 className="text-lg font-bold text-dark-text">Top clientes da campanha</h2>
|
||||||
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 border border-dark-border rounded-xl p-3">
|
</div>
|
||||||
|
<div className="divide-y divide-dark-border rounded-xl border border-dark-border overflow-hidden">
|
||||||
|
{(preview?.customersPreview || []).map((customer, index) => (
|
||||||
|
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 px-4 py-3">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-dark-input text-xs font-bold text-dark-muted">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-semibold text-dark-text truncate">{customer.nome}</p>
|
<p className="font-semibold text-dark-text truncate">{customer.nome}</p>
|
||||||
<p className="text-xs text-dark-muted">{customer.fone}</p>
|
<p className="text-xs text-dark-muted">{customer.fone}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
|
<span className="ml-auto text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!preview?.customersPreview.length && <p className="text-dark-muted text-sm">Nenhum cliente com telefone válido encontrado.</p>}
|
{!preview?.customersPreview.length && (
|
||||||
|
<div className="p-4 text-sm font-semibold text-dark-muted">Nenhum cliente com telefone válido encontrado.</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +354,10 @@ const Campaigns = () => {
|
|||||||
{group.lastError && <p className="text-xs text-red-400 mt-1 max-w-md truncate">{group.lastError}</p>}
|
{group.lastError && <p className="text-xs text-red-400 mt-1 max-w-md truncate">{group.lastError}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3">
|
<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]}`}>
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 border px-2.5 py-1 rounded-full text-xs font-bold"
|
||||||
|
style={statusPillStyle(group.status)}
|
||||||
|
>
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
{statusLabels[group.status]}
|
{statusLabels[group.status]}
|
||||||
</span>
|
</span>
|
||||||
@@ -246,6 +388,8 @@ const Campaigns = () => {
|
|||||||
<div className="p-8 text-center text-dark-muted">Nenhuma campanha registrada ainda.</div>
|
<div className="p-8 text-center text-dark-muted">Nenhuma campanha registrada ainda.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user