Improve the frontend modals
This commit is contained in:
@@ -260,6 +260,13 @@ export interface AddModalProps {
|
||||
|
||||
// Field change callback for dynamic form behavior
|
||||
onFieldChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// Wait-for-refetch support (Option A approach)
|
||||
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
||||
isRefetching?: boolean; // External refetch state (from React Query)
|
||||
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
||||
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
||||
showSuccessState?: boolean; // Show brief success state before closing (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,9 +296,18 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
validationErrors = EMPTY_VALIDATION_ERRORS,
|
||||
onValidationError,
|
||||
onFieldChange,
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch = false,
|
||||
isRefetching = false,
|
||||
onSaveComplete,
|
||||
refetchTimeout = 3000,
|
||||
showSuccessState = true,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track if we've initialized the form data for this modal session
|
||||
@@ -337,6 +353,15 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: string | number) => {
|
||||
// Debug logging for ingredients field
|
||||
if (fieldName === 'ingredients') {
|
||||
console.log('=== AddModal Field Change (ingredients) ===');
|
||||
console.log('New value:', value);
|
||||
console.log('Type:', typeof value);
|
||||
console.log('Is array:', Array.isArray(value));
|
||||
console.log('==========================================');
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
@@ -406,11 +431,62 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Execute the save mutation
|
||||
await onSave(formData);
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become true then false, or timeout
|
||||
const startTime = Date.now();
|
||||
const checkRefetch = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Timeout reached
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached, proceeding anyway');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch completed (was true, now false)
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
await checkRefetch();
|
||||
setIsWaitingForRefetch(false);
|
||||
|
||||
// Show success state briefly
|
||||
if (showSuccessState) {
|
||||
setShowSuccess(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal after save (and optional refetch) completes
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving form:', error);
|
||||
// Don't close modal on error - let the parent handle error display
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
setShowSuccess(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,7 +517,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(value)}
|
||||
value={value}
|
||||
onChange={(newValue) => handleFieldChange(field.name, newValue)}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
@@ -586,14 +662,15 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const StatusIcon = defaultStatusIndicator.icon;
|
||||
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
closeOnOverlayClick={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
@@ -601,8 +678,13 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${defaultStatusIndicator.color}15` }}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
||||
defaultStatusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
||||
} ${defaultStatusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${defaultStatusIndicator.color}15`,
|
||||
...(defaultStatusIndicator.isCritical && { ringColor: defaultStatusIndicator.color })
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
@@ -612,25 +694,13 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
{/* Title and subtitle */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: defaultStatusIndicator.color }}
|
||||
>
|
||||
{defaultStatusIndicator.text}
|
||||
{defaultStatusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{defaultStatusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -642,11 +712,28 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
{(loading || isSaving || isWaitingForRefetch || showSuccess) && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.saving', 'Guardando...')}</span>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{!showSuccess ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{isWaitingForRefetch
|
||||
? t('common:modals.refreshing', 'Actualizando datos...')
|
||||
: isSaving
|
||||
? t('common:modals.saving', 'Guardando...')
|
||||
: t('common:modals.loading', 'Cargando...')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-green-500 text-4xl">✓</div>
|
||||
<span className="text-[var(--text-secondary)] font-medium">
|
||||
{t('common:modals.success', 'Guardado correctamente')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -703,7 +790,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
disabled={loading || isSaving || isWaitingForRefetch}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
@@ -711,10 +798,10 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
disabled={loading || isSaving || isWaitingForRefetch}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{loading ? (
|
||||
{loading || isSaving || isWaitingForRefetch ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
t('common:modals.actions.save', 'Guardar')
|
||||
|
||||
@@ -76,6 +76,12 @@ export interface EditViewModalProps {
|
||||
totalSteps?: number; // Total steps in workflow
|
||||
validationErrors?: Record<string, string>; // Field validation errors
|
||||
onValidationError?: (errors: Record<string, string>) => void; // Validation error handler
|
||||
|
||||
// Wait-for-refetch support (Option A approach)
|
||||
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
||||
isRefetching?: boolean; // External refetch state (from React Query)
|
||||
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
||||
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,9 +345,16 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
totalSteps,
|
||||
validationErrors = {},
|
||||
onValidationError,
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch = false,
|
||||
isRefetching = false,
|
||||
onSaveComplete,
|
||||
refetchTimeout = 3000,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = React.useState(false);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
@@ -352,11 +365,59 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
if (!onSave) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Execute the save mutation
|
||||
await onSave();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become true then false, or timeout
|
||||
const startTime = Date.now();
|
||||
const checkRefetch = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Timeout reached
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached, proceeding anyway');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch completed (was true, now false)
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
await checkRefetch();
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
|
||||
// Switch to view mode after save (and optional refetch) completes
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
// Don't switch mode on error
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -371,30 +432,38 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
|
||||
// Default actions based on mode
|
||||
const defaultActions: EditViewModalAction[] = [];
|
||||
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
||||
|
||||
if (showDefaultActions) {
|
||||
if (mode === 'view') {
|
||||
defaultActions.push({
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
icon: Edit,
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: loading,
|
||||
});
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: onClose,
|
||||
disabled: isProcessing,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: isProcessing,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
disabled: isProcessing,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.save', 'Guardar'),
|
||||
variant: 'primary',
|
||||
onClick: handleSave,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
disabled: isProcessing,
|
||||
loading: isProcessing,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -469,8 +538,8 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
closeOnOverlayClick={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
@@ -479,8 +548,13 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
{/* Status indicator */}
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
||||
statusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
||||
} ${statusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${statusIndicator.color}15`,
|
||||
...(statusIndicator.isCritical && { ringColor: statusIndicator.color })
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
@@ -491,27 +565,13 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
{/* Title and subtitle */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: statusIndicator.color }}
|
||||
>
|
||||
{statusIndicator.text}
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -529,11 +589,17 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
{renderTopActions()}
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
{(loading || isSaving || isWaitingForRefetch) && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.loading', 'Cargando...')}</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{isWaitingForRefetch
|
||||
? t('common:modals.refreshing', 'Actualizando datos...')
|
||||
: isSaving
|
||||
? t('common:modals.saving', 'Guardando...')
|
||||
: t('common:modals.loading', 'Cargando...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,201 +1,120 @@
|
||||
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;
|
||||
}
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
|
||||
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 */
|
||||
/**
|
||||
* Icon component to display (from lucide-react)
|
||||
*/
|
||||
icon: LucideIcon;
|
||||
|
||||
/**
|
||||
* Main title text
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Description text (can be a string or React node for complex content)
|
||||
*/
|
||||
description?: string | React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optional action button label
|
||||
*/
|
||||
actionLabel?: string;
|
||||
|
||||
/**
|
||||
* Optional action button click handler
|
||||
*/
|
||||
onAction?: () => void;
|
||||
|
||||
/**
|
||||
* Optional icon for the action button
|
||||
*/
|
||||
actionIcon?: LucideIcon;
|
||||
|
||||
/**
|
||||
* Optional action button variant
|
||||
*/
|
||||
actionVariant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
|
||||
/**
|
||||
* Optional action button size
|
||||
*/
|
||||
actionSize?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
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,
|
||||
/**
|
||||
* EmptyState Component
|
||||
*
|
||||
* A reusable component for displaying empty states across the application
|
||||
* with consistent styling and behavior.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyState
|
||||
* icon={Package}
|
||||
* title="No items found"
|
||||
* description="Try adjusting your search or add a new item"
|
||||
* actionLabel="Add Item"
|
||||
* actionIcon={Plus}
|
||||
* onAction={() => setShowModal(true)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon: 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
|
||||
);
|
||||
|
||||
actionLabel,
|
||||
onAction,
|
||||
actionIcon: ActionIcon,
|
||||
actionVariant = 'primary',
|
||||
actionSize = 'md',
|
||||
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>
|
||||
)}
|
||||
<div className={`text-center py-12 ${className}`}>
|
||||
{/* Icon */}
|
||||
<Icon className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
|
||||
{/* Título */}
|
||||
{displayTitle && (
|
||||
<h3 className={clsx(
|
||||
'font-semibold text-text-primary mb-2',
|
||||
titleSizeClasses[size]
|
||||
)}>
|
||||
{displayTitle}
|
||||
</h3>
|
||||
)}
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{title}
|
||||
</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>
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="text-[var(--text-secondary)] mb-4">
|
||||
{typeof description === 'string' ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
onClick={onAction}
|
||||
variant={actionVariant}
|
||||
size={actionSize}
|
||||
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
{ActionIcon && (
|
||||
<ActionIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm sm:text-base">{actionLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
|
||||
export default EmptyState;
|
||||
export default EmptyState;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
export { default } from './EmptyState';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { ClipboardCheck, X } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface QualityPromptDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfigureNow: () => void;
|
||||
onConfigureLater: () => void;
|
||||
recipeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QualityPromptDialog - Prompts user to configure quality checks after creating a recipe
|
||||
*/
|
||||
export const QualityPromptDialog: React.FC<QualityPromptDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfigureNow,
|
||||
onConfigureLater,
|
||||
recipeName
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Configurar Control de Calidad"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<ClipboardCheck className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
¡Receta creada exitosamente!
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
La receta <strong>{recipeName}</strong> ha sido creada.
|
||||
Para asegurar la calidad durante la producción, te recomendamos configurar
|
||||
los controles de calidad ahora.
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">
|
||||
¿Qué son los controles de calidad?
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Definir qué verificaciones se deben realizar en cada etapa del proceso de producción
|
||||
(mezclado, fermentación, horneado, etc.) utilizando plantillas de control de calidad
|
||||
reutilizables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onConfigureLater}
|
||||
className="flex-1"
|
||||
>
|
||||
Más Tarde
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfigureNow}
|
||||
className="flex-1"
|
||||
>
|
||||
Configurar Ahora
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityPromptDialog;
|
||||
2
frontend/src/components/ui/QualityPromptDialog/index.ts
Normal file
2
frontend/src/components/ui/QualityPromptDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { QualityPromptDialog } from './QualityPromptDialog';
|
||||
export type { } from './QualityPromptDialog';
|
||||
@@ -320,7 +320,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
];
|
||||
|
||||
const triggerClasses = [
|
||||
'flex items-center justify-between w-full px-3 py-2',
|
||||
'flex items-center justify-between w-full px-3 py-2 gap-2',
|
||||
'bg-[var(--bg-primary,#ffffff)] border border-[var(--border-primary,#e5e7eb)] rounded-lg',
|
||||
'text-[var(--text-primary,#111827)] text-left transition-colors duration-200',
|
||||
'focus:border-[var(--color-primary,#3b82f6)] focus:ring-1 focus:ring-[var(--color-primary,#3b82f6)]',
|
||||
@@ -332,9 +332,9 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 text-sm',
|
||||
md: 'h-10 text-base',
|
||||
lg: 'h-12 text-lg',
|
||||
sm: 'min-h-8 text-sm',
|
||||
md: 'min-h-10 text-base',
|
||||
lg: 'min-h-12 text-lg',
|
||||
};
|
||||
|
||||
const dropdownClasses = [
|
||||
@@ -355,28 +355,28 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
|
||||
if (multiple && Array.isArray(currentValue)) {
|
||||
if (currentValue.length === 0) {
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)] break-words">{placeholder}</span>;
|
||||
}
|
||||
|
||||
|
||||
if (currentValue.length === 1) {
|
||||
const option = selectedOptions[0];
|
||||
return option ? option.label : currentValue[0];
|
||||
return option ? <span className="break-words">{option.label}</span> : <span className="break-words">{currentValue[0]}</span>;
|
||||
}
|
||||
|
||||
return <span>{currentValue.length} elementos seleccionados</span>;
|
||||
|
||||
return <span className="break-words">{currentValue.length} elementos seleccionados</span>;
|
||||
}
|
||||
|
||||
const selectedOption = selectedOptions[0];
|
||||
if (selectedOption) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption.icon && <span>{selectedOption.icon}</span>}
|
||||
<span>{selectedOption.label}</span>
|
||||
{selectedOption.icon && <span className="flex-shrink-0">{selectedOption.icon}</span>}
|
||||
<span className="break-words">{selectedOption.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)] break-words">{placeholder}</span>;
|
||||
};
|
||||
|
||||
const renderMultipleValues = () => {
|
||||
@@ -559,15 +559,15 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
className={clsx(triggerClasses, sizeClasses[size])}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{multiple && Array.isArray(currentValue) && currentValue.length > 0 && currentValue.length <= 3 ? (
|
||||
renderMultipleValues()
|
||||
) : (
|
||||
renderSelectedValue()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{clearable && currentValue && (multiple ? (Array.isArray(currentValue) && currentValue.length > 0) : true) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface StatusCardProps {
|
||||
onClick: () => void;
|
||||
priority?: 'primary' | 'secondary' | 'tertiary';
|
||||
destructive?: boolean;
|
||||
highlighted?: boolean;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
onClick?: () => void;
|
||||
@@ -180,7 +181,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
: statusIndicator.isHighlight
|
||||
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
|
||||
: 'ring-1 shadow-sm'
|
||||
} max-w-[140px] sm:max-w-[160px]`}
|
||||
} max-w-[200px] sm:max-w-[220px]`}
|
||||
style={{
|
||||
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
@@ -201,7 +202,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
className={`${overflowClasses.truncate} flex-1`}
|
||||
title={statusIndicator.text}
|
||||
>
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 14 : 18)}
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 20 : 28)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,7 +337,9 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
: action.highlighted
|
||||
? 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user