Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';