- Convert unit_price to Number before calling toFixed() on line 239 - Handles cases where unit_price may be a string, null, or undefined - Uses fallback to 0 for invalid values to prevent runtime errors This fixes the error that occurred when displaying supplier product prices in the onboarding supplier step.
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">
|
||
€{Number(priceList.unit_price || 0).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>
|
||
);
|
||
};
|