extract frontend analytics helpers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
This commit is contained in:
122
src/analytics/clients.ts
Normal file
122
src/analytics/clients.ts
Normal file
@@ -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<string, {
|
||||
totalSpent: number;
|
||||
totalItems: number;
|
||||
uniqueOrders: Set<string>;
|
||||
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<string, GroupedClientOrder> = {};
|
||||
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 };
|
||||
};
|
||||
85
src/analytics/dashboard.ts
Normal file
85
src/analytics/dashboard.ts
Normal file
@@ -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<string, string> = {};
|
||||
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<string, number> = {};
|
||||
const productRevenueMap: Record<string, number> = {};
|
||||
const productNameIdMap: Record<string, string> = {};
|
||||
|
||||
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<Record<string, string>>((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
|
||||
};
|
||||
};
|
||||
23
src/analytics/orders.ts
Normal file
23
src/analytics/orders.ts
Normal file
@@ -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;
|
||||
};
|
||||
117
src/analytics/products.ts
Normal file
117
src/analytics/products.ts
Normal file
@@ -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<string, ProductSummary> = {};
|
||||
|
||||
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<string, number> = {};
|
||||
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 };
|
||||
};
|
||||
@@ -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<string, { date: string, orderId: string, orderTotal: number, items: OrderData[] }> = {};
|
||||
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) => {
|
||||
|
||||
@@ -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<SortOption>('recent');
|
||||
const [sortBy, setSortBy] = useState<ClientSortOption>('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<string, { totalSpent: number, totalItems: number, uniqueOrders: Set<string>, 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 = () => {
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as SortOption);
|
||||
setSortBy(e.target.value as ClientSortOption);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="appearance-none bg-dark-card border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
||||
|
||||
@@ -4,27 +4,7 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContaine
|
||||
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { parseOrderDate } from '../dataService';
|
||||
|
||||
const COLORS = [
|
||||
// 10 Strong Base Colors
|
||||
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
|
||||
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6',
|
||||
// 10 Softer Versions
|
||||
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74',
|
||||
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4'
|
||||
];
|
||||
|
||||
const globalColorMap: Record<string, string> = {};
|
||||
let globalColorIndex = 0;
|
||||
|
||||
const getProductColor = (name: string) => {
|
||||
if (!globalColorMap[name]) {
|
||||
globalColorMap[name] = COLORS[globalColorIndex % COLORS.length];
|
||||
globalColorIndex++;
|
||||
}
|
||||
return globalColorMap[name];
|
||||
};
|
||||
import { buildDashboardMetrics } from '../analytics/dashboard';
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
@@ -73,65 +53,9 @@ const Dashboard = () => {
|
||||
loadData: (showLoading?: boolean) => void
|
||||
}>();
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const orders = ordersData;
|
||||
return orders.filter(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
return orderDate >= dateRange.start && orderDate <= dateRange.end;
|
||||
});
|
||||
}, [dateRange, ordersData]);
|
||||
|
||||
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
|
||||
let revenue = 0;
|
||||
let totalItems = 0;
|
||||
const productSalesMap: Record<string, number> = {};
|
||||
const productRevenueMap: Record<string, number> = {};
|
||||
const productNameIdMap: Record<string, string> = {};
|
||||
|
||||
filteredData.forEach(order => {
|
||||
const itemRevenue = order.Quantidade * order.Valor_Unitario;
|
||||
revenue += itemRevenue;
|
||||
totalItems += order.Quantidade;
|
||||
const productName = order.Descricao_Produto.split(' TAMANHO')[0];
|
||||
productNameIdMap[productName] = order.ID_Produto;
|
||||
|
||||
if (productSalesMap[productName]) {
|
||||
productSalesMap[productName] += order.Quantidade;
|
||||
productRevenueMap[productName] += itemRevenue;
|
||||
} else {
|
||||
productSalesMap[productName] = order.Quantidade;
|
||||
productRevenueMap[productName] = itemRevenue;
|
||||
}
|
||||
});
|
||||
|
||||
// Identify which products will actually be displayed in both charts (Top 10 of each)
|
||||
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);
|
||||
|
||||
// Combine them into a unique set to assign colors only to the VISIBLE products
|
||||
const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort();
|
||||
const productColors: Record<string, string> = {};
|
||||
|
||||
displayProducts.forEach((name) => {
|
||||
productColors[name] = getProductColor(name);
|
||||
});
|
||||
|
||||
const productsData = topSalesNames.map(name => ({
|
||||
name,
|
||||
id: productNameIdMap[name],
|
||||
value: productSalesMap[name],
|
||||
fill: productColors[name]
|
||||
}));
|
||||
|
||||
const revenueData = topRevenueNames.map(name => ({
|
||||
name,
|
||||
id: productNameIdMap[name],
|
||||
value: productRevenueMap[name],
|
||||
fill: productColors[name]
|
||||
}));
|
||||
|
||||
return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct: productsData, revenueByProduct: revenueData };
|
||||
}, [filteredData]);
|
||||
return buildDashboardMetrics(ordersData, dateRange);
|
||||
}, [dateRange, ordersData]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { parseOrderDate } from '../dataService';
|
||||
import { buildProductDetailsMetrics } from '../analytics/products';
|
||||
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
@@ -36,41 +36,7 @@ const ProductDetails = () => {
|
||||
}>();
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||
const orders = ordersData.filter(order => order.ID_Produto === id);
|
||||
|
||||
if (orders.length === 0) return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
|
||||
|
||||
const info = {
|
||||
id: orders[0].ID_Produto,
|
||||
name: orders[0].Descricao_Produto.split(' TAMANHO')[0],
|
||||
price: orders[0].Valor_Unitario
|
||||
};
|
||||
|
||||
const salesByDate: Record<string, number> = {};
|
||||
let sold = 0;
|
||||
let revenue = 0;
|
||||
|
||||
orders.forEach(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
|
||||
if (orderDate >= dateRange.start && orderDate <= dateRange.end) {
|
||||
const dateStr = order.Data_Pedido;
|
||||
salesByDate[dateStr] = (salesByDate[dateStr] || 0) + order.Quantidade;
|
||||
sold += order.Quantidade;
|
||||
revenue += (order.Quantidade * order.Valor_Unitario);
|
||||
}
|
||||
});
|
||||
|
||||
const chart = 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: info, chartData: chart, totalSold: sold, totalRevenue: revenue };
|
||||
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
||||
}, [id, dateRange, ordersData]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange, StockData } from '../types';
|
||||
import { parseOrderDate, exportToCSV } from '../dataService';
|
||||
import { exportToCSV } from '../dataService';
|
||||
import { buildProductsSummary } from '../analytics/products';
|
||||
|
||||
const Products = () => {
|
||||
const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{
|
||||
@@ -22,54 +23,7 @@ const Products = () => {
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const productsData = useMemo(() => {
|
||||
const orders = ordersData;
|
||||
const productMap: Record<string, { id: string, name: string, totalSold: number, revenue: number, lastPrice: number, stock: number }> = {};
|
||||
|
||||
// Initialize with stock data
|
||||
if (stockData && Array.isArray(stockData)) {
|
||||
stockData.forEach(item => {
|
||||
productMap[item.produto_id] = {
|
||||
id: item.produto_id,
|
||||
name: item.nome,
|
||||
totalSold: 0,
|
||||
revenue: 0,
|
||||
lastPrice: 0,
|
||||
stock: item.saldo || 0
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
orders.forEach(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
if (orderDate < dateRange.start || orderDate > dateRange.end) return;
|
||||
|
||||
if (!productMap[order.ID_Produto]) {
|
||||
productMap[order.ID_Produto] = {
|
||||
id: order.ID_Produto,
|
||||
name: order.Descricao_Produto.split(' TAMANHO')[0],
|
||||
totalSold: 0,
|
||||
revenue: 0,
|
||||
lastPrice: order.Valor_Unitario,
|
||||
stock: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Update name if we didn't get it from stock
|
||||
if (productMap[order.ID_Produto].name === 'Unknown' || !productMap[order.ID_Produto].name) {
|
||||
productMap[order.ID_Produto].name = order.Descricao_Produto.split(' TAMANHO')[0];
|
||||
}
|
||||
|
||||
productMap[order.ID_Produto].totalSold += order.Quantidade;
|
||||
productMap[order.ID_Produto].revenue += (order.Quantidade * order.Valor_Unitario);
|
||||
productMap[order.ID_Produto].lastPrice = order.Valor_Unitario;
|
||||
});
|
||||
|
||||
let result = Object.values(productMap);
|
||||
if (searchTerm) {
|
||||
result = result.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()) || p.id.includes(searchTerm));
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.totalSold - a.totalSold);
|
||||
return buildProductsSummary(ordersData, stockData, dateRange, searchTerm);
|
||||
}, [dateRange, searchTerm, ordersData, stockData]);
|
||||
|
||||
// Pagination logic
|
||||
|
||||
Reference in New Issue
Block a user