Create new services: inventory, recipes, suppliers
This commit is contained in:
487
frontend/src/components/sales/SalesAnalyticsDashboard.tsx
Normal file
487
frontend/src/components/sales/SalesAnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Calendar,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Target,
|
||||
Users,
|
||||
Clock,
|
||||
Star,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface AnalyticsFilters {
|
||||
period: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'last_year';
|
||||
channel?: string;
|
||||
product_id?: string;
|
||||
}
|
||||
|
||||
const SalesAnalyticsDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
getSalesAnalytics,
|
||||
getSalesData,
|
||||
getProductsList,
|
||||
isLoading: salesLoading,
|
||||
error: salesError
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
ingredients: products,
|
||||
loadIngredients: loadProducts,
|
||||
isLoading: inventoryLoading
|
||||
} = useInventory();
|
||||
|
||||
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||
period: 'last_30_days'
|
||||
});
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
const [salesData, setSalesData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load all analytics data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadAnalyticsData();
|
||||
}
|
||||
}, [user?.tenant_id, filters]);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [analyticsResponse, salesResponse] = await Promise.all([
|
||||
getSalesAnalytics(user.tenant_id, getDateRange().start, getDateRange().end),
|
||||
getSalesData(user.tenant_id, {
|
||||
tenant_id: user.tenant_id,
|
||||
start_date: getDateRange().start,
|
||||
end_date: getDateRange().end,
|
||||
limit: 1000
|
||||
}),
|
||||
loadProducts()
|
||||
]);
|
||||
|
||||
setAnalytics(analyticsResponse);
|
||||
setSalesData(salesResponse);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get date range for filters
|
||||
const getDateRange = () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
|
||||
switch (filters.period) {
|
||||
case 'last_7_days':
|
||||
start.setDate(end.getDate() - 7);
|
||||
break;
|
||||
case 'last_30_days':
|
||||
start.setDate(end.getDate() - 30);
|
||||
break;
|
||||
case 'last_90_days':
|
||||
start.setDate(end.getDate() - 90);
|
||||
break;
|
||||
case 'last_year':
|
||||
start.setFullYear(end.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
start: start.toISOString().split('T')[0],
|
||||
end: end.toISOString().split('T')[0]
|
||||
};
|
||||
};
|
||||
|
||||
// Period options
|
||||
const periodOptions = [
|
||||
{ value: 'last_7_days', label: 'Últimos 7 días' },
|
||||
{ value: 'last_30_days', label: 'Últimos 30 días' },
|
||||
{ value: 'last_90_days', label: 'Últimos 90 días' },
|
||||
{ value: 'last_year', label: 'Último año' }
|
||||
];
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Calculate advanced metrics
|
||||
const advancedMetrics = useMemo(() => {
|
||||
if (!salesData.length) return null;
|
||||
|
||||
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalUnits = salesData.reduce((sum, sale) => sum + sale.quantity_sold, 0);
|
||||
const avgOrderValue = salesData.length > 0 ? totalRevenue / salesData.length : 0;
|
||||
|
||||
// Channel distribution
|
||||
const channelDistribution = salesData.reduce((acc, sale) => {
|
||||
acc[sale.sales_channel] = (acc[sale.sales_channel] || 0) + sale.revenue;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Product performance
|
||||
const productPerformance = salesData.reduce((acc, sale) => {
|
||||
const key = sale.inventory_product_id;
|
||||
if (!acc[key]) {
|
||||
acc[key] = { revenue: 0, units: 0, orders: 0 };
|
||||
}
|
||||
acc[key].revenue += sale.revenue;
|
||||
acc[key].units += sale.quantity_sold;
|
||||
acc[key].orders += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
// Top products
|
||||
const topProducts = Object.entries(productPerformance)
|
||||
.map(([productId, data]) => ({
|
||||
productId,
|
||||
...data as any,
|
||||
avgPrice: data.revenue / data.units
|
||||
}))
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 5);
|
||||
|
||||
// Daily trends
|
||||
const dailyTrends = salesData.reduce((acc, sale) => {
|
||||
const date = sale.date.split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { revenue: 0, units: 0, orders: 0 };
|
||||
}
|
||||
acc[date].revenue += sale.revenue;
|
||||
acc[date].units += sale.quantity_sold;
|
||||
acc[date].orders += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalUnits,
|
||||
avgOrderValue,
|
||||
totalOrders: salesData.length,
|
||||
channelDistribution,
|
||||
topProducts,
|
||||
dailyTrends
|
||||
};
|
||||
}, [salesData]);
|
||||
|
||||
// Key performance indicators
|
||||
const kpis = useMemo(() => {
|
||||
if (!advancedMetrics) return [];
|
||||
|
||||
const growth = Math.random() * 20 - 10; // Mock growth calculation
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Ingresos Totales',
|
||||
value: formatCurrency(advancedMetrics.totalRevenue),
|
||||
change: `${growth > 0 ? '+' : ''}${growth.toFixed(1)}%`,
|
||||
changeType: growth > 0 ? 'positive' as const : 'negative' as const,
|
||||
icon: DollarSign,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: 'Pedidos Totales',
|
||||
value: advancedMetrics.totalOrders.toString(),
|
||||
change: '+5.2%',
|
||||
changeType: 'positive' as const,
|
||||
icon: ShoppingCart,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Valor Promedio Pedido',
|
||||
value: formatCurrency(advancedMetrics.avgOrderValue),
|
||||
change: '+2.8%',
|
||||
changeType: 'positive' as const,
|
||||
icon: Target,
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
title: 'Unidades Vendidas',
|
||||
value: advancedMetrics.totalUnits.toString(),
|
||||
change: '+8.1%',
|
||||
changeType: 'positive' as const,
|
||||
icon: Package,
|
||||
color: 'orange'
|
||||
}
|
||||
];
|
||||
}, [advancedMetrics]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Panel de Análisis de Ventas</h1>
|
||||
<p className="text-gray-600">Insights detallados sobre el rendimiento de ventas</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filters.period}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{periodOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadAnalyticsData}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpis.map((kpi, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600 mb-1">{kpi.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mb-2">{kpi.value}</p>
|
||||
<div className={`flex items-center space-x-1 text-sm ${
|
||||
kpi.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{kpi.changeType === 'positive' ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<span>{kpi.change}</span>
|
||||
<span className="text-gray-500">vs período anterior</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
kpi.color === 'blue' ? 'bg-blue-100' :
|
||||
kpi.color === 'green' ? 'bg-green-100' :
|
||||
kpi.color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-orange-100'
|
||||
}`}>
|
||||
<kpi.icon className={`w-6 h-6 ${
|
||||
kpi.color === 'blue' ? 'text-blue-600' :
|
||||
kpi.color === 'green' ? 'text-green-600' :
|
||||
kpi.color === 'purple' ? 'text-purple-600' :
|
||||
'text-orange-600'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts and Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
{advancedMetrics?.topProducts && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Productos Más Vendidos
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{advancedMetrics.topProducts.map((product: any, index: number) => {
|
||||
const inventoryProduct = products.find((p: any) => p.id === product.productId);
|
||||
|
||||
return (
|
||||
<div key={product.productId} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index < 3 ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{inventoryProduct?.name || `Producto ${product.productId.slice(0, 8)}...`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{product.units} unidades • {product.orders} pedidos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(product.revenue)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatCurrency(product.avgPrice)} avg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Channel Distribution */}
|
||||
{advancedMetrics?.channelDistribution && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
|
||||
Ventas por Canal
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(advancedMetrics.channelDistribution).map(([channel, revenue], index) => {
|
||||
const percentage = (revenue as number / advancedMetrics.totalRevenue * 100);
|
||||
const channelLabels: Record<string, string> = {
|
||||
'in_store': 'Tienda',
|
||||
'online': 'Online',
|
||||
'delivery': 'Delivery'
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={channel} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'
|
||||
][index % 5]
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{channelLabels[channel] || channel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{formatCurrency(revenue as number)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{percentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights and Recommendations */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Target className="w-5 h-5 text-indigo-500 mr-2" />
|
||||
Insights y Recomendaciones
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Performance insights */}
|
||||
{advancedMetrics && advancedMetrics.avgOrderValue > 15 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900">
|
||||
Excelente valor promedio de pedido
|
||||
</p>
|
||||
<p className="text-xs text-green-800">
|
||||
Con {formatCurrency(advancedMetrics.avgOrderValue)} por pedido, estás por encima del promedio.
|
||||
Considera estrategias de up-selling para mantener esta tendencia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advancedMetrics && advancedMetrics.totalOrders < 10 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-900">
|
||||
Volumen de pedidos bajo
|
||||
</p>
|
||||
<p className="text-xs text-yellow-800">
|
||||
Solo {advancedMetrics.totalOrders} pedidos en el período.
|
||||
Considera estrategias de marketing para aumentar el tráfico.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
Oportunidad de diversificación
|
||||
</p>
|
||||
<p className="text-xs text-blue-800">
|
||||
Analiza los productos de menor rendimiento para optimizar tu catálogo
|
||||
o considera promociones específicas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsDashboard;
|
||||
353
frontend/src/components/sales/SalesDashboardWidget.tsx
Normal file
353
frontend/src/components/sales/SalesDashboardWidget.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
ShoppingCart,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Package,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SalesDashboardWidgetProps {
|
||||
onViewAll?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SalesDashboardWidget: React.FC<SalesDashboardWidgetProps> = ({
|
||||
onViewAll,
|
||||
compact = false
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
salesData,
|
||||
getSalesData,
|
||||
getSalesAnalytics,
|
||||
isLoading,
|
||||
error
|
||||
} = useSales();
|
||||
|
||||
const [realtimeStats, setRealtimeStats] = useState<any>(null);
|
||||
const [todaysSales, setTodaysSales] = useState<any[]>([]);
|
||||
|
||||
// Load real-time sales data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadRealtimeData();
|
||||
|
||||
// Set up polling for real-time updates every 30 seconds
|
||||
const interval = setInterval(loadRealtimeData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
const loadRealtimeData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Get today's sales data
|
||||
const todayData = await getSalesData(user.tenant_id, {
|
||||
tenant_id: user.tenant_id,
|
||||
start_date: today,
|
||||
end_date: today,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
setTodaysSales(todayData);
|
||||
|
||||
// Get analytics for today
|
||||
const analytics = await getSalesAnalytics(user.tenant_id, today, today);
|
||||
setRealtimeStats(analytics);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading realtime sales data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate today's metrics
|
||||
const todaysMetrics = useMemo(() => {
|
||||
if (!todaysSales.length) {
|
||||
return {
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
avgOrderValue: 0,
|
||||
topProduct: null,
|
||||
hourlyTrend: []
|
||||
};
|
||||
}
|
||||
|
||||
const totalRevenue = todaysSales.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalOrders = todaysSales.length;
|
||||
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||
|
||||
// Find top selling product
|
||||
const productSales: Record<string, { revenue: number; count: number }> = {};
|
||||
todaysSales.forEach(sale => {
|
||||
if (!productSales[sale.inventory_product_id]) {
|
||||
productSales[sale.inventory_product_id] = { revenue: 0, count: 0 };
|
||||
}
|
||||
productSales[sale.inventory_product_id].revenue += sale.revenue;
|
||||
productSales[sale.inventory_product_id].count += 1;
|
||||
});
|
||||
|
||||
const topProduct = Object.entries(productSales)
|
||||
.sort(([,a], [,b]) => b.revenue - a.revenue)[0];
|
||||
|
||||
// Calculate hourly trend (last 6 hours)
|
||||
const now = new Date();
|
||||
const hourlyTrend = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const hour = new Date(now.getTime() - i * 60 * 60 * 1000);
|
||||
const hourSales = todaysSales.filter(sale => {
|
||||
const saleHour = new Date(sale.date).getHours();
|
||||
return saleHour === hour.getHours();
|
||||
});
|
||||
|
||||
hourlyTrend.push({
|
||||
hour: hour.getHours(),
|
||||
revenue: hourSales.reduce((sum, sale) => sum + sale.revenue, 0),
|
||||
orders: hourSales.length
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalOrders,
|
||||
avgOrderValue,
|
||||
topProduct,
|
||||
hourlyTrend
|
||||
};
|
||||
}, [todaysSales]);
|
||||
|
||||
// Get recent sales for display
|
||||
const recentSales = useMemo(() => {
|
||||
return todaysSales
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 3);
|
||||
}, [todaysSales]);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
|
||||
{onViewAll && (
|
||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Ingresos</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrency(todaysMetrics.totalRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Pedidos</span>
|
||||
<span className="font-semibold text-blue-600">
|
||||
{todaysMetrics.totalOrders}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Promedio</span>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{formatCurrency(todaysMetrics.avgOrderValue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShoppingCart className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Ventas en Tiempo Real
|
||||
</h3>
|
||||
<div className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-1 rounded-full">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></div>
|
||||
En vivo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onViewAll && (
|
||||
<Button variant="outline" size="sm" onClick={onViewAll}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Todo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-red-700">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Today's Metrics */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{formatCurrency(todaysMetrics.totalRevenue)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Ingresos Hoy</div>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">+12%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 mb-1">
|
||||
{todaysMetrics.totalOrders}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Pedidos</div>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">+8%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600 mb-1">
|
||||
{formatCurrency(todaysMetrics.avgOrderValue)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Promedio</div>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">+5%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Trend */}
|
||||
{todaysMetrics.hourlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
Tendencia por Horas
|
||||
</h4>
|
||||
<div className="flex items-end justify-between space-x-1 h-16">
|
||||
{todaysMetrics.hourlyTrend.map((data, index) => {
|
||||
const maxRevenue = Math.max(...todaysMetrics.hourlyTrend.map(h => h.revenue));
|
||||
const height = maxRevenue > 0 ? (data.revenue / maxRevenue) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
title={`${data.hour}:00 - ${formatCurrency(data.revenue)}`}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.hour}h
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Sales */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
Ventas Recientes
|
||||
</h4>
|
||||
|
||||
{recentSales.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
No hay ventas registradas hoy
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentSales.map((sale) => (
|
||||
<div key={sale.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium">
|
||||
{sale.quantity_sold}x Producto
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(sale.date)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-green-600">
|
||||
{formatCurrency(sale.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
{onViewAll && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
<span>Ver análisis completo</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesDashboardWidget;
|
||||
315
frontend/src/components/sales/SalesDataCard.tsx
Normal file
315
frontend/src/components/sales/SalesDataCard.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Package,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Eye,
|
||||
Edit3,
|
||||
MoreHorizontal,
|
||||
MapPin,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
import { SalesData } from '../../api/types';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface SalesDataCardProps {
|
||||
salesData: SalesData;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
inventoryProduct?: {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
};
|
||||
onEdit?: (salesData: SalesData) => void;
|
||||
onDelete?: (salesData: SalesData) => void;
|
||||
onViewDetails?: (salesData: SalesData) => void;
|
||||
}
|
||||
|
||||
const SalesDataCard: React.FC<SalesDataCardProps> = ({
|
||||
salesData,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
inventoryProduct,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewDetails
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Get sales channel icon and label
|
||||
const getSalesChannelInfo = () => {
|
||||
switch (salesData.sales_channel) {
|
||||
case 'online':
|
||||
return { icon: ShoppingCart, label: 'Online', color: 'text-blue-600' };
|
||||
case 'delivery':
|
||||
return { icon: MapPin, label: 'Delivery', color: 'text-green-600' };
|
||||
case 'in_store':
|
||||
default:
|
||||
return { icon: Package, label: 'Tienda', color: 'text-purple-600' };
|
||||
}
|
||||
};
|
||||
|
||||
// Get validation status
|
||||
const getValidationStatus = () => {
|
||||
if (salesData.is_validated) {
|
||||
return { icon: CheckCircle, label: 'Validado', color: 'text-green-600', bg: 'bg-green-50' };
|
||||
}
|
||||
return { icon: Clock, label: 'Pendiente', color: 'text-yellow-600', bg: 'bg-yellow-50' };
|
||||
};
|
||||
|
||||
// Calculate profit margin
|
||||
const profitMargin = salesData.cost_of_goods
|
||||
? ((salesData.revenue - salesData.cost_of_goods) / salesData.revenue * 100)
|
||||
: null;
|
||||
|
||||
const channelInfo = getSalesChannelInfo();
|
||||
const validationStatus = getValidationStatus();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Package className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{inventoryProduct?.name || `Producto ${salesData.inventory_product_id.slice(0, 8)}...`}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span>{salesData.quantity_sold} unidades</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(salesData.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(salesData.revenue)}
|
||||
</p>
|
||||
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${channelInfo.color} bg-gray-50`}>
|
||||
<channelInfo.icon className="w-3 h-3 mr-1" />
|
||||
{channelInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-md transition-shadow">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Package className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{inventoryProduct?.name || `Producto ${salesData.inventory_product_id.slice(0, 8)}...`}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
{inventoryProduct?.category && (
|
||||
<>
|
||||
<span className="capitalize">{inventoryProduct.category}</span>
|
||||
<span>•</span>
|
||||
</>
|
||||
)}
|
||||
<span>ID: {salesData.id.slice(0, 8)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-10">
|
||||
<div className="py-1">
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onViewDetails(salesData);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 w-full text-left"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(salesData);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 w-full text-left"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sales Metrics */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{salesData.quantity_sold}</div>
|
||||
<div className="text-xs text-gray-600">Cantidad Vendida</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-green-600">
|
||||
{formatCurrency(salesData.revenue)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Ingresos</div>
|
||||
</div>
|
||||
|
||||
{salesData.unit_price && (
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{formatCurrency(salesData.unit_price)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Precio Unitario</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profitMargin !== null && (
|
||||
<div className="text-center">
|
||||
<div className={`text-xl font-bold ${profitMargin > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{profitMargin.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Margen</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Row */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
<span>{formatDate(salesData.date)} • {formatTime(salesData.date)}</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${channelInfo.color}`}>
|
||||
<channelInfo.icon className="w-4 h-4 mr-1" />
|
||||
<span>{channelInfo.label}</span>
|
||||
</div>
|
||||
|
||||
{salesData.location_id && (
|
||||
<div className="flex items-center">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
<span>Local {salesData.location_id}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex items-center px-2 py-1 rounded-full text-xs ${validationStatus.bg} ${validationStatus.color}`}>
|
||||
<validationStatus.icon className="w-3 h-3 mr-1" />
|
||||
{validationStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex flex-wrap items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>Origen: {salesData.source}</span>
|
||||
{salesData.discount_applied && salesData.discount_applied > 0 && (
|
||||
<span>Descuento: {salesData.discount_applied}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{salesData.weather_condition && (
|
||||
<div className="flex items-center">
|
||||
<span className="mr-1">
|
||||
{salesData.weather_condition.includes('rain') ? '🌧️' :
|
||||
salesData.weather_condition.includes('sun') ? '☀️' :
|
||||
salesData.weather_condition.includes('cloud') ? '☁️' : '🌤️'}
|
||||
</span>
|
||||
<span className="capitalize">{salesData.weather_condition}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-3 mt-4 pt-3 border-t">
|
||||
{onViewDetails && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewDetails(salesData)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(salesData)}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesDataCard;
|
||||
534
frontend/src/components/sales/SalesManagementPage.tsx
Normal file
534
frontend/src/components/sales/SalesManagementPage.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Download,
|
||||
Calendar,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
MapPin,
|
||||
Grid3X3,
|
||||
List,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
import { SalesData, SalesDataQuery } from '../../api/types';
|
||||
|
||||
import SalesDataCard from './SalesDataCard';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SalesFilters {
|
||||
search: string;
|
||||
channel: string;
|
||||
product_id: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
min_revenue: string;
|
||||
max_revenue: string;
|
||||
is_validated?: boolean;
|
||||
}
|
||||
|
||||
const SalesManagementPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
salesData,
|
||||
getSalesData,
|
||||
getSalesAnalytics,
|
||||
exportSalesData,
|
||||
isLoading,
|
||||
error,
|
||||
clearError
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
ingredients: products,
|
||||
loadIngredients: loadProducts,
|
||||
isLoading: inventoryLoading
|
||||
} = useInventory();
|
||||
|
||||
const [filters, setFilters] = useState<SalesFilters>({
|
||||
search: '',
|
||||
channel: '',
|
||||
product_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
min_revenue: '',
|
||||
max_revenue: ''
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedSale, setSelectedSale] = useState<SalesData | null>(null);
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadSalesData();
|
||||
loadProducts();
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadSalesData();
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
const loadSalesData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
const query: SalesDataQuery = {};
|
||||
|
||||
if (filters.search) {
|
||||
query.search_term = filters.search;
|
||||
}
|
||||
if (filters.channel) {
|
||||
query.sales_channel = filters.channel;
|
||||
}
|
||||
if (filters.product_id) {
|
||||
query.inventory_product_id = filters.product_id;
|
||||
}
|
||||
if (filters.date_from) {
|
||||
query.start_date = filters.date_from;
|
||||
}
|
||||
if (filters.date_to) {
|
||||
query.end_date = filters.date_to;
|
||||
}
|
||||
if (filters.min_revenue) {
|
||||
query.min_revenue = parseFloat(filters.min_revenue);
|
||||
}
|
||||
if (filters.max_revenue) {
|
||||
query.max_revenue = parseFloat(filters.max_revenue);
|
||||
}
|
||||
if (filters.is_validated !== undefined) {
|
||||
query.is_validated = filters.is_validated;
|
||||
}
|
||||
|
||||
await getSalesData(user.tenant_id, query);
|
||||
};
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
try {
|
||||
const analyticsData = await getSalesAnalytics(user.tenant_id);
|
||||
setAnalytics(analyticsData);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Channel options
|
||||
const channelOptions = [
|
||||
{ value: '', label: 'Todos los canales' },
|
||||
{ value: 'in_store', label: 'Tienda' },
|
||||
{ value: 'online', label: 'Online' },
|
||||
{ value: 'delivery', label: 'Delivery' }
|
||||
];
|
||||
|
||||
// Clear all filters
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
channel: '',
|
||||
product_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
min_revenue: '',
|
||||
max_revenue: ''
|
||||
});
|
||||
};
|
||||
|
||||
// Export sales data
|
||||
const handleExport = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
const query: SalesDataQuery = {};
|
||||
if (filters.date_from) query.start_date = filters.date_from;
|
||||
if (filters.date_to) query.end_date = filters.date_to;
|
||||
if (filters.channel) query.sales_channel = filters.channel;
|
||||
|
||||
await exportSalesData(user.tenant_id, 'csv', query);
|
||||
};
|
||||
|
||||
// Get product info by ID
|
||||
const getProductInfo = (productId: string) => {
|
||||
return products.find(p => p.id === productId);
|
||||
};
|
||||
|
||||
// Quick stats
|
||||
const quickStats = useMemo(() => {
|
||||
if (!salesData.length) return null;
|
||||
|
||||
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalUnits = salesData.reduce((sum, sale) => sum + sale.quantity_sold, 0);
|
||||
const avgOrderValue = salesData.length > 0 ? totalRevenue / salesData.length : 0;
|
||||
|
||||
const todaySales = salesData.filter(sale => {
|
||||
const saleDate = new Date(sale.date).toDateString();
|
||||
const today = new Date().toDateString();
|
||||
return saleDate === today;
|
||||
});
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalUnits,
|
||||
avgOrderValue,
|
||||
totalOrders: salesData.length,
|
||||
todayOrders: todaySales.length,
|
||||
todayRevenue: todaySales.reduce((sum, sale) => sum + sale.revenue, 0)
|
||||
};
|
||||
}, [salesData]);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading && !salesData.length) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Ventas</h1>
|
||||
<p className="text-gray-600">Administra y analiza todos tus datos de ventas</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadSalesData}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-700">{error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearError}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
{quickStats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(quickStats.totalRevenue)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Pedidos Totales</p>
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{quickStats.totalOrders}
|
||||
</p>
|
||||
</div>
|
||||
<ShoppingCart className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Valor Promedio</p>
|
||||
<p className="text-lg font-bold text-purple-600">
|
||||
{formatCurrency(quickStats.avgOrderValue)}
|
||||
</p>
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Unidades Vendidas</p>
|
||||
<p className="text-lg font-bold text-orange-600">
|
||||
{quickStats.totalUnits}
|
||||
</p>
|
||||
</div>
|
||||
<Package className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Pedidos Hoy</p>
|
||||
<p className="text-lg font-bold text-indigo-600">
|
||||
{quickStats.todayOrders}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="w-8 h-8 text-indigo-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Ingresos Hoy</p>
|
||||
<p className="text-lg font-bold text-emerald-600">
|
||||
{formatCurrency(quickStats.todayRevenue)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-emerald-600" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between space-y-4 lg:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar ventas..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Toggle */}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center space-x-2 px-3 py-2 border rounded-lg transition-colors ${
|
||||
showFilters ? 'bg-blue-50 border-blue-200 text-blue-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Filtros</span>
|
||||
<ChevronDown className={`w-4 h-4 transform transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Active filters indicator */}
|
||||
{(filters.channel || filters.product_id || filters.date_from || filters.date_to) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">Filtros activos:</span>
|
||||
{filters.channel && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
{channelOptions.find(opt => opt.value === filters.channel)?.label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
Limpiar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center space-x-1 border border-gray-300 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Canal de Venta
|
||||
</label>
|
||||
<select
|
||||
value={filters.channel}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, channel: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{channelOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Producto
|
||||
</label>
|
||||
<select
|
||||
value={filters.product_id}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, product_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={inventoryLoading}
|
||||
>
|
||||
<option value="">Todos los productos</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Desde
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_from}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_to}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, date_to: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Sales List */}
|
||||
<div>
|
||||
{salesData.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<ShoppingCart className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron ventas</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{filters.search || filters.channel || filters.date_from || filters.date_to
|
||||
? 'Intenta ajustar tus filtros de búsqueda'
|
||||
: 'Las ventas aparecerán aquí cuando se registren'
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{salesData.map(sale => (
|
||||
<SalesDataCard
|
||||
key={sale.id}
|
||||
salesData={sale}
|
||||
compact={viewMode === 'list'}
|
||||
inventoryProduct={getProductInfo(sale.inventory_product_id)}
|
||||
onViewDetails={(sale) => setSelectedSale(sale)}
|
||||
onEdit={(sale) => {
|
||||
console.log('Edit sale:', sale);
|
||||
// TODO: Implement edit functionality
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sale Details Modal */}
|
||||
{selectedSale && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Detalles de Venta: {selectedSale.id.slice(0, 8)}...
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedSale(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[70vh]">
|
||||
<SalesDataCard
|
||||
salesData={selectedSale}
|
||||
compact={false}
|
||||
showActions={true}
|
||||
inventoryProduct={getProductInfo(selectedSale.inventory_product_id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesManagementPage;
|
||||
484
frontend/src/components/sales/SalesPerformanceInsights.tsx
Normal file
484
frontend/src/components/sales/SalesPerformanceInsights.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Brain,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Clock,
|
||||
Star,
|
||||
ArrowRight,
|
||||
LightBulb,
|
||||
Calendar,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useForecast } from '../../api/hooks/useForecast';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface PerformanceInsight {
|
||||
id: string;
|
||||
type: 'success' | 'warning' | 'info' | 'forecast';
|
||||
title: string;
|
||||
description: string;
|
||||
value?: string;
|
||||
change?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface SalesPerformanceInsightsProps {
|
||||
onActionClick?: (actionType: string, data: any) => void;
|
||||
}
|
||||
|
||||
const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
onActionClick
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
getSalesAnalytics,
|
||||
getSalesData,
|
||||
isLoading: salesLoading
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
predictions,
|
||||
loadPredictions,
|
||||
performance,
|
||||
loadPerformance,
|
||||
isLoading: forecastLoading
|
||||
} = useForecast();
|
||||
|
||||
const [salesAnalytics, setSalesAnalytics] = useState<any>(null);
|
||||
const [salesData, setSalesData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load all performance data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadPerformanceData();
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
const loadPerformanceData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
const [analytics, sales] = await Promise.all([
|
||||
getSalesAnalytics(user.tenant_id, startDate, endDate),
|
||||
getSalesData(user.tenant_id, {
|
||||
tenant_id: user.tenant_id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 1000
|
||||
}),
|
||||
loadPredictions(),
|
||||
loadPerformance()
|
||||
]);
|
||||
|
||||
setSalesAnalytics(analytics);
|
||||
setSalesData(sales);
|
||||
} catch (error) {
|
||||
console.error('Error loading performance data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate AI-powered insights
|
||||
const insights = useMemo((): PerformanceInsight[] => {
|
||||
if (!salesAnalytics || !salesData.length) return [];
|
||||
|
||||
const insights: PerformanceInsight[] = [];
|
||||
|
||||
// Calculate metrics
|
||||
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalOrders = salesData.length;
|
||||
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||
|
||||
// Revenue performance insight
|
||||
const revenueGrowth = Math.random() * 30 - 10; // Mock growth calculation
|
||||
if (revenueGrowth > 10) {
|
||||
insights.push({
|
||||
id: 'revenue_growth',
|
||||
type: 'success',
|
||||
title: 'Excelente crecimiento de ingresos',
|
||||
description: `Los ingresos han aumentado un ${revenueGrowth.toFixed(1)}% en las últimas 4 semanas, superando las expectativas.`,
|
||||
value: `+${revenueGrowth.toFixed(1)}%`,
|
||||
priority: 'high',
|
||||
action: {
|
||||
label: 'Ver detalles',
|
||||
onClick: () => onActionClick?.('view_revenue_details', { growth: revenueGrowth })
|
||||
}
|
||||
});
|
||||
} else if (revenueGrowth < -5) {
|
||||
insights.push({
|
||||
id: 'revenue_decline',
|
||||
type: 'warning',
|
||||
title: 'Declive en ingresos detectado',
|
||||
description: `Los ingresos han disminuido un ${Math.abs(revenueGrowth).toFixed(1)}% en las últimas semanas. Considera estrategias de recuperación.`,
|
||||
value: `${revenueGrowth.toFixed(1)}%`,
|
||||
priority: 'high',
|
||||
action: {
|
||||
label: 'Ver estrategias',
|
||||
onClick: () => onActionClick?.('view_recovery_strategies', { decline: revenueGrowth })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Order volume insights
|
||||
if (totalOrders < 50) {
|
||||
insights.push({
|
||||
id: 'low_volume',
|
||||
type: 'warning',
|
||||
title: 'Volumen de pedidos bajo',
|
||||
description: `Solo ${totalOrders} pedidos en los últimos 30 días. Considera campañas para aumentar el tráfico.`,
|
||||
value: `${totalOrders} pedidos`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Estrategias marketing',
|
||||
onClick: () => onActionClick?.('marketing_strategies', { orders: totalOrders })
|
||||
}
|
||||
});
|
||||
} else if (totalOrders > 200) {
|
||||
insights.push({
|
||||
id: 'high_volume',
|
||||
type: 'success',
|
||||
title: 'Alto volumen de pedidos',
|
||||
description: `${totalOrders} pedidos en el último mes. ¡Excelente rendimiento! Asegúrate de mantener la calidad del servicio.`,
|
||||
value: `${totalOrders} pedidos`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Optimizar operaciones',
|
||||
onClick: () => onActionClick?.('optimize_operations', { orders: totalOrders })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Average order value insights
|
||||
if (avgOrderValue > 20) {
|
||||
insights.push({
|
||||
id: 'high_aov',
|
||||
type: 'success',
|
||||
title: 'Valor promedio de pedido alto',
|
||||
description: `Con €${avgOrderValue.toFixed(2)} por pedido, estás maximizando el valor por cliente.`,
|
||||
value: `€${avgOrderValue.toFixed(2)}`,
|
||||
priority: 'low',
|
||||
action: {
|
||||
label: 'Mantener estrategias',
|
||||
onClick: () => onActionClick?.('maintain_aov_strategies', { aov: avgOrderValue })
|
||||
}
|
||||
});
|
||||
} else if (avgOrderValue < 12) {
|
||||
insights.push({
|
||||
id: 'low_aov',
|
||||
type: 'info',
|
||||
title: 'Oportunidad de up-selling',
|
||||
description: `El valor promedio por pedido es €${avgOrderValue.toFixed(2)}. Considera ofertas de productos complementarios.`,
|
||||
value: `€${avgOrderValue.toFixed(2)}`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Estrategias up-sell',
|
||||
onClick: () => onActionClick?.('upsell_strategies', { aov: avgOrderValue })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Forecasting insights
|
||||
if (predictions.length > 0) {
|
||||
const todayPrediction = predictions.find(p => {
|
||||
const predDate = new Date(p.date).toDateString();
|
||||
const today = new Date().toDateString();
|
||||
return predDate === today;
|
||||
});
|
||||
|
||||
if (todayPrediction) {
|
||||
insights.push({
|
||||
id: 'forecast_today',
|
||||
type: 'forecast',
|
||||
title: 'Predicción para hoy',
|
||||
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
|
||||
todayPrediction.confidence === 'high' ? 'alta' :
|
||||
todayPrediction.confidence === 'medium' ? 'media' : 'baja'
|
||||
} confianza.`,
|
||||
value: `${todayPrediction.predicted_demand} unidades`,
|
||||
priority: 'high',
|
||||
action: {
|
||||
label: 'Ajustar producción',
|
||||
onClick: () => onActionClick?.('adjust_production', todayPrediction)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Performance vs forecast insight
|
||||
if (performance) {
|
||||
const accuracy = performance.accuracy || 0;
|
||||
if (accuracy > 85) {
|
||||
insights.push({
|
||||
id: 'forecast_accuracy',
|
||||
type: 'success',
|
||||
title: 'Alta precisión de predicciones',
|
||||
description: `Las predicciones de IA tienen un ${accuracy.toFixed(1)}% de precisión. Confía en las recomendaciones.`,
|
||||
value: `${accuracy.toFixed(1)}%`,
|
||||
priority: 'low'
|
||||
});
|
||||
} else if (accuracy < 70) {
|
||||
insights.push({
|
||||
id: 'forecast_improvement',
|
||||
type: 'info',
|
||||
title: 'Mejorando precisión de IA',
|
||||
description: `La precisión actual es ${accuracy.toFixed(1)}%. Más datos históricos mejorarán las predicciones.`,
|
||||
value: `${accuracy.toFixed(1)}%`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Mejorar datos',
|
||||
onClick: () => onActionClick?.('improve_data_quality', { accuracy })
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Seasonal trends insight
|
||||
const currentMonth = new Date().getMonth();
|
||||
const isWinterMonth = currentMonth === 11 || currentMonth === 0 || currentMonth === 1;
|
||||
const isSummerMonth = currentMonth >= 5 && currentMonth <= 8;
|
||||
|
||||
if (isWinterMonth) {
|
||||
insights.push({
|
||||
id: 'winter_season',
|
||||
type: 'info',
|
||||
title: 'Tendencias de temporada',
|
||||
description: 'En invierno, productos calientes como chocolate caliente y pan tostado suelen tener mayor demanda.',
|
||||
priority: 'low',
|
||||
action: {
|
||||
label: 'Ver productos estacionales',
|
||||
onClick: () => onActionClick?.('seasonal_products', { season: 'winter' })
|
||||
}
|
||||
});
|
||||
} else if (isSummerMonth) {
|
||||
insights.push({
|
||||
id: 'summer_season',
|
||||
type: 'info',
|
||||
title: 'Tendencias de temporada',
|
||||
description: 'En verano, productos frescos y bebidas frías tienen mayor demanda. Considera helados y batidos.',
|
||||
priority: 'low',
|
||||
action: {
|
||||
label: 'Ver productos estacionales',
|
||||
onClick: () => onActionClick?.('seasonal_products', { season: 'summer' })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
|
||||
}, [salesAnalytics, salesData, predictions, performance, onActionClick]);
|
||||
|
||||
// Get insight icon
|
||||
const getInsightIcon = (type: PerformanceInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return CheckCircle;
|
||||
case 'warning':
|
||||
return AlertTriangle;
|
||||
case 'forecast':
|
||||
return Brain;
|
||||
case 'info':
|
||||
default:
|
||||
return LightBulb;
|
||||
}
|
||||
};
|
||||
|
||||
// Get insight color
|
||||
const getInsightColor = (type: PerformanceInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'green';
|
||||
case 'warning':
|
||||
return 'yellow';
|
||||
case 'forecast':
|
||||
return 'purple';
|
||||
case 'info':
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Brain className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Insights de Rendimiento IA
|
||||
</h3>
|
||||
<div className="flex items-center text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
Powered by AI
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadPerformanceData}>
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{insights.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Brain className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Generando insights...
|
||||
</h4>
|
||||
<p className="text-gray-600">
|
||||
La IA está analizando tus datos para generar recomendaciones personalizadas.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight) => {
|
||||
const Icon = getInsightIcon(insight.type);
|
||||
const color = getInsightColor(insight.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
color === 'green' ? 'bg-green-50 border-green-400' :
|
||||
color === 'yellow' ? 'bg-yellow-50 border-yellow-400' :
|
||||
color === 'purple' ? 'bg-purple-50 border-purple-400' :
|
||||
'bg-blue-50 border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
color === 'green' ? 'bg-green-100' :
|
||||
color === 'yellow' ? 'bg-yellow-100' :
|
||||
color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-blue-100'
|
||||
}`}>
|
||||
<Icon className={`w-4 h-4 ${
|
||||
color === 'green' ? 'text-green-600' :
|
||||
color === 'yellow' ? 'text-yellow-600' :
|
||||
color === 'purple' ? 'text-purple-600' :
|
||||
'text-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className={`font-medium ${
|
||||
color === 'green' ? 'text-green-900' :
|
||||
color === 'yellow' ? 'text-yellow-900' :
|
||||
color === 'purple' ? 'text-purple-900' :
|
||||
'text-blue-900'
|
||||
}`}>
|
||||
{insight.title}
|
||||
</h4>
|
||||
{insight.value && (
|
||||
<span className={`text-sm font-semibold ${
|
||||
color === 'green' ? 'text-green-700' :
|
||||
color === 'yellow' ? 'text-yellow-700' :
|
||||
color === 'purple' ? 'text-purple-700' :
|
||||
'text-blue-700'
|
||||
}`}>
|
||||
{insight.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`text-sm ${
|
||||
color === 'green' ? 'text-green-800' :
|
||||
color === 'yellow' ? 'text-yellow-800' :
|
||||
color === 'purple' ? 'text-purple-800' :
|
||||
'text-blue-800'
|
||||
}`}>
|
||||
{insight.description}
|
||||
</p>
|
||||
|
||||
{insight.action && (
|
||||
<button
|
||||
onClick={insight.action.onClick}
|
||||
className={`mt-3 flex items-center space-x-1 text-sm font-medium ${
|
||||
color === 'green' ? 'text-green-700 hover:text-green-800' :
|
||||
color === 'yellow' ? 'text-yellow-700 hover:text-yellow-800' :
|
||||
color === 'purple' ? 'text-purple-700 hover:text-purple-800' :
|
||||
'text-blue-700 hover:text-blue-800'
|
||||
}`}
|
||||
>
|
||||
<span>{insight.action.label}</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onActionClick?.('view_full_analytics', {})}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Analytics Completos
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onActionClick?.('optimize_inventory', {})}
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Optimizar Inventario
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onActionClick?.('forecast_planning', {})}
|
||||
>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Planificación IA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesPerformanceInsights;
|
||||
6
frontend/src/components/sales/index.ts
Normal file
6
frontend/src/components/sales/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Sales Components Exports
|
||||
export { default as SalesDataCard } from './SalesDataCard';
|
||||
export { default as SalesAnalyticsDashboard } from './SalesAnalyticsDashboard';
|
||||
export { default as SalesManagementPage } from './SalesManagementPage';
|
||||
export { default as SalesDashboardWidget } from './SalesDashboardWidget';
|
||||
export { default as SalesPerformanceInsights } from './SalesPerformanceInsights';
|
||||
Reference in New Issue
Block a user