Files
bakery-ia/frontend/src/components/suppliers/SupplierAnalyticsDashboard.tsx
2025-08-13 17:39:35 +02:00

610 lines
23 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import {
BarChart3,
TrendingUp,
TrendingDown,
DollarSign,
Package,
Clock,
Star,
AlertCircle,
Building,
Truck,
CheckCircle,
Calendar,
Filter,
Download,
RefreshCw
} from 'lucide-react';
import {
useSuppliers,
usePurchaseOrders,
useDeliveries
} from '../../api/hooks/useSuppliers';
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';
supplier_id?: string;
supplier_type?: string;
}
const SupplierAnalyticsDashboard: React.FC = () => {
const { user } = useAuth();
const {
statistics: supplierStats,
activeSuppliers,
topSuppliers,
loadStatistics: loadSupplierStats,
loadActiveSuppliers,
loadTopSuppliers
} = useSuppliers();
const {
statistics: orderStats,
loadStatistics: loadOrderStats
} = usePurchaseOrders();
const {
performanceStats: deliveryStats,
loadPerformanceStats: loadDeliveryStats
} = useDeliveries();
const [filters, setFilters] = useState<AnalyticsFilters>({
period: 'last_30_days'
});
const [isLoading, setIsLoading] = useState(true);
// Load all analytics data
useEffect(() => {
if (user?.tenant_id) {
loadAnalyticsData();
}
}, [user?.tenant_id, filters]);
const loadAnalyticsData = async () => {
setIsLoading(true);
try {
await Promise.all([
loadSupplierStats(),
loadActiveSuppliers(),
loadTopSuppliers(10),
loadOrderStats(),
loadDeliveryStats(getPeriodDays(filters.period))
]);
} catch (error) {
console.error('Error loading analytics data:', error);
} finally {
setIsLoading(false);
}
};
// Convert period to days
const getPeriodDays = (period: string) => {
switch (period) {
case 'last_7_days': return 7;
case 'last_30_days': return 30;
case 'last_90_days': return 90;
case 'last_year': return 365;
default: return 30;
}
};
// 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);
};
// Format percentage
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
// Calculate performance metrics
const performanceMetrics = useMemo(() => {
if (!supplierStats || !orderStats || !deliveryStats) return null;
return {
supplierGrowth: supplierStats.active_suppliers > 0 ?
((supplierStats.total_suppliers - supplierStats.active_suppliers) / supplierStats.active_suppliers * 100) : 0,
orderGrowth: orderStats.this_month_orders > 0 ? 15 : 0, // Mock growth calculation
spendEfficiency: deliveryStats.quality_pass_rate,
deliveryReliability: deliveryStats.on_time_percentage
};
}, [supplierStats, orderStats, deliveryStats]);
// Key performance indicators
const kpis = useMemo(() => {
if (!supplierStats || !orderStats || !deliveryStats) return [];
return [
{
title: 'Gasto Total',
value: formatCurrency(supplierStats.total_spend),
change: '+12.5%',
changeType: 'positive' as const,
icon: DollarSign,
color: 'blue'
},
{
title: 'Pedidos Este Mes',
value: orderStats.this_month_orders.toString(),
change: '+8.3%',
changeType: 'positive' as const,
icon: Package,
color: 'green'
},
{
title: 'Entregas a Tiempo',
value: formatPercentage(deliveryStats.on_time_percentage),
change: deliveryStats.on_time_percentage > 85 ? '+2.1%' : '-1.5%',
changeType: deliveryStats.on_time_percentage > 85 ? 'positive' as const : 'negative' as const,
icon: Clock,
color: 'orange'
},
{
title: 'Calidad Promedio',
value: formatPercentage(supplierStats.avg_quality_rating * 20), // Convert from 5-star to percentage
change: '+3.2%',
changeType: 'positive' as const,
icon: Star,
color: 'purple'
}
];
}, [supplierStats, orderStats, deliveryStats]);
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 Proveedores</h1>
<p className="text-gray-600">Insights y métricas de rendimiento de tus proveedores</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}
>
<RefreshCw className="w-4 h-4 mr-2" />
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 === 'orange' ? 'bg-orange-100' :
'bg-purple-100'
}`}>
<kpi.icon className={`w-6 h-6 ${
kpi.color === 'blue' ? 'text-blue-600' :
kpi.color === 'green' ? 'text-green-600' :
kpi.color === 'orange' ? 'text-orange-600' :
'text-purple-600'
}`} />
</div>
</div>
</Card>
))}
</div>
{/* Performance Overview */}
{performanceMetrics && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Supplier Performance */}
<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">
<Building className="w-5 h-5 text-blue-500 mr-2" />
Rendimiento de Proveedores
</h3>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Proveedores Activos</span>
<div className="flex items-center space-x-2">
<div className="w-32 h-2 bg-gray-200 rounded-full">
<div
className="h-2 bg-green-500 rounded-full"
style={{
width: `${(supplierStats?.active_suppliers / supplierStats?.total_suppliers * 100) || 0}%`
}}
/>
</div>
<span className="text-sm font-semibold text-gray-900">
{supplierStats?.active_suppliers}/{supplierStats?.total_suppliers}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Calidad Promedio</span>
<div className="flex items-center space-x-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < (supplierStats?.avg_quality_rating || 0)
? 'text-yellow-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
<span className="text-sm font-semibold text-gray-900">
{supplierStats?.avg_quality_rating.toFixed(1)}
</span>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Entregas Puntuales</span>
<div className="flex items-center space-x-2">
<div className="flex items-center">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${
i < (supplierStats?.avg_delivery_rating || 0)
? 'text-blue-400 fill-current'
: 'text-gray-300'
}`}
/>
))}
</div>
<span className="text-sm font-semibold text-gray-900">
{supplierStats?.avg_delivery_rating.toFixed(1)}
</span>
</div>
</div>
</div>
</div>
</Card>
{/* Delivery Performance */}
<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">
<Truck className="w-5 h-5 text-green-500 mr-2" />
Rendimiento de Entregas
</h3>
</div>
<div className="space-y-6">
<div className="text-center">
<div className="relative w-32 h-32 mx-auto mb-4">
<svg className="w-32 h-32 transform -rotate-90" viewBox="0 0 100 100">
<circle
cx="50"
cy="50"
r="40"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-gray-200"
/>
<circle
cx="50"
cy="50"
r="40"
stroke="currentColor"
strokeWidth="8"
fill="none"
strokeDasharray={`${(deliveryStats?.on_time_percentage || 0) * 2.51} 251`}
className="text-green-500"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{formatPercentage(deliveryStats?.on_time_percentage || 0)}
</div>
<div className="text-xs text-gray-500">A tiempo</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-xl font-bold text-green-600">
{deliveryStats?.on_time_deliveries || 0}
</div>
<div className="text-xs text-gray-600">A Tiempo</div>
</div>
<div>
<div className="text-xl font-bold text-red-600">
{deliveryStats?.late_deliveries || 0}
</div>
<div className="text-xs text-gray-600">Tardías</div>
</div>
</div>
</div>
</div>
</Card>
</div>
)}
{/* Top Suppliers and Insights */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Suppliers */}
{topSuppliers.length > 0 && (
<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">
<TrendingUp className="w-5 h-5 text-purple-500 mr-2" />
Mejores Proveedores
</h3>
</div>
<div className="space-y-4">
{topSuppliers.slice(0, 5).map((supplier, index) => (
<div key={supplier.id} className="flex items-center justify-between py-2">
<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 === 0 ? 'bg-yellow-100 text-yellow-800' :
index === 1 ? 'bg-gray-100 text-gray-800' :
index === 2 ? 'bg-orange-100 text-orange-800' :
'bg-blue-100 text-blue-800'
}`}>
{index + 1}
</div>
<div>
<p className="font-medium text-gray-900">{supplier.name}</p>
<p className="text-sm text-gray-500">
{supplier.total_orders} pedidos
</p>
</div>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">
{formatCurrency(supplier.total_amount)}
</p>
<div className="flex items-center space-x-1">
<Star className="w-3 h-3 text-yellow-400 fill-current" />
<span className="text-xs text-gray-600">
{supplier.quality_rating?.toFixed(1) || 'N/A'}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
)}
{/* 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">
<BarChart3 className="w-5 h-5 text-indigo-500 mr-2" />
Insights y Recomendaciones
</h3>
</div>
<div className="space-y-4">
{/* Performance insights */}
{deliveryStats && deliveryStats.on_time_percentage > 90 && (
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-green-900">
Excelente rendimiento de entregas
</p>
<p className="text-xs text-green-800">
{formatPercentage(deliveryStats.on_time_percentage)} de entregas a tiempo.
¡Mantén la buena comunicación con tus proveedores!
</p>
</div>
</div>
)}
{deliveryStats && deliveryStats.on_time_percentage < 80 && (
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-yellow-900">
Oportunidad de mejora en entregas
</p>
<p className="text-xs text-yellow-800">
Solo {formatPercentage(deliveryStats.on_time_percentage)} de entregas a tiempo.
Considera revisar los acuerdos de servicio con tus proveedores.
</p>
</div>
</div>
)}
{supplierStats && supplierStats.pending_suppliers > 0 && (
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
<Clock className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
Proveedores pendientes de aprobación
</p>
<p className="text-xs text-blue-800">
Tienes {supplierStats.pending_suppliers} proveedores esperando aprobación.
Revísalos para acelerar tu cadena de suministro.
</p>
</div>
</div>
)}
{orderStats && orderStats.overdue_count > 0 && (
<div className="flex items-start space-x-3 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-900">
Pedidos vencidos
</p>
<p className="text-xs text-red-800">
{orderStats.overdue_count} pedidos han superado su fecha de entrega.
Contacta con tus proveedores para actualizar el estado.
</p>
</div>
</div>
)}
{supplierStats && supplierStats.avg_quality_rating > 4 && (
<div className="flex items-start space-x-3 p-3 bg-purple-50 rounded-lg">
<Star className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-purple-900">
Alta calidad de proveedores
</p>
<p className="text-xs text-purple-800">
Calidad promedio de {supplierStats.avg_quality_rating.toFixed(1)}/5.
Considera destacar estos proveedores como preferidos.
</p>
</div>
</div>
)}
</div>
</div>
</Card>
</div>
{/* Detailed Metrics */}
{orderStats && (
<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">
<Package className="w-5 h-5 text-blue-500 mr-2" />
Métricas Detalladas de Pedidos
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">
{orderStats.total_orders}
</div>
<p className="text-sm text-gray-600">Total Pedidos</p>
<div className="mt-2 text-xs text-gray-500">
{formatCurrency(orderStats.avg_order_value)} valor promedio
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-green-600">
{orderStats.this_month_orders}
</div>
<p className="text-sm text-gray-600">Este Mes</p>
<div className="mt-2 text-xs text-gray-500">
{formatCurrency(orderStats.this_month_spend)} gastado
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">
{orderStats.pending_approval}
</div>
<p className="text-sm text-gray-600">Pendientes Aprobación</p>
<div className="mt-2 text-xs text-gray-500">
Requieren revisión
</div>
</div>
</div>
{/* Order Status Breakdown */}
{orderStats.status_counts && (
<div className="mt-8">
<h4 className="font-medium text-gray-900 mb-4">Distribución por Estado</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(orderStats.status_counts).map(([status, count]) => (
<div key={status} className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-lg font-semibold text-gray-900">
{count as number}
</div>
<div className="text-xs text-gray-600 capitalize">
{status.toLowerCase().replace('_', ' ')}
</div>
</div>
))}
</div>
</div>
)}
</div>
</Card>
)}
</div>
);
};
export default SupplierAnalyticsDashboard;