619 lines
21 KiB
TypeScript
619 lines
21 KiB
TypeScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
Search,
|
|
Filter,
|
|
Plus,
|
|
Download,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
FileText,
|
|
TrendingUp,
|
|
Clock,
|
|
AlertCircle,
|
|
Package,
|
|
DollarSign,
|
|
Grid,
|
|
List
|
|
} from 'lucide-react';
|
|
|
|
import {
|
|
usePurchaseOrders,
|
|
PurchaseOrder,
|
|
CreatePurchaseOrderRequest
|
|
} from '../../api/hooks/useSuppliers';
|
|
import { useAuth } from '../../api/hooks/useAuth';
|
|
|
|
import PurchaseOrderCard from './PurchaseOrderCard';
|
|
import PurchaseOrderForm from './PurchaseOrderForm';
|
|
import Card from '../ui/Card';
|
|
import Button from '../ui/Button';
|
|
import LoadingSpinner from '../ui/LoadingSpinner';
|
|
|
|
interface PurchaseOrderFilters {
|
|
search: string;
|
|
supplier_id: string;
|
|
status: string;
|
|
priority: string;
|
|
date_from: string;
|
|
date_to: string;
|
|
}
|
|
|
|
const PurchaseOrderManagementPage: React.FC = () => {
|
|
const { user } = useAuth();
|
|
const {
|
|
purchaseOrders,
|
|
purchaseOrder: selectedPurchaseOrder,
|
|
statistics,
|
|
ordersRequiringApproval,
|
|
overdueOrders,
|
|
isLoading,
|
|
isCreating,
|
|
error,
|
|
pagination,
|
|
loadPurchaseOrders,
|
|
loadPurchaseOrder,
|
|
loadStatistics,
|
|
loadOrdersRequiringApproval,
|
|
loadOverdueOrders,
|
|
createPurchaseOrder,
|
|
updateOrderStatus,
|
|
approveOrder,
|
|
sendToSupplier,
|
|
cancelOrder,
|
|
clearError,
|
|
refresh,
|
|
setPage
|
|
} = usePurchaseOrders();
|
|
|
|
const [filters, setFilters] = useState<PurchaseOrderFilters>({
|
|
search: '',
|
|
supplier_id: '',
|
|
status: '',
|
|
priority: '',
|
|
date_from: '',
|
|
date_to: ''
|
|
});
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [showPurchaseOrderForm, setShowPurchaseOrderForm] = useState(false);
|
|
const [selectedOrder, setSelectedOrder] = useState<PurchaseOrder | null>(null);
|
|
|
|
// Load initial data
|
|
useEffect(() => {
|
|
if (user?.tenant_id) {
|
|
loadPurchaseOrders();
|
|
loadStatistics();
|
|
loadOrdersRequiringApproval();
|
|
loadOverdueOrders();
|
|
}
|
|
}, [user?.tenant_id]);
|
|
|
|
// Apply filters
|
|
useEffect(() => {
|
|
const searchParams: any = {};
|
|
|
|
if (filters.search) {
|
|
searchParams.search_term = filters.search;
|
|
}
|
|
if (filters.supplier_id) {
|
|
searchParams.supplier_id = filters.supplier_id;
|
|
}
|
|
if (filters.status) {
|
|
searchParams.status = filters.status;
|
|
}
|
|
if (filters.priority) {
|
|
searchParams.priority = filters.priority;
|
|
}
|
|
if (filters.date_from) {
|
|
searchParams.date_from = filters.date_from;
|
|
}
|
|
if (filters.date_to) {
|
|
searchParams.date_to = filters.date_to;
|
|
}
|
|
|
|
loadPurchaseOrders(searchParams);
|
|
}, [filters]);
|
|
|
|
// Status options
|
|
const statusOptions = [
|
|
{ value: '', label: 'Todos los estados' },
|
|
{ value: 'DRAFT', label: 'Borrador' },
|
|
{ value: 'PENDING_APPROVAL', label: 'Pendiente Aprobación' },
|
|
{ value: 'APPROVED', label: 'Aprobado' },
|
|
{ value: 'SENT_TO_SUPPLIER', label: 'Enviado a Proveedor' },
|
|
{ value: 'CONFIRMED', label: 'Confirmado' },
|
|
{ value: 'PARTIALLY_RECEIVED', label: 'Recibido Parcial' },
|
|
{ value: 'COMPLETED', label: 'Completado' },
|
|
{ value: 'CANCELLED', label: 'Cancelado' },
|
|
{ value: 'DISPUTED', label: 'En Disputa' }
|
|
];
|
|
|
|
// Priority options
|
|
const priorityOptions = [
|
|
{ value: '', label: 'Todas las prioridades' },
|
|
{ value: 'LOW', label: 'Baja' },
|
|
{ value: 'NORMAL', label: 'Normal' },
|
|
{ value: 'HIGH', label: 'Alta' },
|
|
{ value: 'URGENT', label: 'Urgente' }
|
|
];
|
|
|
|
// Handle purchase order creation
|
|
const handleCreatePurchaseOrder = async (orderData: CreatePurchaseOrderRequest) => {
|
|
const order = await createPurchaseOrder(orderData);
|
|
if (order) {
|
|
setShowPurchaseOrderForm(false);
|
|
// Refresh statistics and special lists
|
|
loadStatistics();
|
|
if (order.status === 'PENDING_APPROVAL') loadOrdersRequiringApproval();
|
|
}
|
|
};
|
|
|
|
// Handle order approval
|
|
const handleApproveOrder = async (
|
|
order: PurchaseOrder,
|
|
action: 'approve' | 'reject',
|
|
notes?: string
|
|
) => {
|
|
const updatedOrder = await approveOrder(order.id, action, notes);
|
|
if (updatedOrder) {
|
|
// Refresh relevant lists
|
|
loadOrdersRequiringApproval();
|
|
loadStatistics();
|
|
}
|
|
};
|
|
|
|
// Handle send to supplier
|
|
const handleSendToSupplier = async (order: PurchaseOrder, sendEmail: boolean = true) => {
|
|
const updatedOrder = await sendToSupplier(order.id, sendEmail);
|
|
if (updatedOrder) {
|
|
loadStatistics();
|
|
}
|
|
};
|
|
|
|
// Handle cancel order
|
|
const handleCancelOrder = async (order: PurchaseOrder, reason: string) => {
|
|
const updatedOrder = await cancelOrder(order.id, reason);
|
|
if (updatedOrder) {
|
|
loadStatistics();
|
|
}
|
|
};
|
|
|
|
// Handle clear filters
|
|
const handleClearFilters = () => {
|
|
setFilters({
|
|
search: '',
|
|
supplier_id: '',
|
|
status: '',
|
|
priority: '',
|
|
date_from: '',
|
|
date_to: ''
|
|
});
|
|
};
|
|
|
|
// Format currency
|
|
const formatCurrency = (amount: number) => {
|
|
return new Intl.NumberFormat('es-ES', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0
|
|
}).format(amount);
|
|
};
|
|
|
|
// Statistics cards data
|
|
const statsCards = useMemo(() => {
|
|
if (!statistics) return [];
|
|
|
|
return [
|
|
{
|
|
title: 'Total Pedidos',
|
|
value: statistics.total_orders.toString(),
|
|
icon: FileText,
|
|
color: 'blue'
|
|
},
|
|
{
|
|
title: 'Este Mes',
|
|
value: statistics.this_month_orders.toString(),
|
|
icon: TrendingUp,
|
|
color: 'green'
|
|
},
|
|
{
|
|
title: 'Pendientes Aprobación',
|
|
value: statistics.pending_approval.toString(),
|
|
icon: Clock,
|
|
color: 'yellow'
|
|
},
|
|
{
|
|
title: 'Gasto Este Mes',
|
|
value: formatCurrency(statistics.this_month_spend),
|
|
icon: DollarSign,
|
|
color: 'purple'
|
|
}
|
|
];
|
|
}, [statistics]);
|
|
|
|
if (isLoading && !purchaseOrders.length) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Órdenes de Compra</h1>
|
|
<p className="text-gray-600">Gestiona tus pedidos y compras a proveedores</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={refresh}
|
|
disabled={isLoading}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
|
Actualizar
|
|
</Button>
|
|
|
|
<Button
|
|
onClick={() => setShowPurchaseOrderForm(true)}
|
|
disabled={isCreating}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Nueva Orden
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error display */}
|
|
{error && (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<AlertCircle className="w-5 h-5 text-red-500" />
|
|
<span className="text-red-700">{error}</span>
|
|
</div>
|
|
<button
|
|
onClick={clearError}
|
|
className="text-red-500 hover:text-red-700"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Statistics Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{statsCards.map((stat, index) => (
|
|
<Card key={index} className="p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-600">{stat.title}</p>
|
|
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
|
</div>
|
|
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
|
stat.color === 'blue' ? 'bg-blue-100' :
|
|
stat.color === 'green' ? 'bg-green-100' :
|
|
stat.color === 'yellow' ? 'bg-yellow-100' :
|
|
'bg-purple-100'
|
|
}`}>
|
|
<stat.icon className={`w-6 h-6 ${
|
|
stat.color === 'blue' ? 'text-blue-600' :
|
|
stat.color === 'green' ? 'text-green-600' :
|
|
stat.color === 'yellow' ? 'text-yellow-600' :
|
|
'text-purple-600'
|
|
}`} />
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Quick Lists */}
|
|
{(ordersRequiringApproval.length > 0 || overdueOrders.length > 0) && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Orders Requiring Approval */}
|
|
{ordersRequiringApproval.length > 0 && (
|
|
<Card>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
<Clock className="w-5 h-5 text-yellow-500 mr-2" />
|
|
Requieren Aprobación
|
|
</h3>
|
|
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-sm">
|
|
{ordersRequiringApproval.length}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{ordersRequiringApproval.slice(0, 3).map(order => (
|
|
<PurchaseOrderCard
|
|
key={order.id}
|
|
order={order}
|
|
compact
|
|
onApprove={handleApproveOrder}
|
|
onViewDetails={(order) => setSelectedOrder(order)}
|
|
/>
|
|
))}
|
|
{ordersRequiringApproval.length > 3 && (
|
|
<button
|
|
onClick={() => setFilters(prev => ({ ...prev, status: 'PENDING_APPROVAL' }))}
|
|
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
>
|
|
Ver {ordersRequiringApproval.length - 3} más...
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Overdue Orders */}
|
|
{overdueOrders.length > 0 && (
|
|
<Card>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
|
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
|
|
Pedidos Vencidos
|
|
</h3>
|
|
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-sm">
|
|
{overdueOrders.length}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{overdueOrders.slice(0, 3).map(order => (
|
|
<PurchaseOrderCard
|
|
key={order.id}
|
|
order={order}
|
|
compact
|
|
onViewDetails={(order) => setSelectedOrder(order)}
|
|
/>
|
|
))}
|
|
{overdueOrders.length > 3 && (
|
|
<button
|
|
onClick={() => {
|
|
const today = new Date().toISOString().split('T')[0];
|
|
setFilters(prev => ({ ...prev, date_to: today }));
|
|
}}
|
|
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
|
>
|
|
Ver {overdueOrders.length - 3} más...
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters and Search */}
|
|
<Card>
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between space-y-4 lg:space-y-0">
|
|
<div className="flex items-center space-x-4">
|
|
{/* Search */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Buscar pedidos..."
|
|
value={filters.search}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
|
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 w-64"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filter Toggle */}
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className={`flex items-center space-x-2 px-3 py-2 border rounded-lg transition-colors ${
|
|
showFilters ? 'bg-blue-50 border-blue-200 text-blue-700' : 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<Filter className="w-4 h-4" />
|
|
<span>Filtros</span>
|
|
<ChevronDown className={`w-4 h-4 transform transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{/* Active filters indicator */}
|
|
{(filters.status || filters.priority || filters.supplier_id || filters.date_from || filters.date_to) && (
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-sm text-gray-500">Filtros activos:</span>
|
|
{filters.status && (
|
|
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
|
{statusOptions.find(opt => opt.value === filters.status)?.label}
|
|
</span>
|
|
)}
|
|
{filters.priority && (
|
|
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
|
{priorityOptions.find(opt => opt.value === filters.priority)?.label}
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={handleClearFilters}
|
|
className="text-xs text-red-600 hover:text-red-700"
|
|
>
|
|
Limpiar
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
{/* View Mode Toggle */}
|
|
<div className="flex items-center space-x-1 border border-gray-300 rounded-lg p-1">
|
|
<button
|
|
onClick={() => setViewMode('grid')}
|
|
className={`p-2 rounded ${viewMode === 'grid' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
<Grid className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setViewMode('list')}
|
|
className={`p-2 rounded ${viewMode === 'list' ? 'bg-blue-100 text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<Button variant="outline">
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Exportar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Filters */}
|
|
{showFilters && (
|
|
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Estado
|
|
</label>
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
{statusOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Prioridad
|
|
</label>
|
|
<select
|
|
value={filters.priority}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, priority: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
>
|
|
{priorityOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Fecha Desde
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={filters.date_from}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, date_from: e.target.value }))}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Purchase Orders List */}
|
|
<div>
|
|
{purchaseOrders.length === 0 ? (
|
|
<Card className="text-center py-12">
|
|
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">No se encontraron pedidos</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
{filters.search || filters.status || filters.priority
|
|
? 'Intenta ajustar tus filtros de búsqueda'
|
|
: 'Comienza creando tu primera orden de compra'
|
|
}
|
|
</p>
|
|
{!(filters.search || filters.status || filters.priority) && (
|
|
<Button onClick={() => setShowPurchaseOrderForm(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Nueva Orden
|
|
</Button>
|
|
)}
|
|
</Card>
|
|
) : (
|
|
<div className={
|
|
viewMode === 'grid'
|
|
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
|
: 'space-y-4'
|
|
}>
|
|
{purchaseOrders.map(order => (
|
|
<PurchaseOrderCard
|
|
key={order.id}
|
|
order={order}
|
|
compact={viewMode === 'list'}
|
|
onEdit={(order) => {
|
|
setSelectedOrder(order);
|
|
setShowPurchaseOrderForm(true);
|
|
}}
|
|
onViewDetails={(order) => setSelectedOrder(order)}
|
|
onApprove={handleApproveOrder}
|
|
onSendToSupplier={handleSendToSupplier}
|
|
onCancel={handleCancelOrder}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{purchaseOrders.length > 0 && pagination.totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-6">
|
|
<div className="text-sm text-gray-700">
|
|
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
|
|
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
|
|
{pagination.total} pedidos
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<button
|
|
onClick={() => setPage(pagination.page - 1)}
|
|
disabled={pagination.page === 1}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
Anterior
|
|
</button>
|
|
|
|
<span className="px-3 py-2 bg-blue-50 text-blue-600 rounded-lg">
|
|
{pagination.page}
|
|
</span>
|
|
|
|
<button
|
|
onClick={() => setPage(pagination.page + 1)}
|
|
disabled={pagination.page >= pagination.totalPages}
|
|
className="px-3 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
|
>
|
|
Siguiente
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Purchase Order Form Modal */}
|
|
{showPurchaseOrderForm && (
|
|
<PurchaseOrderForm
|
|
order={selectedOrder}
|
|
isOpen={showPurchaseOrderForm}
|
|
isCreating={isCreating}
|
|
onSubmit={selectedOrder ?
|
|
async (data) => {
|
|
// Handle update logic here if needed
|
|
setShowPurchaseOrderForm(false);
|
|
setSelectedOrder(null);
|
|
} :
|
|
handleCreatePurchaseOrder
|
|
}
|
|
onClose={() => {
|
|
setShowPurchaseOrderForm(false);
|
|
setSelectedOrder(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default PurchaseOrderManagementPage; |