Clean frontend
This commit is contained in:
@@ -1,298 +0,0 @@
|
||||
import React, { forwardRef, HTMLAttributes, ReactNode, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card, CardHeader, CardBody, CardFooter, Button, Badge } from '../../ui';
|
||||
|
||||
export interface DashboardCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||
// Card variants for different dashboard contexts
|
||||
variant?: 'metric' | 'chart' | 'list' | 'activity' | 'status' | 'action';
|
||||
|
||||
// Header props
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
|
||||
// Loading and state management
|
||||
isLoading?: boolean;
|
||||
hasError?: boolean;
|
||||
errorMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
emptyMessage?: string;
|
||||
|
||||
// Footer props
|
||||
footerActions?: ReactNode;
|
||||
footerText?: ReactNode;
|
||||
|
||||
// Badge/notification support
|
||||
badge?: string | number;
|
||||
badgeVariant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
||||
|
||||
// Interactive behavior
|
||||
interactive?: boolean;
|
||||
onClick?: () => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
// Layout customization
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
headerPadding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
bodyPadding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
|
||||
// Accessibility
|
||||
'aria-label'?: string;
|
||||
'aria-describedby'?: string;
|
||||
}
|
||||
|
||||
const DashboardCard = forwardRef<HTMLDivElement, DashboardCardProps>(({
|
||||
variant = 'metric',
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
headerActions,
|
||||
isLoading = false,
|
||||
hasError = false,
|
||||
errorMessage = 'Ha ocurrido un error',
|
||||
isEmpty = false,
|
||||
emptyMessage = 'No hay datos disponibles',
|
||||
footerActions,
|
||||
footerText,
|
||||
badge,
|
||||
badgeVariant = 'primary',
|
||||
interactive = false,
|
||||
onClick,
|
||||
onRefresh,
|
||||
padding = 'md',
|
||||
headerPadding,
|
||||
bodyPadding,
|
||||
className,
|
||||
children,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-describedby': ariaDescribedby,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (onRefresh && !isRefreshing) {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
metric: 'bg-gradient-to-br from-white to-blue-50 border-blue-200 hover:border-blue-300',
|
||||
chart: 'bg-white border-gray-200 hover:border-gray-300',
|
||||
list: 'bg-white border-gray-200 hover:border-gray-300',
|
||||
activity: 'bg-gradient-to-br from-white to-green-50 border-green-200 hover:border-green-300',
|
||||
status: 'bg-gradient-to-br from-white to-purple-50 border-purple-200 hover:border-purple-300',
|
||||
action: 'bg-gradient-to-br from-white to-amber-50 border-amber-200 hover:border-amber-300 hover:shadow-lg'
|
||||
};
|
||||
|
||||
const cardClasses = clsx(
|
||||
variantStyles[variant],
|
||||
'transition-all duration-300',
|
||||
{
|
||||
'cursor-pointer transform hover:-translate-y-1': interactive || onClick,
|
||||
'opacity-50': isLoading,
|
||||
'border-red-300 bg-red-50': hasError,
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const hasHeader = title || subtitle || icon || headerActions || badge || onRefresh;
|
||||
|
||||
const renderSkeletonContent = () => {
|
||||
switch (variant) {
|
||||
case 'metric':
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
);
|
||||
case 'chart':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="h-6 bg-gray-200 rounded animate-pulse w-1/2" />
|
||||
<div className="h-32 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
case 'list':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-3 bg-gray-200 rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="h-24 bg-gray-200 rounded animate-pulse" />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderErrorContent = () => (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-4">{errorMessage}</p>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
isLoading={isRefreshing}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEmptyContent = () => (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
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-gray-500">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
className={cardClasses}
|
||||
padding={padding}
|
||||
interactive={interactive}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={ariaDescribedby}
|
||||
{...props}
|
||||
>
|
||||
{hasHeader && (
|
||||
<CardHeader
|
||||
padding={headerPadding}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0 text-gray-600">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{title && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant={badgeVariant}
|
||||
size="sm"
|
||||
shape="pill"
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-gray-600 mt-1 truncate">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
isLoading={isRefreshing}
|
||||
aria-label="Actualizar datos"
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
{headerActions}
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardBody padding={bodyPadding}>
|
||||
{isLoading
|
||||
? renderSkeletonContent()
|
||||
: hasError
|
||||
? renderErrorContent()
|
||||
: isEmpty
|
||||
? renderEmptyContent()
|
||||
: children}
|
||||
</CardBody>
|
||||
|
||||
{(footerActions || footerText) && (
|
||||
<CardFooter className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
{footerText}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{footerActions}
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
DashboardCard.displayName = 'DashboardCard';
|
||||
|
||||
export default DashboardCard;
|
||||
@@ -1,258 +0,0 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
TrendingUp,
|
||||
Package,
|
||||
AlertCircle,
|
||||
Cloud,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Users,
|
||||
ShoppingCart
|
||||
} from 'lucide-react';
|
||||
import { Card } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { dashboardService } from '../../../services/api/dashboard.service';
|
||||
import { ForecastChart } from '../forecasting/ForecastChart';
|
||||
import { SalesChart } from '../sales/SalesChart';
|
||||
import { AIInsightCard } from '../ai/AIInsightCard';
|
||||
import { ProductionStatusCard } from '../production/ProductionStatusCard';
|
||||
import { AlertsFeed } from '../alerts/AlertsFeed';
|
||||
import { useBakeryStore } from '../../../stores/bakery.store';
|
||||
|
||||
export const DashboardGrid: React.FC = () => {
|
||||
const { currentTenant, bakeryType } = useBakeryStore();
|
||||
|
||||
const { data: dashboardData, isLoading } = useQuery({
|
||||
queryKey: ['dashboard', currentTenant?.id],
|
||||
queryFn: () => dashboardService.getDashboardData(currentTenant!.id),
|
||||
enabled: !!currentTenant,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
});
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: 'spring',
|
||||
stiffness: 100,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6"
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* KPI Cards Row */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<KPICard
|
||||
title="Ventas Hoy"
|
||||
value={`€${dashboardData?.sales_today || 0}`}
|
||||
change={dashboardData?.sales_change || 0}
|
||||
icon={<DollarSign className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<KPICard
|
||||
title="Productos Vendidos"
|
||||
value={dashboardData?.products_sold || 0}
|
||||
change={dashboardData?.products_change || 0}
|
||||
icon={<Package className="w-5 h-5" />}
|
||||
color="blue"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<KPICard
|
||||
title="Alertas Activas"
|
||||
value={dashboardData?.active_alerts || 0}
|
||||
urgent={dashboardData?.urgent_alerts || 0}
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="orange"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<WeatherCard
|
||||
temperature={dashboardData?.weather?.temperature}
|
||||
condition={dashboardData?.weather?.condition}
|
||||
impact={dashboardData?.weather?.sales_impact}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<motion.div variants={itemVariants} className="col-span-full lg:col-span-2">
|
||||
<Card className="h-full">
|
||||
<Card.Header>
|
||||
<Card.Title>Predicción de Demanda - 7 Días</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content className="h-80">
|
||||
<ForecastChart
|
||||
data={dashboardData?.forecast_data || []}
|
||||
confidence={dashboardData?.confidence_interval}
|
||||
interactive
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants} className="col-span-full lg:col-span-2">
|
||||
<Card className="h-full">
|
||||
<Card.Header>
|
||||
<Card.Title>Análisis de Ventas</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content className="h-80">
|
||||
<SalesChart
|
||||
data={dashboardData?.sales_data || []}
|
||||
comparison="previous_week"
|
||||
breakdown="category"
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Status Cards */}
|
||||
{bakeryType === 'individual' && (
|
||||
<motion.div variants={itemVariants} className="col-span-full xl:col-span-2">
|
||||
<ProductionStatusCard
|
||||
batches={dashboardData?.production_batches || []}
|
||||
efficiency={dashboardData?.production_efficiency}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* AI Insights */}
|
||||
<motion.div variants={itemVariants} className="col-span-full xl:col-span-1">
|
||||
<AIInsightCard
|
||||
insights={dashboardData?.ai_insights || []}
|
||||
onActionClick={(action) => console.log('AI Action:', action)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Real-time Alerts Feed */}
|
||||
<motion.div variants={itemVariants} className="col-span-full xl:col-span-1">
|
||||
<AlertsFeed />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// KPI Card Component
|
||||
interface KPICardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: number;
|
||||
urgent?: number;
|
||||
icon: React.ReactNode;
|
||||
color: 'green' | 'blue' | 'orange' | 'red';
|
||||
}
|
||||
|
||||
const KPICard: React.FC<KPICardProps> = ({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
urgent,
|
||||
icon,
|
||||
color
|
||||
}) => {
|
||||
const colorClasses = {
|
||||
green: 'bg-green-50 text-green-600 border-green-200',
|
||||
blue: 'bg-blue-50 text-blue-600 border-blue-200',
|
||||
orange: 'bg-orange-50 text-orange-600 border-orange-200',
|
||||
red: 'bg-red-50 text-red-600 border-red-200',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`relative overflow-hidden ${colorClasses[color]} border-2`}>
|
||||
<Card.Content className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-2 rounded-lg ${color === 'green' ? 'bg-green-100' : color === 'blue' ? 'bg-blue-100' : color === 'orange' ? 'bg-orange-100' : 'bg-red-100'}`}>
|
||||
{icon}
|
||||
</div>
|
||||
{urgent !== undefined && urgent > 0 && (
|
||||
<Badge variant="destructive" className="animate-pulse">
|
||||
{urgent} Urgente
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
|
||||
{change !== undefined && (
|
||||
<div className="flex items-center space-x-1 text-sm">
|
||||
<TrendingUp className={`w-4 h-4 ${change >= 0 ? 'text-green-500' : 'text-red-500 rotate-180'}`} />
|
||||
<span className={change >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
{Math.abs(change)}%
|
||||
</span>
|
||||
<span className="text-gray-500">vs ayer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Weather Impact Card
|
||||
const WeatherCard: React.FC<{
|
||||
temperature?: number;
|
||||
condition?: string;
|
||||
impact?: string;
|
||||
}> = ({ temperature, condition, impact }) => {
|
||||
return (
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-cyan-50 border-blue-200">
|
||||
<Card.Content className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Cloud className="w-8 h-8 text-blue-500" />
|
||||
<Badge variant={impact === 'positive' ? 'success' : impact === 'negative' ? 'destructive' : 'secondary'}>
|
||||
{impact === 'positive' ? '↑' : impact === 'negative' ? '↓' : '='} Impacto
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold">{temperature}°C</p>
|
||||
<p className="text-sm text-gray-600">{condition}</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 p-6">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<Card.Content className="p-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
|
||||
<div className="h-12 bg-gray-300 rounded w-1/2" />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,382 +0,0 @@
|
||||
import React, { KeyboardEvent, useCallback, useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button, Badge } from '../../ui';
|
||||
|
||||
export interface QuickAction {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
href?: string;
|
||||
|
||||
// Badge/notification support
|
||||
badge?: string | number;
|
||||
badgeVariant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
|
||||
|
||||
// Access control
|
||||
permissions?: string[];
|
||||
requiredRole?: string;
|
||||
isDisabled?: boolean;
|
||||
disabledReason?: string;
|
||||
|
||||
// Styling
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'success' | 'warning' | 'danger';
|
||||
color?: string;
|
||||
backgroundGradient?: string;
|
||||
|
||||
// Keyboard shortcuts
|
||||
shortcut?: string;
|
||||
|
||||
// Priority for ordering
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface QuickActionsProps {
|
||||
actions: QuickAction[];
|
||||
|
||||
// Layout configuration
|
||||
columns?: 2 | 3 | 4 | 5 | 6;
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
|
||||
// Filtering and user context
|
||||
userRole?: string;
|
||||
userPermissions?: string[];
|
||||
showDisabled?: boolean;
|
||||
maxActions?: number;
|
||||
|
||||
// Event handlers
|
||||
onActionClick?: (action: QuickAction) => void;
|
||||
onActionHover?: (action: QuickAction) => void;
|
||||
|
||||
// Accessibility
|
||||
'aria-label'?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Predefined bakery actions with Spanish context
|
||||
export const BAKERY_QUICK_ACTIONS: QuickAction[] = [
|
||||
{
|
||||
id: 'new-order',
|
||||
title: 'Nuevo Pedido',
|
||||
description: 'Crear un nuevo pedido de cliente',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Nuevo pedido'),
|
||||
variant: 'primary',
|
||||
backgroundGradient: 'from-blue-500 to-blue-600',
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
id: 'add-product',
|
||||
title: 'Agregar Producto',
|
||||
description: 'Añadir nuevo producto al inventario',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Agregar producto'),
|
||||
variant: 'success',
|
||||
backgroundGradient: 'from-green-500 to-green-600',
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
id: 'view-inventory',
|
||||
title: 'Ver Inventario',
|
||||
description: 'Consultar stock y productos',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Ver inventario'),
|
||||
variant: 'outline',
|
||||
backgroundGradient: 'from-purple-500 to-purple-600',
|
||||
priority: 3,
|
||||
badge: '5',
|
||||
badgeVariant: 'warning'
|
||||
},
|
||||
{
|
||||
id: 'production-batch',
|
||||
title: 'Nueva Producción',
|
||||
description: 'Programar lote de producción',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Nueva producción'),
|
||||
variant: 'warning',
|
||||
backgroundGradient: 'from-orange-500 to-orange-600',
|
||||
priority: 4
|
||||
},
|
||||
{
|
||||
id: 'sales-report',
|
||||
title: 'Reporte Ventas',
|
||||
description: 'Ver análisis de ventas',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Reporte ventas'),
|
||||
variant: 'secondary',
|
||||
backgroundGradient: 'from-indigo-500 to-indigo-600',
|
||||
priority: 5
|
||||
},
|
||||
{
|
||||
id: 'manage-suppliers',
|
||||
title: 'Proveedores',
|
||||
description: 'Gestionar proveedores',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Proveedores'),
|
||||
variant: 'outline',
|
||||
backgroundGradient: 'from-teal-500 to-teal-600',
|
||||
priority: 6
|
||||
},
|
||||
{
|
||||
id: 'pos-system',
|
||||
title: 'Sistema POS',
|
||||
description: 'Punto de venta',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Sistema POS'),
|
||||
variant: 'primary',
|
||||
backgroundGradient: 'from-emerald-500 to-emerald-600',
|
||||
priority: 7
|
||||
},
|
||||
{
|
||||
id: 'quality-control',
|
||||
title: 'Control Calidad',
|
||||
description: 'Verificación de calidad',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => console.log('Control calidad'),
|
||||
variant: 'success',
|
||||
backgroundGradient: 'from-lime-500 to-lime-600',
|
||||
priority: 8,
|
||||
requiredRole: 'quality_manager'
|
||||
}
|
||||
];
|
||||
|
||||
const QuickActions: React.FC<QuickActionsProps> = ({
|
||||
actions,
|
||||
columns = 3,
|
||||
gap = 'md',
|
||||
size = 'md',
|
||||
userRole,
|
||||
userPermissions = [],
|
||||
showDisabled = false,
|
||||
maxActions,
|
||||
onActionClick,
|
||||
onActionHover,
|
||||
'aria-label': ariaLabel = 'Acciones rápidas',
|
||||
className
|
||||
}) => {
|
||||
// Filter and sort actions
|
||||
const visibleActions = useMemo(() => {
|
||||
let filteredActions = actions.filter(action => {
|
||||
// Role-based filtering
|
||||
if (action.requiredRole && userRole !== action.requiredRole) {
|
||||
return showDisabled;
|
||||
}
|
||||
|
||||
// Permission-based filtering
|
||||
if (action.permissions && action.permissions.length > 0) {
|
||||
const hasPermission = action.permissions.some(perm =>
|
||||
userPermissions.includes(perm)
|
||||
);
|
||||
if (!hasPermission) {
|
||||
return showDisabled;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by priority
|
||||
filteredActions.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
||||
|
||||
// Limit actions if specified
|
||||
if (maxActions) {
|
||||
filteredActions = filteredActions.slice(0, maxActions);
|
||||
}
|
||||
|
||||
return filteredActions;
|
||||
}, [actions, userRole, userPermissions, showDisabled, maxActions]);
|
||||
|
||||
const handleActionClick = useCallback((action: QuickAction) => {
|
||||
if (action.isDisabled) return;
|
||||
|
||||
onActionClick?.(action);
|
||||
action.onClick();
|
||||
}, [onActionClick]);
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>, action: QuickAction) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleActionClick(action);
|
||||
}
|
||||
}, [handleActionClick]);
|
||||
|
||||
const gridClasses = {
|
||||
2: 'grid-cols-2 sm:grid-cols-2',
|
||||
3: 'grid-cols-2 sm:grid-cols-3',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
|
||||
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6'
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-2',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6'
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-3 min-h-[80px]',
|
||||
md: 'p-4 min-h-[100px]',
|
||||
lg: 'p-6 min-h-[120px]'
|
||||
};
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No hay acciones disponibles</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'grid',
|
||||
gridClasses[columns],
|
||||
gapClasses[gap],
|
||||
className
|
||||
)}
|
||||
role="grid"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{visibleActions.map((action) => {
|
||||
const isDisabled = action.isDisabled ||
|
||||
(action.requiredRole && userRole !== action.requiredRole) ||
|
||||
(action.permissions && !action.permissions.some(perm => userPermissions.includes(perm)));
|
||||
|
||||
const buttonClasses = clsx(
|
||||
'relative group transition-all duration-200',
|
||||
'border border-gray-200 rounded-xl',
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
'hover:shadow-lg hover:-translate-y-1',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
sizeClasses[size],
|
||||
{
|
||||
'bg-white hover:bg-gray-50': !action.backgroundGradient,
|
||||
'bg-gradient-to-br text-white hover:opacity-90': action.backgroundGradient,
|
||||
'opacity-50 cursor-not-allowed hover:transform-none hover:shadow-none': isDisabled,
|
||||
}
|
||||
);
|
||||
|
||||
const gradientStyle = action.backgroundGradient ? {
|
||||
background: `linear-gradient(135deg, var(--tw-gradient-stops))`,
|
||||
} : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={action.id}
|
||||
className={buttonClasses}
|
||||
onClick={() => handleActionClick(action)}
|
||||
onKeyDown={(e) => handleKeyDown(e, action)}
|
||||
onMouseEnter={() => onActionHover?.(action)}
|
||||
disabled={isDisabled}
|
||||
title={isDisabled ? action.disabledReason || 'Acción no disponible' : action.description}
|
||||
aria-label={`${action.title}. ${action.description || ''}`}
|
||||
style={gradientStyle}
|
||||
>
|
||||
{/* Badge */}
|
||||
{action.badge && (
|
||||
<Badge
|
||||
variant={action.badgeVariant}
|
||||
size="sm"
|
||||
className="absolute -top-2 -right-2 z-10"
|
||||
>
|
||||
{action.badge}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
<div className={clsx(
|
||||
'mb-2 transition-transform duration-200',
|
||||
'group-hover:scale-110',
|
||||
{
|
||||
'text-gray-600': !action.backgroundGradient,
|
||||
'text-white': action.backgroundGradient,
|
||||
}
|
||||
)}>
|
||||
{action.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<span className={clsx(
|
||||
'font-medium text-sm leading-tight',
|
||||
{
|
||||
'text-gray-900': !action.backgroundGradient,
|
||||
'text-white': action.backgroundGradient,
|
||||
}
|
||||
)}>
|
||||
{action.title}
|
||||
</span>
|
||||
|
||||
{/* Description */}
|
||||
{action.description && (
|
||||
<span className={clsx(
|
||||
'text-xs mt-1 opacity-75 leading-tight',
|
||||
{
|
||||
'text-gray-600': !action.backgroundGradient,
|
||||
'text-white': action.backgroundGradient,
|
||||
}
|
||||
)}>
|
||||
{action.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Keyboard shortcut */}
|
||||
{action.shortcut && (
|
||||
<div className={clsx(
|
||||
'absolute bottom-1 right-1 text-xs opacity-50',
|
||||
{
|
||||
'text-gray-500': !action.backgroundGradient,
|
||||
'text-white': action.backgroundGradient,
|
||||
}
|
||||
)}>
|
||||
{action.shortcut}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover effect overlay */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-5 rounded-xl transition-all duration-200" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
QuickActions.displayName = 'QuickActions';
|
||||
|
||||
export default QuickActions;
|
||||
@@ -1,524 +0,0 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Avatar, Badge, Button } from '../../ui';
|
||||
|
||||
export interface ActivityUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
user?: ActivityUser;
|
||||
metadata?: Record<string, any>;
|
||||
status?: ActivityStatus;
|
||||
category?: string;
|
||||
|
||||
// Navigation support
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
|
||||
// Visual styling
|
||||
icon?: React.ReactNode;
|
||||
color?: string;
|
||||
priority?: ActivityPriority;
|
||||
}
|
||||
|
||||
export enum ActivityType {
|
||||
ORDER = 'order',
|
||||
PRODUCTION = 'production',
|
||||
INVENTORY = 'inventory',
|
||||
SALES = 'sales',
|
||||
USER = 'user',
|
||||
SYSTEM = 'system',
|
||||
QUALITY = 'quality',
|
||||
SUPPLIER = 'supplier',
|
||||
FINANCE = 'finance',
|
||||
ALERT = 'alert'
|
||||
}
|
||||
|
||||
export enum ActivityStatus {
|
||||
SUCCESS = 'success',
|
||||
WARNING = 'warning',
|
||||
ERROR = 'error',
|
||||
INFO = 'info',
|
||||
PENDING = 'pending',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export enum ActivityPriority {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent'
|
||||
}
|
||||
|
||||
export interface RecentActivityProps {
|
||||
activities: ActivityItem[];
|
||||
|
||||
// Display configuration
|
||||
maxItems?: number;
|
||||
showTimestamp?: boolean;
|
||||
showUserAvatar?: boolean;
|
||||
showTypeIcons?: boolean;
|
||||
compact?: boolean;
|
||||
|
||||
// Filtering
|
||||
allowFiltering?: boolean;
|
||||
filterTypes?: ActivityType[];
|
||||
defaultFilter?: ActivityType | 'all';
|
||||
|
||||
// Pagination and loading
|
||||
hasMore?: boolean;
|
||||
isLoading?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
|
||||
// Event handlers
|
||||
onActivityClick?: (activity: ActivityItem) => void;
|
||||
onRefresh?: () => void;
|
||||
|
||||
// Accessibility and styling
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
// Spanish activity type labels and icons
|
||||
const ACTIVITY_CONFIG = {
|
||||
[ActivityType.ORDER]: {
|
||||
label: 'Pedidos',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
),
|
||||
color: 'blue',
|
||||
bgColor: 'bg-blue-100',
|
||||
textColor: 'text-blue-600',
|
||||
borderColor: 'border-blue-200'
|
||||
},
|
||||
[ActivityType.PRODUCTION]: {
|
||||
label: 'Producción',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-100',
|
||||
textColor: 'text-orange-600',
|
||||
borderColor: 'border-orange-200'
|
||||
},
|
||||
[ActivityType.INVENTORY]: {
|
||||
label: 'Inventario',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-100',
|
||||
textColor: 'text-purple-600',
|
||||
borderColor: 'border-purple-200'
|
||||
},
|
||||
[ActivityType.SALES]: {
|
||||
label: 'Ventas',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'green',
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-600',
|
||||
borderColor: 'border-green-200'
|
||||
},
|
||||
[ActivityType.USER]: {
|
||||
label: 'Usuarios',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'indigo',
|
||||
bgColor: 'bg-indigo-100',
|
||||
textColor: 'text-indigo-600',
|
||||
borderColor: 'border-indigo-200'
|
||||
},
|
||||
[ActivityType.SYSTEM]: {
|
||||
label: 'Sistema',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-600',
|
||||
borderColor: 'border-gray-200'
|
||||
},
|
||||
[ActivityType.QUALITY]: {
|
||||
label: 'Calidad',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'green',
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-600',
|
||||
borderColor: 'border-green-200'
|
||||
},
|
||||
[ActivityType.SUPPLIER]: {
|
||||
label: 'Proveedores',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'teal',
|
||||
bgColor: 'bg-teal-100',
|
||||
textColor: 'text-teal-600',
|
||||
borderColor: 'border-teal-200'
|
||||
},
|
||||
[ActivityType.FINANCE]: {
|
||||
label: 'Finanzas',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'emerald',
|
||||
bgColor: 'bg-emerald-100',
|
||||
textColor: 'text-emerald-600',
|
||||
borderColor: 'border-emerald-200'
|
||||
},
|
||||
[ActivityType.ALERT]: {
|
||||
label: 'Alertas',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'red',
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-600',
|
||||
borderColor: 'border-red-200'
|
||||
}
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
[ActivityStatus.SUCCESS]: { color: 'green', bgColor: 'bg-green-500' },
|
||||
[ActivityStatus.WARNING]: { color: 'yellow', bgColor: 'bg-yellow-500' },
|
||||
[ActivityStatus.ERROR]: { color: 'red', bgColor: 'bg-red-500' },
|
||||
[ActivityStatus.INFO]: { color: 'blue', bgColor: 'bg-blue-500' },
|
||||
[ActivityStatus.PENDING]: { color: 'gray', bgColor: 'bg-gray-500' },
|
||||
[ActivityStatus.IN_PROGRESS]: { color: 'purple', bgColor: 'bg-purple-500' },
|
||||
[ActivityStatus.CANCELLED]: { color: 'gray', bgColor: 'bg-gray-400' }
|
||||
};
|
||||
|
||||
const formatRelativeTime = (timestamp: string): string => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
|
||||
if (diffInMinutes < 1) {
|
||||
return 'Ahora mismo';
|
||||
} else if (diffInMinutes < 60) {
|
||||
return `Hace ${diffInMinutes} min`;
|
||||
} else if (diffInHours < 24) {
|
||||
return `Hace ${diffInHours}h`;
|
||||
} else if (diffInDays === 1) {
|
||||
return 'Ayer';
|
||||
} else if (diffInDays < 7) {
|
||||
return `Hace ${diffInDays} días`;
|
||||
} else {
|
||||
return date.toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const RecentActivity: React.FC<RecentActivityProps> = ({
|
||||
activities,
|
||||
maxItems = 10,
|
||||
showTimestamp = true,
|
||||
showUserAvatar = true,
|
||||
showTypeIcons = true,
|
||||
compact = false,
|
||||
allowFiltering = true,
|
||||
filterTypes = Object.values(ActivityType),
|
||||
defaultFilter = 'all',
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
onLoadMore,
|
||||
onActivityClick,
|
||||
onRefresh,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'Actividad reciente',
|
||||
emptyMessage = 'No hay actividad reciente'
|
||||
}) => {
|
||||
const [activeFilter, setActiveFilter] = useState<ActivityType | 'all'>(defaultFilter);
|
||||
|
||||
const filteredActivities = useMemo(() => {
|
||||
let filtered = activities;
|
||||
|
||||
if (activeFilter !== 'all') {
|
||||
filtered = activities.filter(activity => activity.type === activeFilter);
|
||||
}
|
||||
|
||||
return filtered.slice(0, maxItems);
|
||||
}, [activities, activeFilter, maxItems]);
|
||||
|
||||
const handleActivityClick = useCallback((activity: ActivityItem) => {
|
||||
onActivityClick?.(activity);
|
||||
if (activity.onClick) {
|
||||
activity.onClick();
|
||||
}
|
||||
}, [onActivityClick]);
|
||||
|
||||
const renderActivityItem = (activity: ActivityItem) => {
|
||||
const config = ACTIVITY_CONFIG[activity.type] || {
|
||||
label: 'Actividad',
|
||||
icon: <div className="w-4 h-4 bg-gray-300 rounded" />,
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-600',
|
||||
borderColor: 'border-gray-200'
|
||||
};
|
||||
const statusConfig = activity.status ? STATUS_CONFIG[activity.status] : null;
|
||||
|
||||
const itemClasses = clsx(
|
||||
'group relative flex items-start gap-3 p-3 rounded-lg transition-all duration-200',
|
||||
'hover:bg-gray-50 hover:shadow-sm',
|
||||
{
|
||||
'cursor-pointer': activity.onClick || activity.href,
|
||||
'p-2': compact,
|
||||
'border-l-4': !compact,
|
||||
[config.borderColor]: !compact
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className={itemClasses}
|
||||
onClick={activity.onClick || activity.href ? () => handleActivityClick(activity) : undefined}
|
||||
role={activity.onClick || activity.href ? 'button' : undefined}
|
||||
tabIndex={activity.onClick || activity.href ? 0 : undefined}
|
||||
>
|
||||
{/* Timeline indicator */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{showTypeIcons && (
|
||||
<div className={clsx(
|
||||
'flex items-center justify-center rounded-full',
|
||||
{
|
||||
'w-8 h-8': !compact,
|
||||
'w-6 h-6': compact
|
||||
},
|
||||
config.bgColor,
|
||||
config.textColor
|
||||
)}>
|
||||
{activity.icon || config.icon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status indicator */}
|
||||
{activity.status && statusConfig && (
|
||||
<div className={clsx(
|
||||
'absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-white',
|
||||
statusConfig.bgColor
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={clsx(
|
||||
'font-medium text-gray-900 truncate',
|
||||
{
|
||||
'text-sm': compact,
|
||||
'text-base': !compact
|
||||
}
|
||||
)}>
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className={clsx(
|
||||
'text-gray-600 mt-1',
|
||||
{
|
||||
'text-xs line-clamp-1': compact,
|
||||
'text-sm line-clamp-2': !compact
|
||||
}
|
||||
)}>
|
||||
{activity.description}
|
||||
</p>
|
||||
|
||||
{/* User info */}
|
||||
{activity.user && showUserAvatar && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Avatar
|
||||
size="xs"
|
||||
src={activity.user.avatar}
|
||||
alt={activity.user.name}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">
|
||||
{activity.user.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
{showTimestamp && (
|
||||
<time className={clsx(
|
||||
'text-gray-500 flex-shrink-0',
|
||||
{
|
||||
'text-xs': compact,
|
||||
'text-sm': !compact
|
||||
}
|
||||
)}>
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('space-y-4', className)} aria-label={ariaLabel}>
|
||||
{/* Filters */}
|
||||
{allowFiltering && filterTypes.length > 1 && (
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-gray-200">
|
||||
<Button
|
||||
variant={activeFilter === 'all' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveFilter('all')}
|
||||
>
|
||||
Todos
|
||||
</Button>
|
||||
{filterTypes.map((type) => {
|
||||
const config = ACTIVITY_CONFIG[type] || {
|
||||
label: 'Actividad',
|
||||
icon: <div className="w-4 h-4 bg-gray-300 rounded" />,
|
||||
color: 'gray',
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-600',
|
||||
borderColor: 'border-gray-200'
|
||||
};
|
||||
const count = activities.filter(a => a.type === type).length;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={type}
|
||||
variant={activeFilter === type ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveFilter(type)}
|
||||
leftIcon={config.icon}
|
||||
>
|
||||
{config.label}
|
||||
{count > 0 && (
|
||||
<Badge size="xs" className="ml-1">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{onRefresh && (
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
aria-label="Actualizar actividad"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activity list */}
|
||||
<div className="space-y-1">
|
||||
{filteredActivities.map(renderActivityItem)}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && onLoadMore && !isLoading && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onLoadMore}
|
||||
>
|
||||
Ver más actividad
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RecentActivity.displayName = 'RecentActivity';
|
||||
|
||||
export default RecentActivity;
|
||||
Reference in New Issue
Block a user