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