diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index 5cbfe47..3571802 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -6,6 +6,8 @@ const { getClientFilterOptions, getClientPurchasePatternAnalytics, getDashboardAnalytics, + getSellerAnalytics, + getSellerDetailsAnalytics, getProductAnalytics, getProductDetailsAnalytics, getRfmAnalytics @@ -35,6 +37,29 @@ router.get('/analytics/dashboard', verifyToken, async (req, res) => { } }); +router.get('/analytics/sellers', verifyToken, async (req, res) => { + try { + res.json(await getSellerAnalytics(getRange(req.query))); + } catch (error) { + console.error('Error fetching seller analytics:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.get('/analytics/sellers/:sellerId/details', verifyToken, async (req, res) => { + try { + const details = await getSellerDetailsAnalytics(req.params.sellerId, getRange(req.query)); + if (!details) { + res.status(404).json({ error: 'Seller not found' }); + return; + } + res.json(details); + } catch (error) { + console.error('Error fetching seller details analytics:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + router.get('/analytics/products', verifyToken, async (req, res) => { try { res.json(await getProductAnalytics(getRange(req.query))); diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 04dfeb4..610640f 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -694,6 +694,119 @@ const getDashboardAnalytics = async (range = {}) => { }; }; +const getSellerAnalytics = async (range = {}) => { + const { params, whereClause } = buildDateFilter(range); + const result = await pool.query(` + WITH seller_orders AS ( + SELECT + COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_id, + COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name, + quantidade, + valor_unitario, + pedido_id, + data_pedido, + valor_pedido, + data_pedido_date, + COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(${NORMALIZED_CUSTOMER_NAME_SQL}, 'desconhecido')) as customer_key + FROM orders + ${whereClause} + AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) + ) + SELECT + seller_id as id, + MAX(seller_name) as name, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COALESCE(SUM(quantidade), 0) as quantity_sold, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, + COUNT(DISTINCT customer_key)::int as customer_count, + MAX(data_pedido_date) as last_order_date + FROM seller_orders + GROUP BY seller_id + ORDER BY revenue DESC, order_count DESC, name ASC; + `, params); + + return result.rows.map(row => ({ + id: row.id, + name: row.name, + revenue: toNumber(row.revenue), + quantitySold: toNumber(row.quantity_sold), + orderCount: toNumber(row.order_count), + customerCount: toNumber(row.customer_count), + lastOrderDate: row.last_order_date, + averageTicket: toNumber(row.order_count) ? toNumber(row.revenue) / toNumber(row.order_count) : 0 + })); +}; + +const getSellerDetailsAnalytics = async (sellerId, range = {}) => { + const seller = normalizeSellerFilter(sellerId); + if (!seller) return null; + + const sellerRange = { ...range, seller: sellerId }; + const { params, whereClause } = buildDateFilter(sellerRange); + const [summaryResult, trendResult, marketplaceResult, products, clients] = await Promise.all([ + pool.query(` + SELECT + COALESCE(MAX(${SELLER_NAME_SQL}), MAX(${SELLER_ID_SQL}), 'Sem vendedor') as name, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COALESCE(SUM(quantidade), 0) as quantity_sold, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, + MAX(data_pedido_date) as last_order_date + FROM orders + ${whereClause}; + `, params), + pool.query(` + SELECT + data_pedido_date::text as date, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COALESCE(SUM(quantidade), 0) as quantity_sold, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count + FROM orders + ${whereClause} + GROUP BY data_pedido_date + ORDER BY data_pedido_date ASC; + `, params), + pool.query(` + SELECT + COALESCE(NULLIF(TRIM(marketplace), ''), 'Sem marketplace') as name, + COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, + COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count + FROM orders + ${whereClause} + GROUP BY name + ORDER BY revenue DESC + LIMIT 6; + `, params), + getProductAnalytics(sellerRange), + getClientAnalytics(sellerRange) + ]); + + const summary = summaryResult.rows[0]; + if (!summary || (!toNumber(summary.revenue) && !toNumber(summary.order_count))) return null; + const orderCount = toNumber(summary.order_count); + + return { + seller: { + id: String(sellerId), + name: summary.name, + revenue: toNumber(summary.revenue), + quantitySold: toNumber(summary.quantity_sold), + orderCount, + customerCount: clients.length, + lastOrderDate: summary.last_order_date, + averageTicket: orderCount ? toNumber(summary.revenue) / orderCount : 0 + }, + chartData: trendResult.rows.map(row => ({ + date: row.date, + revenue: toNumber(row.revenue), + quantitySold: toNumber(row.quantity_sold), + orderCount: toNumber(row.order_count) + })), + topProducts: products.slice(0, 8), + topClients: clients.slice(0, 8), + marketplaces: marketplaceResult.rows.map(row => ({ name: row.name, revenue: toNumber(row.revenue), orderCount: toNumber(row.order_count) })) + }; +}; + const getProductAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` @@ -1533,6 +1646,8 @@ module.exports = { getRfmSegment, getClientAnalytics, getDashboardAnalytics, + getSellerAnalytics, + getSellerDetailsAnalytics, getProductDetailsAnalytics, getProductAnalytics, normalizeDateParam, diff --git a/src/App.tsx b/src/App.tsx index a160230..7e24c39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,8 @@ const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Supplies = React.lazy(() => import('./pages/Supplies')); const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); +const Sellers = React.lazy(() => import('./pages/Sellers')); +const SellerDetails = React.lazy(() => import('./pages/SellerDetails')); const Campaigns = React.lazy(() => import('./pages/Campaigns')); const Rfm = React.lazy(() => import('./pages/Rfm')); const Registrations = React.lazy(() => import('./pages/Registrations')); @@ -63,6 +65,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 2acadd5..5595c07 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, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -84,6 +84,7 @@ const Layout = () => { label: 'Comercial', items: [ { name: 'Clientes', href: '/clients', icon: Users }, + { name: 'Vendedores', href: '/sellers', icon: BriefcaseBusiness }, { name: 'RFV', href: '/rfm', icon: Grid3X3 }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone }, ], diff --git a/src/dataService.ts b/src/dataService.ts index 692d61e..40edded 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -541,6 +541,34 @@ export const fetchProductAnalytics = async (dateRange: DateRange): Promise => { + const path = `/analytics/sellers?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return []; + return await response.json(); + } catch (error) { + console.error('Fetch seller analytics failed', error); + return []; + } + }); +}; + +export const fetchSellerDetailsAnalytics = async (sellerId: string, dateRange: DateRange): Promise => { + const path = `/analytics/sellers/${encodeURIComponent(sellerId)}/details?${buildDateRangeParams(dateRange).toString()}`; + return getCachedAnalytics(path, async () => { + try { + const response = await authFetch(path); + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error('Fetch seller details analytics failed', error); + return null; + } + }); +}; + export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise => { const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`; return getCachedAnalytics(path, async () => { diff --git a/src/pages/SellerDetails.tsx b/src/pages/SellerDetails.tsx new file mode 100644 index 0000000..869e352 --- /dev/null +++ b/src/pages/SellerDetails.tsx @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useOutletContext, useParams } from 'react-router-dom'; +import { ArrowLeft } from 'lucide-react'; +import DateRangePicker from '../components/DateRangePicker'; +import { fetchSellerDetailsAnalytics } from '../dataService'; +import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; +import type { DateRange, SellerDetailsAnalytics } from '../types'; + +const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value); +const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); + +const SellerDetails = () => { + const { sellerId = '' } = useParams<{ sellerId: string }>(); + const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>(); + const [details, setDetails] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + const load = async () => { + setIsLoading(true); + const result = await fetchSellerDetailsAnalytics(sellerId, dateRange); + if (isMounted) { setDetails(result); setIsLoading(false); } + }; + void load(); + return () => { isMounted = false; }; + }, [dateRange, sellerId]); + + const maxRevenue = useMemo(() => Math.max(...(details?.chartData.map(item => item.revenue) || [1]), 1), [details]); + if (isLoading && !details) return
{[1, 2, 3, 4].map(item =>
)}
; + if (!details) return

Vendedor não encontrado no período.

Voltar para vendedores
; + const { seller } = details; + + return ( +
+
+
+ Vendedores +

{formatDisplayName(removeTrailingSellerId(seller.name))}

+

Carteira, produtos e resultado comercial no período selecionado.

+
+ +
+
+ {[['Receita', formatCurrency(seller.revenue), 'text-brand-primary'], ['Pedidos', formatNumber(seller.orderCount), 'text-dark-text'], ['Clientes', formatNumber(seller.customerCount), 'text-dark-text'], ['Ticket médio', formatCurrency(seller.averageTicket), 'text-dark-text']].map(([label, value, color]) =>

{label}

{value}

)} +
+
+

Evolução da receita

Vendas por dia no período.

{details.chartData.length ? details.chartData.map(point =>
{new Date(`${point.date}T12:00:00`).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}
{formatCurrency(point.revenue)}
) :

Sem vendas no período.

}
+

Canais

Receita por marketplace.

{details.marketplaces.map(item =>

{item.name}

{formatCurrency(item.revenue)} · {formatNumber(item.orderCount)} pedidos

)}
+
+
+

Produtos mais vendidos

{details.topProducts.map(product =>

{product.name}

{formatNumber(product.quantitySold)} un. vendidas

{formatCurrency(product.revenue)})}
+

Carteira no período

{details.topClients.map(client =>

{client.name}

{formatNumber(client.orderCount)} pedidos

{formatCurrency(client.totalSpent)})}
+
+
+ ); +}; + +export default SellerDetails; diff --git a/src/pages/Sellers.tsx b/src/pages/Sellers.tsx new file mode 100644 index 0000000..a7f024b --- /dev/null +++ b/src/pages/Sellers.tsx @@ -0,0 +1,116 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useOutletContext } from 'react-router-dom'; +import { ArrowRight, BriefcaseBusiness, Search } from 'lucide-react'; +import DateRangePicker from '../components/DateRangePicker'; +import RefreshStatus from '../components/RefreshStatus'; +import { fetchSellerAnalytics } from '../dataService'; +import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters'; +import type { DateRange, SellerAnalyticsItem } from '../types'; + +const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value); +const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); + +const Sellers = () => { + const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>(); + const [sellers, setSellers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [search, setSearch] = useState(''); + + useEffect(() => { + let isMounted = true; + const load = async () => { + setIsLoading(true); + const rows = await fetchSellerAnalytics(dateRange); + if (isMounted) { + setSellers(rows); + setIsLoading(false); + } + }; + void load(); + return () => { isMounted = false; }; + }, [dateRange]); + + const visibleSellers = useMemo(() => { + const term = search.trim().toLocaleLowerCase('pt-BR'); + if (!term) return sellers; + return sellers.filter(seller => `${seller.name} ${seller.id}`.toLocaleLowerCase('pt-BR').includes(term)); + }, [search, sellers]); + const totalRevenue = sellers.reduce((sum, seller) => sum + seller.revenue, 0); + const totalOrders = sellers.reduce((sum, seller) => sum + seller.orderCount, 0); + const totalCustomers = sellers.reduce((sum, seller) => sum + seller.customerCount, 0); + const topSeller = sellers[0]; + + return ( +
+
+
+

Vendedores

+

Performance comercial, carteira e produtos vendidos por responsável.

+
+ +
+ + 0} /> + +
+
+

Receita da equipe

+

{formatCurrency(totalRevenue)}

+
+
+

Pedidos

+

{formatNumber(totalOrders)}

+
+
+

Clientes atendidos

+

{formatNumber(totalCustomers)}

+
+
+

Maior receita

+

{topSeller ? formatDisplayName(removeTrailingSellerId(topSeller.name)) : '—'}

+

{topSeller ? formatCurrency(topSeller.revenue) : 'Sem vendas no período'}

+
+
+ +
+
+
+

Equipe comercial

+

Selecione um vendedor para ver carteira, produtos e evolução.

+
+ +
+ + {isLoading && !sellers.length ? ( +
{[1, 2, 3, 4].map(item =>
)}
+ ) : visibleSellers.length ? ( +
+ {visibleSellers.map((seller, index) => ( + +
+
{index + 1}
+
+

{formatDisplayName(removeTrailingSellerId(seller.name))}

+

{seller.id.startsWith('name:') ? 'Sem ID Tiny' : `ID ${seller.id}`}

+
+
+

Receita

{formatCurrency(seller.revenue)}

+

Pedidos

{formatNumber(seller.orderCount)}

+

Clientes

{formatNumber(seller.customerCount)}

+

Ticket médio

{formatCurrency(seller.averageTicket)}

+ + + ))} +
+ ) : ( +

Nenhum vendedor encontrado.

Ajuste a busca ou o período selecionado.

+ )} +
+
+ ); +}; + +export default Sellers; diff --git a/src/types.ts b/src/types.ts index 296ef0f..a98a103 100644 --- a/src/types.ts +++ b/src/types.ts @@ -528,6 +528,25 @@ export interface ClientAnalyticsItem { lastPurchaseDate: string; } +export interface SellerAnalyticsItem { + id: string; + name: string; + revenue: number; + quantitySold: number; + orderCount: number; + customerCount: number; + lastOrderDate: string | null; + averageTicket: number; +} + +export interface SellerDetailsAnalytics { + seller: SellerAnalyticsItem; + chartData: Array<{ date: string; revenue: number; quantitySold: number; orderCount: number }>; + topProducts: ProductAnalyticsItem[]; + topClients: ClientAnalyticsItem[]; + marketplaces: Array<{ name: string; revenue: number; orderCount: number }>; +} + export interface ClientMetadataFilters { marketplace: string; canal_venda: string;