953 lines
31 KiB
TypeScript
953 lines
31 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package, Eye, X, Send, Building2, Play, FileText, Star, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
|
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig, EmptyState } from '../../../../components/ui';
|
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
|
import { PageHeader } from '../../../../components/layout';
|
|
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
|
|
import {
|
|
usePurchaseOrders,
|
|
usePurchaseOrder,
|
|
useApprovePurchaseOrder,
|
|
useRejectPurchaseOrder,
|
|
useUpdatePurchaseOrder
|
|
} from '../../../../api/hooks/purchase-orders';
|
|
import { useTriggerDailyScheduler } from '../../../../api';
|
|
import type { PurchaseOrderStatus, PurchaseOrderPriority, PurchaseOrderDetail } from '../../../../api/services/purchase_orders';
|
|
import { useTenantStore } from '../../../../stores/tenant.store';
|
|
import { useUserById } from '../../../../api/hooks/user';
|
|
import toast from 'react-hot-toast';
|
|
|
|
const ProcurementPage: React.FC = () => {
|
|
// State
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<PurchaseOrderStatus | ''>('');
|
|
const [priorityFilter, setPriorityFilter] = useState<PurchaseOrderPriority | ''>('');
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [showCreatePOModal, setShowCreatePOModal] = useState(false);
|
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
const [selectedPOId, setSelectedPOId] = useState<string | null>(null);
|
|
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
|
const [approvalAction, setApprovalAction] = useState<'approve' | 'reject'>('approve');
|
|
const [approvalNotes, setApprovalNotes] = useState('');
|
|
|
|
const { currentTenant } = useTenantStore();
|
|
const tenantId = currentTenant?.id || '';
|
|
|
|
// API Hooks
|
|
const { data: purchaseOrdersData = [], isLoading: isPOsLoading, refetch: refetchPOs } = usePurchaseOrders(
|
|
tenantId,
|
|
{
|
|
status: statusFilter || undefined,
|
|
priority: priorityFilter || undefined,
|
|
search_term: searchTerm || undefined,
|
|
limit: 100,
|
|
offset: 0
|
|
},
|
|
{
|
|
enabled: !!tenantId
|
|
}
|
|
);
|
|
|
|
const { data: poDetails, isLoading: isLoadingDetails } = usePurchaseOrder(
|
|
tenantId,
|
|
selectedPOId || '',
|
|
{
|
|
enabled: !!tenantId && !!selectedPOId && showDetailsModal
|
|
}
|
|
);
|
|
|
|
const approvePOMutation = useApprovePurchaseOrder();
|
|
const rejectPOMutation = useRejectPurchaseOrder();
|
|
const updatePOMutation = useUpdatePurchaseOrder();
|
|
const triggerSchedulerMutation = useTriggerDailyScheduler();
|
|
|
|
// Filter POs
|
|
const filteredPOs = useMemo(() => {
|
|
return purchaseOrdersData.filter(po => {
|
|
// Hide archived POs by default
|
|
if (!showArchived && (po.status === 'COMPLETED' || po.status === 'CANCELLED')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [purchaseOrdersData, showArchived]);
|
|
|
|
// Calculate stats
|
|
const poStats = useMemo(() => {
|
|
const total = filteredPOs.length;
|
|
// API returns lowercase status values (e.g., 'pending_approval' not 'PENDING_APPROVAL')
|
|
const pendingApproval = filteredPOs.filter(po => po.status === 'pending_approval').length;
|
|
const approved = filteredPOs.filter(po => po.status === 'approved').length;
|
|
const inProgress = filteredPOs.filter(po =>
|
|
['sent_to_supplier', 'confirmed'].includes(po.status)
|
|
).length;
|
|
const received = filteredPOs.filter(po => po.status === 'received').length;
|
|
const totalAmount = filteredPOs.reduce((sum, po) => {
|
|
// API returns total_amount as a string, so parse it
|
|
const amount = typeof po.total_amount === 'string'
|
|
? parseFloat(po.total_amount) || 0
|
|
: typeof po.total_amount === 'number'
|
|
? po.total_amount
|
|
: 0;
|
|
return sum + amount;
|
|
}, 0);
|
|
|
|
return {
|
|
total,
|
|
pendingApproval,
|
|
approved,
|
|
inProgress,
|
|
received,
|
|
totalAmount
|
|
};
|
|
}, [filteredPOs]);
|
|
|
|
// Handlers
|
|
const handleViewDetails = (po: any) => {
|
|
setSelectedPOId(po.id);
|
|
setShowDetailsModal(true);
|
|
};
|
|
|
|
const handleApprovePO = (po: any) => {
|
|
setSelectedPOId(po.id);
|
|
setApprovalAction('approve');
|
|
setApprovalNotes('');
|
|
setShowApprovalModal(true);
|
|
};
|
|
|
|
const handleRejectPO = (po: any) => {
|
|
setSelectedPOId(po.id);
|
|
setApprovalAction('reject');
|
|
setApprovalNotes('');
|
|
setShowApprovalModal(true);
|
|
};
|
|
|
|
const handleSendToSupplier = async (po: any) => {
|
|
try {
|
|
await updatePOMutation.mutateAsync({
|
|
tenantId,
|
|
poId: po.id,
|
|
data: { status: 'SENT_TO_SUPPLIER' }
|
|
});
|
|
toast.success('Orden enviada al proveedor');
|
|
refetchPOs();
|
|
} catch (error) {
|
|
console.error('Error sending PO to supplier:', error);
|
|
toast.error('Error al enviar orden al proveedor');
|
|
}
|
|
};
|
|
|
|
const handleConfirmPO = async (po: any) => {
|
|
try {
|
|
await updatePOMutation.mutateAsync({
|
|
tenantId,
|
|
poId: po.id,
|
|
data: { status: 'CONFIRMED' }
|
|
});
|
|
toast.success('Orden confirmada');
|
|
refetchPOs();
|
|
} catch (error) {
|
|
console.error('Error confirming PO:', error);
|
|
toast.error('Error al confirmar orden');
|
|
}
|
|
};
|
|
|
|
const handleApprovalSubmit = async () => {
|
|
if (!selectedPOId) return;
|
|
|
|
try {
|
|
if (approvalAction === 'approve') {
|
|
await approvePOMutation.mutateAsync({
|
|
tenantId,
|
|
poId: selectedPOId,
|
|
notes: approvalNotes || undefined
|
|
});
|
|
toast.success('Orden aprobada exitosamente');
|
|
} else {
|
|
if (!approvalNotes.trim()) {
|
|
toast.error('Debes proporcionar una razón para rechazar');
|
|
return;
|
|
}
|
|
await rejectPOMutation.mutateAsync({
|
|
tenantId,
|
|
poId: selectedPOId,
|
|
reason: approvalNotes
|
|
});
|
|
toast.success('Orden rechazada');
|
|
}
|
|
setShowApprovalModal(false);
|
|
setShowDetailsModal(false);
|
|
setApprovalNotes('');
|
|
refetchPOs();
|
|
} catch (error) {
|
|
console.error('Error in approval action:', error);
|
|
toast.error('Error al procesar aprobación');
|
|
}
|
|
};
|
|
|
|
const handleTriggerScheduler = async () => {
|
|
try {
|
|
await triggerSchedulerMutation.mutateAsync(tenantId);
|
|
toast.success('Scheduler ejecutado exitosamente');
|
|
refetchPOs();
|
|
} catch (error) {
|
|
console.error('Error triggering scheduler:', error);
|
|
toast.error('Error al ejecutar scheduler');
|
|
}
|
|
};
|
|
|
|
// Get PO status configuration
|
|
const getPOStatusConfig = (status: PurchaseOrderStatus | string) => {
|
|
// API returns lowercase status, normalize it
|
|
const normalizedStatus = status?.toUpperCase().replace(/_/g, '_') as PurchaseOrderStatus;
|
|
|
|
const configs: Record<string, any> = {
|
|
DRAFT: {
|
|
color: getStatusColor('neutral'),
|
|
text: 'Borrador',
|
|
icon: FileText,
|
|
isCritical: false
|
|
},
|
|
PENDING_APPROVAL: {
|
|
color: getStatusColor('warning'),
|
|
text: 'Pendiente de Aprobación',
|
|
icon: AlertCircle,
|
|
isCritical: true
|
|
},
|
|
APPROVED: {
|
|
color: getStatusColor('approved'),
|
|
text: 'Aprobado',
|
|
icon: CheckCircle,
|
|
isCritical: false
|
|
},
|
|
SENT_TO_SUPPLIER: {
|
|
color: getStatusColor('inProgress'),
|
|
text: 'Enviado al Proveedor',
|
|
icon: Send,
|
|
isCritical: false
|
|
},
|
|
CONFIRMED: {
|
|
color: getStatusColor('approved'),
|
|
text: 'Confirmado',
|
|
icon: CheckCircle,
|
|
isCritical: false
|
|
},
|
|
RECEIVED: {
|
|
color: getStatusColor('delivered'),
|
|
text: 'Recibido',
|
|
icon: Package,
|
|
isCritical: false
|
|
},
|
|
COMPLETED: {
|
|
color: getStatusColor('completed'),
|
|
text: 'Completado',
|
|
icon: CheckCircle,
|
|
isCritical: false
|
|
},
|
|
CANCELLED: {
|
|
color: getStatusColor('cancelled'),
|
|
text: 'Cancelado',
|
|
icon: X,
|
|
isCritical: false
|
|
},
|
|
DISPUTED: {
|
|
color: getStatusColor('expired'),
|
|
text: 'En Disputa',
|
|
icon: AlertCircle,
|
|
isCritical: true
|
|
}
|
|
};
|
|
|
|
// Return config or default if status is undefined/invalid
|
|
return configs[normalizedStatus] || {
|
|
color: getStatusColor('neutral'),
|
|
text: status || 'Desconocido',
|
|
icon: FileText,
|
|
isCritical: false
|
|
};
|
|
};
|
|
|
|
// Get quick actions for a PO based on status
|
|
const getPOQuickActions = (po: any) => {
|
|
const actions = [
|
|
{
|
|
label: 'Ver Detalles',
|
|
icon: Eye,
|
|
onClick: () => handleViewDetails(po),
|
|
priority: 'primary' as const
|
|
}
|
|
];
|
|
|
|
if (po.status === 'pending_approval') {
|
|
actions.push(
|
|
{
|
|
label: 'Aprobar',
|
|
icon: CheckCircle,
|
|
onClick: () => handleApprovePO(po),
|
|
priority: 'primary' as const,
|
|
variant: 'primary' as const
|
|
},
|
|
{
|
|
label: 'Rechazar',
|
|
icon: X,
|
|
onClick: () => handleRejectPO(po),
|
|
priority: 'secondary' as const,
|
|
variant: 'outline' as const,
|
|
destructive: true
|
|
}
|
|
);
|
|
} else if (po.status === 'approved') {
|
|
actions.push({
|
|
label: 'Enviar al Proveedor',
|
|
icon: Send,
|
|
onClick: () => handleSendToSupplier(po),
|
|
priority: 'primary' as const,
|
|
variant: 'primary' as const
|
|
});
|
|
} else if (po.status === 'sent_to_supplier') {
|
|
actions.push({
|
|
label: 'Confirmar',
|
|
icon: CheckCircle,
|
|
onClick: () => handleConfirmPO(po),
|
|
priority: 'primary' as const,
|
|
variant: 'primary' as const
|
|
});
|
|
}
|
|
|
|
return actions;
|
|
};
|
|
|
|
// Component to display user name with data fetching
|
|
const UserName: React.FC<{ userId: string | undefined | null }> = ({ userId }) => {
|
|
// Handle null/undefined
|
|
if (!userId) return <>N/A</>;
|
|
|
|
// Check for system/demo UUID patterns
|
|
if (userId === '00000000-0000-0000-0000-000000000001' || userId === '00000000-0000-0000-0000-000000000000') {
|
|
return <>Sistema</>;
|
|
}
|
|
|
|
// Fetch user data
|
|
const { data: user, isLoading } = useUserById(userId, {
|
|
retry: 1,
|
|
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
});
|
|
|
|
if (isLoading) return <>Cargando...</>;
|
|
if (!user) return <>Usuario Desconocido</>;
|
|
|
|
return <>{user.full_name || user.email || 'Usuario'}</>;
|
|
};
|
|
|
|
// Build details sections for EditViewModal
|
|
const buildPODetailsSections = (po: PurchaseOrderDetail) => {
|
|
const sections = [
|
|
{
|
|
title: 'Información General',
|
|
icon: FileText,
|
|
fields: [
|
|
{
|
|
label: 'Número de Orden',
|
|
value: po.po_number,
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Estado',
|
|
value: getPOStatusConfig(po.status).text,
|
|
type: 'badge' as const,
|
|
badgeColor: getPOStatusConfig(po.status).color
|
|
},
|
|
{
|
|
label: 'Prioridad',
|
|
value: po.priority === 'urgent' ? 'Urgente' : po.priority === 'high' ? 'Alta' : po.priority === 'low' ? 'Baja' : 'Normal',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Fecha de Creación',
|
|
value: new Date(po.created_at).toLocaleDateString('es-ES', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}),
|
|
type: 'text' as const
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Información del Proveedor',
|
|
icon: Building2,
|
|
fields: [
|
|
{
|
|
label: 'Proveedor',
|
|
value: po.supplier?.name || 'N/A',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Código de Proveedor',
|
|
value: po.supplier?.supplier_code || 'N/A',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Email',
|
|
value: po.supplier?.contact_email || 'N/A',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Teléfono',
|
|
value: po.supplier?.contact_phone || 'N/A',
|
|
type: 'text' as const
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Resumen Financiero',
|
|
icon: Euro,
|
|
fields: [
|
|
{
|
|
label: 'Subtotal',
|
|
value: `€${(() => {
|
|
const val = typeof po.subtotal === 'string' ? parseFloat(po.subtotal) : typeof po.subtotal === 'number' ? po.subtotal : 0;
|
|
return val.toFixed(2);
|
|
})()}`,
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Impuestos',
|
|
value: `€${(() => {
|
|
const val = typeof po.tax_amount === 'string' ? parseFloat(po.tax_amount) : typeof po.tax_amount === 'number' ? po.tax_amount : 0;
|
|
return val.toFixed(2);
|
|
})()}`,
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Descuentos',
|
|
value: `€${(() => {
|
|
const val = typeof po.discount_amount === 'string' ? parseFloat(po.discount_amount) : typeof po.discount_amount === 'number' ? po.discount_amount : 0;
|
|
return val.toFixed(2);
|
|
})()}`,
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'TOTAL',
|
|
value: `€${(() => {
|
|
const val = typeof po.total_amount === 'string' ? parseFloat(po.total_amount) : typeof po.total_amount === 'number' ? po.total_amount : 0;
|
|
return val.toFixed(2);
|
|
})()}`,
|
|
type: 'text' as const,
|
|
valueClassName: 'text-xl font-bold text-primary-600'
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Artículos del Pedido',
|
|
icon: Package,
|
|
fields: [
|
|
{
|
|
label: '',
|
|
value: <PurchaseOrderItemsTable items={po.items || []} />,
|
|
type: 'component' as const,
|
|
span: 2
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Entrega',
|
|
icon: Calendar,
|
|
fields: [
|
|
{
|
|
label: 'Fecha de Entrega Requerida',
|
|
value: po.required_delivery_date
|
|
? new Date(po.required_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
: 'No especificada',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Fecha de Entrega Esperada',
|
|
value: po.expected_delivery_date
|
|
? new Date(po.expected_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
: 'No especificada',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Fecha de Entrega Real',
|
|
value: po.actual_delivery_date
|
|
? new Date(po.actual_delivery_date).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric' })
|
|
: 'Pendiente',
|
|
type: 'text' as const
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Aprobación',
|
|
icon: CheckCircle,
|
|
fields: [
|
|
{
|
|
label: 'Aprobado Por',
|
|
value: <UserName userId={po.approved_by} />,
|
|
type: 'component' as const
|
|
},
|
|
{
|
|
label: 'Fecha de Aprobación',
|
|
value: po.approved_at
|
|
? new Date(po.approved_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
: 'N/A',
|
|
type: 'text' as const
|
|
},
|
|
{
|
|
label: 'Notas de Aprobación',
|
|
value: po.approval_notes || 'N/A',
|
|
type: 'textarea' as const
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Notas',
|
|
icon: FileText,
|
|
fields: [
|
|
{
|
|
label: 'Notas de la Orden',
|
|
value: po.notes || 'Sin notas',
|
|
type: 'textarea' as const
|
|
},
|
|
{
|
|
label: 'Notas Internas',
|
|
value: po.internal_notes || 'Sin notas internas',
|
|
type: 'textarea' as const
|
|
}
|
|
]
|
|
},
|
|
{
|
|
title: 'Auditoría',
|
|
icon: FileText,
|
|
fields: [
|
|
{
|
|
label: 'Creado Por',
|
|
value: <UserName userId={po.created_by} />,
|
|
type: 'component' as const
|
|
},
|
|
{
|
|
label: 'Última Actualización',
|
|
value: new Date(po.updated_at).toLocaleDateString('es-ES', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}),
|
|
type: 'text' as const
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
return sections;
|
|
};
|
|
|
|
// Items cards component - Mobile-friendly redesign
|
|
const PurchaseOrderItemsTable: React.FC<{ items: any[] }> = ({ items }) => {
|
|
if (!items || items.length === 0) {
|
|
return (
|
|
<div className="text-center py-8 text-[var(--text-secondary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
|
<Package className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
|
<p>No hay artículos en esta orden</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const totalAmount = items.reduce((sum, item) => {
|
|
const price = typeof item.unit_price === 'string' ? parseFloat(item.unit_price) : typeof item.unit_price === 'number' ? item.unit_price : 0;
|
|
const quantity = typeof item.ordered_quantity === 'number' ? item.ordered_quantity : 0;
|
|
return sum + (price * quantity);
|
|
}, 0);
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Items as cards */}
|
|
{items.map((item, index) => {
|
|
const unitPrice = typeof item.unit_price === 'string' ? parseFloat(item.unit_price) : typeof item.unit_price === 'number' ? item.unit_price : 0;
|
|
const quantity = typeof item.ordered_quantity === 'number' ? item.ordered_quantity : 0;
|
|
const itemTotal = unitPrice * quantity;
|
|
const productName = item.product_name || `Producto ${index + 1}`;
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3"
|
|
>
|
|
{/* Header with product name and total */}
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
|
{productName}
|
|
</span>
|
|
<div className="text-right">
|
|
<p className="text-sm font-bold text-[var(--color-primary)]">
|
|
€{itemTotal.toFixed(2)}
|
|
</p>
|
|
<p className="text-xs text-[var(--text-secondary)]">Subtotal</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Product SKU */}
|
|
{item.product_code && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|
SKU
|
|
</label>
|
|
<p className="text-sm text-[var(--text-primary)]">
|
|
{item.product_code}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quantity and Price details */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|
Cantidad
|
|
</label>
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">
|
|
{quantity} {item.unit_of_measure || ''}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|
Precio Unitario
|
|
</label>
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">
|
|
€{unitPrice.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Optional quality requirements or notes */}
|
|
{(item.quality_requirements || item.notes) && (
|
|
<div className="pt-3 border-t border-[var(--border-primary)] space-y-2">
|
|
{item.quality_requirements && (
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|
Requisitos de Calidad
|
|
</label>
|
|
<p className="text-sm text-[var(--text-primary)]">
|
|
{item.quality_requirements}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{item.notes && (
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
|
Notas
|
|
</label>
|
|
<p className="text-sm text-[var(--text-primary)]">
|
|
{item.notes}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Total summary */}
|
|
{items.length > 0 && (
|
|
<div className="pt-3 border-t border-[var(--border-primary)] text-right">
|
|
<span className="text-lg font-semibold text-[var(--text-primary)]">
|
|
Total: €{totalAmount.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Filters configuration
|
|
const filterConfig: FilterConfig[] = [
|
|
{
|
|
key: 'status',
|
|
type: 'dropdown',
|
|
label: 'Estado',
|
|
value: statusFilter,
|
|
onChange: (value) => setStatusFilter(value as PurchaseOrderStatus | ''),
|
|
placeholder: 'Todos los estados',
|
|
options: [
|
|
{ value: 'DRAFT', label: 'Borrador' },
|
|
{ value: 'PENDING_APPROVAL', label: 'Pendiente de Aprobación' },
|
|
{ value: 'APPROVED', label: 'Aprobado' },
|
|
{ value: 'SENT_TO_SUPPLIER', label: 'Enviado al Proveedor' },
|
|
{ value: 'CONFIRMED', label: 'Confirmado' },
|
|
{ value: 'RECEIVED', label: 'Recibido' },
|
|
{ value: 'COMPLETED', label: 'Completado' },
|
|
{ value: 'CANCELLED', label: 'Cancelado' },
|
|
{ value: 'DISPUTED', label: 'En Disputa' }
|
|
]
|
|
},
|
|
{
|
|
key: 'priority',
|
|
type: 'dropdown',
|
|
label: 'Prioridad',
|
|
value: priorityFilter,
|
|
onChange: (value) => setPriorityFilter(value as PurchaseOrderPriority | ''),
|
|
placeholder: 'Todas las prioridades',
|
|
options: [
|
|
{ value: 'urgent', label: 'Urgente' },
|
|
{ value: 'high', label: 'Alta' },
|
|
{ value: 'normal', label: 'Normal' },
|
|
{ value: 'low', label: 'Baja' }
|
|
]
|
|
},
|
|
{
|
|
key: 'archived',
|
|
type: 'checkbox',
|
|
label: 'Mostrar Archivados',
|
|
value: showArchived,
|
|
onChange: setShowArchived
|
|
}
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<PageHeader
|
|
title="Órdenes de Compra"
|
|
description="Gestiona órdenes de compra y aprovisionamiento"
|
|
actions={[
|
|
{
|
|
id: 'trigger-scheduler',
|
|
label: triggerSchedulerMutation.isPending ? 'Ejecutando...' : 'Ejecutar Scheduler',
|
|
icon: Play,
|
|
onClick: handleTriggerScheduler,
|
|
variant: 'outline',
|
|
size: 'sm',
|
|
disabled: triggerSchedulerMutation.isPending,
|
|
loading: triggerSchedulerMutation.isPending
|
|
},
|
|
{
|
|
id: 'create-po',
|
|
label: 'Nueva Orden',
|
|
icon: Plus,
|
|
onClick: () => setShowCreatePOModal(true),
|
|
variant: 'primary',
|
|
size: 'md'
|
|
}
|
|
]}
|
|
/>
|
|
|
|
{/* Stats */}
|
|
<StatsGrid
|
|
columns={3}
|
|
stats={[
|
|
{
|
|
title: 'Total de Órdenes',
|
|
value: poStats.total,
|
|
formatValue: formatters.number,
|
|
icon: ShoppingCart,
|
|
variant: 'info'
|
|
},
|
|
{
|
|
title: 'Pendientes de Aprobación',
|
|
value: poStats.pendingApproval,
|
|
formatValue: formatters.number,
|
|
icon: AlertCircle,
|
|
variant: 'warning'
|
|
},
|
|
{
|
|
title: 'Aprobadas',
|
|
value: poStats.approved,
|
|
formatValue: formatters.number,
|
|
icon: CheckCircle,
|
|
variant: 'success'
|
|
},
|
|
{
|
|
title: 'En Proceso',
|
|
value: poStats.inProgress,
|
|
formatValue: formatters.number,
|
|
icon: Package,
|
|
variant: 'purple'
|
|
},
|
|
{
|
|
title: 'Recibidas',
|
|
value: poStats.received,
|
|
formatValue: formatters.number,
|
|
icon: CheckCircle,
|
|
variant: 'success'
|
|
},
|
|
{
|
|
title: 'Valor Total',
|
|
value: poStats.totalAmount,
|
|
formatValue: formatters.currency,
|
|
icon: Euro,
|
|
variant: 'success'
|
|
}
|
|
]}
|
|
/>
|
|
|
|
{/* Search and Filters */}
|
|
<SearchAndFilter
|
|
searchValue={searchTerm}
|
|
onSearchChange={setSearchTerm}
|
|
searchPlaceholder="Buscar por número de orden, proveedor..."
|
|
filters={filterConfig}
|
|
/>
|
|
|
|
{/* Purchase Orders Grid */}
|
|
{isPOsLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
|
</div>
|
|
) : filteredPOs.length === 0 ? (
|
|
<EmptyState
|
|
icon={ShoppingCart}
|
|
title="No hay órdenes de compra"
|
|
description="Comienza creando una nueva orden de compra"
|
|
actionLabel="Nueva Orden"
|
|
actionIcon={Plus}
|
|
onAction={() => setShowCreatePOModal(true)}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredPOs.map((po) => {
|
|
const statusConfig = getPOStatusConfig(po.status);
|
|
const priorityText = String(po.priority === 'urgent' ? 'Urgente' : po.priority === 'high' ? 'Alta' : po.priority === 'low' ? 'Baja' : 'Normal');
|
|
|
|
// Parse total_amount - API returns it as a string
|
|
const totalAmountNum = typeof po.total_amount === 'string'
|
|
? parseFloat(po.total_amount)
|
|
: typeof po.total_amount === 'number'
|
|
? po.total_amount
|
|
: 0;
|
|
const totalAmount = String(totalAmountNum.toFixed(2));
|
|
|
|
// items_count is not in the API response, so we don't show it
|
|
const deliveryDate = String(po.required_delivery_date
|
|
? new Date(po.required_delivery_date).toLocaleDateString('es-ES', { month: 'short', day: 'numeric' })
|
|
: 'Sin especificar');
|
|
|
|
return (
|
|
<StatusCard
|
|
key={po.id}
|
|
id={String(po.id)}
|
|
title={String(po.po_number || 'Sin número')}
|
|
subtitle={String(po.supplier_name || po.supplier?.name || 'Proveedor desconocido')}
|
|
statusIndicator={statusConfig}
|
|
primaryValue={`€${totalAmount}`}
|
|
primaryValueLabel="Total"
|
|
metadata={[
|
|
`Prioridad: ${priorityText}`,
|
|
`Entrega: ${deliveryDate}`,
|
|
`Proveedor: ${po.supplier_name || 'Desconocido'}`
|
|
]}
|
|
actions={getPOQuickActions(po)}
|
|
onClick={() => handleViewDetails(po)}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create PO Modal */}
|
|
{showCreatePOModal && (
|
|
<CreatePurchaseOrderModal
|
|
isOpen={showCreatePOModal}
|
|
onClose={() => setShowCreatePOModal(false)}
|
|
requirements={[]}
|
|
onSuccess={() => {
|
|
setShowCreatePOModal(false);
|
|
refetchPOs();
|
|
toast.success('Orden de compra creada exitosamente');
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* PO Details Modal */}
|
|
{showDetailsModal && poDetails && (
|
|
<EditViewModal
|
|
isOpen={showDetailsModal}
|
|
onClose={() => {
|
|
setShowDetailsModal(false);
|
|
setSelectedPOId(null);
|
|
}}
|
|
title={`Orden de Compra: ${poDetails.po_number}`}
|
|
mode="view"
|
|
data={poDetails}
|
|
sections={buildPODetailsSections(poDetails)}
|
|
isLoading={isLoadingDetails}
|
|
actions={
|
|
poDetails.status === 'PENDING_APPROVAL' ? [
|
|
{
|
|
label: 'Aprobar',
|
|
onClick: () => {
|
|
setApprovalAction('approve');
|
|
setApprovalNotes('');
|
|
setShowApprovalModal(true);
|
|
},
|
|
variant: 'primary' as const,
|
|
icon: CheckCircle
|
|
},
|
|
{
|
|
label: 'Rechazar',
|
|
onClick: () => {
|
|
setApprovalAction('reject');
|
|
setApprovalNotes('');
|
|
setShowApprovalModal(true);
|
|
},
|
|
variant: 'outline' as const,
|
|
icon: X
|
|
}
|
|
] : undefined
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Approval Modal */}
|
|
{showApprovalModal && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
|
<div className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4">
|
|
{approvalAction === 'approve' ? 'Aprobar Orden de Compra' : 'Rechazar Orden de Compra'}
|
|
</h3>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
{approvalAction === 'approve' ? 'Notas (opcional)' : 'Razón del rechazo (requerido)'}
|
|
</label>
|
|
<textarea
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
rows={4}
|
|
value={approvalNotes}
|
|
onChange={(e) => setApprovalNotes(e.target.value)}
|
|
placeholder={approvalAction === 'approve'
|
|
? 'Agrega notas sobre la aprobación...'
|
|
: 'Explica por qué se rechaza esta orden...'}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowApprovalModal(false);
|
|
setApprovalNotes('');
|
|
}}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
onClick={handleApprovalSubmit}
|
|
disabled={approvePOMutation.isPending || rejectPOMutation.isPending}
|
|
>
|
|
{approvalAction === 'approve' ? 'Aprobar' : 'Rechazar'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProcurementPage;
|