Files
bakery-ia/frontend/src/pages/app/operations/inventory/InventoryPage.tsx
2025-09-09 22:27:52 +02:00

447 lines
15 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { LowStockAlert } from '../../../../components/domain/inventory';
import { useIngredients, useStockAnalytics } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { IngredientResponse } from '../../../../api/types/inventory';
const InventoryPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API Data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
} = useIngredients(tenantId, { search: searchTerm || undefined });
const {
data: analyticsData,
isLoading: analyticsLoading
} = useStockAnalytics(tenantId);
const ingredients = ingredientsData || [];
const lowStockItems = ingredients.filter(ingredient => ingredient.stock_status === 'low_stock');
const getInventoryStatusConfig = (ingredient: IngredientResponse) => {
const { stock_status } = ingredient;
switch (stock_status) {
case 'out_of_stock':
return {
color: getStatusColor('cancelled'),
text: 'Sin Stock',
icon: AlertTriangle,
isCritical: true,
isHighlight: false
};
case 'low_stock':
return {
color: getStatusColor('pending'),
text: 'Stock Bajo',
icon: AlertTriangle,
isCritical: false,
isHighlight: true
};
case 'overstock':
return {
color: getStatusColor('info'),
text: 'Sobrestock',
icon: Package,
isCritical: false,
isHighlight: false
};
case 'in_stock':
default:
return {
color: getStatusColor('completed'),
text: 'Normal',
icon: CheckCircle,
isCritical: false,
isHighlight: false
};
}
};
const filteredItems = useMemo(() => {
if (!searchTerm) return ingredients;
return ingredients.filter(ingredient => {
const searchLower = searchTerm.toLowerCase();
return ingredient.name.toLowerCase().includes(searchLower) ||
ingredient.category.toLowerCase().includes(searchLower) ||
(ingredient.description && ingredient.description.toLowerCase().includes(searchLower));
});
}, [ingredients, searchTerm]);
const inventoryStats = useMemo(() => {
if (!analyticsData) {
return {
totalItems: ingredients.length,
lowStockItems: lowStockItems.length,
outOfStock: ingredients.filter(item => item.stock_status === 'out_of_stock').length,
expiringSoon: 0, // This would come from expired stock API
totalValue: ingredients.reduce((sum, item) => sum + (item.current_stock_level * (item.average_cost || 0)), 0),
categories: [...new Set(ingredients.map(item => item.category))].length,
};
}
return {
totalItems: analyticsData.total_ingredients || 0,
lowStockItems: analyticsData.low_stock_count || 0,
outOfStock: analyticsData.out_of_stock_count || 0,
expiringSoon: analyticsData.expiring_soon_count || 0,
totalValue: analyticsData.total_stock_value || 0,
categories: [...new Set(ingredients.map(item => item.category))].length,
};
}, [analyticsData, ingredients, lowStockItems]);
const stats = [
{
title: 'Total Artículos',
value: inventoryStats.totalItems,
variant: 'default' as const,
icon: Package,
},
{
title: 'Stock Bajo',
value: inventoryStats.lowStockItems,
variant: 'warning' as const,
icon: AlertTriangle,
},
{
title: 'Sin Stock',
value: inventoryStats.outOfStock,
variant: 'error' as const,
icon: AlertTriangle,
},
{
title: 'Por Caducar',
value: inventoryStats.expiringSoon,
variant: 'error' as const,
icon: Clock,
},
{
title: 'Valor Total',
value: formatters.currency(inventoryStats.totalValue),
variant: 'success' as const,
icon: DollarSign,
},
{
title: 'Categorías',
value: inventoryStats.categories,
variant: 'info' as const,
icon: Package,
},
];
// Loading and error states
if (ingredientsLoading || analyticsLoading || !tenantId) {
return (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando inventario..." />
</div>
);
}
if (ingredientsError) {
return (
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar el inventario
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{ingredientsError.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Gestión de Inventario"
description="Controla el stock de ingredientes y materias primas"
actions={[
{
id: "export",
label: "Exportar",
variant: "outline" as const,
icon: Download,
onClick: () => console.log('Export inventory')
},
{
id: "new",
label: "Nuevo Artículo",
variant: "primary" as const,
icon: Plus,
onClick: () => setShowForm(true)
}
]}
/>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
{/* Low Stock Alert */}
{lowStockItems.length > 0 && (
<LowStockAlert items={lowStockItems} />
)}
{/* Simplified Controls */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
placeholder="Buscar artículos por nombre, categoría o proveedor..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full"
/>
</div>
<Button variant="outline" onClick={() => console.log('Export filtered')}>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</Card>
{/* Inventory Items Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filteredItems.map((ingredient) => {
const statusConfig = getInventoryStatusConfig(ingredient);
const stockPercentage = ingredient.max_stock_level ?
Math.round((ingredient.current_stock_level / ingredient.max_stock_level) * 100) : 0;
const averageCost = ingredient.average_cost || 0;
const totalValue = ingredient.current_stock_level * averageCost;
return (
<StatusCard
key={ingredient.id}
id={ingredient.id}
statusIndicator={statusConfig}
title={ingredient.name}
subtitle={`${ingredient.category}${ingredient.description ? `${ingredient.description}` : ''}`}
primaryValue={ingredient.current_stock_level}
primaryValueLabel={ingredient.unit_of_measure}
secondaryInfo={{
label: 'Valor total',
value: `${formatters.currency(totalValue)}${averageCost > 0 ? ` (${formatters.currency(averageCost)}/${ingredient.unit_of_measure})` : ''}`
}}
progress={ingredient.max_stock_level ? {
label: 'Nivel de stock',
percentage: stockPercentage,
color: statusConfig.color
} : undefined}
metadata={[
`Rango: ${ingredient.low_stock_threshold} - ${ingredient.max_stock_level || 'Sin límite'} ${ingredient.unit_of_measure}`,
`Stock disponible: ${ingredient.available_stock} ${ingredient.unit_of_measure}`,
`Stock reservado: ${ingredient.reserved_stock} ${ingredient.unit_of_measure}`,
ingredient.last_restocked ? `Último restock: ${new Date(ingredient.last_restocked).toLocaleDateString('es-ES')}` : 'Sin historial de restock',
...(ingredient.requires_refrigeration ? ['Requiere refrigeración'] : []),
...(ingredient.requires_freezing ? ['Requiere congelación'] : []),
...(ingredient.is_seasonal ? ['Producto estacional'] : [])
]}
actions={[
{
label: 'Ver',
icon: Eye,
variant: 'outline',
onClick: () => {
setSelectedItem(ingredient);
setModalMode('view');
setShowForm(true);
}
},
{
label: 'Editar',
icon: Edit,
variant: 'outline',
onClick: () => {
setSelectedItem(ingredient);
setModalMode('edit');
setShowForm(true);
}
}
]}
/>
);
})}
</div>
{/* Empty State */}
{filteredItems.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron artículos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario
</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Artículo
</Button>
</div>
)}
{/* Inventory Item Modal */}
{showForm && selectedItem && (
<StatusModal
isOpen={showForm}
onClose={() => {
setShowForm(false);
setSelectedItem(null);
setModalMode('view');
}}
mode={modalMode}
onModeChange={setModalMode}
title={selectedItem.name}
subtitle={`${selectedItem.category}${selectedItem.description ? ` - ${selectedItem.description}` : ''}`}
statusIndicator={getInventoryStatusConfig(selectedItem)}
size="lg"
sections={[
{
title: 'Información Básica',
icon: Package,
fields: [
{
label: 'Nombre',
value: selectedItem.name,
highlight: true
},
{
label: 'Categoría',
value: selectedItem.category
},
{
label: 'Descripción',
value: selectedItem.description || 'Sin descripción'
},
{
label: 'Unidad de medida',
value: selectedItem.unit_of_measure
}
]
},
{
title: 'Stock y Niveles',
icon: Package,
fields: [
{
label: 'Stock actual',
value: `${selectedItem.current_stock_level} ${selectedItem.unit_of_measure}`,
highlight: true
},
{
label: 'Stock disponible',
value: `${selectedItem.available_stock} ${selectedItem.unit_of_measure}`
},
{
label: 'Stock reservado',
value: `${selectedItem.reserved_stock} ${selectedItem.unit_of_measure}`
},
{
label: 'Umbral mínimo',
value: `${selectedItem.low_stock_threshold} ${selectedItem.unit_of_measure}`
},
{
label: 'Stock máximo',
value: selectedItem.max_stock_level ? `${selectedItem.max_stock_level} ${selectedItem.unit_of_measure}` : 'Sin límite'
},
{
label: 'Punto de reorden',
value: `${selectedItem.reorder_point} ${selectedItem.unit_of_measure}`
}
]
},
{
title: 'Información Financiera',
icon: DollarSign,
fields: [
{
label: 'Costo promedio por unidad',
value: selectedItem.average_cost || 0,
type: 'currency'
},
{
label: 'Valor total en stock',
value: selectedItem.current_stock_level * (selectedItem.average_cost || 0),
type: 'currency',
highlight: true
}
]
},
{
title: 'Información Adicional',
icon: Calendar,
fields: [
{
label: 'Último restock',
value: selectedItem.last_restocked || 'Sin historial',
type: selectedItem.last_restocked ? 'datetime' : undefined
},
{
label: 'Vida útil',
value: selectedItem.shelf_life_days ? `${selectedItem.shelf_life_days} días` : 'No especificada'
},
{
label: 'Requiere refrigeración',
value: selectedItem.requires_refrigeration ? 'Sí' : 'No',
highlight: selectedItem.requires_refrigeration
},
{
label: 'Requiere congelación',
value: selectedItem.requires_freezing ? 'Sí' : 'No',
highlight: selectedItem.requires_freezing
},
{
label: 'Producto estacional',
value: selectedItem.is_seasonal ? 'Sí' : 'No'
},
{
label: 'Creado',
value: selectedItem.created_at,
type: 'datetime'
}
]
},
...(selectedItem.notes ? [{
title: 'Notas',
fields: [
{
label: 'Observaciones',
value: selectedItem.notes,
span: 2 as const
}
]
}] : [])
]}
onEdit={() => {
console.log('Editing inventory item:', selectedItem.id);
}}
/>
)}
</div>
);
};
export default InventoryPage;