diff --git a/backend/routes/campaignRoutes.js b/backend/routes/campaignRoutes.js new file mode 100644 index 0000000..82047e8 --- /dev/null +++ b/backend/routes/campaignRoutes.js @@ -0,0 +1,48 @@ +const express = require('express'); +const { verifyToken } = require('../auth'); +const { + getCampaignPreview, + getCampaignQueueSummary, + processPendingStockCampaigns, + retryCampaignItems +} = require('../services/campaignService'); + +const router = express.Router(); + +router.get('/campaigns', verifyToken, async (req, res) => { + try { + res.json(await getCampaignQueueSummary()); + } catch (error) { + console.error('Error fetching campaigns:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.get('/campaigns/preview', verifyToken, async (req, res) => { + try { + res.json(await getCampaignPreview()); + } catch (error) { + console.error('Error fetching campaign preview:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.post('/campaigns/process', verifyToken, async (req, res) => { + try { + res.json(await processPendingStockCampaigns()); + } catch (error) { + console.error('Error processing campaigns:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.post('/campaigns/retry', verifyToken, async (req, res) => { + try { + res.json(await retryCampaignItems(req.body || {})); + } catch (error) { + console.error('Error retrying campaigns:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index 258d7fd..d1899eb 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,7 @@ const bodyParser = require('body-parser'); const authRoutes = require('./routes/authRoutes'); const dataRoutes = require('./routes/dataRoutes'); const stockRoutes = require('./routes/stockRoutes'); +const campaignRoutes = require('./routes/campaignRoutes'); const internalRoutes = require('./routes/internalRoutes'); const createApp = () => { @@ -15,6 +16,7 @@ const createApp = () => { app.use('/api', authRoutes); app.use('/api', dataRoutes); app.use('/api', stockRoutes); + app.use('/api', campaignRoutes); app.use('/api/internal', internalRoutes); return app; diff --git a/backend/services/campaignService.js b/backend/services/campaignService.js index ac99730..80a5cea 100644 --- a/backend/services/campaignService.js +++ b/backend/services/campaignService.js @@ -98,6 +98,128 @@ const countPendingBelowThresholdGroups = async () => { return result.rows[0]?.count || 0; }; +const getCampaignQueueRows = async () => { + const result = await pool.query(` + SELECT * + FROM stock_campaign_queue + ORDER BY created_at DESC, id DESC + LIMIT 500; + `); + + return result.rows; +}; + +const groupCampaignRows = (rows) => { + return Object.values(rows.reduce((acc, row) => { + const key = `${row.base_product_name}:${row.status}`; + if (!acc[key]) { + acc[key] = { + key, + baseProductName: row.base_product_name, + status: row.status, + totalDelta: 0, + rowCount: 0, + attempts: 0, + lastError: null, + createdAt: row.created_at, + updatedAt: row.updated_at, + sentAt: row.sent_at, + items: [] + }; + } + + acc[key].totalDelta += Number(row.delta_estoque || 0); + acc[key].rowCount += 1; + acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0)); + acc[key].lastError = row.last_error || acc[key].lastError; + acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt; + acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt; + acc[key].sentAt = row.sent_at || acc[key].sentAt; + acc[key].items.push(row); + return acc; + }, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); +}; + +const getCampaignQueueSummary = async () => { + const rows = await getCampaignQueueRows(); + return { + threshold: CAMPAIGN_DELTA_THRESHOLD, + maxAttempts: MAX_CAMPAIGN_ATTEMPTS, + groups: groupCampaignRows(rows), + rows + }; +}; + +const getCampaignPreview = async () => { + const result = await pool.query(` + SELECT * + FROM stock_campaign_queue + WHERE status IN ('pending', 'failed') + AND attempts < $1 + ORDER BY created_at ASC, id ASC; + `, [MAX_CAMPAIGN_ATTEMPTS]); + const groups = result.rows.reduce((acc, row) => { + if (!acc[row.base_product_name]) acc[row.base_product_name] = []; + acc[row.base_product_name].push(row); + return acc; + }, {}); + const readyGroups = {}; + const belowThresholdGroups = {}; + + Object.entries(groups).forEach(([baseProductName, items]) => { + const totalDelta = items.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0); + if (totalDelta >= CAMPAIGN_DELTA_THRESHOLD) { + readyGroups[baseProductName] = items; + } else { + belowThresholdGroups[baseProductName] = items; + } + }); + + const readyProducts = mapCampaignProducts(readyGroups).map(({ itemIds, ...product }) => product); + const belowThresholdProducts = mapCampaignProducts(belowThresholdGroups).map(({ itemIds, ...product }) => product); + const customers = await getTopBuyersAllTime(); + + return { + threshold: CAMPAIGN_DELTA_THRESHOLD, + readyProducts, + belowThresholdProducts, + productsText: readyProducts.length ? formatProductList(readyProducts.map(product => product.baseProduct)) : '', + customerCount: customers.length, + customersPreview: customers.slice(0, 10) + }; +}; + +const retryCampaignItems = async ({ ids, baseProductName } = {}) => { + const params = []; + const filters = [`status IN ('failed', 'skipped')`]; + + if (Array.isArray(ids) && ids.length) { + params.push(ids.map(Number)); + filters.push(`id = ANY($${params.length}::int[])`); + } + + if (baseProductName) { + params.push(baseProductName); + filters.push(`base_product_name = $${params.length}`); + } + + const result = await pool.query(` + UPDATE stock_campaign_queue + SET status = 'pending', + attempts = 0, + last_error = NULL, + sent_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE ${filters.join(' AND ')} + RETURNING *; + `, params); + + return { + retried: result.rowCount, + rows: result.rows + }; +}; + const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => { if (!ids.length) return; @@ -210,5 +332,8 @@ const processPendingStockCampaigns = async () => { module.exports = { enqueueStockCampaignItem, + getCampaignPreview, + getCampaignQueueSummary, + retryCampaignItems, processPendingStockCampaigns }; diff --git a/src/App.tsx b/src/App.tsx index 2afe76f..ee2b8cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,7 @@ import Products from './pages/Products'; import ProductDetails from './pages/ProductDetails'; import Clients from './pages/Clients'; import ClientDetails from './pages/ClientDetails'; +import Campaigns from './pages/Campaigns'; import Login from './pages/Login'; import { isAuthenticated } from './dataService'; @@ -28,6 +29,7 @@ function App() { } /> } /> } /> + } /> ); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9a66664..e47adcd 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Outlet, Link, useLocation } from 'react-router-dom'; -import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react'; import type { DateRange, OrderData, StockData } from '../types'; import { fetchData, fetchStock, logout } from '../dataService'; @@ -77,6 +77,7 @@ const Layout = () => { { name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Produtos', href: '/products', icon: Package }, { name: 'Clientes', href: '/clients', icon: Users }, + { name: 'Campanhas', href: '/campaigns', icon: Megaphone }, ]; return ( diff --git a/src/dataService.ts b/src/dataService.ts index d63f59c..b13f2e1 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { OrderData, StockData } from './types'; +import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, OrderData, StockData } from './types'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -72,6 +72,74 @@ export const fetchData = async (): Promise => { } }; +const authFetch = async (path: string, options: RequestInit = {}): Promise => { + const token = localStorage.getItem('auth_token'); + const response = await fetch(`${API_URL}${path}`, { + ...options, + headers: { + ...(options.headers || {}), + 'Authorization': `Bearer ${token}` + } + }); + + if (response.status === 401 || response.status === 403) { + logout(); + } + + return response; +}; + +export const fetchCampaigns = async (): Promise => { + try { + const response = await authFetch('/campaigns'); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch campaigns failed', error); + return null; + } +}; + +export const fetchCampaignPreview = async (): Promise => { + try { + const response = await authFetch('/campaigns/preview'); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch campaign preview failed', error); + return null; + } +}; + +export const processCampaignsNow = async (): Promise => { + try { + const response = await authFetch('/campaigns/process', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Process campaigns failed', error); + return null; + } +}; + +export const retryCampaignGroup = async (baseProductName: string): Promise => { + try { + const response = await authFetch('/campaigns/retry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseProductName }) + }); + return response.ok; + } catch (error) { + console.error('Retry campaign failed', error); + return false; + } +}; + export const parseOrderDate = (dateStr: string): Date => { if (!dateStr) return new Date(0); if (dateStr.includes('T')) return new Date(dateStr); diff --git a/src/pages/Campaigns.tsx b/src/pages/Campaigns.tsx new file mode 100644 index 0000000..7b0f24d --- /dev/null +++ b/src/pages/Campaigns.tsx @@ -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 = { + pending: 'Pendente', + processing: 'Processando', + sent: 'Enviada', + failed: 'Falhou', + skipped: 'Ignorada' +}; + +const statusStyles: Record = { + 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 = { + 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(null); + const [preview, setPreview] = useState(null); + const [processResult, setProcessResult] = useState(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 = { + 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 ( +
+
+
+

Campanhas

+

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

+
+
+ + +
+
+ + {processResult && ( +
+ Resultado: {processResult.claimed} itens processados, {processResult.sentGroups} grupos enviados, {processResult.failedGroups} falhas, {processResult.pendingBelowThresholdGroups} abaixo do limite. +
+ )} + +
+ {Object.entries(groupedCounts).map(([status, count]) => { + const typedStatus = status as CampaignStatus; + const Icon = statusIcons[typedStatus]; + return ( +
+
+ {statusLabels[typedStatus]} + +
+

{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}

+
+
+
+ {(preview?.readyProducts || []).map(product => ( +
+ {product.baseProduct} + {formatDelta(product.total_delta)} +
+ ))} + {(preview?.belowThresholdProducts || []).map(product => ( +
+ {product.baseProduct} + {formatDelta(product.total_delta)} +
+ ))} +
+
+
+ +
+

Top clientes da campanha

+
+ {(preview?.customersPreview || []).map(customer => ( +
+
+

{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 ( + + + + + + + + + + ); + })} + +
ProdutoStatusDeltaItensTentativasAtualizadoAçõ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; diff --git a/src/types.ts b/src/types.ts index ea2cc00..51e0d8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,3 +23,74 @@ export interface DateRange { start: Date; end: Date; } + +export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped'; + +export interface CampaignQueueItem { + id: number; + base_product_name: string; + produto_id: string; + nome: string; + saldo: number; + delta_estoque: number; + status: CampaignStatus; + attempts: number; + last_error?: string | null; + created_at: string; + updated_at: string; + sent_at?: string | null; +} + +export interface CampaignGroup { + key: string; + baseProductName: string; + status: CampaignStatus; + totalDelta: number; + rowCount: number; + attempts: number; + lastError?: string | null; + createdAt: string; + updatedAt: string; + sentAt?: string | null; + items: CampaignQueueItem[]; +} + +export interface CampaignQueueSummary { + threshold: number; + maxAttempts: number; + groups: CampaignGroup[]; + rows: CampaignQueueItem[]; +} + +export interface CampaignProductPreview { + baseProduct: string; + total_delta: number; + sizes: Array<{ + id: string; + nome: string; + delta: number; + saldo: number; + }>; +} + +export interface CampaignPreview { + threshold: number; + readyProducts: CampaignProductPreview[]; + belowThresholdProducts: CampaignProductPreview[]; + productsText: string; + customerCount: number; + customersPreview: Array<{ + nome: string; + fone: string; + total_gasto?: string; + total_comprado?: string; + }>; +} + +export interface CampaignProcessSummary { + claimed: number; + sentGroups: number; + skippedGroups: number; + failedGroups: number; + pendingBelowThresholdGroups: number; +}