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.
1007 lines
44 KiB
TypeScript
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,
|
|
},
|
|
];
|