Fix UI for inventory page

This commit is contained in:
Urtzi Alfaro
2025-09-15 15:31:27 +02:00
parent 36cfc88f93
commit 65a53c6d16
10 changed files with 953 additions and 378 deletions

View File

@@ -1,34 +1,42 @@
import React, { useState, useMemo } from 'react';
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign, ArrowRight, TrendingUp, Shield } from 'lucide-react';
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Edit, Clock, Euro, ArrowRight } 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 { LowStockAlert, InventoryModal } from '../../../../components/domain/inventory';
import { useIngredients, useStockAnalytics, useUpdateIngredient, useCreateIngredient } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { IngredientResponse } from '../../../../api/types/inventory';
import { IngredientResponse, IngredientUpdate, IngredientCreate, UnitOfMeasure, IngredientCategory, ProductType } 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 [modalMode, setModalMode] = useState<'create' | 'view' | 'edit'>('view');
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
const [formData, setFormData] = useState<Partial<IngredientCreate & IngredientUpdate & { initial_stock?: number; product_type?: string }>>({});
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
// API Data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
} = useIngredients(tenantId, { search: searchTerm || undefined });
const {
data: analyticsData,
isLoading: analyticsLoading
const {
data: analyticsData,
isLoading: analyticsLoading
} = useStockAnalytics(tenantId);
// Mutations
const updateIngredientMutation = useUpdateIngredient();
const createIngredientMutation = useCreateIngredient();
const ingredients = ingredientsData || [];
const lowStockItems = ingredients.filter(ingredient => ingredient.stock_status === 'low_stock');
@@ -73,6 +81,125 @@ const InventoryPage: React.FC = () => {
}
};
// Form handlers
const handleSave = async () => {
try {
if (modalMode === 'create') {
// Create new ingredient - ensure required fields are present
const createData: IngredientCreate = {
name: formData.name || '',
unit_of_measure: formData.unit_of_measure || UnitOfMeasure.GRAMS,
low_stock_threshold: formData.low_stock_threshold || 10,
reorder_point: formData.reorder_point || 20,
description: formData.description,
category: formData.category,
max_stock_level: formData.max_stock_level,
shelf_life_days: formData.shelf_life_days,
requires_refrigeration: formData.requires_refrigeration,
requires_freezing: formData.requires_freezing,
is_seasonal: formData.is_seasonal,
supplier_id: formData.supplier_id,
average_cost: formData.average_cost,
notes: formData.notes
};
const createdItem = await createIngredientMutation.mutateAsync({
tenantId,
ingredientData: createData
});
// TODO: Handle initial stock if provided
// if (formData.initial_stock && formData.initial_stock > 0) {
// // Add initial stock using stock transaction API
// await addStockMutation.mutateAsync({
// tenantId,
// ingredientId: createdItem.id,
// quantity: formData.initial_stock,
// transaction_type: 'addition',
// notes: 'Stock inicial'
// });
// }
} else {
// Update existing ingredient
if (!selectedItem) return;
await updateIngredientMutation.mutateAsync({
tenantId,
ingredientId: selectedItem.id,
updateData: formData as IngredientUpdate
});
}
// Reset form data and close modal
setFormData({});
setModalMode('view');
setShowForm(false);
setSelectedItem(null);
} catch (error) {
console.error(`Error ${modalMode === 'create' ? 'creating' : 'updating'} ingredient:`, error);
// TODO: Show error toast
}
};
const handleCancel = () => {
setFormData({});
setModalMode('view');
setShowForm(false);
setSelectedItem(null);
};
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
// Map section and field indexes to actual field names
// Different mapping for create vs view/edit modes
const createFieldMap: { [key: string]: string } = {
'0-0': 'product_type', // Basic Info - Product Type
'0-1': 'name', // Basic Info - Name
'0-2': 'category', // Basic Info - Category
'0-3': 'unit_of_measure', // Basic Info - Unit of measure
'1-0': 'initial_stock', // Stock Config - Initial stock
'1-1': 'low_stock_threshold', // Stock Config - Threshold
'1-2': 'reorder_point', // Stock Config - Reorder point
};
const viewEditFieldMap: { [key: string]: string } = {
'0-0': 'name', // Basic Info - Name
'0-1': 'category', // Basic Info - Category
'0-2': 'description', // Basic Info - Description
'0-3': 'unit_of_measure', // Basic Info - Unit of measure
'1-1': 'low_stock_threshold', // Stock - Threshold
'1-2': 'max_stock_level', // Stock - Max level
'1-3': 'reorder_point', // Stock - Reorder point
'1-4': 'reorder_quantity', // Stock - Reorder quantity
'2-0': 'average_cost', // Financial - Average cost
'3-1': 'shelf_life_days', // Additional - Shelf life
'3-2': 'requires_refrigeration', // Additional - Refrigeration
'3-3': 'requires_freezing', // Additional - Freezing
'3-4': 'is_seasonal', // Additional - Seasonal
'4-0': 'notes' // Notes - Observations
};
// Use appropriate field map based on modal mode
const fieldMap = modalMode === 'create' ? createFieldMap : viewEditFieldMap;
// Boolean field mapping for proper conversion
const booleanFields = ['requires_refrigeration', 'requires_freezing', 'is_seasonal'];
const fieldKey = `${sectionIndex}-${fieldIndex}`;
const fieldName = fieldMap[fieldKey];
if (fieldName) {
// Convert string boolean values to actual booleans for boolean fields
const processedValue = booleanFields.includes(fieldName)
? value === 'true'
: value;
setFormData(prev => ({
...prev,
[fieldName]: processedValue
}));
}
};
const filteredItems = useMemo(() => {
if (!searchTerm) return ingredients;
@@ -84,6 +211,55 @@ const InventoryPage: React.FC = () => {
});
}, [ingredients, searchTerm]);
// Helper function to get category display name
const getCategoryDisplayName = (category?: string): string => {
const categoryMappings: Record<string, string> = {
'flour': 'Harina',
'dairy': 'Lácteos',
'eggs': 'Huevos',
'sugar': 'Azúcar',
'yeast': 'Levadura',
'fats': 'Grasas',
'spices': 'Especias',
'croissants': 'Croissants',
'pastries': 'Pastelería',
'beverages': 'Bebidas',
'bread': 'Pan',
'other': 'Otros'
};
return categoryMappings[category?.toLowerCase() || ''] || category || 'Sin categoría';
};
// Simplified item action handler
const handleItemAction = (ingredient: any, action: 'view' | 'edit') => {
setSelectedItem(ingredient);
setModalMode(action);
setFormData({});
setShowForm(true);
};
// Handle new item creation
const handleNewItem = () => {
setSelectedItem(null);
setModalMode('create');
setFormData({
product_type: 'ingredient', // Default to ingredient
name: '',
unit_of_measure: UnitOfMeasure.GRAMS,
low_stock_threshold: 10,
reorder_point: 20,
reorder_quantity: 50,
category: IngredientCategory.OTHER,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false
});
setShowForm(true);
};
const inventoryStats = useMemo(() => {
if (!analyticsData) {
return {
@@ -91,8 +267,8 @@ const InventoryPage: React.FC = () => {
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,
totalValue: ingredients.reduce((sum, item) => sum + ((item.current_stock || 0) * (item.average_cost || 0)), 0),
categories: Array.from(new Set(ingredients.map(item => item.category))).length,
turnoverRate: 0,
fastMovingItems: 0,
qualityScore: 85,
@@ -101,7 +277,7 @@ const InventoryPage: React.FC = () => {
}
// Extract data from new analytics structure
const stockoutCount = Object.values(analyticsData.stockout_frequency || {}).reduce((sum: number, count) => sum + count, 0);
const stockoutCount = Object.values(analyticsData.stockout_frequency || {}).reduce((sum: number, count: unknown) => sum + (typeof count === 'number' ? count : 0), 0);
const fastMovingCount = (analyticsData.fast_moving_items || []).length;
return {
@@ -110,7 +286,7 @@ const InventoryPage: React.FC = () => {
outOfStock: stockoutCount || ingredients.filter(item => item.stock_status === 'out_of_stock').length,
expiringSoon: Math.round(Number(analyticsData.quality_incidents_rate || 0) * ingredients.length), // Estimate from quality incidents
totalValue: Number(analyticsData.total_inventory_cost || 0),
categories: Object.keys(analyticsData.cost_by_category || {}).length || [...new Set(ingredients.map(item => item.category))].length,
categories: Object.keys(analyticsData.cost_by_category || {}).length || Array.from(new Set(ingredients.map(item => item.category))).length,
turnoverRate: Number(analyticsData.inventory_turnover_rate || 0),
fastMovingItems: fastMovingCount,
qualityScore: Number(analyticsData.food_safety_score || 85),
@@ -147,7 +323,7 @@ const InventoryPage: React.FC = () => {
title: 'Valor Total',
value: formatters.currency(inventoryStats.totalValue),
variant: 'success' as const,
icon: DollarSign,
icon: Euro,
},
{
title: 'Tasa Rotación',
@@ -155,18 +331,6 @@ const InventoryPage: React.FC = () => {
variant: 'info' as const,
icon: ArrowRight,
},
{
title: 'Items Dinámicos',
value: inventoryStats.fastMovingItems,
variant: 'success' as const,
icon: TrendingUp,
},
{
title: 'Puntuación Calidad',
value: `${inventoryStats.qualityScore}%`,
variant: inventoryStats.qualityScore >= 90 ? 'success' as const : inventoryStats.qualityScore >= 70 ? 'warning' as const : 'error' as const,
icon: Shield,
},
];
// Loading and error states
@@ -201,19 +365,12 @@ const InventoryPage: React.FC = () => {
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)
onClick: handleNewItem
}
]}
/>
@@ -221,96 +378,9 @@ const InventoryPage: React.FC = () => {
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={4}
columns={3}
/>
{/* Analytics Section */}
{analyticsData && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Fast Moving Items */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-green-500" />
Items de Alta Rotación
</h3>
<div className="space-y-3">
{(analyticsData.fast_moving_items || []).slice(0, 5).map((item: any, index: number) => (
<div key={item.ingredient_id || index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium">{item.name}</p>
<p className="text-sm text-gray-600">{item.movement_count} movimientos</p>
</div>
<div className="text-right">
<p className="font-medium">{formatters.currency(item.avg_cost)}</p>
</div>
</div>
))}
{(!analyticsData.fast_moving_items || analyticsData.fast_moving_items.length === 0) && (
<p className="text-gray-500 text-center py-4">No hay datos de items de alta rotación</p>
)}
</div>
</Card>
{/* Cost by Category */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Package className="w-5 h-5 text-blue-500" />
Costos por Categoría
</h3>
<div className="space-y-3">
{Object.entries(analyticsData.cost_by_category || {}).slice(0, 5).map(([category, cost]) => (
<div key={category} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<p className="font-medium capitalize">{category}</p>
</div>
<div className="text-right">
<p className="font-medium">{formatters.currency(Number(cost))}</p>
</div>
</div>
))}
{Object.keys(analyticsData.cost_by_category || {}).length === 0 && (
<p className="text-gray-500 text-center py-4">No hay datos de costos por categoría</p>
)}
</div>
</Card>
{/* Efficiency Metrics */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-purple-500" />
Métricas de Eficiencia
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{inventoryStats.reorderAccuracy}%</p>
<p className="text-sm text-gray-600">Precisión Reorden</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{inventoryStats.turnoverRate.toFixed(1)}</p>
<p className="text-sm text-gray-600">Tasa Rotación</p>
</div>
</div>
</Card>
{/* Quality Metrics */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-green-500" />
Indicadores de Calidad
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-green-600">{inventoryStats.qualityScore}%</p>
<p className="text-sm text-gray-600">Puntuación Seguridad</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{Number(analyticsData.temperature_compliance_rate || 95).toFixed(1)}%</p>
<p className="text-sm text-gray-600">Cumplimiento Temp.</p>
</div>
</div>
</Card>
</div>
)}
{/* Low Stock Alert */}
{lowStockItems.length > 0 && (
@@ -328,10 +398,6 @@ const InventoryPage: React.FC = () => {
className="w-full"
/>
</div>
<Button variant="outline" onClick={() => console.log('Export filtered')}>
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
</Card>
@@ -339,10 +405,18 @@ const InventoryPage: React.FC = () => {
<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;
// Safe number conversions with fallbacks
const currentStock = Number(ingredient.current_stock) || 0;
const maxStock = Number(ingredient.max_stock_level) || 0;
const averageCost = Number(ingredient.average_cost) || 0;
// Calculate stock percentage safely
const stockPercentage = maxStock > 0 ?
Math.round((currentStock / maxStock) * 100) : 0;
// Calculate total value safely
const totalValue = currentStock * averageCost;
return (
<StatusCard
@@ -350,47 +424,30 @@ const InventoryPage: React.FC = () => {
id={ingredient.id}
statusIndicator={statusConfig}
title={ingredient.name}
subtitle={`${ingredient.category}${ingredient.description ? `${ingredient.description}` : ''}`}
primaryValue={ingredient.current_stock_level}
subtitle={getCategoryDisplayName(ingredient.category)}
primaryValue={currentStock}
primaryValueLabel={ingredient.unit_of_measure}
secondaryInfo={{
label: 'Valor total',
value: `${formatters.currency(totalValue)}${averageCost > 0 ? ` (${formatters.currency(averageCost)}/${ingredient.unit_of_measure})` : ''}`
label: 'Valor',
value: formatters.currency(totalValue)
}}
progress={ingredient.max_stock_level ? {
label: 'Nivel de stock',
progress={maxStock > 0 ? {
label: `${stockPercentage}% 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);
}
onClick: () => handleItemAction(ingredient, 'view')
},
{
label: 'Editar',
icon: Edit,
variant: 'outline',
onClick: () => {
setSelectedItem(ingredient);
setModalMode('edit');
setShowForm(true);
}
variant: 'primary',
onClick: () => handleItemAction(ingredient, 'edit')
}
]}
/>
@@ -408,148 +465,31 @@ const InventoryPage: React.FC = () => {
<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)}>
<Button onClick={handleNewItem}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Artículo
</Button>
</div>
)}
{/* Inventory Item Modal */}
{showForm && selectedItem && (
<StatusModal
{/* Unified Inventory Modal */}
{showForm && (
<InventoryModal
isOpen={showForm}
onClose={() => {
setShowForm(false);
setSelectedItem(null);
setModalMode('view');
setFormData({});
}}
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);
}}
onModeChange={(mode) => setModalMode(mode as 'create' | 'view' | 'edit')}
selectedItem={selectedItem}
formData={formData}
onFieldChange={handleFieldChange}
onSave={handleSave}
onCancel={handleCancel}
loading={updateIngredientMutation.isPending || createIngredientMutation.isPending}
/>
)}
</div>