[MCH-FE] feat: Connect Inventory to real API
- Replace mock data with real API calls using React Query - Add useQuery hooks for products, movements, low stock, and alerts - Implement loading state with spinner - Implement error state with retry button - Add alerts banner for inventory warnings - Add empty state messages for no data scenarios - Add proper TypeScript interfaces for API responses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
969f8acb9a
commit
0385695d27
@ -1,32 +1,169 @@
|
||||
import { useState } from 'react';
|
||||
import { Package, TrendingUp, TrendingDown, AlertTriangle, Plus, Minus } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Package, TrendingUp, TrendingDown, AlertTriangle, Plus, Minus, Loader2, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { productsApi, inventoryApi } from '../lib/api';
|
||||
|
||||
const mockInventory = [
|
||||
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', stock: 24, minStock: 10, maxStock: 50, cost: 12.00, price: 18.00 },
|
||||
{ id: '2', name: 'Sabritas Original', category: 'botanas', stock: 12, minStock: 5, maxStock: 30, cost: 10.00, price: 15.00 },
|
||||
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', stock: 3, minStock: 8, maxStock: 20, cost: 22.00, price: 28.00 },
|
||||
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', stock: 2, minStock: 5, maxStock: 15, cost: 35.00, price: 45.00 },
|
||||
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', stock: 6, minStock: 5, maxStock: 20, cost: 25.00, price: 32.00 },
|
||||
];
|
||||
// Types based on API responses
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
stock: number;
|
||||
minStock: number;
|
||||
maxStock: number;
|
||||
cost: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
const recentMovements = [
|
||||
{ product: 'Coca-Cola 600ml', type: 'sale', quantity: -2, date: '10:30 AM' },
|
||||
{ product: 'Sabritas', type: 'sale', quantity: -1, date: '10:15 AM' },
|
||||
{ product: 'Leche Lala 1L', type: 'purchase', quantity: +12, date: '09:00 AM' },
|
||||
{ product: 'Pan Bimbo', type: 'sale', quantity: -3, date: '08:45 AM' },
|
||||
];
|
||||
interface InventoryMovement {
|
||||
id: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
type: 'entrada' | 'salida' | 'ajuste' | 'venta';
|
||||
quantity: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface LowStockProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
stock: number;
|
||||
minStock: number;
|
||||
maxStock: number;
|
||||
cost: number;
|
||||
}
|
||||
|
||||
interface InventoryAlert {
|
||||
id: string;
|
||||
type: 'low_stock' | 'out_of_stock' | 'overstock';
|
||||
productId: string;
|
||||
productName: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function Inventory() {
|
||||
const [showLowStock, setShowLowStock] = useState(false);
|
||||
|
||||
const lowStockItems = mockInventory.filter(item => item.stock <= item.minStock);
|
||||
const totalValue = mockInventory.reduce((sum, item) => sum + (item.stock * item.cost), 0);
|
||||
const totalItems = mockInventory.reduce((sum, item) => sum + item.stock, 0);
|
||||
// Fetch products with inventory data
|
||||
const {
|
||||
data: products,
|
||||
isLoading: productsLoading,
|
||||
error: productsError,
|
||||
refetch: refetchProducts
|
||||
} = useQuery<Product[]>({
|
||||
queryKey: ['products'],
|
||||
queryFn: async () => {
|
||||
const res = await productsApi.getAll();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch recent inventory movements
|
||||
const {
|
||||
data: movements,
|
||||
isLoading: movementsLoading,
|
||||
error: movementsError
|
||||
} = useQuery<InventoryMovement[]>({
|
||||
queryKey: ['inventory-movements'],
|
||||
queryFn: async () => {
|
||||
const res = await inventoryApi.getMovements();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch low stock products
|
||||
const {
|
||||
data: lowStockData,
|
||||
isLoading: lowStockLoading
|
||||
} = useQuery<LowStockProduct[]>({
|
||||
queryKey: ['low-stock'],
|
||||
queryFn: async () => {
|
||||
const res = await inventoryApi.getLowStock();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch inventory alerts
|
||||
const { data: alerts } = useQuery<InventoryAlert[]>({
|
||||
queryKey: ['inventory-alerts'],
|
||||
queryFn: async () => {
|
||||
const res = await inventoryApi.getAlerts();
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Loading state
|
||||
const isLoading = productsLoading || movementsLoading || lowStockLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary-600 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Cargando inventario...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (productsError || movementsError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-gray-700 font-medium mb-2">Error al cargar el inventario</p>
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
{(productsError as Error)?.message || (movementsError as Error)?.message || 'Error desconocido'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchProducts()}
|
||||
className="btn-primary flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Use low stock data from API or filter products locally as fallback
|
||||
const lowStockItems = lowStockData || products?.filter(item => item.stock <= item.minStock) || [];
|
||||
const inventoryItems = products || [];
|
||||
|
||||
// Calculate totals
|
||||
const totalValue = inventoryItems.reduce((sum, item) => sum + (item.stock * item.cost), 0);
|
||||
const totalItems = inventoryItems.reduce((sum, item) => sum + item.stock, 0);
|
||||
|
||||
// Filter display items based on low stock toggle
|
||||
const displayItems = showLowStock
|
||||
? lowStockItems
|
||||
: mockInventory;
|
||||
: inventoryItems;
|
||||
|
||||
// Get recent movements (limit to 10)
|
||||
const recentMovements = (movements || []).slice(0, 10);
|
||||
|
||||
// Format movement time
|
||||
const formatMovementTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('es-MX', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Determine movement type for display
|
||||
const getMovementDisplay = (movement: InventoryMovement) => {
|
||||
const isSale = movement.type === 'venta' || movement.type === 'salida';
|
||||
return {
|
||||
isSale,
|
||||
quantity: isSale ? -Math.abs(movement.quantity) : Math.abs(movement.quantity),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -47,6 +184,28 @@ export function Inventory() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts Banner */}
|
||||
{alerts && alerts.length > 0 && (
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 text-orange-800">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-medium">
|
||||
{alerts.length} alerta{alerts.length !== 1 ? 's' : ''} de inventario
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-2 text-sm text-orange-700 space-y-1">
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<li key={alert.id}>{alert.message}</li>
|
||||
))}
|
||||
{alerts.length > 3 && (
|
||||
<li className="text-orange-600 font-medium">
|
||||
+ {alerts.length - 3} alertas mas...
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
@ -88,86 +247,108 @@ export function Inventory() {
|
||||
{/* Inventory Table */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card overflow-hidden p-0">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium text-gray-600">Producto</th>
|
||||
<th className="text-center p-4 font-medium text-gray-600">Stock</th>
|
||||
<th className="text-center p-4 font-medium text-gray-600">Min/Max</th>
|
||||
<th className="text-right p-4 font-medium text-gray-600">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{displayItems.map((item) => {
|
||||
const isLow = item.stock <= item.minStock;
|
||||
const percentage = (item.stock / item.maxStock) * 100;
|
||||
{displayItems.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<Package className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="font-medium">
|
||||
{showLowStock ? 'No hay productos con stock bajo' : 'No hay productos en inventario'}
|
||||
</p>
|
||||
<p className="text-sm mt-1">
|
||||
{showLowStock ? 'Tu inventario esta en buen estado' : 'Agrega productos para comenzar'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-4 font-medium text-gray-600">Producto</th>
|
||||
<th className="text-center p-4 font-medium text-gray-600">Stock</th>
|
||||
<th className="text-center p-4 font-medium text-gray-600">Min/Max</th>
|
||||
<th className="text-right p-4 font-medium text-gray-600">Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{displayItems.map((item) => {
|
||||
const isLow = item.stock <= item.minStock;
|
||||
const percentage = item.maxStock > 0 ? (item.stock / item.maxStock) * 100 : 0;
|
||||
|
||||
return (
|
||||
<tr key={item.id} className={clsx(isLow && 'bg-orange-50')}>
|
||||
<td className="p-4">
|
||||
<p className="font-medium">{item.name}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">{item.category}</p>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
'text-lg font-bold',
|
||||
isLow ? 'text-orange-600' : 'text-gray-900'
|
||||
)}>
|
||||
{item.stock}
|
||||
</span>
|
||||
<div className="w-20 h-2 bg-gray-200 rounded-full mt-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full',
|
||||
percentage < 30 ? 'bg-red-500' :
|
||||
percentage < 50 ? 'bg-orange-500' : 'bg-green-500'
|
||||
)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
return (
|
||||
<tr key={item.id} className={clsx(isLow && 'bg-orange-50')}>
|
||||
<td className="p-4">
|
||||
<p className="font-medium">{item.name}</p>
|
||||
<p className="text-sm text-gray-500 capitalize">{item.category}</p>
|
||||
</td>
|
||||
<td className="p-4 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className={clsx(
|
||||
'text-lg font-bold',
|
||||
isLow ? 'text-orange-600' : 'text-gray-900'
|
||||
)}>
|
||||
{item.stock}
|
||||
</span>
|
||||
<div className="w-20 h-2 bg-gray-200 rounded-full mt-1">
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full',
|
||||
percentage < 30 ? 'bg-red-500' :
|
||||
percentage < 50 ? 'bg-orange-500' : 'bg-green-500'
|
||||
)}
|
||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-center text-sm text-gray-500">
|
||||
{item.minStock} / {item.maxStock}
|
||||
</td>
|
||||
<td className="p-4 text-right font-medium">
|
||||
${(item.stock * item.cost).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td className="p-4 text-center text-sm text-gray-500">
|
||||
{item.minStock} / {item.maxStock}
|
||||
</td>
|
||||
<td className="p-4 text-right font-medium">
|
||||
${(item.stock * item.cost).toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Movements */}
|
||||
<div className="card h-fit">
|
||||
<h2 className="font-bold text-lg mb-4">Movimientos Recientes</h2>
|
||||
<div className="space-y-3">
|
||||
{recentMovements.map((mov, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{mov.type === 'sale' ? (
|
||||
<TrendingDown className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{mov.product}</p>
|
||||
<p className="text-xs text-gray-500">{mov.date}</p>
|
||||
{recentMovements.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-500">
|
||||
<TrendingUp className="h-10 w-10 mx-auto mb-2 text-gray-300" />
|
||||
<p className="text-sm">Sin movimientos recientes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentMovements.map((mov) => {
|
||||
const { isSale, quantity } = getMovementDisplay(mov);
|
||||
return (
|
||||
<div key={mov.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
{isSale ? (
|
||||
<TrendingDown className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<TrendingUp className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">{mov.productName}</p>
|
||||
<p className="text-xs text-gray-500">{formatMovementTime(mov.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
quantity > 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{quantity > 0 ? '+' : ''}{quantity}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={clsx(
|
||||
'font-bold',
|
||||
mov.quantity > 0 ? 'text-green-600' : 'text-red-600'
|
||||
)}>
|
||||
{mov.quantity > 0 ? '+' : ''}{mov.quantity}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user