732 lines
31 KiB
TypeScript
732 lines
31 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo } from 'react';
|
|||
|
|
import { useTranslation } from 'react-i18next';
|
|||
|
|
import { WizardStep, WizardStepProps } from '../../../ui/WizardModal/WizardModal';
|
|||
|
|
import { AdvancedOptionsSection } from '../../../ui/AdvancedOptionsSection';
|
|||
|
|
import {
|
|||
|
|
Building2,
|
|||
|
|
Package,
|
|||
|
|
Calendar,
|
|||
|
|
CheckCircle2,
|
|||
|
|
Plus,
|
|||
|
|
Trash2,
|
|||
|
|
Search,
|
|||
|
|
Loader2,
|
|||
|
|
AlertCircle,
|
|||
|
|
TrendingUp,
|
|||
|
|
} from 'lucide-react';
|
|||
|
|
import { useTenant } from '../../../../stores/tenant.store';
|
|||
|
|
import { useSuppliers } from '../../../../api/hooks/suppliers';
|
|||
|
|
import { useIngredients } from '../../../../api/hooks/inventory';
|
|||
|
|
import { suppliersService } from '../../../../api/services/suppliers';
|
|||
|
|
import { useCreatePurchaseOrder } from '../../../../api/hooks/purchase-orders';
|
|||
|
|
|
|||
|
|
// Step 1: Supplier Selection
|
|||
|
|
const SupplierSelectionStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
|||
|
|
const data = dataRef?.current || {};
|
|||
|
|
const { t } = useTranslation(['wizards', 'procurement']);
|
|||
|
|
const { currentTenant } = useTenant();
|
|||
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|||
|
|
const [selectedSupplier, setSelectedSupplier] = useState(data.supplier || null);
|
|||
|
|
|
|||
|
|
// Fetch suppliers
|
|||
|
|
const { data: suppliersData, isLoading, isError } = useSuppliers(
|
|||
|
|
currentTenant?.id || '',
|
|||
|
|
{ limit: 100 },
|
|||
|
|
{ enabled: !!currentTenant?.id }
|
|||
|
|
);
|
|||
|
|
const suppliers = (suppliersData || []).filter((s: any) => s.status === 'active');
|
|||
|
|
|
|||
|
|
const filteredSuppliers = suppliers.filter((supplier: any) =>
|
|||
|
|
supplier.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|||
|
|
supplier.supplier_code?.toLowerCase().includes(searchQuery.toLowerCase())
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const handleSelectSupplier = (supplier: any) => {
|
|||
|
|
setSelectedSupplier(supplier);
|
|||
|
|
onDataChange?.({
|
|||
|
|
...data,
|
|||
|
|
supplier,
|
|||
|
|
supplier_id: supplier.id,
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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">
|
|||
|
|
Seleccionar Proveedor
|
|||
|
|
</h3>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
Elige el proveedor para esta orden de compra
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isError && (
|
|||
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
|||
|
|
Error al cargar proveedores
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{isLoading ? (
|
|||
|
|
<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 proveedores...</span>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
{/* Search Bar */}
|
|||
|
|
<div className="relative">
|
|||
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
|
|||
|
|
<input
|
|||
|
|
type="text"
|
|||
|
|
value={searchQuery}
|
|||
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|||
|
|
placeholder="Buscar proveedor por nombre o código..."
|
|||
|
|
className="w-full pl-10 pr-4 py-3 border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Supplier List */}
|
|||
|
|
<div className="space-y-3 max-h-96 overflow-y-auto pr-2">
|
|||
|
|
{filteredSuppliers.length === 0 ? (
|
|||
|
|
<div className="text-center py-8 border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
|||
|
|
<Building2 className="w-12 h-12 mx-auto mb-3 text-[var(--text-tertiary)]" />
|
|||
|
|
<p className="text-[var(--text-secondary)] mb-1">No se encontraron proveedores</p>
|
|||
|
|
<p className="text-sm text-[var(--text-tertiary)]">Intenta con una búsqueda diferente</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
filteredSuppliers.map((supplier: any) => (
|
|||
|
|
<button
|
|||
|
|
key={supplier.id}
|
|||
|
|
onClick={() => handleSelectSupplier(supplier)}
|
|||
|
|
className={`w-full p-4 rounded-xl border-2 transition-all text-left group hover:shadow-md ${
|
|||
|
|
selectedSupplier?.id === supplier.id
|
|||
|
|
? 'border-[var(--color-primary)] bg-gradient-to-r from-[var(--color-primary)]/10 to-[var(--color-primary)]/5 shadow-sm'
|
|||
|
|
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50 hover:bg-[var(--bg-secondary)]/30'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<div
|
|||
|
|
className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 transition-colors ${
|
|||
|
|
selectedSupplier?.id === supplier.id
|
|||
|
|
? 'bg-[var(--color-primary)] text-white'
|
|||
|
|
: 'bg-[var(--bg-tertiary)] text-[var(--text-tertiary)] group-hover:bg-[var(--color-primary)]/20 group-hover:text-[var(--color-primary)]'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
<Building2 className="w-6 h-6" />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex items-center gap-2 mb-1">
|
|||
|
|
<h4
|
|||
|
|
className={`font-semibold truncate transition-colors ${
|
|||
|
|
selectedSupplier?.id === supplier.id
|
|||
|
|
? 'text-[var(--color-primary)]'
|
|||
|
|
: 'text-[var(--text-primary)]'
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{supplier.name}
|
|||
|
|
</h4>
|
|||
|
|
{selectedSupplier?.id === supplier.id && (
|
|||
|
|
<CheckCircle2 className="w-5 h-5 text-[var(--color-primary)] flex-shrink-0" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap items-center gap-2 text-sm text-[var(--text-secondary)]">
|
|||
|
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
|||
|
|
{supplier.supplier_code}
|
|||
|
|
</span>
|
|||
|
|
{supplier.email && <span>📧 {supplier.email}</span>}
|
|||
|
|
{supplier.phone && <span>📱 {supplier.phone}</span>}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</button>
|
|||
|
|
))
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Step 2: Add Items
|
|||
|
|
const AddItemsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
|||
|
|
const data = dataRef?.current || {};
|
|||
|
|
const { t } = useTranslation(['wizards', 'procurement']);
|
|||
|
|
const { currentTenant } = useTenant();
|
|||
|
|
const [supplierProductIds, setSupplierProductIds] = useState<string[]>([]);
|
|||
|
|
const [isLoadingSupplierProducts, setIsLoadingSupplierProducts] = useState(false);
|
|||
|
|
|
|||
|
|
// Fetch ALL ingredients
|
|||
|
|
const { data: allIngredientsData = [], isLoading: isLoadingIngredients } = useIngredients(
|
|||
|
|
currentTenant?.id || '',
|
|||
|
|
{},
|
|||
|
|
{ enabled: !!currentTenant?.id }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Fetch supplier products when supplier is available
|
|||
|
|
useEffect(() => {
|
|||
|
|
const fetchSupplierProducts = async () => {
|
|||
|
|
if (!data.supplier_id || !currentTenant?.id) {
|
|||
|
|
setSupplierProductIds([]);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
setIsLoadingSupplierProducts(true);
|
|||
|
|
try {
|
|||
|
|
const products = await suppliersService.getSupplierProducts(currentTenant.id, data.supplier_id);
|
|||
|
|
const productIds = products.map((p: any) => p.inventory_product_id);
|
|||
|
|
setSupplierProductIds(productIds);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Error fetching supplier products:', error);
|
|||
|
|
setSupplierProductIds([]);
|
|||
|
|
} finally {
|
|||
|
|
setIsLoadingSupplierProducts(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
fetchSupplierProducts();
|
|||
|
|
}, [data.supplier_id, currentTenant?.id]);
|
|||
|
|
|
|||
|
|
// Filter ingredients based on supplier products
|
|||
|
|
const ingredientsData = useMemo(() => {
|
|||
|
|
if (!data.supplier_id || supplierProductIds.length === 0) {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
return allIngredientsData.filter((ing: any) => supplierProductIds.includes(ing.id));
|
|||
|
|
}, [allIngredientsData, supplierProductIds, data.supplier_id]);
|
|||
|
|
|
|||
|
|
const handleAddItem = () => {
|
|||
|
|
onDataChange?.({
|
|||
|
|
...data,
|
|||
|
|
items: [
|
|||
|
|
...(data.items || []),
|
|||
|
|
{
|
|||
|
|
id: Date.now(),
|
|||
|
|
inventory_product_id: '',
|
|||
|
|
product_name: '',
|
|||
|
|
ordered_quantity: 1,
|
|||
|
|
unit_price: 0,
|
|||
|
|
unit_of_measure: 'kg',
|
|||
|
|
subtotal: 0,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleUpdateItem = (index: number, field: string, value: any) => {
|
|||
|
|
const updated = (data.items || []).map((item: any, i: number) => {
|
|||
|
|
if (i === index) {
|
|||
|
|
const newItem = { ...item, [field]: value };
|
|||
|
|
|
|||
|
|
if (field === 'inventory_product_id') {
|
|||
|
|
const product = ingredientsData.find((p: any) => p.id === value);
|
|||
|
|
if (product) {
|
|||
|
|
newItem.product_name = product.name;
|
|||
|
|
newItem.unit_price = product.last_purchase_price || product.average_cost || 0;
|
|||
|
|
newItem.unit_of_measure = product.unit_of_measure;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (field === 'ordered_quantity' || field === 'unit_price' || field === 'inventory_product_id') {
|
|||
|
|
newItem.subtotal = (newItem.ordered_quantity || 0) * (newItem.unit_price || 0);
|
|||
|
|
}
|
|||
|
|
return newItem;
|
|||
|
|
}
|
|||
|
|
return item;
|
|||
|
|
});
|
|||
|
|
onDataChange?.({ ...data, items: updated });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleRemoveItem = (index: number) => {
|
|||
|
|
onDataChange?.({ ...data, items: (data.items || []).filter((_: any, i: number) => i !== index) });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const calculateTotal = () => {
|
|||
|
|
return (data.items || []).reduce((sum: number, item: any) => sum + (item.subtotal || 0), 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
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 a Comprar</h3>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
Proveedor: <span className="font-semibold">{data.supplier?.name || 'N/A'}</span>
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isLoadingIngredients || isLoadingSupplierProducts ? (
|
|||
|
|
<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 productos...</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)]">
|
|||
|
|
Productos en la orden
|
|||
|
|
</label>
|
|||
|
|
<button
|
|||
|
|
onClick={handleAddItem}
|
|||
|
|
disabled={ingredientsData.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 flex items-center gap-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
|||
|
|
>
|
|||
|
|
<Plus className="w-4 h-4" />
|
|||
|
|
Agregar Producto
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{ingredientsData.length === 0 && (
|
|||
|
|
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm flex items-center gap-2">
|
|||
|
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
|||
|
|
<span>
|
|||
|
|
Este proveedor no tiene ingredientes asignados. Configura la lista de precios del proveedor primero.
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{(data.items || []).length === 0 ? (
|
|||
|
|
<div className="text-center py-12 border-2 border-dashed border-[var(--border-secondary)] rounded-lg text-[var(--text-tertiary)]">
|
|||
|
|
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
|||
|
|
<p className="mb-2">No hay productos en la orden</p>
|
|||
|
|
<p className="text-sm">Haz clic en "Agregar Producto" para comenzar</p>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{(data.items || []).map((item: any, index: number) => (
|
|||
|
|
<div
|
|||
|
|
key={item.id}
|
|||
|
|
className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/30 space-y-3"
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="text-sm font-semibold text-[var(--text-primary)]">
|
|||
|
|
Producto #{index + 1}
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
onClick={() => handleRemoveItem(index)}
|
|||
|
|
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
|||
|
|
>
|
|||
|
|
<Trash2 className="w-4 h-4" />
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|||
|
|
<div className="md:col-span-2">
|
|||
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|||
|
|
Ingrediente *
|
|||
|
|
</label>
|
|||
|
|
<select
|
|||
|
|
value={item.inventory_product_id}
|
|||
|
|
onChange={(e) => handleUpdateItem(index, 'inventory_product_id', e.target.value)}
|
|||
|
|
className="w-full px-3 py-2 text-sm border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
>
|
|||
|
|
<option value="">Seleccionar ingrediente...</option>
|
|||
|
|
{ingredientsData.map((product: any) => (
|
|||
|
|
<option key={product.id} value={product.id}>
|
|||
|
|
{product.name} - €{(product.last_purchase_price || product.average_cost || 0).toFixed(2)} /{' '}
|
|||
|
|
{product.unit_of_measure}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|||
|
|
Cantidad *
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={item.ordered_quantity}
|
|||
|
|
onChange={(e) =>
|
|||
|
|
handleUpdateItem(index, 'ordered_quantity', parseFloat(e.target.value) || 0)
|
|||
|
|
}
|
|||
|
|
className="w-full px-3 py-2 text-sm border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad *</label>
|
|||
|
|
<select
|
|||
|
|
value={item.unit_of_measure}
|
|||
|
|
onChange={(e) => handleUpdateItem(index, 'unit_of_measure', e.target.value)}
|
|||
|
|
className="w-full px-3 py-2 text-sm border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
>
|
|||
|
|
{unitOptions.map((opt) => (
|
|||
|
|
<option key={opt.value} value={opt.value}>
|
|||
|
|
{opt.label}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="md:col-span-2">
|
|||
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|||
|
|
Precio Unitario (€) *
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={item.unit_price}
|
|||
|
|
onChange={(e) => handleUpdateItem(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
|||
|
|
className="w-full px-3 py-2 text-sm border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="pt-2 border-t border-[var(--border-primary)] text-sm">
|
|||
|
|
<span className="font-semibold text-[var(--text-primary)]">
|
|||
|
|
Subtotal: €{item.subtotal.toFixed(2)}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{(data.items || []).length > 0 && (
|
|||
|
|
<div className="p-4 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 rounded-lg border-2 border-[var(--color-primary)]/20">
|
|||
|
|
<div className="flex justify-between items-center">
|
|||
|
|
<span className="text-lg font-semibold text-[var(--text-primary)]">Total:</span>
|
|||
|
|
<span className="text-2xl font-bold text-[var(--color-primary)]">€{calculateTotal().toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Step 3: Order Details
|
|||
|
|
const OrderDetailsStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
|||
|
|
const data = dataRef?.current || {};
|
|||
|
|
const { t } = useTranslation(['wizards', 'procurement']);
|
|||
|
|
|
|||
|
|
const getValue = (field: string, defaultValue: any = '') => {
|
|||
|
|
return data[field] ?? defaultValue;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleFieldChange = (updates: Record<string, any>) => {
|
|||
|
|
onDataChange?.({ ...data, ...updates });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const priorityOptions = [
|
|||
|
|
{ value: 'low', label: 'Baja' },
|
|||
|
|
{ value: 'normal', label: 'Normal' },
|
|||
|
|
{ value: 'high', label: 'Alta' },
|
|||
|
|
{ value: 'critical', label: 'Crítica' },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
|||
|
|
<Calendar 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">Detalles de la Orden</h3>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">Configura fecha de entrega y prioridad</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|||
|
|
Fecha de Entrega Requerida *
|
|||
|
|
</label>
|
|||
|
|
<input
|
|||
|
|
type="date"
|
|||
|
|
value={getValue('required_delivery_date')}
|
|||
|
|
onChange={(e) => handleFieldChange({ required_delivery_date: e.target.value })}
|
|||
|
|
min={new Date().toISOString().split('T')[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)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Prioridad *</label>
|
|||
|
|
<select
|
|||
|
|
value={getValue('priority', 'normal')}
|
|||
|
|
onChange={(e) => handleFieldChange({ priority: 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)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
>
|
|||
|
|
{priorityOptions.map((opt) => (
|
|||
|
|
<option key={opt.value} value={opt.value}>
|
|||
|
|
{opt.label}
|
|||
|
|
</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="md:col-span-2">
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
|||
|
|
Notas (Opcional)
|
|||
|
|
</label>
|
|||
|
|
<textarea
|
|||
|
|
value={getValue('notes')}
|
|||
|
|
onChange={(e) => handleFieldChange({ notes: e.target.value })}
|
|||
|
|
placeholder="Instrucciones especiales para el proveedor..."
|
|||
|
|
rows={4}
|
|||
|
|
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)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<AdvancedOptionsSection
|
|||
|
|
title="Opciones Avanzadas"
|
|||
|
|
description="Información financiera adicional"
|
|||
|
|
>
|
|||
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Impuestos (€)</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={getValue('tax_amount', 0)}
|
|||
|
|
onChange={(e) => handleFieldChange({ tax_amount: parseFloat(e.target.value) || 0 })}
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
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)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Costo de Envío (€)</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={getValue('shipping_cost', 0)}
|
|||
|
|
onChange={(e) => handleFieldChange({ shipping_cost: parseFloat(e.target.value) || 0 })}
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
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)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Descuento (€)</label>
|
|||
|
|
<input
|
|||
|
|
type="number"
|
|||
|
|
value={getValue('discount_amount', 0)}
|
|||
|
|
onChange={(e) => handleFieldChange({ discount_amount: parseFloat(e.target.value) || 0 })}
|
|||
|
|
min="0"
|
|||
|
|
step="0.01"
|
|||
|
|
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)] bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</AdvancedOptionsSection>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Step 4: Review & Submit
|
|||
|
|
const ReviewSubmitStep: React.FC<WizardStepProps> = ({ dataRef, onDataChange }) => {
|
|||
|
|
const data = dataRef?.current || {};
|
|||
|
|
const { t } = useTranslation(['wizards', 'procurement']);
|
|||
|
|
|
|||
|
|
const calculateSubtotal = () => {
|
|||
|
|
return (data.items || []).reduce((sum: number, item: any) => sum + (item.subtotal || 0), 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const calculateTotal = () => {
|
|||
|
|
const subtotal = calculateSubtotal();
|
|||
|
|
const tax = data.tax_amount || 0;
|
|||
|
|
const shipping = data.shipping_cost || 0;
|
|||
|
|
const discount = data.discount_amount || 0;
|
|||
|
|
return subtotal + tax + shipping - discount;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-6">
|
|||
|
|
<div className="text-center pb-4 border-b border-[var(--border-primary)]">
|
|||
|
|
<CheckCircle2 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">Revisar y Confirmar</h3>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">Verifica los detalles antes de crear la orden</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Supplier Info */}
|
|||
|
|
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
|
|||
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
|||
|
|
<Building2 className="w-5 h-5" />
|
|||
|
|
Información del Proveedor
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-2 text-sm">
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Proveedor:</span>
|
|||
|
|
<span className="font-medium">{data.supplier?.name || 'N/A'}</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Código:</span>
|
|||
|
|
<span className="font-medium">{data.supplier?.supplier_code || 'N/A'}</span>
|
|||
|
|
</div>
|
|||
|
|
{data.supplier?.email && (
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Email:</span>
|
|||
|
|
<span className="font-medium">{data.supplier.email}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Order Details */}
|
|||
|
|
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
|
|||
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
|||
|
|
<Calendar className="w-5 h-5" />
|
|||
|
|
Detalles de la Orden
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-2 text-sm">
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Fecha de Entrega:</span>
|
|||
|
|
<span className="font-medium">
|
|||
|
|
{data.required_delivery_date
|
|||
|
|
? new Date(data.required_delivery_date).toLocaleDateString('es-ES')
|
|||
|
|
: 'No especificada'}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Prioridad:</span>
|
|||
|
|
<span className="font-medium capitalize">{data.priority || 'normal'}</span>
|
|||
|
|
</div>
|
|||
|
|
{data.notes && (
|
|||
|
|
<div className="pt-2 border-t border-[var(--border-primary)]">
|
|||
|
|
<span className="text-[var(--text-secondary)] block mb-1">Notas:</span>
|
|||
|
|
<span className="font-medium">{data.notes}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Items */}
|
|||
|
|
<div className="p-4 bg-[var(--bg-secondary)]/50 rounded-lg border border-[var(--border-secondary)]">
|
|||
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
|||
|
|
<Package className="w-5 h-5" />
|
|||
|
|
Productos ({(data.items || []).length})
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-2">
|
|||
|
|
{(data.items || []).map((item: any, index: number) => (
|
|||
|
|
<div
|
|||
|
|
key={item.id}
|
|||
|
|
className="flex justify-between items-start p-3 bg-[var(--bg-primary)] rounded-lg border border-[var(--border-secondary)]"
|
|||
|
|
>
|
|||
|
|
<div className="flex-1">
|
|||
|
|
<p className="font-medium text-[var(--text-primary)]">{item.product_name || 'Producto sin nombre'}</p>
|
|||
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|||
|
|
{item.ordered_quantity} {item.unit_of_measure} × €{item.unit_price.toFixed(2)}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right">
|
|||
|
|
<p className="font-semibold text-[var(--text-primary)]">€{item.subtotal.toFixed(2)}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Financial Summary */}
|
|||
|
|
<div className="p-4 bg-gradient-to-r from-[var(--color-primary)]/5 to-[var(--color-primary)]/10 rounded-lg border-2 border-[var(--color-primary)]/20">
|
|||
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
|||
|
|
<TrendingUp className="w-5 h-5" />
|
|||
|
|
Resumen Financiero
|
|||
|
|
</h4>
|
|||
|
|
<div className="space-y-2 text-sm">
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Subtotal:</span>
|
|||
|
|
<span className="font-medium">€{calculateSubtotal().toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
{(data.tax_amount || 0) > 0 && (
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Impuestos:</span>
|
|||
|
|
<span className="font-medium">€{(data.tax_amount || 0).toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{(data.shipping_cost || 0) > 0 && (
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Costo de Envío:</span>
|
|||
|
|
<span className="font-medium">€{(data.shipping_cost || 0).toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{(data.discount_amount || 0) > 0 && (
|
|||
|
|
<div className="flex justify-between">
|
|||
|
|
<span className="text-[var(--text-secondary)]">Descuento:</span>
|
|||
|
|
<span className="font-medium text-green-600">-€{(data.discount_amount || 0).toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
<div className="pt-2 border-t-2 border-[var(--color-primary)]/30 flex justify-between">
|
|||
|
|
<span className="text-lg font-semibold text-[var(--text-primary)]">Total:</span>
|
|||
|
|
<span className="text-2xl font-bold text-[var(--color-primary)]">€{calculateTotal().toFixed(2)}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const PurchaseOrderWizardSteps = (
|
|||
|
|
dataRef: React.MutableRefObject<Record<string, any>>,
|
|||
|
|
setData: (data: Record<string, any>) => void
|
|||
|
|
): WizardStep[] => {
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
id: 'supplier-selection',
|
|||
|
|
title: 'Seleccionar Proveedor',
|
|||
|
|
component: SupplierSelectionStep,
|
|||
|
|
validate: () => {
|
|||
|
|
const data = dataRef.current;
|
|||
|
|
if (!data.supplier_id) {
|
|||
|
|
return 'Debes seleccionar un proveedor';
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'add-items',
|
|||
|
|
title: 'Agregar Productos',
|
|||
|
|
component: AddItemsStep,
|
|||
|
|
validate: () => {
|
|||
|
|
const data = dataRef.current;
|
|||
|
|
if (!data.items || data.items.length === 0) {
|
|||
|
|
return 'Debes agregar al menos un producto';
|
|||
|
|
}
|
|||
|
|
const invalidItems = data.items.some(
|
|||
|
|
(item: any) => !item.inventory_product_id || item.ordered_quantity <= 0 || item.unit_price <= 0
|
|||
|
|
);
|
|||
|
|
if (invalidItems) {
|
|||
|
|
return 'Todos los productos deben tener ingrediente, cantidad y precio válidos';
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'order-details',
|
|||
|
|
title: 'Detalles de la Orden',
|
|||
|
|
component: OrderDetailsStep,
|
|||
|
|
validate: () => {
|
|||
|
|
const data = dataRef.current;
|
|||
|
|
if (!data.required_delivery_date) {
|
|||
|
|
return 'Debes especificar una fecha de entrega';
|
|||
|
|
}
|
|||
|
|
return true;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'review-submit',
|
|||
|
|
title: 'Revisar y Confirmar',
|
|||
|
|
component: ReviewSubmitStep,
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
};
|