Files
bakery-ia/frontend/src/components/domain/setup-wizard/steps/SupplierProductManager.tsx
Claude 9162fc32a5 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
2025-11-06 15:25:26 +00:00

442 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
useSupplierPriceLists,
useCreateSupplierPriceList,
useUpdateSupplierPriceList,
useDeleteSupplierPriceList
} from '../../../../api/hooks/suppliers';
import { useIngredients } from '../../../../api/hooks/inventory';
import type { SupplierPriceListCreate, SupplierPriceListResponse } from '../../../../api/types/suppliers';
import { QuickAddIngredientModal } from '../../inventory/QuickAddIngredientModal';
interface SupplierProductManagerProps {
tenantId: string;
supplierId: string;
supplierName: string;
}
interface ProductFormData {
inventory_product_id: string;
product_name?: string;
unit_price: string;
unit_of_measure: string;
minimum_order_quantity: string;
}
export const SupplierProductManager: React.FC<SupplierProductManagerProps> = ({
tenantId,
supplierId,
supplierName
}) => {
const { t } = useTranslation();
// Fetch existing price lists for this supplier
const { data: priceLists = [], isLoading: priceListsLoading } = useSupplierPriceLists(
tenantId,
supplierId,
true // only active
);
// Fetch all inventory items
const { data: inventoryItems = [], isLoading: inventoryLoading } = useIngredients(tenantId);
// Mutations
const createPriceListMutation = useCreateSupplierPriceList();
const updatePriceListMutation = useUpdateSupplierPriceList();
const deletePriceListMutation = useDeleteSupplierPriceList();
// UI State
const [isExpanded, setIsExpanded] = useState(false);
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [selectedProducts, setSelectedProducts] = useState<string[]>([]);
const [productForms, setProductForms] = useState<Record<string, ProductFormData>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
// Quick add modal state
const [showQuickAddModal, setShowQuickAddModal] = useState(false);
// Filter available products (not already in price list)
const availableProducts = inventoryItems.filter(
item => !priceLists.some(pl => pl.inventory_product_id === item.id)
);
const handleToggleProduct = (productId: string) => {
setSelectedProducts(prev => {
if (prev.includes(productId)) {
// Remove
const newSelected = prev.filter(id => id !== productId);
const newForms = { ...productForms };
delete newForms[productId];
setProductForms(newForms);
return newSelected;
} else {
// Add with default form
const product = inventoryItems.find(p => p.id === productId);
setProductForms(prev => ({
...prev,
[productId]: {
inventory_product_id: productId,
product_name: product?.name || '',
unit_price: '',
unit_of_measure: product?.unit_of_measure || 'kg',
minimum_order_quantity: '1'
}
}));
return [...prev, productId];
}
});
};
// Quick add handlers
const handleIngredientCreated = (ingredient: any) => {
// Ingredient created - auto-select it
handleToggleProduct(ingredient.id);
setShowQuickAddModal(false);
};
const handleUpdateForm = (productId: string, field: string, value: string) => {
setProductForms(prev => ({
...prev,
[productId]: {
...prev[productId],
[field]: value
}
}));
};
const validateForms = (): boolean => {
const newErrors: Record<string, string> = {};
selectedProducts.forEach(productId => {
const form = productForms[productId];
if (!form.unit_price || parseFloat(form.unit_price) <= 0) {
newErrors[`${productId}_price`] = 'Price must be greater than 0';
}
if (!form.unit_of_measure) {
newErrors[`${productId}_unit`] = 'Unit of measure is required';
}
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSaveProducts = async () => {
if (!validateForms()) return;
try {
const promises = selectedProducts.map(productId => {
const form = productForms[productId];
const priceListData: SupplierPriceListCreate = {
inventory_product_id: form.inventory_product_id,
unit_price: parseFloat(form.unit_price),
unit_of_measure: form.unit_of_measure,
minimum_order_quantity: form.minimum_order_quantity ? parseInt(form.minimum_order_quantity) : 1,
price_per_unit: parseFloat(form.unit_price), // Same as unit_price for now
is_active: true
};
return createPriceListMutation.mutateAsync({
tenantId,
supplierId,
priceListData
});
});
await Promise.all(promises);
// Reset form
setSelectedProducts([]);
setProductForms({});
setIsAdding(false);
} catch (error) {
console.error('Error saving products:', error);
}
};
const handleDeleteProduct = async (priceListId: string) => {
if (!window.confirm(t('common:confirm_delete', 'Are you sure?'))) return;
try {
await deletePriceListMutation.mutateAsync({
tenantId,
supplierId,
priceListId
});
} catch (error) {
console.error('Error deleting product:', error);
}
};
const getProductName = (inventoryProductId: string) => {
const product = inventoryItems.find(p => p.id === inventoryProductId);
return product?.name || inventoryProductId;
};
if (!isExpanded) {
return (
<div className="mt-3 pt-3 border-t border-[var(--border-secondary)]">
<button
type="button"
onClick={() => setIsExpanded(true)}
className="w-full flex items-center justify-between p-3 text-sm text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4" 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>
<span className="font-medium">
{t('setup_wizard:suppliers.manage_products', 'Manage Products')}
</span>
<span className="text-xs bg-[var(--bg-primary)] px-2 py-0.5 rounded-full">
{priceLists.length} {t('setup_wizard:suppliers.products', 'products')}
</span>
</div>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
);
}
return (
<div className="mt-3 pt-3 border-t border-[var(--border-secondary)]">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-[var(--text-secondary)]" 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>
<span className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:suppliers.products_for', 'Products for {{name}}', { name: supplierName })}
</span>
</div>
<button
type="button"
onClick={() => setIsExpanded(false)}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:collapse', 'Collapse')}
</button>
</div>
{/* Existing products */}
{priceLists.length > 0 && (
<div className="space-y-2 mb-3">
{priceLists.map((priceList) => (
<div
key={priceList.id}
className="flex items-center justify-between p-2 bg-[var(--bg-secondary)] rounded text-sm"
>
<div className="flex-1">
<span className="font-medium text-[var(--text-primary)]">
{getProductName(priceList.inventory_product_id)}
</span>
<span className="text-[var(--text-secondary)] ml-2">
{priceList.unit_price.toFixed(2)}/{priceList.unit_of_measure}
</span>
{priceList.minimum_order_quantity && priceList.minimum_order_quantity > 1 && (
<span className="text-xs text-[var(--text-secondary)] ml-2">
(Min: {priceList.minimum_order_quantity})
</span>
)}
</div>
<button
type="button"
onClick={() => handleDeleteProduct(priceList.id)}
className="p-1 text-[var(--text-secondary)] hover:text-[var(--color-error)] transition-colors"
disabled={deletePriceListMutation.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>
)}
{/* Add products section */}
{!isAdding && (
<button
type="button"
onClick={() => setIsAdding(true)}
disabled={availableProducts.length === 0}
className="w-full p-2 border border-dashed border-[var(--border-secondary)] rounded hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] text-sm text-[var(--text-secondary)] hover:text-[var(--color-primary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
+ {t('setup_wizard:suppliers.add_products', 'Add Products')}
{availableProducts.length === 0 && (
<span className="ml-2 text-xs">({t('setup_wizard:suppliers.no_products_available', 'No products available')})</span>
)}
</button>
)}
{/* Product selection form */}
{isAdding && (
<div className="space-y-3 p-3 border-2 border-[var(--color-primary)] rounded-lg bg-[var(--bg-secondary)]">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-[var(--text-primary)]">
{t('setup_wizard:suppliers.select_products', 'Select Products')}
</h4>
<button
type="button"
onClick={() => {
setIsAdding(false);
setSelectedProducts([]);
setProductForms({});
setErrors({});
}}
className="text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
{/* Product checkboxes */}
<div className="max-h-48 overflow-y-auto space-y-2">
{availableProducts.map(product => (
<div key={product.id}>
<label className="flex items-start gap-2 p-2 hover:bg-[var(--bg-primary)] rounded cursor-pointer">
<input
type="checkbox"
checked={selectedProducts.includes(product.id)}
onChange={() => handleToggleProduct(product.id)}
className="mt-0.5 w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]"
/>
<div className="flex-1 text-sm">
<div className="font-medium text-[var(--text-primary)]">{product.name}</div>
<div className="text-xs text-[var(--text-secondary)]">
{product.category} {product.unit_of_measure}
</div>
</div>
</label>
{/* Price form for selected products */}
{selectedProducts.includes(product.id) && productForms[product.id] && (
<div className="ml-6 mt-2 grid grid-cols-3 gap-2 p-2 bg-[var(--bg-primary)] rounded">
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.unit_price', 'Price')} () *
</label>
<input
type="number"
min="0"
step="0.01"
value={productForms[product.id].unit_price}
onChange={(e) => handleUpdateForm(product.id, 'unit_price', e.target.value)}
className={`w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border ${
errors[`${product.id}_price`] ? 'border-[var(--color-error)]' : 'border-[var(--border-secondary)]'
} rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]`}
placeholder="0.00"
/>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.unit', 'Unit')} *
</label>
<select
value={productForms[product.id].unit_of_measure}
onChange={(e) => handleUpdateForm(product.id, 'unit_of_measure', e.target.value)}
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
>
<option value="kg">kg</option>
<option value="g">g</option>
<option value="L">L</option>
<option value="ml">ml</option>
<option value="units">units</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
{t('setup_wizard:suppliers.min_qty', 'Min Qty')}
</label>
<input
type="number"
min="1"
value={productForms[product.id].minimum_order_quantity}
onChange={(e) => handleUpdateForm(product.id, 'minimum_order_quantity', e.target.value)}
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
/>
</div>
</div>
)}
</div>
))}
{/* Add New Product Button */}
<button
type="button"
onClick={() => setShowQuickAddModal(true)}
className="w-full p-2 mt-2 border border-dashed border-[var(--color-primary)] rounded hover:bg-[var(--color-primary)]/5 transition-colors group"
>
<div className="flex items-center justify-center gap-2 text-[var(--color-primary)]">
<svg className="w-4 h-4" 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="text-sm font-medium">
{t('setup_wizard:suppliers.add_new_product', 'Add New Product')}
</span>
</div>
</button>
</div>
{/* Actions */}
{selectedProducts.length > 0 && (
<div className="flex gap-2 pt-2 border-t border-[var(--border-secondary)]">
<button
type="button"
onClick={handleSaveProducts}
disabled={createPriceListMutation.isPending}
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded hover:bg-[var(--color-primary-dark)] disabled:opacity-50 text-sm font-medium"
>
{createPriceListMutation.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('setup_wizard:suppliers.save_products', 'Save')} (${selectedProducts.length})`
)}
</button>
<button
type="button"
onClick={() => {
setIsAdding(false);
setSelectedProducts([]);
setProductForms({});
setErrors({});
}}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded"
>
{t('common:cancel', 'Cancel')}
</button>
</div>
)}
</div>
)}
{/* Warning if no products */}
{priceLists.length === 0 && !isAdding && (
<div className="mt-2 p-2 bg-[var(--color-warning)]/10 border border-[var(--color-warning)]/20 rounded text-xs text-[var(--color-warning)]">
{t('setup_wizard:suppliers.no_products_warning', 'Add at least 1 product to enable automatic purchase orders')}
</div>
)}
{/* Quick Add Ingredient Modal */}
<QuickAddIngredientModal
isOpen={showQuickAddModal}
onClose={() => setShowQuickAddModal(false)}
onCreated={handleIngredientCreated}
tenantId={tenantId}
context="supplier"
/>
</div>
);
};