Files
bakery-ia/frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx
2025-12-17 13:03:52 +01:00

802 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import type { SetupStepProps } from '../SetupWizard';
import { useRecipes, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { MeasurementUnit } from '../../../../api/types/recipes';
import { ProductType } from '../../../../api/types/inventory';
import type { RecipeCreate, RecipeIngredientCreate } from '../../../../api/types/recipes';
import { getAllRecipeTemplates, matchIngredientToTemplate, type RecipeTemplate } from '../data/recipeTemplates';
import { QuickAddIngredientModal } from '../../inventory/QuickAddIngredientModal';
interface RecipeIngredientForm {
ingredient_id: string;
quantity: string;
unit: MeasurementUnit;
ingredient_order: number;
}
export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplete, canContinue }) => {
const { t } = useTranslation();
// Get tenant ID
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const tenantId = currentTenant?.id || user?.tenant_id || '';
// Fetch recipes and ingredients
const { data: recipesData, isLoading: recipesLoading } = useRecipes(tenantId);
const { data: ingredientsData, isLoading: ingredientsLoading } = useIngredients(tenantId);
const recipes = recipesData || [];
const ingredients = ingredientsData || [];
// Mutations
const createRecipeMutation = useCreateRecipe(tenantId);
const updateRecipeMutation = useUpdateRecipe(tenantId);
const deleteRecipeMutation = useDeleteRecipe(tenantId);
// Form state
const [isAdding, setIsAdding] = useState(false);
const [formData, setFormData] = useState({
name: '',
description: '',
finished_product_id: '',
yield_quantity: '',
yield_unit: MeasurementUnit.UNITS,
category: '',
});
const [recipeIngredients, setRecipeIngredients] = useState<RecipeIngredientForm[]>([]);
const [errors, setErrors] = useState<Record<string, string>>({});
// Template state
const [showTemplates, setShowTemplates] = useState(ingredients.length >= 3 && recipes.length === 0);
const [selectedTemplate, setSelectedTemplate] = useState<RecipeTemplate | null>(null);
const allTemplates = getAllRecipeTemplates();
// Quick add ingredient modal state
const [showQuickAddModal, setShowQuickAddModal] = useState(false);
const [pendingIngredientIndex, setPendingIngredientIndex] = useState<number | null>(null);
// Notify parent when count changes
useEffect(() => {
const count = recipes.length;
onUpdate?.({
itemsCount: count,
canContinue: count >= 1,
});
}, [recipes.length, onUpdate]);
// Validation
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = t('setup_wizard:recipes.errors.name_required', 'Recipe name is required');
}
if (!formData.finished_product_id) {
newErrors.finished_product_id = t('setup_wizard:recipes.errors.finished_product_required', 'Finished product is required');
}
if (!formData.yield_quantity || isNaN(Number(formData.yield_quantity)) || Number(formData.yield_quantity) <= 0) {
newErrors.yield_quantity = t('setup_wizard:recipes.errors.yield_invalid', 'Yield must be a positive number');
}
if (recipeIngredients.length === 0) {
newErrors.ingredients = t('setup_wizard:recipes.errors.ingredients_required', 'At least one ingredient is required');
}
// Validate each ingredient
recipeIngredients.forEach((ing, index) => {
if (!ing.ingredient_id) {
newErrors[`ingredient_${index}_id`] = t('setup_wizard:recipes.errors.ingredient_required', 'Ingredient is required');
}
if (!ing.quantity || isNaN(Number(ing.quantity)) || Number(ing.quantity) <= 0) {
newErrors[`ingredient_${index}_quantity`] = t('setup_wizard:recipes.errors.quantity_invalid', 'Quantity must be positive');
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Form handlers
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
const recipeData: RecipeCreate = {
name: formData.name,
description: formData.description || undefined,
finished_product_id: formData.finished_product_id,
yield_quantity: Number(formData.yield_quantity),
yield_unit: formData.yield_unit,
category: formData.category || undefined,
ingredients: recipeIngredients.map((ing) => ({
ingredient_id: ing.ingredient_id,
quantity: Number(ing.quantity),
unit: ing.unit,
ingredient_order: ing.ingredient_order,
is_optional: false,
} as RecipeIngredientCreate)),
};
await createRecipeMutation.mutateAsync(recipeData);
// Reset form
resetForm();
} catch (error) {
console.error('Error saving recipe:', error);
}
};
const resetForm = () => {
setFormData({
name: '',
description: '',
finished_product_id: '',
yield_quantity: '',
yield_unit: MeasurementUnit.UNITS,
category: '',
});
setRecipeIngredients([]);
setErrors({});
setIsAdding(false);
};
const handleDelete = async (recipeId: string) => {
if (!window.confirm(t('setup_wizard:recipes.confirm_delete', 'Are you sure you want to delete this recipe?'))) {
return;
}
try {
await deleteRecipeMutation.mutateAsync(recipeId);
} catch (error) {
console.error('Error deleting recipe:', error);
}
};
const addIngredient = () => {
setRecipeIngredients([
...recipeIngredients,
{
ingredient_id: '',
quantity: '',
unit: MeasurementUnit.GRAMS,
ingredient_order: recipeIngredients.length + 1,
},
]);
};
const removeIngredient = (index: number) => {
setRecipeIngredients(recipeIngredients.filter((_, i) => i !== index));
};
const updateIngredient = (index: number, field: keyof RecipeIngredientForm, value: any) => {
const updated = [...recipeIngredients];
updated[index] = { ...updated[index], [field]: value };
setRecipeIngredients(updated);
};
// Template handlers
const handleUseTemplate = (template: RecipeTemplate) => {
// Find first ingredient that matches the finished product (usually the main ingredient)
const finishedProductIngredient = ingredients.find(
ing => ing.name.toLowerCase().includes(template.name.toLowerCase().split(' ')[0])
);
// Map template ingredients to actual ingredient IDs
const mappedIngredients: RecipeIngredientForm[] = [];
let order = 1;
for (const templateIng of template.ingredients) {
const ingredientId = matchIngredientToTemplate(templateIng, ingredients);
if (ingredientId) {
mappedIngredients.push({
ingredient_id: ingredientId,
quantity: templateIng.quantity.toString(),
unit: templateIng.unit,
ingredient_order: order++,
});
}
}
setFormData({
name: template.name,
description: template.description,
finished_product_id: finishedProductIngredient?.id || '',
yield_quantity: template.yieldQuantity.toString(),
yield_unit: template.yieldUnit,
category: template.category,
});
setRecipeIngredients(mappedIngredients);
setSelectedTemplate(template);
setIsAdding(true);
setShowTemplates(false);
};
const handlePreviewTemplate = (template: RecipeTemplate) => {
setSelectedTemplate(template);
};
// Quick add ingredient handlers
const handleQuickAddIngredient = (index: number) => {
setPendingIngredientIndex(index);
setShowQuickAddModal(true);
};
const handleIngredientCreated = async (ingredient: any) => {
// Ingredient is already created in the database by the modal
// Now we need to select it for the recipe
if (pendingIngredientIndex === -1) {
// This was for the finished product
setFormData({ ...formData, finished_product_id: ingredient.id });
} else if (pendingIngredientIndex !== null) {
// Update the ingredient at the pending index
updateIngredient(pendingIngredientIndex, 'ingredient_id', ingredient.id);
}
// Clear pending state
setPendingIngredientIndex(null);
setShowQuickAddModal(false);
};
const unitOptions = [
{ value: MeasurementUnit.GRAMS, label: t('recipes:unit.g', 'Grams (g)') },
{ value: MeasurementUnit.KILOGRAMS, label: t('recipes:unit.kg', 'Kilograms (kg)') },
{ value: MeasurementUnit.MILLILITERS, label: t('recipes:unit.ml', 'Milliliters (ml)') },
{ value: MeasurementUnit.LITERS, label: t('recipes:unit.l', 'Liters (l)') },
{ value: MeasurementUnit.UNITS, label: t('recipes:unit.units', 'Units') },
{ value: MeasurementUnit.PIECES, label: t('recipes:unit.pieces', 'Pieces') },
{ value: MeasurementUnit.CUPS, label: t('recipes:unit.cups', 'Cups') },
{ value: MeasurementUnit.TABLESPOONS, label: t('recipes:unit.tbsp', 'Tablespoons') },
{ value: MeasurementUnit.TEASPOONS, label: t('recipes:unit.tsp', 'Teaspoons') },
];
return (
<div className="space-y-6">
{/* Why This Matters */}
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('setup_wizard:why_this_matters', 'Why This Matters')}
</h3>
<p className="text-sm text-[var(--text-secondary)]">
{t('setup_wizard:recipes.why', 'Recipes connect your inventory to production. The system will calculate exact costs per item, track ingredient consumption, and help you optimize your menu profitability.')}
</p>
</div>
{/* Recipe Templates */}
{showTemplates && ingredients.length >= 3 && (
<div className="space-y-4 border-2 border-[var(--color-primary)] rounded-lg p-4 bg-gradient-to-br from-[var(--color-primary)]/5 to-transparent">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-[var(--text-primary)] flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{t('setup_wizard:recipes.quick_start', 'Recipe Templates')}
</h3>
<p className="text-sm text-[var(--text-secondary)] mt-1">
{t('setup_wizard:recipes.quick_start_desc', 'Start with proven recipes and customize to your needs')}
</p>
</div>
<button
type="button"
onClick={() => setShowTemplates(false)}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] p-1"
aria-label={t('common:close', 'Close')}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{Object.entries(allTemplates).map(([categoryKey, templates]) => (
<div key={categoryKey}>
<h4 className="text-sm font-medium text-[var(--text-primary)] mb-2 capitalize">
{t(`setup_wizard:recipes.category.${categoryKey}`, categoryKey)}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{templates.map((template) => (
<div
key={template.id}
className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg p-3 hover:border-[var(--color-primary)] hover:shadow-sm transition-all"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1">
<h5 className="font-medium text-[var(--text-primary)]">{template.name}</h5>
<p className="text-xs text-[var(--text-tertiary)] mt-0.5">{template.description}</p>
</div>
<div className="flex items-center gap-1">
{[...Array(template.difficulty)].map((_, i) => (
<svg key={i} className="w-3 h-3 text-[var(--color-warning)]" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
</div>
<div className="flex items-center gap-3 text-xs text-[var(--text-secondary)] mb-3">
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{template.totalTime} min
</span>
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
{template.ingredients.length} ingredients
</span>
<span>
Yield: {template.yieldQuantity} {template.yieldUnit}
</span>
</div>
{selectedTemplate?.id === template.id && (
<div className="bg-[var(--bg-primary)] rounded p-3 mb-3 space-y-2 text-xs">
<div>
<p className="font-medium text-[var(--text-primary)] mb-1">Ingredients:</p>
<ul className="list-disc list-inside space-y-0.5 text-[var(--text-secondary)]">
{template.ingredients.map((ing, idx) => (
<li key={idx}>
{ing.quantity} {ing.unit} {ing.ingredientName}
</li>
))}
</ul>
</div>
{template.instructions && (
<div>
<p className="font-medium text-[var(--text-primary)] mb-1">Instructions:</p>
<p className="text-[var(--text-secondary)] whitespace-pre-line">{template.instructions}</p>
</div>
)}
{template.tips && template.tips.length > 0 && (
<div>
<p className="font-medium text-[var(--text-primary)] mb-1">Tips:</p>
<ul className="list-disc list-inside space-y-0.5 text-[var(--text-secondary)]">
{template.tips.map((tip, idx) => (
<li key={idx}>{tip}</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => handleUseTemplate(template)}
className="flex-1 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded hover:bg-[var(--color-primary-dark)] transition-colors"
>
{t('setup_wizard:recipes.use_template', 'Use Template')}
</button>
<button
type="button"
onClick={() => handlePreviewTemplate(selectedTemplate?.id === template.id ? null : template)}
className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
{selectedTemplate?.id === template.id ? t('common:hide', 'Hide') : t('common:preview', 'Preview')}
</button>
</div>
</div>
))}
</div>
</div>
))}
<div className="text-xs text-[var(--text-tertiary)] flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('setup_wizard:recipes.templates_hint', 'Templates will automatically match your ingredients. Review and adjust as needed.')}
</div>
</div>
)}
{/* Show templates button when hidden */}
{!showTemplates && recipes.length > 0 && ingredients.length >= 3 && (
<button
type="button"
onClick={() => setShowTemplates(true)}
className="w-full p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-primary)] transition-colors group"
>
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)] text-sm">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>{t('setup_wizard:recipes.show_templates', 'Show Recipe Templates')}</span>
</div>
</button>
)}
{/* Prerequisites check */}
{ingredients.length < 2 && !ingredientsLoading && (
<div className="bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20 rounded-lg p-4">
<div className="flex items-start gap-2">
<svg className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.prerequisites_title', 'More ingredients needed')}
</h4>
<p className="text-sm text-[var(--text-secondary)]">
{t('setup_wizard:recipes.prerequisites_desc', 'You need at least 2 ingredients in your inventory before creating recipes. Go back to the Inventory step to add more ingredients.')}
</p>
</div>
</div>
</div>
)}
{/* Progress indicator */}
<div className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center gap-2">
<svg className="w-5 h-5 text-[var(--text-secondary)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:recipes.added_count', { count: recipes.length, defaultValue: '{{count}} recipe added' })}
</span>
</div>
{recipes.length >= 1 && (
<div className="flex items-center gap-1 text-xs text-[var(--color-success)]">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{t('setup_wizard:recipes.minimum_met', 'Minimum requirement met')}
</div>
)}
</div>
{/* Recipes list */}
{recipes.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--text-secondary)]">
{t('setup_wizard:recipes.your_recipes', 'Your Recipes')}
</h4>
<div className="space-y-2 max-h-80 overflow-y-auto">
{recipes.map((recipe) => (
<div
key={recipe.id}
className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg hover:border-[var(--border-primary)] transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h5 className="font-medium text-[var(--text-primary)] truncate">{recipe.name}</h5>
{recipe.category && (
<span className="text-xs px-2 py-0.5 bg-[var(--bg-primary)] rounded-full text-[var(--text-secondary)]">
{recipe.category}
</span>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--text-secondary)]">
<span>
{t('setup_wizard:recipes.yield_label', 'Yield')}: {recipe.yield_quantity} {recipe.yield_unit}
</span>
{recipe.estimated_cost_per_unit && (
<span className="flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
${Number(recipe.estimated_cost_per_unit).toFixed(2)}/unit
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
type="button"
onClick={() => handleDelete(recipe.id)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors"
aria-label={t('common:delete', 'Delete')}
disabled={deleteRecipeMutation.isPending}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Add form */}
{isAdding ? (
<form onSubmit={handleSubmit} className="space-y-4 p-4 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-[var(--text-primary)]">
{t('setup_wizard:recipes.add_recipe', 'Add Recipe')}
</h4>
<button
type="button"
onClick={resetForm}
className="text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
<div className="space-y-4">
{/* Recipe Name */}
<div>
<label htmlFor="recipe-name" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.name', 'Recipe Name')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="recipe-name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.name ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder={t('setup_wizard:recipes.placeholders.name', 'e.g., Baguette, Croissant')}
/>
{errors.name && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.name}</p>}
</div>
{/* Finished Product */}
<div>
<label htmlFor="finished-product" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.finished_product', 'Finished Product')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="finished-product"
value={formData.finished_product_id}
onChange={(e) => {
if (e.target.value === '__ADD_NEW__') {
setPendingIngredientIndex(-1); // -1 indicates finished product
setShowQuickAddModal(true);
} else {
setFormData({ ...formData, finished_product_id: e.target.value });
}
}}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.finished_product_id ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
>
<option value="">{t('setup_wizard:recipes.placeholders.finished_product', 'Select finished product...')}</option>
{ingredients
.filter((ing) => ing.product_type === ProductType.FINISHED_PRODUCT)
.map((ing) => (
<option key={ing.id} value={ing.id}>
{ing.name} ({ing.unit_of_measure})
</option>
))}
<option value="__ADD_NEW__" className="text-[var(--color-primary)] font-medium">
{t('setup_wizard:recipes.add_new_ingredient', 'Add New Ingredient')}
</option>
</select>
{errors.finished_product_id && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.finished_product_id}</p>}
</div>
{/* Yield */}
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="yield-quantity" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.yield_quantity', 'Yield Quantity')} <span className="text-[var(--color-error)]">*</span>
</label>
<input
id="yield-quantity"
type="number"
step="0.01"
value={formData.yield_quantity}
onChange={(e) => setFormData({ ...formData, yield_quantity: e.target.value })}
className={`w-full px-3 py-2 bg-[var(--bg-primary)] border ${errors.yield_quantity ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder="10"
/>
{errors.yield_quantity && <p className="mt-1 text-xs text-[var(--color-error)]">{errors.yield_quantity}</p>}
</div>
<div>
<label htmlFor="yield-unit" className="block text-sm font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:recipes.fields.yield_unit', 'Unit')} <span className="text-[var(--color-error)]">*</span>
</label>
<select
id="yield-unit"
value={formData.yield_unit}
onChange={(e) => setFormData({ ...formData, yield_unit: e.target.value as MeasurementUnit })}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
{/* Ingredients */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:recipes.fields.ingredients', 'Ingredients')} <span className="text-[var(--color-error)]">*</span>
</label>
<button
type="button"
onClick={addIngredient}
className="text-xs text-[var(--color-primary)] hover:underline"
>
+ {t('setup_wizard:recipes.add_ingredient', 'Add Ingredient')}
</button>
</div>
{errors.ingredients && <p className="mb-2 text-xs text-[var(--color-error)]">{errors.ingredients}</p>}
<div className="space-y-2">
{recipeIngredients.map((ing, index) => (
<div key={index} className="flex gap-2 items-start p-2 bg-[var(--bg-primary)] rounded-lg">
<div className="flex-1 flex gap-2">
<select
value={ing.ingredient_id}
onChange={(e) => {
if (e.target.value === '__ADD_NEW__') {
handleQuickAddIngredient(index);
} else {
updateIngredient(index, 'ingredient_id', e.target.value);
}
}}
className={`flex-1 px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_id`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
>
<option value="">{t('setup_wizard:recipes.select_ingredient', 'Select...')}</option>
{ingredients.map((ingredient) => (
<option key={ingredient.id} value={ingredient.id}>
{ingredient.name}
</option>
))}
<option value="__ADD_NEW__" className="text-[var(--color-primary)] font-medium">
{t('setup_wizard:recipes.add_new_ingredient', 'Add New Ingredient')}
</option>
</select>
{errors[`ingredient_${index}_id`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_id`]}</p>}
</div>
<div className="w-24">
<input
type="number"
step="0.01"
value={ing.quantity}
onChange={(e) => updateIngredient(index, 'quantity', e.target.value)}
placeholder="Qty"
className={`w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border ${errors[`ingredient_${index}_quantity`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
/>
{errors[`ingredient_${index}_quantity`] && <p className="mt-1 text-xs text-[var(--color-error)]">{errors[`ingredient_${index}_quantity`]}</p>}
</div>
<div className="w-20">
<select
value={ing.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value)}
className="w-full px-2 py-1.5 text-sm bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
</option>
))}
</select>
</div>
<button
type="button"
onClick={() => removeIngredient(index)}
className="p-1.5 text-[var(--text-secondary)] hover:text-[var(--color-error)] rounded"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{recipeIngredients.length === 0 && (
<div className="text-center py-4 text-sm text-[var(--text-tertiary)] border border-dashed border-[var(--border-secondary)] rounded-lg">
{t('setup_wizard:recipes.no_ingredients', 'No ingredients added yet')}
</div>
)}
</div>
</div>
</div>
<div className="flex gap-2 pt-2">
<button
type="submit"
disabled={createRecipeMutation.isPending}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{createRecipeMutation.isPending ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('common:saving', 'Saving...')}
</span>
) : (
t('common:add', 'Add')
)}
</button>
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
disabled={ingredients.length < 2}
className="w-full p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="font-medium">
{recipes.length === 0
? t('setup_wizard:recipes.add_first', 'Add Your First Recipe')
: t('setup_wizard:recipes.add_another', 'Add Another Recipe')}
</span>
</div>
</button>
)}
{/* Loading state */}
{(recipesLoading || ingredientsLoading) && recipes.length === 0 && (
<div className="text-center py-8">
<svg className="animate-spin h-8 w-8 text-[var(--color-primary)] mx-auto" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
{t('common:loading', 'Loading...')}
</p>
</div>
)}
{/* Navigation - Show Next button when minimum requirement met */}
{recipes.length >= 1 && !isAdding && (
<div className="flex items-center justify-between pt-6 border-t border-[var(--border-secondary)] mt-6">
<div className="flex items-center gap-2 text-sm text-[var(--color-success)]">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
{t('setup_wizard:recipes.minimum_met', '{{count}} recipe(s) added - Ready to continue!', { count: recipes.length })}
</span>
</div>
<button
onClick={() => onComplete?.()}
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors font-medium"
>
{t('common:next', 'Next')}
</button>
</div>
)}
{/* Quick Add Ingredient Modal */}
<QuickAddIngredientModal
isOpen={showQuickAddModal}
onClose={() => {
setShowQuickAddModal(false);
setPendingIngredientIndex(null);
}}
onCreated={handleIngredientCreated}
tenantId={tenantId}
context="recipe"
/>
</div>
);
};