Refactor components and modals
This commit is contained in:
201
frontend/src/components/ui/EmptyState/EmptyState.tsx
Normal file
201
frontend/src/components/ui/EmptyState/EmptyState.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button } from '../../ui';
|
||||
import type { ButtonProps } from '../../ui';
|
||||
|
||||
export interface EmptyStateAction {
|
||||
/** Texto del botón */
|
||||
label: string;
|
||||
/** Función al hacer click */
|
||||
onClick: () => void;
|
||||
/** Variante del botón */
|
||||
variant?: ButtonProps['variant'];
|
||||
/** Icono del botón */
|
||||
icon?: React.ReactNode;
|
||||
/** Mostrar loading en el botón */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Icono o ilustración */
|
||||
icon?: React.ReactNode;
|
||||
/** Título del estado vacío */
|
||||
title?: string;
|
||||
/** Descripción del estado vacío */
|
||||
description?: string;
|
||||
/** Variante del estado vacío */
|
||||
variant?: 'no-data' | 'error' | 'search' | 'filter';
|
||||
/** Acción principal */
|
||||
primaryAction?: EmptyStateAction;
|
||||
/** Acción secundaria */
|
||||
secondaryAction?: EmptyStateAction;
|
||||
/** Componente personalizado para ilustración */
|
||||
illustration?: React.ReactNode;
|
||||
/** Clase CSS adicional */
|
||||
className?: string;
|
||||
/** Tamaño del componente */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
// Iconos SVG por defecto para cada variante
|
||||
const DefaultIcons = {
|
||||
'no-data': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
),
|
||||
'error': (
|
||||
<svg className="w-16 h-16 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
'search': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
'filter': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.707A1 1 0 013 7V4z" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// Mensajes por defecto en español para cada variante
|
||||
const DefaultMessages = {
|
||||
'no-data': {
|
||||
title: 'No hay datos disponibles',
|
||||
description: 'Aún no se han registrado elementos en esta sección. Comience agregando su primer elemento.'
|
||||
},
|
||||
'error': {
|
||||
title: 'Ha ocurrido un error',
|
||||
description: 'No se pudieron cargar los datos. Por favor, inténtelo de nuevo más tarde.'
|
||||
},
|
||||
'search': {
|
||||
title: 'Sin resultados de búsqueda',
|
||||
description: 'No se encontraron elementos que coincidan con su búsqueda. Intente con términos diferentes.'
|
||||
},
|
||||
'filter': {
|
||||
title: 'Sin resultados con estos filtros',
|
||||
description: 'No se encontraron elementos que coincidan con los filtros aplicados. Ajuste los filtros para ver más resultados.'
|
||||
}
|
||||
};
|
||||
|
||||
const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
variant = 'no-data',
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
illustration,
|
||||
className,
|
||||
size = 'md',
|
||||
...props
|
||||
}, ref) => {
|
||||
const defaultMessage = DefaultMessages[variant];
|
||||
const displayTitle = title || defaultMessage.title;
|
||||
const displayDescription = description || defaultMessage.description;
|
||||
const displayIcon = illustration || icon || DefaultIcons[variant];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'py-8 px-4',
|
||||
md: 'py-12 px-6',
|
||||
lg: 'py-20 px-8'
|
||||
};
|
||||
|
||||
const titleSizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-xl',
|
||||
lg: 'text-2xl'
|
||||
};
|
||||
|
||||
const descriptionSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
};
|
||||
|
||||
const iconContainerClasses = {
|
||||
sm: 'mb-4',
|
||||
md: 'mb-6',
|
||||
lg: 'mb-8'
|
||||
};
|
||||
|
||||
const containerClasses = clsx(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
'min-h-[200px] max-w-md mx-auto',
|
||||
sizeClasses[size],
|
||||
className
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={containerClasses}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
{...props}
|
||||
>
|
||||
{/* Icono o Ilustración */}
|
||||
{displayIcon && (
|
||||
<div className={clsx('flex-shrink-0', iconContainerClasses[size])}>
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Título */}
|
||||
{displayTitle && (
|
||||
<h3 className={clsx(
|
||||
'font-semibold text-text-primary mb-2',
|
||||
titleSizeClasses[size]
|
||||
)}>
|
||||
{displayTitle}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Descripción */}
|
||||
{displayDescription && (
|
||||
<p className={clsx(
|
||||
'text-text-secondary mb-6 leading-relaxed',
|
||||
descriptionSizeClasses[size]
|
||||
)}>
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Acciones */}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
{primaryAction && (
|
||||
<Button
|
||||
variant={primaryAction.variant || 'primary'}
|
||||
onClick={primaryAction.onClick}
|
||||
isLoading={primaryAction.isLoading}
|
||||
leftIcon={primaryAction.icon}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
variant={secondaryAction.variant || 'outline'}
|
||||
onClick={secondaryAction.onClick}
|
||||
isLoading={secondaryAction.isLoading}
|
||||
leftIcon={secondaryAction.icon}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
|
||||
export default EmptyState;
|
||||
Reference in New Issue
Block a user