Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';