Files
bakery-ia/frontend/src/components/domain/setup-wizard/steps/SupplierProductManager.tsx
Claude e2a326d90f Fix TypeError: unit_price.toFixed is not a function in SupplierProductManager
- 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.
2025-11-07 10:12:19 +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">
{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>
);
};