🎯 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
442 lines
18 KiB
TypeScript
442 lines
18 KiB
TypeScript
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>
|
||
);
|
||
};
|