Files
bakery-ia/frontend/src/components/ui/AddModal/AddModal.tsx
2025-10-21 19:50:07 +02:00

722 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] ?? '';
const isFieldDisabled = fieldConfig.disabled ?? false;
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 disabled:opacity-50 disabled:cursor-not-allowed"
required={fieldConfig.required}
disabled={isFieldDisabled}
>
<option value="">{fieldConfig.placeholder || '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 disabled:opacity-50 disabled:cursor-not-allowed"
min="0"
step={fieldConfig.type === 'currency' ? '0.01' : '0.1'}
placeholder={fieldConfig.placeholder}
required={fieldConfig.required}
disabled={isFieldDisabled}
/>
);
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 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={fieldConfig.placeholder}
required={fieldConfig.required}
disabled={isFieldDisabled}
/>
);
}
};
const isDisabled = listConfig.disabled ?? false;
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}
disabled={isDisabled}
className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
isDisabled
? 'bg-gray-300 text-gray-500 cursor-not-allowed opacity-50'
: 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90'
}`}
>
<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>
{!isDisabled && <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;
disabled?: boolean;
}>;
addButtonLabel?: string;
removeButtonLabel?: string;
emptyStateText?: string;
showSubtotals?: boolean; // For calculating item totals
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
disabled?: boolean; // Disable adding new items
};
}
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]"
>
{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>
) : (
t('common:modals.actions.save', 'Guardar')
)}
</Button>
</div>
</ModalFooter>
</Modal>
);
};
export default AddModal;