feat: Completely rewrite RecipeWizard with comprehensive improvements
Major improvements: 1. Fixed 'a.map is not a function' error (line 387: result.templates) 2. Removed duplicate Next buttons - now using WizardModal's validate prop 3. Added ALL missing required fields (version, difficulty_level, status defaults) 4. Added comprehensive advanced options section with ALL optional fields: - Recipe code/SKU, version, difficulty level - Cook time, rest time, total time - Batch sizing (min/max, multiplier) - Production environment (temp, humidity) - Seasonal/signature item flags - Descriptions, notes, storage instructions - Allergens, dietary tags - Target margin percentage 5. Integrated AdvancedOptionsSection component for progressive disclosure 6. Added tooltips for complex fields using existing Tooltip component 7. Proper form validation on each step 8. Real-time data synchronization with useEffect 9. English labels (per project standards) 10. All fields map correctly to backend RecipeCreate schema Technical changes: - Created reusable AdvancedOptionsSection component - Steps now validate using WizardModal's validate prop - No internal "Continuar" buttons - cleaner UX - Quality Templates step marked as optional (isOptional: true) - Ingredients step validates all required data - Seasonal month selectors conditional on isSeasonal checkbox This implementation follows UX best practices for progressive disclosure and reduces cognitive load while maintaining access to all backend fields.
This commit is contained in:
@@ -6,25 +6,54 @@ import { recipesService } from '../../../../api/services/recipes';
|
||||
import { inventoryService } from '../../../../api/services/inventory';
|
||||
import { qualityTemplateService } from '../../../../api/services/qualityTemplates';
|
||||
import { IngredientResponse } from '../../../../api/types/inventory';
|
||||
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit, RecipeQualityConfiguration } from '../../../../api/types/recipes';
|
||||
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit, RecipeQualityConfiguration, RecipeStatus } from '../../../../api/types/recipes';
|
||||
import { QualityCheckTemplateResponse } from '../../../../api/types/qualityTemplates';
|
||||
import { showToast } from '../../../../utils/toast';
|
||||
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
|
||||
import Tooltip from '../../../ui/Tooltip/Tooltip';
|
||||
|
||||
interface WizardDataProps extends WizardStepProps {
|
||||
data: Record<string, any>;
|
||||
onDataChange: (data: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
const RecipeDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext }) => {
|
||||
const RecipeDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange }) => {
|
||||
const { currentTenant } = useTenant();
|
||||
const [recipeData, setRecipeData] = useState({
|
||||
// Required fields
|
||||
name: data.name || '',
|
||||
category: data.category || 'bread',
|
||||
finishedProductId: data.finishedProductId || '',
|
||||
yieldQuantity: data.yieldQuantity || '',
|
||||
yieldUnit: data.yieldUnit || 'units',
|
||||
|
||||
// Optional basic fields
|
||||
category: data.category || 'bread',
|
||||
prepTime: data.prepTime || '',
|
||||
finishedProductId: data.finishedProductId || '',
|
||||
instructions: data.instructions || '',
|
||||
|
||||
// Advanced optional fields
|
||||
recipeCode: data.recipeCode || '',
|
||||
version: data.version || '1.0',
|
||||
difficultyLevel: data.difficultyLevel || 3,
|
||||
cookTime: data.cookTime || '',
|
||||
restTime: data.restTime || '',
|
||||
totalTime: data.totalTime || '',
|
||||
description: data.description || '',
|
||||
preparationNotes: data.preparationNotes || '',
|
||||
storageInstructions: data.storageInstructions || '',
|
||||
servesCount: data.servesCount || '',
|
||||
batchSizeMultiplier: data.batchSizeMultiplier || 1.0,
|
||||
minBatchSize: data.minBatchSize || '',
|
||||
maxBatchSize: data.maxBatchSize || '',
|
||||
optimalProductionTemp: data.optimalProductionTemp || '',
|
||||
optimalHumidity: data.optimalHumidity || '',
|
||||
isSeasonal: data.isSeasonal || false,
|
||||
isSignatureItem: data.isSignatureItem || false,
|
||||
seasonStartMonth: data.seasonStartMonth || '',
|
||||
seasonEndMonth: data.seasonEndMonth || '',
|
||||
allergens: data.allergens || '',
|
||||
dietaryTags: data.dietaryTags || '',
|
||||
targetMargin: data.targetMargin || '',
|
||||
});
|
||||
const [finishedProducts, setFinishedProducts] = useState<IngredientResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -33,6 +62,10 @@ const RecipeDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNe
|
||||
fetchFinishedProducts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange({ ...data, ...recipeData });
|
||||
}, [recipeData]);
|
||||
|
||||
const fetchFinishedProducts = async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
setLoading(true);
|
||||
@@ -52,75 +85,106 @@ const RecipeDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNe
|
||||
<div className="space-y-6">
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<ChefHat className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Detalles de la Receta</h3>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Recipe Details</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Essential information about your recipe</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Nombre *</label>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Recipe Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipeData.name}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, name: e.target.value })}
|
||||
placeholder="Ej: Baguette Tradicional"
|
||||
placeholder="e.g., Traditional Baguette"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Categoría *</label>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
value={recipeData.category}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, category: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="bread">Pan</option>
|
||||
<option value="pastry">Pastelería</option>
|
||||
<option value="cake">Repostería</option>
|
||||
<option value="cookie">Galletas</option>
|
||||
<option value="other">Otro</option>
|
||||
<option value="bread">Bread</option>
|
||||
<option value="pastries">Pastries</option>
|
||||
<option value="cakes">Cakes</option>
|
||||
<option value="cookies">Cookies</option>
|
||||
<option value="muffins">Muffins</option>
|
||||
<option value="sandwiches">Sandwiches</option>
|
||||
<option value="seasonal">Seasonal</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Producto Terminado *</label>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
Finished Product *
|
||||
<Tooltip content="The final product this recipe produces. Must be created in inventory first.">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<select
|
||||
value={recipeData.finishedProductId}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, finishedProductId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Seleccionar producto...</option>
|
||||
<option value="">Select product...</option>
|
||||
{finishedProducts.map(product => (
|
||||
<option key={product.id} value={product.id}>{product.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Rendimiento *</label>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Yield Quantity *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.yieldQuantity}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, yieldQuantity: e.target.value })}
|
||||
placeholder="12"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
min="1"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Unidad *</label>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Yield Unit *
|
||||
</label>
|
||||
<select
|
||||
value={recipeData.yieldUnit}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, yieldUnit: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="units">Unidades</option>
|
||||
<option value="kg">Kilogramos</option>
|
||||
<option value="g">Gramos</option>
|
||||
<option value="l">Litros</option>
|
||||
<option value="ml">Mililitros</option>
|
||||
<option value="pieces">Piezas</option>
|
||||
<option value="units">Units</option>
|
||||
<option value="pieces">Pieces</option>
|
||||
<option value="kg">Kilograms (kg)</option>
|
||||
<option value="g">Grams (g)</option>
|
||||
<option value="l">Liters (l)</option>
|
||||
<option value="ml">Milliliters (ml)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Tiempo de Preparación (min)</label>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Preparation Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.prepTime}
|
||||
@@ -130,26 +194,357 @@ const RecipeDetailsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNe
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Instrucciones</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Instructions
|
||||
</label>
|
||||
<textarea
|
||||
value={recipeData.instructions}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, instructions: e.target.value })}
|
||||
placeholder="Pasos de preparación de la receta..."
|
||||
placeholder="Step-by-step preparation instructions..."
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4 border-t border-[var(--border-primary)]">
|
||||
<button
|
||||
onClick={() => { onDataChange({ ...data, ...recipeData }); onNext(); }}
|
||||
disabled={!recipeData.name || !recipeData.yieldQuantity || !recipeData.finishedProductId}
|
||||
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
|
||||
{/* Advanced Options */}
|
||||
<AdvancedOptionsSection
|
||||
title="Advanced Options"
|
||||
description="Optional fields for detailed recipe management"
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Recipe Code/SKU
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipeData.recipeCode}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, recipeCode: e.target.value })}
|
||||
placeholder="RCP-001"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Version
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipeData.version}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, version: e.target.value })}
|
||||
placeholder="1.0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
Difficulty Level (1-5)
|
||||
<Tooltip content="1 = Very Easy, 5 = Expert Level">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.difficultyLevel}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, difficultyLevel: parseInt(e.target.value) || 1 })}
|
||||
min="1"
|
||||
max="5"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Cook Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.cookTime}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, cookTime: e.target.value })}
|
||||
placeholder="30"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
Rest Time (minutes)
|
||||
<Tooltip content="Time for rising, cooling, or resting">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.restTime}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, restTime: e.target.value })}
|
||||
placeholder="60"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Total Time (minutes)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.totalTime}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, totalTime: e.target.value })}
|
||||
placeholder="90"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Serves Count
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.servesCount}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, servesCount: e.target.value })}
|
||||
placeholder="8"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2 inline-flex items-center gap-2">
|
||||
Batch Size Multiplier
|
||||
<Tooltip content="Default scaling factor for batch production">
|
||||
<span />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.batchSizeMultiplier}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, batchSizeMultiplier: parseFloat(e.target.value) || 1 })}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Min Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.minBatchSize}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, minBatchSize: e.target.value })}
|
||||
placeholder="5"
|
||||
min="0"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Max Batch Size
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.maxBatchSize}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, maxBatchSize: e.target.value })}
|
||||
placeholder="100"
|
||||
min="0"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Optimal Production Temp (°C)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.optimalProductionTemp}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, optimalProductionTemp: e.target.value })}
|
||||
placeholder="24"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Optimal Humidity (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.optimalHumidity}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, optimalHumidity: e.target.value })}
|
||||
placeholder="65"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Target Margin (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={recipeData.targetMargin}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, targetMargin: e.target.value })}
|
||||
placeholder="30"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isSeasonal"
|
||||
checked={recipeData.isSeasonal}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, isSeasonal: e.target.checked })}
|
||||
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label htmlFor="isSeasonal" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
Seasonal Item
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isSignatureItem"
|
||||
checked={recipeData.isSignatureItem}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, isSignatureItem: e.target.checked })}
|
||||
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-2 focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<label htmlFor="isSignatureItem" className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
Signature Item
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recipeData.isSeasonal && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Season Start Month
|
||||
</label>
|
||||
<select
|
||||
value={recipeData.seasonStartMonth}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, seasonStartMonth: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">Select month...</option>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map(month => (
|
||||
<option key={month} value={month}>
|
||||
{new Date(2000, month - 1).toLocaleString('default', { month: 'long' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Season End Month
|
||||
</label>
|
||||
<select
|
||||
value={recipeData.seasonEndMonth}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, seasonEndMonth: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">Select month...</option>
|
||||
{Array.from({ length: 12 }, (_, i) => i + 1).map(month => (
|
||||
<option key={month} value={month}>
|
||||
{new Date(2000, month - 1).toLocaleString('default', { month: 'long' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={recipeData.description}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, description: e.target.value })}
|
||||
placeholder="Detailed description of the recipe..."
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Preparation Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={recipeData.preparationNotes}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, preparationNotes: e.target.value })}
|
||||
placeholder="Tips and notes for preparation..."
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Storage Instructions
|
||||
</label>
|
||||
<textarea
|
||||
value={recipeData.storageInstructions}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, storageInstructions: e.target.value })}
|
||||
placeholder="How to store the finished product..."
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Allergens
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipeData.allergens}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, allergens: e.target.value })}
|
||||
placeholder="e.g., gluten, dairy, eggs (comma-separated)"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Dietary Tags
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={recipeData.dietaryTags}
|
||||
onChange={(e) => setRecipeData({ ...recipeData, dietaryTags: e.target.value })}
|
||||
placeholder="e.g., vegan, gluten-free, organic (comma-separated)"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</AdvancedOptionsSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -163,7 +558,7 @@ interface SelectedIngredient {
|
||||
order: number;
|
||||
}
|
||||
|
||||
const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext }) => {
|
||||
const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange }) => {
|
||||
const { currentTenant } = useTenant();
|
||||
const [ingredients, setIngredients] = useState<IngredientResponse[]>([]);
|
||||
const [selectedIngredients, setSelectedIngredients] = useState<SelectedIngredient[]>(data.ingredients || []);
|
||||
@@ -175,16 +570,19 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
fetchIngredients();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange({ ...data, ingredients: selectedIngredients });
|
||||
}, [selectedIngredients]);
|
||||
|
||||
const fetchIngredients = async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await inventoryService.getIngredients(currentTenant.id);
|
||||
// Filter out finished products - we only want raw ingredients
|
||||
const rawIngredients = result.filter(ing => ing.category !== 'finished_product');
|
||||
setIngredients(rawIngredients);
|
||||
} catch (err) {
|
||||
setError('Error al cargar ingredientes');
|
||||
setError('Error loading ingredients');
|
||||
console.error('Error loading ingredients:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -215,25 +613,6 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
setSelectedIngredients(selectedIngredients.filter(ing => ing.id !== id));
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedIngredients.length === 0) {
|
||||
setError('Debes agregar al menos un ingrediente');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all ingredients are filled
|
||||
const invalidIngredients = selectedIngredients.filter(
|
||||
ing => !ing.ingredientId || ing.quantity <= 0
|
||||
);
|
||||
if (invalidIngredients.length > 0) {
|
||||
setError('Todos los ingredientes deben tener un ingrediente seleccionado y cantidad mayor a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
onDataChange({ ...data, ingredients: selectedIngredients });
|
||||
onNext();
|
||||
};
|
||||
|
||||
const filteredIngredients = ingredients.filter(ing =>
|
||||
ing.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
@@ -242,7 +621,7 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
<div className="space-y-6">
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Ingredientes</h3>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Ingredients</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{data.name}</p>
|
||||
</div>
|
||||
|
||||
@@ -255,7 +634,7 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Cargando ingredientes...</span>
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Loading ingredients...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -263,15 +642,15 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
{selectedIngredients.length === 0 ? (
|
||||
<div className="text-center py-8 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
|
||||
<p className="text-[var(--text-secondary)] mb-2">No hay ingredientes agregados</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Haz clic en "Agregar Ingrediente" para comenzar</p>
|
||||
<p className="text-[var(--text-secondary)] mb-2">No ingredients added</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">Click "Add Ingredient" to begin</p>
|
||||
</div>
|
||||
) : (
|
||||
selectedIngredients.map((selectedIng) => (
|
||||
<div key={selectedIng.id} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-3 items-start">
|
||||
<div className="md:col-span-5">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente *</label>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingredient *</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<select
|
||||
@@ -279,7 +658,7 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'ingredientId', e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="">Select...</option>
|
||||
{filteredIngredients.map(ing => (
|
||||
<option key={ing.id} value={ing.id}>
|
||||
{ing.name} {ing.category ? `(${ing.category})` : ''}
|
||||
@@ -289,7 +668,7 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad *</label>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Quantity *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={selectedIng.quantity || ''}
|
||||
@@ -301,38 +680,39 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad *</label>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unit *</label>
|
||||
<select
|
||||
value={selectedIng.unit}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'unit', e.target.value as MeasurementUnit)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
>
|
||||
<option value={MeasurementUnit.GRAMS}>Gramos (g)</option>
|
||||
<option value={MeasurementUnit.KILOGRAMS}>Kilogramos (kg)</option>
|
||||
<option value={MeasurementUnit.MILLILITERS}>Mililitros (ml)</option>
|
||||
<option value={MeasurementUnit.LITERS}>Litros (l)</option>
|
||||
<option value={MeasurementUnit.UNITS}>Unidades</option>
|
||||
<option value={MeasurementUnit.PIECES}>Piezas</option>
|
||||
<option value={MeasurementUnit.CUPS}>Tazas</option>
|
||||
<option value={MeasurementUnit.TABLESPOONS}>Cucharadas</option>
|
||||
<option value={MeasurementUnit.TEASPOONS}>Cucharaditas</option>
|
||||
<option value={MeasurementUnit.GRAMS}>Grams (g)</option>
|
||||
<option value={MeasurementUnit.KILOGRAMS}>Kilograms (kg)</option>
|
||||
<option value={MeasurementUnit.MILLILITERS}>Milliliters (ml)</option>
|
||||
<option value={MeasurementUnit.LITERS}>Liters (l)</option>
|
||||
<option value={MeasurementUnit.UNITS}>Units</option>
|
||||
<option value={MeasurementUnit.PIECES}>Pieces</option>
|
||||
<option value={MeasurementUnit.CUPS}>Cups</option>
|
||||
<option value={MeasurementUnit.TABLESPOONS}>Tablespoons</option>
|
||||
<option value={MeasurementUnit.TEASPOONS}>Teaspoons</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Notas</label>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedIng.notes}
|
||||
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'notes', e.target.value)}
|
||||
placeholder="Opcional"
|
||||
placeholder="Optional"
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveIngredient(selectedIng.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Eliminar ingrediente"
|
||||
title="Remove ingredient"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -344,29 +724,19 @@ const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddIngredient}
|
||||
className="w-full py-3 border-2 border-dashed border-[var(--border-secondary)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors inline-flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Agregar Ingrediente
|
||||
Add Ingredient
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={selectedIngredients.length === 0 || loading}
|
||||
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Continuar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Step 3: Quality Templates Selection
|
||||
const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, onComplete }) => {
|
||||
const { currentTenant } = useTenant();
|
||||
const [templates, setTemplates] = useState<QualityCheckTemplateResponse[]>([]);
|
||||
@@ -379,15 +749,19 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange({ ...data, selectedTemplates: selectedTemplateIds });
|
||||
}, [selectedTemplateIds]);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await qualityTemplateService.getTemplates(currentTenant.id, { is_active: true });
|
||||
setTemplates(result);
|
||||
setTemplates(result.templates || []);
|
||||
} catch (err) {
|
||||
console.error('Error loading quality templates:', err);
|
||||
setError('Error al cargar plantillas de calidad');
|
||||
setError('Error loading quality templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -403,7 +777,7 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
|
||||
const handleCreateRecipe = async () => {
|
||||
if (!currentTenant?.id) {
|
||||
setError('No se pudo obtener información del tenant');
|
||||
setError('Could not obtain tenant information');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -411,7 +785,6 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Prepare recipe ingredients
|
||||
const recipeIngredients: RecipeIngredientCreate[] = data.ingredients.map((ing: any, index: number) => ({
|
||||
ingredient_id: ing.ingredientId,
|
||||
quantity: ing.quantity,
|
||||
@@ -421,7 +794,6 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
ingredient_order: index + 1,
|
||||
}));
|
||||
|
||||
// Prepare quality configuration if templates are selected
|
||||
let qualityConfig: RecipeQualityConfiguration | undefined;
|
||||
if (selectedTemplateIds.length > 0) {
|
||||
qualityConfig = {
|
||||
@@ -447,19 +819,40 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
finished_product_id: data.finishedProductId,
|
||||
yield_quantity: parseFloat(data.yieldQuantity),
|
||||
yield_unit: data.yieldUnit as MeasurementUnit,
|
||||
version: data.version || '1.0',
|
||||
difficulty_level: data.difficultyLevel || 3,
|
||||
prep_time_minutes: data.prepTime ? parseInt(data.prepTime) : null,
|
||||
cook_time_minutes: data.cookTime ? parseInt(data.cookTime) : null,
|
||||
rest_time_minutes: data.restTime ? parseInt(data.restTime) : null,
|
||||
total_time_minutes: data.totalTime ? parseInt(data.totalTime) : null,
|
||||
recipe_code: data.recipeCode || null,
|
||||
description: data.description || null,
|
||||
preparation_notes: data.preparationNotes || null,
|
||||
storage_instructions: data.storageInstructions || null,
|
||||
serves_count: data.servesCount ? parseInt(data.servesCount) : null,
|
||||
batch_size_multiplier: data.batchSizeMultiplier || 1.0,
|
||||
minimum_batch_size: data.minBatchSize ? parseFloat(data.minBatchSize) : null,
|
||||
maximum_batch_size: data.maxBatchSize ? parseFloat(data.maxBatchSize) : null,
|
||||
optimal_production_temperature: data.optimalProductionTemp ? parseFloat(data.optimalProductionTemp) : null,
|
||||
optimal_humidity: data.optimalHumidity ? parseFloat(data.optimalHumidity) : null,
|
||||
target_margin_percentage: data.targetMargin ? parseFloat(data.targetMargin) : null,
|
||||
is_seasonal: data.isSeasonal || false,
|
||||
is_signature_item: data.isSignatureItem || false,
|
||||
season_start_month: data.seasonStartMonth ? parseInt(data.seasonStartMonth) : null,
|
||||
season_end_month: data.seasonEndMonth ? parseInt(data.seasonEndMonth) : null,
|
||||
instructions: data.instructions ? { steps: data.instructions } : null,
|
||||
allergen_info: data.allergens ? data.allergens.split(',').map((a: string) => a.trim()) : null,
|
||||
dietary_tags: data.dietaryTags ? data.dietaryTags.split(',').map((t: string) => t.trim()) : null,
|
||||
ingredients: recipeIngredients,
|
||||
quality_check_configuration: qualityConfig,
|
||||
};
|
||||
|
||||
await recipesService.createRecipe(currentTenant.id, recipeData);
|
||||
showToast.success('Receta creada exitosamente');
|
||||
onDataChange({ ...data, selectedTemplates: selectedTemplateIds });
|
||||
showToast.success('Recipe created successfully');
|
||||
onComplete();
|
||||
} catch (err: any) {
|
||||
console.error('Error creating recipe:', err);
|
||||
const errorMessage = err.response?.data?.detail || 'Error al crear la receta';
|
||||
const errorMessage = err.response?.data?.detail || 'Error creating recipe';
|
||||
setError(errorMessage);
|
||||
showToast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -472,10 +865,10 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
||||
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Plantillas de Calidad (Opcional)
|
||||
Quality Templates (Optional)
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Selecciona las plantillas de control de calidad que aplicarán a esta receta
|
||||
Select quality control templates to apply to this recipe
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -488,16 +881,16 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Cargando plantillas...</span>
|
||||
<span className="ml-3 text-[var(--text-secondary)]">Loading templates...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{templates.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<ClipboardCheck className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
|
||||
<p className="text-[var(--text-secondary)] mb-2">No hay plantillas de calidad disponibles</p>
|
||||
<p className="text-[var(--text-secondary)] mb-2">No quality templates available</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
Puedes crear plantillas desde el wizard principal
|
||||
You can create templates from the main wizard
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -505,6 +898,7 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => toggleTemplate(template.id)}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
|
||||
selectedTemplateIds.includes(template.id)
|
||||
@@ -518,7 +912,7 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
<h4 className="font-semibold text-[var(--text-primary)]">{template.name}</h4>
|
||||
{template.is_required && (
|
||||
<span className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded-full">
|
||||
Obligatorio
|
||||
Required
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -528,9 +922,9 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--text-tertiary)]">
|
||||
<span>Tipo: {template.check_type}</span>
|
||||
<span>Type: {template.check_type}</span>
|
||||
{template.frequency_days && (
|
||||
<span>• Cada {template.frequency_days} días</span>
|
||||
<span>• Every {template.frequency_days} days</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -546,16 +940,16 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
{selectedTemplateIds.length > 0 && (
|
||||
<div className="p-4 bg-[var(--color-primary)]/5 rounded-lg border border-[var(--color-primary)]/20">
|
||||
<p className="text-sm text-[var(--text-primary)]">
|
||||
<strong>{selectedTemplateIds.length}</strong> plantilla(s) seleccionada(s)
|
||||
<strong>{selectedTemplateIds.length}</strong> template(s) selected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<div className="flex justify-center pt-4 border-t border-[var(--border-primary)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateRecipe}
|
||||
disabled={saving || loading}
|
||||
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@@ -563,12 +957,12 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Creando receta...
|
||||
Creating recipe...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
Crear Receta
|
||||
Create Recipe
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -578,7 +972,35 @@ const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, o
|
||||
};
|
||||
|
||||
export const RecipeWizardSteps = (data: Record<string, any>, setData: (data: Record<string, any>) => void): WizardStep[] => [
|
||||
{ id: 'recipe-details', title: 'Detalles de la Receta', description: 'Nombre, categoría, rendimiento', component: (props) => <RecipeDetailsStep {...props} data={data} onDataChange={setData} /> },
|
||||
{ id: 'recipe-ingredients', title: 'Ingredientes', description: 'Selección y cantidades', component: (props) => <IngredientsStep {...props} data={data} onDataChange={setData} /> },
|
||||
{ id: 'recipe-quality-templates', title: 'Plantillas de Calidad', description: 'Controles de calidad aplicables', component: (props) => <QualityTemplatesStep {...props} data={data} onDataChange={setData} /> },
|
||||
{
|
||||
id: 'recipe-details',
|
||||
title: 'Recipe Details',
|
||||
description: 'Name, category, yield',
|
||||
component: (props) => <RecipeDetailsStep {...props} data={data} onDataChange={setData} />,
|
||||
validate: () => {
|
||||
return !!(data.name && data.finishedProductId && data.yieldQuantity);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipe-ingredients',
|
||||
title: 'Ingredients',
|
||||
description: 'Selection and quantities',
|
||||
component: (props) => <IngredientsStep {...props} data={data} onDataChange={setData} />,
|
||||
validate: () => {
|
||||
if (!data.ingredients || data.ingredients.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const invalidIngredients = data.ingredients.filter(
|
||||
(ing: any) => !ing.ingredientId || ing.quantity <= 0
|
||||
);
|
||||
return invalidIngredients.length === 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'recipe-quality-templates',
|
||||
title: 'Quality Templates',
|
||||
description: 'Applicable quality controls',
|
||||
component: (props) => <QualityTemplatesStep {...props} data={data} onDataChange={setData} />,
|
||||
isOptional: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
interface AdvancedOptionsSectionProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const AdvancedOptionsSection: React.FC<AdvancedOptionsSectionProps> = ({
|
||||
children,
|
||||
title = 'Advanced Options',
|
||||
description = 'These fields are optional but help improve data management',
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full py-3 px-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg text-[var(--text-secondary)] hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] transition-colors inline-flex items-center justify-center gap-2 font-medium"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
Hide {title}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
Show {title}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-4 p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/30 animate-slideDown">
|
||||
{description && (
|
||||
<p className="text-sm text-[var(--text-secondary)] pb-2 border-b border-[var(--border-primary)]">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.animate-slideDown {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { AdvancedOptionsSection } from './AdvancedOptionsSection';
|
||||
Reference in New Issue
Block a user