Refactor components and modals
This commit is contained in:
713
frontend/src/components/ui/AddModal/AddModal.tsx
Normal file
713
frontend/src/components/ui/AddModal/AddModal.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LucideIcon, Plus, Save, X, Trash2 } from 'lucide-react';
|
||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||
import { Button } from '../Button';
|
||||
import { Input } from '../Input';
|
||||
import { Select } from '../Select';
|
||||
import { StatusIndicatorConfig } from '../StatusCard';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
// Constants to prevent re-creation on every render
|
||||
const EMPTY_VALIDATION_ERRORS = {};
|
||||
const EMPTY_INITIAL_DATA = {};
|
||||
|
||||
/**
|
||||
* ListFieldRenderer - Native component for managing lists of structured items
|
||||
*/
|
||||
interface ListFieldRendererProps {
|
||||
field: AddModalField;
|
||||
value: any[];
|
||||
onChange: (newValue: any[]) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onChange, error }) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const listConfig = field.listConfig!;
|
||||
|
||||
const addItem = () => {
|
||||
const newItem: any = { id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||
|
||||
// Initialize with default values
|
||||
listConfig.itemFields.forEach(itemField => {
|
||||
newItem[itemField.name] = itemField.defaultValue ?? (itemField.type === 'number' || itemField.type === 'currency' ? 0 : '');
|
||||
});
|
||||
|
||||
onChange([...value, newItem]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateItem = (index: number, fieldName: string, newValue: any) => {
|
||||
const updated = value.map((item, i) =>
|
||||
i === index ? { ...item, [fieldName]: newValue } : item
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const calculateSubtotal = (item: any) => {
|
||||
if (!listConfig.showSubtotals || !listConfig.subtotalFields) return 0;
|
||||
|
||||
const quantity = item[listConfig.subtotalFields.quantity] || 0;
|
||||
const price = item[listConfig.subtotalFields.price] || 0;
|
||||
return quantity * price;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
if (!listConfig.showSubtotals) return 0;
|
||||
return value.reduce((total, item) => total + calculateSubtotal(item), 0);
|
||||
};
|
||||
|
||||
const renderItemField = (item: any, itemIndex: number, fieldConfig: any) => {
|
||||
const fieldValue = item[fieldConfig.name] ?? '';
|
||||
|
||||
switch (fieldConfig.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
required={fieldConfig.required}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{fieldConfig.options?.map((option: any) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step={fieldConfig.type === 'currency' ? '0.01' : '0.1'}
|
||||
placeholder={fieldConfig.placeholder}
|
||||
required={fieldConfig.required}
|
||||
/>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder={fieldConfig.placeholder}
|
||||
required={fieldConfig.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">{field.label}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{listConfig.addButtonLabel || t('common:modals.actions.add', 'Agregar')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{value.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto mb-2 opacity-50">
|
||||
<Plus className="w-full h-full" />
|
||||
</div>
|
||||
<p>{listConfig.emptyStateText || 'No hay elementos agregados'}</p>
|
||||
<p className="text-sm">Haz clic en "{listConfig.addButtonLabel || 'Agregar'}" para comenzar</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{value.map((item, itemIndex) => (
|
||||
<div key={item.id || itemIndex} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">Elemento #{itemIndex + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(itemIndex)}
|
||||
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{listConfig.itemFields.map((fieldConfig) => (
|
||||
<div key={fieldConfig.name}>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{renderItemField(item, itemIndex, fieldConfig)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{listConfig.showSubtotals && (
|
||||
<div className="pt-2 border-t border-[var(--border-primary)] text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Subtotal: €{calculateSubtotal(item).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listConfig.showSubtotals && value.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-primary)] text-right">
|
||||
<span className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Total: €{calculateTotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface AddModalField {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: 'text' | 'email' | 'tel' | 'number' | 'currency' | 'date' | 'select' | 'textarea' | 'component' | 'list';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{label: string; value: string | number}>;
|
||||
validation?: (value: string | number | any) => string | null;
|
||||
helpText?: string;
|
||||
span?: 1 | 2; // For grid layout
|
||||
defaultValue?: string | number | any;
|
||||
component?: React.ComponentType<any>; // For custom components
|
||||
componentProps?: Record<string, any>; // Props for custom components
|
||||
|
||||
// List field configuration
|
||||
listConfig?: {
|
||||
itemFields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'select' | 'currency';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{label: string; value: string | number}>;
|
||||
defaultValue?: any;
|
||||
validation?: (value: any) => string | null;
|
||||
}>;
|
||||
addButtonLabel?: string;
|
||||
removeButtonLabel?: string;
|
||||
emptyStateText?: string;
|
||||
showSubtotals?: boolean; // For calculating item totals
|
||||
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddModalSection {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
fields: AddModalField[];
|
||||
columns?: 1 | 2; // Grid columns for this section
|
||||
}
|
||||
|
||||
export interface AddModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusIndicator?: StatusIndicatorConfig;
|
||||
sections: AddModalSection[];
|
||||
|
||||
// Actions
|
||||
onSave: (formData: Record<string, any>) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
|
||||
// Layout
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
loading?: boolean;
|
||||
|
||||
// Initial form data
|
||||
initialData?: Record<string, any>;
|
||||
|
||||
// Validation
|
||||
validationErrors?: Record<string, string>;
|
||||
onValidationError?: (errors: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AddModal - Specialized modal component for creating new items
|
||||
* Provides a simplified interface compared to EditViewModal, focused on creation workflows
|
||||
*
|
||||
* Features:
|
||||
* - Form-based interface optimized for adding new items
|
||||
* - Built-in form state management
|
||||
* - Validation support with error display
|
||||
* - Responsive grid layout for fields
|
||||
* - Loading states and action buttons
|
||||
* - Support for various field types
|
||||
*/
|
||||
export const AddModal: React.FC<AddModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
statusIndicator,
|
||||
sections,
|
||||
onSave,
|
||||
onCancel,
|
||||
size = 'lg',
|
||||
loading = false,
|
||||
initialData = EMPTY_INITIAL_DATA,
|
||||
validationErrors = EMPTY_VALIDATION_ERRORS,
|
||||
onValidationError,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track if we've initialized the form data for this modal session
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && !initializedRef.current) {
|
||||
const defaultFormData: Record<string, any> = {};
|
||||
|
||||
// Populate with default values from sections
|
||||
sections.forEach(section => {
|
||||
section.fields.forEach(field => {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultFormData[field.name] = field.defaultValue;
|
||||
} else {
|
||||
defaultFormData[field.name] = field.type === 'number' || field.type === 'currency' ? 0 : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Merge with initialData
|
||||
setFormData({ ...defaultFormData, ...initialData });
|
||||
setFieldErrors({});
|
||||
initializedRef.current = true;
|
||||
} else if (!isOpen) {
|
||||
// Reset initialization flag when modal closes
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
// Update field errors when validation errors change
|
||||
useEffect(() => {
|
||||
setFieldErrors(validationErrors);
|
||||
}, [validationErrors]);
|
||||
|
||||
const defaultStatusIndicator: StatusIndicatorConfig = statusIndicator || {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nuevo',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
|
||||
// Clear field error if it exists
|
||||
if (fieldErrors[fieldName]) {
|
||||
const newErrors = { ...fieldErrors };
|
||||
delete newErrors[fieldName];
|
||||
setFieldErrors(newErrors);
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
|
||||
// Run field validation if provided
|
||||
const field = findFieldByName(fieldName);
|
||||
if (field?.validation) {
|
||||
const error = field.validation(value);
|
||||
if (error) {
|
||||
const newErrors = { ...fieldErrors, [fieldName]: error };
|
||||
setFieldErrors(newErrors);
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findFieldByName = (fieldName: string): AddModalField | undefined => {
|
||||
for (const section of sections) {
|
||||
const field = section.fields.find(f => f.name === fieldName);
|
||||
if (field) return field;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
sections.forEach(section => {
|
||||
section.fields.forEach(field => {
|
||||
const value = formData[field.name];
|
||||
|
||||
// Check required fields
|
||||
if (field.required && (!value || String(value).trim() === '')) {
|
||||
errors[field.name] = `${field.label} ${t('common:modals.validation.required_field', 'es requerido')}`;
|
||||
}
|
||||
|
||||
// Run custom validation
|
||||
if (value && field.validation) {
|
||||
const error = field.validation(value);
|
||||
if (error) {
|
||||
errors[field.name] = error;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setFieldErrors(errors);
|
||||
onValidationError?.(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving form:', error);
|
||||
// Don't close modal on error - let the parent handle error display
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderField = (field: AddModalField): React.ReactNode => {
|
||||
const value = formData[field.name] ?? '';
|
||||
const error = fieldErrors[field.name];
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const newValue = field.type === 'number' || field.type === 'currency'
|
||||
? Number(e.target.value)
|
||||
: e.target.value;
|
||||
handleFieldChange(field.name, newValue);
|
||||
};
|
||||
|
||||
const inputValue = field.type === 'currency'
|
||||
? Number(String(value).replace(/[^0-9.-]+/g, ''))
|
||||
: value;
|
||||
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(value)}
|
||||
onChange={(newValue) => handleFieldChange(field.name, newValue)}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
isRequired={field.required}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={String(value)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
|
||||
error
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
const dateValue = value ? new Date(String(value)).toISOString().split('T')[0] : '';
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={handleChange}
|
||||
required={field.required}
|
||||
className={`w-full ${error ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'component':
|
||||
if (field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Component
|
||||
value={value}
|
||||
onChange={(newValue: any) => handleFieldChange(field.name, newValue)}
|
||||
{...field.componentProps}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ListFieldRenderer
|
||||
field={field}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={(newValue: any[]) => handleFieldChange(field.name, newValue)}
|
||||
error={error}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type="number"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
step={field.type === 'currency' ? '0.01' : '1'}
|
||||
className={`w-full ${error ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type={field.type || 'text'}
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className={`w-full ${error ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const StatusIcon = defaultStatusIndicator.icon;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${defaultStatusIndicator.color}15` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: defaultStatusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
<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">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showCloseButton={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const sectionColumns = section.columns || 2;
|
||||
|
||||
const getGridClasses = () => {
|
||||
return sectionColumns === 1
|
||||
? 'grid grid-cols-1 gap-4'
|
||||
: 'grid grid-cols-1 md:grid-cols-2 gap-4';
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={sectionIndex} className="space-y-4">
|
||||
<div className="flex items-start gap-3 pb-3 border-b border-[var(--border-primary)]">
|
||||
{section.icon && (
|
||||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-[var(--text-primary)] leading-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={getGridClasses()}>
|
||||
{section.fields.map((field, fieldIndex) => (
|
||||
<div
|
||||
key={fieldIndex}
|
||||
className={`space-y-2 ${
|
||||
field.span === 2 ? 'md:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter justify="end">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{t('common:modals.actions.save', 'Guardar')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModal;
|
||||
6
frontend/src/components/ui/AddModal/index.ts
Normal file
6
frontend/src/components/ui/AddModal/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AddModal, default } from './AddModal';
|
||||
export type {
|
||||
AddModalProps,
|
||||
AddModalField,
|
||||
AddModalSection
|
||||
} from './AddModal';
|
||||
321
frontend/src/components/ui/DialogModal/DialogModal.tsx
Normal file
321
frontend/src/components/ui/DialogModal/DialogModal.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LucideIcon, AlertTriangle, CheckCircle, XCircle, Info, X } from 'lucide-react';
|
||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export interface DialogModalAction {
|
||||
label: string;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface DialogModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
type?: 'info' | 'warning' | 'error' | 'success' | 'confirm' | 'custom';
|
||||
icon?: LucideIcon;
|
||||
|
||||
// Actions
|
||||
actions?: DialogModalAction[];
|
||||
showCloseButton?: boolean;
|
||||
|
||||
// Convenience props for common dialogs
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
|
||||
// Layout
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DialogModal - Standardized component for simple confirmation dialogs and alerts
|
||||
*
|
||||
* Features:
|
||||
* - Predefined dialog types with appropriate icons and styling
|
||||
* - Support for custom actions or convenient confirm/cancel pattern
|
||||
* - Responsive design optimized for mobile
|
||||
* - Built-in loading states
|
||||
* - Accessibility features
|
||||
*/
|
||||
export const DialogModal: React.FC<DialogModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
icon,
|
||||
actions,
|
||||
showCloseButton = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
size = 'sm',
|
||||
loading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Default labels with translation fallbacks
|
||||
const defaultConfirmLabel = confirmLabel || t('common:modals.actions.confirm', 'Confirmar');
|
||||
const defaultCancelLabel = cancelLabel || t('common:modals.actions.cancel', 'Cancelar');
|
||||
|
||||
// Get icon and colors based on dialog type
|
||||
const getDialogConfig = () => {
|
||||
if (icon) {
|
||||
return { icon, color: 'var(--text-primary)' };
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return { icon: AlertTriangle, color: '#f59e0b' }; // yellow-500
|
||||
case 'error':
|
||||
return { icon: XCircle, color: '#ef4444' }; // red-500
|
||||
case 'success':
|
||||
return { icon: CheckCircle, color: '#10b981' }; // emerald-500
|
||||
case 'confirm':
|
||||
return { icon: AlertTriangle, color: '#f59e0b' }; // yellow-500
|
||||
case 'info':
|
||||
default:
|
||||
return { icon: Info, color: '#3b82f6' }; // blue-500
|
||||
}
|
||||
};
|
||||
|
||||
const { icon: DialogIcon, color } = getDialogConfig();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (onConfirm) {
|
||||
await onConfirm();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Generate actions based on type and props
|
||||
const getActions = (): DialogModalAction[] => {
|
||||
if (actions) {
|
||||
return actions;
|
||||
}
|
||||
|
||||
// For simple info/success/error dialogs, just show OK button
|
||||
if (type === 'info' || type === 'success' || type === 'error') {
|
||||
return [
|
||||
{
|
||||
label: t('common:modals.actions.ok', 'OK'),
|
||||
variant: 'primary',
|
||||
onClick: onClose,
|
||||
disabled: loading,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// For confirm/warning dialogs, show cancel and confirm buttons
|
||||
if (type === 'confirm' || type === 'warning') {
|
||||
return [
|
||||
{
|
||||
label: defaultCancelLabel,
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
},
|
||||
{
|
||||
label: defaultConfirmLabel,
|
||||
variant: type === 'warning' ? 'danger' : 'primary',
|
||||
onClick: handleConfirm,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Default: just OK button
|
||||
return [
|
||||
{
|
||||
label: 'OK',
|
||||
variant: 'primary',
|
||||
onClick: onClose,
|
||||
disabled: loading,
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const dialogActions = getActions();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Dialog icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${color}15` }}
|
||||
>
|
||||
<DialogIcon
|
||||
className="w-6 h-6"
|
||||
style={{ color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showCloseButton={showCloseButton && !loading}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ModalBody padding="lg">
|
||||
{loading && (
|
||||
<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.processing', 'Procesando...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[var(--text-primary)]">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="leading-relaxed">{message}</p>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
{dialogActions.length > 0 && (
|
||||
<ModalFooter justify="end">
|
||||
<div className="flex gap-3 w-full sm:w-auto">
|
||||
{dialogActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'outline'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || loading}
|
||||
className={`${
|
||||
dialogActions.length > 1 && size === 'xs' ? 'flex-1 sm:flex-none' : ''
|
||||
} min-w-[80px]`}
|
||||
>
|
||||
{action.loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Convenience functions for common dialog types
|
||||
export const showInfoDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onClose: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={message}
|
||||
type="info"
|
||||
/>
|
||||
);
|
||||
|
||||
export const showWarningDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
onCancel?: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onCancel || (() => {})}
|
||||
title={title}
|
||||
message={message}
|
||||
type="warning"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
export const showErrorDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onClose: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={message}
|
||||
type="error"
|
||||
/>
|
||||
);
|
||||
|
||||
export const showSuccessDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onClose: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={message}
|
||||
type="success"
|
||||
/>
|
||||
);
|
||||
|
||||
export const showConfirmDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
onCancel?: () => void,
|
||||
confirmLabel?: string,
|
||||
cancelLabel?: string
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onCancel || (() => {})}
|
||||
title={title}
|
||||
message={message}
|
||||
type="confirm"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmLabel={confirmLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
export default DialogModal;
|
||||
14
frontend/src/components/ui/DialogModal/index.ts
Normal file
14
frontend/src/components/ui/DialogModal/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
DialogModal,
|
||||
default,
|
||||
showInfoDialog,
|
||||
showWarningDialog,
|
||||
showErrorDialog,
|
||||
showSuccessDialog,
|
||||
showConfirmDialog
|
||||
} from './DialogModal';
|
||||
|
||||
export type {
|
||||
DialogModalProps,
|
||||
DialogModalAction
|
||||
} from './DialogModal';
|
||||
698
frontend/src/components/ui/EditViewModal/EditViewModal.tsx
Normal file
698
frontend/src/components/ui/EditViewModal/EditViewModal.tsx
Normal file
@@ -0,0 +1,698 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LucideIcon, Edit } from 'lucide-react';
|
||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||
import { Button } from '../Button';
|
||||
import { Input } from '../Input';
|
||||
import { Select } from '../Select';
|
||||
import { StatusIndicatorConfig, getStatusColor } from '../StatusCard';
|
||||
import { formatters } from '../Stats/StatsPresets';
|
||||
|
||||
export interface EditViewModalField {
|
||||
label: string;
|
||||
value: string | number | React.ReactNode;
|
||||
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select' | 'textarea' | 'component';
|
||||
highlight?: boolean;
|
||||
span?: 1 | 2 | 3; // For grid layout - added 3 for full width on larger screens
|
||||
editable?: boolean; // Whether this field can be edited
|
||||
required?: boolean; // Whether this field is required
|
||||
placeholder?: string; // Placeholder text for inputs
|
||||
options?: Array<{label: string; value: string | number}>; // For select fields
|
||||
validation?: (value: string | number) => string | null; // Custom validation function
|
||||
helpText?: string; // Help text displayed below the field
|
||||
component?: React.ComponentType<any>; // For custom components
|
||||
componentProps?: Record<string, any>; // Props for custom components
|
||||
}
|
||||
|
||||
export interface EditViewModalSection {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
fields: EditViewModalField[];
|
||||
collapsible?: boolean; // Whether section can be collapsed
|
||||
collapsed?: boolean; // Initial collapsed state
|
||||
description?: string; // Section description
|
||||
columns?: 1 | 2 | 3; // Override grid columns for this section
|
||||
}
|
||||
|
||||
export interface EditViewModalAction {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface EditViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'view' | 'edit';
|
||||
onModeChange?: (mode: 'view' | 'edit') => void;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusIndicator?: StatusIndicatorConfig;
|
||||
image?: string;
|
||||
sections: EditViewModalSection[];
|
||||
|
||||
// Actions
|
||||
actions?: EditViewModalAction[];
|
||||
showDefaultActions?: boolean;
|
||||
actionsPosition?: 'header' | 'footer'; // New prop for positioning actions
|
||||
onEdit?: () => void;
|
||||
onSave?: () => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => void;
|
||||
|
||||
// Layout
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
loading?: boolean;
|
||||
|
||||
// Enhanced features
|
||||
mobileOptimized?: boolean; // Enable mobile-first responsive design
|
||||
showStepIndicator?: boolean; // Show step indicator for multi-step workflows
|
||||
currentStep?: number; // Current step in workflow
|
||||
totalSteps?: number; // Total steps in workflow
|
||||
validationErrors?: Record<string, string>; // Field validation errors
|
||||
onValidationError?: (errors: Record<string, string>) => void; // Validation error handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field value based on type
|
||||
*/
|
||||
const formatFieldValue = (value: string | number | React.ReactNode, type: EditViewModalField['type'] = 'text'): React.ReactNode => {
|
||||
if (React.isValidElement(value)) return value;
|
||||
|
||||
switch (type) {
|
||||
case 'currency':
|
||||
return formatters.currency(Number(value));
|
||||
case 'date':
|
||||
return new Date(String(value)).toLocaleDateString('es-ES');
|
||||
case 'datetime':
|
||||
return new Date(String(value)).toLocaleString('es-ES');
|
||||
case 'percentage':
|
||||
return `${value}%`;
|
||||
case 'list':
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{value.map((item, index) => (
|
||||
<li key={index} className="text-sm">{String(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
case 'status':
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${getStatusColor(String(value))}20`,
|
||||
color: getStatusColor(String(value))
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case 'image':
|
||||
return (
|
||||
<img
|
||||
src={String(value)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover bg-[var(--bg-secondary)]"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render editable field based on type and mode
|
||||
*/
|
||||
const renderEditableField = (
|
||||
field: EditViewModalField,
|
||||
isEditMode: boolean,
|
||||
onChange?: (value: string | number) => void,
|
||||
validationError?: string
|
||||
): React.ReactNode => {
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
}
|
||||
|
||||
// Handle custom components
|
||||
if (field.type === 'component' && field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
<Component
|
||||
value={field.value}
|
||||
onChange={onChange}
|
||||
{...field.componentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
const inputValue = field.type === 'currency' ? Number(String(field.value).replace(/[^0-9.-]+/g, '')) : field.value;
|
||||
|
||||
switch (field.type) {
|
||||
case 'email':
|
||||
return (
|
||||
<Input
|
||||
type="email"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'tel':
|
||||
return (
|
||||
<Input
|
||||
type="tel"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
step={field.type === 'currency' ? '0.01' : '1'}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
const dateValue = field.value ? new Date(String(field.value)).toISOString().split('T')[0] : '';
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={handleChange}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'list':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
||||
onChange={(e) => {
|
||||
const stringArray = e.target.value.split('\n');
|
||||
// For list type, we'll pass the joined string instead of array to maintain compatibility
|
||||
onChange?.(stringArray.join('\n'));
|
||||
}}
|
||||
placeholder={field.placeholder || 'Una opción por línea'}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
|
||||
validationError
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||
}`}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={String(field.value)}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
|
||||
validationError
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||
}`}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onChange={(value) => onChange?.(typeof value === 'string' ? value : String(value))}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
isRequired={field.required}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type="text"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className={`w-full ${
|
||||
validationError ? 'border-red-500 focus:ring-red-500' : ''
|
||||
}`}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EditViewModal - Unified modal component for viewing/editing data details
|
||||
* Follows UX best practices for modal dialogs (2024)
|
||||
*
|
||||
* Features:
|
||||
* - Supports actions in header (tab-style navigation) or footer (default)
|
||||
* - Tab-style navigation in header improves discoverability for multi-view modals
|
||||
* - Maintains backward compatibility - existing modals continue working unchanged
|
||||
* - Responsive design with horizontal scroll for many tabs on mobile
|
||||
* - Active state indicated by disabled=true for navigation actions
|
||||
*/
|
||||
export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
onModeChange,
|
||||
title,
|
||||
subtitle,
|
||||
statusIndicator,
|
||||
image,
|
||||
sections,
|
||||
actions = [],
|
||||
showDefaultActions = true,
|
||||
actionsPosition = 'footer',
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onFieldChange,
|
||||
size = 'lg',
|
||||
loading = false,
|
||||
// New enhanced features
|
||||
mobileOptimized = false,
|
||||
showStepIndicator = false,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
validationErrors = {},
|
||||
onValidationError,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
onModeChange('edit');
|
||||
} else if (onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
};
|
||||
|
||||
// Default actions based on mode
|
||||
const defaultActions: EditViewModalAction[] = [];
|
||||
|
||||
if (showDefaultActions) {
|
||||
if (mode === 'view') {
|
||||
defaultActions.push({
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
icon: Edit,
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: loading,
|
||||
});
|
||||
} else {
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.save', 'Guardar'),
|
||||
variant: 'primary',
|
||||
onClick: handleSave,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allActions = [...actions, ...defaultActions];
|
||||
|
||||
// Step indicator component
|
||||
const renderStepIndicator = () => {
|
||||
if (!showStepIndicator || !currentStep || !totalSteps) return null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<span>{t('common:modals.step_indicator.step', 'Paso')} {currentStep} {t('common:modals.step_indicator.of', 'de')} {totalSteps}</span>
|
||||
<div className="flex-1 bg-[var(--bg-tertiary)] rounded-full h-2 mx-3">
|
||||
<div
|
||||
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{Math.round((currentStep / totalSteps) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render top navigation actions (tab-like style)
|
||||
const renderTopActions = () => {
|
||||
if (actionsPosition !== 'header' || allActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex gap-0 overflow-x-auto scrollbar-hide" style={{scrollbarWidth: 'none', msOverflowStyle: 'none'}}>
|
||||
{allActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.disabled || loading ? undefined : action.onClick}
|
||||
disabled={loading}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200
|
||||
min-w-fit whitespace-nowrap border-b-2 -mb-px
|
||||
${loading
|
||||
? 'opacity-50 cursor-not-allowed text-[var(--text-tertiary)] border-transparent'
|
||||
: action.disabled
|
||||
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--color-primary)]/8 cursor-default'
|
||||
: action.variant === 'danger'
|
||||
? 'text-red-600 border-transparent hover:border-red-300 hover:bg-red-50'
|
||||
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{action.loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
<span>{action.label}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: statusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
<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">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showCloseButton={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Step Indicator */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
{/* Top Navigation Actions */}
|
||||
{renderTopActions()}
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-48 object-cover rounded-lg bg-[var(--bg-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
||||
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
|
||||
|
||||
// Determine grid classes based on mobile optimization and section columns
|
||||
const getGridClasses = () => {
|
||||
if (mobileOptimized) {
|
||||
return sectionColumns === 1
|
||||
? 'grid grid-cols-1 gap-4'
|
||||
: sectionColumns === 2
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 gap-4'
|
||||
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4';
|
||||
} else {
|
||||
return sectionColumns === 1
|
||||
? 'grid grid-cols-1 gap-4'
|
||||
: sectionColumns === 3
|
||||
? 'grid grid-cols-1 md:grid-cols-3 gap-4'
|
||||
: 'grid grid-cols-1 md:grid-cols-2 gap-4';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={sectionIndex} className="space-y-4">
|
||||
<div
|
||||
className={`flex items-start gap-3 pb-3 border-b border-[var(--border-primary)] ${
|
||||
section.collapsible ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={section.collapsible ? () => setIsCollapsed(!isCollapsed) : undefined}
|
||||
>
|
||||
{section.icon && (
|
||||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-[var(--text-primary)] leading-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{section.collapsible && (
|
||||
<svg
|
||||
className={`w-5 h-5 text-[var(--text-secondary)] transition-transform ${
|
||||
isCollapsed ? 'rotate-0' : 'rotate-180'
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(!section.collapsible || !isCollapsed) && (
|
||||
<div className={getGridClasses()}>
|
||||
{section.fields.map((field, fieldIndex) => {
|
||||
const fieldKey = `${sectionIndex}-${fieldIndex}`;
|
||||
const validationError = validationErrors[fieldKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fieldIndex}
|
||||
className={`space-y-2 ${
|
||||
field.span === 2 ?
|
||||
(mobileOptimized ? 'sm:col-span-2' : 'md:col-span-2') :
|
||||
field.span === 3 ?
|
||||
(mobileOptimized ? 'sm:col-span-2 lg:col-span-3' : 'md:col-span-3') :
|
||||
''
|
||||
}`}
|
||||
>
|
||||
<dt className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className={`text-sm ${
|
||||
field.highlight
|
||||
? 'font-semibold text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-primary)]'
|
||||
}`}>
|
||||
{renderEditableField(
|
||||
field,
|
||||
mode === 'edit',
|
||||
(value: string | number) => {
|
||||
// Run validation if provided
|
||||
if (field.validation) {
|
||||
const error = field.validation(value);
|
||||
if (error && onValidationError) {
|
||||
onValidationError({
|
||||
...validationErrors,
|
||||
[fieldKey]: error
|
||||
});
|
||||
} else if (!error && validationErrors[fieldKey]) {
|
||||
const newErrors = { ...validationErrors };
|
||||
delete newErrors[fieldKey];
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
}
|
||||
onFieldChange?.(sectionIndex, fieldIndex, value);
|
||||
},
|
||||
validationError
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
{allActions.length > 0 && actionsPosition === 'footer' && (
|
||||
<ModalFooter justify="end">
|
||||
<div className="flex gap-3">
|
||||
{allActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'outline'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{action.loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
{action.icon && <action.icon className="w-4 h-4 mr-2" />}
|
||||
{action.label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditViewModal;
|
||||
7
frontend/src/components/ui/EditViewModal/index.ts
Normal file
7
frontend/src/components/ui/EditViewModal/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { EditViewModal, default } from './EditViewModal';
|
||||
export type {
|
||||
EditViewModalProps,
|
||||
EditViewModalField,
|
||||
EditViewModalSection,
|
||||
EditViewModalAction
|
||||
} from './EditViewModal';
|
||||
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;
|
||||
2
frontend/src/components/ui/EmptyState/index.ts
Normal file
2
frontend/src/components/ui/EmptyState/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
40
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
40
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
overlay?: boolean;
|
||||
text?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
overlay = false,
|
||||
text,
|
||||
size = 'md'
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const spinner = (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className={`animate-spin rounded-full border-4 border-[var(--border-secondary)] border-t-[var(--color-primary)] ${sizeClasses[size]}`}></div>
|
||||
{text && (
|
||||
<p className="mt-4 text-[var(--text-secondary)] text-sm">{text}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (overlay) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg p-6">
|
||||
{spinner}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return spinner;
|
||||
};
|
||||
195
frontend/src/components/ui/LoadingSpinner/LoadingSpinner.tsx
Normal file
195
frontend/src/components/ui/LoadingSpinner/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export interface LoadingSpinnerProps {
|
||||
/** Tamaño del spinner */
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Variante del spinner */
|
||||
variant?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
|
||||
/** Color personalizado */
|
||||
color?: 'primary' | 'secondary' | 'white' | 'gray';
|
||||
/** Texto de carga opcional */
|
||||
text?: string;
|
||||
/** Mostrar como overlay de pantalla completa */
|
||||
overlay?: boolean;
|
||||
/** Centrar el spinner */
|
||||
centered?: boolean;
|
||||
/** Clase CSS adicional */
|
||||
className?: string;
|
||||
/** Props adicionales */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner = forwardRef<HTMLDivElement, LoadingSpinnerProps>(({
|
||||
size = 'md',
|
||||
variant = 'spinner',
|
||||
color = 'primary',
|
||||
text,
|
||||
overlay = false,
|
||||
centered = false,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'Cargando',
|
||||
...props
|
||||
}, ref) => {
|
||||
const sizeClasses = {
|
||||
xs: 'w-4 h-4',
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12',
|
||||
xl: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'text-color-primary',
|
||||
secondary: 'text-color-secondary',
|
||||
white: 'text-white',
|
||||
gray: 'text-text-tertiary'
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl'
|
||||
};
|
||||
|
||||
// Componente Spinner (rotación)
|
||||
const SpinnerIcon = () => (
|
||||
<svg
|
||||
className={clsx(
|
||||
'animate-spin',
|
||||
sizeClasses[size],
|
||||
colorClasses[color]
|
||||
)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Componente Dots (puntos que aparecen y desaparecen)
|
||||
const DotsSpinner = () => {
|
||||
const dotSize = size === 'xs' ? 'w-1 h-1' : size === 'sm' ? 'w-1.5 h-1.5' : size === 'md' ? 'w-2 h-2' : size === 'lg' ? 'w-3 h-3' : 'w-4 h-4';
|
||||
|
||||
return (
|
||||
<div className="flex space-x-1" role="img" aria-label={ariaLabel}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx(
|
||||
'rounded-full animate-pulse',
|
||||
dotSize,
|
||||
colorClasses[color] === 'text-white' ? 'bg-white' :
|
||||
colorClasses[color] === 'text-color-primary' ? 'bg-color-primary' :
|
||||
colorClasses[color] === 'text-color-secondary' ? 'bg-color-secondary' :
|
||||
'bg-text-tertiary'
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
animationDuration: '1.4s'
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Componente Pulse (respiración)
|
||||
const PulseSpinner = () => (
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-pulse rounded-full',
|
||||
sizeClasses[size],
|
||||
colorClasses[color] === 'text-white' ? 'bg-white' :
|
||||
colorClasses[color] === 'text-color-primary' ? 'bg-color-primary' :
|
||||
colorClasses[color] === 'text-color-secondary' ? 'bg-color-secondary' :
|
||||
'bg-text-tertiary'
|
||||
)}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
// Componente Skeleton (placeholder animado)
|
||||
const SkeletonSpinner = () => {
|
||||
const skeletonHeight = size === 'xs' ? 'h-3' : size === 'sm' ? 'h-4' : size === 'md' ? 'h-6' : size === 'lg' ? 'h-8' : 'h-12';
|
||||
|
||||
return (
|
||||
<div className="space-y-2 animate-pulse" role="img" aria-label={ariaLabel}>
|
||||
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-full')} />
|
||||
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-3/4')} />
|
||||
<div className={clsx('bg-bg-quaternary rounded', skeletonHeight, 'w-1/2')} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSpinner = () => {
|
||||
switch (variant) {
|
||||
case 'dots':
|
||||
return <DotsSpinner />;
|
||||
case 'pulse':
|
||||
return <PulseSpinner />;
|
||||
case 'skeleton':
|
||||
return <SkeletonSpinner />;
|
||||
default:
|
||||
return <SpinnerIcon />;
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = clsx(
|
||||
'flex items-center',
|
||||
{
|
||||
'justify-center': centered,
|
||||
'flex-col': text && variant !== 'skeleton',
|
||||
'gap-3': text,
|
||||
'fixed inset-0 bg-black/30 backdrop-blur-sm z-modal': overlay,
|
||||
'min-h-[200px]': overlay
|
||||
},
|
||||
className
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className={containerClasses} ref={ref} {...props}>
|
||||
{renderSpinner()}
|
||||
{text && variant !== 'skeleton' && (
|
||||
<span
|
||||
className={clsx(
|
||||
'font-medium',
|
||||
textSizeClasses[size],
|
||||
colorClasses[color],
|
||||
{
|
||||
'text-white': overlay && color !== 'white'
|
||||
}
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return content;
|
||||
});
|
||||
|
||||
LoadingSpinner.displayName = 'LoadingSpinner';
|
||||
|
||||
export default LoadingSpinner;
|
||||
2
frontend/src/components/ui/LoadingSpinner/index.ts
Normal file
2
frontend/src/components/ui/LoadingSpinner/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
@@ -39,6 +39,8 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
|
||||
noOptionsMessage?: string;
|
||||
loadingMessage?: string;
|
||||
createLabel?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
@@ -70,12 +72,48 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
noOptionsMessage = 'No hay opciones disponibles',
|
||||
loadingMessage = 'Cargando...',
|
||||
createLabel = 'Crear',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
id,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Filter out non-DOM props to avoid React warnings
|
||||
const {
|
||||
// Remove Select-specific props that shouldn't be passed to DOM
|
||||
label: _label,
|
||||
error: _error,
|
||||
helperText: _helperText,
|
||||
placeholder: _placeholder,
|
||||
size: _size,
|
||||
variant: _variant,
|
||||
options: _options,
|
||||
value: _value,
|
||||
defaultValue: _defaultValue,
|
||||
multiple: _multiple,
|
||||
searchable: _searchable,
|
||||
clearable: _clearable,
|
||||
loading: _loading,
|
||||
isRequired: _isRequired,
|
||||
isInvalid: _isInvalid,
|
||||
maxHeight: _maxHeight,
|
||||
dropdownPosition: _dropdownPosition,
|
||||
createable: _createable,
|
||||
onCreate: _onCreate,
|
||||
onSearch: _onSearch,
|
||||
onChange: _onChange,
|
||||
renderOption: _renderOption,
|
||||
renderValue: _renderValue,
|
||||
noOptionsMessage: _noOptionsMessage,
|
||||
loadingMessage: _loadingMessage,
|
||||
createLabel: _createLabel,
|
||||
leftIcon: _leftIcon,
|
||||
rightIcon: _rightIcon,
|
||||
...domProps
|
||||
} = props as any;
|
||||
const [internalValue, setInternalValue] = useState<string | number | Array<string | number>>(
|
||||
value !== undefined ? value : defaultValue || (multiple ? [] : '')
|
||||
);
|
||||
@@ -515,7 +553,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
{...props}
|
||||
{...domProps}
|
||||
>
|
||||
<div
|
||||
className={clsx(triggerClasses, sizeClasses[size])}
|
||||
|
||||
@@ -18,9 +18,13 @@ export { StatusIndicator } from './StatusIndicator';
|
||||
export { ListItem } from './ListItem';
|
||||
export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { StatusModal } from './StatusModal';
|
||||
export { EditViewModal } from './EditViewModal';
|
||||
export { AddModal } from './AddModal';
|
||||
export { DialogModal, showInfoDialog, showWarningDialog, showErrorDialog, showSuccessDialog, showConfirmDialog } from './DialogModal';
|
||||
export { TenantSwitcher } from './TenantSwitcher';
|
||||
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
|
||||
export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { EmptyState } from './EmptyState';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -42,4 +46,8 @@ export type { StatusIndicatorProps } from './StatusIndicator';
|
||||
export type { ListItemProps } from './ListItem';
|
||||
export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps } from './Stats';
|
||||
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';
|
||||
export type { StatusModalProps, StatusModalField, StatusModalSection, StatusModalAction } from './StatusModal';
|
||||
export type { EditViewModalProps, EditViewModalField, EditViewModalSection, EditViewModalAction } from './EditViewModal';
|
||||
export type { AddModalProps, AddModalField, AddModalSection } from './AddModal';
|
||||
export type { DialogModalProps, DialogModalAction } from './DialogModal';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
Reference in New Issue
Block a user