502 lines
15 KiB
TypeScript
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; |