ADD new frontend
This commit is contained in:
298
frontend/src/components/domain/dashboard/DashboardCard.tsx
Normal file
298
frontend/src/components/domain/dashboard/DashboardCard.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
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-[var(--color-info)]/20 hover:border-blue-300',
|
||||
chart: 'bg-white border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
||||
list: 'bg-white border-[var(--border-primary)] hover:border-[var(--border-secondary)]',
|
||||
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-[var(--bg-quaternary)] rounded animate-pulse" />
|
||||
<div className="h-12 bg-[var(--bg-quaternary)] rounded animate-pulse" />
|
||||
<div className="h-4 bg-[var(--bg-quaternary)] rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
);
|
||||
case 'chart':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="h-6 bg-[var(--bg-quaternary)] rounded animate-pulse w-1/2" />
|
||||
<div className="h-32 bg-[var(--bg-quaternary)] 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-[var(--bg-quaternary)] rounded-full animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 bg-[var(--bg-quaternary)] rounded animate-pulse" />
|
||||
<div className="h-3 bg-[var(--bg-quaternary)] rounded animate-pulse w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div className="h-24 bg-[var(--bg-quaternary)] 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-[var(--text-secondary)] 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-[var(--text-tertiary)] 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-[var(--text-tertiary)]">{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-[var(--text-secondary)]">
|
||||
{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-[var(--text-primary)] truncate">
|
||||
{title}
|
||||
</h3>
|
||||
{badge && (
|
||||
<Badge
|
||||
variant={badgeVariant}
|
||||
size="sm"
|
||||
shape="pill"
|
||||
>
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] 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-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
<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-[var(--text-secondary)]">
|
||||
{footerText}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{footerActions}
|
||||
</div>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
DashboardCard.displayName = 'DashboardCard';
|
||||
|
||||
export default DashboardCard;
|
||||
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
122
frontend/src/components/domain/dashboard/DashboardGrid.tsx
Normal file
122
frontend/src/components/domain/dashboard/DashboardGrid.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
Package,
|
||||
AlertCircle,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import { Card, Badge } from '../../ui';
|
||||
|
||||
export const DashboardGrid: React.FC = () => {
|
||||
// Simple placeholder implementation - can be enhanced later
|
||||
const mockData = {
|
||||
sales_today: 1247,
|
||||
sales_change: 12.5,
|
||||
products_sold: 45,
|
||||
products_change: 8.3,
|
||||
active_alerts: 2,
|
||||
urgent_alerts: 1
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* KPI Cards */}
|
||||
<KPICard
|
||||
title="Ventas Hoy"
|
||||
value={`€${mockData.sales_today}`}
|
||||
change={mockData.sales_change}
|
||||
icon={<DollarSign className="w-5 h-5" />}
|
||||
color="green"
|
||||
/>
|
||||
|
||||
<KPICard
|
||||
title="Productos Vendidos"
|
||||
value={mockData.products_sold}
|
||||
change={mockData.products_change}
|
||||
icon={<Package className="w-5 h-5" />}
|
||||
color="blue"
|
||||
/>
|
||||
|
||||
<KPICard
|
||||
title="Alertas Activas"
|
||||
value={mockData.active_alerts}
|
||||
urgent={mockData.urgent_alerts}
|
||||
icon={<AlertCircle className="w-5 h-5" />}
|
||||
color="orange"
|
||||
/>
|
||||
|
||||
<Card className="bg-gradient-to-br from-blue-50 to-cyan-50 border-[var(--color-info)]/20">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<AlertCircle className="w-8 h-8 text-blue-500" />
|
||||
<Badge variant="secondary">Sistema</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold">En línea</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Todo funcionando</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</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-[var(--color-success)] border-green-200',
|
||||
blue: 'bg-[var(--color-info)]/5 text-[var(--color-info)] border-[var(--color-info)]/20',
|
||||
orange: 'bg-orange-50 text-[var(--color-primary)] border-orange-200',
|
||||
red: 'bg-red-50 text-[var(--color-error)] 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-[var(--color-success)]/10' : color === 'blue' ? 'bg-[var(--color-info)]/10' : color === 'orange' ? 'bg-[var(--color-primary)]/10' : 'bg-[var(--color-error)]/10'}`}>
|
||||
{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-[var(--text-secondary)]">{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-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
||||
{Math.abs(change)}%
|
||||
</span>
|
||||
<span className="text-[var(--text-tertiary)]">vs ayer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardGrid;
|
||||
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
502
frontend/src/components/domain/dashboard/KPIWidget.tsx
Normal file
502
frontend/src/components/domain/dashboard/KPIWidget.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Badge } from '../../ui';
|
||||
import DashboardCard from './DashboardCard';
|
||||
|
||||
export interface KPIValue {
|
||||
current: number;
|
||||
previous?: number;
|
||||
target?: number;
|
||||
format: 'currency' | 'number' | 'percentage';
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface KPITrend {
|
||||
direction: 'up' | 'down' | 'stable';
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
comparisonPeriod: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface KPIThreshold {
|
||||
excellent: number;
|
||||
good: number;
|
||||
warning: number;
|
||||
critical: number;
|
||||
}
|
||||
|
||||
export interface SparklineDataPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface KPIWidgetProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
value: KPIValue;
|
||||
trend?: KPITrend;
|
||||
|
||||
// Visual configuration
|
||||
icon?: React.ReactNode;
|
||||
color?: 'blue' | 'green' | 'orange' | 'red' | 'purple' | 'indigo' | 'teal';
|
||||
variant?: 'default' | 'compact' | 'detailed' | 'chart';
|
||||
|
||||
// Chart data
|
||||
sparklineData?: SparklineDataPoint[];
|
||||
showSparkline?: boolean;
|
||||
|
||||
// Thresholds and status
|
||||
thresholds?: KPIThreshold;
|
||||
status?: 'excellent' | 'good' | 'warning' | 'critical' | 'neutral';
|
||||
|
||||
// Comparison and context
|
||||
comparisonLabel?: string;
|
||||
contextInfo?: string;
|
||||
|
||||
// Interactive features
|
||||
isLoading?: boolean;
|
||||
onRefresh?: () => void;
|
||||
onClick?: () => void;
|
||||
|
||||
// Accessibility and styling
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
// Predefined bakery KPI configurations
|
||||
export const BAKERY_KPI_CONFIGS = {
|
||||
dailyRevenue: {
|
||||
title: 'Ingresos Hoy',
|
||||
subtitle: 'Ventas del día actual',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'green' as const,
|
||||
format: 'currency' as const
|
||||
},
|
||||
orderCount: {
|
||||
title: 'Pedidos',
|
||||
subtitle: 'Órdenes procesadas hoy',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
),
|
||||
color: 'blue' as const,
|
||||
format: 'number' as const
|
||||
},
|
||||
productivity: {
|
||||
title: 'Productividad',
|
||||
subtitle: 'Unidades producidas por hora',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
color: 'orange' as const,
|
||||
format: 'number' as const,
|
||||
suffix: '/h'
|
||||
},
|
||||
stockLevel: {
|
||||
title: 'Nivel Stock',
|
||||
subtitle: 'Porcentaje de stock disponible',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
color: 'purple' as const,
|
||||
format: 'percentage' as const
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (value: number, format: KPIValue['format'], prefix?: string, suffix?: string): string => {
|
||||
let formatted: string;
|
||||
|
||||
switch (format) {
|
||||
case 'currency':
|
||||
formatted = new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
}).format(value);
|
||||
break;
|
||||
|
||||
case 'percentage':
|
||||
formatted = new Intl.NumberFormat('es-ES', {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1
|
||||
}).format(value / 100);
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
default:
|
||||
formatted = new Intl.NumberFormat('es-ES').format(value);
|
||||
break;
|
||||
}
|
||||
|
||||
return `${prefix || ''}${formatted}${suffix || ''}`;
|
||||
};
|
||||
|
||||
const calculateTrend = (current: number, previous: number): KPITrend => {
|
||||
// Handle undefined or null values
|
||||
if (current == null || previous == null ||
|
||||
typeof current !== 'number' || typeof previous !== 'number' ||
|
||||
isNaN(current) || isNaN(previous)) {
|
||||
return {
|
||||
direction: 'stable',
|
||||
value: 0,
|
||||
isPositive: true,
|
||||
comparisonPeriod: 'vs período anterior'
|
||||
};
|
||||
}
|
||||
|
||||
const change = current - previous;
|
||||
const percentChange = previous !== 0 ? (change / previous) * 100 : 0;
|
||||
|
||||
return {
|
||||
direction: change > 0 ? 'up' : change < 0 ? 'down' : 'stable',
|
||||
value: Math.abs(percentChange),
|
||||
isPositive: change >= 0,
|
||||
comparisonPeriod: 'vs período anterior'
|
||||
};
|
||||
};
|
||||
|
||||
const getStatusColor = (status: KPIWidgetProps['status']) => {
|
||||
switch (status) {
|
||||
case 'excellent':
|
||||
return {
|
||||
bg: 'bg-[var(--color-success)]/10',
|
||||
text: 'text-[var(--color-success)]',
|
||||
border: 'border-[var(--color-success)]/20',
|
||||
icon: 'text-[var(--color-success)]'
|
||||
};
|
||||
case 'good':
|
||||
return {
|
||||
bg: 'bg-[var(--color-info)]/10',
|
||||
text: 'text-[var(--color-info)]',
|
||||
border: 'border-[var(--color-info)]/20',
|
||||
icon: 'text-[var(--color-info)]'
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-[var(--color-warning)]/10',
|
||||
text: 'text-[var(--color-warning)]',
|
||||
border: 'border-[var(--color-warning)]/20',
|
||||
icon: 'text-[var(--color-warning)]'
|
||||
};
|
||||
case 'critical':
|
||||
return {
|
||||
bg: 'bg-[var(--color-error)]/10',
|
||||
text: 'text-[var(--color-error)]',
|
||||
border: 'border-[var(--color-error)]/20',
|
||||
icon: 'text-[var(--color-error)]'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-[var(--bg-tertiary)]',
|
||||
text: 'text-[var(--text-secondary)]',
|
||||
border: 'border-[var(--border-primary)]',
|
||||
icon: 'text-[var(--text-tertiary)]'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const SimpleSparkline: React.FC<{ data: SparklineDataPoint[]; color: string }> = ({ data, color }) => {
|
||||
const max = Math.max(...data.map(d => d.value));
|
||||
const min = Math.min(...data.map(d => d.value));
|
||||
const range = max - min;
|
||||
|
||||
const points = data.map((point, index) => {
|
||||
const x = (index / (data.length - 1)) * 100;
|
||||
const y = range === 0 ? 50 : ((max - point.value) / range) * 100;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
|
||||
const colorClasses = {
|
||||
blue: 'stroke-[var(--color-info)]',
|
||||
green: 'stroke-[var(--color-success)]',
|
||||
orange: 'stroke-[var(--color-primary)]',
|
||||
red: 'stroke-[var(--color-error)]',
|
||||
purple: 'stroke-[var(--color-info)]',
|
||||
indigo: 'stroke-[var(--color-info)]',
|
||||
teal: 'stroke-[var(--color-success)]'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-12 mt-2">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="overflow-visible"
|
||||
>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
className={clsx(colorClasses[color as keyof typeof colorClasses] || colorClasses.blue)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const KPIWidget: React.FC<KPIWidgetProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
value,
|
||||
trend,
|
||||
icon,
|
||||
color = 'blue',
|
||||
variant = 'default',
|
||||
sparklineData,
|
||||
showSparkline = false,
|
||||
thresholds,
|
||||
status,
|
||||
comparisonLabel,
|
||||
contextInfo,
|
||||
isLoading = false,
|
||||
onRefresh,
|
||||
onClick,
|
||||
className,
|
||||
'aria-label': ariaLabel
|
||||
}) => {
|
||||
// Calculate trend if not provided
|
||||
const calculatedTrend = useMemo(() => {
|
||||
if (trend) return trend;
|
||||
if (value.previous !== undefined) {
|
||||
return calculateTrend(value.current, value.previous);
|
||||
}
|
||||
return null;
|
||||
}, [trend, value.current, value.previous]);
|
||||
|
||||
// Determine status based on thresholds
|
||||
const calculatedStatus = useMemo(() => {
|
||||
if (status) return status;
|
||||
if (!thresholds) return 'neutral';
|
||||
|
||||
const { current } = value;
|
||||
if (current >= thresholds.excellent) return 'excellent';
|
||||
if (current >= thresholds.good) return 'good';
|
||||
if (current >= thresholds.warning) return 'warning';
|
||||
return 'critical';
|
||||
}, [status, thresholds, value.current]);
|
||||
|
||||
const statusStyles = getStatusColor(calculatedStatus);
|
||||
const formattedValue = formatValue(value.current, value.format, value.prefix, value.suffix);
|
||||
const formattedTarget = value.target ? formatValue(value.target, value.format, value.prefix, value.suffix) : null;
|
||||
|
||||
const colorClasses = {
|
||||
blue: 'text-[var(--color-info)]',
|
||||
green: 'text-[var(--color-success)]',
|
||||
orange: 'text-[var(--color-primary)]',
|
||||
red: 'text-[var(--color-error)]',
|
||||
purple: 'text-[var(--color-info)]',
|
||||
indigo: 'text-[var(--color-info)]',
|
||||
teal: 'text-[var(--color-success)]'
|
||||
};
|
||||
|
||||
const renderTrendIcon = (direction: KPITrend['direction']) => {
|
||||
const iconClass = calculatedTrend?.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]';
|
||||
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return (
|
||||
<svg className={clsx('w-4 h-4', iconClass)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 17l9.2-9.2M17 17V7H7" />
|
||||
</svg>
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
<svg className={clsx('w-4 h-4', iconClass)} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 7l-9.2 9.2M7 7v10h10" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className="w-4 h-4 text-[var(--text-tertiary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCompactVariant = () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{icon && (
|
||||
<div className={clsx('p-2 rounded-lg', statusStyles.bg)}>
|
||||
<div className={clsx('w-5 h-5', statusStyles.icon)}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{title}</p>
|
||||
<p className="text-2xl font-bold text-[var(--text-primary)]">{formattedValue}</p>
|
||||
</div>
|
||||
</div>
|
||||
{calculatedTrend && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderTrendIcon(calculatedTrend.direction)}
|
||||
<span className={clsx(
|
||||
'text-sm font-medium',
|
||||
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
||||
)}>
|
||||
{(calculatedTrend.value || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDetailedVariant = () => (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{icon && (
|
||||
<div className={clsx('p-3 rounded-xl', statusStyles.bg)}>
|
||||
<div className={clsx('w-6 h-6', statusStyles.icon)}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{calculatedStatus !== 'neutral' && (
|
||||
<Badge variant={calculatedStatus === 'excellent' ? 'success' : calculatedStatus === 'critical' ? 'error' : 'warning'}>
|
||||
{calculatedStatus === 'excellent' ? 'Excelente' :
|
||||
calculatedStatus === 'good' ? 'Bueno' :
|
||||
calculatedStatus === 'warning' ? 'Atención' : 'Crítico'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value and trend */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline space-x-3">
|
||||
<span className="text-3xl font-bold text-[var(--text-primary)]">{formattedValue}</span>
|
||||
{calculatedTrend && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderTrendIcon(calculatedTrend.direction)}
|
||||
<span className={clsx(
|
||||
'text-sm font-medium',
|
||||
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
||||
)}>
|
||||
{(calculatedTrend.value || 0).toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">
|
||||
{calculatedTrend.comparisonPeriod}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formattedTarget && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Objetivo: <span className="font-medium">{formattedTarget}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{contextInfo && (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">{contextInfo}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sparkline chart */}
|
||||
{showSparkline && sparklineData && sparklineData.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs text-[var(--text-tertiary)] mb-2">Tendencia últimos 7 días</p>
|
||||
<SimpleSparkline data={sparklineData} color={color} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDefaultVariant = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
{icon && (
|
||||
<div className={clsx('w-8 h-8', colorClasses[color])}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)]">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-2xl font-bold text-[var(--text-primary)]">{formattedValue}</span>
|
||||
{calculatedTrend && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{renderTrendIcon(calculatedTrend.direction)}
|
||||
<span className={clsx(
|
||||
'text-sm',
|
||||
calculatedTrend.isPositive ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'
|
||||
)}>
|
||||
{(calculatedTrend.value || 0).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{comparisonLabel && (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">{comparisonLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (variant) {
|
||||
case 'compact':
|
||||
return renderCompactVariant();
|
||||
case 'detailed':
|
||||
return renderDetailedVariant();
|
||||
case 'chart':
|
||||
return renderDetailedVariant();
|
||||
default:
|
||||
return renderDefaultVariant();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardCard
|
||||
variant="metric"
|
||||
isLoading={isLoading}
|
||||
onRefresh={onRefresh}
|
||||
onClick={onClick}
|
||||
interactive={!!onClick}
|
||||
className={className}
|
||||
aria-label={ariaLabel || `${title}: ${formattedValue}`}
|
||||
>
|
||||
{renderContent()}
|
||||
</DashboardCard>
|
||||
);
|
||||
};
|
||||
|
||||
KPIWidget.displayName = 'KPIWidget';
|
||||
|
||||
export default KPIWidget;
|
||||
382
frontend/src/components/domain/dashboard/QuickActions.tsx
Normal file
382
frontend/src/components/domain/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
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-[var(--text-tertiary)]">
|
||||
<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-[var(--border-primary)] 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-[var(--bg-secondary)]': !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-[var(--text-secondary)]': !action.backgroundGradient,
|
||||
'text-white': action.backgroundGradient,
|
||||
}
|
||||
)}>
|
||||
{action.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<span className={clsx(
|
||||
'font-medium text-sm leading-tight',
|
||||
{
|
||||
'text-[var(--text-primary)]': !action.backgroundGradient,
|
||||
'text-white': action.backgroundGradient,
|
||||
}
|
||||
)}>
|
||||
{action.title}
|
||||
</span>
|
||||
|
||||
{/* Description */}
|
||||
{action.description && (
|
||||
<span className={clsx(
|
||||
'text-xs mt-1 opacity-75 leading-tight',
|
||||
{
|
||||
'text-[var(--text-secondary)]': !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-[var(--text-tertiary)]': !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;
|
||||
382
frontend/src/components/domain/dashboard/QuickActions.tsx.backup
Normal file
382
frontend/src/components/domain/dashboard/QuickActions.tsx.backup
Normal file
@@ -0,0 +1,382 @@
|
||||
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;
|
||||
524
frontend/src/components/domain/dashboard/RecentActivity.tsx
Normal file
524
frontend/src/components/domain/dashboard/RecentActivity.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
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-[var(--color-info)]/10',
|
||||
textColor: 'text-[var(--color-info)]',
|
||||
borderColor: 'border-[var(--color-info)]/20'
|
||||
},
|
||||
[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-[var(--color-primary)]/10',
|
||||
textColor: 'text-[var(--color-primary)]',
|
||||
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-[var(--color-success)]/10',
|
||||
textColor: 'text-[var(--color-success)]',
|
||||
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-[var(--bg-tertiary)]',
|
||||
textColor: 'text-[var(--text-secondary)]',
|
||||
borderColor: 'border-[var(--border-primary)]'
|
||||
},
|
||||
[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-[var(--color-success)]/10',
|
||||
textColor: 'text-[var(--color-success)]',
|
||||
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-[var(--color-error)]/10',
|
||||
textColor: 'text-[var(--color-error)]',
|
||||
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-[var(--color-info)]/50' },
|
||||
[ActivityStatus.PENDING]: { color: 'gray', bgColor: 'bg-[var(--bg-secondary)]0' },
|
||||
[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-[var(--bg-tertiary)]',
|
||||
textColor: 'text-[var(--text-secondary)]',
|
||||
borderColor: 'border-[var(--border-primary)]'
|
||||
};
|
||||
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-[var(--bg-secondary)] 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-[var(--text-primary)] truncate',
|
||||
{
|
||||
'text-sm': compact,
|
||||
'text-base': !compact
|
||||
}
|
||||
)}>
|
||||
{activity.title}
|
||||
</p>
|
||||
<p className={clsx(
|
||||
'text-[var(--text-secondary)] 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-[var(--text-tertiary)]">
|
||||
{activity.user.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
{showTimestamp && (
|
||||
<time className={clsx(
|
||||
'text-[var(--text-tertiary)] 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-[var(--text-tertiary)]">
|
||||
<div className="text-[var(--text-tertiary)] 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-[var(--border-primary)]">
|
||||
<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-[var(--bg-tertiary)]',
|
||||
textColor: 'text-[var(--text-secondary)]',
|
||||
borderColor: 'border-[var(--border-primary)]'
|
||||
};
|
||||
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;
|
||||
@@ -0,0 +1,524 @@
|
||||
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;
|
||||
88
frontend/src/components/domain/dashboard/index.ts
Normal file
88
frontend/src/components/domain/dashboard/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Dashboard Domain Components - Bakery Management System
|
||||
|
||||
// Core dashboard components
|
||||
export { default as DashboardCard } from './DashboardCard';
|
||||
export { default as DashboardGrid } from './DashboardGrid';
|
||||
export { default as QuickActions, BAKERY_QUICK_ACTIONS } from './QuickActions';
|
||||
export { default as RecentActivity } from './RecentActivity';
|
||||
export { default as KPIWidget, BAKERY_KPI_CONFIGS } from './KPIWidget';
|
||||
|
||||
// Export types for external usage
|
||||
export type { DashboardCardProps } from './DashboardCard';
|
||||
export type {
|
||||
QuickActionsProps,
|
||||
QuickAction
|
||||
} from './QuickActions';
|
||||
export type {
|
||||
RecentActivityProps,
|
||||
ActivityItem,
|
||||
ActivityUser,
|
||||
ActivityType,
|
||||
ActivityStatus,
|
||||
ActivityPriority
|
||||
} from './RecentActivity';
|
||||
export type {
|
||||
KPIWidgetProps,
|
||||
KPIValue,
|
||||
KPITrend,
|
||||
KPIThreshold,
|
||||
SparklineDataPoint
|
||||
} from './KPIWidget';
|
||||
|
||||
// Re-export enums for convenience
|
||||
export {
|
||||
ActivityType,
|
||||
ActivityStatus,
|
||||
ActivityPriority
|
||||
} from './RecentActivity';
|
||||
|
||||
/**
|
||||
* Dashboard Components Usage Examples:
|
||||
*
|
||||
* import {
|
||||
* DashboardCard,
|
||||
* QuickActions,
|
||||
* BAKERY_QUICK_ACTIONS,
|
||||
* RecentActivity,
|
||||
* KPIWidget,
|
||||
* BAKERY_KPI_CONFIGS
|
||||
* } from '@/components/domain/dashboard';
|
||||
*
|
||||
* // Basic dashboard card
|
||||
* <DashboardCard
|
||||
* title="Ventas Hoy"
|
||||
* variant="metric"
|
||||
* isLoading={false}
|
||||
* >
|
||||
* Content goes here
|
||||
* </DashboardCard>
|
||||
*
|
||||
* // Quick actions with predefined bakery actions
|
||||
* <QuickActions
|
||||
* actions={BAKERY_QUICK_ACTIONS}
|
||||
* columns={3}
|
||||
* userRole="baker"
|
||||
* userPermissions={['orders.create', 'inventory.view']}
|
||||
* />
|
||||
*
|
||||
* // Recent activity feed
|
||||
* <RecentActivity
|
||||
* activities={activities}
|
||||
* showTimestamp={true}
|
||||
* allowFiltering={true}
|
||||
* maxItems={10}
|
||||
* />
|
||||
*
|
||||
* // KPI widget with Spanish formatting
|
||||
* <KPIWidget
|
||||
* title="Ingresos Diarios"
|
||||
* value={{
|
||||
* current: 1250.50,
|
||||
* previous: 1100.00,
|
||||
* format: 'currency'
|
||||
* }}
|
||||
* color="green"
|
||||
* variant="detailed"
|
||||
* showSparkline={true}
|
||||
* />
|
||||
*/
|
||||
Reference in New Issue
Block a user