From 72ded82ec75e994d57217028cbdd32aa6405f2e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 27 May 2026 15:58:12 -0300 Subject: [PATCH] extract frontend analytics helpers --- src/analytics/clients.ts | 122 +++++++++++++++++++++++++++++++++++ src/analytics/dashboard.ts | 85 ++++++++++++++++++++++++ src/analytics/orders.ts | 23 +++++++ src/analytics/products.ts | 117 +++++++++++++++++++++++++++++++++ src/pages/ClientDetails.tsx | 38 +---------- src/pages/Clients.tsx | 63 ++---------------- src/pages/Dashboard.tsx | 82 +---------------------- src/pages/ProductDetails.tsx | 38 +---------- src/pages/Products.tsx | 52 +-------------- 9 files changed, 362 insertions(+), 258 deletions(-) create mode 100644 src/analytics/clients.ts create mode 100644 src/analytics/dashboard.ts create mode 100644 src/analytics/orders.ts create mode 100644 src/analytics/products.ts diff --git a/src/analytics/clients.ts b/src/analytics/clients.ts new file mode 100644 index 0000000..33d60a5 --- /dev/null +++ b/src/analytics/clients.ts @@ -0,0 +1,122 @@ +import type { DateRange, OrderData } from '../types'; +import { parseOrderDate } from '../dataService'; +import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders'; + +export type ClientSortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc'; + +export interface ClientSummary { + name: string; + phone: string; + totalSpent: number; + totalItems: number; + orderCount: number; + lastPurchase: number; +} + +export interface GroupedClientOrder { + date: string; + orderId: string; + orderTotal: number; + items: OrderData[]; +} + +export interface ClientDetailsMetrics { + groupedOrders: GroupedClientOrder[]; + totalSpent: number; + totalItems: number; + clientPhone: string; +} + +export const buildClientsSummary = ( + ordersData: OrderData[], + dateRange: DateRange, + searchTerm: string, + sortBy: ClientSortOption +): ClientSummary[] => { + const orders = filterOrdersByDateRange(ordersData, dateRange); + const clientMap: Record; + lastPurchase: number; + phone: string; + }> = {}; + + orders.forEach(order => { + const clientName = getClientDisplayName(order); + + if (!clientMap[clientName]) { + clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' }; + } + + if (order.Fone_Cliente) { + clientMap[clientName].phone = order.Fone_Cliente; + } + + clientMap[clientName].totalSpent += getOrderItemRevenue(order); + clientMap[clientName].totalItems += order.Quantidade; + clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`); + + const orderTime = parseOrderDate(order.Data_Pedido).getTime(); + if (orderTime > clientMap[clientName].lastPurchase) { + clientMap[clientName].lastPurchase = orderTime; + } + }); + + const normalizedSearch = searchTerm.trim().toLowerCase(); + const clients = Object.keys(clientMap).map(name => ({ + name, + phone: clientMap[name].phone, + totalSpent: clientMap[name].totalSpent, + totalItems: clientMap[name].totalItems, + orderCount: clientMap[name].uniqueOrders.size, + lastPurchase: clientMap[name].lastPurchase + })); + + const filteredClients = normalizedSearch + ? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch)) + : clients; + + return filteredClients.sort((a, b) => { + switch (sortBy) { + case 'recent': return b.lastPurchase - a.lastPurchase; + case 'spent_desc': return b.totalSpent - a.totalSpent; + case 'spent_asc': return a.totalSpent - b.totalSpent; + case 'items_desc': return b.totalItems - a.totalItems; + case 'items_asc': return a.totalItems - b.totalItems; + default: return 0; + } + }); +}; + +export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: string): ClientDetailsMetrics => { + const clientOrders = ordersData.filter(order => getClientDisplayName(order) === clientName); + const groupedOrdersMap: Record = {}; + let clientPhone = ''; + let totalSpent = 0; + let totalItems = 0; + + clientOrders.forEach(order => { + if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente; + + totalSpent += getOrderItemRevenue(order); + totalItems += order.Quantidade; + + const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`; + if (!groupedOrdersMap[key]) { + groupedOrdersMap[key] = { + date: order.Data_Pedido, + orderId: order.ID_Pedido || key, + orderTotal: order.Valor_Pedido, + items: [] + }; + } + groupedOrdersMap[key].items.push(order); + }); + + const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => { + return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime(); + }); + + return { groupedOrders, totalSpent, totalItems, clientPhone }; +}; diff --git a/src/analytics/dashboard.ts b/src/analytics/dashboard.ts new file mode 100644 index 0000000..0f071f6 --- /dev/null +++ b/src/analytics/dashboard.ts @@ -0,0 +1,85 @@ +import type { DateRange, OrderData } from '../types'; +import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders'; + +const COLORS = [ + '#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316', + '#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6', + '#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74', + '#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4' +]; + +const globalColorMap: Record = {}; +let globalColorIndex = 0; + +const getProductColor = (name: string): string => { + if (!globalColorMap[name]) { + globalColorMap[name] = COLORS[globalColorIndex % COLORS.length]; + globalColorIndex += 1; + } + return globalColorMap[name]; +}; + +export interface ChartProductMetric { + name: string; + id: string; + value: number; + fill: string; +} + +export interface DashboardMetrics { + totalRevenue: number; + totalOrders: number; + averageOrderValue: number; + salesByProduct: ChartProductMetric[]; + revenueByProduct: ChartProductMetric[]; +} + +export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRange): DashboardMetrics => { + const filteredData = filterOrdersByDateRange(ordersData, dateRange); + let revenue = 0; + let totalItems = 0; + const productSalesMap: Record = {}; + const productRevenueMap: Record = {}; + const productNameIdMap: Record = {}; + + filteredData.forEach(order => { + const itemRevenue = getOrderItemRevenue(order); + const productName = getBaseProductName(order.Descricao_Produto); + + revenue += itemRevenue; + totalItems += order.Quantidade; + productNameIdMap[productName] = order.ID_Produto; + productSalesMap[productName] = (productSalesMap[productName] || 0) + order.Quantidade; + productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue; + }); + + const topSalesNames = Object.keys(productSalesMap).sort((a, b) => productSalesMap[b] - productSalesMap[a]).slice(0, 10); + const topRevenueNames = Object.keys(productRevenueMap).sort((a, b) => productRevenueMap[b] - productRevenueMap[a]).slice(0, 10); + const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort(); + const productColors = displayProducts.reduce>((colors, name) => { + colors[name] = getProductColor(name); + return colors; + }, {}); + + const salesByProduct = topSalesNames.map(name => ({ + name, + id: productNameIdMap[name], + value: productSalesMap[name], + fill: productColors[name] + })); + + const revenueByProduct = topRevenueNames.map(name => ({ + name, + id: productNameIdMap[name], + value: productRevenueMap[name], + fill: productColors[name] + })); + + return { + totalRevenue: revenue, + totalOrders: totalItems, + averageOrderValue: revenue / (filteredData.length || 1), + salesByProduct, + revenueByProduct + }; +}; diff --git a/src/analytics/orders.ts b/src/analytics/orders.ts new file mode 100644 index 0000000..89a0315 --- /dev/null +++ b/src/analytics/orders.ts @@ -0,0 +1,23 @@ +import type { DateRange, OrderData } from '../types'; +import { parseOrderDate } from '../dataService'; + +export const getBaseProductName = (description: string): string => { + return description.split(' TAMANHO')[0]; +}; + +export const getClientDisplayName = (order: OrderData): string => { + return order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`; +}; + +export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => { + const orderDate = parseOrderDate(order.Data_Pedido); + return orderDate >= dateRange.start && orderDate <= dateRange.end; +}; + +export const filterOrdersByDateRange = (orders: OrderData[], dateRange: DateRange): OrderData[] => { + return orders.filter(order => isOrderInDateRange(order, dateRange)); +}; + +export const getOrderItemRevenue = (order: OrderData): number => { + return order.Quantidade * order.Valor_Unitario; +}; diff --git a/src/analytics/products.ts b/src/analytics/products.ts new file mode 100644 index 0000000..e6090b5 --- /dev/null +++ b/src/analytics/products.ts @@ -0,0 +1,117 @@ +import type { DateRange, OrderData, StockData } from '../types'; +import { getBaseProductName, getOrderItemRevenue, isOrderInDateRange } from './orders'; + +export interface ProductSummary { + id: string; + name: string; + totalSold: number; + revenue: number; + lastPrice: number; + stock: number; +} + +export interface ProductDetailsMetrics { + productInfo: { + id: string; + name: string; + price: number; + } | null; + chartData: Array<{ + date: string; + value: number; + }>; + totalSold: number; + totalRevenue: number; +} + +export const buildProductsSummary = ( + ordersData: OrderData[], + stockData: StockData[], + dateRange: DateRange, + searchTerm: string +): ProductSummary[] => { + const productMap: Record = {}; + + stockData.forEach(item => { + productMap[item.produto_id] = { + id: item.produto_id, + name: item.nome, + totalSold: 0, + revenue: 0, + lastPrice: 0, + stock: item.saldo || 0 + }; + }); + + ordersData.forEach(order => { + if (!isOrderInDateRange(order, dateRange)) return; + + if (!productMap[order.ID_Produto]) { + productMap[order.ID_Produto] = { + id: order.ID_Produto, + name: getBaseProductName(order.Descricao_Produto), + totalSold: 0, + revenue: 0, + lastPrice: order.Valor_Unitario, + stock: 0 + }; + } + + if (productMap[order.ID_Produto].name === 'Unknown' || !productMap[order.ID_Produto].name) { + productMap[order.ID_Produto].name = getBaseProductName(order.Descricao_Produto); + } + + productMap[order.ID_Produto].totalSold += order.Quantidade; + productMap[order.ID_Produto].revenue += getOrderItemRevenue(order); + productMap[order.ID_Produto].lastPrice = order.Valor_Unitario; + }); + + const normalizedSearch = searchTerm.trim().toLowerCase(); + const products = Object.values(productMap); + const filteredProducts = normalizedSearch + ? products.filter(product => product.name.toLowerCase().includes(normalizedSearch) || product.id.includes(searchTerm)) + : products; + + return filteredProducts.sort((a, b) => b.totalSold - a.totalSold); +}; + +export const buildProductDetailsMetrics = ( + ordersData: OrderData[], + productId: string | undefined, + dateRange: DateRange +): ProductDetailsMetrics => { + const productOrders = ordersData.filter(order => order.ID_Produto === productId); + + if (productOrders.length === 0) { + return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 }; + } + + const productInfo = { + id: productOrders[0].ID_Produto, + name: getBaseProductName(productOrders[0].Descricao_Produto), + price: productOrders[0].Valor_Unitario + }; + + const salesByDate: Record = {}; + let totalSold = 0; + let totalRevenue = 0; + + productOrders.forEach(order => { + if (!isOrderInDateRange(order, dateRange)) return; + + salesByDate[order.Data_Pedido] = (salesByDate[order.Data_Pedido] || 0) + order.Quantidade; + totalSold += order.Quantidade; + totalRevenue += getOrderItemRevenue(order); + }); + + const chartData = Object.keys(salesByDate).map(date => ({ + date, + value: salesByDate[date] + })).sort((a, b) => { + const [da, ma, ya] = a.date.split('-').map(Number); + const [db, mb, yb] = b.date.split('-').map(Number); + return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime(); + }); + + return { productInfo, chartData, totalSold, totalRevenue }; +}; diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index f533d08..c20120c 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { useParams, Link, useOutletContext } from 'react-router-dom'; import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone } from 'lucide-react'; import type { OrderData } from '../types'; -import { parseOrderDate } from '../dataService'; +import { buildClientDetailsMetrics } from '../analytics/clients'; const ClientDetails = () => { const { name } = useParams<{ name: string }>(); @@ -10,41 +10,7 @@ const ClientDetails = () => { const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>(); const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => { - const orders = ordersData; - const clientOrders = orders.filter(order => { - const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`; - return clientName === decodedName; - }); - - let clientPhone = ''; - const groupedOrdersMap: Record = {}; - let totalSpent = 0; - let totalItems = 0; - - clientOrders.forEach(order => { - if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente; - totalSpent += (order.Quantidade * order.Valor_Unitario); - totalItems += order.Quantidade; - - // Use ID_Pedido if available, otherwise fallback to date and total order value - const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`; - if (!groupedOrdersMap[key]) { - groupedOrdersMap[key] = { - date: order.Data_Pedido, - orderId: order.ID_Pedido || key, - orderTotal: order.Valor_Pedido, - items: [] - }; - } - groupedOrdersMap[key].items.push(order); - }); - - // Sort grouped orders by date descending - const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => { - return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime(); - }); - - return { groupedOrders, totalSpent, totalItems, clientPhone }; + return buildClientDetailsMetrics(ordersData, decodedName); }, [decodedName, ordersData]); const formatCurrency = (value: number) => { diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index 4a99446..d3dabf7 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -2,10 +2,9 @@ import { useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; import type { OrderData, DateRange } from '../types'; -import { parseOrderDate, exportToCSV } from '../dataService'; +import { exportToCSV } from '../dataService'; import DateRangePicker from '../components/DateRangePicker'; - -type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc'; +import { buildClientsSummary, type ClientSortOption } from '../analytics/clients'; const Clients = () => { const { dateRange, setDateRange, ordersData } = useOutletContext<{ @@ -14,66 +13,14 @@ const Clients = () => { ordersData: OrderData[] }>(); const [searchTerm, setSearchTerm] = useState(''); - const [sortBy, setSortBy] = useState('recent'); + const [sortBy, setSortBy] = useState('recent'); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); const clientsData = useMemo(() => { - const orders = ordersData.filter(order => { - const orderDate = parseOrderDate(order.Data_Pedido); - return orderDate >= dateRange.start && orderDate <= dateRange.end; - }); - - const clientMap: Record, lastPurchase: number, phone: string }> = {}; - - orders.forEach(order => { - const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`; - - if (!clientMap[clientName]) { - clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' }; - } - - if (order.Fone_Cliente) { - clientMap[clientName].phone = order.Fone_Cliente; - } - - // Calculate total spent based on quantity * unit price - clientMap[clientName].totalSpent += (order.Quantidade * order.Valor_Unitario); - clientMap[clientName].totalItems += order.Quantidade; - clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`); - - const orderTime = parseOrderDate(order.Data_Pedido).getTime(); - - if (orderTime > clientMap[clientName].lastPurchase) { - clientMap[clientName].lastPurchase = orderTime; - } - }); - - let result = Object.keys(clientMap).map(name => ({ - name, - phone: clientMap[name].phone, - totalSpent: clientMap[name].totalSpent, - totalItems: clientMap[name].totalItems, - orderCount: clientMap[name].uniqueOrders.size, // Grouped by unique date+value combinations - lastPurchase: clientMap[name].lastPurchase - })); - - if (searchTerm) { - result = result.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase())); - } - - return result.sort((a, b) => { - switch (sortBy) { - case 'recent': return b.lastPurchase - a.lastPurchase; - case 'spent_desc': return b.totalSpent - a.totalSpent; - case 'spent_asc': return a.totalSpent - b.totalSpent; - case 'items_desc': return b.totalItems - a.totalItems; - case 'items_asc': return a.totalItems - b.totalItems; - default: return 0; - } - }); + return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy); }, [searchTerm, sortBy, ordersData, dateRange]); // Pagination logic @@ -107,7 +54,7 @@ const Clients = () => {