447 lines
15 KiB
TypeScript
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; |