Create new services: inventory, recipes, suppliers
This commit is contained in:
542
frontend/src/pages/inventory/InventoryPage.tsx
Normal file
542
frontend/src/pages/inventory/InventoryPage.tsx
Normal file
@@ -0,0 +1,542 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
Download,
|
||||
Upload,
|
||||
Grid3X3,
|
||||
List,
|
||||
Package,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
Loader,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { useInventory } from '../../api/hooks/useInventory';
|
||||
import {
|
||||
InventorySearchParams,
|
||||
ProductType,
|
||||
CreateInventoryItemRequest,
|
||||
UpdateInventoryItemRequest,
|
||||
StockAdjustmentRequest,
|
||||
InventoryItem
|
||||
} from '../../api/services/inventory.service';
|
||||
|
||||
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
|
||||
import StockAlertsPanel from '../../components/inventory/StockAlertsPanel';
|
||||
|
||||
type ViewMode = 'grid' | 'list';
|
||||
|
||||
interface FilterState {
|
||||
search: string;
|
||||
product_type?: ProductType;
|
||||
category?: string;
|
||||
is_active?: boolean;
|
||||
low_stock_only?: boolean;
|
||||
expiring_soon_only?: boolean;
|
||||
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
const {
|
||||
items,
|
||||
stockLevels,
|
||||
alerts,
|
||||
dashboardData,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
loadItems,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
adjustStock,
|
||||
acknowledgeAlert,
|
||||
refresh,
|
||||
clearError
|
||||
} = useInventory();
|
||||
|
||||
// Local state
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [showAlerts, setShowAlerts] = useState(false);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
search: '',
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load items when filters change
|
||||
useEffect(() => {
|
||||
const searchParams: InventorySearchParams = {
|
||||
...filters,
|
||||
page: 1,
|
||||
limit: 20
|
||||
};
|
||||
|
||||
// Remove empty values
|
||||
Object.keys(searchParams).forEach(key => {
|
||||
if (searchParams[key as keyof InventorySearchParams] === '' ||
|
||||
searchParams[key as keyof InventorySearchParams] === undefined) {
|
||||
delete searchParams[key as keyof InventorySearchParams];
|
||||
}
|
||||
});
|
||||
|
||||
loadItems(searchParams);
|
||||
}, [filters, loadItems]);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setFilters(prev => ({ ...prev, search: value }));
|
||||
}, []);
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// Clear all filters
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({
|
||||
search: '',
|
||||
sort_by: 'name',
|
||||
sort_order: 'asc'
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle item selection
|
||||
const toggleItemSelection = (itemId: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(itemId)) {
|
||||
newSelection.delete(itemId);
|
||||
} else {
|
||||
newSelection.add(itemId);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
// Handle stock adjustment
|
||||
const handleStockAdjust = async (item: InventoryItem, adjustment: StockAdjustmentRequest) => {
|
||||
const result = await adjustStock(item.id, adjustment);
|
||||
if (result) {
|
||||
// Refresh data to get updated stock levels
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle item edit
|
||||
const handleItemEdit = (item: InventoryItem) => {
|
||||
// TODO: Open edit modal
|
||||
console.log('Edit item:', item);
|
||||
};
|
||||
|
||||
// Handle item view details
|
||||
const handleItemViewDetails = (item: InventoryItem) => {
|
||||
// TODO: Open details modal or navigate to details page
|
||||
console.log('View details:', item);
|
||||
};
|
||||
|
||||
// Handle alert acknowledgment
|
||||
const handleAcknowledgeAlert = async (alertId: string) => {
|
||||
await acknowledgeAlert(alertId);
|
||||
};
|
||||
|
||||
// Handle bulk acknowledge alerts
|
||||
const handleBulkAcknowledgeAlerts = async (alertIds: string[]) => {
|
||||
// TODO: Implement bulk acknowledge
|
||||
for (const alertId of alertIds) {
|
||||
await acknowledgeAlert(alertId);
|
||||
}
|
||||
};
|
||||
|
||||
// Get quick stats
|
||||
const getQuickStats = () => {
|
||||
const totalItems = items.length;
|
||||
const lowStockItems = alerts.filter(a => a.alert_type === 'low_stock' && !a.is_acknowledged).length;
|
||||
const expiringItems = alerts.filter(a => a.alert_type === 'expiring_soon' && !a.is_acknowledged).length;
|
||||
const totalValue = dashboardData?.total_value || 0;
|
||||
|
||||
return { totalItems, lowStockItems, expiringItems, totalValue };
|
||||
};
|
||||
|
||||
const stats = getQuickStats();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Inventario</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Administra tus productos, stock y alertas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => setShowAlerts(!showAlerts)}
|
||||
className={`relative p-2 rounded-lg transition-colors ${
|
||||
showAlerts ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
{alerts.filter(a => !a.is_acknowledged).length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{alerts.filter(a => !a.is_acknowledged).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
disabled={isLoading}
|
||||
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button className="px-4 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>Nuevo Producto</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className={`${showAlerts ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<Package className="w-8 h-8 text-blue-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Total Productos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.totalItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<TrendingDown className="w-8 h-8 text-yellow-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Stock Bajo</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.lowStockItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-8 h-8 text-red-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Por Vencer</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{stats.expiringItems}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg border">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="w-8 h-8 text-green-600" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-gray-600">Valor Total</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
€{stats.totalValue.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="bg-white rounded-lg border mb-6 p-4">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-md">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar productos..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
|
||||
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span>Filtros</span>
|
||||
</button>
|
||||
|
||||
<div className="flex rounded-lg border">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{/* Product Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tipo de Producto
|
||||
</label>
|
||||
<select
|
||||
value={filters.product_type || ''}
|
||||
onChange={(e) => handleFilterChange('product_type', e.target.value || undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="ingredient">Ingredientes</option>
|
||||
<option value="finished_product">Productos Finales</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Estado
|
||||
</label>
|
||||
<select
|
||||
value={filters.is_active?.toString() || ''}
|
||||
onChange={(e) => handleFilterChange('is_active',
|
||||
e.target.value === '' ? undefined : e.target.value === 'true'
|
||||
)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="true">Activos</option>
|
||||
<option value="false">Inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stock Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stock
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.low_stock_only || false}
|
||||
onChange={(e) => handleFilterChange('low_stock_only', e.target.checked || undefined)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Stock bajo</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.expiring_soon_only || false}
|
||||
onChange={(e) => handleFilterChange('expiring_soon_only', e.target.checked || undefined)}
|
||||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Por vencer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Ordenar por
|
||||
</label>
|
||||
<select
|
||||
value={filters.sort_by || 'name'}
|
||||
onChange={(e) => handleFilterChange('sort_by', 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"
|
||||
>
|
||||
<option value="name">Nombre</option>
|
||||
<option value="category">Categoría</option>
|
||||
<option value="stock_level">Nivel de Stock</option>
|
||||
<option value="created_at">Fecha de Creación</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Orden
|
||||
</label>
|
||||
<select
|
||||
value={filters.sort_order || 'asc'}
|
||||
onChange={(e) => handleFilterChange('sort_order', e.target.value as 'asc' | 'desc')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="asc">Ascendente</option>
|
||||
<option value="desc">Descendente</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items Grid/List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Cargando inventario...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-red-900 mb-2">Error al cargar inventario</h3>
|
||||
<p className="text-red-700 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={refresh}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Reintentar
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{filters.search || Object.values(filters).some(v => v)
|
||||
? 'No se encontraron productos'
|
||||
: 'No tienes productos en tu inventario'
|
||||
}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{filters.search || Object.values(filters).some(v => v)
|
||||
? 'Prueba ajustando los filtros de búsqueda'
|
||||
: 'Comienza agregando tu primer producto al inventario'
|
||||
}
|
||||
</p>
|
||||
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
Agregar Producto
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
|
||||
: 'space-y-4'
|
||||
}>
|
||||
{items.map((item) => (
|
||||
<InventoryItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
stockLevel={stockLevels[item.id]}
|
||||
compact={viewMode === 'list'}
|
||||
onEdit={handleItemEdit}
|
||||
onViewDetails={handleItemViewDetails}
|
||||
onStockAdjust={handleStockAdjust}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
|
||||
{pagination.total} productos
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => {
|
||||
const searchParams: InventorySearchParams = {
|
||||
...filters,
|
||||
page,
|
||||
limit: pagination.limit
|
||||
};
|
||||
loadItems(searchParams);
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg ${
|
||||
page === pagination.page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50 border'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts Panel */}
|
||||
{showAlerts && (
|
||||
<div className="lg:col-span-1">
|
||||
<StockAlertsPanel
|
||||
alerts={alerts}
|
||||
onAcknowledge={handleAcknowledgeAlert}
|
||||
onAcknowledgeAll={handleBulkAcknowledgeAlerts}
|
||||
onViewItem={handleItemViewDetails}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InventoryPage;
|
||||
Reference in New Issue
Block a user