ADD new frontend
This commit is contained in:
@@ -1,438 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Brain, TrendingUp, TrendingDown, Star, Clock, Eye, EyeOff, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
type: 'trend' | 'opportunity' | 'warning' | 'recommendation' | 'achievement';
|
||||
title: string;
|
||||
description: string;
|
||||
confidence: number;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
category: 'demand' | 'revenue' | 'efficiency' | 'quality' | 'customer';
|
||||
timestamp: string;
|
||||
data?: {
|
||||
trend?: {
|
||||
direction: 'up' | 'down';
|
||||
percentage: number;
|
||||
period: string;
|
||||
};
|
||||
revenue?: {
|
||||
amount: number;
|
||||
comparison: string;
|
||||
};
|
||||
actionable?: {
|
||||
action: string;
|
||||
expectedImpact: string;
|
||||
};
|
||||
};
|
||||
isRead?: boolean;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface AIInsightsFeedProps {
|
||||
insights?: AIInsight[];
|
||||
onInsightAction?: (insightId: string, action: 'read' | 'star' | 'dismiss') => void;
|
||||
maxItems?: number;
|
||||
showFilters?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AIInsightsFeed: React.FC<AIInsightsFeedProps> = ({
|
||||
insights: propInsights,
|
||||
onInsightAction,
|
||||
maxItems = 10,
|
||||
showFilters = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [insights, setInsights] = useState<AIInsight[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedImpact, setSelectedImpact] = useState<string>('all');
|
||||
const [showOnlyUnread, setShowOnlyUnread] = useState(false);
|
||||
|
||||
// Generate realistic AI insights if none provided
|
||||
useEffect(() => {
|
||||
if (propInsights) {
|
||||
setInsights(propInsights);
|
||||
} else {
|
||||
setInsights(generateSampleInsights());
|
||||
}
|
||||
}, [propInsights]);
|
||||
|
||||
const generateSampleInsights = (): AIInsight[] => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
|
||||
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
type: 'opportunity',
|
||||
title: 'Oportunidad: Incremento en demanda de tartas',
|
||||
description: 'Los datos muestran un aumento del 23% en la demanda de tartas los viernes. Considera aumentar la producción para maximizar ingresos.',
|
||||
confidence: 87,
|
||||
impact: 'high',
|
||||
category: 'revenue',
|
||||
timestamp: now.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'up', percentage: 23, period: 'últimas 3 semanas' },
|
||||
actionable: { action: 'Aumentar producción de tartas los viernes', expectedImpact: '+€180/semana' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'warning',
|
||||
title: 'Alerta: Posible desperdicio de magdalenas',
|
||||
description: 'Las magdalenas tienen una tasa de venta del 67% los martes. Reducir la producción podría ahorrar €45 semanales.',
|
||||
confidence: 78,
|
||||
impact: 'medium',
|
||||
category: 'efficiency',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 45, comparison: 'ahorro semanal estimado' },
|
||||
actionable: { action: 'Reducir producción de magdalenas los martes', expectedImpact: '-€45 desperdicio' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'trend',
|
||||
title: 'Tendencia: Croissants más populares por las mañanas',
|
||||
description: 'El 78% de las ventas de croissants ocurren antes de las 11 AM. Considera reorganizar la producción matutina.',
|
||||
confidence: 92,
|
||||
impact: 'medium',
|
||||
category: 'efficiency',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'up', percentage: 78, period: 'horario matutino' },
|
||||
actionable: { action: 'Priorizar croissants en producción matutina', expectedImpact: 'Mejor disponibilidad' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: true
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'achievement',
|
||||
title: '¡Éxito! Predicciones de ayer fueron 94% precisas',
|
||||
description: 'Las predicciones de demanda de ayer tuvieron una precisión del 94%, resultando en ventas óptimas sin desperdicios.',
|
||||
confidence: 94,
|
||||
impact: 'high',
|
||||
category: 'quality',
|
||||
timestamp: yesterday.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 127, comparison: 'ingresos adicionales por precisión' }
|
||||
},
|
||||
isRead: false,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'recommendation',
|
||||
title: 'Recomendación: Nuevo producto para fin de semana',
|
||||
description: 'Los datos de fin de semana sugieren que los clientes buscan productos especiales. Una tarta de temporada podría generar €200 adicionales.',
|
||||
confidence: 72,
|
||||
impact: 'high',
|
||||
category: 'revenue',
|
||||
timestamp: twoDaysAgo.toISOString(),
|
||||
data: {
|
||||
revenue: { amount: 200, comparison: 'ingresos potenciales fin de semana' },
|
||||
actionable: { action: 'Introducir tarta especial de fin de semana', expectedImpact: '+€200/semana' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'trend',
|
||||
title: 'Patrón meteorológico: Lluvia afecta ventas -15%',
|
||||
description: 'Los días lluviosos reducen las ventas en promedio 15%. El algoritmo ahora ajusta automáticamente las predicciones.',
|
||||
confidence: 88,
|
||||
impact: 'medium',
|
||||
category: 'demand',
|
||||
timestamp: twoDaysAgo.toISOString(),
|
||||
data: {
|
||||
trend: { direction: 'down', percentage: 15, period: 'días lluviosos' },
|
||||
actionable: { action: 'Producción automáticamente ajustada', expectedImpact: 'Menos desperdicio' }
|
||||
},
|
||||
isRead: true,
|
||||
isStarred: false
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const handleInsightAction = (insightId: string, action: 'read' | 'star' | 'dismiss') => {
|
||||
setInsights(prev => prev.map(insight => {
|
||||
if (insight.id === insightId) {
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return { ...insight, isRead: true };
|
||||
case 'star':
|
||||
return { ...insight, isStarred: !insight.isStarred };
|
||||
case 'dismiss':
|
||||
return insight; // In a real app, this might remove the insight
|
||||
}
|
||||
}
|
||||
return insight;
|
||||
}));
|
||||
|
||||
onInsightAction?.(insightId, action);
|
||||
};
|
||||
|
||||
const filteredInsights = insights.filter(insight => {
|
||||
if (selectedCategory !== 'all' && insight.category !== selectedCategory) return false;
|
||||
if (selectedImpact !== 'all' && insight.impact !== selectedImpact) return false;
|
||||
if (showOnlyUnread && insight.isRead) return false;
|
||||
return true;
|
||||
}).slice(0, maxItems);
|
||||
|
||||
const getInsightIcon = (type: AIInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'trend': return TrendingUp;
|
||||
case 'opportunity': return Star;
|
||||
case 'warning': return TrendingDown;
|
||||
case 'recommendation': return Brain;
|
||||
case 'achievement': return Star;
|
||||
default: return Brain;
|
||||
}
|
||||
};
|
||||
|
||||
const getInsightColors = (type: AIInsight['type'], impact: AIInsight['impact']) => {
|
||||
const baseColors = {
|
||||
trend: 'blue',
|
||||
opportunity: 'green',
|
||||
warning: 'yellow',
|
||||
recommendation: 'purple',
|
||||
achievement: 'emerald'
|
||||
};
|
||||
|
||||
const color = baseColors[type] || 'gray';
|
||||
const intensity = impact === 'high' ? '600' : impact === 'medium' ? '500' : '400';
|
||||
|
||||
return {
|
||||
background: `bg-${color}-50`,
|
||||
border: `border-${color}-200`,
|
||||
icon: `text-${color}-${intensity}`,
|
||||
badge: impact === 'high' ? `bg-${color}-100 text-${color}-800` :
|
||||
impact === 'medium' ? `bg-${color}-50 text-${color}-700` :
|
||||
`bg-gray-100 text-gray-600`
|
||||
};
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffHours < 1) return 'Hace unos minutos';
|
||||
if (diffHours < 24) return `Hace ${diffHours}h`;
|
||||
if (diffHours < 48) return 'Ayer';
|
||||
return date.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
|
||||
};
|
||||
|
||||
const unreadCount = insights.filter(i => !i.isRead).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">
|
||||
<Brain className="h-6 w-6 text-purple-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Insights de IA
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Recomendaciones inteligentes para tu negocio
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded-full">
|
||||
{unreadCount} nuevos
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Todas las categorías</option>
|
||||
<option value="demand">Demanda</option>
|
||||
<option value="revenue">Ingresos</option>
|
||||
<option value="efficiency">Eficiencia</option>
|
||||
<option value="quality">Calidad</option>
|
||||
<option value="customer">Cliente</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedImpact}
|
||||
onChange={(e) => setSelectedImpact(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">Todos los impactos</option>
|
||||
<option value="high">Alto impacto</option>
|
||||
<option value="medium">Impacto medio</option>
|
||||
<option value="low">Bajo impacto</option>
|
||||
</select>
|
||||
|
||||
<label className="flex items-center text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showOnlyUnread}
|
||||
onChange={(e) => setShowOnlyUnread(e.target.checked)}
|
||||
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded mr-2"
|
||||
/>
|
||||
Solo no leídos
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights List */}
|
||||
<div className="divide-y divide-gray-100 max-h-96 overflow-y-auto">
|
||||
{filteredInsights.map((insight) => {
|
||||
const IconComponent = getInsightIcon(insight.type);
|
||||
const colors = getInsightColors(insight.type, insight.impact);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors ${!insight.isRead ? 'bg-purple-25' : ''}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${colors.background}`}>
|
||||
<IconComponent className={`h-4 w-4 ${colors.icon}`} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className={`text-sm font-medium ${!insight.isRead ? 'text-gray-900' : 'text-gray-700'}`}>
|
||||
{insight.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center mt-2 space-x-3">
|
||||
<span className="text-xs text-gray-500 flex items-center">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{formatTimestamp(insight.timestamp)}
|
||||
</span>
|
||||
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${colors.badge}`}>
|
||||
{insight.impact === 'high' ? 'Alto' : insight.impact === 'medium' ? 'Medio' : 'Bajo'} impacto
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-gray-500">
|
||||
{insight.confidence}% confianza
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Data Insights */}
|
||||
{insight.data && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
{insight.data.trend && (
|
||||
<div className="flex items-center text-sm">
|
||||
{insight.data.trend.direction === 'up' ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-600 mr-2" />
|
||||
)}
|
||||
<span className="font-medium">
|
||||
{insight.data.trend.percentage}% {insight.data.trend.direction === 'up' ? 'aumento' : 'reducción'}
|
||||
</span>
|
||||
<span className="text-gray-600 ml-1">
|
||||
en {insight.data.trend.period}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insight.data.revenue && (
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="font-medium text-green-600">€{insight.data.revenue.amount}</span>
|
||||
<span className="text-gray-600 ml-1">{insight.data.revenue.comparison}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{insight.data.actionable && (
|
||||
<div className="mt-2 text-sm">
|
||||
<div className="font-medium text-gray-900">{insight.data.actionable.action}</div>
|
||||
<div className="text-green-600">{insight.data.actionable.expectedImpact}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-1 ml-4">
|
||||
<button
|
||||
onClick={() => handleInsightAction(insight.id, 'star')}
|
||||
className={`p-1.5 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||
insight.isStarred ? 'text-yellow-600' : 'text-gray-400 hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Star className={`h-4 w-4 ${insight.isStarred ? 'fill-current' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleInsightAction(insight.id, 'read')}
|
||||
className={`p-1.5 rounded-lg hover:bg-gray-100 transition-colors ${
|
||||
insight.isRead ? 'text-gray-400' : 'text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{insight.isRead ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<button className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredInsights.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Brain className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay insights disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Los insights aparecerán aquí conforme el sistema analice tus datos
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{filteredInsights.length > 0 && (
|
||||
<div className="p-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Mostrando {filteredInsights.length} de {insights.length} insights
|
||||
</p>
|
||||
<button className="text-xs text-purple-600 hover:text-purple-700 mt-1 font-medium">
|
||||
Ver historial completo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsFeed;
|
||||
360
frontend/src/components/ui/Avatar/Avatar.tsx
Normal file
360
frontend/src/components/ui/Avatar/Avatar.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, { forwardRef, useState, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
src?: string;
|
||||
alt?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
shape?: 'circle' | 'square';
|
||||
name?: string;
|
||||
status?: 'online' | 'offline' | 'away' | 'busy';
|
||||
statusPosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
||||
showStatusText?: boolean;
|
||||
badge?: React.ReactNode;
|
||||
badgePosition?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
loading?: boolean;
|
||||
fallback?: React.ReactNode;
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
}
|
||||
|
||||
export interface AvatarGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
max?: number;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
shape?: 'circle' | 'square';
|
||||
spacing?: 'tight' | 'normal' | 'loose';
|
||||
showMore?: boolean;
|
||||
moreText?: string;
|
||||
onMoreClick?: () => void;
|
||||
}
|
||||
|
||||
const Avatar = forwardRef<HTMLDivElement, AvatarProps>(({
|
||||
src,
|
||||
alt,
|
||||
size = 'md',
|
||||
shape = 'circle',
|
||||
name,
|
||||
status,
|
||||
statusPosition = 'bottom-right',
|
||||
showStatusText = false,
|
||||
badge,
|
||||
badgePosition = 'top-right',
|
||||
icon,
|
||||
onClick,
|
||||
loading = false,
|
||||
fallback,
|
||||
borderColor,
|
||||
borderWidth = 2,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
setImageLoaded(false);
|
||||
};
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoaded(true);
|
||||
setImageError(false);
|
||||
};
|
||||
|
||||
// Generate initials from name
|
||||
const getInitials = (name: string): string => {
|
||||
return name
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
// Generate background color from name
|
||||
const getBackgroundColor = (name: string): string => {
|
||||
const colors = [
|
||||
'#f59e0b', '#ef4444', '#10b981', '#3b82f6', '#8b5cf6',
|
||||
'#f97316', '#06b6d4', '#84cc16', '#ec4899', '#6366f1'
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-xs',
|
||||
sm: 'w-8 h-8 text-sm',
|
||||
md: 'w-10 h-10 text-base',
|
||||
lg: 'w-12 h-12 text-lg',
|
||||
xl: 'w-16 h-16 text-xl',
|
||||
'2xl': 'w-20 h-20 text-2xl',
|
||||
};
|
||||
|
||||
const shapeClasses = {
|
||||
circle: 'rounded-full',
|
||||
square: 'rounded-lg',
|
||||
};
|
||||
|
||||
const statusClasses = {
|
||||
online: 'bg-status-online',
|
||||
offline: 'bg-status-offline',
|
||||
away: 'bg-status-away',
|
||||
busy: 'bg-status-busy',
|
||||
};
|
||||
|
||||
const statusSizeClasses = {
|
||||
xs: 'w-2 h-2',
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-3 h-3',
|
||||
xl: 'w-4 h-4',
|
||||
'2xl': 'w-4 h-4',
|
||||
};
|
||||
|
||||
const statusPositionClasses = {
|
||||
'bottom-right': 'bottom-0 right-0',
|
||||
'bottom-left': 'bottom-0 left-0',
|
||||
'top-right': 'top-0 right-0',
|
||||
'top-left': 'top-0 left-0',
|
||||
};
|
||||
|
||||
const badgePositionClasses = {
|
||||
'bottom-right': 'bottom-0 right-0 translate-x-1/2 translate-y-1/2',
|
||||
'bottom-left': 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2',
|
||||
'top-right': 'top-0 right-0 translate-x-1/2 -translate-y-1/2',
|
||||
'top-left': 'top-0 left-0 -translate-x-1/2 -translate-y-1/2',
|
||||
};
|
||||
|
||||
const baseClasses = [
|
||||
'relative inline-flex items-center justify-center',
|
||||
'bg-bg-tertiary text-text-primary font-medium',
|
||||
'overflow-hidden flex-shrink-0',
|
||||
];
|
||||
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
sizeClasses[size],
|
||||
shapeClasses[shape],
|
||||
{
|
||||
'cursor-pointer hover:opacity-80 transition-opacity duration-200': onClick,
|
||||
'ring-2 ring-white': borderColor || borderWidth > 0,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const borderStyle = borderColor ? {
|
||||
borderColor,
|
||||
borderWidth: `${borderWidth}px`,
|
||||
borderStyle: 'solid',
|
||||
} : {};
|
||||
|
||||
// Render content based on priority: children > src > name > icon > fallback
|
||||
const renderContent = () => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse bg-bg-quaternary w-full h-full" />
|
||||
);
|
||||
}
|
||||
|
||||
if (src && !imageError) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || (name ? `Avatar de ${name}` : 'Avatar')}
|
||||
className={clsx(
|
||||
'w-full h-full object-cover',
|
||||
shapeClasses[shape],
|
||||
{
|
||||
'opacity-0': !imageLoaded,
|
||||
'opacity-100': imageLoaded,
|
||||
}
|
||||
)}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
const initials = getInitials(name);
|
||||
const backgroundColor = getBackgroundColor(name);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="text-white font-semibold"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
<span className="text-text-tertiary">
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Default user icon
|
||||
return (
|
||||
<svg
|
||||
className="w-3/5 h-3/5 text-text-tertiary"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const avatarElement = (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
style={borderStyle}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
} : undefined}
|
||||
{...props}
|
||||
>
|
||||
{renderContent()}
|
||||
|
||||
{/* Status indicator */}
|
||||
{status && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute rounded-full ring-2 ring-bg-primary',
|
||||
statusClasses[status],
|
||||
statusSizeClasses[size],
|
||||
statusPositionClasses[statusPosition]
|
||||
)}
|
||||
aria-label={`Estado: ${status}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Badge */}
|
||||
{badge && (
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute transform',
|
||||
badgePositionClasses[badgePosition]
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Show status text alongside avatar
|
||||
if (showStatusText && status) {
|
||||
const statusTextMap = {
|
||||
online: 'En línea',
|
||||
offline: 'Desconectado',
|
||||
away: 'Ausente',
|
||||
busy: 'Ocupado',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{avatarElement}
|
||||
<span className="text-sm text-text-secondary">
|
||||
{statusTextMap[status]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return avatarElement;
|
||||
});
|
||||
|
||||
const AvatarGroup = forwardRef<HTMLDivElement, AvatarGroupProps>(({
|
||||
max = 5,
|
||||
size = 'md',
|
||||
shape = 'circle',
|
||||
spacing = 'normal',
|
||||
showMore = true,
|
||||
moreText,
|
||||
onMoreClick,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
const visibleChildren = childrenArray.slice(0, max);
|
||||
const hiddenCount = childrenArray.length - max;
|
||||
|
||||
const spacingClasses = {
|
||||
tight: '-space-x-1',
|
||||
normal: '-space-x-2',
|
||||
loose: '-space-x-3',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
'flex items-center',
|
||||
spacingClasses[spacing],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{visibleChildren.map((child, index) => (
|
||||
<div key={index} className="relative ring-2 ring-white">
|
||||
{React.cloneElement(child as React.ReactElement, {
|
||||
size,
|
||||
shape,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hiddenCount > 0 && showMore && (
|
||||
<div className="relative ring-2 ring-white">
|
||||
<Avatar
|
||||
size={size}
|
||||
shape={shape}
|
||||
onClick={onMoreClick}
|
||||
className="bg-bg-quaternary text-text-secondary cursor-pointer hover:bg-bg-tertiary transition-colors duration-200"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{moreText || `+${hiddenCount}`}
|
||||
</span>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Avatar.displayName = 'Avatar';
|
||||
AvatarGroup.displayName = 'AvatarGroup';
|
||||
|
||||
export default Avatar;
|
||||
export { AvatarGroup };
|
||||
3
frontend/src/components/ui/Avatar/index.ts
Normal file
3
frontend/src/components/ui/Avatar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Avatar';
|
||||
export { default as Avatar, AvatarGroup } from './Avatar';
|
||||
export type { AvatarProps, AvatarGroupProps } from './Avatar';
|
||||
241
frontend/src/components/ui/Badge/Badge.tsx
Normal file
241
frontend/src/components/ui/Badge/Badge.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, { forwardRef, HTMLAttributes, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'outline';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
shape?: 'rounded' | 'pill' | 'square';
|
||||
dot?: boolean;
|
||||
count?: number;
|
||||
showZero?: boolean;
|
||||
max?: number;
|
||||
offset?: [number, number];
|
||||
status?: 'default' | 'error' | 'success' | 'warning' | 'processing';
|
||||
text?: string;
|
||||
color?: string;
|
||||
icon?: React.ReactNode;
|
||||
closable?: boolean;
|
||||
onClose?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(({
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
shape = 'rounded',
|
||||
dot = false,
|
||||
count,
|
||||
showZero = false,
|
||||
max = 99,
|
||||
offset,
|
||||
status,
|
||||
text,
|
||||
color,
|
||||
icon,
|
||||
closable = false,
|
||||
onClose,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const hasChildren = children !== undefined;
|
||||
const isStandalone = !hasChildren;
|
||||
|
||||
// Calculate display count
|
||||
const displayCount = useMemo(() => {
|
||||
if (count === undefined || dot) return undefined;
|
||||
if (count === 0 && !showZero) return undefined;
|
||||
if (count > max) return `${max}+`;
|
||||
return count.toString();
|
||||
}, [count, dot, showZero, max]);
|
||||
|
||||
const baseClasses = [
|
||||
'inline-flex items-center justify-center font-medium',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'whitespace-nowrap',
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
default: [
|
||||
'bg-bg-tertiary text-text-primary border border-border-primary',
|
||||
],
|
||||
primary: [
|
||||
'bg-color-primary text-text-inverse',
|
||||
],
|
||||
secondary: [
|
||||
'bg-color-secondary text-text-inverse',
|
||||
],
|
||||
success: [
|
||||
'bg-color-success text-text-inverse',
|
||||
],
|
||||
warning: [
|
||||
'bg-color-warning text-text-inverse',
|
||||
],
|
||||
error: [
|
||||
'bg-color-error text-text-inverse',
|
||||
],
|
||||
info: [
|
||||
'bg-color-info text-text-inverse',
|
||||
],
|
||||
outline: [
|
||||
'bg-transparent border border-current',
|
||||
],
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: isStandalone ? 'px-1.5 py-0.5 text-xs min-h-4' : 'w-4 h-4 text-xs',
|
||||
sm: isStandalone ? 'px-2 py-0.5 text-xs min-h-5' : 'w-5 h-5 text-xs',
|
||||
md: isStandalone ? 'px-2.5 py-1 text-sm min-h-6' : 'w-6 h-6 text-sm',
|
||||
lg: isStandalone ? 'px-3 py-1.5 text-sm min-h-7' : 'w-7 h-7 text-sm',
|
||||
};
|
||||
|
||||
const shapeClasses = {
|
||||
rounded: 'rounded-md',
|
||||
pill: 'rounded-full',
|
||||
square: 'rounded-none',
|
||||
};
|
||||
|
||||
const statusClasses = {
|
||||
default: 'bg-text-tertiary',
|
||||
error: 'bg-color-error',
|
||||
success: 'bg-color-success animate-pulse',
|
||||
warning: 'bg-color-warning',
|
||||
processing: 'bg-color-info animate-pulse',
|
||||
};
|
||||
|
||||
// Dot badge (status indicator)
|
||||
if (dot || status) {
|
||||
const dotClasses = clsx(
|
||||
'w-2 h-2 rounded-full',
|
||||
status ? statusClasses[status] : 'bg-color-primary'
|
||||
);
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<span className="relative inline-flex" ref={ref}>
|
||||
{children}
|
||||
<span
|
||||
className={clsx(
|
||||
dotClasses,
|
||||
'absolute -top-0.5 -right-0.5 ring-2 ring-bg-primary',
|
||||
className
|
||||
)}
|
||||
style={offset ? {
|
||||
transform: `translate(${offset[0]}px, ${offset[1]}px)`,
|
||||
} : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={clsx(dotClasses, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Count badge
|
||||
if (count !== undefined && hasChildren) {
|
||||
if (displayCount === undefined) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex" ref={ref}>
|
||||
{children}
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute -top-2 -right-2 flex items-center justify-center',
|
||||
'min-w-5 h-5 px-1 text-xs font-medium',
|
||||
'bg-color-error text-text-inverse rounded-full',
|
||||
'ring-2 ring-bg-primary',
|
||||
className
|
||||
)}
|
||||
style={offset ? {
|
||||
transform: `translate(${offset[0]}px, ${offset[1]}px)`,
|
||||
} : undefined}
|
||||
{...props}
|
||||
>
|
||||
{displayCount}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone badge
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
shapeClasses[shape],
|
||||
{
|
||||
'gap-1': icon || closable,
|
||||
'pr-1': closable,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const customStyle = color ? {
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
color: getContrastColor(color),
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={classes}
|
||||
style={customStyle}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
)}
|
||||
<span>{text || displayCount || children}</span>
|
||||
{closable && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 ml-1 hover:bg-black/10 rounded-full p-0.5 transition-colors duration-150"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
// Helper function to determine contrast color
|
||||
function getContrastColor(hexColor: string): string {
|
||||
// Remove # if present
|
||||
const color = hexColor.replace('#', '');
|
||||
|
||||
// Convert to RGB
|
||||
const r = parseInt(color.substr(0, 2), 16);
|
||||
const g = parseInt(color.substr(2, 2), 16);
|
||||
const b = parseInt(color.substr(4, 2), 16);
|
||||
|
||||
// Calculate relative luminance
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
// Return black for light colors, white for dark colors
|
||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
Badge.displayName = 'Badge';
|
||||
|
||||
export default Badge;
|
||||
3
frontend/src/components/ui/Badge/index.ts
Normal file
3
frontend/src/components/ui/Badge/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Badge';
|
||||
export { default as Badge } from './Badge';
|
||||
export type { BadgeProps } from './Badge';
|
||||
@@ -1,57 +0,0 @@
|
||||
// src/components/ui/Button.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500 shadow-soft hover:shadow-medium',
|
||||
secondary: 'bg-gray-500 text-white hover:bg-gray-600 focus:ring-gray-500 shadow-soft hover:shadow-medium',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500 shadow-soft hover:shadow-medium',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
55
frontend/src/components/ui/Button/.!76623!Button.test.tsx
Normal file
55
frontend/src/components/ui/Button/.!76623!Button.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Button from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Button>Test Button</Button>);
|
||||
const button = screen.getByRole('button', { name: /test button/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-color-primary'); // default variant
|
||||
});
|
||||
|
||||
it('applies the correct variant classes', () => {
|
||||
const { rerender } = render(<Button variant="secondary">Secondary</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-secondary');
|
||||
|
||||
rerender(<Button variant="danger">Danger</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-error');
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-transparent');
|
||||
expect(button).toHaveClass('border-color-primary');
|
||||
});
|
||||
|
||||
it('applies the correct size classes', () => {
|
||||
const { rerender } = render(<Button size="xs">Extra Small</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-2', 'py-1', 'text-xs');
|
||||
|
||||
rerender(<Button size="lg">Large</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-6', 'py-2.5', 'text-base');
|
||||
});
|
||||
|
||||
it('handles loading state correctly', () => {
|
||||
render(
|
||||
<Button isLoading loadingText="Loading...">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent('Loading...');
|
||||
|
||||
// Check if loading spinner is present
|
||||
const spinner = button.querySelector('svg');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('animate-spin');
|
||||
});
|
||||
|
||||
it('renders icons correctly', () => {
|
||||
46
frontend/src/components/ui/Button/.!76624!Button.stories.tsx
Normal file
46
frontend/src/components/ui/Button/.!76624!Button.stories.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Button from './Button';
|
||||
import {
|
||||
ShoppingCartIcon,
|
||||
HeartIcon,
|
||||
ArrowRightIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'outline', 'ghost', 'danger', 'success', 'warning'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isFullWidth: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
170
frontend/src/components/ui/Button/Button.stories.tsx
Normal file
170
frontend/src/components/ui/Button/Button.stories.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Button from './Button';
|
||||
import {
|
||||
ShoppingCartIcon,
|
||||
HeartIcon,
|
||||
ArrowRightIcon,
|
||||
PlusIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'UI/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'outline', 'ghost', 'danger', 'success', 'warning'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
},
|
||||
isLoading: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isFullWidth: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
children: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: 'Bot<6F>n Principal',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Bot<6F>n Secundario',
|
||||
},
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
children: 'Bot<6F>n Contorno',
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
variant: 'ghost',
|
||||
children: 'Bot<6F>n Fantasma',
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
variant: 'danger',
|
||||
children: 'Eliminar',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
variant: 'success',
|
||||
children: 'Guardar',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
variant: 'warning',
|
||||
children: 'Advertencia',
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
isLoading: true,
|
||||
children: 'Cargando...',
|
||||
loadingText: 'Procesando...',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLeftIcon: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
leftIcon: <ShoppingCartIcon className="w-4 h-4" />,
|
||||
children: 'A<>adir al Carrito',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRightIcon: Story = {
|
||||
args: {
|
||||
variant: 'outline',
|
||||
rightIcon: <ArrowRightIcon className="w-4 h-4" />,
|
||||
children: 'Continuar',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBothIcons: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
leftIcon: <HeartIcon className="w-4 h-4" />,
|
||||
rightIcon: <PlusIcon className="w-4 h-4" />,
|
||||
children: 'Me Gusta',
|
||||
},
|
||||
};
|
||||
|
||||
export const FullWidth: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
isFullWidth: true,
|
||||
children: 'Bot<6F>n de Ancho Completo',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
disabled: true,
|
||||
children: 'Bot<6F>n Deshabilitado',
|
||||
},
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<Button size="xs" variant="primary">Extra Peque<EFBFBD>o</Button>
|
||||
<Button size="sm" variant="primary">Peque<EFBFBD>o</Button>
|
||||
<Button size="md" variant="primary">Mediano</Button>
|
||||
<Button size="lg" variant="primary">Grande</Button>
|
||||
<Button size="xl" variant="primary">Extra Grande</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Variants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary">Principal</Button>
|
||||
<Button variant="secondary">Secundario</Button>
|
||||
<Button variant="outline">Contorno</Button>
|
||||
<Button variant="ghost">Fantasma</Button>
|
||||
<Button variant="danger">Peligro</Button>
|
||||
<Button variant="success"><EFBFBD>xito</Button>
|
||||
<Button variant="warning">Advertencia</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
162
frontend/src/components/ui/Button/Button.test.tsx
Normal file
162
frontend/src/components/ui/Button/Button.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Button from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders correctly with default props', () => {
|
||||
render(<Button>Test Button</Button>);
|
||||
const button = screen.getByRole('button', { name: /test button/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-color-primary'); // default variant
|
||||
});
|
||||
|
||||
it('applies the correct variant classes', () => {
|
||||
const { rerender } = render(<Button variant="secondary">Secondary</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-secondary');
|
||||
|
||||
rerender(<Button variant="danger">Danger</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-color-error');
|
||||
|
||||
rerender(<Button variant="outline">Outline</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-transparent');
|
||||
expect(button).toHaveClass('border-color-primary');
|
||||
});
|
||||
|
||||
it('applies the correct size classes', () => {
|
||||
const { rerender } = render(<Button size="xs">Extra Small</Button>);
|
||||
let button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-2', 'py-1', 'text-xs');
|
||||
|
||||
rerender(<Button size="lg">Large</Button>);
|
||||
button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('px-6', 'py-2.5', 'text-base');
|
||||
});
|
||||
|
||||
it('handles loading state correctly', () => {
|
||||
render(
|
||||
<Button isLoading loadingText="Loading...">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent('Loading...');
|
||||
|
||||
// Check if loading spinner is present
|
||||
const spinner = button.querySelector('svg');
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(spinner).toHaveClass('animate-spin');
|
||||
});
|
||||
|
||||
it('renders icons correctly', () => {
|
||||
const leftIcon = <span data-testid="left-icon"><EFBFBD></span>;
|
||||
const rightIcon = <span data-testid="right-icon"><EFBFBD></span>;
|
||||
|
||||
render(
|
||||
<Button leftIcon={leftIcon} rightIcon={rightIcon}>
|
||||
With Icons
|
||||
</Button>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('left-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('right-icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render icons when loading', () => {
|
||||
const leftIcon = <span data-testid="left-icon"><EFBFBD></span>;
|
||||
const rightIcon = <span data-testid="right-icon"><EFBFBD></span>;
|
||||
|
||||
render(
|
||||
<Button isLoading leftIcon={leftIcon} rightIcon={rightIcon}>
|
||||
Loading
|
||||
</Button>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('left-icon')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('right-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies full width class when isFullWidth is true', () => {
|
||||
render(<Button isFullWidth>Full Width</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('w-full');
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<Button disabled>Disabled Button</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('is disabled when loading', () => {
|
||||
render(<Button isLoading>Loading Button</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('handles click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>Click Me</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Button disabled onClick={handleClick}>
|
||||
Disabled
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call onClick when loading', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Button isLoading onClick={handleClick}>
|
||||
Loading
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards ref correctly', () => {
|
||||
const ref = { current: null };
|
||||
render(<Button ref={ref}>Ref Test</Button>);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<Button className="custom-class">Custom Class</Button>);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('passes through other props', () => {
|
||||
render(
|
||||
<Button type="submit" data-testid="submit-btn">
|
||||
Submit
|
||||
</Button>
|
||||
);
|
||||
|
||||
const button = screen.getByTestId('submit-btn');
|
||||
expect(button).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
145
frontend/src/components/ui/Button/Button.tsx
Normal file
145
frontend/src/components/ui/Button/Button.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success' | 'warning';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
isLoading?: boolean;
|
||||
isFullWidth?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
isFullWidth = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loadingText,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseClasses = [
|
||||
'inline-flex items-center justify-center font-medium',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'border rounded-lg'
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
primary: [
|
||||
'bg-[var(--color-primary)] text-[var(--text-inverse)] border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary-dark)] hover:border-[var(--color-primary-dark)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary-dark)]'
|
||||
],
|
||||
secondary: [
|
||||
'bg-[var(--color-secondary)] text-[var(--text-inverse)] border-[var(--color-secondary)]',
|
||||
'hover:bg-[var(--color-secondary-dark)] hover:border-[var(--color-secondary-dark)]',
|
||||
'focus:ring-[var(--color-secondary)]/20',
|
||||
'active:bg-[var(--color-secondary-dark)]'
|
||||
],
|
||||
outline: [
|
||||
'bg-transparent text-[var(--color-primary)] border-[var(--color-primary)]',
|
||||
'hover:bg-[var(--color-primary)] hover:text-[var(--text-inverse)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary-dark)] active:border-[var(--color-primary-dark)]'
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent text-[var(--color-primary)] border-transparent',
|
||||
'hover:bg-[var(--color-primary)]/10 hover:text-[var(--color-primary-dark)]',
|
||||
'focus:ring-[var(--color-primary)]/20',
|
||||
'active:bg-[var(--color-primary)]/20'
|
||||
],
|
||||
danger: [
|
||||
'bg-[var(--color-error)] text-[var(--text-inverse)] border-[var(--color-error)]',
|
||||
'hover:bg-[var(--color-error-dark)] hover:border-[var(--color-error-dark)]',
|
||||
'focus:ring-[var(--color-error)]/20',
|
||||
'active:bg-[var(--color-error-dark)]'
|
||||
],
|
||||
success: [
|
||||
'bg-[var(--color-success)] text-[var(--text-inverse)] border-[var(--color-success)]',
|
||||
'hover:bg-[var(--color-success-dark)] hover:border-[var(--color-success-dark)]',
|
||||
'focus:ring-[var(--color-success)]/20',
|
||||
'active:bg-[var(--color-success-dark)]'
|
||||
],
|
||||
warning: [
|
||||
'bg-[var(--color-warning)] text-[var(--text-inverse)] border-[var(--color-warning)]',
|
||||
'hover:bg-[var(--color-warning-dark)] hover:border-[var(--color-warning-dark)]',
|
||||
'focus:ring-[var(--color-warning)]/20',
|
||||
'active:bg-[var(--color-warning-dark)]'
|
||||
]
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'px-2 py-1 text-xs gap-1 min-h-6',
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-8',
|
||||
md: 'px-4 py-2 text-sm gap-2 min-h-10',
|
||||
lg: 'px-6 py-2.5 text-base gap-2 min-h-12',
|
||||
xl: 'px-8 py-3 text-lg gap-3 min-h-14'
|
||||
};
|
||||
|
||||
const loadingSpinner = (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
{
|
||||
'w-full': isFullWidth,
|
||||
'cursor-wait': isLoading
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={classes}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && loadingSpinner}
|
||||
{!isLoading && leftIcon && (
|
||||
<span className="flex-shrink-0">{leftIcon}</span>
|
||||
)}
|
||||
<span>
|
||||
{isLoading && loadingText ? loadingText : children}
|
||||
</span>
|
||||
{!isLoading && rightIcon && (
|
||||
<span className="flex-shrink-0">{rightIcon}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
3
frontend/src/components/ui/Button/index.ts
Normal file
3
frontend/src/components/ui/Button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Button';
|
||||
export { default as Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -1,34 +0,0 @@
|
||||
// src/components/ui/Card.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className,
|
||||
padding = 'md'
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'bg-white rounded-xl shadow-soft',
|
||||
paddingClasses[padding],
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
237
frontend/src/components/ui/Card/Card.tsx
Normal file
237
frontend/src/components/ui/Card/Card.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { forwardRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'elevated' | 'outlined' | 'filled' | 'ghost';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
hoverable?: boolean;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
export interface CardBodyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
divider?: boolean;
|
||||
justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(({
|
||||
variant = 'elevated',
|
||||
padding = 'md',
|
||||
rounded = 'md',
|
||||
shadow = 'md',
|
||||
hoverable = false,
|
||||
interactive = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseClasses = [
|
||||
'relative',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
'bg-[var(--bg-primary)]',
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
elevated: [
|
||||
'border border-[var(--border-primary)]',
|
||||
],
|
||||
outlined: [
|
||||
'border-2 border-[var(--border-primary)]',
|
||||
'bg-transparent',
|
||||
],
|
||||
filled: [
|
||||
'bg-[var(--bg-secondary)]',
|
||||
'border border-transparent',
|
||||
],
|
||||
ghost: [
|
||||
'bg-transparent',
|
||||
'border border-transparent',
|
||||
],
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
xl: 'p-8',
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-lg',
|
||||
lg: 'rounded-xl',
|
||||
xl: 'rounded-2xl',
|
||||
full: 'rounded-full',
|
||||
};
|
||||
|
||||
const shadowClasses = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
'2xl': 'shadow-2xl',
|
||||
};
|
||||
|
||||
const interactiveClasses = {
|
||||
hoverable: 'hover:shadow-lg hover:-translate-y-1',
|
||||
interactive: 'cursor-pointer hover:shadow-lg hover:-translate-y-1 focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:ring-offset-2',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
paddingClasses[padding],
|
||||
roundedClasses[rounded],
|
||||
shadowClasses[shadow],
|
||||
{
|
||||
[interactiveClasses.hoverable]: hoverable && !interactive,
|
||||
[interactiveClasses.interactive]: interactive,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const Component = interactive ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
});
|
||||
|
||||
const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(({
|
||||
padding = 'md',
|
||||
divider = true,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
xl: 'p-8',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
'flex items-center justify-between',
|
||||
paddingClasses[padding],
|
||||
{
|
||||
'border-b border-[var(--border-primary)]': divider,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CardBody = forwardRef<HTMLDivElement, CardBodyProps>(({
|
||||
padding = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
xl: 'p-8',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
paddingClasses[padding],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const CardFooter = forwardRef<HTMLDivElement, CardFooterProps>(({
|
||||
padding = 'md',
|
||||
divider = true,
|
||||
justify = 'end',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
xl: 'p-8',
|
||||
};
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
'flex items-center gap-3',
|
||||
paddingClasses[padding],
|
||||
justifyClasses[justify],
|
||||
{
|
||||
'border-t border-[var(--border-primary)]': divider,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Card.displayName = 'Card';
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
CardBody.displayName = 'CardBody';
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export default Card;
|
||||
export { CardHeader, CardBody, CardFooter };
|
||||
3
frontend/src/components/ui/Card/index.ts
Normal file
3
frontend/src/components/ui/Card/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Card';
|
||||
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
|
||||
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
|
||||
@@ -1,408 +0,0 @@
|
||||
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;
|
||||
627
frontend/src/components/ui/DatePicker/DatePicker.tsx
Normal file
627
frontend/src/components/ui/DatePicker/DatePicker.tsx
Normal file
@@ -0,0 +1,627 @@
|
||||
import React, { forwardRef, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface DatePickerProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
placeholder?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'outline' | 'filled' | 'unstyled';
|
||||
value?: Date | null;
|
||||
defaultValue?: Date | null;
|
||||
onChange?: (date: Date | null) => void;
|
||||
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
isRequired?: boolean;
|
||||
isInvalid?: boolean;
|
||||
format?: string;
|
||||
locale?: 'es' | 'en';
|
||||
minDate?: Date;
|
||||
maxDate?: Date;
|
||||
disabledDates?: Date[];
|
||||
disablePast?: boolean;
|
||||
disableFuture?: boolean;
|
||||
showTime?: boolean;
|
||||
showToday?: boolean;
|
||||
showClear?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
firstDayOfWeek?: 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
monthsToShow?: number;
|
||||
yearRange?: number;
|
||||
renderDay?: (date: Date, isSelected: boolean, isToday: boolean, isDisabled: boolean) => React.ReactNode;
|
||||
renderHeader?: (date: Date, onPrevMonth: () => void, onNextMonth: () => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
const DatePicker = forwardRef<HTMLInputElement, DatePickerProps>(({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
placeholder = 'Seleccionar fecha',
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
isRequired = false,
|
||||
isInvalid = false,
|
||||
format = 'dd/mm/yyyy',
|
||||
locale = 'es',
|
||||
minDate,
|
||||
maxDate,
|
||||
disabledDates = [],
|
||||
disablePast = false,
|
||||
disableFuture = false,
|
||||
showTime = false,
|
||||
showToday = true,
|
||||
showClear = true,
|
||||
closeOnSelect = true,
|
||||
firstDayOfWeek = 1,
|
||||
monthsToShow = 1,
|
||||
yearRange = 100,
|
||||
renderDay,
|
||||
renderHeader,
|
||||
className,
|
||||
id,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const datePickerId = id || `datepicker-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const [internalValue, setInternalValue] = useState<Date | null>(
|
||||
value !== undefined ? value : defaultValue || null
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(
|
||||
(value || defaultValue || new Date()).getMonth()
|
||||
);
|
||||
const [currentYear, setCurrentYear] = useState(
|
||||
(value || defaultValue || new Date()).getFullYear()
|
||||
);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [timeValue, setTimeValue] = useState('00:00');
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Use controlled value if provided, otherwise use internal state
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
|
||||
// Localization
|
||||
const translations = {
|
||||
es: {
|
||||
months: [
|
||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||
],
|
||||
weekdays: ['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'],
|
||||
today: 'Hoy',
|
||||
clear: 'Limpiar',
|
||||
},
|
||||
en: {
|
||||
months: [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December'
|
||||
],
|
||||
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
|
||||
today: 'Today',
|
||||
clear: 'Clear',
|
||||
},
|
||||
};
|
||||
|
||||
const t = translations[locale];
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date | null): string => {
|
||||
if (!date) return '';
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
let formatted = format
|
||||
.replace(/dd/g, day)
|
||||
.replace(/mm/g, month)
|
||||
.replace(/yyyy/g, year);
|
||||
|
||||
if (showTime) {
|
||||
formatted += ` ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// Parse date from string
|
||||
const parseDate = (dateString: string): Date | null => {
|
||||
if (!dateString) return null;
|
||||
|
||||
try {
|
||||
const parts = dateString.split(/[/\-\s:]/);
|
||||
|
||||
if (format.startsWith('dd/mm/yyyy')) {
|
||||
const day = parseInt(parts[0], 10);
|
||||
const month = parseInt(parts[1], 10) - 1;
|
||||
const year = parseInt(parts[2], 10);
|
||||
|
||||
if (!isNaN(day) && !isNaN(month) && !isNaN(year)) {
|
||||
const date = new Date(year, month, day);
|
||||
|
||||
if (showTime && parts.length >= 5) {
|
||||
const hours = parseInt(parts[3], 10) || 0;
|
||||
const minutes = parseInt(parts[4], 10) || 0;
|
||||
date.setHours(hours, minutes);
|
||||
}
|
||||
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if date is disabled
|
||||
const isDateDisabled = (date: Date): boolean => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const checkDate = new Date(date);
|
||||
checkDate.setHours(0, 0, 0, 0);
|
||||
|
||||
if (disablePast && checkDate < today) return true;
|
||||
if (disableFuture && checkDate > today) return true;
|
||||
if (minDate && checkDate < minDate) return true;
|
||||
if (maxDate && checkDate > maxDate) return true;
|
||||
|
||||
return disabledDates.some(disabledDate => {
|
||||
const disabled = new Date(disabledDate);
|
||||
disabled.setHours(0, 0, 0, 0);
|
||||
return disabled.getTime() === checkDate.getTime();
|
||||
});
|
||||
};
|
||||
|
||||
// Check if date is today
|
||||
const isToday = (date: Date): boolean => {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
|
||||
// Check if date is selected
|
||||
const isSelected = (date: Date): boolean => {
|
||||
if (!currentValue) return false;
|
||||
return date.getDate() === currentValue.getDate() &&
|
||||
date.getMonth() === currentValue.getMonth() &&
|
||||
date.getFullYear() === currentValue.getFullYear();
|
||||
};
|
||||
|
||||
// Get calendar days for current month
|
||||
const getCalendarDays = useMemo(() => {
|
||||
const firstDay = new Date(currentYear, currentMonth, 1);
|
||||
const lastDay = new Date(currentYear, currentMonth + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
const endDate = new Date(lastDay);
|
||||
|
||||
// Adjust for first day of week
|
||||
const dayOfWeek = firstDay.getDay();
|
||||
const adjustedDayOfWeek = firstDayOfWeek === 0 ? dayOfWeek : (dayOfWeek + 6) % 7;
|
||||
startDate.setDate(1 - adjustedDayOfWeek);
|
||||
|
||||
// Adjust end date to complete the grid
|
||||
const daysToAdd = 6 - ((endDate.getDay() - firstDayOfWeek + 7) % 7);
|
||||
endDate.setDate(endDate.getDate() + daysToAdd);
|
||||
|
||||
const days: Date[] = [];
|
||||
const current = new Date(startDate);
|
||||
|
||||
while (current <= endDate) {
|
||||
days.push(new Date(current));
|
||||
current.setDate(current.getDate() + 1);
|
||||
}
|
||||
|
||||
return days;
|
||||
}, [currentMonth, currentYear, firstDayOfWeek]);
|
||||
|
||||
// Handle date selection
|
||||
const handleDateSelect = (date: Date) => {
|
||||
if (isDateDisabled(date)) return;
|
||||
|
||||
let newDate = new Date(date);
|
||||
|
||||
// Preserve time if time is shown and a previous value exists
|
||||
if (showTime && currentValue) {
|
||||
newDate.setHours(currentValue.getHours(), currentValue.getMinutes());
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(newDate);
|
||||
}
|
||||
onChange?.(newDate);
|
||||
|
||||
if (closeOnSelect && !showTime) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle today button click
|
||||
const handleTodayClick = () => {
|
||||
const today = new Date();
|
||||
if (showTime && currentValue) {
|
||||
today.setHours(currentValue.getHours(), currentValue.getMinutes());
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(today);
|
||||
}
|
||||
onChange?.(today);
|
||||
setCurrentMonth(today.getMonth());
|
||||
setCurrentYear(today.getFullYear());
|
||||
|
||||
if (closeOnSelect && !showTime) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clear button click
|
||||
const handleClear = () => {
|
||||
if (value === undefined) {
|
||||
setInternalValue(null);
|
||||
}
|
||||
onChange?.(null);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
|
||||
const parsedDate = parseDate(newValue);
|
||||
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
||||
if (value === undefined) {
|
||||
setInternalValue(parsedDate);
|
||||
}
|
||||
onChange?.(parsedDate);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle time change
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value;
|
||||
setTimeValue(newTime);
|
||||
|
||||
if (currentValue) {
|
||||
const [hours, minutes] = newTime.split(':').map(num => parseInt(num, 10));
|
||||
const newDate = new Date(currentValue);
|
||||
newDate.setHours(hours, minutes);
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(newDate);
|
||||
}
|
||||
onChange?.(newDate);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle navigation
|
||||
const handlePrevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentMonth(11);
|
||||
setCurrentYear(currentYear - 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentMonth(0);
|
||||
setCurrentYear(currentYear + 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Close calendar when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Update input value when currentValue changes
|
||||
useEffect(() => {
|
||||
if (currentValue) {
|
||||
setInputValue(formatDate(currentValue));
|
||||
if (showTime) {
|
||||
setTimeValue(`${currentValue.getHours().toString().padStart(2, '0')}:${currentValue.getMinutes().toString().padStart(2, '0')}`);
|
||||
}
|
||||
} else {
|
||||
setInputValue('');
|
||||
}
|
||||
}, [currentValue, format, showTime]);
|
||||
|
||||
const hasError = isInvalid || !!error;
|
||||
|
||||
const baseInputClasses = [
|
||||
'w-full transition-colors duration-200',
|
||||
'focus:outline-none',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'placeholder:text-input-placeholder'
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
outline: [
|
||||
'bg-input-bg border border-input-border',
|
||||
'focus:border-input-border-focus focus:ring-1 focus:ring-input-border-focus',
|
||||
hasError ? 'border-input-border-error focus:border-input-border-error focus:ring-input-border-error' : ''
|
||||
],
|
||||
filled: [
|
||||
'bg-bg-secondary border border-transparent',
|
||||
'focus:bg-input-bg focus:border-input-border-focus',
|
||||
hasError ? 'border-input-border-error' : ''
|
||||
],
|
||||
unstyled: [
|
||||
'bg-transparent border-none',
|
||||
'focus:ring-0'
|
||||
]
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-base',
|
||||
lg: 'h-12 px-5 text-lg'
|
||||
};
|
||||
|
||||
const inputClasses = clsx(
|
||||
baseInputClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
'rounded-lg pr-10',
|
||||
className
|
||||
);
|
||||
|
||||
const calendarClasses = clsx(
|
||||
'absolute top-full left-0 z-50 mt-1 bg-dropdown-bg border border-dropdown-border rounded-lg shadow-lg',
|
||||
'transform transition-all duration-200 ease-out',
|
||||
{
|
||||
'opacity-0 scale-95 pointer-events-none': !isOpen,
|
||||
'opacity-100 scale-100': isOpen,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={datePickerId}
|
||||
className="block text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{label}
|
||||
{isRequired && (
|
||||
<span className="text-color-error ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div ref={containerRef} className="relative">
|
||||
<input
|
||||
ref={ref || inputRef}
|
||||
id={datePickerId}
|
||||
type="text"
|
||||
className={inputClasses}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onFocus={(e) => {
|
||||
setIsOpen(true);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={
|
||||
error ? `${datePickerId}-error` :
|
||||
helperText ? `${datePickerId}-helper` :
|
||||
undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-text-tertiary hover:text-text-primary transition-colors duration-150"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Calendar Popup */}
|
||||
<div className={calendarClasses}>
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
{renderHeader ? (
|
||||
renderHeader(new Date(currentYear, currentMonth), handlePrevMonth, handleNextMonth)
|
||||
) : (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-text-tertiary hover:text-text-primary transition-colors duration-150"
|
||||
onClick={handlePrevMonth}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className="px-2 py-1 text-sm border border-border-primary rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||||
value={currentMonth}
|
||||
onChange={(e) => setCurrentMonth(parseInt(e.target.value))}
|
||||
>
|
||||
{t.months.map((month, index) => (
|
||||
<option key={index} value={index}>
|
||||
{month}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="px-2 py-1 text-sm border border-border-primary rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||||
value={currentYear}
|
||||
onChange={(e) => setCurrentYear(parseInt(e.target.value))}
|
||||
>
|
||||
{Array.from({ length: yearRange * 2 + 1 }, (_, i) => {
|
||||
const year = new Date().getFullYear() - yearRange + i;
|
||||
return (
|
||||
<option key={year} value={year}>
|
||||
{year}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-text-tertiary hover:text-text-primary transition-colors duration-150"
|
||||
onClick={handleNextMonth}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekdays */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{(firstDayOfWeek === 0 ? t.weekdays : [...t.weekdays.slice(1), t.weekdays[0]]).map((day) => (
|
||||
<div key={day} className="text-xs font-medium text-text-tertiary text-center p-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{getCalendarDays.map((date) => {
|
||||
const isCurrentMonth = date.getMonth() === currentMonth;
|
||||
const selected = isSelected(date);
|
||||
const today = isToday(date);
|
||||
const disabled = isDateDisabled(date);
|
||||
|
||||
const dayContent = renderDay ? (
|
||||
renderDay(date, selected, today, disabled)
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
'w-8 h-8 text-sm rounded-full transition-colors duration-150 hover:bg-bg-secondary',
|
||||
{
|
||||
'text-text-tertiary': !isCurrentMonth,
|
||||
'text-text-primary': isCurrentMonth && !selected && !today,
|
||||
'bg-color-primary text-text-inverse': selected,
|
||||
'bg-color-primary/10 text-color-primary font-medium': today && !selected,
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
'hover:bg-bg-secondary': !disabled && !selected,
|
||||
}
|
||||
)}
|
||||
onClick={() => handleDateSelect(date)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{date.getDate()}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={date.toString()} className="flex items-center justify-center">
|
||||
{dayContent}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Time Input */}
|
||||
{showTime && (
|
||||
<div className="mt-4 pt-4 border-t border-border-primary">
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Hora
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="w-full px-3 py-2 border border-input-border rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
{(showToday || showClear) && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-primary">
|
||||
{showToday && (
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm text-color-primary hover:bg-color-primary/10 rounded transition-colors duration-150"
|
||||
onClick={handleTodayClick}
|
||||
>
|
||||
{t.today}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showClear && (
|
||||
<button
|
||||
type="button"
|
||||
className="px-3 py-1 text-sm text-text-tertiary hover:text-color-error hover:bg-color-error/10 rounded transition-colors duration-150"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t.clear}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
id={`${datePickerId}-error`}
|
||||
className="mt-2 text-sm text-color-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p
|
||||
id={`${datePickerId}-helper`}
|
||||
className="mt-2 text-sm text-text-secondary"
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DatePicker.displayName = 'DatePicker';
|
||||
|
||||
export default DatePicker;
|
||||
3
frontend/src/components/ui/DatePicker/index.ts
Normal file
3
frontend/src/components/ui/DatePicker/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './DatePicker';
|
||||
export { default as DatePicker } from './DatePicker';
|
||||
export type { DatePickerProps } from './DatePicker';
|
||||
@@ -1,287 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar, TrendingUp, Eye } from 'lucide-react';
|
||||
|
||||
export interface DayDemand {
|
||||
date: string;
|
||||
demand: number;
|
||||
isToday?: boolean;
|
||||
isForecast?: boolean;
|
||||
products?: Array<{
|
||||
name: string;
|
||||
demand: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeekData {
|
||||
weekStart: string;
|
||||
days: DayDemand[];
|
||||
}
|
||||
|
||||
interface DemandHeatmapProps {
|
||||
data: WeekData[];
|
||||
selectedProduct?: string;
|
||||
onDateClick?: (date: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DemandHeatmap: React.FC<DemandHeatmapProps> = ({
|
||||
data,
|
||||
selectedProduct,
|
||||
onDateClick,
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentWeekIndex, setCurrentWeekIndex] = useState(0);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
const maxDemand = Math.max(
|
||||
...data.flatMap(week => week.days.map(day => day.demand))
|
||||
);
|
||||
|
||||
const getDemandIntensity = (demand: number) => {
|
||||
const intensity = demand / maxDemand;
|
||||
if (intensity > 0.8) return 'bg-red-500';
|
||||
if (intensity > 0.6) return 'bg-orange-500';
|
||||
if (intensity > 0.4) return 'bg-yellow-500';
|
||||
if (intensity > 0.2) return 'bg-green-500';
|
||||
return 'bg-gray-200';
|
||||
};
|
||||
|
||||
const getDemandLabel = (demand: number) => {
|
||||
const intensity = demand / maxDemand;
|
||||
if (intensity > 0.8) return 'Muy Alta';
|
||||
if (intensity > 0.6) return 'Alta';
|
||||
if (intensity > 0.4) return 'Media';
|
||||
if (intensity > 0.2) return 'Baja';
|
||||
return 'Muy Baja';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.getDate().toString();
|
||||
};
|
||||
|
||||
const formatWeekRange = (weekStart: string) => {
|
||||
const start = new Date(weekStart);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 6);
|
||||
|
||||
return `${start.getDate()}-${end.getDate()} ${start.toLocaleDateString('es-ES', { month: 'short' })}`;
|
||||
};
|
||||
|
||||
const handleDateClick = (date: string) => {
|
||||
setSelectedDate(date);
|
||||
onDateClick?.(date);
|
||||
};
|
||||
|
||||
const currentWeek = data[currentWeekIndex];
|
||||
const selectedDay = selectedDate ?
|
||||
data.flatMap(w => w.days).find(d => d.date === selectedDate) : null;
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Mapa de Calor de Demanda
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Patrones de demanda visual por día
|
||||
{selectedProduct && ` - ${selectedProduct}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentWeekIndex(Math.max(0, currentWeekIndex - 1))}
|
||||
disabled={currentWeekIndex === 0}
|
||||
className="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-700 min-w-[100px] text-center">
|
||||
{currentWeek ? formatWeekRange(currentWeek.weekStart) : 'Esta Semana'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentWeekIndex(Math.min(data.length - 1, currentWeekIndex + 1))}
|
||||
disabled={currentWeekIndex === data.length - 1}
|
||||
className="p-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Heatmap Grid */}
|
||||
<div className="grid grid-cols-7 gap-2 mb-6">
|
||||
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(day => (
|
||||
<div key={day} className="text-center text-xs font-medium text-gray-600 p-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{currentWeek?.days.map(day => (
|
||||
<button
|
||||
key={day.date}
|
||||
onClick={() => handleDateClick(day.date)}
|
||||
className={`
|
||||
relative p-3 rounded-lg text-white text-sm font-medium
|
||||
transition-all duration-200 hover:scale-105 hover:shadow-md
|
||||
${getDemandIntensity(day.demand)}
|
||||
${selectedDate === day.date ? 'ring-2 ring-primary-600 ring-offset-2' : ''}
|
||||
${day.isToday ? 'ring-2 ring-blue-400' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold">{formatDate(day.date)}</div>
|
||||
<div className="text-xs opacity-90">{day.demand}</div>
|
||||
{day.isForecast && (
|
||||
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-400 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">Demanda:</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-gray-200 rounded"></div>
|
||||
<span className="text-xs">Muy Baja</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-green-500 rounded"></div>
|
||||
<span className="text-xs">Baja</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-yellow-500 rounded"></div>
|
||||
<span className="text-xs">Media</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-orange-500 rounded"></div>
|
||||
<span className="text-xs">Alta</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 bg-red-500 rounded"></div>
|
||||
<span className="text-xs">Muy Alta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-600">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full mr-1"></div>
|
||||
Predicción
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full mr-1"></div>
|
||||
Hoy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Day Details */}
|
||||
{selectedDay && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 flex items-center">
|
||||
<Eye className="h-4 w-4 mr-2 text-primary-600" />
|
||||
{new Date(selectedDay.date).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})}
|
||||
{selectedDay.isToday && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
Hoy
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className="mt-2 flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-4 w-4 text-gray-600 mr-1" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Demanda Total: <span className="font-medium">{selectedDay.demand}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
getDemandIntensity(selectedDay.demand)
|
||||
} text-white`}>
|
||||
{getDemandLabel(selectedDay.demand)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Breakdown */}
|
||||
{selectedDay.products && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Desglose por Producto:</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{selectedDay.products.map((product, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-900">{product.name}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
product.confidence === 'high' ? 'bg-green-100 text-green-800' :
|
||||
product.confidence === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{product.confidence === 'high' ? 'Alta' :
|
||||
product.confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-900 mt-1">
|
||||
{product.demand}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">unidades</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weekly Summary */}
|
||||
{currentWeek && (
|
||||
<div className="mt-6 bg-gray-50 rounded-lg p-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Resumen Semanal:</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{currentWeek.days.reduce((sum, day) => sum + day.demand, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{Math.round(currentWeek.days.reduce((sum, day) => sum + day.demand, 0) / 7)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Promedio/día</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{Math.max(...currentWeek.days.map(d => d.demand))}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Pico</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-gray-900">
|
||||
{currentWeek.days.filter(d => d.isForecast).length}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Predicciones</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DemandHeatmap;
|
||||
@@ -1,54 +0,0 @@
|
||||
// src/components/ui/Input.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-3 border rounded-xl transition-all duration-200',
|
||||
'placeholder-gray-400 text-gray-900',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||
error
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
191
frontend/src/components/ui/Input/Input.tsx
Normal file
191
frontend/src/components/ui/Input/Input.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { forwardRef, InputHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
isRequired?: boolean;
|
||||
isInvalid?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'outline' | 'filled' | 'unstyled';
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
leftAddon?: React.ReactNode;
|
||||
rightAddon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
isRequired = false,
|
||||
isInvalid = false,
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
leftAddon,
|
||||
rightAddon,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const hasError = isInvalid || !!error;
|
||||
|
||||
const baseInputClasses = [
|
||||
'w-full transition-colors duration-200',
|
||||
'focus:outline-none',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'placeholder:text-[var(--text-tertiary)]'
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
outline: [
|
||||
'bg-[var(--bg-primary)] border border-[var(--border-secondary)]',
|
||||
'focus:border-[var(--color-primary)] focus:ring-1 focus:ring-[var(--color-primary)]',
|
||||
hasError ? 'border-[var(--color-error)] focus:border-[var(--color-error)] focus:ring-[var(--color-error)]' : ''
|
||||
],
|
||||
filled: [
|
||||
'bg-[var(--bg-secondary)] border border-transparent',
|
||||
'focus:bg-[var(--bg-primary)] focus:border-[var(--color-primary)]',
|
||||
hasError ? 'border-[var(--color-error)]' : ''
|
||||
],
|
||||
unstyled: [
|
||||
'bg-transparent border-none',
|
||||
'focus:ring-0'
|
||||
]
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: leftIcon || rightIcon ? 'h-8 px-8 text-sm' : 'h-8 px-3 text-sm',
|
||||
md: leftIcon || rightIcon ? 'h-10 px-10 text-base' : 'h-10 px-4 text-base',
|
||||
lg: leftIcon || rightIcon ? 'h-12 px-12 text-lg' : 'h-12 px-5 text-lg'
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6'
|
||||
};
|
||||
|
||||
const inputClasses = clsx(
|
||||
baseInputClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
'rounded-lg',
|
||||
{
|
||||
'pl-8': leftIcon && size === 'sm',
|
||||
'pl-10': leftIcon && size === 'md',
|
||||
'pl-12': leftIcon && size === 'lg',
|
||||
'pr-8': rightIcon && size === 'sm',
|
||||
'pr-10': rightIcon && size === 'md',
|
||||
'pr-12': rightIcon && size === 'lg',
|
||||
'rounded-l-none': leftAddon,
|
||||
'rounded-r-none': rightAddon,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const addonClasses = clsx(
|
||||
'inline-flex items-center px-3 text-sm bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] text-[var(--text-secondary)]',
|
||||
{
|
||||
'border-r-0 rounded-l-lg': leftAddon,
|
||||
'border-l-0 rounded-r-lg': rightAddon,
|
||||
}
|
||||
);
|
||||
|
||||
const iconClasses = clsx(
|
||||
'absolute text-[var(--text-tertiary)] pointer-events-none',
|
||||
iconSizeClasses[size],
|
||||
{
|
||||
'left-2': leftIcon && size === 'sm',
|
||||
'left-2.5': leftIcon && size === 'md',
|
||||
'left-3': leftIcon && size === 'lg',
|
||||
'right-2': rightIcon && size === 'sm',
|
||||
'right-2.5': rightIcon && size === 'md',
|
||||
'right-3': rightIcon && size === 'lg',
|
||||
'top-1/2 -translate-y-1/2': true,
|
||||
}
|
||||
);
|
||||
|
||||
const inputElement = (
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className={iconClasses}>
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={inputClasses}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={
|
||||
error ? `${inputId}-error` :
|
||||
helperText ? `${inputId}-helper` :
|
||||
undefined
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className={iconClasses}>
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const inputWithAddons = (
|
||||
<div className="flex">
|
||||
{leftAddon && (
|
||||
<span className={addonClasses}>{leftAddon}</span>
|
||||
)}
|
||||
{inputElement}
|
||||
{rightAddon && (
|
||||
<span className={addonClasses}>{rightAddon}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-[var(--text-primary)] mb-2"
|
||||
>
|
||||
{label}
|
||||
{isRequired && (
|
||||
<span className="text-[var(--color-error)] ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{leftAddon || rightAddon ? inputWithAddons : inputElement}
|
||||
|
||||
{error && (
|
||||
<p
|
||||
id={`${inputId}-error`}
|
||||
className="mt-2 text-sm text-[var(--color-error)]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p
|
||||
id={`${inputId}-helper`}
|
||||
className="mt-2 text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export default Input;
|
||||
3
frontend/src/components/ui/Input/index.ts
Normal file
3
frontend/src/components/ui/Input/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Input';
|
||||
export { default as Input } from './Input';
|
||||
export type { InputProps } from './Input';
|
||||
138
frontend/src/components/ui/ListItem/ListItem.tsx
Normal file
138
frontend/src/components/ui/ListItem/ListItem.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ListItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
leadingContent?: React.ReactNode;
|
||||
trailingContent?: React.ReactNode;
|
||||
variant?: 'default' | 'compact' | 'detailed';
|
||||
interactive?: boolean;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
const ListItem = forwardRef<HTMLDivElement, ListItemProps>(({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
leadingContent,
|
||||
trailingContent,
|
||||
variant = 'default',
|
||||
interactive = false,
|
||||
active = false,
|
||||
disabled = false,
|
||||
divider = false,
|
||||
className,
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}, ref) => {
|
||||
const baseClasses = [
|
||||
'flex items-center w-full',
|
||||
'transition-colors duration-200',
|
||||
];
|
||||
|
||||
const variantClasses = {
|
||||
default: 'py-3 px-4',
|
||||
compact: 'py-2 px-3',
|
||||
detailed: 'py-4 px-4',
|
||||
};
|
||||
|
||||
const stateClasses = {
|
||||
interactive: interactive ? [
|
||||
'cursor-pointer',
|
||||
'hover:bg-[var(--bg-secondary)]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
] : [],
|
||||
active: active ? 'bg-[var(--color-primary)]/10 border-r-2 border-[var(--color-primary)]' : '',
|
||||
disabled: disabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
};
|
||||
|
||||
const Component = interactive ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Component
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
stateClasses.interactive,
|
||||
stateClasses.active,
|
||||
stateClasses.disabled,
|
||||
className
|
||||
)}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{/* Leading content */}
|
||||
{leadingContent && (
|
||||
<div className="flex-shrink-0 mr-3">
|
||||
{leadingContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={clsx(
|
||||
'font-medium text-[var(--text-primary)] truncate',
|
||||
{
|
||||
'text-sm': variant === 'compact',
|
||||
'text-base': variant === 'default',
|
||||
'text-lg': variant === 'detailed',
|
||||
}
|
||||
)}>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
{subtitle && (
|
||||
<p className={clsx(
|
||||
'text-[var(--text-secondary)] truncate mt-1',
|
||||
{
|
||||
'text-xs': variant === 'compact',
|
||||
'text-sm': variant === 'default' || variant === 'detailed',
|
||||
}
|
||||
)}>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{description && variant === 'detailed' && (
|
||||
<p className="text-sm text-[var(--text-tertiary)] mt-1 line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{children && (
|
||||
<div className="mt-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trailing content */}
|
||||
{trailingContent && (
|
||||
<div className="flex-shrink-0 ml-3">
|
||||
{trailingContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
|
||||
{divider && (
|
||||
<div className="border-b border-[var(--border-primary)] mx-4" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ListItem.displayName = 'ListItem';
|
||||
|
||||
export default ListItem;
|
||||
2
frontend/src/components/ui/ListItem/index.ts
Normal file
2
frontend/src/components/ui/ListItem/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ListItem } from './ListItem';
|
||||
export type { ListItemProps } from './ListItem';
|
||||
@@ -1,27 +0,0 @@
|
||||
// src/components/ui/LoadingSpinner.tsx
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
className={`animate-spin text-primary-500 ${sizeClasses[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
359
frontend/src/components/ui/Modal/Modal.tsx
Normal file
359
frontend/src/components/ui/Modal/Modal.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { forwardRef, useEffect, useRef, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onClose'> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
variant?: 'default' | 'centered' | 'drawer-left' | 'drawer-right' | 'drawer-top' | 'drawer-bottom';
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
preventScroll?: boolean;
|
||||
initialFocus?: React.RefObject<HTMLElement>;
|
||||
finalFocus?: React.RefObject<HTMLElement>;
|
||||
overlayClassName?: string;
|
||||
contentClassName?: string;
|
||||
portalId?: string;
|
||||
}
|
||||
|
||||
export interface ModalHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
showCloseButton?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ModalBodyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
export interface ModalFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
justify?: 'start' | 'end' | 'center' | 'between' | 'around' | 'evenly';
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const Modal = forwardRef<HTMLDivElement, ModalProps>(({
|
||||
isOpen,
|
||||
onClose,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
preventScroll = true,
|
||||
initialFocus,
|
||||
finalFocus,
|
||||
overlayClassName,
|
||||
contentClassName,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEscape) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, closeOnEscape, onClose]);
|
||||
|
||||
// Handle body scroll lock
|
||||
useEffect(() => {
|
||||
if (!isOpen || !preventScroll) return;
|
||||
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = originalStyle;
|
||||
};
|
||||
}, [isOpen, preventScroll]);
|
||||
|
||||
// Handle focus management
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
// Store the currently focused element
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Set focus to initial focus element or modal content
|
||||
const timer = setTimeout(() => {
|
||||
const focusTarget = initialFocus?.current || contentRef.current;
|
||||
if (focusTarget) {
|
||||
focusTarget.focus();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
// Return focus to previously focused element
|
||||
if (finalFocus?.current) {
|
||||
finalFocus.current.focus();
|
||||
} else if (previousFocusRef.current) {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
};
|
||||
}, [isOpen, initialFocus, finalFocus]);
|
||||
|
||||
// Handle overlay click
|
||||
const handleOverlayClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (closeOnOverlayClick && event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'max-w-xs',
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
||||
},
|
||||
centered: {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex items-center justify-center p-4',
|
||||
content: 'w-full transform transition-all duration-300 ease-out scale-100 opacity-100',
|
||||
},
|
||||
'drawer-left': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-start',
|
||||
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
||||
},
|
||||
'drawer-right': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex justify-end',
|
||||
content: 'h-full w-full max-w-md transform transition-all duration-300 ease-out translate-x-0',
|
||||
},
|
||||
'drawer-top': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-start',
|
||||
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
||||
},
|
||||
'drawer-bottom': {
|
||||
overlay: 'fixed inset-0 bg-modal-backdrop backdrop-blur-sm z-50 flex flex-col justify-end',
|
||||
content: 'w-full transform transition-all duration-300 ease-out translate-y-0',
|
||||
},
|
||||
};
|
||||
|
||||
const overlayClasses = clsx(
|
||||
variantClasses[variant].overlay,
|
||||
'animate-in fade-in duration-200',
|
||||
overlayClassName
|
||||
);
|
||||
|
||||
const contentClasses = clsx(
|
||||
'relative bg-modal-bg border border-modal-border rounded-lg shadow-xl',
|
||||
'animate-in zoom-in-95 duration-200',
|
||||
variantClasses[variant].content,
|
||||
sizeClasses[size],
|
||||
{
|
||||
'rounded-none': variant.includes('drawer'),
|
||||
'rounded-t-lg rounded-b-none': variant === 'drawer-bottom',
|
||||
'rounded-b-lg rounded-t-none': variant === 'drawer-top',
|
||||
'rounded-r-lg rounded-l-none': variant === 'drawer-left',
|
||||
'rounded-l-lg rounded-r-none': variant === 'drawer-right',
|
||||
},
|
||||
contentClassName,
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className={overlayClasses}
|
||||
onClick={handleOverlayClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
>
|
||||
<div
|
||||
ref={ref || contentRef}
|
||||
className={contentClasses}
|
||||
{...props}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded-md p-1"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ModalHeader = forwardRef<HTMLDivElement, ModalHeaderProps>(({
|
||||
title,
|
||||
subtitle,
|
||||
showCloseButton = false,
|
||||
onClose,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const classes = clsx(
|
||||
'px-6 py-4 border-b border-border-primary',
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<h2
|
||||
id="modal-title"
|
||||
className="text-lg font-semibold text-text-primary"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{showCloseButton && onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-4 text-text-tertiary hover:text-text-primary transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded-md p-1"
|
||||
onClick={onClose}
|
||||
aria-label="Cerrar modal"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ModalBody = forwardRef<HTMLDivElement, ModalBodyProps>(({
|
||||
padding = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-6',
|
||||
lg: 'p-8',
|
||||
xl: 'p-10',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
paddingClasses[padding],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id="modal-description"
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ModalFooter = forwardRef<HTMLDivElement, ModalFooterProps>(({
|
||||
justify = 'end',
|
||||
padding = 'md',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'px-6 py-4',
|
||||
lg: 'px-8 py-5',
|
||||
xl: 'px-10 py-6',
|
||||
};
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
end: 'justify-end',
|
||||
center: 'justify-center',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
};
|
||||
|
||||
const classes = clsx(
|
||||
'flex items-center gap-3 border-t border-border-primary',
|
||||
paddingClasses[padding],
|
||||
justifyClasses[justify],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Modal.displayName = 'Modal';
|
||||
ModalHeader.displayName = 'ModalHeader';
|
||||
ModalBody.displayName = 'ModalBody';
|
||||
ModalFooter.displayName = 'ModalFooter';
|
||||
|
||||
export default Modal;
|
||||
export { ModalHeader, ModalBody, ModalFooter };
|
||||
3
frontend/src/components/ui/Modal/index.ts
Normal file
3
frontend/src/components/ui/Modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Modal';
|
||||
export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
|
||||
export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
|
||||
@@ -1,207 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, AlertTriangle, TrendingUp } from 'lucide-react';
|
||||
|
||||
export interface ProductionItem {
|
||||
id: string;
|
||||
product: string;
|
||||
quantity: number;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
estimatedTime: number; // minutes
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
confidence: number; // 0-1
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface ProductionTimeSlot {
|
||||
time: string;
|
||||
items: ProductionItem[];
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
interface ProductionScheduleProps {
|
||||
schedule: ProductionTimeSlot[];
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const getPriorityConfig = (priority: ProductionItem['priority']) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return {
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-800',
|
||||
label: 'ALTA'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
textColor: 'text-yellow-800',
|
||||
label: 'MEDIA'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
label: 'BAJA'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: ProductionItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-success-600" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="h-4 w-4 text-primary-600" />;
|
||||
default:
|
||||
return <AlertTriangle className="h-4 w-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence: number) => {
|
||||
if (confidence >= 0.8) return 'text-success-600';
|
||||
if (confidence >= 0.6) return 'text-warning-600';
|
||||
return 'text-danger-600';
|
||||
};
|
||||
|
||||
const ProductionItem: React.FC<{
|
||||
item: ProductionItem;
|
||||
onUpdateQuantity?: (itemId: string, newQuantity: number) => void;
|
||||
onUpdateStatus?: (itemId: string, status: ProductionItem['status']) => void;
|
||||
}> = ({ item, onUpdateQuantity, onUpdateStatus }) => {
|
||||
const priorityConfig = getPriorityConfig(item.priority);
|
||||
|
||||
const handleQuantityChange = (delta: number) => {
|
||||
const newQuantity = Math.max(0, item.quantity + delta);
|
||||
onUpdateQuantity?.(item.id, newQuantity);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(item.status)}
|
||||
<span className="font-medium text-gray-900">{item.product}</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${priorityConfig.bgColor} ${priorityConfig.borderColor} ${priorityConfig.textColor}`}>
|
||||
{priorityConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${getConfidenceColor(item.confidence)}`}>
|
||||
{Math.round(item.confidence * 100)}% confianza
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={() => handleQuantityChange(-5)}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm font-medium"
|
||||
disabled={item.quantity <= 5}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-12 text-center font-bold text-lg">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange(5)}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-sm font-medium"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">unidades</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{item.estimatedTime} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="text-sm text-gray-600 bg-gray-50 rounded p-2">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{item.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(item.id, 'in_progress')}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-primary-700 bg-primary-100 hover:bg-primary-200 rounded transition-colors"
|
||||
>
|
||||
Iniciar Producción
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onUpdateStatus?.(item.id, 'completed')}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-success-700 bg-success-100 hover:bg-success-200 rounded transition-colors"
|
||||
>
|
||||
Marcar Completado
|
||||
</button>
|
||||
)}
|
||||
{item.status === 'completed' && (
|
||||
<div className="flex-1 px-3 py-2 text-sm font-medium text-success-700 bg-success-100 rounded text-center">
|
||||
✓ Completado
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ProductionSchedule: React.FC<ProductionScheduleProps> = ({
|
||||
schedule,
|
||||
onUpdateQuantity,
|
||||
onUpdateStatus,
|
||||
className = ''
|
||||
}) => {
|
||||
const getTotalItems = (items: ProductionItem[]) => {
|
||||
return items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Plan de Producción de Hoy
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span>Optimizado por IA</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{schedule.map((timeSlot, index) => (
|
||||
<div key={index} className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary-100 text-primary-800 px-3 py-1 rounded-lg font-medium text-sm">
|
||||
{timeSlot.time}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{getTotalItems(timeSlot.items)} unidades • {timeSlot.totalTime} min total
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{timeSlot.items.map((item) => (
|
||||
<ProductionItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onUpdateQuantity={onUpdateQuantity}
|
||||
onUpdateStatus={onUpdateStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionSchedule;
|
||||
76
frontend/src/components/ui/ProgressBar/ProgressBar.tsx
Normal file
76
frontend/src/components/ui/ProgressBar/ProgressBar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger';
|
||||
showLabel?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
|
||||
value,
|
||||
max = 100,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
showLabel = false,
|
||||
label,
|
||||
className,
|
||||
animated = false,
|
||||
...props
|
||||
}, ref) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-2',
|
||||
md: 'h-3',
|
||||
lg: 'h-4',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-[var(--color-info)]',
|
||||
success: 'bg-[var(--color-success)]',
|
||||
warning: 'bg-[var(--color-warning)]',
|
||||
danger: 'bg-[var(--color-error)]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx('w-full', className)} {...props}>
|
||||
{(showLabel || label) && (
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{label || `${value}/${max}`}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{Math.round(percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full bg-[var(--bg-quaternary)] rounded-full overflow-hidden',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-300 ease-out',
|
||||
variantClasses[variant],
|
||||
{
|
||||
'animate-pulse': animated && percentage < 100,
|
||||
}
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ProgressBar.displayName = 'ProgressBar';
|
||||
|
||||
export default ProgressBar;
|
||||
2
frontend/src/components/ui/ProgressBar/index.ts
Normal file
2
frontend/src/components/ui/ProgressBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ProgressBar } from './ProgressBar';
|
||||
export type { ProgressBarProps } from './ProgressBar';
|
||||
@@ -1,373 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Zap, Plus, Minus, RotateCcw, TrendingUp, ShoppingCart,
|
||||
Clock, AlertTriangle, Phone, MessageSquare, Calculator,
|
||||
RefreshCw, Package, Users, Settings, ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: any;
|
||||
category: 'production' | 'inventory' | 'sales' | 'customer' | 'system';
|
||||
shortcut?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
estimatedTime?: string;
|
||||
badge?: {
|
||||
text: string;
|
||||
color: 'red' | 'yellow' | 'green' | 'blue' | 'purple';
|
||||
};
|
||||
}
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
onActionClick?: (actionId: string) => void;
|
||||
availableActions?: QuickAction[];
|
||||
compactMode?: boolean;
|
||||
showCategories?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickActionsPanel: React.FC<QuickActionsPanelProps> = ({
|
||||
onActionClick,
|
||||
availableActions,
|
||||
compactMode = false,
|
||||
showCategories = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
|
||||
|
||||
const defaultActions: QuickAction[] = [
|
||||
// Production Actions
|
||||
{
|
||||
id: 'increase_production',
|
||||
title: 'Aumentar Producción',
|
||||
description: 'Incrementa rápidamente la producción del producto más demandado',
|
||||
icon: Plus,
|
||||
category: 'production',
|
||||
shortcut: 'P + A',
|
||||
estimatedTime: '2 min',
|
||||
badge: { text: 'Croissants', color: 'green' }
|
||||
},
|
||||
{
|
||||
id: 'emergency_batch',
|
||||
title: 'Lote de Emergencia',
|
||||
description: 'Inicia producción urgente para productos con stock bajo',
|
||||
icon: AlertTriangle,
|
||||
category: 'production',
|
||||
requiresConfirmation: true,
|
||||
estimatedTime: '45 min',
|
||||
badge: { text: '3 productos', color: 'red' }
|
||||
},
|
||||
{
|
||||
id: 'adjust_schedule',
|
||||
title: 'Ajustar Horario',
|
||||
description: 'Modifica el horario de producción basado en predicciones',
|
||||
icon: Clock,
|
||||
category: 'production',
|
||||
estimatedTime: '1 min'
|
||||
},
|
||||
|
||||
// Inventory Actions
|
||||
{
|
||||
id: 'check_stock',
|
||||
title: 'Revisar Stock',
|
||||
description: 'Verifica niveles de inventario y productos próximos a agotarse',
|
||||
icon: Package,
|
||||
category: 'inventory',
|
||||
shortcut: 'I + S',
|
||||
badge: { text: '2 bajos', color: 'yellow' }
|
||||
},
|
||||
{
|
||||
id: 'order_supplies',
|
||||
title: 'Pedir Suministros',
|
||||
description: 'Genera orden automática de ingredientes basada en predicciones',
|
||||
icon: ShoppingCart,
|
||||
category: 'inventory',
|
||||
estimatedTime: '3 min',
|
||||
badge: { text: 'Harina, Huevos', color: 'blue' }
|
||||
},
|
||||
{
|
||||
id: 'waste_report',
|
||||
title: 'Reportar Desperdicio',
|
||||
description: 'Registra productos no vendidos para mejorar predicciones',
|
||||
icon: Minus,
|
||||
category: 'inventory',
|
||||
estimatedTime: '2 min'
|
||||
},
|
||||
|
||||
// Sales Actions
|
||||
{
|
||||
id: 'price_adjustment',
|
||||
title: 'Ajustar Precios',
|
||||
description: 'Modifica precios para productos con baja rotación',
|
||||
icon: Calculator,
|
||||
category: 'sales',
|
||||
requiresConfirmation: true,
|
||||
badge: { text: 'Magdalenas -10%', color: 'yellow' }
|
||||
},
|
||||
{
|
||||
id: 'promotion_activate',
|
||||
title: 'Activar Promoción',
|
||||
description: 'Inicia promoción instantánea para productos específicos',
|
||||
icon: TrendingUp,
|
||||
category: 'sales',
|
||||
estimatedTime: '1 min',
|
||||
badge: { text: '2x1 Tartas', color: 'green' }
|
||||
},
|
||||
|
||||
// Customer Actions
|
||||
{
|
||||
id: 'notify_customers',
|
||||
title: 'Avisar Clientes',
|
||||
description: 'Notifica a clientes regulares sobre disponibilidad especial',
|
||||
icon: MessageSquare,
|
||||
category: 'customer',
|
||||
badge: { text: '15 clientes', color: 'blue' }
|
||||
},
|
||||
{
|
||||
id: 'call_supplier',
|
||||
title: 'Llamar Proveedor',
|
||||
description: 'Contacto rápido con proveedor principal',
|
||||
icon: Phone,
|
||||
category: 'customer',
|
||||
estimatedTime: '5 min'
|
||||
},
|
||||
|
||||
// System Actions
|
||||
{
|
||||
id: 'refresh_predictions',
|
||||
title: 'Actualizar Predicciones',
|
||||
description: 'Recalcula predicciones con datos más recientes',
|
||||
icon: RefreshCw,
|
||||
category: 'system',
|
||||
estimatedTime: '30 seg'
|
||||
},
|
||||
{
|
||||
id: 'backup_data',
|
||||
title: 'Respaldar Datos',
|
||||
description: 'Crea respaldo de la información del día',
|
||||
icon: RotateCcw,
|
||||
category: 'system',
|
||||
estimatedTime: '1 min'
|
||||
}
|
||||
];
|
||||
|
||||
const actions = availableActions || defaultActions;
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todas', icon: Zap },
|
||||
{ id: 'production', name: 'Producción', icon: Package },
|
||||
{ id: 'inventory', name: 'Inventario', icon: ShoppingCart },
|
||||
{ id: 'sales', name: 'Ventas', icon: TrendingUp },
|
||||
{ id: 'customer', name: 'Clientes', icon: Users },
|
||||
{ id: 'system', name: 'Sistema', icon: Settings }
|
||||
];
|
||||
|
||||
const filteredActions = actions.filter(action =>
|
||||
selectedCategory === 'all' || action.category === selectedCategory
|
||||
);
|
||||
|
||||
const handleActionClick = async (action: QuickAction) => {
|
||||
if (action.requiresConfirmation) {
|
||||
const confirmed = window.confirm(`¿Estás seguro de que quieres ejecutar "${action.title}"?`);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
setActionInProgress(action.id);
|
||||
|
||||
// Simulate action execution
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
setActionInProgress(null);
|
||||
onActionClick?.(action.id);
|
||||
};
|
||||
|
||||
const getBadgeColors = (color: string) => {
|
||||
const colors = {
|
||||
red: 'bg-red-100 text-red-800',
|
||||
yellow: 'bg-yellow-100 text-yellow-800',
|
||||
green: 'bg-green-100 text-green-800',
|
||||
blue: 'bg-blue-100 text-blue-800',
|
||||
purple: 'bg-purple-100 text-purple-800'
|
||||
};
|
||||
return colors[color as keyof typeof colors] || colors.blue;
|
||||
};
|
||||
|
||||
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">
|
||||
<Zap className="h-6 w-6 text-yellow-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Tareas comunes del día a día
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-500">
|
||||
{filteredActions.length} acciones disponibles
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
Usa atajos de teclado para mayor velocidad
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filters */}
|
||||
{showCategories && !compactMode && (
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{categories.map(category => {
|
||||
const IconComponent = category.icon;
|
||||
const isSelected = selectedCategory === category.id;
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
onClick={() => setSelectedCategory(category.id)}
|
||||
className={`flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isSelected
|
||||
? 'bg-yellow-100 text-yellow-800 border border-yellow-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<IconComponent className="h-4 w-4 mr-1.5" />
|
||||
{category.name}
|
||||
{category.id !== 'all' && (
|
||||
<span className="ml-1.5 text-xs bg-white rounded-full px-1.5 py-0.5">
|
||||
{actions.filter(a => a.category === category.id).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Grid */}
|
||||
<div className="p-6">
|
||||
<div className={`grid gap-4 ${
|
||||
compactMode
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'
|
||||
}`}>
|
||||
{filteredActions.map(action => {
|
||||
const IconComponent = action.icon;
|
||||
const isInProgress = actionInProgress === action.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={() => handleActionClick(action)}
|
||||
disabled={isInProgress}
|
||||
className={`relative group text-left p-4 border border-gray-200 rounded-lg hover:border-yellow-300 hover:shadow-md transition-all duration-200 ${
|
||||
isInProgress ? 'opacity-50 cursor-not-allowed' : 'hover:scale-[1.02]'
|
||||
} ${compactMode ? 'flex items-center' : 'block'}`}
|
||||
>
|
||||
{/* Loading overlay */}
|
||||
{isInProgress && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-90 rounded-lg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-yellow-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex ${compactMode ? 'items-center space-x-3' : 'items-start justify-between'}`}>
|
||||
<div className={`flex ${compactMode ? 'items-center space-x-3' : 'items-start space-x-3'}`}>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 p-2 bg-yellow-50 rounded-lg group-hover:bg-yellow-100 transition-colors">
|
||||
<IconComponent className="h-5 w-5 text-yellow-600" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-gray-900 group-hover:text-yellow-700">
|
||||
{action.title}
|
||||
</h4>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex items-center space-x-2 ml-2">
|
||||
{action.badge && (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${getBadgeColors(action.badge.color)}`}>
|
||||
{action.badge.text}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{action.shortcut && !compactMode && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded border font-mono">
|
||||
{action.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!compactMode && (
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center mt-2 space-x-3">
|
||||
{action.estimatedTime && (
|
||||
<span className="flex items-center text-xs text-gray-500">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{action.estimatedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{action.requiresConfirmation && (
|
||||
<span className="flex items-center text-xs text-orange-600">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Requiere confirmación
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow indicator */}
|
||||
{!compactMode && (
|
||||
<ChevronRight className="h-4 w-4 text-gray-400 group-hover:text-yellow-600 transition-colors flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredActions.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Zap className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No hay acciones disponibles</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Las acciones aparecerán basadas en el estado actual de tu panadería
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Help */}
|
||||
{!compactMode && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span className="font-medium">💡 Tip:</span>
|
||||
<span>Usa Ctrl + K para búsqueda rápida de acciones</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickActionsPanel;
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Euro, TrendingUp, TrendingDown, AlertCircle } from 'lucide-react';
|
||||
|
||||
export interface RevenueData {
|
||||
projectedDailyRevenue: number;
|
||||
lostRevenueFromStockouts: number;
|
||||
wasteCost: number;
|
||||
revenueTrend: 'up' | 'down' | 'stable';
|
||||
trendPercentage: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface RevenueMetricsProps {
|
||||
revenueData: RevenueData;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = '€') => {
|
||||
return `${amount.toFixed(0)}${currency}`;
|
||||
};
|
||||
|
||||
const RevenueMetrics: React.FC<RevenueMetricsProps> = ({ revenueData, className = '' }) => {
|
||||
const getTrendIcon = () => {
|
||||
switch (revenueData.revenueTrend) {
|
||||
case 'up':
|
||||
return <TrendingUp className="h-4 w-4 text-success-600" />;
|
||||
case 'down':
|
||||
return <TrendingDown className="h-4 w-4 text-danger-600" />;
|
||||
default:
|
||||
return <TrendingUp className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
switch (revenueData.revenueTrend) {
|
||||
case 'up':
|
||||
return 'text-success-600';
|
||||
case 'down':
|
||||
return 'text-danger-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 md:grid-cols-3 gap-4 ${className}`}>
|
||||
{/* Projected Daily Revenue */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<Euro className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Previstos Hoy</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(revenueData.projectedDailyRevenue, revenueData.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center mt-2 text-sm ${getTrendColor()}`}>
|
||||
{getTrendIcon()}
|
||||
<span className="ml-1">
|
||||
{revenueData.trendPercentage > 0 ? '+' : ''}{revenueData.trendPercentage}% vs ayer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lost Revenue from Stockouts */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-danger-100 rounded-lg">
|
||||
<AlertCircle className="h-6 w-6 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ventas Perdidas</p>
|
||||
<p className="text-2xl font-bold text-danger-700">
|
||||
-{formatCurrency(revenueData.lostRevenueFromStockouts, revenueData.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Por falta de stock (últimos 7 días)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waste Cost Tracker */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<TrendingDown className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Coste Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-warning-700">
|
||||
-{formatCurrency(revenueData.wasteCost, revenueData.currency)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Productos no vendidos (esta semana)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevenueMetrics;
|
||||
598
frontend/src/components/ui/Select/Select.tsx
Normal file
598
frontend/src/components/ui/Select/Select.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
import React, { forwardRef, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
group?: string;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'onSelect'> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
placeholder?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'outline' | 'filled' | 'unstyled';
|
||||
options: SelectOption[];
|
||||
value?: string | number | Array<string | number>;
|
||||
defaultValue?: string | number | Array<string | number>;
|
||||
multiple?: boolean;
|
||||
searchable?: boolean;
|
||||
clearable?: boolean;
|
||||
loading?: boolean;
|
||||
isRequired?: boolean;
|
||||
isInvalid?: boolean;
|
||||
maxHeight?: number;
|
||||
dropdownPosition?: 'auto' | 'top' | 'bottom';
|
||||
createable?: boolean;
|
||||
onCreate?: (inputValue: string) => void;
|
||||
onSearch?: (inputValue: string) => void;
|
||||
onChange?: (value: string | number | Array<string | number>) => void;
|
||||
onBlur?: (event: React.FocusEvent<HTMLDivElement>) => void;
|
||||
onFocus?: (event: React.FocusEvent<HTMLDivElement>) => void;
|
||||
renderOption?: (option: SelectOption, isSelected: boolean) => React.ReactNode;
|
||||
renderValue?: (value: string | number | Array<string | number>, options: SelectOption[]) => React.ReactNode;
|
||||
noOptionsMessage?: string;
|
||||
loadingMessage?: string;
|
||||
createLabel?: string;
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
placeholder = 'Seleccionar...',
|
||||
size = 'md',
|
||||
variant = 'outline',
|
||||
options = [],
|
||||
value,
|
||||
defaultValue,
|
||||
multiple = false,
|
||||
searchable = false,
|
||||
clearable = false,
|
||||
loading = false,
|
||||
isRequired = false,
|
||||
isInvalid = false,
|
||||
maxHeight = 300,
|
||||
dropdownPosition = 'auto',
|
||||
createable = false,
|
||||
onCreate,
|
||||
onSearch,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
renderOption,
|
||||
renderValue,
|
||||
noOptionsMessage = 'No hay opciones disponibles',
|
||||
loadingMessage = 'Cargando...',
|
||||
createLabel = 'Crear',
|
||||
className,
|
||||
id,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const [internalValue, setInternalValue] = useState<string | number | Array<string | number>>(
|
||||
value !== undefined ? value : defaultValue || (multiple ? [] : '')
|
||||
);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const optionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Use controlled value if provided, otherwise use internal state
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
|
||||
// Filter options based on search term
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchable || !searchTerm) return options;
|
||||
|
||||
return options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
option.value.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [options, searchTerm, searchable]);
|
||||
|
||||
// Group options if they have groups
|
||||
const groupedOptions = useMemo(() => {
|
||||
const groups: { [key: string]: SelectOption[] } = {};
|
||||
const ungrouped: SelectOption[] = [];
|
||||
|
||||
filteredOptions.forEach(option => {
|
||||
if (option.group) {
|
||||
if (!groups[option.group]) {
|
||||
groups[option.group] = [];
|
||||
}
|
||||
groups[option.group].push(option);
|
||||
} else {
|
||||
ungrouped.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
return { groups, ungrouped };
|
||||
}, [filteredOptions]);
|
||||
|
||||
// Check if option is selected
|
||||
const isOptionSelected = (option: SelectOption) => {
|
||||
if (multiple && Array.isArray(currentValue)) {
|
||||
return currentValue.includes(option.value);
|
||||
}
|
||||
return currentValue === option.value;
|
||||
};
|
||||
|
||||
// Get selected options for display
|
||||
const getSelectedOptions = () => {
|
||||
if (multiple && Array.isArray(currentValue)) {
|
||||
return options.filter(option => currentValue.includes(option.value));
|
||||
}
|
||||
return options.filter(option => option.value === currentValue);
|
||||
};
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = (option: SelectOption) => {
|
||||
if (option.disabled) return;
|
||||
|
||||
let newValue: string | number | Array<string | number>;
|
||||
|
||||
if (multiple) {
|
||||
const currentArray = Array.isArray(currentValue) ? currentValue : [];
|
||||
if (currentArray.includes(option.value)) {
|
||||
newValue = currentArray.filter(val => val !== option.value);
|
||||
} else {
|
||||
newValue = [...currentArray, option.value];
|
||||
}
|
||||
} else {
|
||||
newValue = option.value;
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// Handle search input change
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setSearchTerm(value);
|
||||
setHighlightedIndex(-1);
|
||||
onSearch?.(value);
|
||||
};
|
||||
|
||||
// Handle clear selection
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newValue = multiple ? [] : '';
|
||||
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// Handle remove single option in multiple mode
|
||||
const handleRemoveOption = (optionValue: string | number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!multiple || !Array.isArray(currentValue)) return;
|
||||
|
||||
const newValue = currentValue.filter(val => val !== optionValue);
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
// Handle create new option
|
||||
const handleCreate = () => {
|
||||
if (!createable || !onCreate || !searchTerm.trim()) return;
|
||||
|
||||
onCreate(searchTerm.trim());
|
||||
setSearchTerm('');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||
handleOptionSelect(filteredOptions[highlightedIndex]);
|
||||
} else if (createable && searchTerm.trim()) {
|
||||
handleCreate();
|
||||
}
|
||||
break;
|
||||
case 'Tab':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Focus search input when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && searchable && searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [isOpen, searchable]);
|
||||
|
||||
// Scroll highlighted option into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && optionsRef.current) {
|
||||
const optionElement = optionsRef.current.children[highlightedIndex] as HTMLElement;
|
||||
if (optionElement) {
|
||||
optionElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex]);
|
||||
|
||||
const hasError = isInvalid || !!error;
|
||||
|
||||
const baseClasses = [
|
||||
'relative w-full cursor-pointer',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none',
|
||||
{
|
||||
'opacity-50 cursor-not-allowed': disabled,
|
||||
}
|
||||
];
|
||||
|
||||
const triggerClasses = [
|
||||
'flex items-center justify-between w-full px-3 py-2',
|
||||
'bg-input-bg border border-input-border rounded-lg',
|
||||
'text-left transition-colors duration-200',
|
||||
'focus:border-input-border-focus focus:ring-1 focus:ring-input-border-focus',
|
||||
{
|
||||
'border-input-border-error focus:border-input-border-error focus:ring-input-border-error': hasError,
|
||||
'bg-bg-secondary border-transparent focus:bg-input-bg focus:border-input-border-focus': variant === 'filled',
|
||||
'bg-transparent border-none focus:ring-0': variant === 'unstyled',
|
||||
}
|
||||
];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 text-sm',
|
||||
md: 'h-10 text-base',
|
||||
lg: 'h-12 text-lg',
|
||||
};
|
||||
|
||||
const dropdownClasses = [
|
||||
'absolute z-50 w-full mt-1 bg-dropdown-bg border border-dropdown-border rounded-lg shadow-lg',
|
||||
'transform transition-all duration-200 ease-out',
|
||||
{
|
||||
'opacity-0 scale-95 pointer-events-none': !isOpen,
|
||||
'opacity-100 scale-100': isOpen,
|
||||
}
|
||||
];
|
||||
|
||||
const renderSelectedValue = () => {
|
||||
const selectedOptions = getSelectedOptions();
|
||||
|
||||
if (renderValue) {
|
||||
return renderValue(currentValue, selectedOptions);
|
||||
}
|
||||
|
||||
if (multiple && Array.isArray(currentValue)) {
|
||||
if (currentValue.length === 0) {
|
||||
return <span className="text-input-placeholder">{placeholder}</span>;
|
||||
}
|
||||
|
||||
if (currentValue.length === 1) {
|
||||
const option = selectedOptions[0];
|
||||
return option ? option.label : currentValue[0];
|
||||
}
|
||||
|
||||
return <span>{currentValue.length} elementos seleccionados</span>;
|
||||
}
|
||||
|
||||
const selectedOption = selectedOptions[0];
|
||||
if (selectedOption) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption.icon && <span>{selectedOption.icon}</span>}
|
||||
<span>{selectedOption.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-input-placeholder">{placeholder}</span>;
|
||||
};
|
||||
|
||||
const renderMultipleValues = () => {
|
||||
if (!multiple || !Array.isArray(currentValue) || currentValue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedOptions = getSelectedOptions();
|
||||
|
||||
if (selectedOptions.length <= 3) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedOptions.map(option => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-bg-tertiary text-text-primary rounded text-sm"
|
||||
>
|
||||
{option.icon && <span className="text-xs">{option.icon}</span>}
|
||||
<span>{option.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveOption(option.value, e)}
|
||||
className="text-text-tertiary hover:text-text-primary transition-colors duration-150"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderOptions = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-text-secondary">
|
||||
{loadingMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredOptions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-text-secondary">
|
||||
{noOptionsMessage}
|
||||
{createable && searchTerm.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="block w-full text-left px-3 py-2 text-color-primary hover:bg-dropdown-item-hover transition-colors duration-150"
|
||||
>
|
||||
{createLabel} "{searchTerm.trim()}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderOptionItem = (option: SelectOption, index: number) => {
|
||||
const isSelected = isOptionSelected(option);
|
||||
const isHighlighted = index === highlightedIndex;
|
||||
|
||||
if (renderOption) {
|
||||
return renderOption(option, isSelected);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={clsx(
|
||||
'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-150',
|
||||
{
|
||||
'bg-dropdown-item-hover': isHighlighted,
|
||||
'bg-color-primary/10 text-color-primary': isSelected && !multiple,
|
||||
'opacity-50 cursor-not-allowed': option.disabled,
|
||||
}
|
||||
)}
|
||||
onClick={() => handleOptionSelect(option)}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
{multiple && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
readOnly
|
||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
||||
/>
|
||||
)}
|
||||
{option.icon && <span>{option.icon}</span>}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{option.label}</div>
|
||||
{option.description && (
|
||||
<div className="text-xs text-text-secondary">{option.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && !multiple && (
|
||||
<svg className="w-4 h-4 text-color-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const allOptions: React.ReactNode[] = [];
|
||||
|
||||
// Add ungrouped options
|
||||
groupedOptions.ungrouped.forEach((option, index) => {
|
||||
allOptions.push(renderOptionItem(option, index));
|
||||
});
|
||||
|
||||
// Add grouped options
|
||||
Object.entries(groupedOptions.groups).forEach(([groupName, groupOptions]) => {
|
||||
allOptions.push(
|
||||
<div key={groupName} className="px-3 py-1 text-xs font-semibold text-text-tertiary uppercase tracking-wide">
|
||||
{groupName}
|
||||
</div>
|
||||
);
|
||||
|
||||
groupOptions.forEach((option, index) => {
|
||||
const globalIndex = groupedOptions.ungrouped.length + index;
|
||||
allOptions.push(renderOptionItem(option, globalIndex));
|
||||
});
|
||||
});
|
||||
|
||||
// Add create option if applicable
|
||||
if (createable && searchTerm.trim() && !filteredOptions.some(opt =>
|
||||
opt.label.toLowerCase() === searchTerm.toLowerCase() ||
|
||||
opt.value.toString().toLowerCase() === searchTerm.toLowerCase()
|
||||
)) {
|
||||
allOptions.push(
|
||||
<button
|
||||
key="__create__"
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="block w-full text-left px-3 py-2 text-color-primary hover:bg-dropdown-item-hover transition-colors duration-150 border-t border-border-primary"
|
||||
>
|
||||
{createLabel} "{searchTerm.trim()}"
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return allOptions;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-text-primary mb-2"
|
||||
>
|
||||
{label}
|
||||
{isRequired && (
|
||||
<span className="text-color-error ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={ref || containerRef}
|
||||
className={clsx(baseClasses, className)}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={clsx(triggerClasses, sizeClasses[size])}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{multiple && Array.isArray(currentValue) && currentValue.length > 0 && currentValue.length <= 3 ? (
|
||||
renderMultipleValues()
|
||||
) : (
|
||||
renderSelectedValue()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{clearable && currentValue && (multiple ? (Array.isArray(currentValue) && currentValue.length > 0) : true) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-text-tertiary hover:text-text-primary transition-colors duration-150 p-1"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<svg
|
||||
className={clsx('w-4 h-4 text-text-tertiary transition-transform duration-200', {
|
||||
'rotate-180': isOpen,
|
||||
})}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(dropdownClasses)} style={{ maxHeight: isOpen ? maxHeight : 0 }}>
|
||||
{searchable && (
|
||||
<div className="p-2 border-b border-border-primary">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Buscar..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full px-3 py-2 border border-input-border rounded bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={optionsRef}
|
||||
className="max-h-60 overflow-y-auto"
|
||||
style={{ maxHeight: searchable ? maxHeight - 60 : maxHeight }}
|
||||
>
|
||||
{renderOptions()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-color-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export default Select;
|
||||
3
frontend/src/components/ui/Select/index.ts
Normal file
3
frontend/src/components/ui/Select/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Select';
|
||||
export { default as Select } from './Select';
|
||||
export type { SelectProps, SelectOption } from './Select';
|
||||
171
frontend/src/components/ui/StatusIndicator/StatusIndicator.tsx
Normal file
171
frontend/src/components/ui/StatusIndicator/StatusIndicator.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface StatusIndicatorProps {
|
||||
status: 'success' | 'warning' | 'danger' | 'error' | 'info' | 'neutral';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
variant?: 'dot' | 'badge' | 'pill';
|
||||
label?: string;
|
||||
showLabel?: boolean;
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const StatusIndicator = forwardRef<HTMLDivElement, StatusIndicatorProps>(({
|
||||
status,
|
||||
size = 'md',
|
||||
variant = 'dot',
|
||||
label,
|
||||
showLabel = false,
|
||||
pulse = false,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
const statusClasses = {
|
||||
success: {
|
||||
bg: 'bg-[var(--color-success)]',
|
||||
text: 'text-[var(--color-success)]',
|
||||
border: 'border-[var(--color-success)]',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-[var(--color-warning)]',
|
||||
text: 'text-[var(--color-warning)]',
|
||||
border: 'border-[var(--color-warning)]',
|
||||
},
|
||||
danger: {
|
||||
bg: 'bg-[var(--color-error)]',
|
||||
text: 'text-[var(--color-error)]',
|
||||
border: 'border-[var(--color-error)]',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-[var(--color-error)]',
|
||||
text: 'text-[var(--color-error)]',
|
||||
border: 'border-[var(--color-error)]',
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-[var(--color-info)]',
|
||||
text: 'text-[var(--color-info)]',
|
||||
border: 'border-[var(--color-info)]',
|
||||
},
|
||||
neutral: {
|
||||
bg: 'bg-[var(--text-tertiary)]',
|
||||
text: 'text-[var(--text-tertiary)]',
|
||||
border: 'border-[var(--text-tertiary)]',
|
||||
},
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
xs: {
|
||||
dot: 'w-2 h-2',
|
||||
badge: 'w-4 h-4',
|
||||
text: 'text-xs',
|
||||
},
|
||||
sm: {
|
||||
dot: 'w-3 h-3',
|
||||
badge: 'w-5 h-5',
|
||||
text: 'text-sm',
|
||||
},
|
||||
md: {
|
||||
dot: 'w-4 h-4',
|
||||
badge: 'w-6 h-6',
|
||||
text: 'text-base',
|
||||
},
|
||||
lg: {
|
||||
dot: 'w-5 h-5',
|
||||
badge: 'w-8 h-8',
|
||||
text: 'text-lg',
|
||||
},
|
||||
};
|
||||
|
||||
const renderDot = () => (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full',
|
||||
sizeClasses[size].dot,
|
||||
statusClasses[status].bg,
|
||||
{
|
||||
'animate-pulse': pulse,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderBadge = () => (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full border-2 flex items-center justify-center',
|
||||
sizeClasses[size].badge,
|
||||
statusClasses[status].border,
|
||||
'bg-white',
|
||||
{
|
||||
'animate-pulse': pulse,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full',
|
||||
`w-${parseInt(sizeClasses[size].badge.split(' ')[0].substring(2)) - 2}`,
|
||||
`h-${parseInt(sizeClasses[size].badge.split(' ')[1].substring(2)) - 2}`,
|
||||
statusClasses[status].bg
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPill = () => (
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-full px-2 py-1 text-white text-xs font-medium',
|
||||
statusClasses[status].bg,
|
||||
{
|
||||
'animate-pulse': pulse,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{label || status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderIndicator = () => {
|
||||
switch (variant) {
|
||||
case 'badge':
|
||||
return renderBadge();
|
||||
case 'pill':
|
||||
return renderPill();
|
||||
default:
|
||||
return renderDot();
|
||||
}
|
||||
};
|
||||
|
||||
if (showLabel && variant !== 'pill') {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{renderIndicator()}
|
||||
<span
|
||||
className={clsx(
|
||||
'font-medium',
|
||||
sizeClasses[size].text,
|
||||
statusClasses[status].text
|
||||
)}
|
||||
>
|
||||
{label || status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx(className)} {...props}>
|
||||
{renderIndicator()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StatusIndicator.displayName = 'StatusIndicator';
|
||||
|
||||
export default StatusIndicator;
|
||||
2
frontend/src/components/ui/StatusIndicator/index.ts
Normal file
2
frontend/src/components/ui/StatusIndicator/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as StatusIndicator } from './StatusIndicator';
|
||||
export type { StatusIndicatorProps } from './StatusIndicator';
|
||||
686
frontend/src/components/ui/Table/Table.tsx
Normal file
686
frontend/src/components/ui/Table/Table.tsx
Normal file
@@ -0,0 +1,686 @@
|
||||
import React, { forwardRef, useMemo, useState, useEffect, TableHTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface TableColumn<T = any> {
|
||||
key: string;
|
||||
title: string;
|
||||
dataIndex?: keyof T;
|
||||
render?: (value: any, record: T, index: number) => React.ReactNode;
|
||||
width?: string | number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
sortable?: boolean;
|
||||
sortKey?: string;
|
||||
className?: string;
|
||||
headerClassName?: string;
|
||||
fixed?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface TableProps<T = any> extends Omit<TableHTMLAttributes<HTMLTableElement>, 'onSelect'> {
|
||||
columns: TableColumn<T>[];
|
||||
data: T[];
|
||||
loading?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'striped' | 'bordered' | 'borderless';
|
||||
hover?: boolean;
|
||||
sticky?: boolean;
|
||||
rowSelection?: {
|
||||
type?: 'checkbox' | 'radio';
|
||||
selectedRowKeys?: React.Key[];
|
||||
onSelect?: (record: T, selected: boolean, selectedRows: T[], nativeEvent: Event) => void;
|
||||
onSelectAll?: (selected: boolean, selectedRows: T[], changeRows: T[]) => void;
|
||||
onSelectInvert?: (selectedRowKeys: React.Key[]) => void;
|
||||
getCheckboxProps?: (record: T) => { disabled?: boolean; name?: string };
|
||||
hideSelectAll?: boolean;
|
||||
preserveSelectedRowKeys?: boolean;
|
||||
};
|
||||
pagination?: {
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
total?: number;
|
||||
showSizeChanger?: boolean;
|
||||
showQuickJumper?: boolean;
|
||||
showTotal?: (total: number, range: [number, number]) => string;
|
||||
onChange?: (page: number, pageSize: number) => void;
|
||||
position?: 'top' | 'bottom' | 'both';
|
||||
};
|
||||
sort?: {
|
||||
field?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
multiple?: boolean;
|
||||
};
|
||||
onSort?: (field: string, order: 'asc' | 'desc' | null) => void;
|
||||
expandable?: {
|
||||
expandedRowKeys?: React.Key[];
|
||||
onExpand?: (expanded: boolean, record: T) => void;
|
||||
onExpandedRowsChange?: (expandedKeys: React.Key[]) => void;
|
||||
expandedRowRender?: (record: T, index: number) => React.ReactNode;
|
||||
rowExpandable?: (record: T) => boolean;
|
||||
expandRowByClick?: boolean;
|
||||
defaultExpandAllRows?: boolean;
|
||||
indentSize?: number;
|
||||
};
|
||||
rowKey?: string | ((record: T) => React.Key);
|
||||
onRow?: (record: T, index: number) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
locale?: {
|
||||
emptyText?: string;
|
||||
selectAll?: string;
|
||||
selectRow?: string;
|
||||
expand?: string;
|
||||
collapse?: string;
|
||||
};
|
||||
scroll?: {
|
||||
x?: string | number | true;
|
||||
y?: string | number;
|
||||
};
|
||||
summary?: (data: T[]) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TablePaginationProps {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
showSizeChanger?: boolean;
|
||||
showQuickJumper?: boolean;
|
||||
showTotal?: (total: number, range: [number, number]) => string;
|
||||
onChange?: (page: number, pageSize: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Table = forwardRef<HTMLTableElement, TableProps>(({
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
hover = true,
|
||||
sticky = false,
|
||||
rowSelection,
|
||||
pagination,
|
||||
sort,
|
||||
onSort,
|
||||
expandable,
|
||||
rowKey = 'id',
|
||||
onRow,
|
||||
locale = {
|
||||
emptyText: 'No hay datos disponibles',
|
||||
selectAll: 'Seleccionar todo',
|
||||
selectRow: 'Seleccionar fila',
|
||||
expand: 'Expandir fila',
|
||||
collapse: 'Contraer fila',
|
||||
},
|
||||
scroll,
|
||||
summary,
|
||||
className,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [sortState, setSortState] = useState<{ field?: string; order?: 'asc' | 'desc' }>({
|
||||
field: sort?.field,
|
||||
order: sort?.order,
|
||||
});
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>(
|
||||
rowSelection?.selectedRowKeys || []
|
||||
);
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>(
|
||||
expandable?.expandedRowKeys || (expandable?.defaultExpandAllRows ? data.map(getRowKey) : [])
|
||||
);
|
||||
|
||||
function getRowKey(record: any, index?: number): React.Key {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(record);
|
||||
}
|
||||
return record[rowKey] || index || 0;
|
||||
}
|
||||
|
||||
// Update local state when props change
|
||||
useEffect(() => {
|
||||
if (rowSelection?.selectedRowKeys) {
|
||||
setSelectedRowKeys(rowSelection.selectedRowKeys);
|
||||
}
|
||||
}, [rowSelection?.selectedRowKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expandable?.expandedRowKeys) {
|
||||
setExpandedRowKeys(expandable.expandedRowKeys);
|
||||
}
|
||||
}, [expandable?.expandedRowKeys]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (field: string) => {
|
||||
let newOrder: 'asc' | 'desc' | null = 'asc';
|
||||
|
||||
if (sortState.field === field) {
|
||||
newOrder = sortState.order === 'asc' ? 'desc' : sortState.order === 'desc' ? null : 'asc';
|
||||
}
|
||||
|
||||
setSortState({ field: newOrder ? field : undefined, order: newOrder || undefined });
|
||||
onSort?.(field, newOrder);
|
||||
};
|
||||
|
||||
// Handle row selection
|
||||
const handleSelectRow = (record: any, selected: boolean, event: Event) => {
|
||||
const key = getRowKey(record);
|
||||
let newSelectedKeys = [...selectedRowKeys];
|
||||
|
||||
if (rowSelection?.type === 'radio') {
|
||||
newSelectedKeys = selected ? [key] : [];
|
||||
} else {
|
||||
if (selected) {
|
||||
newSelectedKeys.push(key);
|
||||
} else {
|
||||
newSelectedKeys = newSelectedKeys.filter(k => k !== key);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedRowKeys(newSelectedKeys);
|
||||
const selectedRows = data.filter(item => newSelectedKeys.includes(getRowKey(item)));
|
||||
rowSelection?.onSelect?.(record, selected, selectedRows, event);
|
||||
};
|
||||
|
||||
const handleSelectAll = (selected: boolean) => {
|
||||
const selectableData = data.filter(record => {
|
||||
const checkboxProps = rowSelection?.getCheckboxProps?.(record);
|
||||
return !checkboxProps?.disabled;
|
||||
});
|
||||
|
||||
const newSelectedKeys = selected ? selectableData.map(getRowKey) : [];
|
||||
const changeRows = selected ? selectableData : selectedRowKeys.map(key => data.find(item => getRowKey(item) === key)).filter(Boolean);
|
||||
|
||||
setSelectedRowKeys(newSelectedKeys);
|
||||
const selectedRows = data.filter(item => newSelectedKeys.includes(getRowKey(item)));
|
||||
rowSelection?.onSelectAll?.(selected, selectedRows, changeRows);
|
||||
};
|
||||
|
||||
// Handle row expansion
|
||||
const handleExpand = (record: any, expanded: boolean) => {
|
||||
const key = getRowKey(record);
|
||||
let newExpandedKeys = [...expandedRowKeys];
|
||||
|
||||
if (expanded) {
|
||||
newExpandedKeys.push(key);
|
||||
} else {
|
||||
newExpandedKeys = newExpandedKeys.filter(k => k !== key);
|
||||
}
|
||||
|
||||
setExpandedRowKeys(newExpandedKeys);
|
||||
expandable?.onExpand?.(expanded, record);
|
||||
expandable?.onExpandedRowsChange?.(newExpandedKeys);
|
||||
};
|
||||
|
||||
// Processed data for current page
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// Apply pagination if needed
|
||||
if (pagination) {
|
||||
const { current = 1, pageSize = 10 } = pagination;
|
||||
const start = (current - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
result = result.slice(start, end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, pagination]);
|
||||
|
||||
// Table classes
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: 'border-collapse border border-table-border',
|
||||
striped: 'border-collapse',
|
||||
bordered: 'border-collapse border border-table-border',
|
||||
borderless: 'border-collapse',
|
||||
};
|
||||
|
||||
const tableClasses = clsx(
|
||||
'w-full bg-table-bg',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
{
|
||||
'table-fixed': scroll?.x,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const containerClasses = clsx(
|
||||
'overflow-auto',
|
||||
{
|
||||
'max-h-96': scroll?.y,
|
||||
}
|
||||
);
|
||||
|
||||
// Render loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-color-primary"></div>
|
||||
<span className="text-text-secondary">Cargando...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render empty state
|
||||
if (processedData.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-text-tertiary mb-2">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-text-secondary">{locale.emptyText}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderHeader = () => {
|
||||
const hasSelection = rowSelection && !rowSelection.hideSelectAll;
|
||||
const hasExpansion = expandable;
|
||||
|
||||
return (
|
||||
<thead className={clsx('bg-table-header-bg', { 'sticky top-0 z-10': sticky })}>
|
||||
<tr>
|
||||
{hasSelection && (
|
||||
<th className="px-4 py-3 text-left font-medium text-text-primary border-b border-table-border w-12">
|
||||
{rowSelection.type !== 'radio' && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-input-border focus:ring-color-primary"
|
||||
checked={selectedRowKeys.length === data.length && data.length > 0}
|
||||
indeterminate={selectedRowKeys.length > 0 && selectedRowKeys.length < data.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
aria-label={locale.selectAll}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
)}
|
||||
{hasExpansion && (
|
||||
<th className="px-4 py-3 text-left font-medium text-text-primary border-b border-table-border w-12"></th>
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const isSorted = sortState.field === (column.sortKey || column.key);
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
'px-4 py-3 font-medium text-text-primary border-b border-table-border',
|
||||
{
|
||||
'text-left': column.align === 'left' || !column.align,
|
||||
'text-center': column.align === 'center',
|
||||
'text-right': column.align === 'right',
|
||||
'cursor-pointer hover:bg-table-row-hover': column.sortable,
|
||||
},
|
||||
column.headerClassName
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
onClick={column.sortable ? () => handleSort(column.sortKey || column.key) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{column.title}</span>
|
||||
{column.sortable && (
|
||||
<span className="flex flex-col">
|
||||
<svg
|
||||
className={clsx('w-3 h-3', {
|
||||
'text-color-primary': isSorted && sortState.order === 'asc',
|
||||
'text-text-quaternary': !isSorted || sortState.order !== 'asc',
|
||||
})}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" />
|
||||
</svg>
|
||||
<svg
|
||||
className={clsx('w-3 h-3 -mt-1', {
|
||||
'text-color-primary': isSorted && sortState.order === 'desc',
|
||||
'text-text-quaternary': !isSorted || sortState.order !== 'desc',
|
||||
})}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => (
|
||||
<tbody>
|
||||
{processedData.map((record, index) => {
|
||||
const key = getRowKey(record, index);
|
||||
const isSelected = selectedRowKeys.includes(key);
|
||||
const isExpanded = expandedRowKeys.includes(key);
|
||||
const canExpand = expandable?.rowExpandable?.(record) !== false;
|
||||
const rowProps = onRow?.(record, index) || {};
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
<tr
|
||||
{...rowProps}
|
||||
className={clsx(
|
||||
'transition-colors duration-150',
|
||||
{
|
||||
'bg-table-row-hover': hover && !isSelected,
|
||||
'bg-table-row-selected': isSelected,
|
||||
'odd:bg-bg-secondary': variant === 'striped' && !isSelected,
|
||||
'border-b border-table-border': variant !== 'borderless',
|
||||
'cursor-pointer': expandable?.expandRowByClick,
|
||||
},
|
||||
rowProps.className
|
||||
)}
|
||||
onClick={expandable?.expandRowByClick ? () => handleExpand(record, !isExpanded) : rowProps.onClick}
|
||||
>
|
||||
{rowSelection && (
|
||||
<td className="px-4 py-3 border-b border-table-border">
|
||||
<input
|
||||
type={rowSelection.type || 'checkbox'}
|
||||
className="rounded border-input-border focus:ring-color-primary"
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleSelectRow(record, e.target.checked, e.nativeEvent)}
|
||||
disabled={rowSelection.getCheckboxProps?.(record)?.disabled}
|
||||
name={rowSelection.getCheckboxProps?.(record)?.name}
|
||||
aria-label={`${locale.selectRow} ${index + 1}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{expandable && (
|
||||
<td className="px-4 py-3 border-b border-table-border">
|
||||
{canExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-tertiary hover:text-text-primary transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-color-primary/20 rounded p-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExpand(record, !isExpanded);
|
||||
}}
|
||||
aria-label={isExpanded ? locale.collapse : locale.expand}
|
||||
>
|
||||
<svg
|
||||
className={clsx('w-4 h-4 transform transition-transform duration-150', {
|
||||
'rotate-90': isExpanded,
|
||||
})}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const value = column.dataIndex ? record[column.dataIndex] : record;
|
||||
const content = column.render ? column.render(value, record, index) : value;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.key}
|
||||
className={clsx(
|
||||
'px-4 py-3 border-b border-table-border',
|
||||
{
|
||||
'text-left': column.align === 'left' || !column.align,
|
||||
'text-center': column.align === 'center',
|
||||
'text-right': column.align === 'right',
|
||||
},
|
||||
column.className
|
||||
)}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{isExpanded && expandable?.expandedRowRender && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (rowSelection ? 1 : 0) + (expandable ? 1 : 0)}
|
||||
className="px-4 py-0 border-b border-table-border bg-bg-secondary"
|
||||
>
|
||||
<div className="py-4" style={{ paddingLeft: expandable.indentSize || 24 }}>
|
||||
{expandable.expandedRowRender(record, index)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{summary && (
|
||||
<tr className="bg-table-header-bg border-t-2 border-table-border font-medium">
|
||||
<td colSpan={columns.length + (rowSelection ? 1 : 0) + (expandable ? 1 : 0)}>
|
||||
{summary(data)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className={containerClasses}>
|
||||
<table
|
||||
ref={ref}
|
||||
className={tableClasses}
|
||||
{...props}
|
||||
>
|
||||
{renderHeader()}
|
||||
{renderBody()}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<TablePagination
|
||||
current={pagination.current || 1}
|
||||
pageSize={pagination.pageSize || 10}
|
||||
total={pagination.total || data.length}
|
||||
showSizeChanger={pagination.showSizeChanger}
|
||||
showQuickJumper={pagination.showQuickJumper}
|
||||
showTotal={pagination.showTotal}
|
||||
onChange={pagination.onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TablePagination: React.FC<TablePaginationProps> = ({
|
||||
current,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger = false,
|
||||
showQuickJumper = false,
|
||||
showTotal,
|
||||
onChange,
|
||||
className,
|
||||
}) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const startRecord = (current - 1) * pageSize + 1;
|
||||
const endRecord = Math.min(current * pageSize, total);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages && page !== current) {
|
||||
onChange?.(page, pageSize);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSizeChange = (newSize: number) => {
|
||||
const newTotalPages = Math.ceil(total / newSize);
|
||||
const newPage = current > newTotalPages ? newTotalPages : current;
|
||||
onChange?.(newPage || 1, newSize);
|
||||
};
|
||||
|
||||
const renderPageButtons = () => {
|
||||
const buttons = [];
|
||||
const maxVisible = 7;
|
||||
let startPage = Math.max(1, current - Math.floor(maxVisible / 2));
|
||||
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
||||
|
||||
if (endPage - startPage + 1 < maxVisible) {
|
||||
startPage = Math.max(1, endPage - maxVisible + 1);
|
||||
}
|
||||
|
||||
// Previous button
|
||||
buttons.push(
|
||||
<button
|
||||
key="prev"
|
||||
type="button"
|
||||
className="px-3 py-2 text-sm font-medium text-text-secondary border border-border-primary rounded-l-md hover:bg-bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150"
|
||||
disabled={current === 1}
|
||||
onClick={() => handlePageChange(current - 1)}
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
);
|
||||
|
||||
// First page and ellipsis
|
||||
if (startPage > 1) {
|
||||
buttons.push(
|
||||
<button
|
||||
key={1}
|
||||
type="button"
|
||||
className="px-3 py-2 text-sm font-medium text-text-secondary border-t border-b border-border-primary hover:bg-bg-secondary transition-colors duration-150"
|
||||
onClick={() => handlePageChange(1)}
|
||||
>
|
||||
1
|
||||
</button>
|
||||
);
|
||||
|
||||
if (startPage > 2) {
|
||||
buttons.push(
|
||||
<span key="start-ellipsis" className="px-3 py-2 text-sm text-text-tertiary border-t border-b border-border-primary">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Page number buttons
|
||||
for (let page = startPage; page <= endPage; page++) {
|
||||
buttons.push(
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
className={clsx(
|
||||
'px-3 py-2 text-sm font-medium border-t border-b border-border-primary transition-colors duration-150',
|
||||
{
|
||||
'bg-color-primary text-text-inverse': page === current,
|
||||
'text-text-secondary hover:bg-bg-secondary': page !== current,
|
||||
}
|
||||
)}
|
||||
onClick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Last page and ellipsis
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
buttons.push(
|
||||
<span key="end-ellipsis" className="px-3 py-2 text-sm text-text-tertiary border-t border-b border-border-primary">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
buttons.push(
|
||||
<button
|
||||
key={totalPages}
|
||||
type="button"
|
||||
className="px-3 py-2 text-sm font-medium text-text-secondary border-t border-b border-border-primary hover:bg-bg-secondary transition-colors duration-150"
|
||||
onClick={() => handlePageChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons.push(
|
||||
<button
|
||||
key="next"
|
||||
type="button"
|
||||
className="px-3 py-2 text-sm font-medium text-text-secondary border border-border-primary rounded-r-md hover:bg-bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-150"
|
||||
disabled={current === totalPages}
|
||||
onClick={() => handlePageChange(current + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
);
|
||||
|
||||
return buttons;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx('flex items-center justify-between', className)}>
|
||||
<div className="flex items-center gap-4">
|
||||
{showTotal && (
|
||||
<span className="text-sm text-text-secondary">
|
||||
{showTotal(total, [startRecord, endRecord])}
|
||||
</span>
|
||||
)}
|
||||
{showSizeChanger && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-secondary">Mostrar</span>
|
||||
<select
|
||||
className="px-2 py-1 text-sm border border-border-primary rounded-md bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||||
value={pageSize}
|
||||
onChange={(e) => handleSizeChange(Number(e.target.value))}
|
||||
>
|
||||
{[10, 20, 50, 100].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm text-text-secondary">por página</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{showQuickJumper && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-text-secondary">Ir a</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
className="w-16 px-2 py-1 text-sm text-center border border-border-primary rounded-md bg-input-bg focus:outline-none focus:ring-2 focus:ring-color-primary/20"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const page = Number((e.target as HTMLInputElement).value);
|
||||
handlePageChange(page);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex">
|
||||
{renderPageButtons()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Table.displayName = 'Table';
|
||||
|
||||
export default Table;
|
||||
export { TablePagination };
|
||||
3
frontend/src/components/ui/Table/index.ts
Normal file
3
frontend/src/components/ui/Table/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Table';
|
||||
export { default as Table, TablePagination } from './Table';
|
||||
export type { TableProps, TableColumn, TablePaginationProps } from './Table';
|
||||
230
frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx
Normal file
230
frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { Button } from '../Button';
|
||||
import { Sun, Moon, Computer } from 'lucide-react';
|
||||
|
||||
export interface ThemeToggleProps {
|
||||
className?: string;
|
||||
/**
|
||||
* Toggle style variant
|
||||
*/
|
||||
variant?: 'button' | 'dropdown' | 'switch';
|
||||
/**
|
||||
* Size of the toggle
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/**
|
||||
* Show labels alongside icons
|
||||
*/
|
||||
showLabels?: boolean;
|
||||
/**
|
||||
* Position of dropdown (when variant='dropdown')
|
||||
*/
|
||||
dropdownPosition?: 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
/**
|
||||
* ThemeToggle - Reusable theme switching component
|
||||
*
|
||||
* Features:
|
||||
* - Multiple display variants (button, dropdown, switch)
|
||||
* - Support for light/dark/system themes
|
||||
* - Configurable size and labels
|
||||
* - Accessible keyboard navigation
|
||||
* - Click outside to close dropdown
|
||||
*/
|
||||
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
||||
className,
|
||||
variant = 'button',
|
||||
size = 'md',
|
||||
showLabels = false,
|
||||
dropdownPosition = 'right',
|
||||
}) => {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const themes = [
|
||||
{ key: 'light' as const, label: 'Claro', icon: Sun },
|
||||
{ key: 'dark' as const, label: 'Oscuro', icon: Moon },
|
||||
{ key: 'auto' as const, label: 'Sistema', icon: Computer },
|
||||
];
|
||||
|
||||
const currentTheme = themes.find(t => t.key === theme) || themes[0];
|
||||
const CurrentIcon = currentTheme.icon;
|
||||
|
||||
// Size mappings
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isDropdownOpen) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
React.useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('[data-theme-toggle]')) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}, [isDropdownOpen]);
|
||||
|
||||
// Cycle through themes for button variant
|
||||
const handleButtonToggle = () => {
|
||||
const currentIndex = themes.findIndex(t => t.key === theme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex].key);
|
||||
};
|
||||
|
||||
// Handle theme selection
|
||||
const handleThemeSelect = (themeKey: 'light' | 'dark' | 'auto') => {
|
||||
setTheme(themeKey);
|
||||
setIsDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Button variant - cycles through themes
|
||||
if (variant === 'button') {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleButtonToggle}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 p-2',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
aria-label={`Cambiar tema - Actual: ${currentTheme.label}`}
|
||||
data-theme-toggle
|
||||
>
|
||||
<CurrentIcon className={iconSizes[size]} />
|
||||
{showLabels && (
|
||||
<span className="hidden sm:inline">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Switch variant - simple toggle between light/dark
|
||||
if (variant === 'switch') {
|
||||
const isDark = resolvedTheme === 'dark';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||
className={clsx(
|
||||
'relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20',
|
||||
'bg-[var(--bg-tertiary)] hover:bg-[var(--bg-quaternary)]',
|
||||
size === 'sm' ? 'h-6 w-11' : size === 'lg' ? 'h-8 w-14' : 'h-7 w-12',
|
||||
className
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={isDark}
|
||||
aria-label="Alternar tema oscuro"
|
||||
data-theme-toggle
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block rounded-full bg-white shadow-sm transform transition-transform duration-200 flex items-center justify-center',
|
||||
size === 'sm' ? 'h-5 w-5' : size === 'lg' ? 'h-7 w-7' : 'h-6 w-6',
|
||||
isDark ? 'translate-x-5' : 'translate-x-0.5'
|
||||
)}
|
||||
>
|
||||
{isDark ? (
|
||||
<Moon className={clsx(iconSizes[size], 'text-[var(--color-primary)]')} />
|
||||
) : (
|
||||
<Sun className={clsx(iconSizes[size], 'text-[var(--color-primary)]')} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown variant - shows all theme options
|
||||
return (
|
||||
<div className="relative" data-theme-toggle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 p-2',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
aria-label={`Seleccionar tema - Actual: ${currentTheme.label}`}
|
||||
aria-expanded={isDropdownOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<CurrentIcon className={iconSizes[size]} />
|
||||
{showLabels && (
|
||||
<span className="hidden sm:inline">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-full mt-2 w-48 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-1 z-[var(--z-dropdown)]',
|
||||
dropdownPosition === 'left' && 'left-0',
|
||||
dropdownPosition === 'right' && 'right-0',
|
||||
dropdownPosition === 'center' && 'left-1/2 transform -translate-x-1/2'
|
||||
)}
|
||||
role="menu"
|
||||
aria-labelledby="theme-menu"
|
||||
>
|
||||
{themes.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleThemeSelect(key)}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
|
||||
'hover:bg-[var(--bg-secondary)] transition-colors',
|
||||
'focus:bg-[var(--bg-secondary)] focus:outline-none',
|
||||
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-label={`Cambiar a tema ${label.toLowerCase()}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
{theme === key && (
|
||||
<div className="ml-auto w-2 h-2 bg-[var(--color-primary)] rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
1
frontend/src/components/ui/ThemeToggle/index.ts
Normal file
1
frontend/src/components/ui/ThemeToggle/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ThemeToggle, type ThemeToggleProps } from './ThemeToggle';
|
||||
391
frontend/src/components/ui/Tooltip/Tooltip.tsx
Normal file
391
frontend/src/components/ui/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import React, { forwardRef, useState, useRef, useEffect, cloneElement, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface TooltipProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
|
||||
content: React.ReactNode;
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end';
|
||||
trigger?: 'hover' | 'click' | 'focus' | 'manual';
|
||||
delay?: number;
|
||||
hideDelay?: number;
|
||||
disabled?: boolean;
|
||||
arrow?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'dark' | 'light' | 'error' | 'warning' | 'success' | 'info';
|
||||
maxWidth?: string | number;
|
||||
offset?: number;
|
||||
zIndex?: number;
|
||||
portalId?: string;
|
||||
followCursor?: boolean;
|
||||
interactive?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(({
|
||||
content,
|
||||
placement = 'top',
|
||||
trigger = 'hover',
|
||||
delay = 100,
|
||||
hideDelay = 100,
|
||||
disabled = false,
|
||||
arrow = true,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
maxWidth = 320,
|
||||
offset = 8,
|
||||
zIndex = 1000,
|
||||
followCursor = false,
|
||||
interactive = false,
|
||||
onVisibleChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [actualPlacement, setActualPlacement] = useState(placement);
|
||||
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimer = useRef<NodeJS.Timeout>();
|
||||
const hideTimer = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Clear timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (showTimer.current) clearTimeout(showTimer.current);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate tooltip position
|
||||
const calculatePosition = (cursorX?: number, cursorY?: number) => {
|
||||
if (!triggerRef.current || !tooltipRef.current) return;
|
||||
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let finalPlacement = placement;
|
||||
|
||||
// Use cursor position if followCursor is enabled
|
||||
if (followCursor && cursorX !== undefined && cursorY !== undefined) {
|
||||
top = cursorY + offset;
|
||||
left = cursorX + offset;
|
||||
} else {
|
||||
// Calculate position based on placement
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
case 'top-start':
|
||||
case 'top-end':
|
||||
top = triggerRect.top - tooltipRect.height - offset;
|
||||
left = placement === 'top-start'
|
||||
? triggerRect.left
|
||||
: placement === 'top-end'
|
||||
? triggerRect.right - tooltipRect.width
|
||||
: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
case 'bottom-start':
|
||||
case 'bottom-end':
|
||||
top = triggerRect.bottom + offset;
|
||||
left = placement === 'bottom-start'
|
||||
? triggerRect.left
|
||||
: placement === 'bottom-end'
|
||||
? triggerRect.right - tooltipRect.width
|
||||
: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
case 'left':
|
||||
case 'left-start':
|
||||
case 'left-end':
|
||||
top = placement === 'left-start'
|
||||
? triggerRect.top
|
||||
: placement === 'left-end'
|
||||
? triggerRect.bottom - tooltipRect.height
|
||||
: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||
left = triggerRect.left - tooltipRect.width - offset;
|
||||
break;
|
||||
case 'right':
|
||||
case 'right-start':
|
||||
case 'right-end':
|
||||
top = placement === 'right-start'
|
||||
? triggerRect.top
|
||||
: placement === 'right-end'
|
||||
? triggerRect.bottom - tooltipRect.height
|
||||
: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||
left = triggerRect.right + offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flip if tooltip would go outside viewport
|
||||
const willOverflowTop = top < 0;
|
||||
const willOverflowBottom = top + tooltipRect.height > viewport.height;
|
||||
const willOverflowLeft = left < 0;
|
||||
const willOverflowRight = left + tooltipRect.width > viewport.width;
|
||||
|
||||
// Adjust for viewport constraints
|
||||
if (willOverflowTop && (placement.startsWith('top') || placement.includes('left') || placement.includes('right'))) {
|
||||
if (placement.startsWith('top')) {
|
||||
finalPlacement = placement.replace('top', 'bottom') as typeof placement;
|
||||
top = triggerRect.bottom + offset;
|
||||
} else {
|
||||
top = Math.max(8, top);
|
||||
}
|
||||
}
|
||||
|
||||
if (willOverflowBottom && placement.startsWith('bottom')) {
|
||||
finalPlacement = placement.replace('bottom', 'top') as typeof placement;
|
||||
top = triggerRect.top - tooltipRect.height - offset;
|
||||
}
|
||||
|
||||
if (willOverflowLeft) {
|
||||
if (placement.startsWith('left')) {
|
||||
finalPlacement = placement.replace('left', 'right') as typeof placement;
|
||||
left = triggerRect.right + offset;
|
||||
} else {
|
||||
left = Math.max(8, left);
|
||||
}
|
||||
}
|
||||
|
||||
if (willOverflowRight) {
|
||||
if (placement.startsWith('right')) {
|
||||
finalPlacement = placement.replace('right', 'left') as typeof placement;
|
||||
left = triggerRect.left - tooltipRect.width - offset;
|
||||
} else {
|
||||
left = Math.min(viewport.width - tooltipRect.width - 8, left);
|
||||
}
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
setActualPlacement(finalPlacement);
|
||||
};
|
||||
|
||||
const showTooltip = (cursorX?: number, cursorY?: number) => {
|
||||
if (disabled || !content) return;
|
||||
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = undefined;
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
showTimer.current = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
onVisibleChange?.(true);
|
||||
// Calculate position after showing to get accurate measurements
|
||||
setTimeout(() => calculatePosition(cursorX, cursorY), 0);
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (showTimer.current) {
|
||||
clearTimeout(showTimer.current);
|
||||
showTimer.current = undefined;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onVisibleChange?.(false);
|
||||
}, hideDelay);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
if (trigger === 'hover') {
|
||||
showTooltip(followCursor ? e.clientX : undefined, followCursor ? e.clientY : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (trigger === 'hover') {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (trigger === 'hover' && followCursor && isVisible) {
|
||||
calculatePosition(e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (trigger === 'click') {
|
||||
if (isVisible) {
|
||||
hideTooltip();
|
||||
} else {
|
||||
showTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (trigger === 'focus') {
|
||||
showTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (trigger === 'focus') {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTooltipMouseEnter = () => {
|
||||
if (interactive && trigger === 'hover') {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTooltipMouseLeave = () => {
|
||||
if (interactive && trigger === 'hover') {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// Recalculate position on window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (isVisible) {
|
||||
calculatePosition();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [isVisible]);
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-2 text-sm',
|
||||
lg: 'px-4 py-3 text-base',
|
||||
};
|
||||
|
||||
// Variant classes
|
||||
const variantClasses = {
|
||||
default: 'bg-text-primary text-text-inverse',
|
||||
dark: 'bg-gray-900 text-white',
|
||||
light: 'bg-white text-text-primary border border-border-primary shadow-lg',
|
||||
error: 'bg-color-error text-text-inverse',
|
||||
warning: 'bg-color-warning text-text-inverse',
|
||||
success: 'bg-color-success text-text-inverse',
|
||||
info: 'bg-color-info text-text-inverse',
|
||||
};
|
||||
|
||||
// Arrow classes based on placement
|
||||
const getArrowClasses = () => {
|
||||
const baseArrow = 'absolute w-2 h-2 rotate-45';
|
||||
const arrowColor = variant === 'light'
|
||||
? 'bg-white border border-border-primary'
|
||||
: variantClasses[variant].split(' ')[0];
|
||||
|
||||
switch (actualPlacement.split('-')[0]) {
|
||||
case 'top':
|
||||
return `${baseArrow} ${arrowColor} -bottom-1 left-1/2 -translate-x-1/2`;
|
||||
case 'bottom':
|
||||
return `${baseArrow} ${arrowColor} -top-1 left-1/2 -translate-x-1/2`;
|
||||
case 'left':
|
||||
return `${baseArrow} ${arrowColor} -right-1 top-1/2 -translate-y-1/2`;
|
||||
case 'right':
|
||||
return `${baseArrow} ${arrowColor} -left-1 top-1/2 -translate-y-1/2`;
|
||||
default:
|
||||
return `${baseArrow} ${arrowColor} -bottom-1 left-1/2 -translate-x-1/2`;
|
||||
}
|
||||
};
|
||||
|
||||
const tooltipClasses = clsx(
|
||||
'absolute rounded-lg font-medium leading-tight',
|
||||
'transition-all duration-200 ease-out',
|
||||
'pointer-events-auto',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
{
|
||||
'opacity-0 scale-95 pointer-events-none': !isVisible,
|
||||
'opacity-100 scale-100': isVisible,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
zIndex,
|
||||
...props.style,
|
||||
};
|
||||
|
||||
// Clone the trigger element and add event handlers
|
||||
const triggerElement = React.isValidElement(children)
|
||||
? cloneElement(children as React.ReactElement, {
|
||||
ref: (node: HTMLElement) => {
|
||||
triggerRef.current = node;
|
||||
// Preserve original ref if it exists
|
||||
const originalRef = (children as any).ref;
|
||||
if (typeof originalRef === 'function') {
|
||||
originalRef(node);
|
||||
} else if (originalRef) {
|
||||
originalRef.current = node;
|
||||
}
|
||||
},
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
handleMouseEnter(e);
|
||||
// Call original handler if it exists
|
||||
(children as any).props?.onMouseEnter?.(e);
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
handleMouseLeave();
|
||||
(children as any).props?.onMouseLeave?.(e);
|
||||
},
|
||||
onMouseMove: (e: React.MouseEvent) => {
|
||||
handleMouseMove(e);
|
||||
(children as any).props?.onMouseMove?.(e);
|
||||
},
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
handleClick();
|
||||
(children as any).props?.onClick?.(e);
|
||||
},
|
||||
onFocus: (e: React.FocusEvent) => {
|
||||
handleFocus();
|
||||
(children as any).props?.onFocus?.(e);
|
||||
},
|
||||
onBlur: (e: React.FocusEvent) => {
|
||||
handleBlur();
|
||||
(children as any).props?.onBlur?.(e);
|
||||
},
|
||||
})
|
||||
: children;
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
{content && (
|
||||
<div
|
||||
ref={ref || tooltipRef}
|
||||
className={tooltipClasses}
|
||||
style={tooltipStyle}
|
||||
role="tooltip"
|
||||
onMouseEnter={handleTooltipMouseEnter}
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
{arrow && <div className={getArrowClasses()} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Tooltip.displayName = 'Tooltip';
|
||||
|
||||
export default Tooltip;
|
||||
391
frontend/src/components/ui/Tooltip/Tooltip.tsx.backup
Normal file
391
frontend/src/components/ui/Tooltip/Tooltip.tsx.backup
Normal file
@@ -0,0 +1,391 @@
|
||||
import React, { forwardRef, useState, useRef, useEffect, cloneElement, HTMLAttributes } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface TooltipProps extends Omit<HTMLAttributes<HTMLDivElement>, 'content'> {
|
||||
content: React.ReactNode;
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end';
|
||||
trigger?: 'hover' | 'click' | 'focus' | 'manual';
|
||||
delay?: number;
|
||||
hideDelay?: number;
|
||||
disabled?: boolean;
|
||||
arrow?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'dark' | 'light' | 'error' | 'warning' | 'success' | 'info';
|
||||
maxWidth?: string | number;
|
||||
offset?: number;
|
||||
zIndex?: number;
|
||||
portalId?: string;
|
||||
followCursor?: boolean;
|
||||
interactive?: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(({
|
||||
content,
|
||||
placement = 'top',
|
||||
trigger = 'hover',
|
||||
delay = 100,
|
||||
hideDelay = 100,
|
||||
disabled = false,
|
||||
arrow = true,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
maxWidth = 320,
|
||||
offset = 8,
|
||||
zIndex = 1000,
|
||||
followCursor = false,
|
||||
interactive = false,
|
||||
onVisibleChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const [actualPlacement, setActualPlacement] = useState(placement);
|
||||
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const showTimer = useRef<NodeJS.Timeout>();
|
||||
const hideTimer = useRef<NodeJS.Timeout>();
|
||||
|
||||
// Clear timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (showTimer.current) clearTimeout(showTimer.current);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Calculate tooltip position
|
||||
const calculatePosition = (cursorX?: number, cursorY?: number) => {
|
||||
if (!triggerRef.current || !tooltipRef.current) return;
|
||||
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
const viewport = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
let finalPlacement = placement;
|
||||
|
||||
// Use cursor position if followCursor is enabled
|
||||
if (followCursor && cursorX !== undefined && cursorY !== undefined) {
|
||||
top = cursorY + offset;
|
||||
left = cursorX + offset;
|
||||
} else {
|
||||
// Calculate position based on placement
|
||||
switch (placement) {
|
||||
case 'top':
|
||||
case 'top-start':
|
||||
case 'top-end':
|
||||
top = triggerRect.top - tooltipRect.height - offset;
|
||||
left = placement === 'top-start'
|
||||
? triggerRect.left
|
||||
: placement === 'top-end'
|
||||
? triggerRect.right - tooltipRect.width
|
||||
: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
case 'bottom-start':
|
||||
case 'bottom-end':
|
||||
top = triggerRect.bottom + offset;
|
||||
left = placement === 'bottom-start'
|
||||
? triggerRect.left
|
||||
: placement === 'bottom-end'
|
||||
? triggerRect.right - tooltipRect.width
|
||||
: triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||
break;
|
||||
case 'left':
|
||||
case 'left-start':
|
||||
case 'left-end':
|
||||
top = placement === 'left-start'
|
||||
? triggerRect.top
|
||||
: placement === 'left-end'
|
||||
? triggerRect.bottom - tooltipRect.height
|
||||
: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||
left = triggerRect.left - tooltipRect.width - offset;
|
||||
break;
|
||||
case 'right':
|
||||
case 'right-start':
|
||||
case 'right-end':
|
||||
top = placement === 'right-start'
|
||||
? triggerRect.top
|
||||
: placement === 'right-end'
|
||||
? triggerRect.bottom - tooltipRect.height
|
||||
: triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||
left = triggerRect.right + offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Flip if tooltip would go outside viewport
|
||||
const willOverflowTop = top < 0;
|
||||
const willOverflowBottom = top + tooltipRect.height > viewport.height;
|
||||
const willOverflowLeft = left < 0;
|
||||
const willOverflowRight = left + tooltipRect.width > viewport.width;
|
||||
|
||||
// Adjust for viewport constraints
|
||||
if (willOverflowTop && (placement.startsWith('top') || placement.includes('left') || placement.includes('right'))) {
|
||||
if (placement.startsWith('top')) {
|
||||
finalPlacement = placement.replace('top', 'bottom') as typeof placement;
|
||||
top = triggerRect.bottom + offset;
|
||||
} else {
|
||||
top = Math.max(8, top);
|
||||
}
|
||||
}
|
||||
|
||||
if (willOverflowBottom && placement.startsWith('bottom')) {
|
||||
finalPlacement = placement.replace('bottom', 'top') as typeof placement;
|
||||
top = triggerRect.top - tooltipRect.height - offset;
|
||||
}
|
||||
|
||||
if (willOverflowLeft) {
|
||||
if (placement.startsWith('left')) {
|
||||
finalPlacement = placement.replace('left', 'right') as typeof placement;
|
||||
left = triggerRect.right + offset;
|
||||
} else {
|
||||
left = Math.max(8, left);
|
||||
}
|
||||
}
|
||||
|
||||
if (willOverflowRight) {
|
||||
if (placement.startsWith('right')) {
|
||||
finalPlacement = placement.replace('right', 'left') as typeof placement;
|
||||
left = triggerRect.left - tooltipRect.width - offset;
|
||||
} else {
|
||||
left = Math.min(viewport.width - tooltipRect.width - 8, left);
|
||||
}
|
||||
}
|
||||
|
||||
setPosition({ top, left });
|
||||
setActualPlacement(finalPlacement);
|
||||
};
|
||||
|
||||
const showTooltip = (cursorX?: number, cursorY?: number) => {
|
||||
if (disabled || !content) return;
|
||||
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = undefined;
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
showTimer.current = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
onVisibleChange?.(true);
|
||||
// Calculate position after showing to get accurate measurements
|
||||
setTimeout(() => calculatePosition(cursorX, cursorY), 0);
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
const hideTooltip = () => {
|
||||
if (showTimer.current) {
|
||||
clearTimeout(showTimer.current);
|
||||
showTimer.current = undefined;
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onVisibleChange?.(false);
|
||||
}, hideDelay);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
if (trigger === 'hover') {
|
||||
showTooltip(followCursor ? e.clientX : undefined, followCursor ? e.clientY : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (trigger === 'hover') {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (trigger === 'hover' && followCursor && isVisible) {
|
||||
calculatePosition(e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (trigger === 'click') {
|
||||
if (isVisible) {
|
||||
hideTooltip();
|
||||
} else {
|
||||
showTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (trigger === 'focus') {
|
||||
showTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
if (trigger === 'focus') {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTooltipMouseEnter = () => {
|
||||
if (interactive && trigger === 'hover') {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
hideTimer.current = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTooltipMouseLeave = () => {
|
||||
if (interactive && trigger === 'hover') {
|
||||
hideTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
// Recalculate position on window resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (isVisible) {
|
||||
calculatePosition();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [isVisible]);
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
sm: 'px-2 py-1 text-xs',
|
||||
md: 'px-3 py-2 text-sm',
|
||||
lg: 'px-4 py-3 text-base',
|
||||
};
|
||||
|
||||
// Variant classes
|
||||
const variantClasses = {
|
||||
default: 'bg-text-primary text-text-inverse',
|
||||
dark: 'bg-gray-900 text-white',
|
||||
light: 'bg-white text-text-primary border border-border-primary shadow-lg',
|
||||
error: 'bg-color-error text-text-inverse',
|
||||
warning: 'bg-color-warning text-text-inverse',
|
||||
success: 'bg-color-success text-text-inverse',
|
||||
info: 'bg-color-info text-text-inverse',
|
||||
};
|
||||
|
||||
// Arrow classes based on placement
|
||||
const getArrowClasses = () => {
|
||||
const baseArrow = 'absolute w-2 h-2 rotate-45';
|
||||
const arrowColor = variant === 'light'
|
||||
? 'bg-white border border-border-primary'
|
||||
: variantClasses[variant].split(' ')[0];
|
||||
|
||||
switch (actualPlacement.split('-')[0]) {
|
||||
case 'top':
|
||||
return `${baseArrow} ${arrowColor} -bottom-1 left-1/2 -translate-x-1/2`;
|
||||
case 'bottom':
|
||||
return `${baseArrow} ${arrowColor} -top-1 left-1/2 -translate-x-1/2`;
|
||||
case 'left':
|
||||
return `${baseArrow} ${arrowColor} -right-1 top-1/2 -translate-y-1/2`;
|
||||
case 'right':
|
||||
return `${baseArrow} ${arrowColor} -left-1 top-1/2 -translate-y-1/2`;
|
||||
default:
|
||||
return `${baseArrow} ${arrowColor} -bottom-1 left-1/2 -translate-x-1/2`;
|
||||
}
|
||||
};
|
||||
|
||||
const tooltipClasses = clsx(
|
||||
'absolute rounded-lg font-medium leading-tight',
|
||||
'transition-all duration-200 ease-out',
|
||||
'pointer-events-auto',
|
||||
sizeClasses[size],
|
||||
variantClasses[variant],
|
||||
{
|
||||
'opacity-0 scale-95 pointer-events-none': !isVisible,
|
||||
'opacity-100 scale-100': isVisible,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const tooltipStyle: React.CSSProperties = {
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth,
|
||||
zIndex,
|
||||
...props.style,
|
||||
};
|
||||
|
||||
// Clone the trigger element and add event handlers
|
||||
const triggerElement = React.isValidElement(children)
|
||||
? cloneElement(children as React.ReactElement, {
|
||||
ref: (node: HTMLElement) => {
|
||||
triggerRef.current = node;
|
||||
// Preserve original ref if it exists
|
||||
const originalRef = (children as any).ref;
|
||||
if (typeof originalRef === 'function') {
|
||||
originalRef(node);
|
||||
} else if (originalRef) {
|
||||
originalRef.current = node;
|
||||
}
|
||||
},
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
handleMouseEnter(e);
|
||||
// Call original handler if it exists
|
||||
(children as any).props?.onMouseEnter?.(e);
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
handleMouseLeave();
|
||||
(children as any).props?.onMouseLeave?.(e);
|
||||
},
|
||||
onMouseMove: (e: React.MouseEvent) => {
|
||||
handleMouseMove(e);
|
||||
(children as any).props?.onMouseMove?.(e);
|
||||
},
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
handleClick();
|
||||
(children as any).props?.onClick?.(e);
|
||||
},
|
||||
onFocus: (e: React.FocusEvent) => {
|
||||
handleFocus();
|
||||
(children as any).props?.onFocus?.(e);
|
||||
},
|
||||
onBlur: (e: React.FocusEvent) => {
|
||||
handleBlur();
|
||||
(children as any).props?.onBlur?.(e);
|
||||
},
|
||||
})
|
||||
: children;
|
||||
|
||||
return (
|
||||
<>
|
||||
{triggerElement}
|
||||
{content && (
|
||||
<div
|
||||
ref={ref || tooltipRef}
|
||||
className={tooltipClasses}
|
||||
style={tooltipStyle}
|
||||
role="tooltip"
|
||||
onMouseEnter={handleTooltipMouseEnter}
|
||||
onMouseLeave={handleTooltipMouseLeave}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
{arrow && <div className={getArrowClasses()} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Tooltip.displayName = 'Tooltip';
|
||||
|
||||
export default Tooltip;
|
||||
3
frontend/src/components/ui/Tooltip/index.ts
Normal file
3
frontend/src/components/ui/Tooltip/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default } from './Tooltip';
|
||||
export { default as Tooltip } from './Tooltip';
|
||||
export type { TooltipProps } from './Tooltip';
|
||||
@@ -1,557 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, RotateCcw, TrendingUp, TrendingDown, AlertTriangle, Euro, Calendar } from 'lucide-react';
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'weather' | 'promotion' | 'event' | 'supply' | 'custom';
|
||||
icon: any;
|
||||
parameters: {
|
||||
[key: string]: {
|
||||
label: string;
|
||||
type: 'number' | 'select' | 'boolean';
|
||||
value: any;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ScenarioResult {
|
||||
scenarioId: string;
|
||||
demandChange: number;
|
||||
revenueImpact: number;
|
||||
productImpacts: Array<{
|
||||
name: string;
|
||||
demandChange: number;
|
||||
newDemand: number;
|
||||
revenueImpact: number;
|
||||
}>;
|
||||
recommendations: string[];
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface WhatIfPlannerProps {
|
||||
baselineData?: {
|
||||
totalDemand: number;
|
||||
totalRevenue: number;
|
||||
products: Array<{
|
||||
name: string;
|
||||
demand: number;
|
||||
price: number;
|
||||
}>;
|
||||
};
|
||||
onScenarioRun?: (scenario: Scenario, result: ScenarioResult) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WhatIfPlanner: React.FC<WhatIfPlannerProps> = ({
|
||||
baselineData = {
|
||||
totalDemand: 180,
|
||||
totalRevenue: 420,
|
||||
products: [
|
||||
{ name: 'Croissants', demand: 45, price: 2.5 },
|
||||
{ name: 'Pan', demand: 30, price: 1.8 },
|
||||
{ name: 'Magdalenas', demand: 25, price: 1.2 },
|
||||
{ name: 'Empanadas', demand: 20, price: 3.2 },
|
||||
{ name: 'Tartas', demand: 15, price: 12.0 },
|
||||
]
|
||||
},
|
||||
onScenarioRun,
|
||||
className = ''
|
||||
}) => {
|
||||
const [selectedScenario, setSelectedScenario] = useState<string | null>(null);
|
||||
const [scenarioResult, setScenarioResult] = useState<ScenarioResult | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
|
||||
const scenarios: Scenario[] = [
|
||||
{
|
||||
id: 'rain',
|
||||
name: 'Día Lluvioso',
|
||||
description: 'Simula el impacto de un día de lluvia en Madrid',
|
||||
type: 'weather',
|
||||
icon: AlertTriangle,
|
||||
parameters: {
|
||||
rainIntensity: {
|
||||
label: 'Intensidad de lluvia',
|
||||
type: 'select',
|
||||
value: 'moderate',
|
||||
options: ['light', 'moderate', 'heavy']
|
||||
},
|
||||
temperature: {
|
||||
label: 'Temperatura (°C)',
|
||||
type: 'number',
|
||||
value: 15,
|
||||
min: 5,
|
||||
max: 25,
|
||||
step: 1,
|
||||
unit: '°C'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'promotion',
|
||||
name: 'Promoción Especial',
|
||||
description: 'Aplica un descuento y ve el impacto en la demanda',
|
||||
type: 'promotion',
|
||||
icon: TrendingUp,
|
||||
parameters: {
|
||||
discount: {
|
||||
label: 'Descuento',
|
||||
type: 'number',
|
||||
value: 20,
|
||||
min: 5,
|
||||
max: 50,
|
||||
step: 5,
|
||||
unit: '%'
|
||||
},
|
||||
targetProduct: {
|
||||
label: 'Producto objetivo',
|
||||
type: 'select',
|
||||
value: 'Croissants',
|
||||
options: baselineData.products.map(p => p.name)
|
||||
},
|
||||
duration: {
|
||||
label: 'Duración (días)',
|
||||
type: 'number',
|
||||
value: 3,
|
||||
min: 1,
|
||||
max: 7,
|
||||
step: 1,
|
||||
unit: 'días'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'weekend',
|
||||
name: 'Fin de Semana',
|
||||
description: 'Simula la demanda típica de fin de semana',
|
||||
type: 'event',
|
||||
icon: Calendar,
|
||||
parameters: {
|
||||
dayType: {
|
||||
label: 'Tipo de día',
|
||||
type: 'select',
|
||||
value: 'saturday',
|
||||
options: ['saturday', 'sunday', 'holiday']
|
||||
},
|
||||
weatherGood: {
|
||||
label: 'Buen tiempo',
|
||||
type: 'boolean',
|
||||
value: true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'supply_shortage',
|
||||
name: 'Escasez de Ingredientes',
|
||||
description: 'Simula falta de ingredientes clave',
|
||||
type: 'supply',
|
||||
icon: AlertTriangle,
|
||||
parameters: {
|
||||
ingredient: {
|
||||
label: 'Ingrediente afectado',
|
||||
type: 'select',
|
||||
value: 'flour',
|
||||
options: ['flour', 'butter', 'eggs', 'sugar', 'chocolate']
|
||||
},
|
||||
shortage: {
|
||||
label: 'Nivel de escasez',
|
||||
type: 'select',
|
||||
value: 'moderate',
|
||||
options: ['mild', 'moderate', 'severe']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const runScenario = async (scenario: Scenario) => {
|
||||
setIsRunning(true);
|
||||
setScenarioResult(null);
|
||||
|
||||
// Simulate API call delay
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// Generate realistic scenario results based on parameters
|
||||
const result = generateScenarioResult(scenario, baselineData);
|
||||
|
||||
setScenarioResult(result);
|
||||
setIsRunning(false);
|
||||
onScenarioRun?.(scenario, result);
|
||||
};
|
||||
|
||||
const generateScenarioResult = (scenario: Scenario, baseline: typeof baselineData): ScenarioResult => {
|
||||
let demandChange = 0;
|
||||
let productImpacts: ScenarioResult['productImpacts'] = [];
|
||||
let recommendations: string[] = [];
|
||||
let confidence: 'high' | 'medium' | 'low' = 'medium';
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'rain':
|
||||
const intensity = scenario.parameters.rainIntensity.value;
|
||||
demandChange = intensity === 'light' ? -5 : intensity === 'moderate' ? -15 : -25;
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
'Reduce la producción de productos para llevar',
|
||||
'Aumenta café caliente y productos de temporada',
|
||||
'Prepara promociones para el día siguiente'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = product.name === 'Croissants' ? demandChange * 1.2 : demandChange;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'promotion':
|
||||
const discount = scenario.parameters.discount.value;
|
||||
const targetProduct = scenario.parameters.targetProduct.value;
|
||||
demandChange = discount * 1.5; // 20% discount = ~30% demand increase
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
`Aumenta la producción de ${targetProduct} en un ${Math.round(demandChange)}%`,
|
||||
'Asegúrate de tener suficientes ingredientes',
|
||||
'Promociona productos complementarios'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = product.name === targetProduct ? demandChange : demandChange * 0.3;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: product.demand + Math.round(change * product.demand / 100),
|
||||
revenueImpact: Math.round((change * product.demand / 100) * product.price * (product.name === targetProduct ? (1 - discount/100) : 1))
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'weekend':
|
||||
const isWeekend = scenario.parameters.dayType.value;
|
||||
const goodWeather = scenario.parameters.weatherGood.value;
|
||||
demandChange = (isWeekend === 'saturday' ? 25 : isWeekend === 'sunday' ? 15 : 35) + (goodWeather ? 10 : -5);
|
||||
confidence = 'high';
|
||||
recommendations = [
|
||||
'Aumenta la producción de productos especiales',
|
||||
'Prepara más variedad para familias',
|
||||
'Considera abrir más temprano'
|
||||
];
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const multiplier = product.name === 'Tartas' ? 1.5 : product.name === 'Croissants' ? 1.3 : 1.0;
|
||||
const change = demandChange * multiplier;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: product.demand + Math.round(change * product.demand / 100),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'supply_shortage':
|
||||
const ingredient = scenario.parameters.ingredient.value;
|
||||
const shortage = scenario.parameters.shortage.value;
|
||||
demandChange = shortage === 'mild' ? -10 : shortage === 'moderate' ? -25 : -40;
|
||||
confidence = 'medium';
|
||||
recommendations = [
|
||||
'Busca proveedores alternativos inmediatamente',
|
||||
'Promociona productos que no requieren este ingrediente',
|
||||
'Informa a los clientes sobre productos no disponibles'
|
||||
];
|
||||
const affectedProducts = getAffectedProducts(ingredient);
|
||||
productImpacts = baseline.products.map(product => {
|
||||
const change = affectedProducts.includes(product.name) ? demandChange : 0;
|
||||
return {
|
||||
name: product.name,
|
||||
demandChange: Math.round(change * product.demand / 100),
|
||||
newDemand: Math.max(0, product.demand + Math.round(change * product.demand / 100)),
|
||||
revenueImpact: Math.round(change * product.demand * product.price / 100)
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const totalRevenueImpact = productImpacts.reduce((sum, impact) => sum + impact.revenueImpact, 0);
|
||||
|
||||
return {
|
||||
scenarioId: scenario.id,
|
||||
demandChange,
|
||||
revenueImpact: totalRevenueImpact,
|
||||
productImpacts,
|
||||
recommendations,
|
||||
confidence
|
||||
};
|
||||
};
|
||||
|
||||
const getAffectedProducts = (ingredient: string): string[] => {
|
||||
const ingredientMap: Record<string, string[]> = {
|
||||
flour: ['Pan', 'Croissants', 'Magdalenas', 'Tartas'],
|
||||
butter: ['Croissants', 'Tartas', 'Magdalenas'],
|
||||
eggs: ['Magdalenas', 'Tartas'],
|
||||
sugar: ['Magdalenas', 'Tartas'],
|
||||
chocolate: ['Tartas']
|
||||
};
|
||||
return ingredientMap[ingredient] || [];
|
||||
};
|
||||
|
||||
const updateParameter = (scenarioId: string, paramKey: string, value: any) => {
|
||||
// This would update the scenario parameters in a real implementation
|
||||
console.log('Updating parameter:', scenarioId, paramKey, value);
|
||||
};
|
||||
|
||||
const resetScenario = () => {
|
||||
setSelectedScenario(null);
|
||||
setScenarioResult(null);
|
||||
};
|
||||
|
||||
const selectedScenarioData = scenarios.find(s => s.id === selectedScenario);
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-soft p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Play className="h-5 w-5 mr-2 text-primary-600" />
|
||||
Simulador de Escenarios
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Simula diferentes situaciones y ve el impacto en tu negocio
|
||||
</p>
|
||||
</div>
|
||||
{selectedScenario && (
|
||||
<button
|
||||
onClick={resetScenario}
|
||||
className="flex items-center px-3 py-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-1" />
|
||||
Reiniciar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedScenario ? (
|
||||
// Scenario Selection
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{scenarios.map(scenario => {
|
||||
const IconComponent = scenario.icon;
|
||||
return (
|
||||
<button
|
||||
key={scenario.id}
|
||||
onClick={() => setSelectedScenario(scenario.id)}
|
||||
className="text-left p-4 border border-gray-200 rounded-lg hover:border-primary-300 hover:shadow-md transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0 p-2 bg-primary-50 rounded-lg group-hover:bg-primary-100 transition-colors">
|
||||
<IconComponent className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h4 className="font-medium text-gray-900 group-hover:text-primary-700">
|
||||
{scenario.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{scenario.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Selected Scenario Configuration
|
||||
<div className="space-y-6">
|
||||
{selectedScenarioData && (
|
||||
<>
|
||||
{/* Scenario Header */}
|
||||
<div className="flex items-center p-4 bg-primary-50 rounded-lg">
|
||||
<selectedScenarioData.icon className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<div>
|
||||
<h4 className="font-medium text-primary-900">{selectedScenarioData.name}</h4>
|
||||
<p className="text-sm text-primary-700 mt-1">{selectedScenarioData.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">Parámetros del Escenario:</h5>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(selectedScenarioData.parameters).map(([key, param]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{param.label}
|
||||
</label>
|
||||
{param.type === 'select' ? (
|
||||
<select
|
||||
value={param.value}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
>
|
||||
{param.options?.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option === 'light' ? 'Ligera' :
|
||||
option === 'moderate' ? 'Moderada' :
|
||||
option === 'heavy' ? 'Intensa' :
|
||||
option === 'saturday' ? 'Sábado' :
|
||||
option === 'sunday' ? 'Domingo' :
|
||||
option === 'holiday' ? 'Festivo' :
|
||||
option === 'mild' ? 'Leve' :
|
||||
option === 'severe' ? 'Severa' :
|
||||
option === 'flour' ? 'Harina' :
|
||||
option === 'butter' ? 'Mantequilla' :
|
||||
option === 'eggs' ? 'Huevos' :
|
||||
option === 'sugar' ? 'Azúcar' :
|
||||
option === 'chocolate' ? 'Chocolate' :
|
||||
option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : param.type === 'number' ? (
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={param.value}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, Number(e.target.value))}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
{param.unit && (
|
||||
<span className="ml-2 text-sm text-gray-600">{param.unit}</span>
|
||||
)}
|
||||
</div>
|
||||
) : param.type === 'boolean' ? (
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.value}
|
||||
onChange={(e) => updateParameter(selectedScenarioData.id, key, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{param.value ? 'Sí' : 'No'}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Run Scenario Button */}
|
||||
<button
|
||||
onClick={() => runScenario(selectedScenarioData)}
|
||||
disabled={isRunning}
|
||||
className="w-full flex items-center justify-center px-4 py-3 bg-primary-600 text-white font-medium rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Simulando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Ejecutar Simulación
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{scenarioResult && (
|
||||
<div className="border-t border-gray-200 pt-6 space-y-6">
|
||||
<div>
|
||||
<h5 className="text-lg font-medium text-gray-900 mb-4">Resultados de la Simulación</h5>
|
||||
|
||||
{/* Overall Impact */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Cambio en Demanda</span>
|
||||
<span className={`text-lg font-bold ${scenarioResult.demandChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{scenarioResult.demandChange >= 0 ? '+' : ''}{scenarioResult.demandChange}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Impacto en Ingresos</span>
|
||||
<span className={`text-lg font-bold flex items-center ${scenarioResult.revenueImpact >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Euro className="h-4 w-4 mr-1" />
|
||||
{scenarioResult.revenueImpact >= 0 ? '+' : ''}{scenarioResult.revenueImpact}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Confianza</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
scenarioResult.confidence === 'high' ? 'bg-green-100 text-green-800' :
|
||||
scenarioResult.confidence === 'medium' ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{scenarioResult.confidence === 'high' ? 'Alta' :
|
||||
scenarioResult.confidence === 'medium' ? 'Media' : 'Baja'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Impact */}
|
||||
<div className="mb-6">
|
||||
<h6 className="text-sm font-medium text-gray-700 mb-3">Impacto por Producto:</h6>
|
||||
<div className="space-y-2">
|
||||
{scenarioResult.productImpacts.map((impact, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-gray-900">{impact.name}</span>
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{baselineData.products.find(p => p.name === impact.name)?.demand} → {impact.newDemand}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className={`text-sm font-medium ${impact.demandChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{impact.demandChange >= 0 ? '+' : ''}{impact.demandChange}
|
||||
</span>
|
||||
<span className={`text-sm font-medium flex items-center ${impact.revenueImpact >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<Euro className="h-3 w-3 mr-1" />
|
||||
{impact.revenueImpact >= 0 ? '+' : ''}{impact.revenueImpact}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div>
|
||||
<h6 className="text-sm font-medium text-gray-700 mb-3">Recomendaciones:</h6>
|
||||
<ul className="space-y-2">
|
||||
{scenarioResult.recommendations.map((rec, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<div className="flex-shrink-0 w-2 h-2 bg-primary-600 rounded-full mt-2 mr-3"></div>
|
||||
<span className="text-sm text-gray-700">{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WhatIfPlanner;
|
||||
31
frontend/src/components/ui/index.ts
Normal file
31
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// UI Components - Design System
|
||||
export { default as Button } from './Button';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
|
||||
export { default as Modal } from './Modal';
|
||||
export { default as Table } from './Table';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Tooltip } from './Tooltip';
|
||||
export { default as Select } from './Select';
|
||||
export { default as DatePicker } from './DatePicker';
|
||||
export { ThemeToggle } from './ThemeToggle';
|
||||
export { ProgressBar } from './ProgressBar';
|
||||
export { StatusIndicator } from './StatusIndicator';
|
||||
export { ListItem } from './ListItem';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
export type { InputProps } from './Input';
|
||||
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
|
||||
export type { ModalProps } from './Modal';
|
||||
export type { TableProps, TableColumn, TableRow } from './Table';
|
||||
export type { BadgeProps } from './Badge';
|
||||
export type { AvatarProps } from './Avatar';
|
||||
export type { TooltipProps } from './Tooltip';
|
||||
export type { SelectProps, SelectOption } from './Select';
|
||||
export type { DatePickerProps } from './DatePicker';
|
||||
export type { ThemeToggleProps } from './ThemeToggle';
|
||||
export type { ProgressBarProps } from './ProgressBar';
|
||||
export type { StatusIndicatorProps } from './StatusIndicator';
|
||||
export type { ListItemProps } from './ListItem';
|
||||
Reference in New Issue
Block a user