[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 { 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 clsx from 'clsx';
import { productsApi, inventoryApi } from '../lib/api';
const mockInventory = [ // Types based on API responses
{ id: '1', name: 'Coca-Cola 600ml', category: 'bebidas', stock: 24, minStock: 10, maxStock: 50, cost: 12.00, price: 18.00 }, interface Product {
{ id: '2', name: 'Sabritas Original', category: 'botanas', stock: 12, minStock: 5, maxStock: 30, cost: 10.00, price: 15.00 }, id: string;
{ id: '3', name: 'Leche Lala 1L', category: 'lacteos', stock: 3, minStock: 8, maxStock: 20, cost: 22.00, price: 28.00 }, name: string;
{ id: '4', name: 'Pan Bimbo Grande', category: 'panaderia', stock: 2, minStock: 5, maxStock: 15, cost: 35.00, price: 45.00 }, category: string;
{ id: '5', name: 'Fabuloso 1L', category: 'limpieza', stock: 6, minStock: 5, maxStock: 20, cost: 25.00, price: 32.00 }, stock: number;
]; minStock: number;
maxStock: number;
cost: number;
price: number;
}
const recentMovements = [ interface InventoryMovement {
{ product: 'Coca-Cola 600ml', type: 'sale', quantity: -2, date: '10:30 AM' }, id: string;
{ product: 'Sabritas', type: 'sale', quantity: -1, date: '10:15 AM' }, productId: string;
{ product: 'Leche Lala 1L', type: 'purchase', quantity: +12, date: '09:00 AM' }, productName: string;
{ product: 'Pan Bimbo', type: 'sale', quantity: -3, date: '08:45 AM' }, 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() { export function Inventory() {
const [showLowStock, setShowLowStock] = useState(false); const [showLowStock, setShowLowStock] = useState(false);
const lowStockItems = mockInventory.filter(item => item.stock <= item.minStock); // Fetch products with inventory data
const totalValue = mockInventory.reduce((sum, item) => sum + (item.stock * item.cost), 0); const {
const totalItems = mockInventory.reduce((sum, item) => sum + item.stock, 0); 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 const displayItems = showLowStock
? lowStockItems ? 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -47,6 +184,28 @@ export function Inventory() {
</div> </div>
</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 */} {/* Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card"> <div className="card">
@ -88,86 +247,108 @@ export function Inventory() {
{/* Inventory Table */} {/* Inventory Table */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<div className="card overflow-hidden p-0"> <div className="card overflow-hidden p-0">
<table className="w-full"> {displayItems.length === 0 ? (
<thead className="bg-gray-50"> <div className="p-8 text-center text-gray-500">
<tr> <Package className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<th className="text-left p-4 font-medium text-gray-600">Producto</th> <p className="font-medium">
<th className="text-center p-4 font-medium text-gray-600">Stock</th> {showLowStock ? 'No hay productos con stock bajo' : 'No hay productos en inventario'}
<th className="text-center p-4 font-medium text-gray-600">Min/Max</th> </p>
<th className="text-right p-4 font-medium text-gray-600">Valor</th> <p className="text-sm mt-1">
</tr> {showLowStock ? 'Tu inventario esta en buen estado' : 'Agrega productos para comenzar'}
</thead> </p>
<tbody className="divide-y"> </div>
{displayItems.map((item) => { ) : (
const isLow = item.stock <= item.minStock; <table className="w-full">
const percentage = (item.stock / item.maxStock) * 100; <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 ( return (
<tr key={item.id} className={clsx(isLow && 'bg-orange-50')}> <tr key={item.id} className={clsx(isLow && 'bg-orange-50')}>
<td className="p-4"> <td className="p-4">
<p className="font-medium">{item.name}</p> <p className="font-medium">{item.name}</p>
<p className="text-sm text-gray-500 capitalize">{item.category}</p> <p className="text-sm text-gray-500 capitalize">{item.category}</p>
</td> </td>
<td className="p-4 text-center"> <td className="p-4 text-center">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className={clsx( <span className={clsx(
'text-lg font-bold', 'text-lg font-bold',
isLow ? 'text-orange-600' : 'text-gray-900' isLow ? 'text-orange-600' : 'text-gray-900'
)}> )}>
{item.stock} {item.stock}
</span> </span>
<div className="w-20 h-2 bg-gray-200 rounded-full mt-1"> <div className="w-20 h-2 bg-gray-200 rounded-full mt-1">
<div <div
className={clsx( className={clsx(
'h-full rounded-full', 'h-full rounded-full',
percentage < 30 ? 'bg-red-500' : percentage < 30 ? 'bg-red-500' :
percentage < 50 ? 'bg-orange-500' : 'bg-green-500' percentage < 50 ? 'bg-orange-500' : 'bg-green-500'
)} )}
style={{ width: `${Math.min(percentage, 100)}%` }} style={{ width: `${Math.min(percentage, 100)}%` }}
/> />
</div>
</div> </div>
</div> </td>
</td> <td className="p-4 text-center text-sm text-gray-500">
<td className="p-4 text-center text-sm text-gray-500"> {item.minStock} / {item.maxStock}
{item.minStock} / {item.maxStock} </td>
</td> <td className="p-4 text-right font-medium">
<td className="p-4 text-right font-medium"> ${(item.stock * item.cost).toFixed(2)}
${(item.stock * item.cost).toFixed(2)} </td>
</td> </tr>
</tr> );
); })}
})} </tbody>
</tbody> </table>
</table> )}
</div> </div>
</div> </div>
{/* Recent Movements */} {/* Recent Movements */}
<div className="card h-fit"> <div className="card h-fit">
<h2 className="font-bold text-lg mb-4">Movimientos Recientes</h2> <h2 className="font-bold text-lg mb-4">Movimientos Recientes</h2>
<div className="space-y-3"> {recentMovements.length === 0 ? (
{recentMovements.map((mov, i) => ( <div className="text-center py-6 text-gray-500">
<div key={i} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"> <TrendingUp className="h-10 w-10 mx-auto mb-2 text-gray-300" />
<div className="flex items-center gap-3"> <p className="text-sm">Sin movimientos recientes</p>
{mov.type === 'sale' ? ( </div>
<TrendingDown className="h-5 w-5 text-red-500" /> ) : (
) : ( <div className="space-y-3">
<TrendingUp className="h-5 w-5 text-green-500" /> {recentMovements.map((mov) => {
)} const { isSale, quantity } = getMovementDisplay(mov);
<div> return (
<p className="font-medium text-sm">{mov.product}</p> <div key={mov.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<p className="text-xs text-gray-500">{mov.date}</p> <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>
</div> );
<span className={clsx( })}
'font-bold', </div>
mov.quantity > 0 ? 'text-green-600' : 'text-red-600' )}
)}>
{mov.quantity > 0 ? '+' : ''}{mov.quantity}
</span>
</div>
))}
</div>
</div> </div>
</div> </div>
</div> </div>