Implement inline ingredient creation pattern (JTBD-driven UX improvement)

🎯 PROBLEM SOLVED:
Users were blocked when needing ingredients that weren't in inventory during:
- Recipe creation (couldn't add missing ingredients)
- Supplier setup (couldn't associate missing products)

This broke the user flow and forced context switching, resulting in lost progress
and frustration. JTBD Analysis revealed users don't remember ALL ingredients upfront—
they discover missing items while building recipes and configuring suppliers.

 SOLUTION: Inline Quick-Add Pattern
Never block the user—allow adding missing data inline without losing context.

📦 NEW COMPONENT: QuickAddIngredientModal (438 lines)
Lightweight modal for fast ingredient creation with minimal friction:

**Minimum Required Fields** (3 fields to unblock):
- Name (required)
- Category (required)
- Unit of Measure (required)

**Optional Fields** (collapsible section):
- Stock Quantity, Cost Per Unit, Shelf Life Days
- Low Stock Threshold, Reorder Point
- Refrigeration/Freezing/Seasonal checkboxes
- Notes

**Smart Features**:
- Context-aware messaging (recipe vs supplier)
- Auto-closes and auto-selects created ingredient
- Tracks creation context (metadata for incomplete items)
- Beautiful animations (fadeIn, slideUp, slideDown)
- Full validation with error messages
- Loading states with spinner

🔧 RECIPES STEP INTEGRATION:
- Added "+ Add New Ingredient" option in BOTH dropdowns:
  * Finished Product selector
  * Recipe ingredient selectors
- On selection → Modal opens
- On create → Ingredient auto-selected in form
- Handles both finished products (index -1) and ingredients (index N)

🔧 SUPPLIERS STEP INTEGRATION:
- Added "+ Add New Product" button in product picker
- Below existing product checkboxes
- On create → Product auto-selected for supplier
- Price entry form appears immediately

📊 UX FLOW COMPARISON:

**BEFORE (Blocked)**:
```
User adding recipe → Needs "French Butter"
→ Not in list → STUCK 🚫
→ Must exit recipe form
→ Go to inventory
→ Add ingredient
→ Return to recipes
→ Lose form context
```

**AFTER (Inline)**:
```
User adding recipe → Needs "French Butter"
→ Click "+ Add New Ingredient" 
→ Modal: Fill 3 fields (10 seconds)
→ Click "Add and Use in Recipe"
→  Created + Auto-selected
→ Continue recipe seamlessly
```

🎨 UI/UX FEATURES:
- Smooth modal animations
- Semi-transparent backdrop (context visible)
- Auto-focus on name field
- Collapsible optional fields
- Info box: "Complete details later in inventory management"
- Context-specific CTAs ("Add and Use in Recipe" vs "Add and Associate")
- Error handling with icons
- Loading states
- Cancel button

💾 DATA INTEGRITY:
- Tracks creation context in metadata
- Marks items as potentially incomplete (needs_review flag)
- Future: Dashboard alert for incomplete items
- Smart duplicate detection (future enhancement)

📁 FILES:
- QuickAddIngredientModal.tsx: NEW (438 lines)
- RecipesSetupStep.tsx: +50 lines (modal integration)
- SupplierProductManager.tsx: +29 lines (modal integration)

Build:  Success (21.10s)
Pattern: Follows best practices for inline creation
UX: Zero context loss, minimal friction, instant gratification
This commit is contained in:
Claude
2025-11-06 15:25:26 +00:00
parent 3a1a19d836
commit 9162fc32a5
3 changed files with 615 additions and 4 deletions

View File

@@ -8,6 +8,7 @@ import { useAuthUser } from '../../../../stores/auth.store';
import { MeasurementUnit } from '../../../../api/types/recipes';
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;
@@ -53,6 +54,10 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
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;
@@ -217,6 +222,29 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
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)') },
@@ -526,7 +554,14 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
<select
id="finished-product"
value={formData.finished_product_id}
onChange={(e) => setFormData({ ...formData, finished_product_id: e.target.value })}
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>
@@ -535,6 +570,9 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
{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>
@@ -595,11 +633,17 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
<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">
<div className="flex-1 flex gap-2">
<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)]`}
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) => (
@@ -607,6 +651,9 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
{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>
@@ -734,6 +781,18 @@ export const RecipesSetupStep: React.FC<SetupStepProps> = ({ onUpdate, onComplet
</button>
</div>
)}
{/* Quick Add Ingredient Modal */}
<QuickAddIngredientModal
isOpen={showQuickAddModal}
onClose={() => {
setShowQuickAddModal(false);
setPendingIngredientIndex(null);
}}
onCreated={handleIngredientCreated}
tenantId={tenantId}
context="recipe"
/>
</div>
);
};