802 lines
40 KiB
TypeScript
802 lines
40 KiB
TypeScript
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>
|
||
);
|
||
};
|