2025-08-28 10:41:04 +02:00
|
|
|
import React, { useState } from 'react';
|
2025-08-30 19:11:15 +02:00
|
|
|
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
2025-08-31 10:46:13 +02:00
|
|
|
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
2025-08-30 19:11:15 +02:00
|
|
|
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
2025-08-28 10:41:04 +02:00
|
|
|
import { PageHeader } from '../../../../components/layout';
|
2025-08-30 19:11:15 +02:00
|
|
|
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
const InventoryPage: React.FC = () => {
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
|
const [showForm, setShowForm] = useState(false);
|
2025-08-31 10:46:13 +02:00
|
|
|
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
2025-08-30 19:11:15 +02:00
|
|
|
const [selectedItem, setSelectedItem] = useState<typeof mockInventoryItems[0] | null>(null);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
const mockInventoryItems = [
|
|
|
|
|
{
|
|
|
|
|
id: '1',
|
|
|
|
|
name: 'Harina de Trigo',
|
|
|
|
|
category: 'Harinas',
|
|
|
|
|
currentStock: 45,
|
|
|
|
|
minStock: 20,
|
|
|
|
|
maxStock: 100,
|
|
|
|
|
unit: 'kg',
|
|
|
|
|
cost: 1.20,
|
|
|
|
|
supplier: 'Molinos del Sur',
|
|
|
|
|
lastRestocked: '2024-01-20',
|
|
|
|
|
expirationDate: '2024-06-30',
|
|
|
|
|
status: 'normal',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: '2',
|
|
|
|
|
name: 'Levadura Fresca',
|
|
|
|
|
category: 'Levaduras',
|
|
|
|
|
currentStock: 8,
|
|
|
|
|
minStock: 10,
|
|
|
|
|
maxStock: 25,
|
|
|
|
|
unit: 'kg',
|
|
|
|
|
cost: 8.50,
|
|
|
|
|
supplier: 'Levaduras SA',
|
|
|
|
|
lastRestocked: '2024-01-25',
|
|
|
|
|
expirationDate: '2024-02-15',
|
|
|
|
|
status: 'low',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: '3',
|
|
|
|
|
name: 'Mantequilla',
|
|
|
|
|
category: 'Lácteos',
|
|
|
|
|
currentStock: 15,
|
|
|
|
|
minStock: 5,
|
|
|
|
|
maxStock: 30,
|
|
|
|
|
unit: 'kg',
|
|
|
|
|
cost: 5.80,
|
|
|
|
|
supplier: 'Lácteos Frescos',
|
|
|
|
|
lastRestocked: '2024-01-24',
|
|
|
|
|
expirationDate: '2024-02-10',
|
|
|
|
|
status: 'normal',
|
|
|
|
|
},
|
2025-08-30 19:11:15 +02:00
|
|
|
{
|
|
|
|
|
id: '4',
|
|
|
|
|
name: 'Azúcar Blanco',
|
|
|
|
|
category: 'Azúcares',
|
|
|
|
|
currentStock: 0,
|
|
|
|
|
minStock: 15,
|
|
|
|
|
maxStock: 50,
|
|
|
|
|
unit: 'kg',
|
|
|
|
|
cost: 0.95,
|
|
|
|
|
supplier: 'Distribuidora Central',
|
|
|
|
|
lastRestocked: '2024-01-10',
|
|
|
|
|
expirationDate: '2024-12-31',
|
|
|
|
|
status: 'out',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: '5',
|
|
|
|
|
name: 'Leche Entera',
|
|
|
|
|
category: 'Lácteos',
|
|
|
|
|
currentStock: 3,
|
|
|
|
|
minStock: 10,
|
|
|
|
|
maxStock: 40,
|
|
|
|
|
unit: 'L',
|
|
|
|
|
cost: 1.45,
|
|
|
|
|
supplier: 'Lácteos Frescos',
|
|
|
|
|
lastRestocked: '2024-01-22',
|
|
|
|
|
expirationDate: '2024-01-28',
|
|
|
|
|
status: 'expired',
|
|
|
|
|
},
|
2025-08-28 10:41:04 +02:00
|
|
|
];
|
|
|
|
|
|
2025-08-31 10:46:13 +02:00
|
|
|
const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => {
|
2025-08-30 19:11:15 +02:00
|
|
|
const { currentStock, minStock, status } = item;
|
|
|
|
|
|
|
|
|
|
if (status === 'expired') {
|
2025-08-31 10:46:13 +02:00
|
|
|
return {
|
|
|
|
|
color: getStatusColor('expired'),
|
|
|
|
|
text: 'Caducado',
|
|
|
|
|
icon: AlertTriangle,
|
|
|
|
|
isCritical: true,
|
|
|
|
|
isHighlight: false
|
|
|
|
|
};
|
2025-08-30 19:11:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentStock === 0) {
|
2025-08-31 10:46:13 +02:00
|
|
|
return {
|
|
|
|
|
color: getStatusColor('out'),
|
|
|
|
|
text: 'Sin Stock',
|
|
|
|
|
icon: AlertTriangle,
|
|
|
|
|
isCritical: true,
|
|
|
|
|
isHighlight: false
|
|
|
|
|
};
|
2025-08-30 19:11:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (currentStock <= minStock) {
|
2025-08-31 10:46:13 +02:00
|
|
|
return {
|
|
|
|
|
color: getStatusColor('low'),
|
|
|
|
|
text: 'Stock Bajo',
|
|
|
|
|
icon: AlertTriangle,
|
|
|
|
|
isCritical: false,
|
|
|
|
|
isHighlight: true
|
|
|
|
|
};
|
2025-08-30 19:11:15 +02:00
|
|
|
}
|
|
|
|
|
|
2025-08-31 10:46:13 +02:00
|
|
|
return {
|
|
|
|
|
color: getStatusColor('normal'),
|
|
|
|
|
text: 'Normal',
|
|
|
|
|
icon: CheckCircle,
|
|
|
|
|
isCritical: false,
|
|
|
|
|
isHighlight: false
|
2025-08-30 19:11:15 +02:00
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const filteredItems = mockInventoryItems.filter(item => {
|
|
|
|
|
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
item.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
|
|
|
item.supplier.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
|
|
|
|
|
|
return matchesSearch;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const lowStockItems = mockInventoryItems.filter(item =>
|
|
|
|
|
item.currentStock <= item.minStock || item.status === 'low' || item.status === 'out' || item.status === 'expired'
|
|
|
|
|
);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-08-30 19:11:15 +02:00
|
|
|
const mockInventoryStats = {
|
2025-08-28 10:41:04 +02:00
|
|
|
totalItems: mockInventoryItems.length,
|
|
|
|
|
lowStockItems: lowStockItems.length,
|
2025-08-30 19:11:15 +02:00
|
|
|
outOfStock: mockInventoryItems.filter(item => item.currentStock === 0).length,
|
|
|
|
|
expiringSoon: mockInventoryItems.filter(item => item.status === 'expired').length,
|
2025-08-28 10:41:04 +02:00
|
|
|
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
2025-08-30 19:11:15 +02:00
|
|
|
categories: [...new Set(mockInventoryItems.map(item => item.category))].length,
|
2025-08-28 10:41:04 +02:00
|
|
|
};
|
|
|
|
|
|
2025-08-30 19:11:15 +02:00
|
|
|
const inventoryStats = [
|
|
|
|
|
{
|
|
|
|
|
title: 'Total Artículos',
|
|
|
|
|
value: mockInventoryStats.totalItems,
|
|
|
|
|
variant: 'default' as const,
|
|
|
|
|
icon: Package,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Stock Bajo',
|
|
|
|
|
value: mockInventoryStats.lowStockItems,
|
|
|
|
|
variant: 'warning' as const,
|
|
|
|
|
icon: AlertTriangle,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Sin Stock',
|
|
|
|
|
value: mockInventoryStats.outOfStock,
|
|
|
|
|
variant: 'error' as const,
|
|
|
|
|
icon: AlertTriangle,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Por Caducar',
|
|
|
|
|
value: mockInventoryStats.expiringSoon,
|
|
|
|
|
variant: 'error' as const,
|
|
|
|
|
icon: Clock,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Valor Total',
|
|
|
|
|
value: formatters.currency(mockInventoryStats.totalValue),
|
|
|
|
|
variant: 'success' as const,
|
|
|
|
|
icon: DollarSign,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Categorías',
|
|
|
|
|
value: mockInventoryStats.categories,
|
|
|
|
|
variant: 'info' as const,
|
|
|
|
|
icon: Package,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
return (
|
2025-08-30 19:11:15 +02:00
|
|
|
<div className="space-y-6">
|
2025-08-28 10:41:04 +02:00
|
|
|
<PageHeader
|
|
|
|
|
title="Gestión de Inventario"
|
|
|
|
|
description="Controla el stock de ingredientes y materias primas"
|
2025-08-30 19:11:15 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
]}
|
2025-08-28 10:41:04 +02:00
|
|
|
/>
|
|
|
|
|
|
2025-08-30 19:11:15 +02:00
|
|
|
{/* Stats Grid */}
|
|
|
|
|
<StatsGrid
|
|
|
|
|
stats={inventoryStats}
|
2025-08-30 19:21:15 +02:00
|
|
|
columns={3}
|
2025-08-30 19:11:15 +02:00
|
|
|
/>
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
{/* Low Stock Alert */}
|
|
|
|
|
{lowStockItems.length > 0 && (
|
|
|
|
|
<LowStockAlert items={lowStockItems} />
|
|
|
|
|
)}
|
|
|
|
|
|
2025-08-30 19:11:15 +02:00
|
|
|
{/* Simplified Controls */}
|
|
|
|
|
<Card className="p-4">
|
2025-08-28 10:41:04 +02:00
|
|
|
<div className="flex flex-col sm:flex-row gap-4">
|
|
|
|
|
<div className="flex-1">
|
2025-08-30 19:11:15 +02:00
|
|
|
<Input
|
|
|
|
|
placeholder="Buscar artículos por nombre, categoría o proveedor..."
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
className="w-full"
|
|
|
|
|
/>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
2025-08-30 19:11:15 +02:00
|
|
|
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
|
|
|
|
<Download className="w-4 h-4 mr-2" />
|
|
|
|
|
Exportar
|
|
|
|
|
</Button>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
</Card>
|
|
|
|
|
|
2025-08-30 19:11:15 +02:00
|
|
|
{/* Inventory Items Grid */}
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
2025-08-31 10:46:13 +02:00
|
|
|
{filteredItems.map((item) => {
|
|
|
|
|
const statusConfig = getInventoryStatusConfig(item);
|
|
|
|
|
const stockPercentage = Math.round((item.currentStock / item.maxStock) * 100);
|
|
|
|
|
const isExpiringSoon = new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
|
|
|
const isExpired = new Date(item.expirationDate) < new Date();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<StatusCard
|
|
|
|
|
key={item.id}
|
|
|
|
|
id={item.id}
|
|
|
|
|
statusIndicator={statusConfig}
|
|
|
|
|
title={item.name}
|
|
|
|
|
subtitle={`${item.category} • ${item.supplier}`}
|
|
|
|
|
primaryValue={item.currentStock}
|
|
|
|
|
primaryValueLabel={item.unit}
|
|
|
|
|
secondaryInfo={{
|
|
|
|
|
label: 'Valor total',
|
|
|
|
|
value: `${formatters.currency(item.currentStock * item.cost)} (${formatters.currency(item.cost)}/${item.unit})`
|
|
|
|
|
}}
|
|
|
|
|
progress={{
|
|
|
|
|
label: 'Nivel de stock',
|
|
|
|
|
percentage: stockPercentage,
|
|
|
|
|
color: statusConfig.color
|
|
|
|
|
}}
|
|
|
|
|
metadata={[
|
|
|
|
|
`Rango: ${item.minStock} - ${item.maxStock} ${item.unit}`,
|
|
|
|
|
`Caduca: ${new Date(item.expirationDate).toLocaleDateString('es-ES')}${isExpired ? ' (CADUCADO)' : isExpiringSoon ? ' (PRONTO)' : ''}`,
|
|
|
|
|
`Último restock: ${new Date(item.lastRestocked).toLocaleDateString('es-ES')}`
|
|
|
|
|
]}
|
|
|
|
|
actions={[
|
|
|
|
|
{
|
|
|
|
|
label: 'Ver',
|
|
|
|
|
icon: Eye,
|
|
|
|
|
variant: 'outline',
|
|
|
|
|
onClick: () => {
|
2025-08-30 19:11:15 +02:00
|
|
|
setSelectedItem(item);
|
2025-08-31 10:46:13 +02:00
|
|
|
setModalMode('view');
|
2025-08-30 19:11:15 +02:00
|
|
|
setShowForm(true);
|
2025-08-31 10:46:13 +02:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Editar',
|
|
|
|
|
icon: Edit,
|
|
|
|
|
variant: 'outline',
|
|
|
|
|
onClick: () => {
|
2025-08-30 19:11:15 +02:00
|
|
|
setSelectedItem(item);
|
2025-08-31 10:46:13 +02:00
|
|
|
setModalMode('edit');
|
2025-08-30 19:11:15 +02:00
|
|
|
setShowForm(true);
|
2025-08-31 10:46:13 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-08-30 19:11:15 +02:00
|
|
|
</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>
|
|
|
|
|
)}
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-08-31 10:46:13 +02:00
|
|
|
{/* Inventory Item Modal */}
|
|
|
|
|
{showForm && selectedItem && (
|
|
|
|
|
<StatusModal
|
|
|
|
|
isOpen={showForm}
|
2025-08-28 10:41:04 +02:00
|
|
|
onClose={() => {
|
|
|
|
|
setShowForm(false);
|
|
|
|
|
setSelectedItem(null);
|
2025-08-31 10:46:13 +02:00
|
|
|
setModalMode('view');
|
2025-08-28 10:41:04 +02:00
|
|
|
}}
|
2025-08-31 10:46:13 +02:00
|
|
|
mode={modalMode}
|
|
|
|
|
onModeChange={setModalMode}
|
|
|
|
|
title={selectedItem.name}
|
|
|
|
|
subtitle={`${selectedItem.category} - ${selectedItem.supplier}`}
|
|
|
|
|
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: 'Proveedor',
|
|
|
|
|
value: selectedItem.supplier
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Unidad de medida',
|
|
|
|
|
value: selectedItem.unit
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Stock y Niveles',
|
|
|
|
|
icon: Package,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
label: 'Stock actual',
|
|
|
|
|
value: `${selectedItem.currentStock} ${selectedItem.unit}`,
|
|
|
|
|
highlight: true
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Stock mínimo',
|
|
|
|
|
value: `${selectedItem.minStock} ${selectedItem.unit}`
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Stock máximo',
|
|
|
|
|
value: `${selectedItem.maxStock} ${selectedItem.unit}`
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Porcentaje de stock',
|
|
|
|
|
value: Math.round((selectedItem.currentStock / selectedItem.maxStock) * 100),
|
|
|
|
|
type: 'percentage',
|
|
|
|
|
highlight: selectedItem.currentStock <= selectedItem.minStock
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Información Financiera',
|
|
|
|
|
icon: DollarSign,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
label: 'Costo por unidad',
|
|
|
|
|
value: selectedItem.cost,
|
|
|
|
|
type: 'currency'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Valor total en stock',
|
|
|
|
|
value: selectedItem.currentStock * selectedItem.cost,
|
|
|
|
|
type: 'currency',
|
|
|
|
|
highlight: true
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Fechas Importantes',
|
|
|
|
|
icon: Calendar,
|
|
|
|
|
fields: [
|
|
|
|
|
{
|
|
|
|
|
label: 'Último restock',
|
|
|
|
|
value: selectedItem.lastRestocked,
|
|
|
|
|
type: 'date'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Fecha de caducidad',
|
|
|
|
|
value: selectedItem.expirationDate,
|
|
|
|
|
type: 'date',
|
|
|
|
|
highlight: new Date(selectedItem.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
]}
|
|
|
|
|
onEdit={() => {
|
|
|
|
|
console.log('Editing inventory item:', selectedItem.id);
|
2025-08-28 10:41:04 +02:00
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default InventoryPage;
|