2025-09-26 07:46:25 +02:00
|
|
|
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] ?? '';
|
2025-10-21 19:50:07 +02:00
|
|
|
const isFieldDisabled = fieldConfig.disabled ?? false;
|
2025-09-26 07:46:25 +02:00
|
|
|
|
|
|
|
|
switch (fieldConfig.type) {
|
|
|
|
|
case 'select':
|
|
|
|
|
return (
|
|
|
|
|
<select
|
|
|
|
|
value={fieldValue}
|
|
|
|
|
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
2025-10-21 19:50:07 +02:00
|
|
|
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"
|
2025-09-26 07:46:25 +02:00
|
|
|
required={fieldConfig.required}
|
2025-10-21 19:50:07 +02:00
|
|
|
disabled={isFieldDisabled}
|
2025-09-26 07:46:25 +02:00
|
|
|
>
|
2025-10-21 19:50:07 +02:00
|
|
|
<option value="">{fieldConfig.placeholder || 'Seleccionar...'}</option>
|
2025-09-26 07:46:25 +02:00
|
|
|
{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)}
|
2025-10-21 19:50:07 +02:00
|
|
|
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"
|
2025-09-26 07:46:25 +02:00
|
|
|
min="0"
|
|
|
|
|
step={fieldConfig.type === 'currency' ? '0.01' : '0.1'}
|
|
|
|
|
placeholder={fieldConfig.placeholder}
|
|
|
|
|
required={fieldConfig.required}
|
2025-10-21 19:50:07 +02:00
|
|
|
disabled={isFieldDisabled}
|
2025-09-26 07:46:25 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
default: // text
|
|
|
|
|
return (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={fieldValue}
|
|
|
|
|
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
2025-10-21 19:50:07 +02:00
|
|
|
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"
|
2025-09-26 07:46:25 +02:00
|
|
|
placeholder={fieldConfig.placeholder}
|
|
|
|
|
required={fieldConfig.required}
|
2025-10-21 19:50:07 +02:00
|
|
|
disabled={isFieldDisabled}
|
2025-09-26 07:46:25 +02:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
const isDisabled = listConfig.disabled ?? false;
|
2025-12-10 11:23:53 +01:00
|
|
|
const disableRemove = listConfig.disableRemove ?? false;
|
2025-10-21 19:50:07 +02:00
|
|
|
|
2025-09-26 07:46:25 +02:00
|
|
|
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}
|
2025-10-21 19:50:07 +02:00
|
|
|
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'
|
|
|
|
|
}`}
|
2025-09-26 07:46:25 +02:00
|
|
|
>
|
|
|
|
|
<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>
|
2025-10-21 19:50:07 +02:00
|
|
|
{!isDisabled && <p className="text-sm">Haz clic en "{listConfig.addButtonLabel || 'Agregar'}" para comenzar</p>}
|
2025-09-26 07:46:25 +02:00
|
|
|
</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>
|
2025-12-10 11:23:53 +01:00
|
|
|
{!disableRemove && (
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
2025-09-26 07:46:25 +02:00
|
|
|
</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;
|
2025-10-21 19:50:07 +02:00
|
|
|
disabled?: boolean;
|
2025-09-26 07:46:25 +02:00
|
|
|
}>;
|
|
|
|
|
addButtonLabel?: string;
|
|
|
|
|
removeButtonLabel?: string;
|
|
|
|
|
emptyStateText?: string;
|
|
|
|
|
showSubtotals?: boolean; // For calculating item totals
|
|
|
|
|
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
|
2025-10-21 19:50:07 +02:00
|
|
|
disabled?: boolean; // Disable adding new items
|
2025-12-10 11:23:53 +01:00
|
|
|
disableRemove?: boolean; // Disable removing existing items
|
2025-09-26 07:46:25 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-10-23 07:44:54 +02:00
|
|
|
|
|
|
|
|
// Field change callback for dynamic form behavior
|
|
|
|
|
onFieldChange?: (fieldName: string, value: any) => void;
|
2025-10-27 16:33:26 +01:00
|
|
|
|
|
|
|
|
// Wait-for-refetch support (Option A approach)
|
|
|
|
|
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
|
|
|
|
isRefetching?: boolean; // External refetch state (from React Query)
|
|
|
|
|
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
|
|
|
|
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
|
|
|
|
showSuccessState?: boolean; // Show brief success state before closing (default: true)
|
2025-09-26 07:46:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
2025-10-23 07:44:54 +02:00
|
|
|
onFieldChange,
|
2025-10-27 16:33:26 +01:00
|
|
|
// Wait-for-refetch support
|
|
|
|
|
waitForRefetch = false,
|
|
|
|
|
isRefetching = false,
|
|
|
|
|
onSaveComplete,
|
|
|
|
|
refetchTimeout = 3000,
|
|
|
|
|
showSuccessState = true,
|
2025-09-26 07:46:25 +02:00
|
|
|
}) => {
|
|
|
|
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
|
|
|
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
2025-10-27 16:33:26 +01:00
|
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
|
|
|
|
const [showSuccess, setShowSuccess] = useState(false);
|
2025-09-26 07:46:25 +02:00
|
|
|
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) => {
|
2025-10-27 16:33:26 +01:00
|
|
|
// Debug logging for ingredients field
|
|
|
|
|
if (fieldName === 'ingredients') {
|
|
|
|
|
console.log('=== AddModal Field Change (ingredients) ===');
|
|
|
|
|
console.log('New value:', value);
|
|
|
|
|
console.log('Type:', typeof value);
|
|
|
|
|
console.log('Is array:', Array.isArray(value));
|
|
|
|
|
console.log('==========================================');
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-26 07:46:25 +02:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-23 07:44:54 +02:00
|
|
|
|
|
|
|
|
// Notify parent component of field change
|
|
|
|
|
onFieldChange?.(fieldName, value);
|
2025-09-26 07:46:25 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 {
|
2025-10-27 16:33:26 +01:00
|
|
|
setIsSaving(true);
|
|
|
|
|
|
|
|
|
|
// Execute the save mutation
|
2025-09-26 07:46:25 +02:00
|
|
|
await onSave(formData);
|
2025-10-27 16:33:26 +01:00
|
|
|
|
|
|
|
|
// If waitForRefetch is enabled, wait for data to refresh
|
|
|
|
|
if (waitForRefetch && onSaveComplete) {
|
|
|
|
|
setIsWaitingForRefetch(true);
|
|
|
|
|
|
|
|
|
|
// Trigger the refetch
|
|
|
|
|
await onSaveComplete();
|
|
|
|
|
|
|
|
|
|
// Wait for isRefetching to become true then false, or timeout
|
|
|
|
|
const startTime = Date.now();
|
|
|
|
|
const checkRefetch = () => {
|
|
|
|
|
return new Promise<void>((resolve) => {
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
|
|
|
|
|
|
// Timeout reached
|
|
|
|
|
if (elapsed >= refetchTimeout) {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
console.warn('Refetch timeout reached, proceeding anyway');
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Refetch completed (was true, now false)
|
|
|
|
|
if (!isRefetching) {
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
}, 100);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await checkRefetch();
|
|
|
|
|
setIsWaitingForRefetch(false);
|
|
|
|
|
|
|
|
|
|
// Show success state briefly
|
|
|
|
|
if (showSuccessState) {
|
|
|
|
|
setShowSuccess(true);
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 800));
|
|
|
|
|
setShowSuccess(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Close modal after save (and optional refetch) completes
|
2025-09-26 07:46:25 +02:00
|
|
|
onClose();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error saving form:', error);
|
|
|
|
|
// Don't close modal on error - let the parent handle error display
|
2025-10-27 16:33:26 +01:00
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
setIsWaitingForRefetch(false);
|
|
|
|
|
setShowSuccess(false);
|
2025-09-26 07:46:25 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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
|
2025-10-27 16:33:26 +01:00
|
|
|
value={value}
|
2025-09-26 07:46:25 +02:00
|
|
|
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;
|
2025-10-27 16:33:26 +01:00
|
|
|
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
2025-09-26 07:46:25 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Modal
|
|
|
|
|
isOpen={isOpen}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
size={size}
|
2025-10-27 16:33:26 +01:00
|
|
|
closeOnOverlayClick={!isProcessing}
|
|
|
|
|
closeOnEscape={!isProcessing}
|
2025-09-26 07:46:25 +02:00
|
|
|
showCloseButton={false}
|
|
|
|
|
>
|
|
|
|
|
<ModalHeader
|
|
|
|
|
title={
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
{/* Status indicator */}
|
|
|
|
|
<div
|
2025-10-27 16:33:26 +01:00
|
|
|
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
|
|
|
|
defaultStatusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
|
|
|
|
} ${defaultStatusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: `${defaultStatusIndicator.color}15`,
|
|
|
|
|
...(defaultStatusIndicator.isCritical && { ringColor: defaultStatusIndicator.color })
|
|
|
|
|
}}
|
2025-09-26 07:46:25 +02:00
|
|
|
>
|
|
|
|
|
{StatusIcon && (
|
|
|
|
|
<StatusIcon
|
|
|
|
|
className="w-5 h-5"
|
|
|
|
|
style={{ color: defaultStatusIndicator.color }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-27 16:33:26 +01:00
|
|
|
{/* Title and subtitle */}
|
|
|
|
|
<div className="flex-1">
|
2025-09-26 07:46:25 +02:00
|
|
|
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
|
|
|
|
{title}
|
|
|
|
|
</h2>
|
|
|
|
|
{subtitle && (
|
2025-10-27 16:33:26 +01:00
|
|
|
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
2025-09-26 07:46:25 +02:00
|
|
|
{subtitle}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
showCloseButton={true}
|
|
|
|
|
onClose={onClose}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<ModalBody>
|
2025-10-27 16:33:26 +01:00
|
|
|
{(loading || isSaving || isWaitingForRefetch || showSuccess) && (
|
2025-09-26 07:46:25 +02:00
|
|
|
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
2025-10-27 16:33:26 +01:00
|
|
|
<div className="flex flex-col items-center gap-3">
|
|
|
|
|
{!showSuccess ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
|
|
|
|
<span className="text-[var(--text-secondary)]">
|
|
|
|
|
{isWaitingForRefetch
|
|
|
|
|
? t('common:modals.refreshing', 'Actualizando datos...')
|
|
|
|
|
: isSaving
|
|
|
|
|
? t('common:modals.saving', 'Guardando...')
|
|
|
|
|
: t('common:modals.loading', 'Cargando...')}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-green-500 text-4xl">✓</div>
|
|
|
|
|
<span className="text-[var(--text-secondary)] font-medium">
|
|
|
|
|
{t('common:modals.success', 'Guardado correctamente')}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2025-09-26 07:46:25 +02:00
|
|
|
</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}
|
2025-10-27 16:33:26 +01:00
|
|
|
disabled={loading || isSaving || isWaitingForRefetch}
|
2025-09-26 07:46:25 +02:00
|
|
|
className="min-w-[80px]"
|
|
|
|
|
>
|
2025-10-21 19:50:07 +02:00
|
|
|
{t('common:modals.actions.cancel', 'Cancelar')}
|
2025-09-26 07:46:25 +02:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
onClick={handleSave}
|
2025-10-27 16:33:26 +01:00
|
|
|
disabled={loading || isSaving || isWaitingForRefetch}
|
2025-09-26 07:46:25 +02:00
|
|
|
className="min-w-[80px]"
|
|
|
|
|
>
|
2025-10-27 16:33:26 +01:00
|
|
|
{loading || isSaving || isWaitingForRefetch ? (
|
2025-09-26 07:46:25 +02:00
|
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
|
|
|
|
) : (
|
2025-10-21 19:50:07 +02:00
|
|
|
t('common:modals.actions.save', 'Guardar')
|
2025-09-26 07:46:25 +02:00
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</ModalFooter>
|
|
|
|
|
</Modal>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default AddModal;
|