[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:
rckrdmrd 2026-01-20 02:15:16 -06:00
parent 969f8acb9a
commit 0385695d27

View File

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