Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -256,6 +256,13 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
};
const handleSave = async (formData: Record<string, any>) => {
// Debug: Log the ingredients array to understand what's being submitted
console.log('=== Recipe Save Debug ===');
console.log('formData.ingredients:', JSON.stringify(formData.ingredients, null, 2));
console.log('Type of formData.ingredients:', typeof formData.ingredients);
console.log('Is array:', Array.isArray(formData.ingredients));
console.log('========================');
// Validate ingredients
const ingredientError = validateIngredients(formData.ingredients || []);
if (ingredientError) {
@@ -284,6 +291,19 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
(Number(formData.cook_time_minutes) || 0) +
(Number(formData.rest_time_minutes) || 0);
// Filter and validate ingredients before creating the recipe
const validIngredients = (formData.ingredients || [])
.filter((ing: RecipeIngredientCreate) => ing.ingredient_id && ing.ingredient_id.trim() !== '')
.map((ing: RecipeIngredientCreate, index: number) => ({
...ing,
ingredient_order: index + 1
}));
// Ensure we have at least one valid ingredient
if (validIngredients.length === 0) {
throw new Error('Debe agregar al menos un ingrediente válido con un ingrediente seleccionado');
}
const recipeData: RecipeCreate = {
name: formData.name,
recipe_code: recipeCode,
@@ -300,13 +320,10 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
total_time_minutes: totalTime,
rest_time_minutes: Number(formData.rest_time_minutes) || 0,
target_margin_percentage: Number(formData.target_margin_percentage) || 30,
instructions: formData.preparation_notes ? { steps: formData.preparation_notes } : null,
instructions: formData.instructions_text ? { steps: formData.instructions_text } : null,
preparation_notes: formData.preparation_notes || '',
storage_instructions: formData.storage_instructions || '',
quality_standards: formData.quality_standards || '',
quality_check_configuration: null,
quality_check_points: null,
common_issues: null,
serves_count: Number(formData.serves_count) || 1,
is_seasonal: formData.is_seasonal || false,
season_start_month: formData.is_seasonal ? Number(formData.season_start_month) : undefined,
@@ -320,14 +337,16 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
allergen_info: formData.allergen_info_text ? { allergens: formData.allergen_info_text.split(',').map((a: string) => a.trim()) } : null,
dietary_tags: formData.dietary_tags_text ? { tags: formData.dietary_tags_text.split(',').map((t: string) => t.trim()) } : null,
nutritional_info: formData.nutritional_info_text ? { info: formData.nutritional_info_text } : null,
// Use the ingredients from form data
ingredients: (formData.ingredients || []).filter((ing: RecipeIngredientCreate) => ing.ingredient_id.trim() !== '')
.map((ing: RecipeIngredientCreate, index: number) => ({
...ing,
ingredient_order: index + 1
}))
// Use the validated ingredients list
ingredients: validIngredients
};
// Debug: Log the final payload before sending to API
console.log('=== Final Recipe Payload ===');
console.log('recipeData:', JSON.stringify(recipeData, null, 2));
console.log('ingredients count:', recipeData.ingredients.length);
console.log('===========================');
if (onCreateRecipe) {
await onCreateRecipe(recipeData);
}
@@ -572,7 +591,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
]
},
{
title: 'Instrucciones y Calidad',
title: 'Instrucciones',
icon: FileText,
fields: [
{
@@ -590,14 +609,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
placeholder: 'Como conservar el producto terminado...',
span: 2,
helpText: 'Condiciones de almacenamiento del producto final'
},
{
label: 'Estándares de calidad',
name: 'quality_standards',
type: 'textarea' as const,
placeholder: 'Criterios de calidad que debe cumplir...',
span: 2,
helpText: 'Criterios que debe cumplir el producto final'
}
]
},
@@ -621,6 +632,21 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
}
]
},
{
title: 'Instrucciones Detalladas',
icon: FileText,
columns: 1,
fields: [
{
label: 'Instrucciones de preparación',
name: 'instructions_text',
type: 'textarea' as const,
placeholder: 'Describir paso a paso el proceso de elaboración...',
helpText: 'Instrucciones detalladas para la preparación',
span: 2
}
]
},
{
title: 'Información Nutricional',
icon: Package,

View File

@@ -0,0 +1,376 @@
import React, { useState, useEffect } from 'react';
import { Trash2, AlertTriangle, Info } from 'lucide-react';
import { Modal, Button } from '../../ui';
import { RecipeResponse, RecipeDeletionSummary } from '../../../api/types/recipes';
import { useTranslation } from 'react-i18next';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useRecipeDeletionSummary } from '../../../api/hooks/recipes';
type DeleteMode = 'soft' | 'hard';
interface DeleteRecipeModalProps {
isOpen: boolean;
onClose: () => void;
recipe: RecipeResponse | null;
onSoftDelete: (recipeId: string) => Promise<void>;
onHardDelete: (recipeId: string) => Promise<void>;
isLoading?: boolean;
}
/**
* Modal for recipe deletion with soft/hard delete options
* - Soft delete: Archive recipe (reversible)
* - Hard delete: Permanent deletion with dependency checking
*/
export const DeleteRecipeModal: React.FC<DeleteRecipeModalProps> = ({
isOpen,
onClose,
recipe,
onSoftDelete,
onHardDelete,
isLoading = false,
}) => {
const { t } = useTranslation(['recipes', 'common']);
const currentTenant = useCurrentTenant();
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [deletionComplete, setDeletionComplete] = useState(false);
// Fetch deletion summary when modal opens for hard delete
const { data: deletionSummary, isLoading: summaryLoading } = useRecipeDeletionSummary(
currentTenant?.id || '',
recipe?.id || '',
{
enabled: isOpen && !!recipe && selectedMode === 'hard' && showConfirmation,
}
);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setShowConfirmation(false);
setSelectedMode('soft');
setConfirmText('');
setDeletionComplete(false);
}
}, [isOpen]);
if (!recipe) return null;
const handleDeleteModeSelect = (mode: DeleteMode) => {
setSelectedMode(mode);
setShowConfirmation(true);
setConfirmText('');
};
const handleConfirmDelete = async () => {
try {
if (selectedMode === 'hard') {
await onHardDelete(recipe.id);
} else {
await onSoftDelete(recipe.id);
}
setDeletionComplete(true);
// Auto-close after 1.5 seconds
setTimeout(() => {
handleClose();
}, 1500);
} catch (error) {
console.error('Error deleting recipe:', error);
// Error handling is done by parent component
}
};
const handleClose = () => {
setShowConfirmation(false);
setSelectedMode('soft');
setConfirmText('');
setDeletionComplete(false);
onClose();
};
const isConfirmDisabled =
selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR';
// Show deletion success
if (deletionComplete) {
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
{selectedMode === 'hard'
? t('recipes:delete.success_hard_title', 'Receta Eliminada')
: t('recipes:delete.success_soft_title', 'Receta Archivada')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{selectedMode === 'hard'
? t('recipes:delete.recipe_deleted', { name: recipe.name })
: t('recipes:delete.recipe_archived', { name: recipe.name })}
</p>
</div>
</div>
</div>
</Modal>
);
}
// Show confirmation step
if (showConfirmation) {
const isHardDelete = selectedMode === 'hard';
const canDelete = !isHardDelete || (deletionSummary?.can_delete !== false);
return (
<Modal isOpen={isOpen} onClose={handleClose} size="md">
<div className="p-6">
<div className="flex items-start gap-4 mb-6">
<div className="flex-shrink-0">
{isHardDelete ? (
<AlertTriangle className="w-8 h-8 text-red-500" />
) : (
<Info className="w-8 h-8 text-orange-500" />
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
{isHardDelete
? t('recipes:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
: t('recipes:delete.confirm_soft_title', 'Confirmación de Archivo')}
</h3>
<div className="mb-4">
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
</p>
</div>
{isHardDelete ? (
<>
{summaryLoading ? (
<div className="text-center py-4">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--text-primary)]"></div>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{t('recipes:delete.checking_dependencies', 'Verificando dependencias...')}
</p>
</div>
) : deletionSummary && !canDelete ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<p className="font-medium text-red-700 dark:text-red-400 mb-2">
{t('recipes:delete.cannot_delete', '⚠️ No se puede eliminar esta receta')}
</p>
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
{deletionSummary.warnings.map((warning, idx) => (
<li key={idx}> {warning}</li>
))}
</ul>
</div>
) : (
<div className="text-red-600 dark:text-red-400 mb-4">
<p className="font-medium mb-2">
{t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
</p>
<ul className="text-sm space-y-1 ml-4">
<li>{t('recipes:delete.hard_warning_1', '• La receta y toda su información')}</li>
<li>{t('recipes:delete.hard_warning_2', '• Todos los ingredientes asociados')}</li>
{deletionSummary && (
<>
{deletionSummary.production_batches_count > 0 && (
<li>{t('recipes:delete.batches_affected', { count: deletionSummary.production_batches_count }, `${deletionSummary.production_batches_count} lotes de producción`)}</li>
)}
{deletionSummary.affected_orders_count > 0 && (
<li>{t('recipes:delete.orders_affected', { count: deletionSummary.affected_orders_count }, `${deletionSummary.affected_orders_count} pedidos afectados`)}</li>
)}
</>
)}
</ul>
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
{t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer')}
</p>
</div>
)}
</>
) : (
<div className="text-orange-600 dark:text-orange-400 mb-4">
<p className="font-medium mb-2">
{t('recipes:delete.soft_info_title', ' Esta acción archivará la receta:')}
</p>
<ul className="text-sm space-y-1 ml-4">
<li>{t('recipes:delete.soft_info_1', '• La receta cambiará a estado ARCHIVADO')}</li>
<li>{t('recipes:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
<li>{t('recipes:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
<li>{t('recipes:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
</ul>
</div>
)}
</div>
{isHardDelete && canDelete && (
<div className="mb-6">
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
{t('recipes:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
placeholder={t('recipes:delete.type_placeholder', 'Escriba ELIMINAR')}
autoComplete="off"
/>
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmation(false)}
disabled={isLoading}
>
{t('common:back', 'Volver')}
</Button>
{(!isHardDelete || canDelete) && (
<Button
variant={isHardDelete ? 'danger' : 'warning'}
onClick={handleConfirmDelete}
disabled={isConfirmDisabled || isLoading || summaryLoading}
isLoading={isLoading}
>
{isHardDelete
? t('recipes:delete.confirm_hard', 'Eliminar Permanentemente')
: t('recipes:delete.confirm_soft', 'Archivar Receta')}
</Button>
)}
</div>
</div>
</Modal>
);
}
// Initial mode selection
return (
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
<div className="p-6">
<div className="mb-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{t('recipes:delete.title', 'Eliminar Receta')}
</h2>
</div>
<div className="mb-6">
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
</p>
</div>
</div>
<div className="mb-6">
<p className="text-[var(--text-primary)] mb-4">
{t('recipes:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
</p>
<div className="space-y-4">
{/* Soft Delete Option */}
<div
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'soft'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
: 'border-[var(--border-color)] hover:border-orange-300'
}`}
onClick={() => setSelectedMode('soft')}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className={`w-4 h-4 rounded-full border-2 ${
selectedMode === 'soft'
? 'border-orange-500 bg-orange-500'
: 'border-[var(--border-color)]'
}`}>
{selectedMode === 'soft' && (
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
)}
</div>
</div>
<div>
<h3 className="font-medium text-[var(--text-primary)] mb-1">
{t('recipes:delete.soft_delete', 'Archivar (Recomendado)')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.')}
</p>
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
{t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
</div>
</div>
</div>
</div>
{/* Hard Delete Option */}
<div
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
selectedMode === 'hard'
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
: 'border-[var(--border-color)] hover:border-red-300'
}`}
onClick={() => setSelectedMode('hard')}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
<div className={`w-4 h-4 rounded-full border-2 ${
selectedMode === 'hard'
? 'border-red-500 bg-red-500'
: 'border-[var(--border-color)]'
}`}>
{selectedMode === 'hard' && (
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
)}
</div>
</div>
<div>
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
{t('recipes:delete.hard_delete', 'Eliminar Permanentemente')}
<AlertTriangle className="w-4 h-4 text-red-500" />
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
</p>
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
{t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex gap-3 justify-end">
<Button variant="outline" onClick={handleClose}>
{t('common:cancel', 'Cancelar')}
</Button>
<Button
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
onClick={() => handleDeleteModeSelect(selectedMode)}
>
<Trash2 className="w-4 h-4 mr-2" />
{t('common:continue', 'Continuar')}
</Button>
</div>
</div>
</Modal>
);
};

View File

@@ -59,7 +59,11 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
return existing || {
stages: {},
global_parameters: {},
default_templates: []
default_templates: [],
overall_quality_threshold: 7.0,
critical_stage_blocking: true,
auto_create_quality_checks: true,
quality_manager_approval_required: false
};
});
@@ -76,7 +80,11 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
setConfiguration(existing || {
stages: {},
global_parameters: {},
default_templates: []
default_templates: [],
overall_quality_threshold: 7.0,
critical_stage_blocking: true,
auto_create_quality_checks: true,
quality_manager_approval_required: false
});
}, [recipe]);
@@ -149,6 +157,16 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
});
};
const handleGlobalSettingChange = (
setting: 'overall_quality_threshold' | 'critical_stage_blocking' | 'auto_create_quality_checks' | 'quality_manager_approval_required',
value: number | boolean
) => {
setConfiguration(prev => ({
...prev,
[setting]: value
}));
};
const handleSave = async () => {
try {
await onSaveConfiguration(configuration);
@@ -225,6 +243,86 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
</div>
</Card>
{/* Global Settings */}
<Card className="p-4">
<h4 className="font-medium text-[var(--text-primary)] mb-4 flex items-center gap-2">
<Settings className="w-5 h-5" />
Configuración Global
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Umbral de Calidad Mínimo
</label>
<input
type="number"
min="0"
max="10"
step="0.1"
value={configuration.overall_quality_threshold || 7.0}
onChange={(e) => handleGlobalSettingChange('overall_quality_threshold', parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
/>
<p className="text-xs text-[var(--text-secondary)] mt-1">
Puntuación mínima requerida (0-10)
</p>
</div>
<div className="space-y-3">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={configuration.critical_stage_blocking || false}
onChange={(e) => handleGlobalSettingChange('critical_stage_blocking', e.target.checked)}
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
/>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">
Bloqueo en Etapas Críticas
</span>
<p className="text-xs text-[var(--text-secondary)]">
Bloquear progreso si fallan checks críticos
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={configuration.auto_create_quality_checks || false}
onChange={(e) => handleGlobalSettingChange('auto_create_quality_checks', e.target.checked)}
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
/>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">
Auto-crear Controles
</span>
<p className="text-xs text-[var(--text-secondary)]">
Crear automáticamente checks al iniciar lote
</p>
</div>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={configuration.quality_manager_approval_required || false}
onChange={(e) => handleGlobalSettingChange('quality_manager_approval_required', e.target.checked)}
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
/>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">
Aprobación Requerida
</span>
<p className="text-xs text-[var(--text-secondary)]">
Requiere aprobación del gerente de calidad
</p>
</div>
</label>
</div>
</div>
</Card>
{/* Process Stages Configuration */}
<div className="space-y-4">
<h4 className="font-medium text-[var(--text-primary)] flex items-center gap-2">

View File

@@ -1,2 +1,3 @@
export { CreateRecipeModal } from './CreateRecipeModal';
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
export { DeleteRecipeModal } from './DeleteRecipeModal';