Files
bakery-ia/frontend/src/components/domain/procurement/CreatePurchaseOrderModal.tsx
2025-10-21 19:50:07 +02:00

417 lines
14 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { Plus, Package, Calendar, Building2 } from 'lucide-react';
import { AddModal } from '../../ui/AddModal/AddModal';
import { useSuppliers } from '../../../api/hooks/suppliers';
import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers';
import { useIngredients } from '../../../api/hooks/inventory';
import { useTenantStore } from '../../../stores/tenant.store';
import { suppliersService } from '../../../api/services/suppliers';
import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders';
import type { SupplierSummary } from '../../../api/types/suppliers';
import type { IngredientResponse } from '../../../api/types/inventory';
import { statusColors } from '../../../styles/colors';
interface CreatePurchaseOrderModalProps {
isOpen: boolean;
onClose: () => void;
requirements: ProcurementRequirementResponse[];
onSuccess?: () => void;
}
/**
* CreatePurchaseOrderModal - Modal for creating purchase orders from procurement requirements
* Allows supplier selection and purchase order creation for ingredients
* Can also be used for manual purchase order creation when no requirements are provided
*/
export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> = ({
isOpen,
onClose,
requirements,
onSuccess
}) => {
const [loading, setLoading] = useState(false);
const [selectedSupplier, setSelectedSupplier] = useState<string>('');
const [formData, setFormData] = useState<Record<string, any>>({});
// Get current tenant
const { currentTenant } = useTenantStore();
const tenantId = currentTenant?.id || '';
// Fetch suppliers (without status filter to avoid backend enum issue)
const { data: suppliersData, isLoading: isLoadingSuppliers, isError: isSuppliersError, error: suppliersError } = useSuppliers(
tenantId,
{ limit: 100 },
{ enabled: !!tenantId && isOpen }
);
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
// State for supplier products
const [supplierProductIds, setSupplierProductIds] = useState<string[]>([]);
const [isLoadingSupplierProducts, setIsLoadingSupplierProducts] = useState(false);
// Fetch ALL ingredients (we'll filter client-side based on supplier products)
const { data: allIngredientsData = [], isLoading: isLoadingIngredients } = useIngredients(
tenantId,
{},
{ enabled: !!tenantId && isOpen && !requirements?.length }
);
// Fetch supplier products when supplier is selected
useEffect(() => {
const fetchSupplierProducts = async () => {
if (!selectedSupplier || !tenantId) {
setSupplierProductIds([]);
return;
}
setIsLoadingSupplierProducts(true);
try {
const products = await suppliersService.getSupplierProducts(tenantId, selectedSupplier);
const productIds = products.map(p => p.inventory_product_id);
setSupplierProductIds(productIds);
} catch (error) {
console.error('Error fetching supplier products:', error);
setSupplierProductIds([]);
} finally {
setIsLoadingSupplierProducts(false);
}
};
fetchSupplierProducts();
}, [selectedSupplier, tenantId]);
// Filter ingredients based on supplier products
const ingredientsData = useMemo(() => {
if (!selectedSupplier || supplierProductIds.length === 0) {
return [];
}
return allIngredientsData.filter(ing => supplierProductIds.includes(ing.id));
}, [allIngredientsData, supplierProductIds, selectedSupplier]);
// Create purchase order mutation
const createPurchaseOrderMutation = useCreatePurchaseOrder();
const supplierOptions = useMemo(() => suppliers.map(supplier => ({
value: supplier.id,
label: `${supplier.name} (${supplier.supplier_code})`
})), [suppliers]);
// Create ingredient options from supplier-filtered ingredients
const ingredientOptions = useMemo(() => ingredientsData.map(ingredient => ({
value: ingredient.id,
label: ingredient.name,
data: ingredient // Store full ingredient data for later use
})), [ingredientsData]);
// Reset selected supplier when modal closes
useEffect(() => {
if (!isOpen) {
setSelectedSupplier('');
setFormData({});
}
}, [isOpen]);
// Unit options for select field
const unitOptions = [
{ value: 'kg', label: 'Kilogramos' },
{ value: 'g', label: 'Gramos' },
{ value: 'l', label: 'Litros' },
{ value: 'ml', label: 'Mililitros' },
{ value: 'units', label: 'Unidades' },
{ value: 'boxes', label: 'Cajas' },
{ value: 'bags', label: 'Bolsas' }
];
const handleSave = async (formData: Record<string, any>) => {
setLoading(true);
try {
let items: PurchaseOrderItem[] = [];
if (requirements && requirements.length > 0) {
// Create items from requirements list
const requiredIngredients = formData.required_ingredients || [];
if (requiredIngredients.length === 0) {
throw new Error('Por favor, selecciona al menos un ingrediente');
}
// Validate quantities
const invalidQuantities = requiredIngredients.some((item: any) => item.quantity <= 0);
if (invalidQuantities) {
throw new Error('Todas las cantidades deben ser mayores a 0');
}
// Prepare purchase order items from requirements
items = requiredIngredients.map((item: any) => {
// Find original requirement to get product_id
const originalReq = requirements.find(req => req.id === item.id);
return {
inventory_product_id: originalReq?.product_id || '',
product_code: item.product_sku || '',
product_name: item.product_name,
ordered_quantity: item.quantity,
unit_of_measure: item.unit_of_measure,
unit_price: item.unit_price,
quality_requirements: originalReq?.quality_specifications ? JSON.stringify(originalReq.quality_specifications) : undefined,
notes: originalReq?.special_requirements || undefined
};
});
} else {
// Create items from manual entries
const manualProducts = formData.manual_products || [];
if (manualProducts.length === 0) {
throw new Error('Por favor, agrega al menos un producto');
}
// Validate quantities for manual items
const invalidQuantities = manualProducts.some((item: any) => item.quantity <= 0);
if (invalidQuantities) {
throw new Error('Todas las cantidades deben ser mayores a 0');
}
// Validate required fields
const invalidProducts = manualProducts.some((item: any) => !item.ingredient_id);
if (invalidProducts) {
throw new Error('Todos los productos deben tener un ingrediente seleccionado');
}
// Prepare purchase order items from manual entries with ingredient data
items = manualProducts.map((item: any) => {
// Find the selected ingredient data
const selectedIngredient = ingredientsData.find(ing => ing.id === item.ingredient_id);
return {
inventory_product_id: item.ingredient_id,
product_code: selectedIngredient?.sku || '',
product_name: selectedIngredient?.name || 'Ingrediente desconocido',
ordered_quantity: item.quantity,
unit_of_measure: item.unit_of_measure,
unit_price: item.unit_price,
quality_requirements: undefined,
notes: undefined
};
});
}
// Create purchase order
await createPurchaseOrderMutation.mutateAsync({
supplier_id: formData.supplier_id,
priority: 'normal',
required_delivery_date: formData.delivery_date || undefined,
notes: formData.notes || undefined,
items
});
// Purchase order created successfully
// Trigger success callback
if (onSuccess) {
onSuccess();
}
} catch (error) {
console.error('Error creating purchase order:', error);
throw error; // Let AddModal handle error display
} finally {
setLoading(false);
}
};
const statusConfig = {
color: statusColors.inProgress.primary,
text: 'Nueva Orden',
icon: Plus,
isCritical: false,
isHighlight: true
};
// Build sections dynamically based on selectedSupplier
const sections = useMemo(() => {
const supplierSection = {
title: 'Información del Proveedor',
icon: Building2,
fields: [
{
label: 'Proveedor',
name: 'supplier_id',
type: 'select' as const,
required: true,
options: supplierOptions,
placeholder: 'Seleccionar proveedor...',
span: 2,
validation: (value: any) => {
// Update selectedSupplier when supplier changes
if (value && value !== selectedSupplier) {
setTimeout(() => setSelectedSupplier(value), 0);
}
return null;
}
}
]
};
const orderDetailsSection = {
title: 'Detalles de la Orden',
icon: Calendar,
fields: [
{
label: 'Fecha de Entrega Requerida',
name: 'delivery_date',
type: 'date' as const,
helpText: 'Fecha límite para la entrega (opcional)'
},
{
label: 'Notas',
name: 'notes',
type: 'textarea' as const,
placeholder: 'Instrucciones especiales para el proveedor...',
span: 2,
helpText: 'Información adicional o instrucciones especiales'
}
]
};
const ingredientsSection = {
title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar',
icon: Package,
fields: [
requirements && requirements.length > 0 ? {
label: 'Ingredientes Requeridos',
name: 'required_ingredients',
type: 'list' as const,
span: 2,
defaultValue: requirements.map(req => ({
id: req.id,
product_name: req.product_name,
product_sku: req.product_sku || '',
quantity: req.approved_quantity || req.net_requirement || req.required_quantity,
unit_of_measure: req.unit_of_measure,
unit_price: req.estimated_unit_cost || 0,
selected: true
})),
listConfig: {
itemFields: [
{
name: 'product_name',
label: 'Producto',
type: 'text',
required: false // Read-only display
},
{
name: 'product_sku',
label: 'SKU',
type: 'text',
required: false
},
{
name: 'quantity',
label: 'Cantidad Requerida',
type: 'number',
required: true
},
{
name: 'unit_of_measure',
label: 'Unidad',
type: 'text',
required: false
},
{
name: 'unit_price',
label: 'Precio Est. (€)',
type: 'currency',
required: true
}
],
addButtonLabel: 'Agregar Ingrediente',
emptyStateText: 'No hay ingredientes requeridos',
showSubtotals: true,
subtotalFields: { quantity: 'quantity', price: 'unit_price' }
},
helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos'
} : {
label: selectedSupplier ? 'Productos a Comprar' : 'Selecciona un proveedor primero',
name: 'manual_products',
type: 'list' as const,
span: 2,
defaultValue: [],
listConfig: {
itemFields: [
{
name: 'ingredient_id',
label: 'Ingrediente',
type: 'select',
required: true,
options: ingredientOptions,
placeholder: isLoadingSupplierProducts || isLoadingIngredients ? 'Cargando ingredientes...' : ingredientOptions.length === 0 ? 'No hay ingredientes disponibles para este proveedor' : 'Seleccionar ingrediente...',
disabled: !selectedSupplier || isLoadingIngredients || isLoadingSupplierProducts
},
{
name: 'quantity',
label: 'Cantidad',
type: 'number',
required: true,
defaultValue: 1
},
{
name: 'unit_of_measure',
label: 'Unidad',
type: 'select',
required: true,
defaultValue: 'kg',
options: unitOptions
},
{
name: 'unit_price',
label: 'Precio Unitario (€)',
type: 'currency',
required: true,
defaultValue: 0,
placeholder: '0.00'
}
],
addButtonLabel: 'Agregar Ingrediente',
emptyStateText: !selectedSupplier
? 'Selecciona un proveedor para agregar ingredientes'
: isLoadingSupplierProducts || isLoadingIngredients
? 'Cargando ingredientes del proveedor...'
: ingredientOptions.length === 0
? 'Este proveedor no tiene ingredientes asignados en su lista de precios'
: 'No hay ingredientes agregados',
showSubtotals: true,
subtotalFields: { quantity: 'quantity', price: 'unit_price' },
disabled: !selectedSupplier
},
helpText: !selectedSupplier
? 'Primero selecciona un proveedor en la sección anterior'
: 'Selecciona ingredientes disponibles del proveedor seleccionado'
}
]
};
return [supplierSection, orderDetailsSection, ingredientsSection];
}, [requirements, supplierOptions, ingredientOptions, selectedSupplier, isLoadingIngredients, unitOptions]);
return (
<>
<AddModal
isOpen={isOpen}
onClose={onClose}
title="Crear Orden de Compra"
subtitle={requirements && requirements.length > 0
? "Generar orden de compra desde requerimientos de procuración"
: "Crear orden de compra manual"}
statusIndicator={statusConfig}
sections={sections}
size="xl"
loading={loading}
onSave={handleSave}
/>
</>
);
};
export default CreatePurchaseOrderModal;