Implement Phase 2: Core data entry steps for setup wizard
This commit implements the three core data entry steps for the bakery setup wizard, enabling users to configure their essential operational data immediately after onboarding. ## Implemented Steps ### 1. Suppliers Setup Step (SuppliersSetupStep.tsx) - Inline form for adding/editing suppliers - Required fields: name, supplier_type - Optional fields: contact_person, phone, email - List view with edit/delete actions - Minimum requirement: 1 supplier - Real-time validation and error handling - Integration with existing suppliers API hooks ### 2. Inventory Setup Step (InventorySetupStep.tsx) - Inline form for adding/editing ingredients - Required fields: name, category, unit_of_measure - Optional fields: brand, standard_cost - List view with edit/delete actions (scrollable) - Minimum requirement: 3 ingredients - Progress indicator showing remaining items needed - Category and unit dropdowns with i18n support ### 3. Recipes Setup Step (RecipesSetupStep.tsx) - Recipe creation form with ingredient management - Required fields: name, finished_product, yield_quantity, yield_unit - Dynamic ingredient list (add/remove ingredients) - Prerequisite check (requires ≥2 inventory items) - Per-ingredient validation (ingredient_id, quantity) - Minimum requirement: 1 recipe - Integration with recipes and inventory APIs ## Key Features ### Shared Functionality Across All Steps: - Parent notification via onUpdate callback (itemsCount, canContinue) - Inline forms (not modals) for better UX flow - Real-time validation with error messages - Loading states and empty states - Responsive design (mobile-first) - i18n support with translation keys - Delete confirmation dialogs - "Why This Matters" sections explaining value ### Progress Tracking: - Progress indicators showing count and requirement status - Visual feedback when minimum requirements met - "Need X more" messages for incomplete steps ### Error Handling: - Field-level validation errors - Type-safe number inputs - Required field indicators - User-friendly error messages ## Technical Implementation ### API Integration: - Uses existing React Query hooks pattern - Proper cache invalidation on mutations - Tenant-scoped queries - Optimistic updates where applicable ### State Management: - Local form state for each step - useEffect for parent updates - Reset functionality on cancel/success ### Type Safety: - TypeScript interfaces for all data - Enum types for categories and units - Proper typing for mutation callbacks ## Files Modified: - frontend/src/components/domain/setup-wizard/steps/SuppliersSetupStep.tsx - frontend/src/components/domain/setup-wizard/steps/InventorySetupStep.tsx - frontend/src/components/domain/setup-wizard/steps/RecipesSetupStep.tsx ## Related: - Builds on Phase 1 wizard foundation - Integrates with existing suppliers, inventory, and recipes services - Follows design specification in docs/wizard-flow-specification.md - Addresses JTBD analysis findings in docs/jtbd-analysis-inventory-setup.md ## Next Steps (Phase 3): - Quality Setup Step - Team Setup Step - Template systems - Bulk import functionality
This commit is contained in:
@@ -1,12 +1,190 @@
|
||||
import React from 'react';
|
||||
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 type { RecipeCreate, RecipeIngredientCreate } from '../../../../api/types/recipes';
|
||||
|
||||
export const RecipesSetupStep: React.FC<SetupStepProps> = () => {
|
||||
interface RecipeIngredientForm {
|
||||
ingredient_id: string;
|
||||
quantity: string;
|
||||
unit: MeasurementUnit;
|
||||
ingredient_order: number;
|
||||
}
|
||||
|
||||
export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate }) => {
|
||||
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>>({});
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -19,20 +197,328 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="w-16 h-16 bg-[var(--bg-secondary)] rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||
👨🍳
|
||||
{/* 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>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{t('setup_wizard:recipes.placeholder_title', 'Recipes Management')}
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{t('setup_wizard:recipes.placeholder_desc', 'This feature will be implemented in Phase 2')}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-tertiary)]">
|
||||
{t('setup_wizard:recipes.min_required', 'Minimum required: 1 recipe')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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) => 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.map((ing) => (
|
||||
<option key={ing.id} value={ing.id}>
|
||||
{ing.name} ({ing.unit_of_measure})
|
||||
</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">
|
||||
<select
|
||||
value={ing.ingredient_id}
|
||||
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
|
||||
className={`w-full 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>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user