Files
bakery-ia/frontend/src/components/domain/unified-wizard/wizards/RecipeWizard.tsx
Claude 3b66bb869a 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.
2025-11-10 07:28:20 +00:00

1007 lines
44 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
import { ChefHat, Package, ClipboardCheck, CheckCircle2, Loader2, Plus, X, Search } from 'lucide-react';
import { useTenant } from '../../../../stores/tenant.store';
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, 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 }) => {
const { currentTenant } = useTenant();
const [recipeData, setRecipeData] = useState({
// Required fields
name: data.name || '',
finishedProductId: data.finishedProductId || '',
yieldQuantity: data.yieldQuantity || '',
yieldUnit: data.yieldUnit || 'units',
// Optional basic fields
category: data.category || 'bread',
prepTime: data.prepTime || '',
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);
useEffect(() => {
fetchFinishedProducts();
}, []);
useEffect(() => {
onDataChange({ ...data, ...recipeData });
}, [recipeData]);
const fetchFinishedProducts = async () => {
if (!currentTenant?.id) return;
setLoading(true);
try {
const result = await inventoryService.getIngredients(currentTenant.id, {
category: 'finished_product'
});
setFinishedProducts(result);
} catch (err) {
console.error('Error loading finished products:', err);
} finally {
setLoading(false);
}
};
return (
<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">Recipe Details</h3>
<p className="text-sm text-[var(--text-secondary)]">Essential information about your recipe</p>
</div>
{/* 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="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">
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">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 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="">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">
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="0.01"
step="0.01"
/>
</div>
<div>
<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">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">
Preparation Time (minutes)
</label>
<input
type="number"
value={recipeData.prepTime}
onChange={(e) => setRecipeData({ ...recipeData, prepTime: e.target.value })}
placeholder="60"
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="0"
/>
</div>
<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="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>
{/* Advanced Options */}
<AdvancedOptionsSection
title="Advanced Options"
description="Optional fields for detailed recipe management"
>
<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>
);
};
interface SelectedIngredient {
id: string;
ingredientId: string;
quantity: number;
unit: MeasurementUnit;
notes: string;
order: number;
}
const IngredientsStep: React.FC<WizardDataProps> = ({ data, onDataChange }) => {
const { currentTenant } = useTenant();
const [ingredients, setIngredients] = useState<IngredientResponse[]>([]);
const [selectedIngredients, setSelectedIngredients] = useState<SelectedIngredient[]>(data.ingredients || []);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchIngredients();
}, []);
useEffect(() => {
onDataChange({ ...data, ingredients: selectedIngredients });
}, [selectedIngredients]);
const fetchIngredients = async () => {
if (!currentTenant?.id) return;
setLoading(true);
try {
const result = await inventoryService.getIngredients(currentTenant.id);
const rawIngredients = result.filter(ing => ing.category !== 'finished_product');
setIngredients(rawIngredients);
} catch (err) {
setError('Error loading ingredients');
console.error('Error loading ingredients:', err);
} finally {
setLoading(false);
}
};
const handleAddIngredient = () => {
const newIngredient: SelectedIngredient = {
id: Date.now().toString(),
ingredientId: '',
quantity: 0,
unit: MeasurementUnit.GRAMS,
notes: '',
order: selectedIngredients.length + 1,
};
setSelectedIngredients([...selectedIngredients, newIngredient]);
};
const handleUpdateIngredient = (id: string, field: keyof SelectedIngredient, value: any) => {
setSelectedIngredients(
selectedIngredients.map(ing =>
ing.id === id ? { ...ing, [field]: value } : ing
)
);
};
const handleRemoveIngredient = (id: string) => {
setSelectedIngredients(selectedIngredients.filter(ing => ing.id !== id));
};
const filteredIngredients = ingredients.filter(ing =>
ing.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<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">Ingredients</h3>
<p className="text-sm text-[var(--text-secondary)]">{data.name}</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{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)]">Loading ingredients...</span>
</div>
) : (
<>
<div className="space-y-4">
{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 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">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
value={selectedIng.ingredientId}
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="">Select...</option>
{filteredIngredients.map(ing => (
<option key={ing.id} value={ing.id}>
{ing.name} {ing.category ? `(${ing.category})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Quantity *</label>
<input
type="number"
value={selectedIng.quantity || ''}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'quantity', parseFloat(e.target.value) || 0)}
placeholder="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)] text-sm"
min="0"
step="0.01"
/>
</div>
<div className="md:col-span-2">
<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}>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">Notes</label>
<input
type="text"
value={selectedIng.notes}
onChange={(e) => handleUpdateIngredient(selectedIng.id, 'notes', e.target.value)}
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="Remove ingredient"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
))
)}
</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" />
Add Ingredient
</button>
</>
)}
</div>
);
};
const QualityTemplatesStep: React.FC<WizardDataProps> = ({ data, onDataChange, onComplete }) => {
const { currentTenant } = useTenant();
const [templates, setTemplates] = useState<QualityCheckTemplateResponse[]>([]);
const [selectedTemplateIds, setSelectedTemplateIds] = useState<string[]>(data.selectedTemplates || []);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
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.templates || []);
} catch (err) {
console.error('Error loading quality templates:', err);
setError('Error loading quality templates');
} finally {
setLoading(false);
}
};
const toggleTemplate = (templateId: string) => {
setSelectedTemplateIds(prev =>
prev.includes(templateId)
? prev.filter(id => id !== templateId)
: [...prev, templateId]
);
};
const handleCreateRecipe = async () => {
if (!currentTenant?.id) {
setError('Could not obtain tenant information');
return;
}
setSaving(true);
setError(null);
try {
const recipeIngredients: RecipeIngredientCreate[] = data.ingredients.map((ing: any, index: number) => ({
ingredient_id: ing.ingredientId,
quantity: ing.quantity,
unit: ing.unit,
ingredient_notes: ing.notes || null,
is_optional: false,
ingredient_order: index + 1,
}));
let qualityConfig: RecipeQualityConfiguration | undefined;
if (selectedTemplateIds.length > 0) {
qualityConfig = {
stages: {
production: {
template_ids: selectedTemplateIds,
required_checks: [],
optional_checks: [],
blocking_on_failure: true,
min_quality_score: 7.0,
}
},
overall_quality_threshold: 7.0,
critical_stage_blocking: true,
auto_create_quality_checks: true,
quality_manager_approval_required: false,
};
}
const recipeData: RecipeCreate = {
name: data.name,
category: data.category,
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('Recipe created successfully');
onComplete();
} catch (err: any) {
console.error('Error creating recipe:', err);
const errorMessage = err.response?.data?.detail || 'Error creating recipe';
setError(errorMessage);
showToast.error(errorMessage);
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<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">
Quality Templates (Optional)
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Select quality control templates to apply to this recipe
</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{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)]">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 quality templates available</p>
<p className="text-sm text-[var(--text-tertiary)]">
You can create templates from the main wizard
</p>
</div>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto">
{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)
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<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">
Required
</span>
)}
</div>
{template.description && (
<p className="text-sm text-[var(--text-secondary)] line-clamp-2">
{template.description}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-[var(--text-tertiary)]">
<span>Type: {template.check_type}</span>
{template.frequency_days && (
<span> Every {template.frequency_days} days</span>
)}
</div>
</div>
{selectedTemplateIds.includes(template.id) && (
<CheckCircle2 className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0 ml-3" />
)}
</div>
</button>
))}
</div>
)}
{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> template(s) selected
</p>
</div>
)}
</>
)}
<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"
>
{saving ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
Creating recipe...
</>
) : (
<>
<CheckCircle2 className="w-5 h-5" />
Create Recipe
</>
)}
</button>
</div>
</div>
);
};
export const RecipeWizardSteps = (data: Record<string, any>, setData: (data: Record<string, any>) => void): WizardStep[] => [
{
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,
},
];