Files
bakery-ia/frontend/src/components/domain/production/RecipeDisplay.tsx.backup
2025-08-28 10:41:04 +02:00

794 lines
28 KiB
Plaintext

import React, { useState, useCallback, useMemo } from 'react';
import { Card, Button, Badge, Input, Modal, Table, Select } from '../../ui';
import type { Recipe, RecipeIngredient, RecipeInstruction, NutritionalInfo, DifficultyLevel } from '../../../types/production.types';
interface RecipeDisplayProps {
className?: string;
recipe: Recipe;
editable?: boolean;
showNutrition?: boolean;
showCosting?: boolean;
onScaleChange?: (scaleFactor: number, scaledRecipe: Recipe) => void;
onVersionUpdate?: (newVersion: Recipe) => void;
onCostCalculation?: (totalCost: number, costPerUnit: number) => void;
}
interface ScaledIngredient extends RecipeIngredient {
scaledQuantity: number;
scaledCost?: number;
}
interface ScaledInstruction extends RecipeInstruction {
scaledDuration?: number;
}
interface ScaledRecipe extends Omit<Recipe, 'ingredients' | 'instructions'> {
scaledYieldQuantity: number;
scaledTotalTime: number;
ingredients: ScaledIngredient[];
instructions: ScaledInstruction[];
scaleFactor: number;
estimatedTotalCost?: number;
costPerScaledUnit?: number;
}
interface TimerState {
instructionId: string;
duration: number;
remaining: number;
isActive: boolean;
isComplete: boolean;
}
const DIFFICULTY_COLORS = {
[DifficultyLevel.BEGINNER]: 'bg-green-100 text-green-800',
[DifficultyLevel.INTERMEDIATE]: 'bg-yellow-100 text-yellow-800',
[DifficultyLevel.ADVANCED]: 'bg-orange-100 text-orange-800',
[DifficultyLevel.EXPERT]: 'bg-red-100 text-red-800',
};
const DIFFICULTY_LABELS = {
[DifficultyLevel.BEGINNER]: 'Principiante',
[DifficultyLevel.INTERMEDIATE]: 'Intermedio',
[DifficultyLevel.ADVANCED]: 'Avanzado',
[DifficultyLevel.EXPERT]: 'Experto',
};
const ALLERGEN_ICONS: Record<string, string> = {
gluten: '🌾',
milk: '🥛',
eggs: '🥚',
nuts: '🥜',
soy: '🫘',
sesame: '🌰',
fish: '🐟',
shellfish: '🦐',
};
const EQUIPMENT_ICONS: Record<string, string> = {
oven: '🔥',
mixer: '🥄',
scale: '⚖️',
bowl: '🥣',
whisk: '🔄',
spatula: '🍴',
thermometer: '🌡️',
timer: '⏰',
};
export const RecipeDisplay: React.FC<RecipeDisplayProps> = ({
className = '',
recipe,
editable = false,
showNutrition = true,
showCosting = false,
onScaleChange,
onVersionUpdate,
onCostCalculation,
}) => {
const [scaleFactor, setScaleFactor] = useState<number>(1);
const [activeTimers, setActiveTimers] = useState<Record<string, TimerState>>({});
const [isNutritionModalOpen, setIsNutritionModalOpen] = useState(false);
const [isCostingModalOpen, setIsCostingModalOpen] = useState(false);
const [isEquipmentModalOpen, setIsEquipmentModalOpen] = useState(false);
const [selectedInstruction, setSelectedInstruction] = useState<RecipeInstruction | null>(null);
const [showAllergensDetail, setShowAllergensDetail] = useState(false);
// Mock ingredient costs for demonstration
const ingredientCosts: Record<string, number> = {
flour: 1.2, // €/kg
water: 0.001, // €/l
salt: 0.8, // €/kg
yeast: 8.5, // €/kg
sugar: 1.5, // €/kg
butter: 6.2, // €/kg
milk: 1.3, // €/l
eggs: 0.25, // €/unit
};
const scaledRecipe = useMemo((): ScaledRecipe => {
const scaledIngredients: ScaledIngredient[] = recipe.ingredients.map(ingredient => ({
...ingredient,
scaledQuantity: ingredient.quantity * scaleFactor,
scaledCost: ingredientCosts[ingredient.ingredient_id]
? ingredientCosts[ingredient.ingredient_id] * ingredient.quantity * scaleFactor
: undefined,
}));
const scaledInstructions: ScaledInstruction[] = recipe.instructions.map(instruction => ({
...instruction,
scaledDuration: instruction.duration_minutes ? Math.ceil(instruction.duration_minutes * scaleFactor) : undefined,
}));
const estimatedTotalCost = scaledIngredients.reduce((total, ingredient) =>
total + (ingredient.scaledCost || 0), 0
);
const scaledYieldQuantity = recipe.yield_quantity * scaleFactor;
const costPerScaledUnit = scaledYieldQuantity > 0 ? estimatedTotalCost / scaledYieldQuantity : 0;
return {
...recipe,
scaledYieldQuantity,
scaledTotalTime: Math.ceil(recipe.total_time_minutes * scaleFactor),
ingredients: scaledIngredients,
instructions: scaledInstructions,
scaleFactor,
estimatedTotalCost,
costPerScaledUnit,
};
}, [recipe, scaleFactor, ingredientCosts]);
const handleScaleChange = useCallback((newScaleFactor: number) => {
setScaleFactor(newScaleFactor);
if (onScaleChange) {
onScaleChange(newScaleFactor, scaledRecipe as unknown as Recipe);
}
}, [scaledRecipe, onScaleChange]);
const startTimer = (instruction: RecipeInstruction) => {
if (!instruction.duration_minutes) return;
const timerState: TimerState = {
instructionId: instruction.step_number.toString(),
duration: instruction.duration_minutes * 60, // Convert to seconds
remaining: instruction.duration_minutes * 60,
isActive: true,
isComplete: false,
};
setActiveTimers(prev => ({
...prev,
[instruction.step_number.toString()]: timerState,
}));
// Start countdown
const interval = setInterval(() => {
setActiveTimers(prev => {
const current = prev[instruction.step_number.toString()];
if (!current || current.remaining <= 0) {
clearInterval(interval);
return {
...prev,
[instruction.step_number.toString()]: {
...current,
remaining: 0,
isActive: false,
isComplete: true,
}
};
}
return {
...prev,
[instruction.step_number.toString()]: {
...current,
remaining: current.remaining - 1,
}
};
});
}, 1000);
};
const stopTimer = (instructionId: string) => {
setActiveTimers(prev => ({
...prev,
[instructionId]: {
...prev[instructionId],
isActive: false,
}
}));
};
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
return `${minutes}:${String(secs).padStart(2, '0')}`;
};
const renderScalingControls = () => (
<Card className="p-4">
<h3 className="font-semibold text-lg mb-4">Escalado de receta</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Factor de escala
</label>
<Input
type="number"
min="0.1"
max="10"
step="0.1"
value={scaleFactor}
onChange={(e) => handleScaleChange(parseFloat(e.target.value) || 1)}
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cantidad original
</label>
<p className="text-lg font-semibold text-gray-900">
{recipe.yield_quantity} {recipe.yield_unit}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cantidad escalada
</label>
<p className="text-lg font-semibold text-blue-600">
{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}
</p>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(0.5)}
className={scaleFactor === 0.5 ? 'bg-blue-50 border-blue-300' : ''}
>
1/2
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(1)}
className={scaleFactor === 1 ? 'bg-blue-50 border-blue-300' : ''}
>
1x
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(2)}
className={scaleFactor === 2 ? 'bg-blue-50 border-blue-300' : ''}
>
2x
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(3)}
className={scaleFactor === 3 ? 'bg-blue-50 border-blue-300' : ''}
>
3x
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleScaleChange(5)}
className={scaleFactor === 5 ? 'bg-blue-50 border-blue-300' : ''}
>
5x
</Button>
</div>
{scaleFactor !== 1 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<span className="font-medium">Tiempo total escalado:</span> {Math.ceil(scaledRecipe.scaledTotalTime / 60)}h {scaledRecipe.scaledTotalTime % 60}m
</p>
{showCosting && scaledRecipe.estimatedTotalCost && (
<p className="text-sm text-blue-800 mt-1">
<span className="font-medium">Costo estimado:</span> €{scaledRecipe.estimatedTotalCost.toFixed(2)}
(€{scaledRecipe.costPerScaledUnit?.toFixed(3)}/{recipe.yield_unit})
</p>
)}
</div>
)}
</Card>
);
const renderRecipeHeader = () => (
<Card className="p-6">
<div className="flex flex-col lg:flex-row gap-6">
<div className="flex-1">
<div className="flex items-start justify-between mb-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">{recipe.name}</h1>
<p className="text-gray-600 mt-1">{recipe.description}</p>
</div>
<div className="flex gap-2">
<Badge className={DIFFICULTY_COLORS[recipe.difficulty_level]}>
{DIFFICULTY_LABELS[recipe.difficulty_level]}
</Badge>
<Badge className="bg-blue-100 text-blue-800">
v{recipe.version}
</Badge>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-gray-600">Preparación</p>
<p className="font-semibold">{recipe.prep_time_minutes} min</p>
</div>
<div>
<p className="text-sm text-gray-600">Cocción</p>
<p className="font-semibold">{recipe.cook_time_minutes} min</p>
</div>
<div>
<p className="text-sm text-gray-600">Total</p>
<p className="font-semibold">{Math.ceil(recipe.total_time_minutes / 60)}h {recipe.total_time_minutes % 60}m</p>
</div>
<div>
<p className="text-sm text-gray-600">Rendimiento</p>
<p className="font-semibold">{scaledRecipe.scaledYieldQuantity} {recipe.yield_unit}</p>
</div>
</div>
{recipe.allergen_warnings.length > 0 && (
<div className="mt-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-700">Alérgenos:</p>
<Button
variant="ghost"
size="sm"
onClick={() => setShowAllergensDetail(!showAllergensDetail)}
>
{showAllergensDetail ? 'Ocultar' : 'Ver detalles'}
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{recipe.allergen_warnings.map((allergen) => (
<Badge
key={allergen}
className="bg-red-100 text-red-800 border border-red-200"
>
{ALLERGEN_ICONS[allergen]} {allergen}
</Badge>
))}
</div>
{showAllergensDetail && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-800">
⚠️ Este producto contiene los siguientes alérgenos. Revisar cuidadosamente
antes del consumo si existe alguna alergia o intolerancia alimentaria.
</p>
</div>
)}
</div>
)}
{recipe.storage_instructions && (
<div className="mt-4 p-3 bg-gray-50 border border-gray-200 rounded-lg">
<p className="text-sm text-gray-700">
<span className="font-medium">Conservación:</span> {recipe.storage_instructions}
{recipe.shelf_life_hours && (
<span className="ml-2">
• Vida útil: {Math.ceil(recipe.shelf_life_hours / 24)} días
</span>
)}
</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 lg:w-48">
{showNutrition && (
<Button
variant="outline"
onClick={() => setIsNutritionModalOpen(true)}
className="w-full"
>
📊 Información nutricional
</Button>
)}
{showCosting && (
<Button
variant="outline"
onClick={() => setIsCostingModalOpen(true)}
className="w-full"
>
💰 Análisis de costos
</Button>
)}
<Button
variant="outline"
onClick={() => setIsEquipmentModalOpen(true)}
className="w-full"
>
🔧 Equipo necesario
</Button>
{editable && onVersionUpdate && (
<Button variant="primary" className="w-full">
✏️ Editar receta
</Button>
)}
</div>
</div>
</Card>
);
const renderIngredients = () => (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Ingredientes</h2>
<div className="space-y-3">
{scaledRecipe.ingredients.map((ingredient, index) => (
<div
key={`${ingredient.ingredient_id}-${index}`}
className="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0"
>
<div className="flex-1">
<p className="font-medium text-gray-900">{ingredient.ingredient_name}</p>
{ingredient.preparation_notes && (
<p className="text-sm text-gray-600 italic">{ingredient.preparation_notes}</p>
)}
{ingredient.is_optional && (
<Badge className="bg-gray-100 text-gray-600 text-xs mt-1">
Opcional
</Badge>
)}
</div>
<div className="text-right ml-4">
<p className="font-semibold text-lg">
{ingredient.scaledQuantity.toFixed(ingredient.scaledQuantity < 1 ? 2 : 0)} {ingredient.unit}
</p>
{scaleFactor !== 1 && (
<p className="text-sm text-gray-500">
(original: {ingredient.quantity} {ingredient.unit})
</p>
)}
{showCosting && ingredient.scaledCost && (
<p className="text-sm text-green-600">
€{ingredient.scaledCost.toFixed(2)}
</p>
)}
</div>
</div>
))}
</div>
{showCosting && scaledRecipe.estimatedTotalCost && (
<div className="mt-4 pt-4 border-t border-gray-200">
<div className="flex justify-between items-center">
<span className="font-semibold">Costo total estimado:</span>
<span className="text-xl font-bold text-green-600">
€{scaledRecipe.estimatedTotalCost.toFixed(2)}
</span>
</div>
<p className="text-sm text-gray-600 text-right">
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
</p>
</div>
)}
</Card>
);
const renderInstructions = () => (
<Card className="p-6">
<h2 className="text-xl font-semibold mb-4">Instrucciones</h2>
<div className="space-y-4">
{scaledRecipe.instructions.map((instruction, index) => {
const timer = activeTimers[instruction.step_number.toString()];
return (
<div
key={instruction.step_number}
className={`p-4 border rounded-lg ${
instruction.critical_control_point
? 'border-orange-300 bg-orange-50'
: 'border-gray-200 bg-gray-50'
}`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
{instruction.step_number}
</div>
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{instruction.duration_minutes && (
<Badge className="bg-blue-100 text-blue-800">
⏱️ {instruction.scaledDuration || instruction.duration_minutes} min
</Badge>
)}
{instruction.temperature && (
<Badge className="bg-red-100 text-red-800">
🌡️ {instruction.temperature}°C
</Badge>
)}
{instruction.critical_control_point && (
<Badge className="bg-orange-100 text-orange-800">
🚨 PCC
</Badge>
)}
</div>
{instruction.duration_minutes && (
<div className="flex gap-2">
{!timer?.isActive && !timer?.isComplete && (
<Button
variant="outline"
size="sm"
onClick={() => startTimer(instruction)}
>
▶️ Iniciar timer
</Button>
)}
{timer?.isActive && (
<div className="flex items-center gap-2">
<span className="text-lg font-mono font-bold text-blue-600">
{formatTime(timer.remaining)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => stopTimer(instruction.step_number.toString())}
>
⏸️
</Button>
</div>
)}
{timer?.isComplete && (
<Badge className="bg-green-100 text-green-800 animate-pulse">
✅ Completado
</Badge>
)}
</div>
)}
</div>
<p className="text-gray-800 leading-relaxed mb-2">
{instruction.instruction}
</p>
{instruction.equipment && instruction.equipment.length > 0 && (
<div className="mb-2">
<p className="text-sm text-gray-600 mb-1">Equipo necesario:</p>
<div className="flex flex-wrap gap-1">
{instruction.equipment.map((equipment, idx) => (
<Badge key={idx} className="bg-gray-100 text-gray-700 text-xs">
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
</Badge>
))}
</div>
</div>
)}
{instruction.tips && (
<div className="mt-3 p-2 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-sm text-yellow-800">
💡 <span className="font-medium">Tip:</span> {instruction.tips}
</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
);
const renderNutritionModal = () => (
<Modal
isOpen={isNutritionModalOpen}
onClose={() => setIsNutritionModalOpen(false)}
title="Información Nutricional"
>
{recipe.nutritional_info ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Calorías por porción</p>
<p className="text-2xl font-bold text-gray-900">
{recipe.nutritional_info.calories_per_serving || 'N/A'}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Tamaño de porción</p>
<p className="text-lg font-semibold text-gray-900">
{recipe.nutritional_info.serving_size || 'N/A'}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-600">Proteínas</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.protein_g || 0}g</p>
</div>
<div>
<p className="text-sm text-gray-600">Carbohidratos</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.carbohydrates_g || 0}g</p>
</div>
<div>
<p className="text-sm text-gray-600">Grasas</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.fat_g || 0}g</p>
</div>
<div>
<p className="text-sm text-gray-600">Fibra</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.fiber_g || 0}g</p>
</div>
<div>
<p className="text-sm text-gray-600">Azúcares</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.sugar_g || 0}g</p>
</div>
<div>
<p className="text-sm text-gray-600">Sodio</p>
<p className="text-lg font-semibold">{recipe.nutritional_info.sodium_mg || 0}mg</p>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-blue-800">
<span className="font-medium">Porciones por lote escalado:</span> {
recipe.nutritional_info.servings_per_batch
? Math.ceil(recipe.nutritional_info.servings_per_batch * scaleFactor)
: 'N/A'
}
</p>
</div>
</div>
) : (
<p className="text-gray-500 text-center py-8">
No hay información nutricional disponible para esta receta.
</p>
)}
</Modal>
);
const renderCostingModal = () => (
<Modal
isOpen={isCostingModalOpen}
onClose={() => setIsCostingModalOpen(false)}
title="Análisis de Costos"
>
<div className="space-y-4">
<div className="bg-green-50 border border-green-200 p-4 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-semibold text-green-900">Costo total</span>
<span className="text-2xl font-bold text-green-600">
€{scaledRecipe.estimatedTotalCost?.toFixed(2)}
</span>
</div>
<p className="text-sm text-green-700">
€{scaledRecipe.costPerScaledUnit?.toFixed(3)} por {recipe.yield_unit}
</p>
</div>
<div>
<h4 className="font-semibold mb-3">Desglose por ingrediente</h4>
<div className="space-y-2">
{scaledRecipe.ingredients.map((ingredient, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
<div>
<p className="font-medium">{ingredient.ingredient_name}</p>
<p className="text-sm text-gray-600">
{ingredient.scaledQuantity.toFixed(2)} {ingredient.unit}
</p>
</div>
<p className="font-semibold text-green-600">
€{ingredient.scaledCost?.toFixed(2) || '0.00'}
</p>
</div>
))}
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<h4 className="font-semibold text-blue-900 mb-2">Análisis de rentabilidad</h4>
<div className="space-y-1 text-sm text-blue-800">
<p>• Costo de ingredientes: €{scaledRecipe.estimatedTotalCost?.toFixed(2)}</p>
<p>• Margen sugerido (300%): €{((scaledRecipe.estimatedTotalCost || 0) * 3).toFixed(2)}</p>
<p>• Precio de venta sugerido por {recipe.yield_unit}: €{((scaledRecipe.costPerScaledUnit || 0) * 4).toFixed(2)}</p>
</div>
</div>
</div>
</Modal>
);
const renderEquipmentModal = () => (
<Modal
isOpen={isEquipmentModalOpen}
onClose={() => setIsEquipmentModalOpen(false)}
title="Equipo Necesario"
>
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-3">Equipo general de la receta</h4>
<div className="grid grid-cols-2 gap-2">
{recipe.equipment_needed.map((equipment, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-gray-50 rounded">
<span className="text-xl">
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'}
</span>
<span className="text-sm">{equipment}</span>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold mb-3">Equipo por instrucción</h4>
<div className="space-y-2">
{recipe.instructions
.filter(instruction => instruction.equipment && instruction.equipment.length > 0)
.map((instruction) => (
<div key={instruction.step_number} className="border border-gray-200 p-3 rounded">
<p className="font-medium text-sm mb-2">
Paso {instruction.step_number}
</p>
<div className="flex flex-wrap gap-1">
{instruction.equipment?.map((equipment, idx) => (
<Badge key={idx} className="bg-blue-100 text-blue-800 text-xs">
{EQUIPMENT_ICONS[equipment.toLowerCase()] || '🔧'} {equipment}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
</div>
</Modal>
);
return (
<div className={`space-y-6 ${className}`}>
{renderRecipeHeader()}
{renderScalingControls()}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{renderIngredients()}
<div className="space-y-6">
{renderInstructions()}
</div>
</div>
{renderNutritionModal()}
{renderCostingModal()}
{renderEquipmentModal()}
</div>
);
};
export default RecipeDisplay;