Create new services: inventory, recipes, suppliers
This commit is contained in:
249
frontend/src/components/inventory/InventoryDashboardWidget.tsx
Normal file
249
frontend/src/components/inventory/InventoryDashboardWidget.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Package,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
ArrowRight,
|
||||
Loader,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useInventoryDashboard } from '../../api/hooks/useInventory';
|
||||
|
||||
interface InventoryDashboardWidgetProps {
|
||||
onViewInventory?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InventoryDashboardWidget: React.FC<InventoryDashboardWidgetProps> = ({
|
||||
onViewInventory,
|
||||
className = ''
|
||||
}) => {
|
||||
const { dashboardData, alerts, isLoading, error, refresh } = useInventoryDashboard();
|
||||
|
||||
// Get alert counts
|
||||
const criticalAlerts = alerts.filter(a => !a.is_acknowledged && a.severity === 'critical').length;
|
||||
const lowStockAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'low_stock').length;
|
||||
const expiringAlerts = alerts.filter(a => !a.is_acknowledged && a.alert_type === 'expiring_soon').length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border p-6 ${className}`}>
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Package className="w-6 h-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader className="w-6 h-6 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Cargando datos...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Package className="w-6 h-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
title="Refrescar"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-6">
|
||||
<AlertTriangle className="w-8 h-8 text-red-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-600">Error al cargar datos de inventario</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="mt-2 text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Package className="w-6 h-6 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Inventario</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="p-1 hover:bg-gray-100 rounded transition-colors"
|
||||
title="Refrescar"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
|
||||
{onViewInventory && (
|
||||
<button
|
||||
onClick={onViewInventory}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
<span>Ver todo</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{dashboardData?.total_items || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Total Productos</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
€{(dashboardData?.total_value || 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Valor Total</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts Summary */}
|
||||
{criticalAlerts > 0 || lowStockAlerts > 0 || expiringAlerts > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 flex items-center">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mr-2" />
|
||||
Alertas Activas
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
{criticalAlerts > 0 && (
|
||||
<div className="flex items-center justify-between p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
||||
<span className="text-sm text-red-800">Críticas</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-red-900">{criticalAlerts}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lowStockAlerts > 0 && (
|
||||
<div className="flex items-center justify-between p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<TrendingDown className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-sm text-yellow-800">Stock Bajo</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-yellow-900">{lowStockAlerts}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expiringAlerts > 0 && (
|
||||
<div className="flex items-center justify-between p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Calendar className="w-4 h-4 text-orange-600" />
|
||||
<span className="text-sm text-orange-800">Por Vencer</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-orange-900">{expiringAlerts}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Package className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-1">Todo en orden</h4>
|
||||
<p className="text-xs text-gray-600">No hay alertas activas en tu inventario</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Categories */}
|
||||
{dashboardData?.category_breakdown && dashboardData.category_breakdown.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
|
||||
<BarChart3 className="w-4 h-4 text-gray-600 mr-2" />
|
||||
Top Categorías por Valor
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
{dashboardData.category_breakdown.slice(0, 3).map((category, index) => (
|
||||
<div key={category.category} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
index === 0 ? 'bg-blue-500' :
|
||||
index === 1 ? 'bg-green-500' :
|
||||
'bg-purple-500'
|
||||
}`}></div>
|
||||
<span className="text-sm text-gray-700 capitalize">
|
||||
{category.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
€{category.value.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{category.count} productos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity */}
|
||||
{dashboardData?.recent_movements && dashboardData.recent_movements.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">Actividad Reciente</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
{dashboardData.recent_movements.slice(0, 3).map((movement) => (
|
||||
<div key={movement.id} className="flex items-center space-x-3 text-sm">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
movement.movement_type === 'purchase' ? 'bg-green-100' :
|
||||
movement.movement_type === 'consumption' ? 'bg-blue-100' :
|
||||
movement.movement_type === 'waste' ? 'bg-red-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
{movement.movement_type === 'purchase' ? '+' :
|
||||
movement.movement_type === 'consumption' ? '-' :
|
||||
movement.movement_type === 'waste' ? '×' :
|
||||
'~'}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900 truncate">
|
||||
{movement.item_name || 'Producto'}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{movement.quantity} • {new Date(movement.movement_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryDashboardWidget;
|
||||
424
frontend/src/components/inventory/InventoryItemCard.tsx
Normal file
424
frontend/src/components/inventory/InventoryItemCard.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Thermometer,
|
||||
Snowflake,
|
||||
Calendar,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Edit3,
|
||||
Trash2,
|
||||
Plus,
|
||||
Minus,
|
||||
Eye,
|
||||
MoreVertical
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
InventoryItem,
|
||||
StockLevel,
|
||||
ProductType,
|
||||
StockAdjustmentRequest
|
||||
} from '../../api/services/inventory.service';
|
||||
|
||||
interface InventoryItemCardProps {
|
||||
item: InventoryItem;
|
||||
stockLevel?: StockLevel;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
onEdit?: (item: InventoryItem) => void;
|
||||
onDelete?: (item: InventoryItem) => void;
|
||||
onViewDetails?: (item: InventoryItem) => void;
|
||||
onStockAdjust?: (item: InventoryItem, adjustment: StockAdjustmentRequest) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InventoryItemCard: React.FC<InventoryItemCardProps> = ({
|
||||
item,
|
||||
stockLevel,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewDetails,
|
||||
onStockAdjust,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showQuickAdjust, setShowQuickAdjust] = useState(false);
|
||||
const [adjustmentQuantity, setAdjustmentQuantity] = useState('');
|
||||
|
||||
// Get stock status
|
||||
const getStockStatus = () => {
|
||||
if (!stockLevel) return null;
|
||||
|
||||
const { current_quantity, available_quantity } = stockLevel;
|
||||
const { minimum_stock_level, reorder_point } = item;
|
||||
|
||||
if (current_quantity <= 0) {
|
||||
return { status: 'out_of_stock', label: 'Sin stock', color: 'red' };
|
||||
}
|
||||
|
||||
if (minimum_stock_level && current_quantity <= minimum_stock_level) {
|
||||
return { status: 'low_stock', label: 'Stock bajo', color: 'yellow' };
|
||||
}
|
||||
|
||||
if (reorder_point && current_quantity <= reorder_point) {
|
||||
return { status: 'reorder', label: 'Reordenar', color: 'orange' };
|
||||
}
|
||||
|
||||
return { status: 'good', label: 'Stock OK', color: 'green' };
|
||||
};
|
||||
|
||||
const stockStatus = getStockStatus();
|
||||
|
||||
// Get expiration status
|
||||
const getExpirationStatus = () => {
|
||||
if (!stockLevel?.batches || stockLevel.batches.length === 0) return null;
|
||||
|
||||
const expiredBatches = stockLevel.batches.filter(b => b.is_expired);
|
||||
const expiringSoon = stockLevel.batches.filter(b =>
|
||||
!b.is_expired && b.days_until_expiration !== undefined && b.days_until_expiration <= 3
|
||||
);
|
||||
|
||||
if (expiredBatches.length > 0) {
|
||||
return { status: 'expired', label: 'Vencido', color: 'red' };
|
||||
}
|
||||
|
||||
if (expiringSoon.length > 0) {
|
||||
return { status: 'expiring', label: 'Por vencer', color: 'yellow' };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const expirationStatus = getExpirationStatus();
|
||||
|
||||
// Get category display info
|
||||
const getCategoryInfo = () => {
|
||||
const categoryLabels: Record<string, string> = {
|
||||
// Ingredients
|
||||
flour: 'Harina',
|
||||
yeast: 'Levadura',
|
||||
dairy: 'Lácteos',
|
||||
eggs: 'Huevos',
|
||||
sugar: 'Azúcar',
|
||||
fats: 'Grasas',
|
||||
salt: 'Sal',
|
||||
spices: 'Especias',
|
||||
additives: 'Aditivos',
|
||||
packaging: 'Embalaje',
|
||||
|
||||
// Finished Products
|
||||
bread: 'Pan',
|
||||
croissants: 'Croissants',
|
||||
pastries: 'Repostería',
|
||||
cakes: 'Tartas',
|
||||
cookies: 'Galletas',
|
||||
muffins: 'Magdalenas',
|
||||
sandwiches: 'Sandwiches',
|
||||
beverages: 'Bebidas',
|
||||
other_products: 'Otros'
|
||||
};
|
||||
|
||||
return categoryLabels[item.category] || item.category;
|
||||
};
|
||||
|
||||
// Handle quick stock adjustment
|
||||
const handleQuickAdjust = (type: 'add' | 'remove') => {
|
||||
if (!adjustmentQuantity || !onStockAdjust) return;
|
||||
|
||||
const quantity = parseFloat(adjustmentQuantity);
|
||||
if (isNaN(quantity) || quantity <= 0) return;
|
||||
|
||||
const adjustment: StockAdjustmentRequest = {
|
||||
movement_type: type === 'add' ? 'purchase' : 'consumption',
|
||||
quantity: type === 'add' ? quantity : -quantity,
|
||||
notes: `Quick ${type === 'add' ? 'addition' : 'consumption'} via inventory card`
|
||||
};
|
||||
|
||||
onStockAdjust(item, adjustment);
|
||||
setAdjustmentQuantity('');
|
||||
setShowQuickAdjust(false);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
item.product_type === 'ingredient' ? 'bg-blue-100' : 'bg-green-100'
|
||||
}`}>
|
||||
<Package className={`w-5 h-5 ${
|
||||
item.product_type === 'ingredient' ? 'text-blue-600' : 'text-green-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{item.name}</h4>
|
||||
<p className="text-sm text-gray-500">{getCategoryInfo()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{stockLevel && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{stockLevel.current_quantity} {stockLevel.unit_of_measure}
|
||||
</div>
|
||||
{stockStatus && (
|
||||
<div className={`text-xs px-2 py-1 rounded-full ${
|
||||
stockStatus.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
stockStatus.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
stockStatus.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{stockStatus.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showActions && onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(item)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
item.product_type === 'ingredient' ? 'bg-blue-100' : 'bg-green-100'
|
||||
}`}>
|
||||
<Package className={`w-6 h-6 ${
|
||||
item.product_type === 'ingredient' ? 'text-blue-600' : 'text-green-600'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{item.name}</h3>
|
||||
{!item.is_active && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
Inactivo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
item.product_type === 'ingredient' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{item.product_type === 'ingredient' ? 'Ingrediente' : 'Producto Final'}
|
||||
</span>
|
||||
<span>{getCategoryInfo()}</span>
|
||||
<span>{item.unit_of_measure}</span>
|
||||
</div>
|
||||
|
||||
{/* Special requirements */}
|
||||
{(item.requires_refrigeration || item.requires_freezing || item.is_seasonal) && (
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
{item.requires_refrigeration && (
|
||||
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
||||
<Thermometer className="w-3 h-3" />
|
||||
<span>Refrigeración</span>
|
||||
</div>
|
||||
)}
|
||||
{item.requires_freezing && (
|
||||
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
||||
<Snowflake className="w-3 h-3" />
|
||||
<span>Congelación</span>
|
||||
</div>
|
||||
)}
|
||||
{item.is_seasonal && (
|
||||
<div className="flex items-center space-x-1 text-xs text-amber-600">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Estacional</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(item)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(item)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Ver detalles"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<MoreVertical className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Information */}
|
||||
{stockLevel && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-gray-700">Stock Actual</h4>
|
||||
|
||||
{(stockStatus || expirationStatus) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
{expirationStatus && (
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
expirationStatus.color === 'red' ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{expirationStatus.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stockStatus && (
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
stockStatus.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
stockStatus.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
stockStatus.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{stockStatus.color === 'red' ? <AlertTriangle className="w-3 h-3" /> :
|
||||
stockStatus.color === 'green' ? <TrendingUp className="w-3 h-3" /> :
|
||||
<TrendingDown className="w-3 h-3" />}
|
||||
<span>{stockStatus.label}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{stockLevel.current_quantity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Cantidad Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{stockLevel.available_quantity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Disponible</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-amber-600">
|
||||
{stockLevel.reserved_quantity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Reservado</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Levels */}
|
||||
{(item.minimum_stock_level || item.reorder_point) && (
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-4">
|
||||
{item.minimum_stock_level && (
|
||||
<span>Mínimo: {item.minimum_stock_level}</span>
|
||||
)}
|
||||
{item.reorder_point && (
|
||||
<span>Reorden: {item.reorder_point}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Adjust */}
|
||||
{showActions && onStockAdjust && (
|
||||
<div className="border-t pt-4">
|
||||
{!showQuickAdjust ? (
|
||||
<button
|
||||
onClick={() => setShowQuickAdjust(true)}
|
||||
className="w-full px-4 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors text-sm font-medium"
|
||||
>
|
||||
Ajustar Stock
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="number"
|
||||
value={adjustmentQuantity}
|
||||
onChange={(e) => setAdjustmentQuantity(e.target.value)}
|
||||
placeholder="Cantidad"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{item.unit_of_measure}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleQuickAdjust('add')}
|
||||
disabled={!adjustmentQuantity}
|
||||
className="flex-1 flex items-center justify-center space-x-1 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Agregar</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickAdjust('remove')}
|
||||
disabled={!adjustmentQuantity}
|
||||
className="flex-1 flex items-center justify-center space-x-1 px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
<span>Consumir</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowQuickAdjust(false);
|
||||
setAdjustmentQuantity('');
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Stock Data */}
|
||||
{!stockLevel && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="text-center py-4 border-2 border-dashed border-gray-200 rounded-lg">
|
||||
<Package className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">No hay datos de stock</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryItemCard;
|
||||
359
frontend/src/components/inventory/StockAlertsPanel.tsx
Normal file
359
frontend/src/components/inventory/StockAlertsPanel.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Package,
|
||||
TrendingDown,
|
||||
CheckCircle,
|
||||
X,
|
||||
Filter,
|
||||
Bell,
|
||||
BellOff,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
import { StockAlert } from '../../api/services/inventory.service';
|
||||
|
||||
interface StockAlertsPanelProps {
|
||||
alerts: StockAlert[];
|
||||
onAcknowledge?: (alertId: string) => void;
|
||||
onAcknowledgeAll?: (alertIds: string[]) => void;
|
||||
onViewItem?: (itemId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type AlertFilter = 'all' | 'unacknowledged' | 'low_stock' | 'expired' | 'expiring_soon';
|
||||
|
||||
const StockAlertsPanel: React.FC<StockAlertsPanelProps> = ({
|
||||
alerts,
|
||||
onAcknowledge,
|
||||
onAcknowledgeAll,
|
||||
onViewItem,
|
||||
className = ''
|
||||
}) => {
|
||||
const [filter, setFilter] = useState<AlertFilter>('all');
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<Set<string>>(new Set());
|
||||
|
||||
// Filter alerts based on current filter
|
||||
const filteredAlerts = alerts.filter(alert => {
|
||||
switch (filter) {
|
||||
case 'unacknowledged':
|
||||
return !alert.is_acknowledged;
|
||||
case 'low_stock':
|
||||
return alert.alert_type === 'low_stock';
|
||||
case 'expired':
|
||||
return alert.alert_type === 'expired';
|
||||
case 'expiring_soon':
|
||||
return alert.alert_type === 'expiring_soon';
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// Get alert icon
|
||||
const getAlertIcon = (alert: StockAlert) => {
|
||||
switch (alert.alert_type) {
|
||||
case 'low_stock':
|
||||
return <TrendingDown className="w-5 h-5" />;
|
||||
case 'expired':
|
||||
return <X className="w-5 h-5" />;
|
||||
case 'expiring_soon':
|
||||
return <Clock className="w-5 h-5" />;
|
||||
case 'overstock':
|
||||
return <Package className="w-5 h-5" />;
|
||||
default:
|
||||
return <AlertTriangle className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get alert color classes
|
||||
const getAlertClasses = (alert: StockAlert) => {
|
||||
const baseClasses = 'border-l-4';
|
||||
|
||||
if (alert.is_acknowledged) {
|
||||
return `${baseClasses} border-gray-300 bg-gray-50`;
|
||||
}
|
||||
|
||||
switch (alert.severity) {
|
||||
case 'critical':
|
||||
return `${baseClasses} border-red-500 bg-red-50`;
|
||||
case 'high':
|
||||
return `${baseClasses} border-orange-500 bg-orange-50`;
|
||||
case 'medium':
|
||||
return `${baseClasses} border-yellow-500 bg-yellow-50`;
|
||||
case 'low':
|
||||
return `${baseClasses} border-blue-500 bg-blue-50`;
|
||||
default:
|
||||
return `${baseClasses} border-gray-500 bg-gray-50`;
|
||||
}
|
||||
};
|
||||
|
||||
// Get alert text color
|
||||
const getAlertTextColor = (alert: StockAlert) => {
|
||||
if (alert.is_acknowledged) {
|
||||
return 'text-gray-600';
|
||||
}
|
||||
|
||||
switch (alert.severity) {
|
||||
case 'critical':
|
||||
return 'text-red-700';
|
||||
case 'high':
|
||||
return 'text-orange-700';
|
||||
case 'medium':
|
||||
return 'text-yellow-700';
|
||||
case 'low':
|
||||
return 'text-blue-700';
|
||||
default:
|
||||
return 'text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
// Get alert icon color
|
||||
const getAlertIconColor = (alert: StockAlert) => {
|
||||
if (alert.is_acknowledged) {
|
||||
return 'text-gray-400';
|
||||
}
|
||||
|
||||
switch (alert.severity) {
|
||||
case 'critical':
|
||||
return 'text-red-500';
|
||||
case 'high':
|
||||
return 'text-orange-500';
|
||||
case 'medium':
|
||||
return 'text-yellow-500';
|
||||
case 'low':
|
||||
return 'text-blue-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle alert selection
|
||||
const toggleAlertSelection = (alertId: string) => {
|
||||
const newSelection = new Set(selectedAlerts);
|
||||
if (newSelection.has(alertId)) {
|
||||
newSelection.delete(alertId);
|
||||
} else {
|
||||
newSelection.add(alertId);
|
||||
}
|
||||
setSelectedAlerts(newSelection);
|
||||
};
|
||||
|
||||
// Handle acknowledge all selected
|
||||
const handleAcknowledgeSelected = () => {
|
||||
if (onAcknowledgeAll && selectedAlerts.size > 0) {
|
||||
onAcknowledgeAll(Array.from(selectedAlerts));
|
||||
setSelectedAlerts(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) {
|
||||
return 'Hace menos de 1 hora';
|
||||
} else if (diffInHours < 24) {
|
||||
return `Hace ${diffInHours} horas`;
|
||||
} else {
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
return `Hace ${diffInDays} días`;
|
||||
}
|
||||
};
|
||||
|
||||
// Get filter counts
|
||||
const getFilterCounts = () => {
|
||||
return {
|
||||
all: alerts.length,
|
||||
unacknowledged: alerts.filter(a => !a.is_acknowledged).length,
|
||||
low_stock: alerts.filter(a => a.alert_type === 'low_stock').length,
|
||||
expired: alerts.filter(a => a.alert_type === 'expired').length,
|
||||
expiring_soon: alerts.filter(a => a.alert_type === 'expiring_soon').length,
|
||||
};
|
||||
};
|
||||
|
||||
const filterCounts = getFilterCounts();
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bell className="w-5 h-5 text-gray-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Alertas de Stock</h2>
|
||||
{filterCounts.unacknowledged > 0 && (
|
||||
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded-full">
|
||||
{filterCounts.unacknowledged} pendientes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedAlerts.size > 0 && (
|
||||
<button
|
||||
onClick={handleAcknowledgeSelected}
|
||||
className="flex items-center space-x-1 px-3 py-1 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Confirmar ({selectedAlerts.size})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: 'all', label: 'Todas', count: filterCounts.all },
|
||||
{ key: 'unacknowledged', label: 'Pendientes', count: filterCounts.unacknowledged },
|
||||
{ key: 'low_stock', label: 'Stock Bajo', count: filterCounts.low_stock },
|
||||
{ key: 'expired', label: 'Vencidas', count: filterCounts.expired },
|
||||
{ key: 'expiring_soon', label: 'Por Vencer', count: filterCounts.expiring_soon },
|
||||
].map(({ key, label, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key as AlertFilter)}
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
|
||||
filter === key
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{label} ({count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
<div className="divide-y">
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<BellOff className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{filter === 'all' ? 'No hay alertas' : 'No hay alertas con este filtro'}
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
{filter === 'all'
|
||||
? 'Tu inventario está en buen estado'
|
||||
: 'Prueba con un filtro diferente'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors ${getAlertClasses(alert)}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* Selection checkbox */}
|
||||
{!alert.is_acknowledged && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAlerts.has(alert.id)}
|
||||
onChange={() => toggleAlertSelection(alert.id)}
|
||||
className="mt-1 w-4 h-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Alert Icon */}
|
||||
<div className={`mt-0.5 ${getAlertIconColor(alert)}`}>
|
||||
{getAlertIcon(alert)}
|
||||
</div>
|
||||
|
||||
{/* Alert Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className={`font-medium ${getAlertTextColor(alert)}`}>
|
||||
{alert.item?.name || 'Producto desconocido'}
|
||||
</h4>
|
||||
<p className={`text-sm mt-1 ${getAlertTextColor(alert)}`}>
|
||||
{alert.message}
|
||||
</p>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="flex items-center space-x-4 mt-2 text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>{formatTimeAgo(alert.created_at)}</span>
|
||||
</div>
|
||||
|
||||
{alert.threshold_value && alert.current_value && (
|
||||
<span>
|
||||
Umbral: {alert.threshold_value} | Actual: {alert.current_value}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="capitalize">
|
||||
Severidad: {alert.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Acknowledged Info */}
|
||||
{alert.is_acknowledged && alert.acknowledged_at && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<span>✓ Confirmada {formatTimeAgo(alert.acknowledged_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{onViewItem && alert.item_id && (
|
||||
<button
|
||||
onClick={() => onViewItem(alert.item_id)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Ver producto
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!alert.is_acknowledged && onAcknowledge && (
|
||||
<button
|
||||
onClick={() => onAcknowledge(alert.id)}
|
||||
className="text-green-600 hover:text-green-800 text-sm font-medium"
|
||||
>
|
||||
Confirmar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with bulk actions */}
|
||||
{filteredAlerts.length > 0 && filterCounts.unacknowledged > 0 && (
|
||||
<div className="p-4 border-t bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">
|
||||
{filterCounts.unacknowledged} alertas pendientes
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onAcknowledgeAll) {
|
||||
const unacknowledgedIds = alerts
|
||||
.filter(a => !a.is_acknowledged)
|
||||
.map(a => a.id);
|
||||
onAcknowledgeAll(unacknowledgedIds);
|
||||
}
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Confirmar todas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockAlertsPanel;
|
||||
727
frontend/src/components/onboarding/SmartHistoricalDataImport.tsx
Normal file
727
frontend/src/components/onboarding/SmartHistoricalDataImport.tsx
Normal file
@@ -0,0 +1,727 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
Upload,
|
||||
Brain,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Loader,
|
||||
Store,
|
||||
Factory,
|
||||
Settings2,
|
||||
Package,
|
||||
Coffee,
|
||||
Wheat,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
Lightbulb
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import {
|
||||
OnboardingAnalysisResult,
|
||||
InventorySuggestion,
|
||||
BusinessModelAnalysis,
|
||||
InventoryCreationResult,
|
||||
SalesImportResult,
|
||||
onboardingService
|
||||
} from '../../api/services/onboarding.service';
|
||||
|
||||
interface SmartHistoricalDataImportProps {
|
||||
tenantId: string;
|
||||
onComplete: (result: SalesImportResult) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
type ImportPhase = 'upload' | 'analysis' | 'review' | 'creation' | 'import' | 'complete';
|
||||
|
||||
interface PhaseState {
|
||||
phase: ImportPhase;
|
||||
file?: File;
|
||||
analysisResult?: OnboardingAnalysisResult;
|
||||
reviewedSuggestions?: InventorySuggestion[];
|
||||
creationResult?: InventoryCreationResult;
|
||||
importResult?: SalesImportResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SmartHistoricalDataImport: React.FC<SmartHistoricalDataImportProps> = ({
|
||||
tenantId,
|
||||
onComplete,
|
||||
onBack
|
||||
}) => {
|
||||
const [state, setState] = useState<PhaseState>({ phase: 'upload' });
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [showAllSuggestions, setShowAllSuggestions] = useState(false);
|
||||
|
||||
const handleFileUpload = useCallback(async (file: File) => {
|
||||
setState(prev => ({ ...prev, file, phase: 'analysis' }));
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
toast.loading('🧠 Analizando tu archivo con IA...', { id: 'analysis' });
|
||||
|
||||
const analysisResult = await onboardingService.analyzeSalesDataForOnboarding(tenantId, file);
|
||||
|
||||
toast.success(`¡Análisis completado! ${analysisResult.total_products_found} productos encontrados`, {
|
||||
id: 'analysis'
|
||||
});
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
analysisResult,
|
||||
reviewedSuggestions: analysisResult.inventory_suggestions.map(s => ({
|
||||
...s,
|
||||
user_approved: s.confidence_score >= 0.7
|
||||
})),
|
||||
phase: 'review'
|
||||
}));
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error('Error al analizar el archivo', { id: 'analysis' });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: error.message || 'Error desconocido',
|
||||
phase: 'upload'
|
||||
}));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
const handleSuggestionUpdate = useCallback((suggestionId: string, updates: Partial<InventorySuggestion>) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewedSuggestions: prev.reviewedSuggestions?.map(s =>
|
||||
s.suggestion_id === suggestionId ? { ...s, ...updates } : s
|
||||
)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleCreateInventory = useCallback(async () => {
|
||||
if (!state.reviewedSuggestions) return;
|
||||
|
||||
setState(prev => ({ ...prev, phase: 'creation' }));
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
const approvedSuggestions = state.reviewedSuggestions.filter(s => s.user_approved);
|
||||
|
||||
if (approvedSuggestions.length === 0) {
|
||||
toast.error('Debes aprobar al menos un producto para continuar');
|
||||
setState(prev => ({ ...prev, phase: 'review' }));
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.loading(`Creando ${approvedSuggestions.length} productos en tu inventario...`, { id: 'creation' });
|
||||
|
||||
const creationResult = await onboardingService.createInventoryFromSuggestions(
|
||||
tenantId,
|
||||
approvedSuggestions
|
||||
);
|
||||
|
||||
toast.success(`¡${creationResult.created_items.length} productos creados exitosamente!`, {
|
||||
id: 'creation'
|
||||
});
|
||||
|
||||
setState(prev => ({ ...prev, creationResult, phase: 'import' }));
|
||||
|
||||
// Auto-proceed to final import
|
||||
setTimeout(() => handleFinalImport(creationResult), 1500);
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error('Error al crear productos en inventario', { id: 'creation' });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: error.message || 'Error al crear inventario',
|
||||
phase: 'review'
|
||||
}));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [state.reviewedSuggestions, tenantId]);
|
||||
|
||||
const handleFinalImport = useCallback(async (creationResult?: InventoryCreationResult) => {
|
||||
if (!state.file || !state.reviewedSuggestions) return;
|
||||
|
||||
const currentCreationResult = creationResult || state.creationResult;
|
||||
if (!currentCreationResult) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Create mapping from product names to inventory IDs
|
||||
const inventoryMapping: Record<string, string> = {};
|
||||
|
||||
currentCreationResult.created_items.forEach(item => {
|
||||
// Find the original suggestion that created this item
|
||||
const suggestion = state.reviewedSuggestions!.find(s =>
|
||||
s.suggested_name === item.name || s.original_name === item.original_name
|
||||
);
|
||||
|
||||
if (suggestion) {
|
||||
inventoryMapping[suggestion.original_name] = item.id;
|
||||
}
|
||||
});
|
||||
|
||||
toast.loading('Importando datos históricos con inventario...', { id: 'import' });
|
||||
|
||||
const importResult = await onboardingService.importSalesWithInventory(
|
||||
tenantId,
|
||||
state.file,
|
||||
inventoryMapping
|
||||
);
|
||||
|
||||
toast.success(
|
||||
`¡Importación completada! ${importResult.successful_imports} registros importados`,
|
||||
{ id: 'import' }
|
||||
);
|
||||
|
||||
setState(prev => ({ ...prev, importResult, phase: 'complete' }));
|
||||
|
||||
// Complete the process
|
||||
setTimeout(() => onComplete(importResult), 2000);
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error('Error en importación final', { id: 'import' });
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: error.message || 'Error en importación final',
|
||||
phase: 'creation'
|
||||
}));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [state.file, state.reviewedSuggestions, state.creationResult, tenantId, onComplete]);
|
||||
|
||||
const renderBusinessModelInsight = (analysis: BusinessModelAnalysis) => {
|
||||
const modelConfig = {
|
||||
production: {
|
||||
icon: Factory,
|
||||
title: 'Panadería de Producción',
|
||||
description: 'Produces items from raw ingredients',
|
||||
color: 'blue',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
textColor: 'text-blue-900'
|
||||
},
|
||||
retail: {
|
||||
icon: Store,
|
||||
title: 'Panadería de Distribución',
|
||||
description: 'Sells finished products from suppliers',
|
||||
color: 'green',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-900'
|
||||
},
|
||||
hybrid: {
|
||||
icon: Settings2,
|
||||
title: 'Modelo Híbrido',
|
||||
description: 'Both produces and distributes products',
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-50',
|
||||
borderColor: 'border-purple-200',
|
||||
textColor: 'text-purple-900'
|
||||
}
|
||||
};
|
||||
|
||||
const config = modelConfig[analysis.model];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className={`${config.bgColor} ${config.borderColor} border rounded-xl p-6 mb-6`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className={`w-12 h-12 ${config.bgColor} rounded-lg flex items-center justify-center`}>
|
||||
<IconComponent className={`w-6 h-6 text-${config.color}-600`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className={`font-semibold ${config.textColor}`}>{config.title}</h3>
|
||||
<span className={`px-3 py-1 bg-white rounded-full text-sm font-medium text-${config.color}-600`}>
|
||||
{Math.round(analysis.confidence * 100)}% confianza
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm ${config.textColor} mb-3`}>{config.description}</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wheat className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{analysis.ingredient_count} ingredientes
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Coffee className="w-4 h-4 text-brown-500" />
|
||||
<span className="text-sm text-gray-700">
|
||||
{analysis.finished_product_count} productos finales
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className={`text-sm font-medium ${config.textColor} mb-2`}>
|
||||
Recomendaciones personalizadas:
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{analysis.recommendations.slice(0, 2).map((rec, idx) => (
|
||||
<li key={idx} className={`text-sm ${config.textColor} flex items-center space-x-2`}>
|
||||
<Lightbulb className="w-3 h-3" />
|
||||
<span>{rec}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSuggestionCard = (suggestion: InventorySuggestion) => {
|
||||
const isHighConfidence = suggestion.confidence_score >= 0.7;
|
||||
const isMediumConfidence = suggestion.confidence_score >= 0.4;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={suggestion.suggestion_id}
|
||||
className={`border rounded-lg p-4 transition-all ${
|
||||
suggestion.user_approved
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => handleSuggestionUpdate(suggestion.suggestion_id, {
|
||||
user_approved: !suggestion.user_approved
|
||||
})}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
|
||||
suggestion.user_approved
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-gray-300 hover:border-green-300'
|
||||
}`}
|
||||
>
|
||||
{suggestion.user_approved && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{suggestion.suggested_name}</h4>
|
||||
{suggestion.original_name !== suggestion.suggested_name && (
|
||||
<p className="text-sm text-gray-500">"{suggestion.original_name}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
isHighConfidence ? 'bg-green-100 text-green-800' :
|
||||
isMediumConfidence ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(suggestion.confidence_score * 100)}% confianza
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Tipo:</span>
|
||||
<span className="ml-2 font-medium">
|
||||
{suggestion.product_type === 'ingredient' ? 'Ingrediente' : 'Producto Final'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Categoría:</span>
|
||||
<span className="ml-2 font-medium">{suggestion.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Unidad:</span>
|
||||
<span className="ml-2 font-medium">{suggestion.unit_of_measure}</span>
|
||||
</div>
|
||||
{suggestion.estimated_shelf_life_days && (
|
||||
<div>
|
||||
<span className="text-gray-500">Duración:</span>
|
||||
<span className="ml-2 font-medium">{suggestion.estimated_shelf_life_days} días</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(suggestion.requires_refrigeration || suggestion.requires_freezing || suggestion.is_seasonal) && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{suggestion.requires_refrigeration && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
❄️ Refrigeración
|
||||
</span>
|
||||
)}
|
||||
{suggestion.requires_freezing && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
🧊 Congelación
|
||||
</span>
|
||||
)}
|
||||
{suggestion.is_seasonal && (
|
||||
<span className="px-2 py-1 bg-orange-100 text-orange-800 text-xs rounded-full">
|
||||
🍂 Estacional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isHighConfidence && suggestion.notes && (
|
||||
<div className="mt-3 p-2 bg-amber-50 border border-amber-200 rounded text-xs text-amber-800">
|
||||
💡 {suggestion.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main render logic based on current phase
|
||||
switch (state.phase) {
|
||||
case 'upload':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Brain className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Importación Inteligente de Datos
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Nuestra IA analizará tus datos históricos y creará automáticamente tu inventario
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
🚀 ¿Cómo funciona la magia?
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Upload className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">1. Subes tu archivo</div>
|
||||
<div className="text-sm text-gray-600 mt-1">CSV, Excel o JSON</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Brain className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">2. IA analiza productos</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Clasificación inteligente</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">3. Inventario listo</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Con categorías y detalles</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center hover:border-blue-300 transition-colors">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<label htmlFor="smart-upload" className="cursor-pointer">
|
||||
<span className="text-lg font-medium text-gray-900 block mb-2">
|
||||
Sube tu archivo de datos históricos
|
||||
</span>
|
||||
<span className="text-gray-600">
|
||||
Arrastra tu archivo aquí o haz clic para seleccionar
|
||||
</span>
|
||||
<span className="block text-sm text-gray-400 mt-2">
|
||||
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="smart-upload"
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls,.json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('El archivo es demasiado grande. Máximo 10MB.');
|
||||
return;
|
||||
}
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex">
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{state.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'analysis':
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
||||
<Brain className="w-10 h-10 text-white" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">
|
||||
🧠 Analizando tu archivo con IA...
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Esto puede tomar unos momentos mientras clasificamos tus productos
|
||||
</p>
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 max-w-md mx-auto">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Archivo:</span>
|
||||
<span className="font-medium">{state.file?.name}</span>
|
||||
</div>
|
||||
<div className="mt-2 bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-purple-500 h-2 rounded-full w-1/2 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'review':
|
||||
if (!state.analysisResult) return null;
|
||||
|
||||
const { analysisResult, reviewedSuggestions } = state;
|
||||
const approvedCount = reviewedSuggestions?.filter(s => s.user_approved).length || 0;
|
||||
const highConfidenceCount = reviewedSuggestions?.filter(s => s.confidence_score >= 0.7).length || 0;
|
||||
const visibleSuggestions = showAllSuggestions
|
||||
? reviewedSuggestions
|
||||
: reviewedSuggestions?.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
¡Análisis Completado! 🎉
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Hemos encontrado <strong>{analysisResult.total_products_found} productos</strong> y
|
||||
sugerimos <strong>{approvedCount} para tu inventario</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderBusinessModelInsight(analysisResult.business_model_analysis)}
|
||||
|
||||
<div className="bg-white border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Productos Sugeridos para tu Inventario
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{highConfidenceCount} con alta confianza • {approvedCount} pre-aprobados
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const allApproved = approvedCount === reviewedSuggestions?.length;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewedSuggestions: prev.reviewedSuggestions?.map(s => ({
|
||||
...s,
|
||||
user_approved: !allApproved
|
||||
}))
|
||||
}));
|
||||
}}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
{approvedCount === reviewedSuggestions?.length ? 'Desaprobar todos' : 'Aprobar todos'}
|
||||
</button>
|
||||
|
||||
{(reviewedSuggestions?.length || 0) > 6 && (
|
||||
<button
|
||||
onClick={() => setShowAllSuggestions(!showAllSuggestions)}
|
||||
className="flex items-center px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
>
|
||||
{showAllSuggestions ? (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4 mr-1" />
|
||||
Ver menos
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Ver todos ({reviewedSuggestions?.length})
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 mb-6">
|
||||
{visibleSuggestions?.map(renderSuggestionCard)}
|
||||
</div>
|
||||
|
||||
{analysisResult.warnings.length > 0 && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-400" />
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-amber-800">Advertencias</h4>
|
||||
<ul className="mt-2 text-sm text-amber-700 space-y-1">
|
||||
{analysisResult.warnings.map((warning, idx) => (
|
||||
<li key={idx}>• {warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCreateInventory}
|
||||
disabled={approvedCount === 0 || isProcessing}
|
||||
className="flex items-center px-6 py-3 bg-gradient-to-r from-green-500 to-blue-500 text-white rounded-xl hover:from-green-600 hover:to-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 mr-2 animate-spin" />
|
||||
Creando inventario...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Crear inventario ({approvedCount} productos)
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'creation':
|
||||
case 'import':
|
||||
const isCreating = state.phase === 'creation';
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-20 h-20 bg-gradient-to-r from-green-400 to-blue-500 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
||||
{isCreating ? (
|
||||
<Package className="w-10 h-10 text-white" />
|
||||
) : (
|
||||
<Upload className="w-10 h-10 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">
|
||||
{isCreating ? '📦 Creando productos en tu inventario...' : '📊 Importando datos históricos...'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{isCreating
|
||||
? 'Configurando cada producto con sus detalles específicos'
|
||||
: 'Vinculando tus ventas históricas con el nuevo inventario'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm p-6 max-w-md mx-auto">
|
||||
{state.creationResult && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-center space-x-2 text-green-600 mb-2">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span className="font-medium">
|
||||
{state.creationResult.created_items.length} productos creados
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-200 rounded-full h-3">
|
||||
<div className="bg-gradient-to-r from-green-400 to-blue-500 h-3 rounded-full w-3/4 animate-pulse"></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{isCreating ? 'Creando inventario...' : 'Procesando importación final...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'complete':
|
||||
if (!state.importResult || !state.creationResult) return null;
|
||||
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-24 h-24 bg-gradient-to-r from-green-400 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce">
|
||||
<CheckCircle2 className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
¡Importación Completada! 🎉
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 mb-8">
|
||||
Tu inventario inteligente está listo
|
||||
</p>
|
||||
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 rounded-3xl p-8 max-w-2xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-green-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{state.creationResult.created_items.length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Productos en inventario</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Upload className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{state.importResult.successful_imports}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Registros históricos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
✨ Tu IA está lista para predecir la demanda con precisión
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SmartHistoricalDataImport;
|
||||
323
frontend/src/components/recipes/IngredientList.tsx
Normal file
323
frontend/src/components/recipes/IngredientList.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
// frontend/src/components/recipes/IngredientList.tsx
|
||||
import React from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Minus,
|
||||
Edit2,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Info,
|
||||
AlertCircle,
|
||||
Package,
|
||||
Droplets,
|
||||
Scale,
|
||||
Euro
|
||||
} from 'lucide-react';
|
||||
|
||||
import { RecipeIngredient } from '../../api/services/recipes.service';
|
||||
|
||||
interface IngredientListProps {
|
||||
ingredients: RecipeIngredient[];
|
||||
editable?: boolean;
|
||||
showCosts?: boolean;
|
||||
showGroups?: boolean;
|
||||
batchMultiplier?: number;
|
||||
onAddIngredient?: () => void;
|
||||
onEditIngredient?: (ingredient: RecipeIngredient) => void;
|
||||
onRemoveIngredient?: (ingredientId: string) => void;
|
||||
onReorderIngredients?: (ingredients: RecipeIngredient[]) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const IngredientList: React.FC<IngredientListProps> = ({
|
||||
ingredients,
|
||||
editable = false,
|
||||
showCosts = false,
|
||||
showGroups = true,
|
||||
batchMultiplier = 1,
|
||||
onAddIngredient,
|
||||
onEditIngredient,
|
||||
onRemoveIngredient,
|
||||
onReorderIngredients,
|
||||
className = ''
|
||||
}) => {
|
||||
// Group ingredients by ingredient_group
|
||||
const groupedIngredients = React.useMemo(() => {
|
||||
if (!showGroups) {
|
||||
return { 'All Ingredients': ingredients };
|
||||
}
|
||||
|
||||
const groups: Record<string, RecipeIngredient[]> = {};
|
||||
|
||||
ingredients.forEach(ingredient => {
|
||||
const group = ingredient.ingredient_group || 'Other';
|
||||
if (!groups[group]) {
|
||||
groups[group] = [];
|
||||
}
|
||||
groups[group].push(ingredient);
|
||||
});
|
||||
|
||||
// Sort ingredients within each group by order
|
||||
Object.keys(groups).forEach(group => {
|
||||
groups[group].sort((a, b) => a.ingredient_order - b.ingredient_order);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [ingredients, showGroups]);
|
||||
|
||||
// Get unit icon
|
||||
const getUnitIcon = (unit: string) => {
|
||||
switch (unit.toLowerCase()) {
|
||||
case 'g':
|
||||
case 'kg':
|
||||
return <Scale className="w-4 h-4" />;
|
||||
case 'ml':
|
||||
case 'l':
|
||||
return <Droplets className="w-4 h-4" />;
|
||||
case 'units':
|
||||
case 'pieces':
|
||||
case 'pcs':
|
||||
return <Package className="w-4 h-4" />;
|
||||
default:
|
||||
return <Scale className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Format quantity with multiplier
|
||||
const formatQuantity = (quantity: number, unit: string) => {
|
||||
const adjustedQuantity = quantity * batchMultiplier;
|
||||
return `${adjustedQuantity} ${unit}`;
|
||||
};
|
||||
|
||||
// Calculate total cost
|
||||
const getTotalCost = () => {
|
||||
return ingredients.reduce((total, ingredient) => {
|
||||
const cost = ingredient.total_cost || 0;
|
||||
return total + (cost * batchMultiplier);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-gray-50 rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Ingredients</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{ingredients.length} ingredient{ingredients.length !== 1 ? 's' : ''}
|
||||
{batchMultiplier !== 1 && (
|
||||
<span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||||
×{batchMultiplier} batch
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{showCosts && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">Total Cost</div>
|
||||
<div className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Euro className="w-4 h-4 mr-1" />
|
||||
{getTotalCost().toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && onAddIngredient && (
|
||||
<button
|
||||
onClick={onAddIngredient}
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Add Ingredient</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ingredients List */}
|
||||
<div className="divide-y">
|
||||
{Object.entries(groupedIngredients).map(([groupName, groupIngredients]) => (
|
||||
<div key={groupName}>
|
||||
{/* Group Header */}
|
||||
{showGroups && Object.keys(groupedIngredients).length > 1 && (
|
||||
<div className="px-4 py-2 bg-gray-25 border-b">
|
||||
<h4 className="text-sm font-medium text-gray-700 uppercase tracking-wide">
|
||||
{groupName}
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group Ingredients */}
|
||||
{groupIngredients.map((ingredient, index) => (
|
||||
<div
|
||||
key={ingredient.id}
|
||||
className="p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Drag Handle */}
|
||||
{editable && onReorderIngredients && (
|
||||
<div className="cursor-move text-gray-400 hover:text-gray-600">
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order Number */}
|
||||
<div className="w-6 h-6 bg-gray-200 rounded-full flex items-center justify-center text-xs font-medium text-gray-600">
|
||||
{ingredient.ingredient_order}
|
||||
</div>
|
||||
|
||||
{/* Ingredient Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{ingredient.ingredient_id} {/* This would be ingredient name from inventory */}
|
||||
</h4>
|
||||
|
||||
{ingredient.is_optional && (
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
{getUnitIcon(ingredient.unit)}
|
||||
<span className="font-medium">
|
||||
{formatQuantity(ingredient.quantity, ingredient.unit)}
|
||||
</span>
|
||||
|
||||
{ingredient.alternative_quantity && ingredient.alternative_unit && (
|
||||
<span className="text-gray-500">
|
||||
(≈ {formatQuantity(ingredient.alternative_quantity, ingredient.alternative_unit)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preparation Method */}
|
||||
{ingredient.preparation_method && (
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
<span className="font-medium">Prep:</span> {ingredient.preparation_method}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{ingredient.ingredient_notes && (
|
||||
<div className="text-sm text-gray-600 mt-1 flex items-start">
|
||||
<Info className="w-3 h-3 mr-1 mt-0.5 flex-shrink-0" />
|
||||
<span>{ingredient.ingredient_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Substitutions */}
|
||||
{ingredient.substitution_options && (
|
||||
<div className="text-sm text-blue-600 mt-1">
|
||||
<span className="font-medium">Substitutions available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost */}
|
||||
{showCosts && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
€{((ingredient.total_cost || 0) * batchMultiplier).toFixed(2)}
|
||||
</div>
|
||||
{ingredient.unit_cost && (
|
||||
<div className="text-xs text-gray-600">
|
||||
€{ingredient.unit_cost.toFixed(2)}/{ingredient.unit}
|
||||
</div>
|
||||
)}
|
||||
{ingredient.cost_updated_at && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{new Date(ingredient.cost_updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{editable && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onEditIngredient?.(ingredient)}
|
||||
className="p-1 text-gray-400 hover:text-blue-600 transition-colors"
|
||||
title="Edit ingredient"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onRemoveIngredient?.(ingredient.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600 transition-colors"
|
||||
title="Remove ingredient"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{ingredients.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No ingredients yet</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Add ingredients to start building your recipe
|
||||
</p>
|
||||
{editable && onAddIngredient && (
|
||||
<button
|
||||
onClick={onAddIngredient}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Add First Ingredient
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{ingredients.length > 0 && (
|
||||
<div className="p-4 bg-gray-50 border-t rounded-b-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-600">
|
||||
{ingredients.length} total ingredients
|
||||
</span>
|
||||
|
||||
{ingredients.filter(i => i.is_optional).length > 0 && (
|
||||
<span className="text-yellow-600">
|
||||
{ingredients.filter(i => i.is_optional).length} optional
|
||||
</span>
|
||||
)}
|
||||
|
||||
{ingredients.some(i => i.substitution_options) && (
|
||||
<span className="text-blue-600">
|
||||
{ingredients.filter(i => i.substitution_options).length} with substitutions
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCosts && (
|
||||
<div className="font-medium text-gray-900">
|
||||
Total: €{getTotalCost().toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngredientList;
|
||||
547
frontend/src/components/recipes/ProductionBatchCard.tsx
Normal file
547
frontend/src/components/recipes/ProductionBatchCard.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
// frontend/src/components/recipes/ProductionBatchCard.tsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
Play,
|
||||
Pause,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
BarChart3,
|
||||
Thermometer,
|
||||
Target,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar,
|
||||
Package,
|
||||
Star,
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit,
|
||||
Euro
|
||||
} from 'lucide-react';
|
||||
|
||||
import { ProductionBatch } from '../../api/services/recipes.service';
|
||||
|
||||
interface ProductionBatchCardProps {
|
||||
batch: ProductionBatch;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
onView?: (batch: ProductionBatch) => void;
|
||||
onEdit?: (batch: ProductionBatch) => void;
|
||||
onStart?: (batch: ProductionBatch) => void;
|
||||
onComplete?: (batch: ProductionBatch) => void;
|
||||
onCancel?: (batch: ProductionBatch) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ProductionBatchCard: React.FC<ProductionBatchCardProps> = ({
|
||||
batch,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
onView,
|
||||
onEdit,
|
||||
onStart,
|
||||
onComplete,
|
||||
onCancel,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
// Status styling
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'in_progress':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
// Priority styling
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'normal':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'low':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// Status icon
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return <Clock className="w-4 h-4" />;
|
||||
case 'in_progress':
|
||||
return <Play className="w-4 h-4" />;
|
||||
case 'completed':
|
||||
return <CheckCircle className="w-4 h-4" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4" />;
|
||||
case 'cancelled':
|
||||
return <Pause className="w-4 h-4" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
return new Date(dateString).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Calculate progress percentage
|
||||
const getProgressPercentage = () => {
|
||||
if (batch.status === 'completed') return 100;
|
||||
if (batch.status === 'failed' || batch.status === 'cancelled') return 0;
|
||||
if (batch.status === 'in_progress') {
|
||||
// Calculate based on time if available
|
||||
if (batch.actual_start_time && batch.planned_end_time) {
|
||||
const start = new Date(batch.actual_start_time).getTime();
|
||||
const end = new Date(batch.planned_end_time).getTime();
|
||||
const now = Date.now();
|
||||
const progress = ((now - start) / (end - start)) * 100;
|
||||
return Math.min(Math.max(progress, 0), 100);
|
||||
}
|
||||
return 50; // Default for in progress
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const progress = getProgressPercentage();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 flex-1 min-w-0">
|
||||
{/* Batch Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="font-medium text-gray-900">{batch.batch_number}</h3>
|
||||
{batch.priority !== 'normal' && (
|
||||
<span className={`px-2 py-1 rounded text-xs ${getPriorityColor(batch.priority)}`}>
|
||||
{batch.priority}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-sm text-gray-600">
|
||||
<span className={`px-2 py-1 rounded-full text-xs border flex items-center ${getStatusColor(batch.status)}`}>
|
||||
{getStatusIcon(batch.status)}
|
||||
<span className="ml-1">{batch.status}</span>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{new Date(batch.production_date).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Package className="w-3 h-3 mr-1" />
|
||||
{batch.planned_quantity} units
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-24">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mb-1">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
batch.status === 'completed' ? 'bg-green-500' :
|
||||
batch.status === 'failed' ? 'bg-red-500' :
|
||||
batch.status === 'in_progress' ? 'bg-yellow-500' :
|
||||
'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 text-center">
|
||||
{Math.round(progress)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Yield */}
|
||||
{batch.actual_quantity && (
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{batch.actual_quantity} / {batch.planned_quantity}
|
||||
</div>
|
||||
{batch.yield_percentage && (
|
||||
<div className={`text-xs flex items-center justify-end ${
|
||||
batch.yield_percentage >= 95 ? 'text-green-600' :
|
||||
batch.yield_percentage >= 80 ? 'text-yellow-600' :
|
||||
'text-red-600'
|
||||
}`}>
|
||||
{batch.yield_percentage >= 100 ? (
|
||||
<TrendingUp className="w-3 h-3 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
{batch.yield_percentage.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
onClick={() => onView?.(batch)}
|
||||
className="p-1 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{batch.status === 'planned' && (
|
||||
<button
|
||||
onClick={() => onStart?.(batch)}
|
||||
className="p-1 text-gray-600 hover:text-green-600"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{batch.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onComplete?.(batch)}
|
||||
className="p-1 text-gray-600 hover:text-green-600"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border shadow-sm hover:shadow-md transition-shadow ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{batch.batch_number}</h3>
|
||||
{batch.priority !== 'normal' && (
|
||||
<span className={`px-2 py-1 rounded text-sm ${getPriorityColor(batch.priority)}`}>
|
||||
{batch.priority} priority
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batch.production_notes && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2">{batch.production_notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="relative ml-4">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-8 bg-white border rounded-lg shadow-lg py-1 z-10 min-w-[140px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
onView?.(batch);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Batch
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit?.(batch);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit Batch
|
||||
</button>
|
||||
|
||||
{batch.status === 'planned' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onStart?.(batch);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-green-600"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Start Production
|
||||
</button>
|
||||
)}
|
||||
|
||||
{batch.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onComplete?.(batch);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-green-600"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Complete Batch
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(batch.status === 'planned' || batch.status === 'in_progress') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onCancel?.(batch);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-red-600"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Cancel Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className={`px-3 py-1 rounded-full text-sm border flex items-center ${getStatusColor(batch.status)}`}>
|
||||
{getStatusIcon(batch.status)}
|
||||
<span className="ml-2">{batch.status}</span>
|
||||
</span>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
{Math.round(progress)}% complete
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${
|
||||
batch.status === 'completed' ? 'bg-green-500' :
|
||||
batch.status === 'failed' ? 'bg-red-500' :
|
||||
batch.status === 'in_progress' ? 'bg-yellow-500' :
|
||||
'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{batch.actual_quantity || batch.planned_quantity} / {batch.planned_quantity}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Quantity</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{batch.yield_percentage ? (
|
||||
<>
|
||||
{batch.yield_percentage.toFixed(1)}%
|
||||
{batch.yield_percentage >= 100 ? (
|
||||
<TrendingUp className="w-4 h-4 inline ml-1 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4 inline ml-1 text-red-500" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Yield</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Information */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600 mb-4">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 mb-1">Scheduled</div>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{new Date(batch.production_date).toLocaleDateString()}
|
||||
</div>
|
||||
{batch.planned_start_time && (
|
||||
<div className="flex items-center mt-1">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
{formatTime(batch.planned_start_time)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 mb-1">Actual</div>
|
||||
{batch.actual_start_time && (
|
||||
<div className="flex items-center">
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Started {formatTime(batch.actual_start_time)}
|
||||
</div>
|
||||
)}
|
||||
{batch.actual_end_time && (
|
||||
<div className="flex items-center mt-1">
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
Completed {formatTime(batch.actual_end_time)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality & Cost */}
|
||||
{(batch.quality_score || batch.total_production_cost) && (
|
||||
<div className="grid grid-cols-2 gap-4 text-sm mb-4">
|
||||
{batch.quality_score && (
|
||||
<div className="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-blue-900 flex items-center justify-center">
|
||||
<Star className="w-4 h-4 mr-1" />
|
||||
{batch.quality_score.toFixed(1)}/10
|
||||
</div>
|
||||
<div className="text-sm text-blue-700">Quality Score</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.total_production_cost && (
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-green-900 flex items-center justify-center">
|
||||
<Euro className="w-4 h-4 mr-1" />
|
||||
{batch.total_production_cost.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-green-700">Total Cost</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staff & Environment */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{batch.assigned_staff && batch.assigned_staff.length > 0 && (
|
||||
<div className="flex items-center mb-2">
|
||||
<Users className="w-4 h-4 mr-2" />
|
||||
<span>{batch.assigned_staff.length} staff assigned</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.production_temperature && (
|
||||
<div className="flex items-center mb-2">
|
||||
<Thermometer className="w-4 h-4 mr-2" />
|
||||
<span>{batch.production_temperature}°C</span>
|
||||
{batch.production_humidity && (
|
||||
<span className="ml-2">• {batch.production_humidity}% humidity</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.efficiency_percentage && (
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
<span>
|
||||
{batch.efficiency_percentage.toFixed(1)}% efficiency
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{(batch.rework_required || (batch.defect_rate && batch.defect_rate > 0) || batch.waste_quantity > 0) && (
|
||||
<div className="mt-4 p-3 rounded-lg border border-yellow-200 bg-yellow-50">
|
||||
<div className="flex items-center text-yellow-800 text-sm font-medium mb-2">
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
Quality Issues
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-yellow-700 space-y-1">
|
||||
{batch.rework_required && (
|
||||
<div>• Rework required</div>
|
||||
)}
|
||||
|
||||
{batch.defect_rate && batch.defect_rate > 0 && (
|
||||
<div>• {batch.defect_rate.toFixed(1)}% defect rate</div>
|
||||
)}
|
||||
|
||||
{batch.waste_quantity > 0 && (
|
||||
<div>• {batch.waste_quantity} units wasted</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Footer */}
|
||||
{showActions && (
|
||||
<div className="px-6 py-4 bg-gray-50 rounded-b-xl border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onView?.(batch)}
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
|
||||
{batch.status === 'planned' && (
|
||||
<button
|
||||
onClick={() => onStart?.(batch)}
|
||||
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
>
|
||||
Start Production
|
||||
</button>
|
||||
)}
|
||||
|
||||
{batch.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => onComplete?.(batch)}
|
||||
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
>
|
||||
Complete Batch
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Updated {new Date(batch.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionBatchCard;
|
||||
445
frontend/src/components/recipes/RecipeCard.tsx
Normal file
445
frontend/src/components/recipes/RecipeCard.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
// frontend/src/components/recipes/RecipeCard.tsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
Users,
|
||||
ChefHat,
|
||||
Star,
|
||||
Eye,
|
||||
Edit,
|
||||
Copy,
|
||||
Play,
|
||||
MoreVertical,
|
||||
Leaf,
|
||||
Thermometer,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Euro
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Recipe, RecipeFeasibility } from '../../api/services/recipes.service';
|
||||
|
||||
interface RecipeCardProps {
|
||||
recipe: Recipe;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
onView?: (recipe: Recipe) => void;
|
||||
onEdit?: (recipe: Recipe) => void;
|
||||
onDuplicate?: (recipe: Recipe) => void;
|
||||
onActivate?: (recipe: Recipe) => void;
|
||||
onCheckFeasibility?: (recipe: Recipe) => void;
|
||||
feasibility?: RecipeFeasibility | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RecipeCard: React.FC<RecipeCardProps> = ({
|
||||
recipe,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
onView,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onActivate,
|
||||
onCheckFeasibility,
|
||||
feasibility,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isCheckingFeasibility, setIsCheckingFeasibility] = useState(false);
|
||||
|
||||
// Status styling
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'draft':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'testing':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'archived':
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
case 'discontinued':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
// Difficulty display
|
||||
const getDifficultyStars = (level: number) => {
|
||||
return Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-3 h-3 ${
|
||||
i < level ? 'text-yellow-400 fill-current' : 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (minutes?: number) => {
|
||||
if (!minutes) return null;
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
// Handle feasibility check
|
||||
const handleCheckFeasibility = async () => {
|
||||
if (!onCheckFeasibility) return;
|
||||
|
||||
setIsCheckingFeasibility(true);
|
||||
try {
|
||||
await onCheckFeasibility(recipe);
|
||||
} finally {
|
||||
setIsCheckingFeasibility(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4 flex-1 min-w-0">
|
||||
{/* Recipe Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="font-medium text-gray-900 truncate">{recipe.name}</h3>
|
||||
{recipe.is_signature_item && (
|
||||
<Star className="w-4 h-4 text-yellow-500 fill-current" />
|
||||
)}
|
||||
{recipe.is_seasonal && (
|
||||
<Leaf className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 text-sm text-gray-600">
|
||||
<span className={`px-2 py-1 rounded-full text-xs border ${getStatusColor(recipe.status)}`}>
|
||||
{recipe.status}
|
||||
</span>
|
||||
|
||||
{recipe.category && (
|
||||
<span className="text-gray-500">• {recipe.category}</span>
|
||||
)}
|
||||
|
||||
{recipe.total_time_minutes && (
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
{formatTime(recipe.total_time_minutes)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.serves_count && (
|
||||
<div className="flex items-center">
|
||||
<Users className="w-3 h-3 mr-1" />
|
||||
{recipe.serves_count}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost & Yield */}
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{recipe.yield_quantity} {recipe.yield_unit}
|
||||
</div>
|
||||
{recipe.last_calculated_cost && (
|
||||
<div className="text-sm text-gray-600 flex items-center">
|
||||
<Euro className="w-3 h-3 mr-1" />
|
||||
{recipe.last_calculated_cost.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
{feasibility && (
|
||||
<div className={`p-1 rounded ${
|
||||
feasibility.feasible ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{feasibility.feasible ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onView?.(recipe)}
|
||||
className="p-1 text-gray-600 hover:text-blue-600"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{recipe.status === 'active' && (
|
||||
<button
|
||||
onClick={handleCheckFeasibility}
|
||||
disabled={isCheckingFeasibility}
|
||||
className="p-1 text-gray-600 hover:text-green-600 disabled:opacity-50"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border shadow-sm hover:shadow-md transition-shadow ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{recipe.name}</h3>
|
||||
{recipe.is_signature_item && (
|
||||
<Star className="w-5 h-5 text-yellow-500 fill-current" />
|
||||
)}
|
||||
{recipe.is_seasonal && (
|
||||
<Leaf className="w-5 h-5 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recipe.description && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2">{recipe.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="relative ml-4">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-8 bg-white border rounded-lg shadow-lg py-1 z-10 min-w-[140px]">
|
||||
<button
|
||||
onClick={() => {
|
||||
onView?.(recipe);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
View Recipe
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit?.(recipe);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit Recipe
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
onDuplicate?.(recipe);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Duplicate
|
||||
</button>
|
||||
|
||||
{recipe.status === 'draft' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onActivate?.(recipe);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-green-600"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Activate
|
||||
</button>
|
||||
)}
|
||||
|
||||
{recipe.status === 'active' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleCheckFeasibility();
|
||||
setShowMenu(false);
|
||||
}}
|
||||
disabled={isCheckingFeasibility}
|
||||
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-50 flex items-center text-blue-600 disabled:opacity-50"
|
||||
>
|
||||
<TrendingUp className="w-4 h-4 mr-2" />
|
||||
Check Feasibility
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status & Category */}
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<span className={`px-3 py-1 rounded-full text-sm border ${getStatusColor(recipe.status)}`}>
|
||||
{recipe.status}
|
||||
</span>
|
||||
|
||||
{recipe.category && (
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
|
||||
{recipe.category}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center">
|
||||
{getDifficultyStars(recipe.difficulty_level)}
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
Level {recipe.difficulty_level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{recipe.yield_quantity} {recipe.yield_unit}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Yield</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{recipe.last_calculated_cost ? (
|
||||
<>€{recipe.last_calculated_cost.toFixed(2)}</>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Cost/Unit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Information */}
|
||||
{(recipe.prep_time_minutes || recipe.cook_time_minutes || recipe.total_time_minutes) && (
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600 mb-4">
|
||||
{recipe.prep_time_minutes && (
|
||||
<div className="flex items-center">
|
||||
<ChefHat className="w-4 h-4 mr-1" />
|
||||
Prep: {formatTime(recipe.prep_time_minutes)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.cook_time_minutes && (
|
||||
<div className="flex items-center">
|
||||
<Thermometer className="w-4 h-4 mr-1" />
|
||||
Cook: {formatTime(recipe.cook_time_minutes)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.total_time_minutes && (
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
Total: {formatTime(recipe.total_time_minutes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Special Properties */}
|
||||
<div className="flex items-center space-x-3 text-sm">
|
||||
{recipe.serves_count && (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Users className="w-4 h-4 mr-1" />
|
||||
Serves {recipe.serves_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.is_seasonal && (
|
||||
<div className="flex items-center text-green-600">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
Seasonal
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.optimal_production_temperature && (
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Thermometer className="w-4 h-4 mr-1" />
|
||||
{recipe.optimal_production_temperature}°C
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feasibility Status */}
|
||||
{feasibility && (
|
||||
<div className={`mt-4 p-3 rounded-lg border ${
|
||||
feasibility.feasible
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className={`flex items-center text-sm font-medium ${
|
||||
feasibility.feasible ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{feasibility.feasible ? (
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{feasibility.feasible ? 'Ready to produce' : 'Cannot produce - missing ingredients'}
|
||||
</div>
|
||||
|
||||
{!feasibility.feasible && feasibility.missing_ingredients.length > 0 && (
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
Missing: {feasibility.missing_ingredients.map(ing => ing.ingredient_name).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions Footer */}
|
||||
{showActions && (
|
||||
<div className="px-6 py-4 bg-gray-50 rounded-b-xl border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onView?.(recipe)}
|
||||
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
View Details
|
||||
</button>
|
||||
|
||||
{recipe.status === 'active' && (
|
||||
<button
|
||||
onClick={handleCheckFeasibility}
|
||||
disabled={isCheckingFeasibility}
|
||||
className="px-3 py-1 text-sm text-green-600 hover:bg-green-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isCheckingFeasibility ? 'Checking...' : 'Check Feasibility'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Updated {new Date(recipe.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecipeCard;
|
||||
487
frontend/src/components/sales/SalesAnalyticsDashboard.tsx
Normal file
487
frontend/src/components/sales/SalesAnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Calendar,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Target,
|
||||
Users,
|
||||
Clock,
|
||||
Star,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface AnalyticsFilters {
|
||||
period: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'last_year';
|
||||
channel?: string;
|
||||
product_id?: string;
|
||||
}
|
||||
|
||||
const SalesAnalyticsDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
getSalesAnalytics,
|
||||
getSalesData,
|
||||
getProductsList,
|
||||
isLoading: salesLoading,
|
||||
error: salesError
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
ingredients: products,
|
||||
loadIngredients: loadProducts,
|
||||
isLoading: inventoryLoading
|
||||
} = useInventory();
|
||||
|
||||
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||
period: 'last_30_days'
|
||||
});
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
const [salesData, setSalesData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load all analytics data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadAnalyticsData();
|
||||
}
|
||||
}, [user?.tenant_id, filters]);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [analyticsResponse, salesResponse] = await Promise.all([
|
||||
getSalesAnalytics(user.tenant_id, getDateRange().start, getDateRange().end),
|
||||
getSalesData(user.tenant_id, {
|
||||
tenant_id: user.tenant_id,
|
||||
start_date: getDateRange().start,
|
||||
end_date: getDateRange().end,
|
||||
limit: 1000
|
||||
}),
|
||||
loadProducts()
|
||||
]);
|
||||
|
||||
setAnalytics(analyticsResponse);
|
||||
setSalesData(salesResponse);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get date range for filters
|
||||
const getDateRange = () => {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
|
||||
switch (filters.period) {
|
||||
case 'last_7_days':
|
||||
start.setDate(end.getDate() - 7);
|
||||
break;
|
||||
case 'last_30_days':
|
||||
start.setDate(end.getDate() - 30);
|
||||
break;
|
||||
case 'last_90_days':
|
||||
start.setDate(end.getDate() - 90);
|
||||
break;
|
||||
case 'last_year':
|
||||
start.setFullYear(end.getFullYear() - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
start: start.toISOString().split('T')[0],
|
||||
end: end.toISOString().split('T')[0]
|
||||
};
|
||||
};
|
||||
|
||||
// Period options
|
||||
const periodOptions = [
|
||||
{ value: 'last_7_days', label: 'Últimos 7 días' },
|
||||
{ value: 'last_30_days', label: 'Últimos 30 días' },
|
||||
{ value: 'last_90_days', label: 'Últimos 90 días' },
|
||||
{ value: 'last_year', label: 'Último año' }
|
||||
];
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Calculate advanced metrics
|
||||
const advancedMetrics = useMemo(() => {
|
||||
if (!salesData.length) return null;
|
||||
|
||||
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalUnits = salesData.reduce((sum, sale) => sum + sale.quantity_sold, 0);
|
||||
const avgOrderValue = salesData.length > 0 ? totalRevenue / salesData.length : 0;
|
||||
|
||||
// Channel distribution
|
||||
const channelDistribution = salesData.reduce((acc, sale) => {
|
||||
acc[sale.sales_channel] = (acc[sale.sales_channel] || 0) + sale.revenue;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Product performance
|
||||
const productPerformance = salesData.reduce((acc, sale) => {
|
||||
const key = sale.inventory_product_id;
|
||||
if (!acc[key]) {
|
||||
acc[key] = { revenue: 0, units: 0, orders: 0 };
|
||||
}
|
||||
acc[key].revenue += sale.revenue;
|
||||
acc[key].units += sale.quantity_sold;
|
||||
acc[key].orders += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
// Top products
|
||||
const topProducts = Object.entries(productPerformance)
|
||||
.map(([productId, data]) => ({
|
||||
productId,
|
||||
...data as any,
|
||||
avgPrice: data.revenue / data.units
|
||||
}))
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 5);
|
||||
|
||||
// Daily trends
|
||||
const dailyTrends = salesData.reduce((acc, sale) => {
|
||||
const date = sale.date.split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { revenue: 0, units: 0, orders: 0 };
|
||||
}
|
||||
acc[date].revenue += sale.revenue;
|
||||
acc[date].units += sale.quantity_sold;
|
||||
acc[date].orders += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalUnits,
|
||||
avgOrderValue,
|
||||
totalOrders: salesData.length,
|
||||
channelDistribution,
|
||||
topProducts,
|
||||
dailyTrends
|
||||
};
|
||||
}, [salesData]);
|
||||
|
||||
// Key performance indicators
|
||||
const kpis = useMemo(() => {
|
||||
if (!advancedMetrics) return [];
|
||||
|
||||
const growth = Math.random() * 20 - 10; // Mock growth calculation
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Ingresos Totales',
|
||||
value: formatCurrency(advancedMetrics.totalRevenue),
|
||||
change: `${growth > 0 ? '+' : ''}${growth.toFixed(1)}%`,
|
||||
changeType: growth > 0 ? 'positive' as const : 'negative' as const,
|
||||
icon: DollarSign,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: 'Pedidos Totales',
|
||||
value: advancedMetrics.totalOrders.toString(),
|
||||
change: '+5.2%',
|
||||
changeType: 'positive' as const,
|
||||
icon: ShoppingCart,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Valor Promedio Pedido',
|
||||
value: formatCurrency(advancedMetrics.avgOrderValue),
|
||||
change: '+2.8%',
|
||||
changeType: 'positive' as const,
|
||||
icon: Target,
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
title: 'Unidades Vendidas',
|
||||
value: advancedMetrics.totalUnits.toString(),
|
||||
change: '+8.1%',
|
||||
changeType: 'positive' as const,
|
||||
icon: Package,
|
||||
color: 'orange'
|
||||
}
|
||||
];
|
||||
}, [advancedMetrics]);
|
||||
|
||||
if (isLoading) {
|
||||
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">Panel de Análisis de Ventas</h1>
|
||||
<p className="text-gray-600">Insights detallados sobre el rendimiento de ventas</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filters.period}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{periodOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadAnalyticsData}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpis.map((kpi, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600 mb-1">{kpi.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mb-2">{kpi.value}</p>
|
||||
<div className={`flex items-center space-x-1 text-sm ${
|
||||
kpi.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{kpi.changeType === 'positive' ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<span>{kpi.change}</span>
|
||||
<span className="text-gray-500">vs período anterior</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
kpi.color === 'blue' ? 'bg-blue-100' :
|
||||
kpi.color === 'green' ? 'bg-green-100' :
|
||||
kpi.color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-orange-100'
|
||||
}`}>
|
||||
<kpi.icon className={`w-6 h-6 ${
|
||||
kpi.color === 'blue' ? 'text-blue-600' :
|
||||
kpi.color === 'green' ? 'text-green-600' :
|
||||
kpi.color === 'purple' ? 'text-purple-600' :
|
||||
'text-orange-600'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts and Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
{advancedMetrics?.topProducts && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Productos Más Vendidos
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{advancedMetrics.topProducts.map((product: any, index: number) => {
|
||||
const inventoryProduct = products.find((p: any) => p.id === product.productId);
|
||||
|
||||
return (
|
||||
<div key={product.productId} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index < 3 ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{inventoryProduct?.name || `Producto ${product.productId.slice(0, 8)}...`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{product.units} unidades • {product.orders} pedidos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(product.revenue)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatCurrency(product.avgPrice)} avg
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Channel Distribution */}
|
||||
{advancedMetrics?.channelDistribution && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
|
||||
Ventas por Canal
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(advancedMetrics.channelDistribution).map(([channel, revenue], index) => {
|
||||
const percentage = (revenue as number / advancedMetrics.totalRevenue * 100);
|
||||
const channelLabels: Record<string, string> = {
|
||||
'in_store': 'Tienda',
|
||||
'online': 'Online',
|
||||
'delivery': 'Delivery'
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={channel} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'
|
||||
][index % 5]
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{channelLabels[channel] || channel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{formatCurrency(revenue as number)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{percentage.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights and Recommendations */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Target className="w-5 h-5 text-indigo-500 mr-2" />
|
||||
Insights y Recomendaciones
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Performance insights */}
|
||||
{advancedMetrics && advancedMetrics.avgOrderValue > 15 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
|
||||
<TrendingUp className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900">
|
||||
Excelente valor promedio de pedido
|
||||
</p>
|
||||
<p className="text-xs text-green-800">
|
||||
Con {formatCurrency(advancedMetrics.avgOrderValue)} por pedido, estás por encima del promedio.
|
||||
Considera estrategias de up-selling para mantener esta tendencia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{advancedMetrics && advancedMetrics.totalOrders < 10 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-900">
|
||||
Volumen de pedidos bajo
|
||||
</p>
|
||||
<p className="text-xs text-yellow-800">
|
||||
Solo {advancedMetrics.totalOrders} pedidos en el período.
|
||||
Considera estrategias de marketing para aumentar el tráfico.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
|
||||
<BarChart3 className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
Oportunidad de diversificación
|
||||
</p>
|
||||
<p className="text-xs text-blue-800">
|
||||
Analiza los productos de menor rendimiento para optimizar tu catálogo
|
||||
o considera promociones específicas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsDashboard;
|
||||
353
frontend/src/components/sales/SalesDashboardWidget.tsx
Normal file
353
frontend/src/components/sales/SalesDashboardWidget.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
ShoppingCart,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Package,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SalesDashboardWidgetProps {
|
||||
onViewAll?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SalesDashboardWidget: React.FC<SalesDashboardWidgetProps> = ({
|
||||
onViewAll,
|
||||
compact = false
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
salesData,
|
||||
getSalesData,
|
||||
getSalesAnalytics,
|
||||
isLoading,
|
||||
error
|
||||
} = useSales();
|
||||
|
||||
const [realtimeStats, setRealtimeStats] = useState<any>(null);
|
||||
const [todaysSales, setTodaysSales] = useState<any[]>([]);
|
||||
|
||||
// Load real-time sales data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadRealtimeData();
|
||||
|
||||
// Set up polling for real-time updates every 30 seconds
|
||||
const interval = setInterval(loadRealtimeData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
const loadRealtimeData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Get today's sales data
|
||||
const todayData = await getSalesData(user.tenant_id, {
|
||||
tenant_id: user.tenant_id,
|
||||
start_date: today,
|
||||
end_date: today,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
setTodaysSales(todayData);
|
||||
|
||||
// Get analytics for today
|
||||
const analytics = await getSalesAnalytics(user.tenant_id, today, today);
|
||||
setRealtimeStats(analytics);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading realtime sales data:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate today's metrics
|
||||
const todaysMetrics = useMemo(() => {
|
||||
if (!todaysSales.length) {
|
||||
return {
|
||||
totalRevenue: 0,
|
||||
totalOrders: 0,
|
||||
avgOrderValue: 0,
|
||||
topProduct: null,
|
||||
hourlyTrend: []
|
||||
};
|
||||
}
|
||||
|
||||
const totalRevenue = todaysSales.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalOrders = todaysSales.length;
|
||||
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||
|
||||
// Find top selling product
|
||||
const productSales: Record<string, { revenue: number; count: number }> = {};
|
||||
todaysSales.forEach(sale => {
|
||||
if (!productSales[sale.inventory_product_id]) {
|
||||
productSales[sale.inventory_product_id] = { revenue: 0, count: 0 };
|
||||
}
|
||||
productSales[sale.inventory_product_id].revenue += sale.revenue;
|
||||
productSales[sale.inventory_product_id].count += 1;
|
||||
});
|
||||
|
||||
const topProduct = Object.entries(productSales)
|
||||
.sort(([,a], [,b]) => b.revenue - a.revenue)[0];
|
||||
|
||||
// Calculate hourly trend (last 6 hours)
|
||||
const now = new Date();
|
||||
const hourlyTrend = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const hour = new Date(now.getTime() - i * 60 * 60 * 1000);
|
||||
const hourSales = todaysSales.filter(sale => {
|
||||
const saleHour = new Date(sale.date).getHours();
|
||||
return saleHour === hour.getHours();
|
||||
});
|
||||
|
||||
hourlyTrend.push({
|
||||
hour: hour.getHours(),
|
||||
revenue: hourSales.reduce((sum, sale) => sum + sale.revenue, 0),
|
||||
orders: hourSales.length
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalOrders,
|
||||
avgOrderValue,
|
||||
topProduct,
|
||||
hourlyTrend
|
||||
};
|
||||
}, [todaysSales]);
|
||||
|
||||
// Get recent sales for display
|
||||
const recentSales = useMemo(() => {
|
||||
return todaysSales
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 3);
|
||||
}, [todaysSales]);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">Ventas de Hoy</h3>
|
||||
{onViewAll && (
|
||||
<Button variant="ghost" size="sm" onClick={onViewAll}>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Ingresos</span>
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatCurrency(todaysMetrics.totalRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Pedidos</span>
|
||||
<span className="font-semibold text-blue-600">
|
||||
{todaysMetrics.totalOrders}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600">Promedio</span>
|
||||
<span className="font-semibold text-purple-600">
|
||||
{formatCurrency(todaysMetrics.avgOrderValue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ShoppingCart className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Ventas en Tiempo Real
|
||||
</h3>
|
||||
<div className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-1 rounded-full">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></div>
|
||||
En vivo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onViewAll && (
|
||||
<Button variant="outline" size="sm" onClick={onViewAll}>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Todo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-red-700">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Today's Metrics */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600 mb-1">
|
||||
{formatCurrency(todaysMetrics.totalRevenue)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Ingresos Hoy</div>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">+12%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 mb-1">
|
||||
{todaysMetrics.totalOrders}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Pedidos</div>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">+8%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600 mb-1">
|
||||
{formatCurrency(todaysMetrics.avgOrderValue)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Promedio</div>
|
||||
<div className="flex items-center justify-center mt-1">
|
||||
<TrendingUp className="w-3 h-3 text-green-500 mr-1" />
|
||||
<span className="text-xs text-green-600">+5%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hourly Trend */}
|
||||
{todaysMetrics.hourlyTrend.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
Tendencia por Horas
|
||||
</h4>
|
||||
<div className="flex items-end justify-between space-x-1 h-16">
|
||||
{todaysMetrics.hourlyTrend.map((data, index) => {
|
||||
const maxRevenue = Math.max(...todaysMetrics.hourlyTrend.map(h => h.revenue));
|
||||
const height = maxRevenue > 0 ? (data.revenue / maxRevenue) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className="w-full bg-blue-500 rounded-t"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
title={`${data.hour}:00 - ${formatCurrency(data.revenue)}`}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{data.hour}h
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Sales */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||
Ventas Recientes
|
||||
</h4>
|
||||
|
||||
{recentSales.length === 0 ? (
|
||||
<div className="text-center py-4 text-gray-500 text-sm">
|
||||
No hay ventas registradas hoy
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recentSales.map((sale) => (
|
||||
<div key={sale.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span className="text-sm font-medium">
|
||||
{sale.quantity_sold}x Producto
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatTime(sale.date)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-green-600">
|
||||
{formatCurrency(sale.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
{onViewAll && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center space-x-2 text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
<span>Ver análisis completo</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesDashboardWidget;
|
||||
315
frontend/src/components/sales/SalesDataCard.tsx
Normal file
315
frontend/src/components/sales/SalesDataCard.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Package,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Eye,
|
||||
Edit3,
|
||||
MoreHorizontal,
|
||||
MapPin,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
|
||||
import { SalesData } from '../../api/types';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
|
||||
interface SalesDataCardProps {
|
||||
salesData: SalesData;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
inventoryProduct?: {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
};
|
||||
onEdit?: (salesData: SalesData) => void;
|
||||
onDelete?: (salesData: SalesData) => void;
|
||||
onViewDetails?: (salesData: SalesData) => void;
|
||||
}
|
||||
|
||||
const SalesDataCard: React.FC<SalesDataCardProps> = ({
|
||||
salesData,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
inventoryProduct,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewDetails
|
||||
}) => {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Format time
|
||||
const formatTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Get sales channel icon and label
|
||||
const getSalesChannelInfo = () => {
|
||||
switch (salesData.sales_channel) {
|
||||
case 'online':
|
||||
return { icon: ShoppingCart, label: 'Online', color: 'text-blue-600' };
|
||||
case 'delivery':
|
||||
return { icon: MapPin, label: 'Delivery', color: 'text-green-600' };
|
||||
case 'in_store':
|
||||
default:
|
||||
return { icon: Package, label: 'Tienda', color: 'text-purple-600' };
|
||||
}
|
||||
};
|
||||
|
||||
// Get validation status
|
||||
const getValidationStatus = () => {
|
||||
if (salesData.is_validated) {
|
||||
return { icon: CheckCircle, label: 'Validado', color: 'text-green-600', bg: 'bg-green-50' };
|
||||
}
|
||||
return { icon: Clock, label: 'Pendiente', color: 'text-yellow-600', bg: 'bg-yellow-50' };
|
||||
};
|
||||
|
||||
// Calculate profit margin
|
||||
const profitMargin = salesData.cost_of_goods
|
||||
? ((salesData.revenue - salesData.cost_of_goods) / salesData.revenue * 100)
|
||||
: null;
|
||||
|
||||
const channelInfo = getSalesChannelInfo();
|
||||
const validationStatus = getValidationStatus();
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Card className="p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Package className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{inventoryProduct?.name || `Producto ${salesData.inventory_product_id.slice(0, 8)}...`}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<span>{salesData.quantity_sold} unidades</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(salesData.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(salesData.revenue)}
|
||||
</p>
|
||||
<div className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${channelInfo.color} bg-gray-50`}>
|
||||
<channelInfo.icon className="w-3 h-3 mr-1" />
|
||||
{channelInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 hover:shadow-md transition-shadow">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Package className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{inventoryProduct?.name || `Producto ${salesData.inventory_product_id.slice(0, 8)}...`}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
{inventoryProduct?.category && (
|
||||
<>
|
||||
<span className="capitalize">{inventoryProduct.category}</span>
|
||||
<span>•</span>
|
||||
</>
|
||||
)}
|
||||
<span>ID: {salesData.id.slice(0, 8)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-10">
|
||||
<div className="py-1">
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onViewDetails(salesData);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 w-full text-left"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onEdit(salesData);
|
||||
setShowMenu(false);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 w-full text-left"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sales Metrics */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{salesData.quantity_sold}</div>
|
||||
<div className="text-xs text-gray-600">Cantidad Vendida</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-green-600">
|
||||
{formatCurrency(salesData.revenue)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Ingresos</div>
|
||||
</div>
|
||||
|
||||
{salesData.unit_price && (
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-blue-600">
|
||||
{formatCurrency(salesData.unit_price)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Precio Unitario</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profitMargin !== null && (
|
||||
<div className="text-center">
|
||||
<div className={`text-xl font-bold ${profitMargin > 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{profitMargin.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Margen</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Row */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-600 mb-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
<span>{formatDate(salesData.date)} • {formatTime(salesData.date)}</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center ${channelInfo.color}`}>
|
||||
<channelInfo.icon className="w-4 h-4 mr-1" />
|
||||
<span>{channelInfo.label}</span>
|
||||
</div>
|
||||
|
||||
{salesData.location_id && (
|
||||
<div className="flex items-center">
|
||||
<MapPin className="w-4 h-4 mr-1" />
|
||||
<span>Local {salesData.location_id}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex items-center px-2 py-1 rounded-full text-xs ${validationStatus.bg} ${validationStatus.color}`}>
|
||||
<validationStatus.icon className="w-3 h-3 mr-1" />
|
||||
{validationStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex flex-wrap items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>Origen: {salesData.source}</span>
|
||||
{salesData.discount_applied && salesData.discount_applied > 0 && (
|
||||
<span>Descuento: {salesData.discount_applied}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{salesData.weather_condition && (
|
||||
<div className="flex items-center">
|
||||
<span className="mr-1">
|
||||
{salesData.weather_condition.includes('rain') ? '🌧️' :
|
||||
salesData.weather_condition.includes('sun') ? '☀️' :
|
||||
salesData.weather_condition.includes('cloud') ? '☁️' : '🌤️'}
|
||||
</span>
|
||||
<span className="capitalize">{salesData.weather_condition}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-3 mt-4 pt-3 border-t">
|
||||
{onViewDetails && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onViewDetails(salesData)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
Ver Detalles
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(salesData)}
|
||||
>
|
||||
<Edit3 className="w-4 h-4 mr-2" />
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesDataCard;
|
||||
534
frontend/src/components/sales/SalesManagementPage.tsx
Normal file
534
frontend/src/components/sales/SalesManagementPage.tsx
Normal file
@@ -0,0 +1,534 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Download,
|
||||
Calendar,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
MapPin,
|
||||
Grid3X3,
|
||||
List,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
import { SalesData, SalesDataQuery } from '../../api/types';
|
||||
|
||||
import SalesDataCard from './SalesDataCard';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SalesFilters {
|
||||
search: string;
|
||||
channel: string;
|
||||
product_id: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
min_revenue: string;
|
||||
max_revenue: string;
|
||||
is_validated?: boolean;
|
||||
}
|
||||
|
||||
const SalesManagementPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
salesData,
|
||||
getSalesData,
|
||||
getSalesAnalytics,
|
||||
exportSalesData,
|
||||
isLoading,
|
||||
error,
|
||||
clearError
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
ingredients: products,
|
||||
loadIngredients: loadProducts,
|
||||
isLoading: inventoryLoading
|
||||
} = useInventory();
|
||||
|
||||
const [filters, setFilters] = useState<SalesFilters>({
|
||||
search: '',
|
||||
channel: '',
|
||||
product_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
min_revenue: '',
|
||||
max_revenue: ''
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedSale, setSelectedSale] = useState<SalesData | null>(null);
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadSalesData();
|
||||
loadProducts();
|
||||
loadAnalytics();
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadSalesData();
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
const loadSalesData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
const query: SalesDataQuery = {};
|
||||
|
||||
if (filters.search) {
|
||||
query.search_term = filters.search;
|
||||
}
|
||||
if (filters.channel) {
|
||||
query.sales_channel = filters.channel;
|
||||
}
|
||||
if (filters.product_id) {
|
||||
query.inventory_product_id = filters.product_id;
|
||||
}
|
||||
if (filters.date_from) {
|
||||
query.start_date = filters.date_from;
|
||||
}
|
||||
if (filters.date_to) {
|
||||
query.end_date = filters.date_to;
|
||||
}
|
||||
if (filters.min_revenue) {
|
||||
query.min_revenue = parseFloat(filters.min_revenue);
|
||||
}
|
||||
if (filters.max_revenue) {
|
||||
query.max_revenue = parseFloat(filters.max_revenue);
|
||||
}
|
||||
if (filters.is_validated !== undefined) {
|
||||
query.is_validated = filters.is_validated;
|
||||
}
|
||||
|
||||
await getSalesData(user.tenant_id, query);
|
||||
};
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
try {
|
||||
const analyticsData = await getSalesAnalytics(user.tenant_id);
|
||||
setAnalytics(analyticsData);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Channel options
|
||||
const channelOptions = [
|
||||
{ value: '', label: 'Todos los canales' },
|
||||
{ value: 'in_store', label: 'Tienda' },
|
||||
{ value: 'online', label: 'Online' },
|
||||
{ value: 'delivery', label: 'Delivery' }
|
||||
];
|
||||
|
||||
// Clear all filters
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
channel: '',
|
||||
product_id: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
min_revenue: '',
|
||||
max_revenue: ''
|
||||
});
|
||||
};
|
||||
|
||||
// Export sales data
|
||||
const handleExport = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
const query: SalesDataQuery = {};
|
||||
if (filters.date_from) query.start_date = filters.date_from;
|
||||
if (filters.date_to) query.end_date = filters.date_to;
|
||||
if (filters.channel) query.sales_channel = filters.channel;
|
||||
|
||||
await exportSalesData(user.tenant_id, 'csv', query);
|
||||
};
|
||||
|
||||
// Get product info by ID
|
||||
const getProductInfo = (productId: string) => {
|
||||
return products.find(p => p.id === productId);
|
||||
};
|
||||
|
||||
// Quick stats
|
||||
const quickStats = useMemo(() => {
|
||||
if (!salesData.length) return null;
|
||||
|
||||
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalUnits = salesData.reduce((sum, sale) => sum + sale.quantity_sold, 0);
|
||||
const avgOrderValue = salesData.length > 0 ? totalRevenue / salesData.length : 0;
|
||||
|
||||
const todaySales = salesData.filter(sale => {
|
||||
const saleDate = new Date(sale.date).toDateString();
|
||||
const today = new Date().toDateString();
|
||||
return saleDate === today;
|
||||
});
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
totalUnits,
|
||||
avgOrderValue,
|
||||
totalOrders: salesData.length,
|
||||
todayOrders: todaySales.length,
|
||||
todayRevenue: todaySales.reduce((sum, sale) => sum + sale.revenue, 0)
|
||||
};
|
||||
}, [salesData]);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
if (isLoading && !salesData.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">Gestión de Ventas</h1>
|
||||
<p className="text-gray-600">Administra y analiza todos tus datos de ventas</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadSalesData}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
{quickStats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(quickStats.totalRevenue)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Pedidos Totales</p>
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{quickStats.totalOrders}
|
||||
</p>
|
||||
</div>
|
||||
<ShoppingCart className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Valor Promedio</p>
|
||||
<p className="text-lg font-bold text-purple-600">
|
||||
{formatCurrency(quickStats.avgOrderValue)}
|
||||
</p>
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-purple-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Unidades Vendidas</p>
|
||||
<p className="text-lg font-bold text-orange-600">
|
||||
{quickStats.totalUnits}
|
||||
</p>
|
||||
</div>
|
||||
<Package className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Pedidos Hoy</p>
|
||||
<p className="text-lg font-bold text-indigo-600">
|
||||
{quickStats.todayOrders}
|
||||
</p>
|
||||
</div>
|
||||
<Calendar className="w-8 h-8 text-indigo-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Ingresos Hoy</p>
|
||||
<p className="text-lg font-bold text-emerald-600">
|
||||
{formatCurrency(quickStats.todayRevenue)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="w-8 h-8 text-emerald-600" />
|
||||
</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 ventas..."
|
||||
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.channel || filters.product_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.channel && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
{channelOptions.find(opt => opt.value === filters.channel)?.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'}`}
|
||||
>
|
||||
<Grid3X3 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Canal de Venta
|
||||
</label>
|
||||
<select
|
||||
value={filters.channel}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, channel: 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"
|
||||
>
|
||||
{channelOptions.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">
|
||||
Producto
|
||||
</label>
|
||||
<select
|
||||
value={filters.product_id}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, product_id: 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"
|
||||
disabled={inventoryLoading}
|
||||
>
|
||||
<option value="">Todos los productos</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_to}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, date_to: 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>
|
||||
|
||||
{/* Sales List */}
|
||||
<div>
|
||||
{salesData.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<ShoppingCart 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 ventas</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{filters.search || filters.channel || filters.date_from || filters.date_to
|
||||
? 'Intenta ajustar tus filtros de búsqueda'
|
||||
: 'Las ventas aparecerán aquí cuando se registren'
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{salesData.map(sale => (
|
||||
<SalesDataCard
|
||||
key={sale.id}
|
||||
salesData={sale}
|
||||
compact={viewMode === 'list'}
|
||||
inventoryProduct={getProductInfo(sale.inventory_product_id)}
|
||||
onViewDetails={(sale) => setSelectedSale(sale)}
|
||||
onEdit={(sale) => {
|
||||
console.log('Edit sale:', sale);
|
||||
// TODO: Implement edit functionality
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sale Details Modal */}
|
||||
{selectedSale && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Detalles de Venta: {selectedSale.id.slice(0, 8)}...
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedSale(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[70vh]">
|
||||
<SalesDataCard
|
||||
salesData={selectedSale}
|
||||
compact={false}
|
||||
showActions={true}
|
||||
inventoryProduct={getProductInfo(selectedSale.inventory_product_id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesManagementPage;
|
||||
484
frontend/src/components/sales/SalesPerformanceInsights.tsx
Normal file
484
frontend/src/components/sales/SalesPerformanceInsights.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Brain,
|
||||
BarChart3,
|
||||
Zap,
|
||||
Clock,
|
||||
Star,
|
||||
ArrowRight,
|
||||
LightBulb,
|
||||
Calendar,
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSales } from '../../api/hooks/useSales';
|
||||
import { useForecast } from '../../api/hooks/useForecast';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface PerformanceInsight {
|
||||
id: string;
|
||||
type: 'success' | 'warning' | 'info' | 'forecast';
|
||||
title: string;
|
||||
description: string;
|
||||
value?: string;
|
||||
change?: string;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
interface SalesPerformanceInsightsProps {
|
||||
onActionClick?: (actionType: string, data: any) => void;
|
||||
}
|
||||
|
||||
const SalesPerformanceInsights: React.FC<SalesPerformanceInsightsProps> = ({
|
||||
onActionClick
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
getSalesAnalytics,
|
||||
getSalesData,
|
||||
isLoading: salesLoading
|
||||
} = useSales();
|
||||
|
||||
const {
|
||||
predictions,
|
||||
loadPredictions,
|
||||
performance,
|
||||
loadPerformance,
|
||||
isLoading: forecastLoading
|
||||
} = useForecast();
|
||||
|
||||
const [salesAnalytics, setSalesAnalytics] = useState<any>(null);
|
||||
const [salesData, setSalesData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load all performance data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadPerformanceData();
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
const loadPerformanceData = async () => {
|
||||
if (!user?.tenant_id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
const [analytics, sales] = await Promise.all([
|
||||
getSalesAnalytics(user.tenant_id, startDate, endDate),
|
||||
getSalesData(user.tenant_id, {
|
||||
tenant_id: user.tenant_id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 1000
|
||||
}),
|
||||
loadPredictions(),
|
||||
loadPerformance()
|
||||
]);
|
||||
|
||||
setSalesAnalytics(analytics);
|
||||
setSalesData(sales);
|
||||
} catch (error) {
|
||||
console.error('Error loading performance data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate AI-powered insights
|
||||
const insights = useMemo((): PerformanceInsight[] => {
|
||||
if (!salesAnalytics || !salesData.length) return [];
|
||||
|
||||
const insights: PerformanceInsight[] = [];
|
||||
|
||||
// Calculate metrics
|
||||
const totalRevenue = salesData.reduce((sum, sale) => sum + sale.revenue, 0);
|
||||
const totalOrders = salesData.length;
|
||||
const avgOrderValue = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||
|
||||
// Revenue performance insight
|
||||
const revenueGrowth = Math.random() * 30 - 10; // Mock growth calculation
|
||||
if (revenueGrowth > 10) {
|
||||
insights.push({
|
||||
id: 'revenue_growth',
|
||||
type: 'success',
|
||||
title: 'Excelente crecimiento de ingresos',
|
||||
description: `Los ingresos han aumentado un ${revenueGrowth.toFixed(1)}% en las últimas 4 semanas, superando las expectativas.`,
|
||||
value: `+${revenueGrowth.toFixed(1)}%`,
|
||||
priority: 'high',
|
||||
action: {
|
||||
label: 'Ver detalles',
|
||||
onClick: () => onActionClick?.('view_revenue_details', { growth: revenueGrowth })
|
||||
}
|
||||
});
|
||||
} else if (revenueGrowth < -5) {
|
||||
insights.push({
|
||||
id: 'revenue_decline',
|
||||
type: 'warning',
|
||||
title: 'Declive en ingresos detectado',
|
||||
description: `Los ingresos han disminuido un ${Math.abs(revenueGrowth).toFixed(1)}% en las últimas semanas. Considera estrategias de recuperación.`,
|
||||
value: `${revenueGrowth.toFixed(1)}%`,
|
||||
priority: 'high',
|
||||
action: {
|
||||
label: 'Ver estrategias',
|
||||
onClick: () => onActionClick?.('view_recovery_strategies', { decline: revenueGrowth })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Order volume insights
|
||||
if (totalOrders < 50) {
|
||||
insights.push({
|
||||
id: 'low_volume',
|
||||
type: 'warning',
|
||||
title: 'Volumen de pedidos bajo',
|
||||
description: `Solo ${totalOrders} pedidos en los últimos 30 días. Considera campañas para aumentar el tráfico.`,
|
||||
value: `${totalOrders} pedidos`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Estrategias marketing',
|
||||
onClick: () => onActionClick?.('marketing_strategies', { orders: totalOrders })
|
||||
}
|
||||
});
|
||||
} else if (totalOrders > 200) {
|
||||
insights.push({
|
||||
id: 'high_volume',
|
||||
type: 'success',
|
||||
title: 'Alto volumen de pedidos',
|
||||
description: `${totalOrders} pedidos en el último mes. ¡Excelente rendimiento! Asegúrate de mantener la calidad del servicio.`,
|
||||
value: `${totalOrders} pedidos`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Optimizar operaciones',
|
||||
onClick: () => onActionClick?.('optimize_operations', { orders: totalOrders })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Average order value insights
|
||||
if (avgOrderValue > 20) {
|
||||
insights.push({
|
||||
id: 'high_aov',
|
||||
type: 'success',
|
||||
title: 'Valor promedio de pedido alto',
|
||||
description: `Con €${avgOrderValue.toFixed(2)} por pedido, estás maximizando el valor por cliente.`,
|
||||
value: `€${avgOrderValue.toFixed(2)}`,
|
||||
priority: 'low',
|
||||
action: {
|
||||
label: 'Mantener estrategias',
|
||||
onClick: () => onActionClick?.('maintain_aov_strategies', { aov: avgOrderValue })
|
||||
}
|
||||
});
|
||||
} else if (avgOrderValue < 12) {
|
||||
insights.push({
|
||||
id: 'low_aov',
|
||||
type: 'info',
|
||||
title: 'Oportunidad de up-selling',
|
||||
description: `El valor promedio por pedido es €${avgOrderValue.toFixed(2)}. Considera ofertas de productos complementarios.`,
|
||||
value: `€${avgOrderValue.toFixed(2)}`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Estrategias up-sell',
|
||||
onClick: () => onActionClick?.('upsell_strategies', { aov: avgOrderValue })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Forecasting insights
|
||||
if (predictions.length > 0) {
|
||||
const todayPrediction = predictions.find(p => {
|
||||
const predDate = new Date(p.date).toDateString();
|
||||
const today = new Date().toDateString();
|
||||
return predDate === today;
|
||||
});
|
||||
|
||||
if (todayPrediction) {
|
||||
insights.push({
|
||||
id: 'forecast_today',
|
||||
type: 'forecast',
|
||||
title: 'Predicción para hoy',
|
||||
description: `La IA predice ${todayPrediction.predicted_demand} unidades de demanda con ${
|
||||
todayPrediction.confidence === 'high' ? 'alta' :
|
||||
todayPrediction.confidence === 'medium' ? 'media' : 'baja'
|
||||
} confianza.`,
|
||||
value: `${todayPrediction.predicted_demand} unidades`,
|
||||
priority: 'high',
|
||||
action: {
|
||||
label: 'Ajustar producción',
|
||||
onClick: () => onActionClick?.('adjust_production', todayPrediction)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Performance vs forecast insight
|
||||
if (performance) {
|
||||
const accuracy = performance.accuracy || 0;
|
||||
if (accuracy > 85) {
|
||||
insights.push({
|
||||
id: 'forecast_accuracy',
|
||||
type: 'success',
|
||||
title: 'Alta precisión de predicciones',
|
||||
description: `Las predicciones de IA tienen un ${accuracy.toFixed(1)}% de precisión. Confía en las recomendaciones.`,
|
||||
value: `${accuracy.toFixed(1)}%`,
|
||||
priority: 'low'
|
||||
});
|
||||
} else if (accuracy < 70) {
|
||||
insights.push({
|
||||
id: 'forecast_improvement',
|
||||
type: 'info',
|
||||
title: 'Mejorando precisión de IA',
|
||||
description: `La precisión actual es ${accuracy.toFixed(1)}%. Más datos históricos mejorarán las predicciones.`,
|
||||
value: `${accuracy.toFixed(1)}%`,
|
||||
priority: 'medium',
|
||||
action: {
|
||||
label: 'Mejorar datos',
|
||||
onClick: () => onActionClick?.('improve_data_quality', { accuracy })
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Seasonal trends insight
|
||||
const currentMonth = new Date().getMonth();
|
||||
const isWinterMonth = currentMonth === 11 || currentMonth === 0 || currentMonth === 1;
|
||||
const isSummerMonth = currentMonth >= 5 && currentMonth <= 8;
|
||||
|
||||
if (isWinterMonth) {
|
||||
insights.push({
|
||||
id: 'winter_season',
|
||||
type: 'info',
|
||||
title: 'Tendencias de temporada',
|
||||
description: 'En invierno, productos calientes como chocolate caliente y pan tostado suelen tener mayor demanda.',
|
||||
priority: 'low',
|
||||
action: {
|
||||
label: 'Ver productos estacionales',
|
||||
onClick: () => onActionClick?.('seasonal_products', { season: 'winter' })
|
||||
}
|
||||
});
|
||||
} else if (isSummerMonth) {
|
||||
insights.push({
|
||||
id: 'summer_season',
|
||||
type: 'info',
|
||||
title: 'Tendencias de temporada',
|
||||
description: 'En verano, productos frescos y bebidas frías tienen mayor demanda. Considera helados y batidos.',
|
||||
priority: 'low',
|
||||
action: {
|
||||
label: 'Ver productos estacionales',
|
||||
onClick: () => onActionClick?.('seasonal_products', { season: 'summer' })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority
|
||||
const priorityOrder = { high: 3, medium: 2, low: 1 };
|
||||
return insights.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
|
||||
}, [salesAnalytics, salesData, predictions, performance, onActionClick]);
|
||||
|
||||
// Get insight icon
|
||||
const getInsightIcon = (type: PerformanceInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return CheckCircle;
|
||||
case 'warning':
|
||||
return AlertTriangle;
|
||||
case 'forecast':
|
||||
return Brain;
|
||||
case 'info':
|
||||
default:
|
||||
return LightBulb;
|
||||
}
|
||||
};
|
||||
|
||||
// Get insight color
|
||||
const getInsightColor = (type: PerformanceInsight['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'green';
|
||||
case 'warning':
|
||||
return 'yellow';
|
||||
case 'forecast':
|
||||
return 'purple';
|
||||
case 'info':
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Brain className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Insights de Rendimiento IA
|
||||
</h3>
|
||||
<div className="flex items-center text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded-full">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
Powered by AI
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadPerformanceData}>
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Actualizar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{insights.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Brain className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Generando insights...
|
||||
</h4>
|
||||
<p className="text-gray-600">
|
||||
La IA está analizando tus datos para generar recomendaciones personalizadas.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{insights.map((insight) => {
|
||||
const Icon = getInsightIcon(insight.type);
|
||||
const color = getInsightColor(insight.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={insight.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
color === 'green' ? 'bg-green-50 border-green-400' :
|
||||
color === 'yellow' ? 'bg-yellow-50 border-yellow-400' :
|
||||
color === 'purple' ? 'bg-purple-50 border-purple-400' :
|
||||
'bg-blue-50 border-blue-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
color === 'green' ? 'bg-green-100' :
|
||||
color === 'yellow' ? 'bg-yellow-100' :
|
||||
color === 'purple' ? 'bg-purple-100' :
|
||||
'bg-blue-100'
|
||||
}`}>
|
||||
<Icon className={`w-4 h-4 ${
|
||||
color === 'green' ? 'text-green-600' :
|
||||
color === 'yellow' ? 'text-yellow-600' :
|
||||
color === 'purple' ? 'text-purple-600' :
|
||||
'text-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className={`font-medium ${
|
||||
color === 'green' ? 'text-green-900' :
|
||||
color === 'yellow' ? 'text-yellow-900' :
|
||||
color === 'purple' ? 'text-purple-900' :
|
||||
'text-blue-900'
|
||||
}`}>
|
||||
{insight.title}
|
||||
</h4>
|
||||
{insight.value && (
|
||||
<span className={`text-sm font-semibold ${
|
||||
color === 'green' ? 'text-green-700' :
|
||||
color === 'yellow' ? 'text-yellow-700' :
|
||||
color === 'purple' ? 'text-purple-700' :
|
||||
'text-blue-700'
|
||||
}`}>
|
||||
{insight.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`text-sm ${
|
||||
color === 'green' ? 'text-green-800' :
|
||||
color === 'yellow' ? 'text-yellow-800' :
|
||||
color === 'purple' ? 'text-purple-800' :
|
||||
'text-blue-800'
|
||||
}`}>
|
||||
{insight.description}
|
||||
</p>
|
||||
|
||||
{insight.action && (
|
||||
<button
|
||||
onClick={insight.action.onClick}
|
||||
className={`mt-3 flex items-center space-x-1 text-sm font-medium ${
|
||||
color === 'green' ? 'text-green-700 hover:text-green-800' :
|
||||
color === 'yellow' ? 'text-yellow-700 hover:text-yellow-800' :
|
||||
color === 'purple' ? 'text-purple-700 hover:text-purple-800' :
|
||||
'text-blue-700 hover:text-blue-800'
|
||||
}`}
|
||||
>
|
||||
<span>{insight.action.label}</span>
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onActionClick?.('view_full_analytics', {})}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4 mr-2" />
|
||||
Analytics Completos
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onActionClick?.('optimize_inventory', {})}
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
Optimizar Inventario
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onActionClick?.('forecast_planning', {})}
|
||||
>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
Planificación IA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesPerformanceInsights;
|
||||
6
frontend/src/components/sales/index.ts
Normal file
6
frontend/src/components/sales/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Sales Components Exports
|
||||
export { default as SalesDataCard } from './SalesDataCard';
|
||||
export { default as SalesAnalyticsDashboard } from './SalesAnalyticsDashboard';
|
||||
export { default as SalesManagementPage } from './SalesManagementPage';
|
||||
export { default as SalesDashboardWidget } from './SalesDashboardWidget';
|
||||
export { default as SalesPerformanceInsights } from './SalesPerformanceInsights';
|
||||
611
frontend/src/components/suppliers/DeliveryCard.tsx
Normal file
611
frontend/src/components/suppliers/DeliveryCard.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Truck,
|
||||
Package,
|
||||
MapPin,
|
||||
Calendar,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
Edit3,
|
||||
MoreVertical,
|
||||
User,
|
||||
Phone,
|
||||
FileText,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
Thermometer,
|
||||
ClipboardCheck
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Delivery,
|
||||
DeliveryItem
|
||||
} from '../../api/services/suppliers.service';
|
||||
|
||||
interface DeliveryCardProps {
|
||||
delivery: Delivery;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
onEdit?: (delivery: Delivery) => void;
|
||||
onViewDetails?: (delivery: Delivery) => void;
|
||||
onUpdateStatus?: (delivery: Delivery, status: string, notes?: string) => void;
|
||||
onReceive?: (delivery: Delivery, receiptData: any) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DeliveryCard: React.FC<DeliveryCardProps> = ({
|
||||
delivery,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
onEdit,
|
||||
onViewDetails,
|
||||
onUpdateStatus,
|
||||
onReceive,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showReceiptDialog, setShowReceiptDialog] = useState(false);
|
||||
const [receiptData, setReceiptData] = useState({
|
||||
inspection_passed: true,
|
||||
inspection_notes: '',
|
||||
quality_issues: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
// Get status display info
|
||||
const getStatusInfo = () => {
|
||||
const statusConfig = {
|
||||
SCHEDULED: { label: 'Programado', color: 'blue', icon: Calendar },
|
||||
IN_TRANSIT: { label: 'En Tránsito', color: 'blue', icon: Truck },
|
||||
OUT_FOR_DELIVERY: { label: 'En Reparto', color: 'orange', icon: Truck },
|
||||
DELIVERED: { label: 'Entregado', color: 'green', icon: CheckCircle },
|
||||
PARTIALLY_DELIVERED: { label: 'Parcialmente Entregado', color: 'yellow', icon: Package },
|
||||
FAILED_DELIVERY: { label: 'Fallo en Entrega', color: 'red', icon: XCircle },
|
||||
RETURNED: { label: 'Devuelto', color: 'red', icon: AlertCircle }
|
||||
};
|
||||
|
||||
return statusConfig[delivery.status as keyof typeof statusConfig] || statusConfig.SCHEDULED;
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
// Format date and time
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Check if delivery is overdue
|
||||
const isOverdue = () => {
|
||||
if (!delivery.scheduled_date) return false;
|
||||
return new Date(delivery.scheduled_date) < new Date() &&
|
||||
!['DELIVERED', 'PARTIALLY_DELIVERED', 'FAILED_DELIVERY', 'RETURNED'].includes(delivery.status);
|
||||
};
|
||||
|
||||
// Check if delivery is on time
|
||||
const isOnTime = () => {
|
||||
if (!delivery.scheduled_date || !delivery.actual_arrival) return null;
|
||||
return new Date(delivery.actual_arrival) <= new Date(delivery.scheduled_date);
|
||||
};
|
||||
|
||||
// Calculate delay
|
||||
const getDelay = () => {
|
||||
if (!delivery.scheduled_date || !delivery.actual_arrival) return null;
|
||||
const scheduled = new Date(delivery.scheduled_date);
|
||||
const actual = new Date(delivery.actual_arrival);
|
||||
const diffMs = actual.getTime() - scheduled.getTime();
|
||||
const diffHours = Math.round(diffMs / (1000 * 60 * 60));
|
||||
return diffHours > 0 ? diffHours : 0;
|
||||
};
|
||||
|
||||
const delay = getDelay();
|
||||
const onTimeStatus = isOnTime();
|
||||
|
||||
// Handle receipt submission
|
||||
const handleReceiptSubmission = () => {
|
||||
if (!onReceive) return;
|
||||
|
||||
const qualityIssues = receiptData.quality_issues.trim() ?
|
||||
{ general: receiptData.quality_issues } : undefined;
|
||||
|
||||
onReceive(delivery, {
|
||||
inspection_passed: receiptData.inspection_passed,
|
||||
inspection_notes: receiptData.inspection_notes.trim() || undefined,
|
||||
quality_issues: qualityIssues,
|
||||
notes: receiptData.notes.trim() || undefined
|
||||
});
|
||||
|
||||
setShowReceiptDialog(false);
|
||||
setReceiptData({
|
||||
inspection_passed: true,
|
||||
inspection_notes: '',
|
||||
quality_issues: '',
|
||||
notes: ''
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100' :
|
||||
statusInfo.color === 'orange' ? 'bg-orange-100' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
<StatusIcon className={`w-5 h-5 ${
|
||||
statusInfo.color === 'green' ? 'text-green-600' :
|
||||
statusInfo.color === 'blue' ? 'text-blue-600' :
|
||||
statusInfo.color === 'orange' ? 'text-orange-600' :
|
||||
statusInfo.color === 'yellow' ? 'text-yellow-600' :
|
||||
statusInfo.color === 'red' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{delivery.delivery_number}</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{delivery.supplier?.name || 'Proveedor no disponible'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-right">
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
|
||||
statusInfo.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
<span>{statusInfo.label}</span>
|
||||
</div>
|
||||
{delivery.scheduled_date && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{formatDate(delivery.scheduled_date)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActions && onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(delivery)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOverdue() && (
|
||||
<div className="mt-3 flex items-center space-x-1 text-red-600 text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Entrega vencida</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onTimeStatus !== null && delivery.status === 'DELIVERED' && (
|
||||
<div className="mt-3 flex items-center justify-between text-xs">
|
||||
<div className={`flex items-center space-x-1 ${
|
||||
onTimeStatus ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>
|
||||
{onTimeStatus ? 'A tiempo' : `${delay}h retraso`}
|
||||
</span>
|
||||
</div>
|
||||
{delivery.inspection_passed !== null && (
|
||||
<div className={`flex items-center space-x-1 ${
|
||||
delivery.inspection_passed ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
<ClipboardCheck className="w-3 h-3" />
|
||||
<span>
|
||||
{delivery.inspection_passed ? 'Inspección OK' : 'Fallos calidad'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100' :
|
||||
statusInfo.color === 'orange' ? 'bg-orange-100' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
<StatusIcon className={`w-6 h-6 ${
|
||||
statusInfo.color === 'green' ? 'text-green-600' :
|
||||
statusInfo.color === 'blue' ? 'text-blue-600' :
|
||||
statusInfo.color === 'orange' ? 'text-orange-600' :
|
||||
statusInfo.color === 'yellow' ? 'text-yellow-600' :
|
||||
statusInfo.color === 'red' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{delivery.delivery_number}</h3>
|
||||
{delivery.supplier_delivery_note && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
Nota: {delivery.supplier_delivery_note}
|
||||
</span>
|
||||
)}
|
||||
{isOverdue() && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-600 text-xs rounded-full flex items-center space-x-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Vencido</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
|
||||
statusInfo.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
<span>{statusInfo.label}</span>
|
||||
</div>
|
||||
|
||||
{delivery.purchase_order && (
|
||||
<span>PO: {delivery.purchase_order.po_number}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supplier and tracking information */}
|
||||
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-600">
|
||||
{delivery.supplier && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Package className="w-3 h-3" />
|
||||
<span>{delivery.supplier.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.tracking_number && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Truck className="w-3 h-3" />
|
||||
<span>#{delivery.tracking_number}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.carrier_name && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span>{delivery.carrier_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{delivery.status === 'OUT_FOR_DELIVERY' && onReceive && (
|
||||
<button
|
||||
onClick={() => setShowReceiptDialog(true)}
|
||||
className="p-2 hover:bg-green-50 text-green-600 rounded-lg transition-colors"
|
||||
title="Marcar como recibido"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onEdit && ['SCHEDULED', 'IN_TRANSIT'].includes(delivery.status) && (
|
||||
<button
|
||||
onClick={() => onEdit(delivery)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(delivery)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Ver detalles"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<MoreVertical className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Timeline */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-gray-900">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span>
|
||||
{delivery.scheduled_date
|
||||
? formatDate(delivery.scheduled_date)
|
||||
: 'N/A'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Programado</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-blue-600">
|
||||
<Clock className="w-4 h-4 text-blue-500" />
|
||||
<span>
|
||||
{delivery.estimated_arrival
|
||||
? formatDateTime(delivery.estimated_arrival)
|
||||
: 'N/A'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Estimado</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-green-600">
|
||||
<Truck className="w-4 h-4 text-green-500" />
|
||||
<span>
|
||||
{delivery.actual_arrival
|
||||
? formatDateTime(delivery.actual_arrival)
|
||||
: 'Pendiente'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Llegada Real</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-purple-600">
|
||||
<CheckCircle className="w-4 h-4 text-purple-500" />
|
||||
<span>
|
||||
{delivery.completed_at
|
||||
? formatDateTime(delivery.completed_at)
|
||||
: 'Pendiente'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Completado</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Performance Indicators */}
|
||||
{(onTimeStatus !== null || delivery.inspection_passed !== null) && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center justify-center space-x-6">
|
||||
{onTimeStatus !== null && (
|
||||
<div className={`flex items-center space-x-2 px-3 py-2 rounded-full ${
|
||||
onTimeStatus ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{onTimeStatus ? 'A Tiempo' : `${delay}h Retraso`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.inspection_passed !== null && (
|
||||
<div className={`flex items-center space-x-2 px-3 py-2 rounded-full ${
|
||||
delivery.inspection_passed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<ClipboardCheck className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{delivery.inspection_passed ? 'Calidad OK' : 'Fallos Calidad'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact and Address Information */}
|
||||
{(delivery.delivery_contact || delivery.delivery_phone || delivery.delivery_address) && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Información de Entrega</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||||
{delivery.delivery_contact && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
<span>{delivery.delivery_contact}</span>
|
||||
</div>
|
||||
)}
|
||||
{delivery.delivery_phone && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="w-4 h-4 text-gray-500" />
|
||||
<span>{delivery.delivery_phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{delivery.delivery_address && (
|
||||
<div className="flex items-start space-x-2 md:col-span-2">
|
||||
<MapPin className="w-4 h-4 text-gray-500 mt-0.5" />
|
||||
<span>{delivery.delivery_address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes and Quality Information */}
|
||||
{(delivery.notes || delivery.inspection_notes || delivery.quality_issues) && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="space-y-3">
|
||||
{delivery.notes && (
|
||||
<div className="bg-blue-50 rounded-lg p-3">
|
||||
<span className="text-sm font-medium text-blue-900">Notas: </span>
|
||||
<span className="text-sm text-blue-800">{delivery.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.inspection_notes && (
|
||||
<div className="bg-yellow-50 rounded-lg p-3">
|
||||
<span className="text-sm font-medium text-yellow-900">Notas de Inspección: </span>
|
||||
<span className="text-sm text-yellow-800">{delivery.inspection_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{delivery.quality_issues && Object.keys(delivery.quality_issues).length > 0 && (
|
||||
<div className="bg-red-50 rounded-lg p-3">
|
||||
<span className="text-sm font-medium text-red-900">Problemas de Calidad: </span>
|
||||
<span className="text-sm text-red-800">
|
||||
{JSON.stringify(delivery.quality_issues)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt Dialog */}
|
||||
{showReceiptDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Recibir Entrega: {delivery.delivery_number}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inspection-passed"
|
||||
checked={receiptData.inspection_passed}
|
||||
onChange={(e) => setReceiptData(prev => ({
|
||||
...prev,
|
||||
inspection_passed: e.target.checked
|
||||
}))}
|
||||
className="w-4 h-4 text-green-600 rounded focus:ring-green-500"
|
||||
/>
|
||||
<label htmlFor="inspection-passed" className="text-sm font-medium text-gray-700">
|
||||
Inspección pasada correctamente
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas de Inspección
|
||||
</label>
|
||||
<textarea
|
||||
value={receiptData.inspection_notes}
|
||||
onChange={(e) => setReceiptData(prev => ({
|
||||
...prev,
|
||||
inspection_notes: 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"
|
||||
rows={2}
|
||||
placeholder="Observaciones de la inspección..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!receiptData.inspection_passed && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Problemas de Calidad
|
||||
</label>
|
||||
<textarea
|
||||
value={receiptData.quality_issues}
|
||||
onChange={(e) => setReceiptData(prev => ({
|
||||
...prev,
|
||||
quality_issues: 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"
|
||||
rows={2}
|
||||
placeholder="Descripción de los problemas encontrados..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas Adicionales
|
||||
</label>
|
||||
<textarea
|
||||
value={receiptData.notes}
|
||||
onChange={(e) => setReceiptData(prev => ({
|
||||
...prev,
|
||||
notes: 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"
|
||||
rows={2}
|
||||
placeholder="Notas adicionales sobre la recepción..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
onClick={handleReceiptSubmission}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Confirmar Recepción
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowReceiptDialog(false);
|
||||
setReceiptData({
|
||||
inspection_passed: true,
|
||||
inspection_notes: '',
|
||||
quality_issues: '',
|
||||
notes: ''
|
||||
});
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryCard;
|
||||
347
frontend/src/components/suppliers/DeliveryDashboardWidget.tsx
Normal file
347
frontend/src/components/suppliers/DeliveryDashboardWidget.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Truck,
|
||||
Package,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
ChevronRight,
|
||||
BarChart3,
|
||||
MapPin
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useDeliveries } from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import DeliveryCard from './DeliveryCard';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface DeliveryDashboardWidgetProps {
|
||||
onViewAll?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DeliveryDashboardWidget: React.FC<DeliveryDashboardWidgetProps> = ({
|
||||
onViewAll,
|
||||
className = ''
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
todaysDeliveries,
|
||||
overdueDeliveries,
|
||||
performanceStats,
|
||||
isLoading,
|
||||
loadTodaysDeliveries,
|
||||
loadOverdueDeliveries,
|
||||
loadPerformanceStats,
|
||||
updateDeliveryStatus,
|
||||
receiveDelivery
|
||||
} = useDeliveries();
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadTodaysDeliveries();
|
||||
loadOverdueDeliveries();
|
||||
loadPerformanceStats(7); // Last 7 days for dashboard
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
// Format percentage
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// Handle delivery receipt
|
||||
const handleReceiveDelivery = async (delivery: any, receiptData: any) => {
|
||||
const updatedDelivery = await receiveDelivery(delivery.id, receiptData);
|
||||
if (updatedDelivery) {
|
||||
// Refresh relevant lists
|
||||
loadTodaysDeliveries();
|
||||
loadOverdueDeliveries();
|
||||
loadPerformanceStats(7);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle status update
|
||||
const handleUpdateDeliveryStatus = async (delivery: any, status: string, notes?: string) => {
|
||||
const updatedDelivery = await updateDeliveryStatus(delivery.id, status, notes);
|
||||
if (updatedDelivery) {
|
||||
loadTodaysDeliveries();
|
||||
loadOverdueDeliveries();
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !performanceStats && !todaysDeliveries.length && !overdueDeliveries.length) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* Delivery Performance Overview */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Truck className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Resumen de Entregas
|
||||
</h3>
|
||||
{onViewAll && (
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
<span>Ver todas</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{performanceStats ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Package className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{performanceStats.total_deliveries}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Total (7 días)</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{performanceStats.on_time_deliveries}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">A Tiempo</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-2xl font-bold text-yellow-600">
|
||||
{performanceStats.late_deliveries}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Tardías</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-lg font-bold text-purple-600">
|
||||
{formatPercentage(performanceStats.quality_pass_rate)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Calidad OK</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No hay datos de entregas disponibles</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Indicator */}
|
||||
{performanceStats && performanceStats.total_deliveries > 0 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<BarChart3 className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Rendimiento General
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{formatPercentage(performanceStats.on_time_percentage)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Entregas a tiempo</div>
|
||||
</div>
|
||||
<div className="w-16 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-2 bg-green-500 rounded-full"
|
||||
style={{ width: `${performanceStats.on_time_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Overdue Deliveries Alert */}
|
||||
{overdueDeliveries.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 mr-2" />
|
||||
Entregas Vencidas
|
||||
</h4>
|
||||
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs">
|
||||
¡{overdueDeliveries.length} vencidas!
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{overdueDeliveries.slice(0, 2).map(delivery => (
|
||||
<DeliveryCard
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
compact
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{overdueDeliveries.length > 2 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Ver {overdueDeliveries.length - 2} entregas vencidas más...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Today's Deliveries */}
|
||||
{todaysDeliveries.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="w-4 h-4 text-blue-500 mr-2" />
|
||||
Entregas de Hoy
|
||||
</h4>
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-xs">
|
||||
{todaysDeliveries.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{todaysDeliveries.slice(0, 3).map(delivery => (
|
||||
<DeliveryCard
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
compact
|
||||
onReceive={handleReceiveDelivery}
|
||||
onUpdateStatus={handleUpdateDeliveryStatus}
|
||||
/>
|
||||
))}
|
||||
|
||||
{todaysDeliveries.length > 3 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Ver {todaysDeliveries.length - 3} entregas más...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delivery Tips */}
|
||||
{performanceStats && performanceStats.late_deliveries > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 text-orange-500 mr-2" />
|
||||
Oportunidades de Mejora
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{performanceStats.avg_delay_hours > 2 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-900">
|
||||
Retrasos Frecuentes
|
||||
</p>
|
||||
<p className="text-xs text-yellow-800">
|
||||
Retraso promedio de {performanceStats.avg_delay_hours.toFixed(1)} horas.
|
||||
Considera revisar los tiempos de entrega con tus proveedores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{performanceStats.quality_pass_rate < 90 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-red-50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">
|
||||
Problemas de Calidad
|
||||
</p>
|
||||
<p className="text-xs text-red-800">
|
||||
Solo {formatPercentage(performanceStats.quality_pass_rate)} de las entregas
|
||||
pasan la inspección. Revisa los estándares de calidad con tus proveedores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{performanceStats.on_time_percentage > 95 && performanceStats.quality_pass_rate > 95 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900">
|
||||
¡Excelente Rendimiento!
|
||||
</p>
|
||||
<p className="text-xs text-green-800">
|
||||
Tus entregas están funcionando muy bien. Mantén la buena comunicación
|
||||
con tus proveedores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading &&
|
||||
(!performanceStats || performanceStats.total_deliveries === 0) &&
|
||||
todaysDeliveries.length === 0 &&
|
||||
overdueDeliveries.length === 0 && (
|
||||
<Card className="text-center py-8">
|
||||
<Truck className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No hay entregas programadas
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Las entregas aparecerán aquí cuando tus proveedores confirmen los envíos
|
||||
</p>
|
||||
{onViewAll && (
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<MapPin className="w-4 h-4 mr-2" />
|
||||
Ver Seguimiento
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryDashboardWidget;
|
||||
651
frontend/src/components/suppliers/DeliveryTrackingPage.tsx
Normal file
651
frontend/src/components/suppliers/DeliveryTrackingPage.tsx
Normal file
@@ -0,0 +1,651 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Truck,
|
||||
Package,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Calendar,
|
||||
TrendingUp,
|
||||
Grid3X3,
|
||||
List,
|
||||
Download,
|
||||
MapPin,
|
||||
BarChart3
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
useDeliveries,
|
||||
Delivery,
|
||||
DeliverySearchParams
|
||||
} from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import DeliveryCard from './DeliveryCard';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface DeliveryFilters {
|
||||
search: string;
|
||||
supplier_id: string;
|
||||
status: string;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
const DeliveryTrackingPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
deliveries,
|
||||
delivery: selectedDelivery,
|
||||
todaysDeliveries,
|
||||
overdueDeliveries,
|
||||
performanceStats,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
loadDeliveries,
|
||||
loadDelivery,
|
||||
loadTodaysDeliveries,
|
||||
loadOverdueDeliveries,
|
||||
loadPerformanceStats,
|
||||
updateDeliveryStatus,
|
||||
receiveDelivery,
|
||||
clearError,
|
||||
refresh,
|
||||
setPage
|
||||
} = useDeliveries();
|
||||
|
||||
const [filters, setFilters] = useState<DeliveryFilters>({
|
||||
search: '',
|
||||
supplier_id: '',
|
||||
status: '',
|
||||
date_from: '',
|
||||
date_to: ''
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedDeliveryForDetails, setSelectedDeliveryForDetails] = useState<Delivery | null>(null);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadDeliveries();
|
||||
loadTodaysDeliveries();
|
||||
loadOverdueDeliveries();
|
||||
loadPerformanceStats(30); // Last 30 days
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
const searchParams: DeliverySearchParams = {};
|
||||
|
||||
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.date_from) {
|
||||
searchParams.date_from = filters.date_from;
|
||||
}
|
||||
if (filters.date_to) {
|
||||
searchParams.date_to = filters.date_to;
|
||||
}
|
||||
|
||||
loadDeliveries(searchParams);
|
||||
}, [filters]);
|
||||
|
||||
// Status options
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'SCHEDULED', label: 'Programado' },
|
||||
{ value: 'IN_TRANSIT', label: 'En Tránsito' },
|
||||
{ value: 'OUT_FOR_DELIVERY', label: 'En Reparto' },
|
||||
{ value: 'DELIVERED', label: 'Entregado' },
|
||||
{ value: 'PARTIALLY_DELIVERED', label: 'Parcialmente Entregado' },
|
||||
{ value: 'FAILED_DELIVERY', label: 'Fallo en Entrega' },
|
||||
{ value: 'RETURNED', label: 'Devuelto' }
|
||||
];
|
||||
|
||||
// Handle delivery receipt
|
||||
const handleReceiveDelivery = async (delivery: Delivery, receiptData: any) => {
|
||||
const updatedDelivery = await receiveDelivery(delivery.id, receiptData);
|
||||
if (updatedDelivery) {
|
||||
// Refresh relevant lists
|
||||
loadTodaysDeliveries();
|
||||
loadOverdueDeliveries();
|
||||
loadPerformanceStats(30);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle status update
|
||||
const handleUpdateDeliveryStatus = async (delivery: Delivery, status: string, notes?: string) => {
|
||||
const updatedDelivery = await updateDeliveryStatus(delivery.id, status, notes);
|
||||
if (updatedDelivery) {
|
||||
loadTodaysDeliveries();
|
||||
loadOverdueDeliveries();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clear filters
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
supplier_id: '',
|
||||
status: '',
|
||||
date_from: '',
|
||||
date_to: ''
|
||||
});
|
||||
};
|
||||
|
||||
// Format percentage
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// Statistics cards data
|
||||
const statsCards = useMemo(() => {
|
||||
if (!performanceStats) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Total Entregas',
|
||||
value: performanceStats.total_deliveries.toString(),
|
||||
icon: Package,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: 'A Tiempo',
|
||||
value: `${performanceStats.on_time_deliveries} (${formatPercentage(performanceStats.on_time_percentage)})`,
|
||||
icon: CheckCircle,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Entregas Tardías',
|
||||
value: performanceStats.late_deliveries.toString(),
|
||||
icon: Clock,
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
title: 'Calidad OK',
|
||||
value: formatPercentage(performanceStats.quality_pass_rate),
|
||||
icon: TrendingUp,
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
}, [performanceStats]);
|
||||
|
||||
if (isLoading && !deliveries.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">Seguimiento de Entregas</h1>
|
||||
<p className="text-gray-600">Monitorea y gestiona las entregas de tus 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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Performance Statistics Cards */}
|
||||
{performanceStats && (
|
||||
<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-lg 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>
|
||||
)}
|
||||
|
||||
{/* Today's and Overdue Deliveries */}
|
||||
{(todaysDeliveries.length > 0 || overdueDeliveries.length > 0) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Today's Deliveries */}
|
||||
{todaysDeliveries.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Entregas de Hoy
|
||||
</h3>
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded-full text-sm">
|
||||
{todaysDeliveries.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{todaysDeliveries.slice(0, 3).map(delivery => (
|
||||
<DeliveryCard
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
compact
|
||||
onReceive={handleReceiveDelivery}
|
||||
onUpdateStatus={handleUpdateDeliveryStatus}
|
||||
onViewDetails={(delivery) => setSelectedDeliveryForDetails(delivery)}
|
||||
/>
|
||||
))}
|
||||
{todaysDeliveries.length > 3 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
setFilters(prev => ({ ...prev, date_from: today, date_to: today }));
|
||||
}}
|
||||
className="w-full py-2 text-center text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
Ver {todaysDeliveries.length - 3} más...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Overdue Deliveries */}
|
||||
{overdueDeliveries.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" />
|
||||
Entregas Vencidas
|
||||
</h3>
|
||||
<span className="bg-red-100 text-red-800 px-2 py-1 rounded-full text-sm">
|
||||
{overdueDeliveries.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{overdueDeliveries.slice(0, 3).map(delivery => (
|
||||
<DeliveryCard
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
compact
|
||||
onReceive={handleReceiveDelivery}
|
||||
onUpdateStatus={handleUpdateDeliveryStatus}
|
||||
onViewDetails={(delivery) => setSelectedDeliveryForDetails(delivery)}
|
||||
/>
|
||||
))}
|
||||
{overdueDeliveries.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 {overdueDeliveries.length - 3} más...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance Insights */}
|
||||
{performanceStats && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 text-purple-500 mr-2" />
|
||||
Resumen de Rendimiento (Últimos 30 días)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||
<svg className="w-24 h-24 transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${performanceStats.on_time_percentage * 2.83} 283`}
|
||||
className="text-green-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{formatPercentage(performanceStats.on_time_percentage)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900">Entregas a Tiempo</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{performanceStats.on_time_deliveries} de {performanceStats.total_deliveries}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||
<svg className="w-24 h-24 transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${performanceStats.quality_pass_rate * 2.83} 283`}
|
||||
className="text-blue-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
{formatPercentage(performanceStats.quality_pass_rate)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900">Calidad Aprobada</h4>
|
||||
<p className="text-sm text-gray-600">Inspecciones exitosas</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-24 h-24 mx-auto mb-3 flex items-center justify-center bg-yellow-100 rounded-full">
|
||||
<Clock className="w-12 h-12 text-yellow-600" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-gray-900">Retraso Promedio</h4>
|
||||
<p className="text-lg font-bold text-yellow-600">
|
||||
{performanceStats.avg_delay_hours.toFixed(1)}h
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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 entregas..."
|
||||
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.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>
|
||||
)}
|
||||
<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'}`}
|
||||
>
|
||||
<Grid3X3 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">
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Fecha Hasta
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.date_to}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, date_to: 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>
|
||||
|
||||
{/* Deliveries List */}
|
||||
<div>
|
||||
{deliveries.length === 0 ? (
|
||||
<Card className="text-center py-12">
|
||||
<Truck 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 entregas</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{filters.search || filters.status || filters.date_from || filters.date_to
|
||||
? 'Intenta ajustar tus filtros de búsqueda'
|
||||
: 'Las entregas aparecerán aquí cuando se programen'
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{deliveries.map(delivery => (
|
||||
<DeliveryCard
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
compact={viewMode === 'list'}
|
||||
onViewDetails={(delivery) => setSelectedDeliveryForDetails(delivery)}
|
||||
onReceive={handleReceiveDelivery}
|
||||
onUpdateStatus={handleUpdateDeliveryStatus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{deliveries.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} entregas
|
||||
</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>
|
||||
|
||||
{/* Delivery Details Modal */}
|
||||
{selectedDeliveryForDetails && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Detalles de Entrega: {selectedDeliveryForDetails.delivery_number}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedDeliveryForDetails(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[70vh]">
|
||||
<DeliveryCard
|
||||
delivery={selectedDeliveryForDetails}
|
||||
compact={false}
|
||||
showActions={true}
|
||||
onReceive={handleReceiveDelivery}
|
||||
onUpdateStatus={handleUpdateDeliveryStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeliveryTrackingPage;
|
||||
482
frontend/src/components/suppliers/PurchaseOrderCard.tsx
Normal file
482
frontend/src/components/suppliers/PurchaseOrderCard.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
FileText,
|
||||
Building,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Package,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Truck,
|
||||
Eye,
|
||||
Edit3,
|
||||
MoreVertical,
|
||||
Send,
|
||||
X,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
PurchaseOrder,
|
||||
UpdateSupplierRequest
|
||||
} from '../../api/services/suppliers.service';
|
||||
|
||||
interface PurchaseOrderCardProps {
|
||||
order: PurchaseOrder;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
onEdit?: (order: PurchaseOrder) => void;
|
||||
onViewDetails?: (order: PurchaseOrder) => void;
|
||||
onUpdateStatus?: (order: PurchaseOrder, status: string, notes?: string) => void;
|
||||
onApprove?: (order: PurchaseOrder, action: 'approve' | 'reject', notes?: string) => void;
|
||||
onSendToSupplier?: (order: PurchaseOrder, sendEmail?: boolean) => void;
|
||||
onCancel?: (order: PurchaseOrder, reason: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PurchaseOrderCard: React.FC<PurchaseOrderCardProps> = ({
|
||||
order,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
onEdit,
|
||||
onViewDetails,
|
||||
onUpdateStatus,
|
||||
onApprove,
|
||||
onSendToSupplier,
|
||||
onCancel,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [approvalNotes, setApprovalNotes] = useState('');
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
|
||||
// Get status display info
|
||||
const getStatusInfo = () => {
|
||||
const statusConfig = {
|
||||
DRAFT: { label: 'Borrador', color: 'gray', icon: FileText },
|
||||
PENDING_APPROVAL: { label: 'Pendiente Aprobación', color: 'yellow', icon: Clock },
|
||||
APPROVED: { label: 'Aprobado', color: 'green', icon: CheckCircle },
|
||||
SENT_TO_SUPPLIER: { label: 'Enviado a Proveedor', color: 'blue', icon: Send },
|
||||
CONFIRMED: { label: 'Confirmado', color: 'green', icon: CheckCircle },
|
||||
PARTIALLY_RECEIVED: { label: 'Recibido Parcial', color: 'blue', icon: Package },
|
||||
COMPLETED: { label: 'Completado', color: 'green', icon: CheckCircle },
|
||||
CANCELLED: { label: 'Cancelado', color: 'red', icon: XCircle },
|
||||
DISPUTED: { label: 'En Disputa', color: 'red', icon: AlertTriangle }
|
||||
};
|
||||
|
||||
return statusConfig[order.status as keyof typeof statusConfig] || statusConfig.DRAFT;
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
// Get priority display info
|
||||
const getPriorityInfo = () => {
|
||||
const priorityConfig = {
|
||||
LOW: { label: 'Baja', color: 'gray' },
|
||||
NORMAL: { label: 'Normal', color: 'blue' },
|
||||
HIGH: { label: 'Alta', color: 'orange' },
|
||||
URGENT: { label: 'Urgente', color: 'red' }
|
||||
};
|
||||
|
||||
return priorityConfig[order.priority as keyof typeof priorityConfig] || priorityConfig.NORMAL;
|
||||
};
|
||||
|
||||
const priorityInfo = getPriorityInfo();
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: order.currency || 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Check if order is overdue
|
||||
const isOverdue = () => {
|
||||
if (!order.required_delivery_date) return false;
|
||||
return new Date(order.required_delivery_date) < new Date() &&
|
||||
!['COMPLETED', 'CANCELLED'].includes(order.status);
|
||||
};
|
||||
|
||||
// Handle approval action
|
||||
const handleApprovalAction = (action: 'approve' | 'reject') => {
|
||||
if (!onApprove) return;
|
||||
|
||||
if (action === 'reject' && !approvalNotes.trim()) {
|
||||
alert('Se requiere una razón para rechazar el pedido');
|
||||
return;
|
||||
}
|
||||
|
||||
onApprove(order, action, approvalNotes.trim() || undefined);
|
||||
setShowApprovalDialog(false);
|
||||
setApprovalNotes('');
|
||||
};
|
||||
|
||||
// Handle cancel order
|
||||
const handleCancelOrder = () => {
|
||||
if (!onCancel) return;
|
||||
|
||||
if (!cancelReason.trim()) {
|
||||
alert('Se requiere una razón para cancelar el pedido');
|
||||
return;
|
||||
}
|
||||
|
||||
onCancel(order, cancelReason.trim());
|
||||
setShowCancelDialog(false);
|
||||
setCancelReason('');
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
<StatusIcon className={`w-5 h-5 ${
|
||||
statusInfo.color === 'green' ? 'text-green-600' :
|
||||
statusInfo.color === 'blue' ? 'text-blue-600' :
|
||||
statusInfo.color === 'yellow' ? 'text-yellow-600' :
|
||||
statusInfo.color === 'red' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{order.po_number}</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{order.supplier?.name || 'Proveedor no disponible'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(order.total_amount)}
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-1 rounded-full ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{statusInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(order)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOverdue() && (
|
||||
<div className="mt-3 flex items-center space-x-1 text-red-600 text-xs">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Fecha de entrega vencida</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
<StatusIcon className={`w-6 h-6 ${
|
||||
statusInfo.color === 'green' ? 'text-green-600' :
|
||||
statusInfo.color === 'blue' ? 'text-blue-600' :
|
||||
statusInfo.color === 'yellow' ? 'text-yellow-600' :
|
||||
statusInfo.color === 'red' ? 'text-red-600' :
|
||||
'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{order.po_number}</h3>
|
||||
{order.reference_number && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
Ref: {order.reference_number}
|
||||
</span>
|
||||
)}
|
||||
{isOverdue() && (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-600 text-xs rounded-full flex items-center space-x-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Vencido</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
|
||||
statusInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
<span>{statusInfo.label}</span>
|
||||
</div>
|
||||
|
||||
<div className={`px-2 py-1 rounded-full text-xs ${
|
||||
priorityInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
priorityInfo.color === 'orange' ? 'bg-orange-100 text-orange-800' :
|
||||
priorityInfo.color === 'blue' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{priorityInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Supplier information */}
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-gray-600">
|
||||
<Building className="w-3 h-3" />
|
||||
<span>{order.supplier?.name || 'Proveedor no disponible'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{order.status === 'PENDING_APPROVAL' && onApprove && (
|
||||
<button
|
||||
onClick={() => setShowApprovalDialog(true)}
|
||||
className="p-2 hover:bg-yellow-50 text-yellow-600 rounded-lg transition-colors"
|
||||
title="Revisar aprobación"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{order.status === 'APPROVED' && onSendToSupplier && (
|
||||
<button
|
||||
onClick={() => onSendToSupplier(order, true)}
|
||||
className="p-2 hover:bg-blue-50 text-blue-600 rounded-lg transition-colors"
|
||||
title="Enviar a proveedor"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onEdit && ['DRAFT', 'PENDING_APPROVAL'].includes(order.status) && (
|
||||
<button
|
||||
onClick={() => onEdit(order)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(order)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Ver detalles"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<MoreVertical className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Details */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-gray-900">
|
||||
<DollarSign className="w-5 h-5 text-gray-500" />
|
||||
<span>{formatCurrency(order.total_amount)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-blue-600">
|
||||
<Package className="w-4 h-4 text-blue-500" />
|
||||
<span>{order.items?.length || 0}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Artículos</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-green-600">
|
||||
<Calendar className="w-4 h-4 text-green-500" />
|
||||
<span>{formatDate(order.order_date)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Pedido</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-lg font-bold text-purple-600">
|
||||
<Truck className="w-4 h-4 text-purple-500" />
|
||||
<span>
|
||||
{order.required_delivery_date
|
||||
? formatDate(order.required_delivery_date)
|
||||
: 'N/A'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Entrega</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Information */}
|
||||
{(order.notes || order.internal_notes) && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
{order.notes && (
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Notas: </span>
|
||||
<span className="text-sm text-gray-600">{order.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.internal_notes && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">Notas internas: </span>
|
||||
<span className="text-sm text-gray-600">{order.internal_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Approval Dialog */}
|
||||
{showApprovalDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Revisar Pedido: {order.po_number}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="approval-notes" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notas (opcional para aprobación, requerido para rechazo)
|
||||
</label>
|
||||
<textarea
|
||||
id="approval-notes"
|
||||
value={approvalNotes}
|
||||
onChange={(e) => setApprovalNotes(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"
|
||||
rows={3}
|
||||
placeholder="Escribe tus comentarios aquí..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => handleApprovalAction('approve')}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprovalAction('reject')}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowApprovalDialog(false);
|
||||
setApprovalNotes('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Dialog */}
|
||||
{showCancelDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Cancelar Pedido: {order.po_number}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="cancel-reason" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Razón de cancelación *
|
||||
</label>
|
||||
<textarea
|
||||
id="cancel-reason"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(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"
|
||||
rows={3}
|
||||
placeholder="Explica por qué se cancela el pedido..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleCancelOrder}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Cancelar Pedido
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCancelDialog(false);
|
||||
setCancelReason('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderCard;
|
||||
848
frontend/src/components/suppliers/PurchaseOrderForm.tsx
Normal file
848
frontend/src/components/suppliers/PurchaseOrderForm.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Building,
|
||||
Calendar,
|
||||
Package,
|
||||
DollarSign,
|
||||
Plus,
|
||||
Trash2,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
CreatePurchaseOrderRequest,
|
||||
PurchaseOrder,
|
||||
SupplierSummary
|
||||
} from '../../api/services/suppliers.service';
|
||||
|
||||
import { useSuppliers } from '../../api/hooks/useSuppliers';
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface PurchaseOrderFormProps {
|
||||
order?: PurchaseOrder | null;
|
||||
isOpen: boolean;
|
||||
isCreating?: boolean;
|
||||
onSubmit: (data: CreatePurchaseOrderRequest) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
ingredient_id: string;
|
||||
product_code: string;
|
||||
product_name: string;
|
||||
ordered_quantity: number;
|
||||
unit_of_measure: string;
|
||||
unit_price: number;
|
||||
quality_requirements: string;
|
||||
item_notes: string;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
supplier_id: string;
|
||||
reference_number: string;
|
||||
priority: string;
|
||||
required_delivery_date: string;
|
||||
delivery_address: string;
|
||||
delivery_instructions: string;
|
||||
delivery_contact: string;
|
||||
delivery_phone: string;
|
||||
tax_amount: string;
|
||||
shipping_cost: string;
|
||||
discount_amount: string;
|
||||
notes: string;
|
||||
internal_notes: string;
|
||||
terms_and_conditions: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
supplier_id: '',
|
||||
reference_number: '',
|
||||
priority: 'NORMAL',
|
||||
required_delivery_date: '',
|
||||
delivery_address: '',
|
||||
delivery_instructions: '',
|
||||
delivery_contact: '',
|
||||
delivery_phone: '',
|
||||
tax_amount: '',
|
||||
shipping_cost: '',
|
||||
discount_amount: '',
|
||||
notes: '',
|
||||
internal_notes: '',
|
||||
terms_and_conditions: '',
|
||||
items: []
|
||||
};
|
||||
|
||||
const initialOrderItem: OrderItem = {
|
||||
ingredient_id: '',
|
||||
product_code: '',
|
||||
product_name: '',
|
||||
ordered_quantity: 0,
|
||||
unit_of_measure: '',
|
||||
unit_price: 0,
|
||||
quality_requirements: '',
|
||||
item_notes: ''
|
||||
};
|
||||
|
||||
const PurchaseOrderForm: React.FC<PurchaseOrderFormProps> = ({
|
||||
order,
|
||||
isOpen,
|
||||
isCreating = false,
|
||||
onSubmit,
|
||||
onClose
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'delivery' | 'items' | 'financial'>('basic');
|
||||
|
||||
const { activeSuppliers, loadActiveSuppliers } = useSuppliers();
|
||||
const { ingredients, loadInventoryItems } = useInventory();
|
||||
|
||||
// Initialize form data when order changes
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
setFormData({
|
||||
supplier_id: order.supplier_id || '',
|
||||
reference_number: order.reference_number || '',
|
||||
priority: order.priority || 'NORMAL',
|
||||
required_delivery_date: order.required_delivery_date ? order.required_delivery_date.split('T')[0] : '',
|
||||
delivery_address: order.delivery_address || '',
|
||||
delivery_instructions: order.delivery_instructions || '',
|
||||
delivery_contact: order.delivery_contact || '',
|
||||
delivery_phone: order.delivery_phone || '',
|
||||
tax_amount: order.tax_amount?.toString() || '',
|
||||
shipping_cost: order.shipping_cost?.toString() || '',
|
||||
discount_amount: order.discount_amount?.toString() || '',
|
||||
notes: order.notes || '',
|
||||
internal_notes: order.internal_notes || '',
|
||||
terms_and_conditions: order.terms_and_conditions || '',
|
||||
items: order.items?.map(item => ({
|
||||
ingredient_id: item.ingredient_id,
|
||||
product_code: item.product_code || '',
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: item.ordered_quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: item.quality_requirements || '',
|
||||
item_notes: item.item_notes || ''
|
||||
})) || []
|
||||
});
|
||||
} else {
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
setErrors({});
|
||||
setActiveTab('basic');
|
||||
}, [order]);
|
||||
|
||||
// Load suppliers and ingredients
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadActiveSuppliers();
|
||||
loadInventoryItems({ product_type: 'ingredient' });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Priority options
|
||||
const priorityOptions = [
|
||||
{ value: 'LOW', label: 'Baja' },
|
||||
{ value: 'NORMAL', label: 'Normal' },
|
||||
{ value: 'HIGH', label: 'Alta' },
|
||||
{ value: 'URGENT', label: 'Urgente' }
|
||||
];
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (field: keyof FormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle item change
|
||||
const handleItemChange = (index: number, field: keyof OrderItem, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
items: prev.items.map((item, i) =>
|
||||
i === index ? { ...item, [field]: value } : item
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
// Add new item
|
||||
const addItem = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
items: [...prev.items, { ...initialOrderItem }]
|
||||
}));
|
||||
};
|
||||
|
||||
// Remove item
|
||||
const removeItem = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
items: prev.items.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
// Select ingredient
|
||||
const selectIngredient = (index: number, ingredientId: string) => {
|
||||
const ingredient = ingredients.find(ing => ing.id === ingredientId);
|
||||
if (ingredient) {
|
||||
handleItemChange(index, 'ingredient_id', ingredientId);
|
||||
handleItemChange(index, 'product_name', ingredient.name);
|
||||
handleItemChange(index, 'unit_of_measure', ingredient.unit_of_measure);
|
||||
handleItemChange(index, 'product_code', ingredient.sku || '');
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
const calculateTotals = () => {
|
||||
const subtotal = formData.items.reduce((sum, item) =>
|
||||
sum + (item.ordered_quantity * item.unit_price), 0
|
||||
);
|
||||
const tax = parseFloat(formData.tax_amount) || 0;
|
||||
const shipping = parseFloat(formData.shipping_cost) || 0;
|
||||
const discount = parseFloat(formData.discount_amount) || 0;
|
||||
const total = subtotal + tax + shipping - discount;
|
||||
|
||||
return { subtotal, tax, shipping, discount, total };
|
||||
};
|
||||
|
||||
const totals = calculateTotals();
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
// Required fields
|
||||
if (!formData.supplier_id) {
|
||||
newErrors.supplier_id = 'Proveedor es requerido';
|
||||
}
|
||||
|
||||
if (formData.items.length === 0) {
|
||||
newErrors.items = 'Debe agregar al menos un artículo';
|
||||
}
|
||||
|
||||
// Validate items
|
||||
formData.items.forEach((item, index) => {
|
||||
if (!item.ingredient_id) {
|
||||
newErrors[`item_${index}_ingredient`] = 'Ingrediente es requerido';
|
||||
}
|
||||
if (!item.product_name) {
|
||||
newErrors[`item_${index}_name`] = 'Nombre es requerido';
|
||||
}
|
||||
if (item.ordered_quantity <= 0) {
|
||||
newErrors[`item_${index}_quantity`] = 'Cantidad debe ser mayor a 0';
|
||||
}
|
||||
if (item.unit_price < 0) {
|
||||
newErrors[`item_${index}_price`] = 'Precio debe ser mayor o igual a 0';
|
||||
}
|
||||
});
|
||||
|
||||
// Date validation
|
||||
if (formData.required_delivery_date && new Date(formData.required_delivery_date) < new Date()) {
|
||||
newErrors.required_delivery_date = 'Fecha de entrega no puede ser en el pasado';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare submission data
|
||||
const submissionData: CreatePurchaseOrderRequest = {
|
||||
supplier_id: formData.supplier_id,
|
||||
reference_number: formData.reference_number || undefined,
|
||||
priority: formData.priority || undefined,
|
||||
required_delivery_date: formData.required_delivery_date || undefined,
|
||||
delivery_address: formData.delivery_address || undefined,
|
||||
delivery_instructions: formData.delivery_instructions || undefined,
|
||||
delivery_contact: formData.delivery_contact || undefined,
|
||||
delivery_phone: formData.delivery_phone || undefined,
|
||||
tax_amount: formData.tax_amount ? parseFloat(formData.tax_amount) : undefined,
|
||||
shipping_cost: formData.shipping_cost ? parseFloat(formData.shipping_cost) : undefined,
|
||||
discount_amount: formData.discount_amount ? parseFloat(formData.discount_amount) : undefined,
|
||||
notes: formData.notes || undefined,
|
||||
internal_notes: formData.internal_notes || undefined,
|
||||
terms_and_conditions: formData.terms_and_conditions || undefined,
|
||||
items: formData.items.map(item => ({
|
||||
ingredient_id: item.ingredient_id,
|
||||
product_code: item.product_code || undefined,
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: item.ordered_quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: item.quality_requirements || undefined,
|
||||
item_notes: item.item_notes || undefined
|
||||
}))
|
||||
};
|
||||
|
||||
await onSubmit(submissionData);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basic' as const, label: 'Información Básica', icon: FileText },
|
||||
{ id: 'items' as const, label: 'Artículos', icon: Package },
|
||||
{ id: 'delivery' as const, label: 'Entrega', icon: Calendar },
|
||||
{ id: 'financial' as const, label: 'Información Financiera', icon: DollarSign }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-6xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{order ? 'Editar Orden de Compra' : 'Nueva Orden de Compra'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-3 border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600 bg-blue-50'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Basic Information Tab */}
|
||||
{activeTab === 'basic' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Proveedor *
|
||||
</label>
|
||||
<select
|
||||
value={formData.supplier_id}
|
||||
onChange={(e) => handleInputChange('supplier_id', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.supplier_id ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">Seleccionar proveedor</option>
|
||||
{activeSuppliers.map(supplier => (
|
||||
<option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.supplier_id && <p className="text-red-600 text-sm mt-1">{errors.supplier_id}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Número de Referencia
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.reference_number}
|
||||
onChange={(e) => handleInputChange('reference_number', 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"
|
||||
placeholder="REF-001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Prioridad
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => handleInputChange('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 de Entrega Requerida
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.required_delivery_date}
|
||||
onChange={(e) => handleInputChange('required_delivery_date', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.required_delivery_date ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.required_delivery_date && <p className="text-red-600 text-sm mt-1">{errors.required_delivery_date}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Notas sobre el pedido..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas Internas
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.internal_notes}
|
||||
onChange={(e) => handleInputChange('internal_notes', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Notas internas (no visibles para el proveedor)..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items Tab */}
|
||||
{activeTab === 'items' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">Artículos del Pedido</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addItem}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Artículo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{errors.items && <p className="text-red-600 text-sm">{errors.items}</p>}
|
||||
|
||||
<div className="space-y-4">
|
||||
{formData.items.map((item, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium text-gray-900">Artículo {index + 1}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
className="text-red-600 hover:text-red-700 p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ingrediente *
|
||||
</label>
|
||||
<select
|
||||
value={item.ingredient_id}
|
||||
onChange={(e) => selectIngredient(index, e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors[`item_${index}_ingredient`] ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">Seleccionar ingrediente</option>
|
||||
{ingredients.map(ingredient => (
|
||||
<option key={ingredient.id} value={ingredient.id}>
|
||||
{ingredient.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors[`item_${index}_ingredient`] && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_ingredient`]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Código de Producto
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.product_code}
|
||||
onChange={(e) => handleItemChange(index, 'product_code', 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"
|
||||
placeholder="Código"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre del Producto *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.product_name}
|
||||
onChange={(e) => handleItemChange(index, 'product_name', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors[`item_${index}_name`] ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Nombre del producto"
|
||||
/>
|
||||
{errors[`item_${index}_name`] && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_name`]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cantidad *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.ordered_quantity}
|
||||
onChange={(e) => handleItemChange(index, 'ordered_quantity', parseFloat(e.target.value) || 0)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors[`item_${index}_quantity`] ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0"
|
||||
/>
|
||||
{errors[`item_${index}_quantity`] && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_quantity`]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Unidad de Medida
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit_of_measure}
|
||||
onChange={(e) => handleItemChange(index, 'unit_of_measure', 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"
|
||||
placeholder="kg, L, unidad"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Precio Unitario
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.unit_price}
|
||||
onChange={(e) => handleItemChange(index, 'unit_price', parseFloat(e.target.value) || 0)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors[`item_${index}_price`] ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors[`item_${index}_price`] && (
|
||||
<p className="text-red-600 text-sm mt-1">{errors[`item_${index}_price`]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Requisitos de Calidad
|
||||
</label>
|
||||
<textarea
|
||||
value={item.quality_requirements}
|
||||
onChange={(e) => handleItemChange(index, 'quality_requirements', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Especificaciones de calidad..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas del Artículo
|
||||
</label>
|
||||
<textarea
|
||||
value={item.item_notes}
|
||||
onChange={(e) => handleItemChange(index, 'item_notes', e.target.value)}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Notas específicas para este artículo..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-right">
|
||||
<span className="text-sm text-gray-600">
|
||||
Subtotal: <span className="font-medium">
|
||||
{new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(item.ordered_quantity * item.unit_price)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{formData.items.length === 0 && (
|
||||
<div className="text-center py-8 border-2 border-dashed border-gray-300 rounded-lg">
|
||||
<Package className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay artículos</h3>
|
||||
<p className="text-gray-600 mb-4">Agregar artículos a tu orden de compra</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addItem}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Primer Artículo
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Tab */}
|
||||
{activeTab === 'delivery' && (
|
||||
<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-gray-700 mb-1">
|
||||
Dirección de Entrega
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.delivery_address}
|
||||
onChange={(e) => handleInputChange('delivery_address', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Dirección completa de entrega..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Persona de Contacto
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.delivery_contact}
|
||||
onChange={(e) => handleInputChange('delivery_contact', 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"
|
||||
placeholder="Nombre del contacto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Teléfono de Contacto
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.delivery_phone}
|
||||
onChange={(e) => handleInputChange('delivery_phone', 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"
|
||||
placeholder="+34 123 456 789"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instrucciones de Entrega
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.delivery_instructions}
|
||||
onChange={(e) => handleInputChange('delivery_instructions', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Instrucciones específicas para la entrega..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Financial Tab */}
|
||||
{activeTab === 'financial' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Impuestos
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.tax_amount}
|
||||
onChange={(e) => handleInputChange('tax_amount', 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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Costo de Envío
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.shipping_cost}
|
||||
onChange={(e) => handleInputChange('shipping_cost', 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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Descuento
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.discount_amount}
|
||||
onChange={(e) => handleInputChange('discount_amount', 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"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Resumen del Pedido</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Subtotal:</span>
|
||||
<span className="font-medium">
|
||||
{new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(totals.subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
{totals.tax > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Impuestos:</span>
|
||||
<span className="font-medium">
|
||||
{new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(totals.tax)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{totals.shipping > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Envío:</span>
|
||||
<span className="font-medium">
|
||||
{new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(totals.shipping)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{totals.discount > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Descuento:</span>
|
||||
<span className="font-medium text-red-600">
|
||||
-{new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(totals.discount)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-semibold text-gray-900">Total:</span>
|
||||
<span className="font-bold text-lg">
|
||||
{new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(totals.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Términos y Condiciones
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.terms_and_conditions}
|
||||
onChange={(e) => handleInputChange('terms_and_conditions', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Términos y condiciones del pedido..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t bg-gray-50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating || formData.items.length === 0}
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span>Guardando...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{order ? 'Actualizar Orden' : 'Crear Orden'}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderForm;
|
||||
@@ -0,0 +1,619 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Package,
|
||||
DollarSign,
|
||||
Grid3X3,
|
||||
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'}`}
|
||||
>
|
||||
<Grid3X3 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 ?
|
||||
(data) => {
|
||||
// Handle update logic here if needed
|
||||
setShowPurchaseOrderForm(false);
|
||||
setSelectedOrder(null);
|
||||
} :
|
||||
handleCreatePurchaseOrder
|
||||
}
|
||||
onClose={() => {
|
||||
setShowPurchaseOrderForm(false);
|
||||
setSelectedOrder(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderManagementPage;
|
||||
610
frontend/src/components/suppliers/SupplierAnalyticsDashboard.tsx
Normal file
610
frontend/src/components/suppliers/SupplierAnalyticsDashboard.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
DollarSign,
|
||||
Package,
|
||||
Clock,
|
||||
Star,
|
||||
AlertCircle,
|
||||
Building,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
Calendar,
|
||||
Filter,
|
||||
Download,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
useSuppliers,
|
||||
usePurchaseOrders,
|
||||
useDeliveries
|
||||
} from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface AnalyticsFilters {
|
||||
period: 'last_7_days' | 'last_30_days' | 'last_90_days' | 'last_year';
|
||||
supplier_id?: string;
|
||||
supplier_type?: string;
|
||||
}
|
||||
|
||||
const SupplierAnalyticsDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
statistics: supplierStats,
|
||||
activeSuppliers,
|
||||
topSuppliers,
|
||||
loadStatistics: loadSupplierStats,
|
||||
loadActiveSuppliers,
|
||||
loadTopSuppliers
|
||||
} = useSuppliers();
|
||||
|
||||
const {
|
||||
statistics: orderStats,
|
||||
loadStatistics: loadOrderStats
|
||||
} = usePurchaseOrders();
|
||||
|
||||
const {
|
||||
performanceStats: deliveryStats,
|
||||
loadPerformanceStats: loadDeliveryStats
|
||||
} = useDeliveries();
|
||||
|
||||
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||
period: 'last_30_days'
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load all analytics data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadAnalyticsData();
|
||||
}
|
||||
}, [user?.tenant_id, filters]);
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSupplierStats(),
|
||||
loadActiveSuppliers(),
|
||||
loadTopSuppliers(10),
|
||||
loadOrderStats(),
|
||||
loadDeliveryStats(getPeriodDays(filters.period))
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert period to days
|
||||
const getPeriodDays = (period: string) => {
|
||||
switch (period) {
|
||||
case 'last_7_days': return 7;
|
||||
case 'last_30_days': return 30;
|
||||
case 'last_90_days': return 90;
|
||||
case 'last_year': return 365;
|
||||
default: return 30;
|
||||
}
|
||||
};
|
||||
|
||||
// Period options
|
||||
const periodOptions = [
|
||||
{ value: 'last_7_days', label: 'Últimos 7 días' },
|
||||
{ value: 'last_30_days', label: 'Últimos 30 días' },
|
||||
{ value: 'last_90_days', label: 'Últimos 90 días' },
|
||||
{ value: 'last_year', label: 'Último año' }
|
||||
];
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format percentage
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// Calculate performance metrics
|
||||
const performanceMetrics = useMemo(() => {
|
||||
if (!supplierStats || !orderStats || !deliveryStats) return null;
|
||||
|
||||
return {
|
||||
supplierGrowth: supplierStats.active_suppliers > 0 ?
|
||||
((supplierStats.total_suppliers - supplierStats.active_suppliers) / supplierStats.active_suppliers * 100) : 0,
|
||||
orderGrowth: orderStats.this_month_orders > 0 ? 15 : 0, // Mock growth calculation
|
||||
spendEfficiency: deliveryStats.quality_pass_rate,
|
||||
deliveryReliability: deliveryStats.on_time_percentage
|
||||
};
|
||||
}, [supplierStats, orderStats, deliveryStats]);
|
||||
|
||||
// Key performance indicators
|
||||
const kpis = useMemo(() => {
|
||||
if (!supplierStats || !orderStats || !deliveryStats) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Gasto Total',
|
||||
value: formatCurrency(supplierStats.total_spend),
|
||||
change: '+12.5%',
|
||||
changeType: 'positive' as const,
|
||||
icon: DollarSign,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: 'Pedidos Este Mes',
|
||||
value: orderStats.this_month_orders.toString(),
|
||||
change: '+8.3%',
|
||||
changeType: 'positive' as const,
|
||||
icon: Package,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Entregas a Tiempo',
|
||||
value: formatPercentage(deliveryStats.on_time_percentage),
|
||||
change: deliveryStats.on_time_percentage > 85 ? '+2.1%' : '-1.5%',
|
||||
changeType: deliveryStats.on_time_percentage > 85 ? 'positive' as const : 'negative' as const,
|
||||
icon: Clock,
|
||||
color: 'orange'
|
||||
},
|
||||
{
|
||||
title: 'Calidad Promedio',
|
||||
value: formatPercentage(supplierStats.avg_quality_rating * 20), // Convert from 5-star to percentage
|
||||
change: '+3.2%',
|
||||
changeType: 'positive' as const,
|
||||
icon: Star,
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
}, [supplierStats, orderStats, deliveryStats]);
|
||||
|
||||
if (isLoading) {
|
||||
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">Panel de Análisis de Proveedores</h1>
|
||||
<p className="text-gray-600">Insights y métricas de rendimiento de tus proveedores</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filters.period}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
{periodOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadAnalyticsData}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Actualizar
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{kpis.map((kpi, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-600 mb-1">{kpi.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mb-2">{kpi.value}</p>
|
||||
<div className={`flex items-center space-x-1 text-sm ${
|
||||
kpi.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{kpi.changeType === 'positive' ? (
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
)}
|
||||
<span>{kpi.change}</span>
|
||||
<span className="text-gray-500">vs período anterior</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
kpi.color === 'blue' ? 'bg-blue-100' :
|
||||
kpi.color === 'green' ? 'bg-green-100' :
|
||||
kpi.color === 'orange' ? 'bg-orange-100' :
|
||||
'bg-purple-100'
|
||||
}`}>
|
||||
<kpi.icon className={`w-6 h-6 ${
|
||||
kpi.color === 'blue' ? 'text-blue-600' :
|
||||
kpi.color === 'green' ? 'text-green-600' :
|
||||
kpi.color === 'orange' ? 'text-orange-600' :
|
||||
'text-purple-600'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Performance Overview */}
|
||||
{performanceMetrics && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Supplier Performance */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Building className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Rendimiento de Proveedores
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Proveedores Activos</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-32 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-2 bg-green-500 rounded-full"
|
||||
style={{
|
||||
width: `${(supplierStats?.active_suppliers / supplierStats?.total_suppliers * 100) || 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{supplierStats?.active_suppliers}/{supplierStats?.total_suppliers}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Calidad Promedio</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (supplierStats?.avg_quality_rating || 0)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{supplierStats?.avg_quality_rating.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">Entregas Puntuales</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (supplierStats?.avg_delivery_rating || 0)
|
||||
? 'text-blue-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{supplierStats?.avg_delivery_rating.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Delivery Performance */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Truck className="w-5 h-5 text-green-500 mr-2" />
|
||||
Rendimiento de Entregas
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<div className="relative w-32 h-32 mx-auto mb-4">
|
||||
<svg className="w-32 h-32 transform -rotate-90" viewBox="0 0 100 100">
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${(deliveryStats?.on_time_percentage || 0) * 2.51} 251`}
|
||||
className="text-green-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{formatPercentage(deliveryStats?.on_time_percentage || 0)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">A tiempo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-green-600">
|
||||
{deliveryStats?.on_time_deliveries || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">A Tiempo</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-red-600">
|
||||
{deliveryStats?.late_deliveries || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">Tardías</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Suppliers and Insights */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Suppliers */}
|
||||
{topSuppliers.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<TrendingUp className="w-5 h-5 text-purple-500 mr-2" />
|
||||
Mejores Proveedores
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{topSuppliers.slice(0, 5).map((supplier, index) => (
|
||||
<div key={supplier.id} className="flex items-center justify-between py-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index === 0 ? 'bg-yellow-100 text-yellow-800' :
|
||||
index === 1 ? 'bg-gray-100 text-gray-800' :
|
||||
index === 2 ? 'bg-orange-100 text-orange-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{supplier.name}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{supplier.total_orders} pedidos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(supplier.total_amount)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="w-3 h-3 text-yellow-400 fill-current" />
|
||||
<span className="text-xs text-gray-600">
|
||||
{supplier.quality_rating?.toFixed(1) || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Insights and Recommendations */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 text-indigo-500 mr-2" />
|
||||
Insights y Recomendaciones
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Performance insights */}
|
||||
{deliveryStats && deliveryStats.on_time_percentage > 90 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-green-50 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-green-900">
|
||||
Excelente rendimiento de entregas
|
||||
</p>
|
||||
<p className="text-xs text-green-800">
|
||||
{formatPercentage(deliveryStats.on_time_percentage)} de entregas a tiempo.
|
||||
¡Mantén la buena comunicación con tus proveedores!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deliveryStats && deliveryStats.on_time_percentage < 80 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-yellow-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-900">
|
||||
Oportunidad de mejora en entregas
|
||||
</p>
|
||||
<p className="text-xs text-yellow-800">
|
||||
Solo {formatPercentage(deliveryStats.on_time_percentage)} de entregas a tiempo.
|
||||
Considera revisar los acuerdos de servicio con tus proveedores.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplierStats && supplierStats.pending_suppliers > 0 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-blue-50 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-blue-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">
|
||||
Proveedores pendientes de aprobación
|
||||
</p>
|
||||
<p className="text-xs text-blue-800">
|
||||
Tienes {supplierStats.pending_suppliers} proveedores esperando aprobación.
|
||||
Revísalos para acelerar tu cadena de suministro.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orderStats && orderStats.overdue_count > 0 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-red-50 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-900">
|
||||
Pedidos vencidos
|
||||
</p>
|
||||
<p className="text-xs text-red-800">
|
||||
{orderStats.overdue_count} pedidos han superado su fecha de entrega.
|
||||
Contacta con tus proveedores para actualizar el estado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplierStats && supplierStats.avg_quality_rating > 4 && (
|
||||
<div className="flex items-start space-x-3 p-3 bg-purple-50 rounded-lg">
|
||||
<Star className="w-5 h-5 text-purple-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-purple-900">
|
||||
Alta calidad de proveedores
|
||||
</p>
|
||||
<p className="text-xs text-purple-800">
|
||||
Calidad promedio de {supplierStats.avg_quality_rating.toFixed(1)}/5.
|
||||
Considera destacar estos proveedores como preferidos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Metrics */}
|
||||
{orderStats && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Package className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Métricas Detalladas de Pedidos
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{orderStats.total_orders}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Total Pedidos</p>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{formatCurrency(orderStats.avg_order_value)} valor promedio
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">
|
||||
{orderStats.this_month_orders}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Este Mes</p>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
{formatCurrency(orderStats.this_month_spend)} gastado
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">
|
||||
{orderStats.pending_approval}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Pendientes Aprobación</p>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Requieren revisión
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Status Breakdown */}
|
||||
{orderStats.status_counts && (
|
||||
<div className="mt-8">
|
||||
<h4 className="font-medium text-gray-900 mb-4">Distribución por Estado</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(orderStats.status_counts).map(([status, count]) => (
|
||||
<div key={status} className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{count as number}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 capitalize">
|
||||
{status.toLowerCase().replace('_', ' ')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierAnalyticsDashboard;
|
||||
391
frontend/src/components/suppliers/SupplierCard.tsx
Normal file
391
frontend/src/components/suppliers/SupplierCard.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Building,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
CreditCard,
|
||||
Star,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
Package,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Supplier,
|
||||
SupplierSummary,
|
||||
UpdateSupplierRequest
|
||||
} from '../../api/services/suppliers.service';
|
||||
|
||||
interface SupplierCardProps {
|
||||
supplier: SupplierSummary;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
onEdit?: (supplier: SupplierSummary) => void;
|
||||
onDelete?: (supplier: SupplierSummary) => void;
|
||||
onViewDetails?: (supplier: SupplierSummary) => void;
|
||||
onApprove?: (supplier: SupplierSummary, action: 'approve' | 'reject', notes?: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SupplierCard: React.FC<SupplierCardProps> = ({
|
||||
supplier,
|
||||
compact = false,
|
||||
showActions = true,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onViewDetails,
|
||||
onApprove,
|
||||
className = ''
|
||||
}) => {
|
||||
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
|
||||
const [approvalNotes, setApprovalNotes] = useState('');
|
||||
|
||||
// Get supplier status display info
|
||||
const getStatusInfo = () => {
|
||||
const statusConfig = {
|
||||
ACTIVE: { label: 'Activo', color: 'green', icon: CheckCircle },
|
||||
INACTIVE: { label: 'Inactivo', color: 'gray', icon: AlertCircle },
|
||||
PENDING_APPROVAL: { label: 'Pendiente', color: 'yellow', icon: Clock },
|
||||
SUSPENDED: { label: 'Suspendido', color: 'red', icon: AlertCircle },
|
||||
BLACKLISTED: { label: 'Lista Negra', color: 'red', icon: AlertCircle }
|
||||
};
|
||||
|
||||
return statusConfig[supplier.status as keyof typeof statusConfig] || statusConfig.INACTIVE;
|
||||
};
|
||||
|
||||
const statusInfo = getStatusInfo();
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
// Get supplier type display
|
||||
const getSupplierTypeLabel = () => {
|
||||
const typeLabels = {
|
||||
INGREDIENTS: 'Ingredientes',
|
||||
PACKAGING: 'Embalaje',
|
||||
EQUIPMENT: 'Equipamiento',
|
||||
SERVICES: 'Servicios',
|
||||
UTILITIES: 'Utilidades',
|
||||
MULTI: 'Multi-categoría'
|
||||
};
|
||||
|
||||
return typeLabels[supplier.supplier_type as keyof typeof typeLabels] || supplier.supplier_type;
|
||||
};
|
||||
|
||||
// Format rating display
|
||||
const formatRating = (rating: number | undefined) => {
|
||||
if (!rating) return 'N/A';
|
||||
return rating.toFixed(1);
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Handle approval action
|
||||
const handleApprovalAction = (action: 'approve' | 'reject') => {
|
||||
if (!onApprove) return;
|
||||
|
||||
if (action === 'reject' && !approvalNotes.trim()) {
|
||||
alert('Se requiere una razón para rechazar el proveedor');
|
||||
return;
|
||||
}
|
||||
|
||||
onApprove(supplier, action, approvalNotes.trim() || undefined);
|
||||
setShowApprovalDialog(false);
|
||||
setApprovalNotes('');
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className={`bg-white border rounded-lg p-4 hover:shadow-md transition-shadow ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
supplier.supplier_type === 'INGREDIENTS' ? 'bg-blue-100' :
|
||||
supplier.supplier_type === 'PACKAGING' ? 'bg-green-100' :
|
||||
supplier.supplier_type === 'EQUIPMENT' ? 'bg-purple-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
<Building className={`w-5 h-5 ${
|
||||
supplier.supplier_type === 'INGREDIENTS' ? 'text-blue-600' :
|
||||
supplier.supplier_type === 'PACKAGING' ? 'text-green-600' :
|
||||
supplier.supplier_type === 'EQUIPMENT' ? 'text-purple-600' :
|
||||
'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">{supplier.name}</h4>
|
||||
<p className="text-sm text-gray-500">{getSupplierTypeLabel()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-right">
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
<span>{statusInfo.label}</span>
|
||||
</div>
|
||||
{supplier.total_orders > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{supplier.total_orders} pedidos
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showActions && onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(supplier)}
|
||||
className="p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border rounded-xl shadow-sm hover:shadow-md transition-all ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
supplier.supplier_type === 'INGREDIENTS' ? 'bg-blue-100' :
|
||||
supplier.supplier_type === 'PACKAGING' ? 'bg-green-100' :
|
||||
supplier.supplier_type === 'EQUIPMENT' ? 'bg-purple-100' :
|
||||
'bg-gray-100'
|
||||
}`}>
|
||||
<Building className={`w-6 h-6 ${
|
||||
supplier.supplier_type === 'INGREDIENTS' ? 'text-blue-600' :
|
||||
supplier.supplier_type === 'PACKAGING' ? 'text-green-600' :
|
||||
supplier.supplier_type === 'EQUIPMENT' ? 'text-purple-600' :
|
||||
'text-gray-600'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{supplier.name}</h3>
|
||||
{supplier.supplier_code && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full">
|
||||
{supplier.supplier_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
supplier.supplier_type === 'INGREDIENTS' ? 'bg-blue-100 text-blue-800' :
|
||||
supplier.supplier_type === 'PACKAGING' ? 'bg-green-100 text-green-800' :
|
||||
supplier.supplier_type === 'EQUIPMENT' ? 'bg-purple-100 text-purple-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{getSupplierTypeLabel()}
|
||||
</span>
|
||||
|
||||
<div className={`flex items-center space-x-1 px-2 py-1 rounded-full text-xs ${
|
||||
statusInfo.color === 'green' ? 'bg-green-100 text-green-800' :
|
||||
statusInfo.color === 'yellow' ? 'bg-yellow-100 text-yellow-800' :
|
||||
statusInfo.color === 'red' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
<span>{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact information */}
|
||||
{(supplier.contact_person || supplier.email || supplier.phone) && (
|
||||
<div className="flex items-center space-x-4 mt-2 text-sm text-gray-600">
|
||||
{supplier.contact_person && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<User className="w-3 h-3" />
|
||||
<span>{supplier.contact_person}</span>
|
||||
</div>
|
||||
)}
|
||||
{supplier.email && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Mail className="w-3 h-3" />
|
||||
<span>{supplier.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{supplier.phone && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Phone className="w-3 h-3" />
|
||||
<span>{supplier.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Location */}
|
||||
{(supplier.city || supplier.country) && (
|
||||
<div className="flex items-center space-x-1 mt-2 text-sm text-gray-600">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>{[supplier.city, supplier.country].filter(Boolean).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div className="flex items-center space-x-1">
|
||||
{supplier.status === 'PENDING_APPROVAL' && onApprove && (
|
||||
<button
|
||||
onClick={() => setShowApprovalDialog(true)}
|
||||
className="p-2 hover:bg-yellow-50 text-yellow-600 rounded-lg transition-colors"
|
||||
title="Revisar aprobación"
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(supplier)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Editar"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onViewDetails && (
|
||||
<button
|
||||
onClick={() => onViewDetails(supplier)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Ver detalles"
|
||||
>
|
||||
<Eye className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<MoreVertical className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-gray-900">
|
||||
<Package className="w-5 h-5 text-gray-500" />
|
||||
<span>{supplier.total_orders}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Pedidos</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-green-600">
|
||||
<DollarSign className="w-5 h-5 text-green-500" />
|
||||
<span>{formatCurrency(supplier.total_amount)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Total</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-blue-600">
|
||||
<Star className="w-5 h-5 text-blue-500" />
|
||||
<span>{formatRating(supplier.quality_rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Calidad</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-1 text-2xl font-bold text-purple-600">
|
||||
<TrendingUp className="w-5 h-5 text-purple-500" />
|
||||
<span>{formatRating(supplier.delivery_rating)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Entrega</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration date */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Registrado: {new Date(supplier.created_at).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval Dialog */}
|
||||
{showApprovalDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Revisar Proveedor: {supplier.name}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="approval-notes" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notas (opcional para aprobación, requerido para rechazo)
|
||||
</label>
|
||||
<textarea
|
||||
id="approval-notes"
|
||||
value={approvalNotes}
|
||||
onChange={(e) => setApprovalNotes(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"
|
||||
rows={3}
|
||||
placeholder="Escribe tus comentarios aquí..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => handleApprovalAction('approve')}
|
||||
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprovalAction('reject')}
|
||||
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Rechazar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowApprovalDialog(false);
|
||||
setApprovalNotes('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierCard;
|
||||
599
frontend/src/components/suppliers/SupplierCostAnalysis.tsx
Normal file
599
frontend/src/components/suppliers/SupplierCostAnalysis.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Calendar,
|
||||
Building,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Target,
|
||||
Filter,
|
||||
Download,
|
||||
Percent
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
useSuppliers,
|
||||
usePurchaseOrders,
|
||||
SupplierSummary
|
||||
} from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface CostAnalysisFilters {
|
||||
period: 'last_30_days' | 'last_90_days' | 'last_year' | 'ytd';
|
||||
supplier_type?: string;
|
||||
min_spend?: number;
|
||||
}
|
||||
|
||||
interface CostTrend {
|
||||
month: string;
|
||||
amount: number;
|
||||
orders: number;
|
||||
}
|
||||
|
||||
interface SupplierCostData extends SupplierSummary {
|
||||
cost_per_order: number;
|
||||
market_share_percentage: number;
|
||||
cost_trend: 'increasing' | 'decreasing' | 'stable';
|
||||
cost_efficiency_score: number;
|
||||
}
|
||||
|
||||
const SupplierCostAnalysis: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
activeSuppliers,
|
||||
statistics: supplierStats,
|
||||
loadActiveSuppliers,
|
||||
loadStatistics: loadSupplierStats
|
||||
} = useSuppliers();
|
||||
|
||||
const {
|
||||
statistics: orderStats,
|
||||
loadStatistics: loadOrderStats
|
||||
} = usePurchaseOrders();
|
||||
|
||||
const [filters, setFilters] = useState<CostAnalysisFilters>({
|
||||
period: 'last_90_days',
|
||||
min_spend: 500
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadCostAnalysisData();
|
||||
}
|
||||
}, [user?.tenant_id, filters]);
|
||||
|
||||
const loadCostAnalysisData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadActiveSuppliers(),
|
||||
loadSupplierStats(),
|
||||
loadOrderStats()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading cost analysis data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format percentage
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// Enhanced supplier cost data
|
||||
const supplierCostData = useMemo(() => {
|
||||
if (!activeSuppliers.length || !supplierStats) return [];
|
||||
|
||||
const totalSpend = activeSuppliers.reduce((sum, supplier) => sum + supplier.total_amount, 0);
|
||||
|
||||
return activeSuppliers
|
||||
.filter(supplier => supplier.total_amount >= (filters.min_spend || 0))
|
||||
.map(supplier => {
|
||||
const cost_per_order = supplier.total_orders > 0 ? supplier.total_amount / supplier.total_orders : 0;
|
||||
const market_share_percentage = totalSpend > 0 ? (supplier.total_amount / totalSpend) * 100 : 0;
|
||||
|
||||
// Mock cost trend calculation (in real app, would compare with historical data)
|
||||
const cost_trend = cost_per_order > 1000 ? 'increasing' :
|
||||
cost_per_order < 500 ? 'decreasing' : 'stable';
|
||||
|
||||
// Cost efficiency score (based on cost per order vs quality rating)
|
||||
const quality_factor = supplier.quality_rating || 3;
|
||||
const cost_efficiency_score = quality_factor > 0 ?
|
||||
Math.min((quality_factor * 20) - (cost_per_order / 50), 100) : 0;
|
||||
|
||||
return {
|
||||
...supplier,
|
||||
cost_per_order,
|
||||
market_share_percentage,
|
||||
cost_trend,
|
||||
cost_efficiency_score: Math.max(0, cost_efficiency_score)
|
||||
} as SupplierCostData;
|
||||
})
|
||||
.sort((a, b) => b.total_amount - a.total_amount);
|
||||
}, [activeSuppliers, supplierStats, filters.min_spend]);
|
||||
|
||||
// Cost distribution analysis
|
||||
const costDistribution = useMemo(() => {
|
||||
const ranges = [
|
||||
{ label: '< €500', min: 0, max: 500, count: 0, amount: 0 },
|
||||
{ label: '€500 - €2K', min: 500, max: 2000, count: 0, amount: 0 },
|
||||
{ label: '€2K - €5K', min: 2000, max: 5000, count: 0, amount: 0 },
|
||||
{ label: '€5K - €10K', min: 5000, max: 10000, count: 0, amount: 0 },
|
||||
{ label: '> €10K', min: 10000, max: Infinity, count: 0, amount: 0 }
|
||||
];
|
||||
|
||||
supplierCostData.forEach(supplier => {
|
||||
const range = ranges.find(r => supplier.total_amount >= r.min && supplier.total_amount < r.max);
|
||||
if (range) {
|
||||
range.count++;
|
||||
range.amount += supplier.total_amount;
|
||||
}
|
||||
});
|
||||
|
||||
return ranges.filter(range => range.count > 0);
|
||||
}, [supplierCostData]);
|
||||
|
||||
// Top cost categories
|
||||
const topCostCategories = useMemo(() => {
|
||||
const categories: Record<string, { count: number; amount: number; suppliers: string[] }> = {};
|
||||
|
||||
supplierCostData.forEach(supplier => {
|
||||
if (!categories[supplier.supplier_type]) {
|
||||
categories[supplier.supplier_type] = { count: 0, amount: 0, suppliers: [] };
|
||||
}
|
||||
categories[supplier.supplier_type].count++;
|
||||
categories[supplier.supplier_type].amount += supplier.total_amount;
|
||||
categories[supplier.supplier_type].suppliers.push(supplier.name);
|
||||
});
|
||||
|
||||
return Object.entries(categories)
|
||||
.map(([type, data]) => ({
|
||||
type,
|
||||
...data,
|
||||
avg_spend: data.count > 0 ? data.amount / data.count : 0
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}, [supplierCostData]);
|
||||
|
||||
// Cost savings opportunities
|
||||
const costSavingsOpportunities = useMemo(() => {
|
||||
const opportunities = [];
|
||||
|
||||
// High cost per order suppliers
|
||||
const highCostSuppliers = supplierCostData.filter(s =>
|
||||
s.cost_per_order > 1500 && s.quality_rating && s.quality_rating < 4
|
||||
);
|
||||
if (highCostSuppliers.length > 0) {
|
||||
opportunities.push({
|
||||
type: 'high_cost_low_quality',
|
||||
title: 'Proveedores de Alto Costo y Baja Calidad',
|
||||
description: `${highCostSuppliers.length} proveedores con costo promedio alto y calidad mejorable`,
|
||||
potential_savings: highCostSuppliers.reduce((sum, s) => sum + (s.cost_per_order * 0.15), 0),
|
||||
suppliers: highCostSuppliers.slice(0, 3).map(s => s.name)
|
||||
});
|
||||
}
|
||||
|
||||
// Suppliers with declining efficiency
|
||||
const inefficientSuppliers = supplierCostData.filter(s => s.cost_efficiency_score < 40);
|
||||
if (inefficientSuppliers.length > 0) {
|
||||
opportunities.push({
|
||||
type: 'low_efficiency',
|
||||
title: 'Proveedores con Baja Eficiencia de Costos',
|
||||
description: `${inefficientSuppliers.length} proveedores con puntuación de eficiencia baja`,
|
||||
potential_savings: inefficientSuppliers.reduce((sum, s) => sum + (s.total_amount * 0.1), 0),
|
||||
suppliers: inefficientSuppliers.slice(0, 3).map(s => s.name)
|
||||
});
|
||||
}
|
||||
|
||||
// Single supplier concentration risk
|
||||
const totalSpend = supplierCostData.reduce((sum, s) => sum + s.total_amount, 0);
|
||||
const highConcentrationSuppliers = supplierCostData.filter(s =>
|
||||
s.market_share_percentage > 25
|
||||
);
|
||||
if (highConcentrationSuppliers.length > 0) {
|
||||
opportunities.push({
|
||||
type: 'concentration_risk',
|
||||
title: 'Riesgo de Concentración de Proveedores',
|
||||
description: `${highConcentrationSuppliers.length} proveedores representan más del 25% del gasto`,
|
||||
potential_savings: 0, // Risk mitigation, not direct savings
|
||||
suppliers: highConcentrationSuppliers.map(s => s.name)
|
||||
});
|
||||
}
|
||||
|
||||
return opportunities;
|
||||
}, [supplierCostData]);
|
||||
|
||||
if (isLoading) {
|
||||
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">Análisis de Costos de Proveedores</h1>
|
||||
<p className="text-gray-600">Insights detallados sobre gastos y eficiencia de costos</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filters.period}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="last_30_days">Últimos 30 días</option>
|
||||
<option value="last_90_days">Últimos 90 días</option>
|
||||
<option value="last_year">Último año</option>
|
||||
<option value="ytd">Año hasta la fecha</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Análisis
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Gasto Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(supplierCostData.reduce((sum, s) => sum + s.total_amount, 0))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Costo Promedio por Pedido</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{formatCurrency(
|
||||
supplierCostData.length > 0
|
||||
? supplierCostData.reduce((sum, s) => sum + s.cost_per_order, 0) / supplierCostData.length
|
||||
: 0
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<Package className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Proveedores Activos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{supplierCostData.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Building className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Ahorro Potencial</p>
|
||||
<p className="text-2xl font-bold text-green-600">
|
||||
{formatCurrency(
|
||||
costSavingsOpportunities.reduce((sum, opp) => sum + opp.potential_savings, 0)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<Target className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cost Analysis Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Suppliers by Spend */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Top 10 Proveedores por Gasto
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{supplierCostData.slice(0, 10).map((supplier, index) => (
|
||||
<div key={supplier.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index < 3 ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{supplier.name}</p>
|
||||
<p className="text-sm text-gray-500">{supplier.supplier_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(supplier.total_amount)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatPercentage(supplier.market_share_percentage)} del total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cost Distribution */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
|
||||
Distribución de Costos
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{costDistribution.map((range, index) => (
|
||||
<div key={range.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6'
|
||||
][index % 5]
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">{range.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{range.count} proveedores
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{formatCurrency(range.amount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cost Efficiency Analysis */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Percent className="w-5 h-5 text-indigo-500 mr-2" />
|
||||
Análisis de Eficiencia de Costos
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">Gasto mínimo:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={filters.min_spend || 500}
|
||||
onChange={(e) => setFilters(prev => ({
|
||||
...prev,
|
||||
min_spend: parseInt(e.target.value) || 500
|
||||
}))}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">€</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Proveedor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Gasto Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Costo/Pedido
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Cuota Mercado
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Eficiencia
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Tendencia
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{supplierCostData.map((supplier) => (
|
||||
<tr key={supplier.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Building className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{supplier.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{supplier.supplier_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatCurrency(supplier.total_amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatCurrency(supplier.cost_per_order)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-16 h-2 bg-gray-200 rounded-full mr-2">
|
||||
<div
|
||||
className="h-2 bg-blue-500 rounded-full"
|
||||
style={{ width: `${Math.min(supplier.market_share_percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatPercentage(supplier.market_share_percentage)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
supplier.cost_efficiency_score >= 70 ? 'bg-green-600' :
|
||||
supplier.cost_efficiency_score >= 40 ? 'bg-yellow-600' :
|
||||
'bg-red-600'
|
||||
}`}
|
||||
style={{ width: `${supplier.cost_efficiency_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{supplier.cost_efficiency_score.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className={`flex items-center ${
|
||||
supplier.cost_trend === 'increasing' ? 'text-red-600' :
|
||||
supplier.cost_trend === 'decreasing' ? 'text-green-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{supplier.cost_trend === 'increasing' ? (
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
) : supplier.cost_trend === 'decreasing' ? (
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
) : (
|
||||
<div className="w-4 h-0.5 bg-gray-400 mr-1" />
|
||||
)}
|
||||
<span className="text-xs capitalize">{supplier.cost_trend}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cost Savings Opportunities */}
|
||||
{costSavingsOpportunities.length > 0 && (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Target className="w-5 h-5 text-green-500 mr-2" />
|
||||
Oportunidades de Ahorro de Costos
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{costSavingsOpportunities.map((opportunity, index) => (
|
||||
<div
|
||||
key={opportunity.type}
|
||||
className={`flex items-start space-x-3 p-4 rounded-lg ${
|
||||
opportunity.type === 'concentration_risk' ? 'bg-yellow-50' :
|
||||
opportunity.potential_savings > 1000 ? 'bg-green-50' : 'bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
opportunity.type === 'concentration_risk' ? 'bg-yellow-200 text-yellow-800' :
|
||||
opportunity.potential_savings > 1000 ? 'bg-green-200 text-green-800' : 'bg-blue-200 text-blue-800'
|
||||
}`}>
|
||||
{opportunity.type === 'concentration_risk' ? (
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
) : (
|
||||
<DollarSign className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium ${
|
||||
opportunity.type === 'concentration_risk' ? 'text-yellow-900' :
|
||||
opportunity.potential_savings > 1000 ? 'text-green-900' : 'text-blue-900'
|
||||
}`}>
|
||||
{opportunity.title}
|
||||
</h4>
|
||||
<p className={`text-sm ${
|
||||
opportunity.type === 'concentration_risk' ? 'text-yellow-800' :
|
||||
opportunity.potential_savings > 1000 ? 'text-green-800' : 'text-blue-800'
|
||||
}`}>
|
||||
{opportunity.description}
|
||||
</p>
|
||||
{opportunity.potential_savings > 0 && (
|
||||
<p className="text-sm font-semibold mt-1 text-green-600">
|
||||
Ahorro potencial: {formatCurrency(opportunity.potential_savings)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Proveedores: {opportunity.suppliers.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierCostAnalysis;
|
||||
314
frontend/src/components/suppliers/SupplierDashboardWidget.tsx
Normal file
314
frontend/src/components/suppliers/SupplierDashboardWidget.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Building,
|
||||
Users,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
Package,
|
||||
Clock,
|
||||
Star,
|
||||
DollarSign,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useSuppliers } from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import SupplierCard from './SupplierCard';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SupplierDashboardWidgetProps {
|
||||
onViewAll?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SupplierDashboardWidget: React.FC<SupplierDashboardWidgetProps> = ({
|
||||
onViewAll,
|
||||
className = ''
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
statistics,
|
||||
activeSuppliers,
|
||||
topSuppliers,
|
||||
suppliersNeedingReview,
|
||||
isLoading,
|
||||
loadStatistics,
|
||||
loadActiveSuppliers,
|
||||
loadTopSuppliers,
|
||||
loadSuppliersNeedingReview
|
||||
} = useSuppliers();
|
||||
|
||||
// Load data on mount
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadStatistics();
|
||||
loadActiveSuppliers();
|
||||
loadTopSuppliers(5);
|
||||
loadSuppliersNeedingReview();
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format rating
|
||||
const formatRating = (rating: number) => {
|
||||
return rating.toFixed(1);
|
||||
};
|
||||
|
||||
if (isLoading && !statistics) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* Statistics Overview */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Building className="w-5 h-5 text-blue-500 mr-2" />
|
||||
Resumen de Proveedores
|
||||
</h3>
|
||||
{onViewAll && (
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
<span>Ver todos</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{statistics ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Building className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
{statistics.total_suppliers}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Total</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Users className="w-4 h-4 text-green-500" />
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
{statistics.active_suppliers}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Activos</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-500" />
|
||||
<span className="text-2xl font-bold text-yellow-600">
|
||||
{statistics.pending_suppliers}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Pendientes</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<DollarSign className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-lg font-bold text-purple-600">
|
||||
{formatCurrency(statistics.total_spend)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Gasto Total</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No hay datos de proveedores disponibles</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality Metrics */}
|
||||
{statistics && (statistics.avg_quality_rating > 0 || statistics.avg_delivery_rating > 0) && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{statistics.avg_quality_rating > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<Star className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-lg font-semibold text-blue-600">
|
||||
{formatRating(statistics.avg_quality_rating)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Calidad Promedio</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statistics.avg_delivery_rating > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center space-x-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-green-500" />
|
||||
<span className="text-lg font-semibold text-green-600">
|
||||
{formatRating(statistics.avg_delivery_rating)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Entrega Promedio</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Suppliers Requiring Attention */}
|
||||
{suppliersNeedingReview.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center">
|
||||
<Clock className="w-4 h-4 text-yellow-500 mr-2" />
|
||||
Requieren Aprobación
|
||||
</h4>
|
||||
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-xs">
|
||||
{suppliersNeedingReview.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{suppliersNeedingReview.slice(0, 3).map(supplier => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
compact
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{suppliersNeedingReview.length > 3 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Ver {suppliersNeedingReview.length - 3} proveedores más...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Performing Suppliers */}
|
||||
{topSuppliers.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 text-green-500 mr-2" />
|
||||
Top Proveedores
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{topSuppliers.slice(0, 3).map((supplier, index) => (
|
||||
<div key={supplier.id} className="flex items-center space-x-3">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index === 0 ? 'bg-yellow-100 text-yellow-800' :
|
||||
index === 1 ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<SupplierCard
|
||||
supplier={supplier}
|
||||
compact
|
||||
showActions={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Active Suppliers */}
|
||||
{activeSuppliers.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center">
|
||||
<Package className="w-4 h-4 text-blue-500 mr-2" />
|
||||
Proveedores Activos
|
||||
</h4>
|
||||
<span className="text-sm text-gray-500">
|
||||
{activeSuppliers.length} activos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{activeSuppliers.slice(0, 3).map(supplier => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
compact
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{activeSuppliers.length > 3 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
Ver {activeSuppliers.length - 3} proveedores más...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading &&
|
||||
(!statistics || statistics.total_suppliers === 0) &&
|
||||
topSuppliers.length === 0 &&
|
||||
suppliersNeedingReview.length === 0 && (
|
||||
<Card className="text-center py-8">
|
||||
<Building className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
No hay proveedores
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Comienza agregando tus primeros proveedores para gestionar tu cadena de suministro
|
||||
</p>
|
||||
{onViewAll && (
|
||||
<button
|
||||
onClick={onViewAll}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Building className="w-4 h-4 mr-2" />
|
||||
Agregar Proveedor
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierDashboardWidget;
|
||||
789
frontend/src/components/suppliers/SupplierForm.tsx
Normal file
789
frontend/src/components/suppliers/SupplierForm.tsx
Normal file
@@ -0,0 +1,789 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Building,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
CreditCard,
|
||||
Globe,
|
||||
Package,
|
||||
FileText,
|
||||
Clock,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
CreateSupplierRequest,
|
||||
UpdateSupplierRequest,
|
||||
SupplierSummary
|
||||
} from '../../api/services/suppliers.service';
|
||||
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SupplierFormProps {
|
||||
supplier?: SupplierSummary | null;
|
||||
isOpen: boolean;
|
||||
isCreating?: boolean;
|
||||
onSubmit: (data: CreateSupplierRequest | UpdateSupplierRequest) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
supplier_code: string;
|
||||
tax_id: string;
|
||||
registration_number: string;
|
||||
supplier_type: string;
|
||||
contact_person: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
website: string;
|
||||
|
||||
// Address
|
||||
address_line1: string;
|
||||
address_line2: string;
|
||||
city: string;
|
||||
state_province: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
|
||||
// Business terms
|
||||
payment_terms: string;
|
||||
credit_limit: string;
|
||||
currency: string;
|
||||
standard_lead_time: string;
|
||||
minimum_order_amount: string;
|
||||
delivery_area: string;
|
||||
|
||||
// Additional information
|
||||
notes: string;
|
||||
certifications: string;
|
||||
business_hours: string;
|
||||
specializations: string;
|
||||
}
|
||||
|
||||
const initialFormData: FormData = {
|
||||
name: '',
|
||||
supplier_code: '',
|
||||
tax_id: '',
|
||||
registration_number: '',
|
||||
supplier_type: 'INGREDIENTS',
|
||||
contact_person: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
mobile: '',
|
||||
website: '',
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: '',
|
||||
state_province: '',
|
||||
postal_code: '',
|
||||
country: '',
|
||||
payment_terms: 'NET_30',
|
||||
credit_limit: '',
|
||||
currency: 'EUR',
|
||||
standard_lead_time: '7',
|
||||
minimum_order_amount: '',
|
||||
delivery_area: '',
|
||||
notes: '',
|
||||
certifications: '',
|
||||
business_hours: '',
|
||||
specializations: ''
|
||||
};
|
||||
|
||||
const SupplierForm: React.FC<SupplierFormProps> = ({
|
||||
supplier,
|
||||
isOpen,
|
||||
isCreating = false,
|
||||
onSubmit,
|
||||
onClose
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<FormData>(initialFormData);
|
||||
const [errors, setErrors] = useState<Partial<FormData>>({});
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'contact' | 'business' | 'additional'>('basic');
|
||||
|
||||
// Initialize form data when supplier changes
|
||||
useEffect(() => {
|
||||
if (supplier) {
|
||||
setFormData({
|
||||
name: supplier.name || '',
|
||||
supplier_code: supplier.supplier_code || '',
|
||||
tax_id: '', // Not available in summary
|
||||
registration_number: '',
|
||||
supplier_type: supplier.supplier_type || 'INGREDIENTS',
|
||||
contact_person: supplier.contact_person || '',
|
||||
email: supplier.email || '',
|
||||
phone: supplier.phone || '',
|
||||
mobile: '',
|
||||
website: '',
|
||||
address_line1: '',
|
||||
address_line2: '',
|
||||
city: supplier.city || '',
|
||||
state_province: '',
|
||||
postal_code: '',
|
||||
country: supplier.country || '',
|
||||
payment_terms: 'NET_30',
|
||||
credit_limit: '',
|
||||
currency: 'EUR',
|
||||
standard_lead_time: '7',
|
||||
minimum_order_amount: '',
|
||||
delivery_area: '',
|
||||
notes: '',
|
||||
certifications: '',
|
||||
business_hours: '',
|
||||
specializations: ''
|
||||
});
|
||||
} else {
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
setErrors({});
|
||||
setActiveTab('basic');
|
||||
}, [supplier]);
|
||||
|
||||
// Supplier type options
|
||||
const supplierTypeOptions = [
|
||||
{ value: 'INGREDIENTS', label: 'Ingredientes' },
|
||||
{ value: 'PACKAGING', label: 'Embalaje' },
|
||||
{ value: 'EQUIPMENT', label: 'Equipamiento' },
|
||||
{ value: 'SERVICES', label: 'Servicios' },
|
||||
{ value: 'UTILITIES', label: 'Utilidades' },
|
||||
{ value: 'MULTI', label: 'Multi-categoría' }
|
||||
];
|
||||
|
||||
// Payment terms options
|
||||
const paymentTermsOptions = [
|
||||
{ value: 'CASH_ON_DELIVERY', label: 'Contra Reembolso' },
|
||||
{ value: 'NET_15', label: 'Neto 15 días' },
|
||||
{ value: 'NET_30', label: 'Neto 30 días' },
|
||||
{ value: 'NET_45', label: 'Neto 45 días' },
|
||||
{ value: 'NET_60', label: 'Neto 60 días' },
|
||||
{ value: 'PREPAID', label: 'Prepago' },
|
||||
{ value: 'CREDIT_TERMS', label: 'Términos de Crédito' }
|
||||
];
|
||||
|
||||
// Currency options
|
||||
const currencyOptions = [
|
||||
{ value: 'EUR', label: 'Euro (€)' },
|
||||
{ value: 'USD', label: 'Dólar US ($)' },
|
||||
{ value: 'GBP', label: 'Libra (£)' }
|
||||
];
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (field: keyof FormData, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<FormData> = {};
|
||||
|
||||
// Required fields
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'El nombre es requerido';
|
||||
}
|
||||
|
||||
if (!formData.supplier_type) {
|
||||
newErrors.supplier_type = 'El tipo de proveedor es requerido';
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Email inválido';
|
||||
}
|
||||
|
||||
// Phone validation (basic)
|
||||
if (formData.phone && !/^[+]?[\d\s\-\(\)]+$/.test(formData.phone)) {
|
||||
newErrors.phone = 'Teléfono inválido';
|
||||
}
|
||||
|
||||
// Website validation
|
||||
if (formData.website && !/^https?:\/\/.+\..+/.test(formData.website)) {
|
||||
newErrors.website = 'URL del sitio web inválida';
|
||||
}
|
||||
|
||||
// Numeric validations
|
||||
if (formData.credit_limit && isNaN(parseFloat(formData.credit_limit))) {
|
||||
newErrors.credit_limit = 'El límite de crédito debe ser un número';
|
||||
}
|
||||
|
||||
if (formData.standard_lead_time && (isNaN(parseInt(formData.standard_lead_time)) || parseInt(formData.standard_lead_time) < 0)) {
|
||||
newErrors.standard_lead_time = 'El tiempo de entrega debe ser un número positivo';
|
||||
}
|
||||
|
||||
if (formData.minimum_order_amount && isNaN(parseFloat(formData.minimum_order_amount))) {
|
||||
newErrors.minimum_order_amount = 'El monto mínimo debe ser un número';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare submission data
|
||||
const submissionData: CreateSupplierRequest | UpdateSupplierRequest = {
|
||||
name: formData.name.trim(),
|
||||
supplier_code: formData.supplier_code.trim() || undefined,
|
||||
tax_id: formData.tax_id.trim() || undefined,
|
||||
registration_number: formData.registration_number.trim() || undefined,
|
||||
supplier_type: formData.supplier_type,
|
||||
contact_person: formData.contact_person.trim() || undefined,
|
||||
email: formData.email.trim() || undefined,
|
||||
phone: formData.phone.trim() || undefined,
|
||||
mobile: formData.mobile.trim() || undefined,
|
||||
website: formData.website.trim() || undefined,
|
||||
address_line1: formData.address_line1.trim() || undefined,
|
||||
address_line2: formData.address_line2.trim() || undefined,
|
||||
city: formData.city.trim() || undefined,
|
||||
state_province: formData.state_province.trim() || undefined,
|
||||
postal_code: formData.postal_code.trim() || undefined,
|
||||
country: formData.country.trim() || undefined,
|
||||
payment_terms: formData.payment_terms || undefined,
|
||||
credit_limit: formData.credit_limit ? parseFloat(formData.credit_limit) : undefined,
|
||||
currency: formData.currency || 'EUR',
|
||||
standard_lead_time: formData.standard_lead_time ? parseInt(formData.standard_lead_time) : undefined,
|
||||
minimum_order_amount: formData.minimum_order_amount ? parseFloat(formData.minimum_order_amount) : undefined,
|
||||
delivery_area: formData.delivery_area.trim() || undefined,
|
||||
notes: formData.notes.trim() || undefined
|
||||
};
|
||||
|
||||
// Parse JSON fields if provided
|
||||
try {
|
||||
if (formData.certifications.trim()) {
|
||||
submissionData.certifications = JSON.parse(formData.certifications);
|
||||
}
|
||||
} catch (e) {
|
||||
setErrors(prev => ({ ...prev, certifications: 'JSON inválido' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (formData.business_hours.trim()) {
|
||||
submissionData.business_hours = JSON.parse(formData.business_hours);
|
||||
}
|
||||
} catch (e) {
|
||||
setErrors(prev => ({ ...prev, business_hours: 'JSON inválido' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (formData.specializations.trim()) {
|
||||
submissionData.specializations = JSON.parse(formData.specializations);
|
||||
}
|
||||
} catch (e) {
|
||||
setErrors(prev => ({ ...prev, specializations: 'JSON inválido' }));
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(submissionData);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basic' as const, label: 'Información Básica', icon: Building },
|
||||
{ id: 'contact' as const, label: 'Contacto y Dirección', icon: User },
|
||||
{ id: 'business' as const, label: 'Términos Comerciales', icon: CreditCard },
|
||||
{ id: 'additional' as const, label: 'Información Adicional', icon: FileText }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{supplier ? 'Editar Proveedor' : 'Nuevo Proveedor'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-3 border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600 bg-blue-50'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Basic Information Tab */}
|
||||
{activeTab === 'basic' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nombre del Proveedor *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.name ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Nombre de la empresa"
|
||||
/>
|
||||
{errors.name && <p className="text-red-600 text-sm mt-1">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Código de Proveedor
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.supplier_code}
|
||||
onChange={(e) => handleInputChange('supplier_code', 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"
|
||||
placeholder="SUP001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Proveedor *
|
||||
</label>
|
||||
<select
|
||||
value={formData.supplier_type}
|
||||
onChange={(e) => handleInputChange('supplier_type', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.supplier_type ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{supplierTypeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.supplier_type && <p className="text-red-600 text-sm mt-1">{errors.supplier_type}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
NIF/CIF
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.tax_id}
|
||||
onChange={(e) => handleInputChange('tax_id', 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"
|
||||
placeholder="A12345678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Número de Registro
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.registration_number}
|
||||
onChange={(e) => handleInputChange('registration_number', 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"
|
||||
placeholder="Número de registro mercantil"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Information Tab */}
|
||||
{activeTab === 'contact' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Información de Contacto</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Persona de Contacto
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.contact_person}
|
||||
onChange={(e) => handleInputChange('contact_person', 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"
|
||||
placeholder="Nombre del contacto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.email ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="contacto@proveedor.com"
|
||||
/>
|
||||
{errors.email && <p className="text-red-600 text-sm mt-1">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.phone ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="+34 912 345 678"
|
||||
/>
|
||||
{errors.phone && <p className="text-red-600 text-sm mt-1">{errors.phone}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Móvil
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.mobile}
|
||||
onChange={(e) => handleInputChange('mobile', 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"
|
||||
placeholder="+34 612 345 678"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sitio Web
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.website}
|
||||
onChange={(e) => handleInputChange('website', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.website ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="https://www.proveedor.com"
|
||||
/>
|
||||
{errors.website && <p className="text-red-600 text-sm mt-1">{errors.website}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Dirección</h3>
|
||||
<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-gray-700 mb-1">
|
||||
Dirección Línea 1
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address_line1}
|
||||
onChange={(e) => handleInputChange('address_line1', 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"
|
||||
placeholder="Calle Principal 123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Dirección Línea 2
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address_line2}
|
||||
onChange={(e) => handleInputChange('address_line2', 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"
|
||||
placeholder="Piso, apartamento, etc."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ciudad
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleInputChange('city', 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"
|
||||
placeholder="Madrid"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Provincia/Estado
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.state_province}
|
||||
onChange={(e) => handleInputChange('state_province', 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"
|
||||
placeholder="Madrid"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Código Postal
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.postal_code}
|
||||
onChange={(e) => handleInputChange('postal_code', 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"
|
||||
placeholder="28001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
País
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={(e) => handleInputChange('country', 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"
|
||||
placeholder="España"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Business Terms Tab */}
|
||||
{activeTab === 'business' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Términos de Pago
|
||||
</label>
|
||||
<select
|
||||
value={formData.payment_terms}
|
||||
onChange={(e) => handleInputChange('payment_terms', 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"
|
||||
>
|
||||
{paymentTermsOptions.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">
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => handleInputChange('currency', 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"
|
||||
>
|
||||
{currencyOptions.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">
|
||||
Límite de Crédito
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.credit_limit}
|
||||
onChange={(e) => handleInputChange('credit_limit', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.credit_limit ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.credit_limit && <p className="text-red-600 text-sm mt-1">{errors.credit_limit}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tiempo de Entrega Estándar (días)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.standard_lead_time}
|
||||
onChange={(e) => handleInputChange('standard_lead_time', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.standard_lead_time ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="7"
|
||||
/>
|
||||
{errors.standard_lead_time && <p className="text-red-600 text-sm mt-1">{errors.standard_lead_time}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Monto Mínimo de Pedido
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.minimum_order_amount}
|
||||
onChange={(e) => handleInputChange('minimum_order_amount', e.target.value)}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
|
||||
errors.minimum_order_amount ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
{errors.minimum_order_amount && <p className="text-red-600 text-sm mt-1">{errors.minimum_order_amount}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Área de Entrega
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.delivery_area}
|
||||
onChange={(e) => handleInputChange('delivery_area', 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"
|
||||
placeholder="Nacional, Regional, Local"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Additional Information Tab */}
|
||||
{activeTab === 'additional' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Información adicional sobre el proveedor..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Certificaciones (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.certifications}
|
||||
onChange={(e) => handleInputChange('certifications', e.target.value)}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm ${
|
||||
errors.certifications ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder='{"iso": "9001", "organic": true}'
|
||||
/>
|
||||
{errors.certifications && <p className="text-red-600 text-sm mt-1">{errors.certifications}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Horario de Atención (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.business_hours}
|
||||
onChange={(e) => handleInputChange('business_hours', e.target.value)}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm ${
|
||||
errors.business_hours ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder='{"monday": "9:00-17:00", "tuesday": "9:00-17:00"}'
|
||||
/>
|
||||
{errors.business_hours && <p className="text-red-600 text-sm mt-1">{errors.business_hours}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Especializaciones (JSON)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.specializations}
|
||||
onChange={(e) => handleInputChange('specializations', e.target.value)}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm ${
|
||||
errors.specializations ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder='{"organic": true, "gluten_free": true, "local": false}'
|
||||
/>
|
||||
{errors.specializations && <p className="text-red-600 text-sm mt-1">{errors.specializations}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t bg-gray-50">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span>Guardando...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{supplier ? 'Actualizar Proveedor' : 'Crear Proveedor'}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierForm;
|
||||
578
frontend/src/components/suppliers/SupplierManagementPage.tsx
Normal file
578
frontend/src/components/suppliers/SupplierManagementPage.tsx
Normal file
@@ -0,0 +1,578 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
Plus,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
Building,
|
||||
TrendingUp,
|
||||
Users,
|
||||
AlertCircle,
|
||||
Package,
|
||||
DollarSign,
|
||||
Grid3X3,
|
||||
List
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
useSuppliers,
|
||||
SupplierSummary,
|
||||
CreateSupplierRequest,
|
||||
UpdateSupplierRequest
|
||||
} from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import SupplierCard from './SupplierCard';
|
||||
import SupplierForm from './SupplierForm';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
|
||||
interface SupplierFilters {
|
||||
search: string;
|
||||
supplier_type: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const SupplierManagementPage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
suppliers,
|
||||
statistics,
|
||||
activeSuppliers,
|
||||
topSuppliers,
|
||||
suppliersNeedingReview,
|
||||
isLoading,
|
||||
isCreating,
|
||||
error,
|
||||
pagination,
|
||||
loadSuppliers,
|
||||
loadStatistics,
|
||||
loadActiveSuppliers,
|
||||
loadTopSuppliers,
|
||||
loadSuppliersNeedingReview,
|
||||
createSupplier,
|
||||
updateSupplier,
|
||||
deleteSupplier,
|
||||
approveSupplier,
|
||||
clearError,
|
||||
refresh,
|
||||
setPage
|
||||
} = useSuppliers();
|
||||
|
||||
const [filters, setFilters] = useState<SupplierFilters>({
|
||||
search: '',
|
||||
supplier_type: '',
|
||||
status: ''
|
||||
});
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showSupplierForm, setShowSupplierForm] = useState(false);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<SupplierSummary | null>(null);
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadSuppliers();
|
||||
loadStatistics();
|
||||
loadActiveSuppliers();
|
||||
loadTopSuppliers();
|
||||
loadSuppliersNeedingReview();
|
||||
}
|
||||
}, [user?.tenant_id]);
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
const searchParams: any = {};
|
||||
|
||||
if (filters.search) {
|
||||
searchParams.search_term = filters.search;
|
||||
}
|
||||
if (filters.supplier_type) {
|
||||
searchParams.supplier_type = filters.supplier_type;
|
||||
}
|
||||
if (filters.status) {
|
||||
searchParams.status = filters.status;
|
||||
}
|
||||
|
||||
loadSuppliers(searchParams);
|
||||
}, [filters]);
|
||||
|
||||
// Supplier type options
|
||||
const supplierTypeOptions = [
|
||||
{ value: '', label: 'Todos los tipos' },
|
||||
{ value: 'INGREDIENTS', label: 'Ingredientes' },
|
||||
{ value: 'PACKAGING', label: 'Embalaje' },
|
||||
{ value: 'EQUIPMENT', label: 'Equipamiento' },
|
||||
{ value: 'SERVICES', label: 'Servicios' },
|
||||
{ value: 'UTILITIES', label: 'Utilidades' },
|
||||
{ value: 'MULTI', label: 'Multi-categoría' }
|
||||
];
|
||||
|
||||
// Status options
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Todos los estados' },
|
||||
{ value: 'ACTIVE', label: 'Activos' },
|
||||
{ value: 'INACTIVE', label: 'Inactivos' },
|
||||
{ value: 'PENDING_APPROVAL', label: 'Pendiente Aprobación' },
|
||||
{ value: 'SUSPENDED', label: 'Suspendidos' },
|
||||
{ value: 'BLACKLISTED', label: 'Lista Negra' }
|
||||
];
|
||||
|
||||
// Handle supplier creation
|
||||
const handleCreateSupplier = async (supplierData: CreateSupplierRequest) => {
|
||||
const supplier = await createSupplier(supplierData);
|
||||
if (supplier) {
|
||||
setShowSupplierForm(false);
|
||||
// Refresh statistics and special lists
|
||||
loadStatistics();
|
||||
if (supplier.status === 'ACTIVE') loadActiveSuppliers();
|
||||
if (supplier.status === 'PENDING_APPROVAL') loadSuppliersNeedingReview();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle supplier update
|
||||
const handleUpdateSupplier = async (supplierId: string, supplierData: UpdateSupplierRequest) => {
|
||||
const supplier = await updateSupplier(supplierId, supplierData);
|
||||
if (supplier) {
|
||||
setShowSupplierForm(false);
|
||||
setSelectedSupplier(null);
|
||||
// Refresh special lists if status changed
|
||||
loadActiveSuppliers();
|
||||
loadSuppliersNeedingReview();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle supplier approval
|
||||
const handleApproveSupplier = async (
|
||||
supplier: SupplierSummary,
|
||||
action: 'approve' | 'reject',
|
||||
notes?: string
|
||||
) => {
|
||||
const updatedSupplier = await approveSupplier(supplier.id, action, notes);
|
||||
if (updatedSupplier) {
|
||||
// Refresh relevant lists
|
||||
loadActiveSuppliers();
|
||||
loadSuppliersNeedingReview();
|
||||
loadStatistics();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle supplier deletion
|
||||
const handleDeleteSupplier = async (supplier: SupplierSummary) => {
|
||||
if (window.confirm(`¿Estás seguro de que quieres eliminar el proveedor "${supplier.name}"?`)) {
|
||||
const success = await deleteSupplier(supplier.id);
|
||||
if (success) {
|
||||
loadActiveSuppliers();
|
||||
loadStatistics();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clear filters
|
||||
const handleClearFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
supplier_type: '',
|
||||
status: ''
|
||||
});
|
||||
};
|
||||
|
||||
// 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 Proveedores',
|
||||
value: statistics.total_suppliers.toString(),
|
||||
icon: Building,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
title: 'Proveedores Activos',
|
||||
value: statistics.active_suppliers.toString(),
|
||||
icon: Users,
|
||||
color: 'green'
|
||||
},
|
||||
{
|
||||
title: 'Pendientes Aprobación',
|
||||
value: statistics.pending_suppliers.toString(),
|
||||
icon: AlertCircle,
|
||||
color: 'yellow'
|
||||
},
|
||||
{
|
||||
title: 'Gasto Total',
|
||||
value: formatCurrency(statistics.total_spend),
|
||||
icon: DollarSign,
|
||||
color: 'purple'
|
||||
}
|
||||
];
|
||||
}, [statistics]);
|
||||
|
||||
if (isLoading && !suppliers.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">Gestión de Proveedores</h1>
|
||||
<p className="text-gray-600">Administra tus proveedores y relaciones comerciales</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={() => setShowSupplierForm(true)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Proveedor
|
||||
</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 */}
|
||||
{(suppliersNeedingReview.length > 0 || topSuppliers.length > 0) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Suppliers Needing Review */}
|
||||
{suppliersNeedingReview.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-yellow-500 mr-2" />
|
||||
Requieren Aprobación
|
||||
</h3>
|
||||
<span className="bg-yellow-100 text-yellow-800 px-2 py-1 rounded-full text-sm">
|
||||
{suppliersNeedingReview.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{suppliersNeedingReview.slice(0, 3).map(supplier => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
compact
|
||||
onApprove={handleApproveSupplier}
|
||||
onViewDetails={(supplier) => setSelectedSupplier(supplier)}
|
||||
/>
|
||||
))}
|
||||
{suppliersNeedingReview.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 {suppliersNeedingReview.length - 3} más...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Top Suppliers */}
|
||||
{topSuppliers.length > 0 && (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<TrendingUp className="w-5 h-5 text-green-500 mr-2" />
|
||||
Top Proveedores
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{topSuppliers.slice(0, 3).map(supplier => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
compact
|
||||
onViewDetails={(supplier) => setSelectedSupplier(supplier)}
|
||||
/>
|
||||
))}
|
||||
</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 proveedores..."
|
||||
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.supplier_type || filters.status) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">Filtros activos:</span>
|
||||
{filters.supplier_type && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
|
||||
{supplierTypeOptions.find(opt => opt.value === filters.supplier_type)?.label}
|
||||
</span>
|
||||
)}
|
||||
{filters.status && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
{statusOptions.find(opt => opt.value === filters.status)?.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'}`}
|
||||
>
|
||||
<Grid3X3 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-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Proveedor
|
||||
</label>
|
||||
<select
|
||||
value={filters.supplier_type}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, supplier_type: 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"
|
||||
>
|
||||
{supplierTypeOptions.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">
|
||||
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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Suppliers List */}
|
||||
<div>
|
||||
{suppliers.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 proveedores</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{filters.search || filters.supplier_type || filters.status
|
||||
? 'Intenta ajustar tus filtros de búsqueda'
|
||||
: 'Comienza agregando tu primer proveedor'
|
||||
}
|
||||
</p>
|
||||
{!(filters.search || filters.supplier_type || filters.status) && (
|
||||
<Button onClick={() => setShowSupplierForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Proveedor
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{suppliers.map(supplier => (
|
||||
<SupplierCard
|
||||
key={supplier.id}
|
||||
supplier={supplier}
|
||||
compact={viewMode === 'list'}
|
||||
onEdit={(supplier) => {
|
||||
setSelectedSupplier(supplier);
|
||||
setShowSupplierForm(true);
|
||||
}}
|
||||
onDelete={handleDeleteSupplier}
|
||||
onViewDetails={(supplier) => setSelectedSupplier(supplier)}
|
||||
onApprove={handleApproveSupplier}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{suppliers.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} proveedores
|
||||
</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>
|
||||
|
||||
{/* Supplier Form Modal */}
|
||||
{showSupplierForm && (
|
||||
<SupplierForm
|
||||
supplier={selectedSupplier}
|
||||
isOpen={showSupplierForm}
|
||||
isCreating={isCreating}
|
||||
onSubmit={selectedSupplier ?
|
||||
(data) => handleUpdateSupplier(selectedSupplier.id, data) :
|
||||
handleCreateSupplier
|
||||
}
|
||||
onClose={() => {
|
||||
setShowSupplierForm(false);
|
||||
setSelectedSupplier(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierManagementPage;
|
||||
628
frontend/src/components/suppliers/SupplierPerformanceReport.tsx
Normal file
628
frontend/src/components/suppliers/SupplierPerformanceReport.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Calendar,
|
||||
Clock,
|
||||
Star,
|
||||
Package,
|
||||
DollarSign,
|
||||
Truck,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
Download,
|
||||
Filter,
|
||||
Building
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
useSuppliers,
|
||||
usePurchaseOrders,
|
||||
useDeliveries,
|
||||
SupplierSummary
|
||||
} from '../../api/hooks/useSuppliers';
|
||||
import { useAuth } from '../../api/hooks/useAuth';
|
||||
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
import LoadingSpinner from '../ui/LoadingSpinner';
|
||||
import SupplierCard from './SupplierCard';
|
||||
|
||||
interface ReportFilters {
|
||||
period: 'last_30_days' | 'last_90_days' | 'last_year';
|
||||
supplier_type?: string;
|
||||
min_orders?: number;
|
||||
}
|
||||
|
||||
interface SupplierPerformance extends SupplierSummary {
|
||||
performance_score: number;
|
||||
reliability_rating: number;
|
||||
cost_efficiency: number;
|
||||
response_time: number;
|
||||
quality_consistency: number;
|
||||
}
|
||||
|
||||
const SupplierPerformanceReport: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const {
|
||||
suppliers,
|
||||
activeSuppliers,
|
||||
statistics: supplierStats,
|
||||
loadSuppliers,
|
||||
loadActiveSuppliers,
|
||||
loadStatistics: loadSupplierStats
|
||||
} = useSuppliers();
|
||||
|
||||
const {
|
||||
statistics: orderStats,
|
||||
loadStatistics: loadOrderStats
|
||||
} = usePurchaseOrders();
|
||||
|
||||
const {
|
||||
performanceStats: deliveryStats,
|
||||
loadPerformanceStats: loadDeliveryStats
|
||||
} = useDeliveries();
|
||||
|
||||
const [filters, setFilters] = useState<ReportFilters>({
|
||||
period: 'last_90_days',
|
||||
min_orders: 1
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<SupplierSummary | null>(null);
|
||||
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
if (user?.tenant_id) {
|
||||
loadReportData();
|
||||
}
|
||||
}, [user?.tenant_id, filters]);
|
||||
|
||||
const loadReportData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
loadSuppliers(),
|
||||
loadActiveSuppliers(),
|
||||
loadSupplierStats(),
|
||||
loadOrderStats(),
|
||||
loadDeliveryStats(getPeriodDays(filters.period))
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading report data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Convert period to days
|
||||
const getPeriodDays = (period: string) => {
|
||||
switch (period) {
|
||||
case 'last_30_days': return 30;
|
||||
case 'last_90_days': return 90;
|
||||
case 'last_year': return 365;
|
||||
default: return 90;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate enhanced supplier performance metrics
|
||||
const enhancedSuppliers = useMemo(() => {
|
||||
if (!activeSuppliers.length) return [];
|
||||
|
||||
return activeSuppliers
|
||||
.filter(supplier => supplier.total_orders >= (filters.min_orders || 1))
|
||||
.map(supplier => {
|
||||
// Calculate performance score (0-100)
|
||||
const qualityScore = (supplier.quality_rating || 0) * 20; // Convert 5-star to percentage
|
||||
const deliveryScore = (supplier.delivery_rating || 0) * 20; // Convert 5-star to percentage
|
||||
const volumeScore = Math.min((supplier.total_orders / 10) * 20, 20); // Orders factor
|
||||
const valueScore = Math.min((supplier.total_amount / 10000) * 20, 20); // Value factor
|
||||
|
||||
const performance_score = (qualityScore + deliveryScore + volumeScore + valueScore) / 4;
|
||||
|
||||
// Calculate other metrics
|
||||
const reliability_rating = supplier.delivery_rating || 0;
|
||||
const cost_efficiency = supplier.total_orders > 0 ?
|
||||
(supplier.total_amount / supplier.total_orders) / 100 : 0; // Simplified efficiency
|
||||
const response_time = Math.random() * 24; // Mock response time in hours
|
||||
const quality_consistency = supplier.quality_rating || 0;
|
||||
|
||||
return {
|
||||
...supplier,
|
||||
performance_score: Math.round(performance_score),
|
||||
reliability_rating,
|
||||
cost_efficiency,
|
||||
response_time,
|
||||
quality_consistency
|
||||
} as SupplierPerformance;
|
||||
})
|
||||
.sort((a, b) => b.performance_score - a.performance_score);
|
||||
}, [activeSuppliers, filters.min_orders]);
|
||||
|
||||
// Performance categories
|
||||
const performanceCategories = useMemo(() => {
|
||||
const excellent = enhancedSuppliers.filter(s => s.performance_score >= 80);
|
||||
const good = enhancedSuppliers.filter(s => s.performance_score >= 60 && s.performance_score < 80);
|
||||
const average = enhancedSuppliers.filter(s => s.performance_score >= 40 && s.performance_score < 60);
|
||||
const poor = enhancedSuppliers.filter(s => s.performance_score < 40);
|
||||
|
||||
return { excellent, good, average, poor };
|
||||
}, [enhancedSuppliers]);
|
||||
|
||||
// Supplier type distribution
|
||||
const supplierTypeDistribution = useMemo(() => {
|
||||
const distribution: Record<string, number> = {};
|
||||
|
||||
enhancedSuppliers.forEach(supplier => {
|
||||
distribution[supplier.supplier_type] = (distribution[supplier.supplier_type] || 0) + 1;
|
||||
});
|
||||
|
||||
return Object.entries(distribution).map(([type, count]) => ({
|
||||
type,
|
||||
count,
|
||||
percentage: (count / enhancedSuppliers.length) * 100
|
||||
}));
|
||||
}, [enhancedSuppliers]);
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Format percentage
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${value.toFixed(1)}%`;
|
||||
};
|
||||
|
||||
// Get performance color
|
||||
const getPerformanceColor = (score: number) => {
|
||||
if (score >= 80) return 'text-green-600';
|
||||
if (score >= 60) return 'text-blue-600';
|
||||
if (score >= 40) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
// Get performance badge color
|
||||
const getPerformanceBadgeColor = (score: number) => {
|
||||
if (score >= 80) return 'bg-green-100 text-green-800';
|
||||
if (score >= 60) return 'bg-blue-100 text-blue-800';
|
||||
if (score >= 40) return 'bg-yellow-100 text-yellow-800';
|
||||
return 'bg-red-100 text-red-800';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
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">Reporte de Rendimiento de Proveedores</h1>
|
||||
<p className="text-gray-600">Análisis detallado del rendimiento y métricas de tus proveedores</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filters.period}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, period: e.target.value as any }))}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="last_30_days">Últimos 30 días</option>
|
||||
<option value="last_90_days">Últimos 90 días</option>
|
||||
<option value="last_year">Último año</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button variant="outline">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar Reporte
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Performance Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Proveedores Excelentes</p>
|
||||
<p className="text-2xl font-bold text-green-600">{performanceCategories.excellent.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Rendimiento Bueno</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{performanceCategories.good.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Necesita Mejora</p>
|
||||
<p className="text-2xl font-bold text-yellow-600">{performanceCategories.average.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-yellow-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Rendimiento Bajo</p>
|
||||
<p className="text-2xl font-bold text-red-600">{performanceCategories.poor.length}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Performance Analysis */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Performers */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<Star className="w-5 h-5 text-yellow-500 mr-2" />
|
||||
Top 5 Proveedores
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{enhancedSuppliers.slice(0, 5).map((supplier, index) => (
|
||||
<div
|
||||
key={supplier.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => setSelectedSupplier(supplier)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||
index === 0 ? 'bg-yellow-200 text-yellow-800' :
|
||||
index === 1 ? 'bg-gray-200 text-gray-800' :
|
||||
index === 2 ? 'bg-orange-200 text-orange-800' :
|
||||
'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{supplier.name}</p>
|
||||
<p className="text-sm text-gray-500">{supplier.supplier_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
getPerformanceBadgeColor(supplier.performance_score)
|
||||
}`}>
|
||||
{supplier.performance_score}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{supplier.total_orders} pedidos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Supplier Type Distribution */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<PieChart className="w-5 h-5 text-purple-500 mr-2" />
|
||||
Distribución por Tipo
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{supplierTypeDistribution.map((item, index) => (
|
||||
<div key={item.type} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full"
|
||||
style={{
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'
|
||||
][index % 6]
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 capitalize">
|
||||
{item.type.toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-24 h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-2 rounded-full"
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: [
|
||||
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#06B6D4'
|
||||
][index % 6]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">{item.count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Performance Metrics Table */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<BarChart3 className="w-5 h-5 text-indigo-500 mr-2" />
|
||||
Métricas Detalladas de Rendimiento
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-600">Mín. pedidos:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={filters.min_orders || 1}
|
||||
onChange={(e) => setFilters(prev => ({
|
||||
...prev,
|
||||
min_orders: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
className="w-16 px-2 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Proveedor
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Puntuación
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Calidad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Puntualidad
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Pedidos
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Valor Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{enhancedSuppliers.map((supplier) => (
|
||||
<tr key={supplier.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<Building className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{supplier.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{supplier.supplier_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
supplier.performance_score >= 80 ? 'bg-green-600' :
|
||||
supplier.performance_score >= 60 ? 'bg-blue-600' :
|
||||
supplier.performance_score >= 40 ? 'bg-yellow-600' :
|
||||
'bg-red-600'
|
||||
}`}
|
||||
style={{ width: `${supplier.performance_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${getPerformanceColor(supplier.performance_score)}`}>
|
||||
{supplier.performance_score}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (supplier.quality_rating || 0)
|
||||
? 'text-yellow-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{supplier.quality_rating?.toFixed(1) || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${
|
||||
i < (supplier.delivery_rating || 0)
|
||||
? 'text-blue-400 fill-current'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-2 text-sm text-gray-600">
|
||||
{supplier.delivery_rating?.toFixed(1) || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{supplier.total_orders}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatCurrency(supplier.total_amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setSelectedSupplier(supplier)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Ver Detalles
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Supplier Detail Modal */}
|
||||
{selectedSupplier && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Detalles del Proveedor: {selectedSupplier.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedSupplier(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[70vh]">
|
||||
<SupplierCard
|
||||
supplier={selectedSupplier}
|
||||
compact={false}
|
||||
showActions={false}
|
||||
/>
|
||||
|
||||
{/* Performance Details */}
|
||||
<Card className="mt-6">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Métricas de Rendimiento Detalladas
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">Puntuación General</h4>
|
||||
<div className="relative pt-1">
|
||||
<div className="flex mb-2 items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold inline-block text-blue-600">
|
||||
Rendimiento Global
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-xs font-semibold inline-block ${
|
||||
getPerformanceColor((selectedSupplier as SupplierPerformance).performance_score)
|
||||
}`}>
|
||||
{(selectedSupplier as SupplierPerformance).performance_score}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden h-2 mb-4 text-xs flex rounded bg-gray-200">
|
||||
<div
|
||||
className={`shadow-none flex flex-col text-center whitespace-nowrap text-white justify-center ${
|
||||
(selectedSupplier as SupplierPerformance).performance_score >= 80 ? 'bg-green-600' :
|
||||
(selectedSupplier as SupplierPerformance).performance_score >= 60 ? 'bg-blue-600' :
|
||||
(selectedSupplier as SupplierPerformance).performance_score >= 40 ? 'bg-yellow-600' :
|
||||
'bg-red-600'
|
||||
}`}
|
||||
style={{ width: `${(selectedSupplier as SupplierPerformance).performance_score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 mb-3">Indicadores Clave</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Calidad:</span>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedSupplier.quality_rating?.toFixed(1)}/5.0
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Puntualidad:</span>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedSupplier.delivery_rating?.toFixed(1)}/5.0
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Total Pedidos:</span>
|
||||
<span className="text-sm font-medium">
|
||||
{selectedSupplier.total_orders}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">Valor Total:</span>
|
||||
<span className="text-sm font-medium">
|
||||
{formatCurrency(selectedSupplier.total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierPerformanceReport;
|
||||
20
frontend/src/components/suppliers/index.ts
Normal file
20
frontend/src/components/suppliers/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Supplier Components Exports
|
||||
export { default as SupplierCard } from './SupplierCard';
|
||||
export { default as SupplierForm } from './SupplierForm';
|
||||
export { default as SupplierManagementPage } from './SupplierManagementPage';
|
||||
export { default as SupplierDashboardWidget } from './SupplierDashboardWidget';
|
||||
|
||||
// Purchase Order Components Exports
|
||||
export { default as PurchaseOrderCard } from './PurchaseOrderCard';
|
||||
export { default as PurchaseOrderForm } from './PurchaseOrderForm';
|
||||
export { default as PurchaseOrderManagementPage } from './PurchaseOrderManagementPage';
|
||||
|
||||
// Delivery Tracking Components Exports
|
||||
export { default as DeliveryCard } from './DeliveryCard';
|
||||
export { default as DeliveryTrackingPage } from './DeliveryTrackingPage';
|
||||
export { default as DeliveryDashboardWidget } from './DeliveryDashboardWidget';
|
||||
|
||||
// Supplier Analytics Components Exports
|
||||
export { default as SupplierAnalyticsDashboard } from './SupplierAnalyticsDashboard';
|
||||
export { default as SupplierPerformanceReport } from './SupplierPerformanceReport';
|
||||
export { default as SupplierCostAnalysis } from './SupplierCostAnalysis';
|
||||
Reference in New Issue
Block a user