Files
bakery-ia/frontend/src/components/domain/inventory/QuickAddIngredientModal.tsx
Claude 000e352ef9 Implement 5 UX enhancements for ingredient management
This commit implements the requested enhancements for the ingredient
quick-add system and batch management:

**1. Duplicate Detection**
- Real-time Levenshtein distance-based similarity checking
- Shows warning with top 3 similar ingredients (70%+ similarity)
- Prevents accidental duplicate creation
- Location: QuickAddIngredientModal.tsx

**2. Smart Category Suggestions**
- Auto-populates category based on ingredient name patterns
- Supports Spanish and English ingredient names
- Shows visual indicator when category is AI-suggested
- Pattern matching for: Baking, Dairy, Fruits, Vegetables, Meat, Seafood, Spices
- Location: ingredientHelpers.ts

**3. Quick Templates**
- 10 pre-configured common bakery ingredients
- One-click template application
- Templates include: Flour, Butter, Sugar, Eggs, Yeast, Milk, Chocolate, Vanilla, Salt, Cream
- Each template has sensible defaults (shelf life, refrigeration requirements)
- Location: QuickAddIngredientModal.tsx

**4. Batch Creation Mode**
- BatchAddIngredientsModal component for adding multiple ingredients at once
- Table-based interface for efficient data entry
- "Load from Templates" quick action
- Duplicate detection within batch
- Partial success handling (some ingredients succeed, some fail)
- Location: BatchAddIngredientsModal.tsx
- Integration: UploadSalesDataStep.tsx (2 buttons: "Add One" / "Add Multiple")

**5. Dashboard Alert for Incomplete Ingredients**
- IncompleteIngredientsAlert component on dashboard
- Queries ingredients with needs_review metadata flag
- Shows count badge and first 5 incomplete ingredients
- "Complete Information" button links to inventory page
- Only shows when incomplete ingredients exist
- Location: IncompleteIngredientsAlert.tsx
- Integration: DashboardPage.tsx

**New Files Created:**
- ingredientHelpers.ts - Utilities for duplicate detection, smart suggestions, templates
- BatchAddIngredientsModal.tsx - Batch ingredient creation component
- IncompleteIngredientsAlert.tsx - Dashboard alert component

**Files Modified:**
- QuickAddIngredientModal.tsx - Added duplicate detection, smart suggestions, templates
- UploadSalesDataStep.tsx - Integrated batch creation modal
- DashboardPage.tsx - Added incomplete ingredients alert

**Technical Highlights:**
- Levenshtein distance algorithm for fuzzy name matching
- Pattern-based category suggestions (supports 100+ ingredient patterns)
- Metadata tracking (needs_review, created_context)
- Real-time validation and error handling
- Responsive UI with animations
- Consistent with existing design system

All features built and tested successfully.
Build time: 21.29s
2025-11-06 15:39:30 +00:00

658 lines
28 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 } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateIngredient, useIngredients } from '../../../api/hooks/inventory';
import type { Ingredient } from '../../../api/types/inventory';
import {
findSimilarIngredients,
suggestCategory,
commonIngredientTemplates,
type IngredientTemplate
} from './ingredientHelpers';
interface QuickAddIngredientModalProps {
isOpen: boolean;
onClose: () => void;
onCreated: (ingredient: Ingredient) => void;
tenantId: string;
context: 'recipe' | 'supplier' | 'standalone';
}
export const QuickAddIngredientModal: React.FC<QuickAddIngredientModalProps> = ({
isOpen,
onClose,
onCreated,
tenantId,
context
}) => {
const { t } = useTranslation();
const createIngredient = useCreateIngredient();
// Fetch existing ingredients for duplicate detection
const { data: existingIngredients = [] } = useIngredients(tenantId, {}, {
enabled: isOpen && !!tenantId
});
// Form state - minimal required fields
const [formData, setFormData] = useState({
name: '',
category: '',
unit_of_measure: 'kg',
// Optional fields (collapsed by default)
stock_quantity: 0,
cost_per_unit: 0,
estimated_shelf_life_days: 30,
low_stock_threshold: 0,
reorder_point: 0,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false,
notes: '',
});
const [showOptionalFields, setShowOptionalFields] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [showTemplates, setShowTemplates] = useState(false);
const [similarIngredients, setSimilarIngredients] = useState<Array<{ id: string; name: string; similarity: number }>>([]);
const categoryOptions = [
'Baking Ingredients',
'Dairy',
'Fruits',
'Vegetables',
'Meat',
'Seafood',
'Spices',
'Other'
];
const unitOptions = ['kg', 'g', 'L', 'ml', 'units', 'dozen'];
// Check for duplicates when name changes
useEffect(() => {
if (formData.name && formData.name.trim().length >= 3) {
const similar = findSimilarIngredients(
formData.name,
existingIngredients.map(ing => ({ id: ing.id, name: ing.name }))
);
setSimilarIngredients(similar);
} else {
setSimilarIngredients([]);
}
}, [formData.name, existingIngredients]);
// Smart category suggestion when name changes
useEffect(() => {
if (formData.name && formData.name.trim().length >= 3 && !formData.category) {
const suggested = suggestCategory(formData.name);
if (suggested) {
setFormData(prev => ({ ...prev, category: suggested }));
}
}
}, [formData.name]);
const handleApplyTemplate = (template: IngredientTemplate) => {
setFormData({
...formData,
name: template.name,
category: template.category,
unit_of_measure: template.unit_of_measure,
estimated_shelf_life_days: template.estimated_shelf_life_days || 30,
requires_refrigeration: template.requires_refrigeration || false,
requires_freezing: template.requires_freezing || false,
});
setShowTemplates(false);
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'El nombre es requerido';
}
if (!formData.category) {
newErrors.category = 'La categoría es requerida';
}
if (!formData.unit_of_measure) {
newErrors.unit_of_measure = 'La unidad es requerida';
}
// Validate optional fields if shown
if (showOptionalFields) {
if (formData.stock_quantity < 0) {
newErrors.stock_quantity = 'El stock no puede ser negativo';
}
if (formData.cost_per_unit < 0) {
newErrors.cost_per_unit = 'El costo no puede ser negativo';
}
if (formData.estimated_shelf_life_days <= 0) {
newErrors.estimated_shelf_life_days = 'Los días de caducidad deben ser mayores a 0';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsSubmitting(true);
try {
const ingredientData = {
name: formData.name.trim(),
product_type: 'ingredient',
category: formData.category,
unit_of_measure: formData.unit_of_measure,
low_stock_threshold: showOptionalFields ? formData.low_stock_threshold : 1,
max_stock_level: showOptionalFields ? formData.stock_quantity * 2 : 100,
reorder_point: showOptionalFields ? formData.reorder_point : 2,
shelf_life_days: showOptionalFields ? formData.estimated_shelf_life_days : 30,
requires_refrigeration: formData.requires_refrigeration,
requires_freezing: formData.requires_freezing,
is_seasonal: formData.is_seasonal,
average_cost: showOptionalFields ? formData.cost_per_unit : 0,
notes: formData.notes || `Creado durante ${context === 'recipe' ? 'configuración de receta' : 'configuración de proveedor'}`,
// Track that this was created inline
metadata: {
created_context: context,
is_complete: showOptionalFields,
needs_review: !showOptionalFields,
}
};
const createdIngredient = await createIngredient.mutateAsync({
tenantId,
ingredientData
});
// Call parent with created ingredient
onCreated(createdIngredient);
// Reset and close
resetForm();
onClose();
} catch (error) {
console.error('Error creating ingredient:', error);
setErrors({ submit: 'Error al crear el ingrediente. Inténtalo de nuevo.' });
} finally {
setIsSubmitting(false);
}
};
const resetForm = () => {
setFormData({
name: '',
category: '',
unit_of_measure: 'kg',
stock_quantity: 0,
cost_per_unit: 0,
estimated_shelf_life_days: 30,
low_stock_threshold: 0,
reorder_point: 0,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false,
notes: '',
});
setShowOptionalFields(false);
setShowTemplates(false);
setSimilarIngredients([]);
setErrors({});
};
const handleClose = () => {
resetForm();
onClose();
};
if (!isOpen) return null;
const getContextMessage = () => {
switch (context) {
case 'recipe':
return 'El ingrediente se agregará al inventario y estará disponible para usar en esta receta.';
case 'supplier':
return 'El ingrediente se agregará al inventario y podrás asociarlo con este proveedor.';
default:
return 'El ingrediente se agregará a tu inventario.';
}
};
const getCtaText = () => {
switch (context) {
case 'recipe':
return 'Agregar y Usar en Receta';
case 'supplier':
return 'Agregar y Asociar con Proveedor';
default:
return 'Agregar Ingrediente';
}
};
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 animate-fadeIn"
onClick={handleClose}
/>
{/* Modal */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
className="bg-[var(--bg-primary)] rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto pointer-events-auto animate-slideUp"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border-secondary)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--color-primary)]/10 flex items-center justify-center">
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</div>
<div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
Agregar Ingrediente Rápido
</h2>
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
{getContextMessage()}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-5">
{/* Quick Templates */}
{!showTemplates && (
<button
type="button"
onClick={() => setShowTemplates(!showTemplates)}
className="w-full p-3 mb-2 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 border border-[var(--color-primary)]/20 rounded-lg hover:from-[var(--color-primary)]/10 hover:to-[var(--color-primary)]/15 transition-all group"
>
<div className="flex items-center justify-center gap-2 text-[var(--color-primary)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="font-medium"> Usar Plantilla Rápida</span>
<svg className="w-4 h-4 transition-transform group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
)}
{showTemplates && (
<div className="mb-4 p-4 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)] animate-slideDown">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Plantillas Comunes</h3>
<button
type="button"
onClick={() => setShowTemplates(false)}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
Cerrar
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{commonIngredientTemplates.map((template, index) => (
<button
key={index}
type="button"
onClick={() => handleApplyTemplate(template)}
className="p-2.5 bg-[var(--bg-primary)] hover:bg-[var(--bg-tertiary)] border border-[var(--border-secondary)] hover:border-[var(--color-primary)] rounded-lg transition-all text-left group"
>
<div className="flex items-center gap-2">
<span className="text-xl">{template.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--text-primary)] truncate group-hover:text-[var(--color-primary)]">
{template.name}
</div>
<div className="text-xs text-[var(--text-tertiary)]">
{template.category}
</div>
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* Required Fields */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Nombre del Ingrediente *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent text-[var(--text-primary)] transition-all"
placeholder="Ej: Harina de trigo integral"
autoFocus
/>
{errors.name && (
<p className="text-xs text-[var(--color-error)] mt-1.5 flex items-center gap-1">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{errors.name}
</p>
)}
{/* Duplicate Detection Warning */}
{similarIngredients.length > 0 && (
<div className="mt-2 p-2.5 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/30 rounded-lg animate-slideDown">
<div className="flex items-start gap-2">
<svg className="w-4 h-4 text-[var(--color-warning)] mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="flex-1">
<p className="text-xs font-medium text-[var(--color-warning)] mb-1">
Ingredientes similares encontrados:
</p>
<ul className="text-xs text-[var(--text-secondary)] space-y-0.5">
{similarIngredients.map((similar) => (
<li key={similar.id} className="flex items-center gap-2">
<span className="font-medium">{similar.name}</span>
<span className="text-[var(--text-tertiary)]">
({similar.similarity}% similar)
</span>
</li>
))}
</ul>
<p className="text-xs text-[var(--text-tertiary)] mt-1.5">
Verifica que no sea un duplicado antes de continuar.
</p>
</div>
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Categoría *
{formData.category && suggestCategory(formData.name) === formData.category && (
<span className="ml-2 text-xs text-[var(--color-success)] font-normal">
Sugerido automáticamente
</span>
)}
</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent text-[var(--text-primary)] transition-all"
>
<option value="">Seleccionar...</option>
{categoryOptions.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{errors.category && (
<p className="text-xs text-[var(--color-error)] mt-1.5">{errors.category}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Unidad de Medida *
</label>
<select
value={formData.unit_of_measure}
onChange={(e) => setFormData({ ...formData, unit_of_measure: e.target.value })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent text-[var(--text-primary)] transition-all"
>
{unitOptions.map(unit => (
<option key={unit} value={unit}>{unit}</option>
))}
</select>
{errors.unit_of_measure && (
<p className="text-xs text-[var(--color-error)] mt-1.5">{errors.unit_of_measure}</p>
)}
</div>
</div>
</div>
{/* Optional Fields Toggle */}
<div className="border-t border-[var(--border-secondary)] pt-4">
<button
type="button"
onClick={() => setShowOptionalFields(!showOptionalFields)}
className="flex items-center justify-between w-full p-3 bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] rounded-lg transition-colors group"
>
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-[var(--text-secondary)] transition-transform ${showOptionalFields ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-sm font-medium text-[var(--text-primary)]">
Detalles Adicionales (Opcional)
</span>
</div>
<span className="text-xs text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]">
{showOptionalFields ? 'Ocultar' : 'Mostrar'}
</span>
</button>
{/* Optional Fields */}
{showOptionalFields && (
<div className="mt-4 space-y-4 animate-slideDown">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Stock Inicial
</label>
<input
type="number"
min="0"
step="0.01"
value={formData.stock_quantity}
onChange={(e) => setFormData({ ...formData, stock_quantity: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
/>
{errors.stock_quantity && (
<p className="text-xs text-[var(--color-error)] mt-1.5">{errors.stock_quantity}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Costo por Unidad ()
</label>
<input
type="number"
min="0"
step="0.01"
value={formData.cost_per_unit}
onChange={(e) => setFormData({ ...formData, cost_per_unit: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
/>
{errors.cost_per_unit && (
<p className="text-xs text-[var(--color-error)] mt-1.5">{errors.cost_per_unit}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Días de Caducidad
</label>
<input
type="number"
min="1"
value={formData.estimated_shelf_life_days}
onChange={(e) => setFormData({ ...formData, estimated_shelf_life_days: parseInt(e.target.value) || 30 })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
/>
{errors.estimated_shelf_life_days && (
<p className="text-xs text-[var(--color-error)] mt-1.5">{errors.estimated_shelf_life_days}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1.5">
Punto de Reorden
</label>
<input
type="number"
min="0"
value={formData.reorder_point}
onChange={(e) => setFormData({ ...formData, reorder_point: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2.5 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
/>
</div>
</div>
<div className="flex items-center gap-4 flex-wrap">
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input
type="checkbox"
checked={formData.requires_refrigeration}
onChange={(e) => setFormData({ ...formData, requires_refrigeration: e.target.checked })}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
Refrigeración
</label>
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input
type="checkbox"
checked={formData.requires_freezing}
onChange={(e) => setFormData({ ...formData, requires_freezing: e.target.checked })}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
🧊 Congelación
</label>
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)] cursor-pointer">
<input
type="checkbox"
checked={formData.is_seasonal}
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
🌿 Estacional
</label>
</div>
</div>
)}
</div>
{/* Info Box */}
{!showOptionalFields && (
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-3">
<p className="text-sm text-[var(--text-secondary)] flex items-start gap-2">
<svg className="w-4 h-4 text-[var(--color-info)] mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
💡 Puedes completar los detalles de stock y costos después en la gestión de inventario.
</span>
</p>
</div>
)}
{/* Error Message */}
{errors.submit && (
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-3">
<p className="text-sm text-[var(--color-error)] flex items-center gap-2">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{errors.submit}
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={handleClose}
disabled={isSubmitting}
className="flex-1 px-4 py-2.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors font-medium disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center justify-center gap-2"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Agregando...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{getCtaText()}
</>
)}
</button>
</div>
</form>
</div>
</div>
{/* Animation Styles */}
<style>{`
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 500px;
}
}
.animate-fadeIn {
animation: fadeIn 0.2s ease-out;
}
.animate-slideUp {
animation: slideUp 0.3s ease-out;
}
.animate-slideDown {
animation: slideDown 0.3s ease-out;
}
`}</style>
</>
);
};