Improve the design of the frontend

This commit is contained in:
Urtzi Alfaro
2025-08-08 19:21:23 +02:00
parent 488bb3ef93
commit 62ca49d4b8
53 changed files with 5395 additions and 5387 deletions

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