408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
import { useState } from 'react';
|
|
import { BarChart3, TrendingUp, TrendingDown, Award, Target, Eye, EyeOff } from 'lucide-react';
|
|
|
|
interface BenchmarkMetric {
|
|
id: string;
|
|
name: string;
|
|
yourValue: number;
|
|
industryAverage: number;
|
|
topPerformers: number;
|
|
unit: string;
|
|
description: string;
|
|
category: 'efficiency' | 'revenue' | 'waste' | 'customer' | 'quality';
|
|
trend: 'improving' | 'declining' | 'stable';
|
|
percentile: number; // Your position (0-100)
|
|
insights: string[];
|
|
}
|
|
|
|
interface CompetitiveBenchmarksProps {
|
|
metrics?: BenchmarkMetric[];
|
|
location?: string; // e.g., "Madrid Centro"
|
|
showSensitiveData?: boolean;
|
|
className?: string;
|
|
}
|
|
|
|
const CompetitiveBenchmarks: React.FC<CompetitiveBenchmarksProps> = ({
|
|
metrics: propMetrics,
|
|
location = "Madrid Centro",
|
|
showSensitiveData = true,
|
|
className = ''
|
|
}) => {
|
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
|
const [showDetails, setShowDetails] = useState<boolean>(showSensitiveData);
|
|
|
|
// Sample benchmark data (anonymized)
|
|
const defaultMetrics: BenchmarkMetric[] = [
|
|
{
|
|
id: 'forecast_accuracy',
|
|
name: 'Precisión de Predicciones',
|
|
yourValue: 87.2,
|
|
industryAverage: 72.5,
|
|
topPerformers: 94.1,
|
|
unit: '%',
|
|
description: 'Qué tan precisas son las predicciones vs. ventas reales',
|
|
category: 'quality',
|
|
trend: 'improving',
|
|
percentile: 85,
|
|
insights: [
|
|
'Superioridad del 15% vs. promedio de la industria',
|
|
'Solo 7 puntos por debajo de los mejores del sector',
|
|
'Mejora consistente en los últimos 3 meses'
|
|
]
|
|
},
|
|
{
|
|
id: 'waste_percentage',
|
|
name: 'Porcentaje de Desperdicio',
|
|
yourValue: 8.3,
|
|
industryAverage: 12.7,
|
|
topPerformers: 4.2,
|
|
unit: '%',
|
|
description: 'Productos no vendidos como % del total producido',
|
|
category: 'waste',
|
|
trend: 'improving',
|
|
percentile: 78,
|
|
insights: [
|
|
'35% menos desperdicio que el promedio',
|
|
'Oportunidad: reducir 4 puntos más para llegar al top',
|
|
'Ahorro de ~€230/mes vs. promedio de la industria'
|
|
]
|
|
},
|
|
{
|
|
id: 'revenue_per_sqm',
|
|
name: 'Ingresos por m²',
|
|
yourValue: 2847,
|
|
industryAverage: 2134,
|
|
topPerformers: 4521,
|
|
unit: '€/mes',
|
|
description: 'Ingresos mensuales por metro cuadrado de local',
|
|
category: 'revenue',
|
|
trend: 'stable',
|
|
percentile: 73,
|
|
insights: [
|
|
'33% más eficiente en generación de ingresos',
|
|
'Potencial de crecimiento: +59% para alcanzar el top',
|
|
'Excelente aprovechamiento del espacio'
|
|
]
|
|
},
|
|
{
|
|
id: 'customer_retention',
|
|
name: 'Retención de Clientes',
|
|
yourValue: 68,
|
|
industryAverage: 61,
|
|
topPerformers: 84,
|
|
unit: '%',
|
|
description: 'Clientes que regresan al menos una vez por semana',
|
|
category: 'customer',
|
|
trend: 'improving',
|
|
percentile: 67,
|
|
insights: [
|
|
'11% mejor retención que la competencia',
|
|
'Oportunidad: programas de fidelización podrían sumar 16 puntos',
|
|
'Base de clientes sólida y leal'
|
|
]
|
|
},
|
|
{
|
|
id: 'production_efficiency',
|
|
name: 'Eficiencia de Producción',
|
|
yourValue: 1.8,
|
|
industryAverage: 2.3,
|
|
topPerformers: 1.2,
|
|
unit: 'h/100 unidades',
|
|
description: 'Tiempo promedio para producir 100 unidades',
|
|
category: 'efficiency',
|
|
trend: 'improving',
|
|
percentile: 71,
|
|
insights: [
|
|
'22% más rápido que el promedio',
|
|
'Excelente optimización de procesos',
|
|
'Margen para mejora: -33% para ser top performer'
|
|
]
|
|
},
|
|
{
|
|
id: 'profit_margin',
|
|
name: 'Margen de Ganancia',
|
|
yourValue: 32.5,
|
|
industryAverage: 28.1,
|
|
topPerformers: 41.7,
|
|
unit: '%',
|
|
description: 'Ganancia neta como % de los ingresos totales',
|
|
category: 'revenue',
|
|
trend: 'stable',
|
|
percentile: 69,
|
|
insights: [
|
|
'16% más rentable que la competencia',
|
|
'Sólida gestión de costos',
|
|
'Oportunidad: optimizar ingredientes premium'
|
|
]
|
|
}
|
|
];
|
|
|
|
const metrics = propMetrics || defaultMetrics;
|
|
|
|
const categories = [
|
|
{ id: 'all', name: 'Todas', count: metrics.length },
|
|
{ id: 'revenue', name: 'Ingresos', count: metrics.filter(m => m.category === 'revenue').length },
|
|
{ id: 'efficiency', name: 'Eficiencia', count: metrics.filter(m => m.category === 'efficiency').length },
|
|
{ id: 'waste', name: 'Desperdicio', count: metrics.filter(m => m.category === 'waste').length },
|
|
{ id: 'customer', name: 'Clientes', count: metrics.filter(m => m.category === 'customer').length },
|
|
{ id: 'quality', name: 'Calidad', count: metrics.filter(m => m.category === 'quality').length }
|
|
];
|
|
|
|
const filteredMetrics = metrics.filter(metric =>
|
|
selectedCategory === 'all' || metric.category === selectedCategory
|
|
);
|
|
|
|
const getPerformanceLevel = (percentile: number) => {
|
|
if (percentile >= 90) return { label: 'Excelente', color: 'text-green-600', bg: 'bg-green-50' };
|
|
if (percentile >= 75) return { label: 'Bueno', color: 'text-blue-600', bg: 'bg-blue-50' };
|
|
if (percentile >= 50) return { label: 'Promedio', color: 'text-yellow-600', bg: 'bg-yellow-50' };
|
|
return { label: 'Mejora Necesaria', color: 'text-red-600', bg: 'bg-red-50' };
|
|
};
|
|
|
|
const getTrendIcon = (trend: string) => {
|
|
switch (trend) {
|
|
case 'improving': return <TrendingUp className="h-4 w-4 text-green-600" />;
|
|
case 'declining': return <TrendingDown className="h-4 w-4 text-red-600" />;
|
|
default: return <div className="w-4 h-4 bg-gray-400 rounded-full"></div>;
|
|
}
|
|
};
|
|
|
|
const getComparisonPercentage = (yourValue: number, compareValue: number, isLowerBetter = false) => {
|
|
const diff = isLowerBetter
|
|
? ((compareValue - yourValue) / compareValue) * 100
|
|
: ((yourValue - compareValue) / compareValue) * 100;
|
|
return {
|
|
value: Math.abs(diff),
|
|
isPositive: diff > 0
|
|
};
|
|
};
|
|
|
|
const isLowerBetter = (metricId: string) => {
|
|
return ['waste_percentage', 'production_efficiency'].includes(metricId);
|
|
};
|
|
|
|
const averagePercentile = Math.round(metrics.reduce((sum, m) => sum + m.percentile, 0) / metrics.length);
|
|
|
|
return (
|
|
<div className={`bg-white rounded-xl shadow-soft ${className}`}>
|
|
{/* Header */}
|
|
<div className="p-6 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center">
|
|
<BarChart3 className="h-6 w-6 text-indigo-600 mr-3" />
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
Benchmarks Competitivos
|
|
</h3>
|
|
<p className="text-sm text-gray-600">
|
|
Comparación anónima con panaderías similares en {location}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
{/* Overall Score */}
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-indigo-600">
|
|
{averagePercentile}
|
|
</div>
|
|
<div className="text-xs text-gray-600">Percentil General</div>
|
|
</div>
|
|
|
|
{/* Toggle Details */}
|
|
<button
|
|
onClick={() => setShowDetails(!showDetails)}
|
|
className="p-2 rounded-lg hover:bg-gray-100 text-gray-600"
|
|
title={showDetails ? "Ocultar detalles" : "Mostrar detalles"}
|
|
>
|
|
{showDetails ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Performance Summary */}
|
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
|
<div className="bg-green-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-green-600">
|
|
{metrics.filter(m => m.percentile >= 75).length}
|
|
</div>
|
|
<div className="text-xs text-green-700">Métricas Top 25%</div>
|
|
</div>
|
|
<div className="bg-blue-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-blue-600">
|
|
{metrics.filter(m => m.trend === 'improving').length}
|
|
</div>
|
|
<div className="text-xs text-blue-700">En Mejora</div>
|
|
</div>
|
|
<div className="bg-yellow-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-yellow-600">
|
|
{metrics.filter(m => m.percentile < 50).length}
|
|
</div>
|
|
<div className="text-xs text-yellow-700">Áreas de Oportunidad</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Filters */}
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{categories.map(category => (
|
|
<button
|
|
key={category.id}
|
|
onClick={() => setSelectedCategory(category.id)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
|
selectedCategory === category.id
|
|
? 'bg-indigo-100 text-indigo-800 border border-indigo-200'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{category.name}
|
|
<span className="ml-1.5 text-xs bg-white rounded-full px-1.5 py-0.5">
|
|
{category.count}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics List */}
|
|
<div className="divide-y divide-gray-100">
|
|
{filteredMetrics.map(metric => {
|
|
const performance = getPerformanceLevel(metric.percentile);
|
|
const vsAverage = getComparisonPercentage(
|
|
metric.yourValue,
|
|
metric.industryAverage,
|
|
isLowerBetter(metric.id)
|
|
);
|
|
const vsTop = getComparisonPercentage(
|
|
metric.yourValue,
|
|
metric.topPerformers,
|
|
isLowerBetter(metric.id)
|
|
);
|
|
|
|
return (
|
|
<div key={metric.id} className="p-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-3">
|
|
<h4 className="text-lg font-medium text-gray-900">
|
|
{metric.name}
|
|
</h4>
|
|
{getTrendIcon(metric.trend)}
|
|
<span className={`px-2 py-1 text-xs rounded-full ${performance.bg} ${performance.color}`}>
|
|
{performance.label}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-1">{metric.description}</p>
|
|
</div>
|
|
|
|
<div className="text-right ml-4">
|
|
<div className="text-2xl font-bold text-gray-900">
|
|
{metric.yourValue.toLocaleString('es-ES')}<span className="text-sm text-gray-500">{metric.unit}</span>
|
|
</div>
|
|
<div className="text-sm text-gray-600">Tu Resultado</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comparison Bars */}
|
|
<div className="space-y-3">
|
|
{/* Your Performance */}
|
|
<div className="relative">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-sm font-medium text-gray-900">Tu Rendimiento</span>
|
|
<span className="text-sm text-indigo-600 font-medium">Percentil {metric.percentile}</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-indigo-600 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${metric.percentile}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Industry Average */}
|
|
<div className="relative">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-sm text-gray-600">Promedio Industria</span>
|
|
<span className="text-sm text-gray-600">
|
|
{metric.industryAverage.toLocaleString('es-ES')}{metric.unit}
|
|
<span className={`ml-2 ${vsAverage.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
|
({vsAverage.isPositive ? '+' : '-'}{vsAverage.value.toFixed(1)}%)
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
|
<div
|
|
className="bg-gray-400 h-1.5 rounded-full"
|
|
style={{ width: '50%' }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Performers */}
|
|
<div className="relative">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-sm text-gray-600 flex items-center">
|
|
<Award className="h-3 w-3 mr-1 text-yellow-500" />
|
|
Top Performers
|
|
</span>
|
|
<span className="text-sm text-gray-600">
|
|
{metric.topPerformers.toLocaleString('es-ES')}{metric.unit}
|
|
<span className={`ml-2 ${vsTop.isPositive ? 'text-green-600' : 'text-orange-600'}`}>
|
|
({vsTop.isPositive ? '+' : '-'}{vsTop.value.toFixed(1)}%)
|
|
</span>
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
|
<div
|
|
className="bg-yellow-400 h-1.5 rounded-full"
|
|
style={{ width: '90%' }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Insights */}
|
|
{showDetails && (
|
|
<div className="mt-4 p-4 bg-gray-50 rounded-lg">
|
|
<h5 className="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
|
<Target className="h-4 w-4 mr-2 text-indigo-600" />
|
|
Insights Clave:
|
|
</h5>
|
|
<ul className="space-y-1">
|
|
{metric.insights.map((insight, index) => (
|
|
<li key={index} className="text-sm text-gray-600 flex items-start">
|
|
<div className="w-1.5 h-1.5 bg-indigo-400 rounded-full mt-2 mr-2 flex-shrink-0"></div>
|
|
{insight}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{filteredMetrics.length === 0 && (
|
|
<div className="p-8 text-center">
|
|
<BarChart3 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
|
<p className="text-gray-500">No hay métricas disponibles</p>
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
Los benchmarks aparecerán cuando haya suficientes datos
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Disclaimer */}
|
|
<div className="px-6 pb-4">
|
|
<div className="bg-gray-50 rounded-lg p-3">
|
|
<div className="text-xs text-gray-600">
|
|
<strong>🔒 Privacidad:</strong> Todos los datos están anonimizados.
|
|
Solo se comparten métricas agregadas de panaderías similares en tamaño y ubicación.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CompetitiveBenchmarks; |