Improve the design of the frontend
This commit is contained in:
408
frontend/src/components/ui/CompetitiveBenchmarks.tsx
Normal file
408
frontend/src/components/ui/CompetitiveBenchmarks.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user