ADD new frontend
This commit is contained in:
502
frontend/src/components/domain/dashboard/KPIWidget.tsx
Normal file
502
frontend/src/components/domain/dashboard/KPIWidget.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user