610 lines
23 KiB
TypeScript
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; |