Supplier Wizard Improvements: - Added 'Días de Entrega' (Lead Time Days) field - CRITICAL field - Field shows as required with asterisk and helper text - Validates that lead time is provided before allowing continue - Made 'Términos de Pago' optional (not critical info) - Added empty option 'Seleccionar...' to payment terms dropdown - Updated API call to include lead_time_days parameter - Payment terms now sends undefined if not selected - Lead time days properly parsed as integer before sending to API These changes ensure critical logistics information is captured while making optional business terms more flexible.
424 lines
17 KiB
TypeScript
424 lines
17 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
|
|
import { Building2, Package, Euro, CheckCircle2, Phone, Mail, Loader2, AlertCircle } from 'lucide-react';
|
|
import { useTenant } from '../../../../stores/tenant.store';
|
|
import { suppliersService } from '../../../../api/services/suppliers';
|
|
import { inventoryService } from '../../../../api/services/inventory';
|
|
|
|
interface WizardDataProps extends WizardStepProps {
|
|
data: Record<string, any>;
|
|
onDataChange: (data: Record<string, any>) => void;
|
|
}
|
|
|
|
// Step 1: Supplier Information
|
|
const SupplierInfoStep: React.FC<WizardDataProps> = ({ data, onDataChange, onNext }) => {
|
|
const [supplierData, setSupplierData] = useState({
|
|
name: data.name || '',
|
|
contactPerson: data.contactPerson || '',
|
|
phone: data.phone || '',
|
|
email: data.email || '',
|
|
address: data.address || '',
|
|
paymentTerms: data.paymentTerms || '',
|
|
leadTimeDays: data.leadTimeDays || '',
|
|
notes: data.notes || '',
|
|
});
|
|
|
|
const handleContinue = () => {
|
|
onDataChange({ ...data, ...supplierData });
|
|
onNext();
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
|
<Building2 className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
|
Información del Proveedor
|
|
</h3>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
Nombre del Proveedor *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={supplierData.name}
|
|
onChange={(e) => setSupplierData({ ...supplierData, name: e.target.value })}
|
|
placeholder="Ej: Harinas Premium S.L."
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
Persona de Contacto
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={supplierData.contactPerson}
|
|
onChange={(e) => setSupplierData({ ...supplierData, contactPerson: e.target.value })}
|
|
placeholder="Nombre del contacto"
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
<Phone className="w-3.5 h-3.5 inline mr-1" />
|
|
Teléfono *
|
|
</label>
|
|
<input
|
|
type="tel"
|
|
value={supplierData.phone}
|
|
onChange={(e) => setSupplierData({ ...supplierData, phone: e.target.value })}
|
|
placeholder="+34 123 456 789"
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
<Mail className="w-3.5 h-3.5 inline mr-1" />
|
|
Email
|
|
</label>
|
|
<input
|
|
type="email"
|
|
value={supplierData.email}
|
|
onChange={(e) => setSupplierData({ ...supplierData, email: e.target.value })}
|
|
placeholder="contacto@proveedor.com"
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
Dirección
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={supplierData.address}
|
|
onChange={(e) => setSupplierData({ ...supplierData, address: e.target.value })}
|
|
placeholder="Calle, Ciudad, País"
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
Días de Entrega *
|
|
<span className="ml-1 text-xs text-[var(--text-tertiary)]">(Tiempo de lead time)</span>
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={supplierData.leadTimeDays}
|
|
onChange={(e) => setSupplierData({ ...supplierData, leadTimeDays: e.target.value })}
|
|
placeholder="Ej: 7"
|
|
min="0"
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
Términos de Pago (Opcional)
|
|
</label>
|
|
<select
|
|
value={supplierData.paymentTerms}
|
|
onChange={(e) => setSupplierData({ ...supplierData, paymentTerms: e.target.value })}
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
>
|
|
<option value="">Seleccionar...</option>
|
|
<option value="immediate">Inmediato</option>
|
|
<option value="net30">Neto 30 días</option>
|
|
<option value="net60">Neto 60 días</option>
|
|
<option value="net90">Neto 90 días</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|
Notas
|
|
</label>
|
|
<textarea
|
|
value={supplierData.notes}
|
|
onChange={(e) => setSupplierData({ ...supplierData, notes: e.target.value })}
|
|
placeholder="Información adicional sobre el proveedor..."
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end pt-4 border-t border-[var(--border-primary)]">
|
|
<button
|
|
onClick={handleContinue}
|
|
disabled={!supplierData.name || !supplierData.phone || !supplierData.leadTimeDays}
|
|
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary)]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
Continuar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Step 2: Products & Pricing
|
|
const ProductsPricingStep: React.FC<WizardDataProps> = ({ data, onDataChange, onComplete }) => {
|
|
const { currentTenant } = useTenant();
|
|
const [products, setProducts] = useState(data.products || []);
|
|
const [ingredients, setIngredients] = useState<any[]>([]);
|
|
const [loadingIngredients, setLoadingIngredients] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchIngredients();
|
|
}, []);
|
|
|
|
const fetchIngredients = async () => {
|
|
if (!currentTenant?.id) return;
|
|
|
|
setLoadingIngredients(true);
|
|
try {
|
|
const result = await inventoryService.getIngredients(currentTenant.id);
|
|
setIngredients(result);
|
|
} catch (err: any) {
|
|
console.error('Error fetching ingredients:', err);
|
|
setError('Error al cargar los ingredientes');
|
|
} finally {
|
|
setLoadingIngredients(false);
|
|
}
|
|
};
|
|
|
|
const handleAddProduct = () => {
|
|
setProducts([
|
|
...products,
|
|
{ id: Date.now(), ingredientId: '', price: 0, minimumOrder: 1 },
|
|
]);
|
|
};
|
|
|
|
const handleUpdateProduct = (index: number, field: string, value: any) => {
|
|
const updated = products.map((item: any, i: number) => {
|
|
if (i === index) {
|
|
return { ...item, [field]: value };
|
|
}
|
|
return item;
|
|
});
|
|
setProducts(updated);
|
|
};
|
|
|
|
const handleRemoveProduct = (index: number) => {
|
|
setProducts(products.filter((_: any, i: number) => i !== index));
|
|
};
|
|
|
|
const handleConfirm = async () => {
|
|
if (!currentTenant?.id) {
|
|
setError('No se pudo obtener información del tenant');
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// Create the supplier
|
|
const supplierData = {
|
|
name: data.name,
|
|
supplier_type: 'ingredients',
|
|
contact_person: data.contactPerson || undefined,
|
|
email: data.email || undefined,
|
|
phone: data.phone,
|
|
address: data.address || undefined,
|
|
payment_terms: data.paymentTerms || undefined,
|
|
lead_time_days: data.leadTimeDays ? parseInt(data.leadTimeDays) : undefined,
|
|
tax_id: undefined,
|
|
notes: data.notes || undefined,
|
|
status: 'active',
|
|
};
|
|
|
|
const createdSupplier = await suppliersService.createSupplier(currentTenant.id, supplierData);
|
|
|
|
// Create price list for the products if any
|
|
if (products.length > 0 && createdSupplier.id) {
|
|
const priceListItems = products.map((product: any) => ({
|
|
inventory_product_id: product.ingredientId,
|
|
unit_price: product.price,
|
|
minimum_order_quantity: product.minimumOrder,
|
|
is_active: true,
|
|
}));
|
|
|
|
await suppliersService.createSupplierPriceList(currentTenant.id, createdSupplier.id, {
|
|
name: `Lista de Precios - ${data.name}`,
|
|
effective_date: new Date().toISOString().split('T')[0],
|
|
currency: 'EUR',
|
|
is_active: true,
|
|
items: priceListItems,
|
|
});
|
|
}
|
|
|
|
onDataChange({ ...data, products });
|
|
onComplete();
|
|
} catch (err: any) {
|
|
console.error('Error saving supplier:', err);
|
|
setError(err.response?.data?.detail || 'Error al guardar el proveedor');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (loadingIngredients) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="w-8 h-8 animate-spin text-[var(--color-primary)]" />
|
|
<span className="ml-3 text-[var(--text-secondary)]">Cargando ingredientes...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
|
<Package className="w-12 h-12 mx-auto mb-3 text-[var(--color-primary)]" />
|
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
|
Productos y Precios
|
|
</h3>
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
{data.name}
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm flex items-start gap-2">
|
|
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)]">
|
|
Ingredientes que Suministra
|
|
</label>
|
|
<button
|
|
onClick={handleAddProduct}
|
|
disabled={ingredients.length === 0}
|
|
className="px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
+ Agregar Producto
|
|
</button>
|
|
</div>
|
|
|
|
{ingredients.length === 0 ? (
|
|
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
|
<p className="text-[var(--text-tertiary)]">
|
|
No hay ingredientes disponibles. Crea ingredientes primero en la sección de Inventario.
|
|
</p>
|
|
</div>
|
|
) : products.length === 0 ? (
|
|
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
|
<p className="text-[var(--text-tertiary)]">No hay productos agregados</p>
|
|
<p className="text-xs text-[var(--text-tertiary)] mt-1">Opcional - puedes agregar productos más tarde</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{products.map((product: any, index: number) => (
|
|
<div
|
|
key={product.id}
|
|
className="p-3 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/30"
|
|
>
|
|
<div className="grid grid-cols-12 gap-2 items-center">
|
|
<div className="col-span-12 md:col-span-5">
|
|
<select
|
|
value={product.ingredientId}
|
|
onChange={(e) => handleUpdateProduct(index, 'ingredientId', e.target.value)}
|
|
className="w-full px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
|
>
|
|
<option value="">Seleccionar ingrediente...</option>
|
|
{ingredients.map((ing) => (
|
|
<option key={ing.id} value={ing.id}>
|
|
{ing.name} ({ing.unit})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-span-5 md:col-span-3">
|
|
<input
|
|
type="number"
|
|
value={product.price}
|
|
onChange={(e) => handleUpdateProduct(index, 'price', parseFloat(e.target.value) || 0)}
|
|
placeholder="Precio/unidad"
|
|
className="w-full px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
|
min="0"
|
|
step="0.01"
|
|
/>
|
|
</div>
|
|
<div className="col-span-6 md:col-span-3">
|
|
<input
|
|
type="number"
|
|
value={product.minimumOrder}
|
|
onChange={(e) => handleUpdateProduct(index, 'minimumOrder', parseFloat(e.target.value) || 0)}
|
|
placeholder="Pedido mín."
|
|
className="w-full px-2 py-1.5 text-sm border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
|
min="1"
|
|
/>
|
|
</div>
|
|
<div className="col-span-1 flex justify-end">
|
|
<button
|
|
onClick={() => handleRemoveProduct(index)}
|
|
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-4 border-t border-[var(--border-primary)]">
|
|
<button
|
|
onClick={handleConfirm}
|
|
disabled={saving}
|
|
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold inline-flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 animate-spin" />
|
|
Guardando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle2 className="w-5 h-5" />
|
|
Crear Proveedor
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const SupplierWizardSteps = (
|
|
data: Record<string, any>,
|
|
setData: (data: Record<string, any>) => void
|
|
): WizardStep[] => [
|
|
{
|
|
id: 'supplier-info',
|
|
title: 'Información',
|
|
description: 'Datos del proveedor',
|
|
component: (props) => <SupplierInfoStep {...props} data={data} onDataChange={setData} />,
|
|
},
|
|
{
|
|
id: 'products-pricing',
|
|
title: 'Productos y Precios',
|
|
description: 'Lista de precios',
|
|
component: (props) => <ProductsPricingStep {...props} data={data} onDataChange={setData} />,
|
|
},
|
|
];
|