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 { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone } from 'lucide-react';
|
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone } from 'lucide-react';
|
||||||
import type { OrderData } from '../types';
|
import type { OrderData } from '../types';
|
||||||
import { parseOrderDate } from '../dataService';
|
import { buildClientDetailsMetrics } from '../analytics/clients';
|
||||||
|
|
||||||
const ClientDetails = () => {
|
const ClientDetails = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
@@ -10,41 +10,7 @@ const ClientDetails = () => {
|
|||||||
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
|
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
|
||||||
|
|
||||||
const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
|
const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
|
||||||
const orders = ordersData;
|
return buildClientDetailsMetrics(ordersData, decodedName);
|
||||||
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 };
|
|
||||||
}, [decodedName, ordersData]);
|
}, [decodedName, ordersData]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useMemo, useState } from 'react';
|
|||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { OrderData, DateRange } from '../types';
|
||||||
import { parseOrderDate, exportToCSV } from '../dataService';
|
import { exportToCSV } from '../dataService';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import { buildClientsSummary, type ClientSortOption } from '../analytics/clients';
|
||||||
type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
|
|
||||||
|
|
||||||
const Clients = () => {
|
const Clients = () => {
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||||
@@ -14,66 +13,14 @@ const Clients = () => {
|
|||||||
ordersData: OrderData[]
|
ordersData: OrderData[]
|
||||||
}>();
|
}>();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
const clientsData = useMemo(() => {
|
const clientsData = useMemo(() => {
|
||||||
const orders = ordersData.filter(order => {
|
return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy);
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [searchTerm, sortBy, ordersData, dateRange]);
|
}, [searchTerm, sortBy, ordersData, dateRange]);
|
||||||
|
|
||||||
// Pagination logic
|
// Pagination logic
|
||||||
@@ -107,7 +54,7 @@ const Clients = () => {
|
|||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSortBy(e.target.value as SortOption);
|
setSortBy(e.target.value as ClientSortOption);
|
||||||
setCurrentPage(1);
|
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"
|
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 { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { OrderData, DateRange } from '../types';
|
||||||
import { parseOrderDate } from '../dataService';
|
import { buildDashboardMetrics } from '../analytics/dashboard';
|
||||||
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
@@ -73,65 +53,9 @@ const Dashboard = () => {
|
|||||||
loadData: (showLoading?: boolean) => void
|
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(() => {
|
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
|
||||||
let revenue = 0;
|
return buildDashboardMetrics(ordersData, dateRange);
|
||||||
let totalItems = 0;
|
}, [dateRange, ordersData]);
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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 { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { OrderData, DateRange } from '../types';
|
||||||
import { parseOrderDate } from '../dataService';
|
import { buildProductDetailsMetrics } from '../analytics/products';
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
type CustomTooltipProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -36,41 +36,7 @@ const ProductDetails = () => {
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||||
const orders = ordersData.filter(order => order.ID_Produto === id);
|
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
||||||
|
|
||||||
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 };
|
|
||||||
}, [id, dateRange, ordersData]);
|
}, [id, dateRange, ordersData]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
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 { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange, StockData } from '../types';
|
import type { OrderData, DateRange, StockData } from '../types';
|
||||||
import { parseOrderDate, exportToCSV } from '../dataService';
|
import { exportToCSV } from '../dataService';
|
||||||
|
import { buildProductsSummary } from '../analytics/products';
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{
|
const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{
|
||||||
@@ -22,54 +23,7 @@ const Products = () => {
|
|||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
const productsData = useMemo(() => {
|
const productsData = useMemo(() => {
|
||||||
const orders = ordersData;
|
return buildProductsSummary(ordersData, stockData, dateRange, searchTerm);
|
||||||
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);
|
|
||||||
}, [dateRange, searchTerm, ordersData, stockData]);
|
}, [dateRange, searchTerm, ordersData, stockData]);
|
||||||
|
|
||||||
// Pagination logic
|
// Pagination logic
|
||||||
|
|||||||
Reference in New Issue
Block a user