Files
bakery-ia/frontend/src/components/domain/dashboard/KPIWidget.tsx
2025-08-28 10:41:04 +02:00

502 lines
15 KiB
TypeScript

import React, { useMemo } from 'react';
import { clsx } from 'clsx';
import { Badge } from '../../ui';
import DashboardCard from './DashboardCard';
export interface KPIValue {
current: number;
previous?: number;
target?: number;
format: 'currency' | 'number' | 'percentage';
prefix?: string;
suffix?: string;
}
export interface KPITrend {
direction: 'up' | 'down' | 'stable';
value: number;
isPositive: boolean;
comparisonPeriod: string;
description?: string;
}
export interface KPIThreshold {
excellent: number;
good: number;
warning: number;
critical: number;
}
export interface SparklineDataPoint {
date: string;
value: number;
label?: string;
}
export interface KPIWidgetProps {
title: string;
subtitle?: string;
value: KPIValue;
trend?: KPITrend;
// Visual configuration
icon?: React.ReactNode;
color?: 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'indigo' | 'teal';
variant?: 'default' | 'compact' | 'detailed' | 'chart';
// Chart data
sparklineData?: SparklineDataPoint[];
showSparkline?: boolean;
// Thresholds and status
thresholds?: KPIThreshold;
status?: 'excellent' | 'good' | 'warning' | 'critical' | 'neutral';
// Comparison and context
comparisonLabel?: string;
contextInfo?: string;
// Interactive features
isLoading?: boolean;
onRefresh?: () => void;
onClick?: () => void;
// Accessibility and styling
className?: string;
'aria-label'?: string;
}
// Predefined bakery KPI configurations
export const BAKERY_KPI_CONFIGS = {
dailyRevenue: {
title: 'Ingresos Hoy',
subtitle: 'Ventas del día actual',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
color: 'green' as const,
format: 'currency' as const
},
orderCount: {
title: 'Pedidos',
subtitle: 'Órdenes procesadas hoy',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
color: 'blue' as const,
format: 'number' as const
},
productivity: {
title: 'Productividad',
subtitle: 'Unidades producidas por hora',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
),
color: 'orange' as const,
format: 'number' as const,
suffix: '/h'
},
stockLevel: {
title: 'Nivel Stock',
subtitle: 'Porcentaje de stock disponible',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
color: 'purple' as const,
format: 'percentage' as const
}
};
const formatValue = (value: number, format: KPIValue['format'], prefix?: string, suffix?: string): string => {
let formatted: string;
switch (format) {
case 'currency':
formatted = new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 0,
maximumFractionDigits: 2
}).format(value);
break;
case 'percentage':
formatted = new Intl.NumberFormat('es-ES', {
style: 'percent',
minimumFractionDigits: 0,
maximumFractionDigits: 1
}).format(value / 100);
break;
case 'number':
default:
formatted = new Intl.NumberFormat('es-ES').format(value);
break;
}
return `${prefix || ''}${formatted}${suffix || ''}`;
};
const calculateTrend = (current: number, previous: number): KPITrend => {
// Handle undefined or null values
if (current == null || previous == null ||
typeof current !== 'number' || typeof previous !== 'number' ||
isNaN(current) || isNaN(previous)) {
return {
direction: 'stable',
value: 0,
isPositive: true,
comparisonPeriod: 'vs período anterior'
};
}
const change = current - previous;
const percentChange = previous !== 0 ? (change / previous) * 100 : 0;
return {
direction: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
value: Math.abs(percentChange),
isPositive: change >= 0,
comparisonPeriod: 'vs período anterior'
};
};
const getStatusColor = (status: KPIWidgetProps['status']) => {
switch (status) {
case 'excellent':
return {
bg: 'bg-[var(--color-success)]/10',
text: 'text-[var(--color-success)]',
border: 'border-[var(--color-success)]/20',
icon: 'text-[var(--color-success)]'
};
case 'good':
return {
bg: 'bg-[var(--color-info)]/10',
text: 'text-[var(--color-info)]',
border: 'border-[var(--color-info)]/20',
icon: 'text-[var(--color-info)]'
};
case 'warning':
return {
bg: 'bg-[var(--color-warning)]/10',
text: 'text-[var(--color-warning)]',
border: 'border-[var(--color-warning)]/20',
icon: 'text-[var(--color-warning)]'
};
case 'critical':
return {
bg: 'bg-[var(--color-error)]/10',
text: 'text-[var(--color-error)]',
border: 'border-[var(--color-error)]/20',
icon: 'text-[var(--color-error)]'
};
default:
return {
bg: 'bg-[var(--bg-tertiary)]',
text: 'text-[var(--text-secondary)]',
border: 'border-[var(--border-primary)]',
icon: 'text-[var(--text-tertiary)]'
};
}
};
const SimpleSparkline: React.FC<{ data: SparklineDataPoint[]; color: string }> = ({ data, color }) => {
const max = Math.max(...data.map(d => d.value));
const min = Math.min(...data.map(d => d.value));
const range = max - min;
const points = data.map((point, index) => {
const x = (index / (data.length - 1)) * 100;
const y = range === 0 ? 50 : ((max - point.value) / range) * 100;
return `${x},${y}`;
}).join(' ');
const colorClasses = {
blue: 'stroke-[var(--color-info)]',
green: 'stroke-[var(--color-success)]',
orange: 'stroke-[var(--color-primary)]',
red: 'stroke-[var(--color-error)]',
purple: 'stroke-[var(--color-info)]',
indigo: 'stroke-[var(--color-info)]',
teal: 'stroke-[var(--color-success)]'
};
return (
<div className="w-full h-12 mt-2">
<svg
width="100%"
height="100%"
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="overflow-visible"
>
<polyline
points={points}
fill="none"
strokeWidth="2"
className={clsx(colorClasses[color as keyof typeof colorClasses] || colorClasses.blue)}
/>
</svg>
</div>
);
};
const KPIWidget: React.FC<KPIWidgetProps> = ({
title,
subtitle,
value,
trend,
icon,
color = 'blue',
variant = 'default',
sparklineData,
showSparkline = false,
thresholds,
status,
comparisonLabel,
contextInfo,
isLoading = false,
onRefresh,
onClick,
className,
'aria-label': ariaLabel
}) => {
// Calculate trend if not provided
const calculatedTrend = useMemo(() => {
if (trend) return trend;
if (value.previous !== undefined) {
return calculateTrend(value.current, value.previous);
}
return null;
}, [trend, value.current, value.previous]);
// Determine status based on thresholds
const calculatedStatus = useMemo(() => {
if (status) return status;
if (!thresholds) return 'neutral';
const { current } = value;
if (current >= thresholds.excellent) return 'excellent';
if (current >= thresholds.good) return 'good';
if (current >= thresholds.warning) return 'warning';
return 'critical';
}, [status, thresholds, value.current]);
const statusStyles = getStatusColor(calculatedStatus);
const formattedValue = formatValue(value.current, value.format, value.prefix, value.suffix);
const formattedTarget = value.target ? formatValue(value.target, value.format, value.prefix, value.suffix) : null;
const colorClasses = {
blue: 'text-[var(--color-info)]',
green: 'text-[var(--color-success)]',
orange: 'text-[var(--color-primary)]',
red: 'text-[var(--color-error)]',
purple: 'text-[var(--color-info)]',
indigo: 'text-[var(--color-info)]',
teal: 'text-[var(--color-success)]'
};
const renderTrendIcon = (direction: KPITrend['direction']) => {
const iconClass = calculatedTrend?.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
switch (direction) {
case 'up':
return (
<svg className={clsx('w-4 h-4', iconClass)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 17l9.2-9.2M17 17V7H7" />
</svg>
);
case 'down':
return (
<svg className={clsx('w-4 h-4', iconClass)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 7l-9.2 9.2M7 7v10h10" />
</svg>
);
default:
return (
<svg className="w-4 h-4 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
);
}
};
const renderCompactVariant = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{icon && (
<div className={clsx('p-2 rounded-lg', statusStyles.bg)}>
<div className={clsx('w-5 h-5', statusStyles.icon)}>
{icon}
</div>
</div>
)}
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{title}</p>
<p className="text-2xl font-bold text-[var(--text-primary)]">{formattedValue}</p>
</div>
</div>
{calculatedTrend && (
<div className="flex items-center space-x-1">
{renderTrendIcon(calculatedTrend.direction)}
<span className={clsx(
'text-sm font-medium',
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
)}>
{(calculatedTrend.value || 0).toFixed(1)}%
</span>
</div>
)}
</div>
);
const renderDetailedVariant = () => (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{icon && (
<div className={clsx('p-3 rounded-xl', statusStyles.bg)}>
<div className={clsx('w-6 h-6', statusStyles.icon)}>
{icon}
</div>
</div>
)}
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
{subtitle && (
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
)}
</div>
</div>
{calculatedStatus !== 'neutral' && (
<Badge variant={calculatedStatus === 'excellent' ? 'success' : calculatedStatus === 'critical' ? 'error' : 'warning'}>
{calculatedStatus === 'excellent' ? 'Excelente' :
calculatedStatus === 'good' ? 'Bueno' :
calculatedStatus === 'warning' ? 'Atención' : 'Crítico'}
</Badge>
)}
</div>
{/* Value and trend */}
<div className="space-y-2">
<div className="flex items-baseline space-x-3">
<span className="text-3xl font-bold text-[var(--text-primary)]">{formattedValue}</span>
{calculatedTrend && (
<div className="flex items-center space-x-1">
{renderTrendIcon(calculatedTrend.direction)}
<span className={clsx(
'text-sm font-medium',
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
)}>
{(calculatedTrend.value || 0).toFixed(1)}%
</span>
<span className="text-sm text-[var(--text-tertiary)]">
{calculatedTrend.comparisonPeriod}
</span>
</div>
)}
</div>
{formattedTarget && (
<p className="text-sm text-[var(--text-secondary)]">
Objetivo: <span className="font-medium">{formattedTarget}</span>
</p>
)}
{contextInfo && (
<p className="text-sm text-[var(--text-tertiary)]">{contextInfo}</p>
)}
</div>
{/* Sparkline chart */}
{showSparkline && sparklineData && sparklineData.length > 0 && (
<div className="mt-4">
<p className="text-xs text-[var(--text-tertiary)] mb-2">Tendencia últimos 7 días</p>
<SimpleSparkline data={sparklineData} color={color} />
</div>
)}
</div>
);
const renderDefaultVariant = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
{icon && (
<div className={clsx('w-8 h-8', colorClasses[color])}>
{icon}
</div>
)}
<div>
<h3 className="font-medium text-[var(--text-primary)]">{title}</h3>
{subtitle && (
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
)}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-baseline space-x-2">
<span className="text-2xl font-bold text-[var(--text-primary)]">{formattedValue}</span>
{calculatedTrend && (
<div className="flex items-center space-x-1">
{renderTrendIcon(calculatedTrend.direction)}
<span className={clsx(
'text-sm',
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
)}>
{(calculatedTrend.value || 0).toFixed(1)}%
</span>
</div>
)}
</div>
{comparisonLabel && (
<p className="text-sm text-[var(--text-tertiary)]">{comparisonLabel}</p>
)}
</div>
</div>
);
const renderContent = () => {
switch (variant) {
case 'compact':
return renderCompactVariant();
case 'detailed':
return renderDetailedVariant();
case 'chart':
return renderDetailedVariant();
default:
return renderDefaultVariant();
}
};
return (
<DashboardCard
variant="metric"
isLoading={isLoading}
onRefresh={onRefresh}
onClick={onClick}
interactive={!!onClick}
className={className}
aria-label={ariaLabel || `${title}: ${formattedValue}`}
>
{renderContent()}
</DashboardCard>
);
};
KPIWidget.displayName = 'KPIWidget';
export default KPIWidget;