Fix UI for inventory page 3

This commit is contained in:
Urtzi Alfaro
2025-09-16 12:21:15 +02:00
parent dd4016e217
commit 7aa26d51d3
15 changed files with 1660 additions and 2030 deletions

View File

@@ -174,9 +174,9 @@ export const useStockMovements = (
ingredientId?: string, ingredientId?: string,
limit: number = 50, limit: number = 50,
offset: number = 0, offset: number = 0,
options?: Omit<UseQueryOptions<PaginatedResponse<StockMovementResponse>, ApiError>, 'queryKey' | 'queryFn'> options?: Omit<UseQueryOptions<StockMovementResponse[], ApiError>, 'queryKey' | 'queryFn'>
) => { ) => {
return useQuery<PaginatedResponse<StockMovementResponse>, ApiError>({ return useQuery<StockMovementResponse[], ApiError>({
queryKey: inventoryKeys.stock.movements(tenantId, ingredientId), queryKey: inventoryKeys.stock.movements(tenantId, ingredientId),
queryFn: () => inventoryService.getStockMovements(tenantId, ingredientId, limit, offset), queryFn: () => inventoryService.getStockMovements(tenantId, ingredientId, limit, offset),
enabled: !!tenantId, enabled: !!tenantId,
@@ -339,34 +339,117 @@ export const useConsumeStock = (
export const useCreateStockMovement = ( export const useCreateStockMovement = (
options?: UseMutationOptions< options?: UseMutationOptions<
StockMovementResponse, StockMovementResponse,
ApiError, ApiError,
{ tenantId: string; movementData: StockMovementCreate } { tenantId: string; movementData: StockMovementCreate }
> >
) => { ) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation< return useMutation<
StockMovementResponse, StockMovementResponse,
ApiError, ApiError,
{ tenantId: string; movementData: StockMovementCreate } { tenantId: string; movementData: StockMovementCreate }
>({ >({
mutationFn: ({ tenantId, movementData }) => inventoryService.createStockMovement(tenantId, movementData), mutationFn: ({ tenantId, movementData }) => inventoryService.createStockMovement(tenantId, movementData),
onSuccess: (data, { tenantId, movementData }) => { onSuccess: (data, { tenantId, movementData }) => {
// Invalidate movement queries // Invalidate movement queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) }); queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.movements(tenantId, movementData.ingredient_id) queryKey: inventoryKeys.stock.movements(tenantId, movementData.ingredient_id)
}); });
// Invalidate stock queries if this affects stock levels // Invalidate stock queries if this affects stock levels
if (['in', 'out', 'adjustment'].includes(movementData.movement_type)) { if (['in', 'out', 'adjustment'].includes(movementData.movement_type)) {
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() }); queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.lists() });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: inventoryKeys.stock.byIngredient(tenantId, movementData.ingredient_id) queryKey: inventoryKeys.stock.byIngredient(tenantId, movementData.ingredient_id)
}); });
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() }); queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
} }
}, },
...options, ...options,
}); });
};
// Custom hooks for stock management operations
export const useStockOperations = (tenantId: string) => {
const queryClient = useQueryClient();
const addStock = useMutation({
mutationFn: async ({ ingredientId, quantity, unit_cost, notes }: {
ingredientId: string;
quantity: number;
unit_cost?: number;
notes?: string;
}) => {
// Create stock entry via backend API
const stockData: StockCreate = {
ingredient_id: ingredientId,
quantity,
unit_price: unit_cost || 0,
notes
};
return inventoryService.addStock(tenantId, stockData);
},
onSuccess: (data, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) });
}
});
const consumeStock = useMutation({
mutationFn: async ({ ingredientId, quantity, reference_number, notes, fifo = true }: {
ingredientId: string;
quantity: number;
reference_number?: string;
notes?: string;
fifo?: boolean;
}) => {
const consumptionData: StockConsumptionRequest = {
ingredient_id: ingredientId,
quantity,
reference_number,
notes,
fifo
};
return inventoryService.consumeStock(tenantId, consumptionData);
},
onSuccess: (data, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) });
}
});
const adjustStock = useMutation({
mutationFn: async ({ ingredientId, quantity, notes }: {
ingredientId: string;
quantity: number;
notes?: string;
}) => {
// Create adjustment movement via backend API
const movementData: StockMovementCreate = {
ingredient_id: ingredientId,
movement_type: 'adjustment',
quantity,
notes
};
return inventoryService.createStockMovement(tenantId, movementData);
},
onSuccess: (data, variables) => {
// Invalidate relevant queries
queryClient.invalidateQueries({ queryKey: inventoryKeys.ingredients.lists() });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId) });
queryClient.invalidateQueries({ queryKey: inventoryKeys.stock.movements(tenantId, variables.ingredientId) });
}
});
return {
addStock,
consumeStock,
adjustStock
};
}; };

View File

@@ -172,15 +172,23 @@ export class InventoryService {
ingredientId?: string, ingredientId?: string,
limit: number = 50, limit: number = 50,
offset: number = 0 offset: number = 0
): Promise<PaginatedResponse<StockMovementResponse>> { ): Promise<StockMovementResponse[]> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (ingredientId) queryParams.append('ingredient_id', ingredientId); if (ingredientId) queryParams.append('ingredient_id', ingredientId);
queryParams.append('limit', limit.toString()); queryParams.append('limit', limit.toString());
queryParams.append('offset', offset.toString()); queryParams.append('skip', offset.toString()); // Backend expects 'skip' not 'offset'
return apiClient.get<PaginatedResponse<StockMovementResponse>>( const url = `${this.baseUrl}/${tenantId}/stock/movements?${queryParams.toString()}`;
`${this.baseUrl}/${tenantId}/stock/movements?${queryParams.toString()}` console.log('🔍 Frontend calling API:', url);
);
try {
const result = await apiClient.get<StockMovementResponse[]>(url);
console.log('✅ Frontend API response:', result);
return result;
} catch (error) {
console.error('❌ Frontend API error:', error);
throw error;
}
} }
// Expiry Management // Expiry Management

View File

@@ -183,27 +183,36 @@ export interface StockResponse {
export interface StockMovementCreate { export interface StockMovementCreate {
ingredient_id: string; ingredient_id: string;
movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste'; stock_id?: string;
movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock';
quantity: number; quantity: number;
unit_price?: number; unit_cost?: number;
reference_number?: string; reference_number?: string;
supplier_id?: string;
notes?: string; notes?: string;
related_stock_id?: string; reason_code?: string;
movement_date?: string;
} }
export interface StockMovementResponse { export interface StockMovementResponse {
id: string; id: string;
ingredient_id: string;
tenant_id: string; tenant_id: string;
movement_type: 'in' | 'out' | 'adjustment' | 'transfer' | 'waste'; ingredient_id: string;
stock_id?: string;
movement_type: 'purchase' | 'production_use' | 'adjustment' | 'waste' | 'transfer' | 'return' | 'initial_stock';
quantity: number; quantity: number;
unit_price?: number; unit_cost?: number;
total_value?: number; total_cost?: number;
quantity_before?: number;
quantity_after?: number;
reference_number?: string; reference_number?: string;
supplier_id?: string;
notes?: string; notes?: string;
related_stock_id?: string; reason_code?: string;
movement_date: string;
created_at: string; created_at: string;
created_by?: string; created_by?: string;
ingredient?: IngredientResponse;
} }
// Filter and Query Types // Filter and Query Types

View File

@@ -1,364 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { clsx } from 'clsx';
import { Button } from '../../ui';
import { Input } from '../../ui';
import { Select } from '../../ui';
import { Card } from '../../ui';
import { Badge } from '../../ui';
import { Modal } from '../../ui';
import { IngredientCreate, IngredientResponse } from '../../../api/types/inventory';
export interface InventoryFormProps {
item?: IngredientResponse;
open?: boolean;
onClose?: () => void;
onSubmit?: (data: IngredientCreate) => Promise<void>;
loading?: boolean;
className?: string;
}
// Spanish bakery categories
const BAKERY_CATEGORIES = [
{ value: 'harinas', label: 'Harinas' },
{ value: 'levaduras', label: 'Levaduras' },
{ value: 'azucares', label: 'Azúcares y Endulzantes' },
{ value: 'chocolates', label: 'Chocolates y Cacao' },
{ value: 'frutas', label: 'Frutas y Frutos Secos' },
{ value: 'lacteos', label: 'Lácteos' },
{ value: 'huevos', label: 'Huevos' },
{ value: 'mantequillas', label: 'Mantequillas y Grasas' },
{ value: 'especias', label: 'Especias y Aromas' },
{ value: 'conservantes', label: 'Conservantes y Aditivos' },
{ value: 'decoracion', label: 'Decoración' },
{ value: 'envases', label: 'Envases y Embalajes' },
{ value: 'utensilios', label: 'Utensilios y Equipos' },
{ value: 'limpieza', label: 'Limpieza e Higiene' },
];
const UNITS_OF_MEASURE = [
{ value: 'kg', label: 'Kilogramo (kg)' },
{ value: 'g', label: 'Gramo (g)' },
{ value: 'l', label: 'Litro (l)' },
{ value: 'ml', label: 'Mililitro (ml)' },
{ value: 'pz', label: 'Pieza (pz)' },
{ value: 'pkg', label: 'Paquete' },
{ value: 'bag', label: 'Bolsa' },
{ value: 'box', label: 'Caja' },
{ value: 'dozen', label: 'Docena' },
{ value: 'cup', label: 'Taza' },
{ value: 'tbsp', label: 'Cucharada' },
{ value: 'tsp', label: 'Cucharadita' },
{ value: 'lb', label: 'Libra (lb)' },
{ value: 'oz', label: 'Onza (oz)' },
];
const initialFormData: IngredientCreate = {
name: '',
description: '',
category: '',
unit_of_measure: 'kg',
low_stock_threshold: 10,
max_stock_level: 100,
reorder_point: 20,
shelf_life_days: undefined,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false,
supplier_id: undefined,
average_cost: undefined,
notes: '',
};
export const InventoryForm: React.FC<InventoryFormProps> = ({
item,
open = false,
onClose,
onSubmit,
loading = false,
className,
}) => {
const [formData, setFormData] = useState<IngredientCreate>(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const isEditing = !!item;
// Initialize form data when item changes
useEffect(() => {
if (item) {
setFormData({
name: item.name,
description: item.description || '',
category: item.category,
unit_of_measure: item.unit_of_measure,
low_stock_threshold: item.low_stock_threshold,
max_stock_level: item.max_stock_level,
reorder_point: item.reorder_point,
shelf_life_days: item.shelf_life_days,
requires_refrigeration: item.requires_refrigeration,
requires_freezing: item.requires_freezing,
is_seasonal: item.is_seasonal,
supplier_id: item.supplier_id,
average_cost: item.average_cost,
notes: item.notes || '',
});
} else {
setFormData(initialFormData);
}
setErrors({});
}, [item]);
// Handle input changes
const handleInputChange = useCallback((field: keyof IngredientCreate, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error for this field
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
}, [errors]);
// Validate form
const validateForm = useCallback((): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = 'El nombre es obligatorio';
}
if (!formData.unit_of_measure) {
newErrors.unit_of_measure = 'La unidad de medida es obligatoria';
}
if (formData.low_stock_threshold < 0) {
newErrors.low_stock_threshold = 'El stock mínimo no puede ser negativo';
}
if (formData.reorder_point < 0) {
newErrors.reorder_point = 'El punto de reorden no puede ser negativo';
}
if (formData.max_stock_level !== undefined && formData.max_stock_level < formData.low_stock_threshold) {
newErrors.max_stock_level = 'El stock máximo debe ser mayor que el mínimo';
}
if (formData.average_cost !== undefined && formData.average_cost < 0) {
newErrors.average_cost = 'El precio no puede ser negativo';
}
if (formData.shelf_life_days !== undefined && formData.shelf_life_days <= 0) {
newErrors.shelf_life_days = 'La vida útil debe ser mayor que 0';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [formData]);
// Handle form submission
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
try {
await onSubmit?.(formData);
} catch (error) {
console.error('Form submission failed:', error);
}
}, [formData, validateForm, onSubmit]);
return (
<Modal
open={open}
onClose={onClose}
title={isEditing ? 'Editar Ingrediente' : 'Nuevo Ingrediente'}
size="lg"
className={className}
>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Basic Information */}
<div className="space-y-4">
<Card className="p-4">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Información Básica</h3>
<div className="space-y-4">
<Input
label="Nombre del Ingrediente"
isRequired
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
placeholder="Ej. Harina de trigo"
/>
<Select
label="Unidad de Medida"
isRequired
value={formData.unit_of_measure}
onChange={(value) => handleInputChange('unit_of_measure', value)}
options={UNITS_OF_MEASURE}
error={errors.unit_of_measure}
/>
<Input
label="Descripción"
value={formData.description || ''}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Descripción detallada del ingrediente"
/>
<Select
label="Categoría"
value={formData.category || ''}
onChange={(value) => handleInputChange('category', value)}
options={[{ value: '', label: 'Seleccionar categoría' }, ...BAKERY_CATEGORIES]}
placeholder="Seleccionar categoría"
/>
<Input
label="Notas"
value={formData.notes || ''}
onChange={(e) => handleInputChange('notes', e.target.value)}
placeholder="Notas adicionales sobre el ingrediente"
/>
</div>
</Card>
</div>
{/* Right Column - Specifications */}
<div className="space-y-4">
<Card className="p-4">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Precios y Costos</h3>
<div className="space-y-4">
<Input
label="Costo Promedio"
type="number"
step="0.01"
min="0"
value={formData.average_cost?.toString() || ''}
onChange={(e) => handleInputChange('average_cost', e.target.value ? parseFloat(e.target.value) : undefined)}
error={errors.average_cost}
leftAddon="€"
placeholder="0.00"
/>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Gestión de Stock</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Input
label="Stock Mínimo"
isRequired
type="number"
step="0.01"
min="0"
value={formData.low_stock_threshold?.toString() || ''}
onChange={(e) => handleInputChange('low_stock_threshold', parseFloat(e.target.value) || 0)}
error={errors.low_stock_threshold}
placeholder="10"
/>
<Input
label="Punto de Reorden"
isRequired
type="number"
step="0.01"
min="0"
value={formData.reorder_point?.toString() || ''}
onChange={(e) => handleInputChange('reorder_point', parseFloat(e.target.value) || 0)}
error={errors.reorder_point}
placeholder="20"
/>
</div>
<Input
label="Stock Máximo (Opcional)"
type="number"
step="0.01"
min="0"
value={formData.max_stock_level?.toString() || ''}
onChange={(e) => handleInputChange('max_stock_level', e.target.value ? parseFloat(e.target.value) : undefined)}
error={errors.max_stock_level}
placeholder="Ej. 100"
helperText="Dejar vacío para stock ilimitado"
/>
</div>
</Card>
</div>
</div>
{/* Storage and Preservation */}
<Card className="p-4">
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Almacenamiento y Conservación</h3>
<div className="space-y-4">
<div className="flex flex-wrap gap-4">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.is_seasonal || false}
onChange={(e) => handleInputChange('is_seasonal', e.target.checked)}
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<span className="ml-2 text-sm text-[var(--text-primary)]">Producto Estacional</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.requires_refrigeration || false}
onChange={(e) => handleInputChange('requires_refrigeration', e.target.checked)}
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<span className="ml-2 text-sm text-[var(--text-primary)]">Requiere Refrigeración</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.requires_freezing || false}
onChange={(e) => handleInputChange('requires_freezing', e.target.checked)}
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
<span className="ml-2 text-sm text-[var(--text-primary)]">Requiere Congelación</span>
</label>
</div>
<Input
label="Vida Útil (días)"
type="number"
min="1"
value={formData.shelf_life_days?.toString() || ''}
onChange={(e) => handleInputChange('shelf_life_days', e.target.value ? parseFloat(e.target.value) : undefined)}
error={errors.shelf_life_days}
placeholder="Ej. 30"
/>
</div>
</Card>
{/* Form Actions */}
<div className="flex gap-4 justify-end pt-4 border-t border-[var(--border-primary)]">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={loading}
>
Cancelar
</Button>
<Button
type="submit"
loading={loading}
disabled={loading}
>
{isEditing ? 'Actualizar' : 'Crear'} Ingrediente
</Button>
</div>
</form>
</Modal>
);
};
export default InventoryForm;

File diff suppressed because it is too large Load Diff

View File

@@ -1,389 +0,0 @@
import React from 'react';
import { Layers, Settings, Calculator, FileText, Calendar } from 'lucide-react';
import { StatusModal, StatusModalSection, getStatusColor } from '../../ui';
import { IngredientResponse, ProductType, UnitOfMeasure, IngredientCategory, ProductCategory, IngredientCreate, IngredientUpdate } from '../../../api/types/inventory';
export interface InventoryModalProps {
isOpen: boolean;
onClose: () => void;
mode: 'create' | 'view' | 'edit';
onModeChange?: (mode: 'view' | 'edit') => void;
selectedItem?: IngredientResponse | null;
formData: Partial<IngredientCreate & IngredientUpdate & { initial_stock?: number; product_type?: string }>;
onFieldChange: (sectionIndex: number, fieldIndex: number, value: string | number) => void;
onSave: () => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
/**
* Unified Inventory Modal Component
* Handles create, view, and edit modes with consistent styling and UX
*/
export const InventoryModal: React.FC<InventoryModalProps> = ({
isOpen,
onClose,
mode,
onModeChange,
selectedItem,
formData,
onFieldChange,
onSave,
onCancel,
loading = false
}) => {
const isCreating = mode === 'create';
const isEditing = mode === 'edit';
const isViewing = mode === 'view';
// Helper functions to get dropdown options
const getUnitOfMeasureOptions = () => {
return Object.entries(UnitOfMeasure).map(([, value]) => ({
label: value,
value: value
}));
};
const getCategoryOptions = (productType?: ProductType) => {
if (productType === ProductType.INGREDIENT) {
return Object.entries(IngredientCategory).map(([, value]) => ({
label: value.charAt(0).toUpperCase() + value.slice(1).replace('_', ' '),
value: value
}));
} else {
return Object.entries(ProductCategory).map(([, value]) => ({
label: value.charAt(0).toUpperCase() + value.slice(1).replace('_', ' '),
value: value
}));
}
};
// Get status indicator based on mode and item
const getStatusIndicator = () => {
if (isCreating) {
return undefined; // No status indicator for create mode to reduce clutter
}
if (!selectedItem) return undefined;
const { stock_status } = selectedItem;
switch (stock_status) {
case 'out_of_stock':
return {
color: getStatusColor('cancelled'),
text: 'Sin Stock',
icon: Calendar,
isCritical: true,
isHighlight: false
};
case 'low_stock':
return {
color: getStatusColor('pending'),
text: 'Stock Bajo',
icon: Calendar,
isCritical: false,
isHighlight: true
};
case 'overstock':
return {
color: getStatusColor('info'),
text: 'Sobrestock',
icon: Calendar,
isCritical: false,
isHighlight: false
};
case 'in_stock':
default:
return {
color: getStatusColor('completed'),
text: 'Normal',
icon: Calendar,
isCritical: false,
isHighlight: false
};
}
};
// Get modal title based on mode
const getTitle = () => {
if (isCreating) return 'Nuevo Artículo';
return selectedItem?.name || 'Artículo de Inventario';
};
// Get modal subtitle based on mode
const getSubtitle = () => {
if (isCreating) return ''; // No subtitle for create mode
if (!selectedItem) return '';
const category = selectedItem.category || '';
const description = selectedItem.description || '';
return `${category}${description ? ` - ${description}` : ''}`;
};
// Define modal sections with create-mode simplification
const sections: StatusModalSection[] = isCreating ? [
// SIMPLIFIED CREATE MODE - Only essential fields
{
title: 'Información Básica',
icon: Layers,
fields: [
{
label: 'Tipo de producto',
value: formData.product_type || ProductType.INGREDIENT,
type: 'select',
editable: true,
required: true,
placeholder: 'Seleccionar tipo',
options: [
{ label: 'Ingrediente', value: ProductType.INGREDIENT },
{ label: 'Producto Final', value: ProductType.FINISHED_PRODUCT }
]
},
{
label: 'Nombre',
value: formData.name || '',
highlight: true,
editable: true,
required: true,
placeholder: 'Ej: Harina de trigo o Croissant de chocolate'
},
{
label: 'Categoría',
value: formData.category || '',
type: 'select',
editable: true,
required: true,
placeholder: 'Seleccionar categoría',
options: getCategoryOptions(formData.product_type as ProductType || ProductType.INGREDIENT)
},
{
label: 'Unidad de medida',
value: formData.unit_of_measure || '',
type: 'select',
editable: true,
required: true,
placeholder: 'Seleccionar unidad',
options: getUnitOfMeasureOptions()
}
]
},
{
title: 'Configuración de Stock',
icon: Settings,
fields: [
{
label: 'Stock inicial (opcional)',
value: formData.initial_stock || '',
type: 'number' as const,
editable: true,
required: false,
placeholder: 'Ej: 50 (si ya tienes este producto)'
},
{
label: 'Umbral mínimo',
value: formData.low_stock_threshold || 10,
type: 'number' as const,
editable: true,
required: true,
placeholder: 'Ej: 10'
},
{
label: 'Punto de reorden',
value: formData.reorder_point || 20,
type: 'number' as const,
editable: true,
required: true,
placeholder: 'Ej: 20'
}
]
}
] : [
// FULL VIEW/EDIT MODE - Complete information
{
title: 'Información Básica',
icon: Layers,
fields: [
{
label: 'Nombre',
value: formData.name || selectedItem?.name || '',
highlight: true,
editable: isEditing,
required: true,
placeholder: 'Nombre del ingrediente'
},
{
label: 'Categoría',
value: formData.category || selectedItem?.category || '',
type: 'select',
editable: isEditing,
placeholder: 'Seleccionar categoría',
options: getCategoryOptions(selectedItem?.product_type || ProductType.INGREDIENT)
},
{
label: 'Descripción',
value: formData.description || selectedItem?.description || '',
editable: isEditing,
placeholder: 'Descripción del producto'
},
{
label: 'Unidad de medida',
value: formData.unit_of_measure || selectedItem?.unit_of_measure || '',
type: 'select',
editable: isEditing,
required: true,
placeholder: 'Seleccionar unidad',
options: getUnitOfMeasureOptions()
}
]
},
{
title: 'Gestión de Stock',
icon: Settings,
fields: [
{
label: 'Stock actual',
value: `${Number(selectedItem?.current_stock) || 0} ${selectedItem?.unit_of_measure || ''}`,
highlight: true
},
{
label: 'Umbral mínimo',
value: formData.low_stock_threshold || selectedItem?.low_stock_threshold || 10,
type: 'number' as const,
editable: isEditing,
required: true,
placeholder: 'Cantidad mínima'
},
{
label: 'Stock máximo',
value: formData.max_stock_level || selectedItem?.max_stock_level || '',
type: 'number' as const,
editable: isEditing,
placeholder: 'Cantidad máxima (opcional)'
},
{
label: 'Punto de reorden',
value: formData.reorder_point || selectedItem?.reorder_point || 20,
type: 'number' as const,
editable: isEditing,
required: true,
placeholder: 'Punto de reorden'
},
{
label: 'Cantidad a reordenar',
value: formData.reorder_quantity || selectedItem?.reorder_quantity || 50,
type: 'number' as const,
editable: isEditing,
required: true,
placeholder: 'Cantidad por pedido'
}
]
},
{
title: 'Información Financiera',
icon: Calculator,
fields: [
{
label: 'Costo promedio por unidad',
value: formData.average_cost || selectedItem?.average_cost || 0,
type: 'currency',
editable: isEditing,
placeholder: 'Costo por unidad'
},
{
label: 'Valor total en stock',
value: (Number(selectedItem?.current_stock) || 0) * (Number(formData.average_cost || selectedItem?.average_cost) || 0),
type: 'currency' as const,
highlight: true
}
]
},
{
title: 'Información Adicional',
icon: Calendar,
fields: [
{
label: 'Último restock',
value: selectedItem?.last_restocked || 'Sin historial',
type: selectedItem?.last_restocked ? 'datetime' as const : 'text' as const
},
{
label: 'Vida útil (días)',
value: formData.shelf_life_days || selectedItem?.shelf_life_days || '',
type: 'number',
editable: isEditing,
placeholder: 'Días de vida útil'
},
{
label: 'Requiere refrigeración',
value: (formData.requires_refrigeration !== undefined ? formData.requires_refrigeration : selectedItem?.requires_refrigeration) ? 'Sí' : 'No',
type: 'select',
editable: isEditing,
options: [
{ label: 'No', value: 'false' },
{ label: 'Sí', value: 'true' }
]
},
{
label: 'Requiere congelación',
value: (formData.requires_freezing !== undefined ? formData.requires_freezing : selectedItem?.requires_freezing) ? 'Sí' : 'No',
type: 'select',
editable: isEditing,
options: [
{ label: 'No', value: 'false' },
{ label: 'Sí', value: 'true' }
]
},
{
label: 'Producto estacional',
value: (formData.is_seasonal !== undefined ? formData.is_seasonal : selectedItem?.is_seasonal) ? 'Sí' : 'No',
type: 'select',
editable: isEditing,
options: [
{ label: 'No', value: 'false' },
{ label: 'Sí', value: 'true' }
]
},
{
label: 'Creado',
value: selectedItem?.created_at,
type: 'datetime' as const
}
]
},
{
title: 'Notas',
icon: FileText,
fields: [
{
label: 'Observaciones',
value: formData.notes || selectedItem?.notes || '',
span: 2 as const,
editable: isEditing,
placeholder: 'Notas adicionales sobre el producto'
}
]
}
];
return (
<StatusModal
isOpen={isOpen}
onClose={onClose}
mode={isViewing ? 'view' : 'edit'}
onModeChange={onModeChange}
title={getTitle()}
subtitle={getSubtitle()}
statusIndicator={getStatusIndicator()}
size="lg"
sections={sections}
onFieldChange={onFieldChange}
onSave={onSave}
onCancel={onCancel}
loading={loading}
showDefaultActions={true}
/>
);
};
export default InventoryModal;

View File

@@ -1,627 +0,0 @@
import React, { useState, useCallback, useMemo } from 'react';
import { clsx } from 'clsx';
import { Table, TableColumn } from '../../ui';
import { Badge } from '../../ui';
import { Button } from '../../ui';
import { Input } from '../../ui';
import { Select } from '../../ui';
import { Modal } from '../../ui';
import { ConfirmDialog } from '../../shared';
import { InventoryFilters, IngredientResponse, UnitOfMeasure, SortOrder } from '../../../types/inventory.types';
import { inventoryService } from '../../../api/services/inventory.service';
import { StockLevelIndicator } from './StockLevelIndicator';
export interface InventoryTableProps {
data: IngredientResponse[];
loading?: boolean;
total?: number;
page?: number;
pageSize?: number;
filters?: InventoryFilters;
selectedItems?: string[];
className?: string;
onPageChange?: (page: number, pageSize: number) => void;
onFiltersChange?: (filters: InventoryFilters) => void;
onSelectionChange?: (selectedIds: string[]) => void;
onEdit?: (item: IngredientResponse) => void;
onDelete?: (item: IngredientResponse) => void;
onAdjustStock?: (item: IngredientResponse) => void;
onMarkExpired?: (item: IngredientResponse) => void;
onRefresh?: () => void;
onExport?: () => void;
onBulkAction?: (action: string, items: IngredientResponse[]) => void;
}
// Spanish bakery categories
const BAKERY_CATEGORIES = [
{ value: '', label: 'Todas las categorías' },
{ value: 'harinas', label: 'Harinas' },
{ value: 'levaduras', label: 'Levaduras' },
{ value: 'azucares', label: 'Azúcares y Endulzantes' },
{ value: 'chocolates', label: 'Chocolates y Cacao' },
{ value: 'frutas', label: 'Frutas y Frutos Secos' },
{ value: 'lacteos', label: 'Lácteos' },
{ value: 'huevos', label: 'Huevos' },
{ value: 'mantequillas', label: 'Mantequillas y Grasas' },
{ value: 'especias', label: 'Especias y Aromas' },
{ value: 'conservantes', label: 'Conservantes y Aditivos' },
{ value: 'decoracion', label: 'Decoración' },
{ value: 'envases', label: 'Envases y Embalajes' },
{ value: 'utensilios', label: 'Utensilios y Equipos' },
{ value: 'limpieza', label: 'Limpieza e Higiene' },
];
const STOCK_LEVEL_FILTERS = [
{ value: '', label: 'Todos los niveles' },
{ value: 'good', label: 'Stock Normal' },
{ value: 'low', label: 'Stock Bajo' },
{ value: 'critical', label: 'Stock Crítico' },
{ value: 'out', label: 'Sin Stock' },
];
const SORT_OPTIONS = [
{ value: 'name_asc', label: 'Nombre (A-Z)' },
{ value: 'name_desc', label: 'Nombre (Z-A)' },
{ value: 'category_asc', label: 'Categoría (A-Z)' },
{ value: 'current_stock_asc', label: 'Stock (Menor a Mayor)' },
{ value: 'current_stock_desc', label: 'Stock (Mayor a Menor)' },
{ value: 'updated_at_desc', label: 'Actualizado Recientemente' },
{ value: 'created_at_desc', label: 'Agregado Recientemente' },
];
export const InventoryTable: React.FC<InventoryTableProps> = ({
data,
loading = false,
total = 0,
page = 1,
pageSize = 20,
filters = {},
selectedItems = [],
className,
onPageChange,
onFiltersChange,
onSelectionChange,
onEdit,
onDelete,
onAdjustStock,
onMarkExpired,
onRefresh,
onExport,
onBulkAction,
}) => {
const [localFilters, setLocalFilters] = useState<InventoryFilters>(filters);
const [searchValue, setSearchValue] = useState(filters.search || '');
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showBulkActionModal, setShowBulkActionModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState<IngredientResponse | null>(null);
const [selectedBulkAction, setSelectedBulkAction] = useState('');
// Get selected items data
const selectedItemsData = useMemo(() => {
return (data || []).filter(item => selectedItems.includes(item.id));
}, [data, selectedItems]);
// Handle search with debouncing
const handleSearch = useCallback((value: string) => {
setSearchValue(value);
const newFilters = { ...localFilters, search: value || undefined };
setLocalFilters(newFilters);
onFiltersChange?.(newFilters);
}, [localFilters, onFiltersChange]);
// Handle filter changes
const handleFilterChange = useCallback((key: keyof InventoryFilters, value: any) => {
const newFilters = { ...localFilters, [key]: value || undefined };
setLocalFilters(newFilters);
onFiltersChange?.(newFilters);
}, [localFilters, onFiltersChange]);
// Handle sort changes
const handleSortChange = useCallback((value: string) => {
if (!value) return;
const [field, order] = value.split('_');
const newFilters = {
...localFilters,
sort_by: field as any,
sort_order: order as SortOrder,
};
setLocalFilters(newFilters);
onFiltersChange?.(newFilters);
}, [localFilters, onFiltersChange]);
// Clear all filters
const handleClearFilters = useCallback(() => {
const clearedFilters = { search: undefined };
setLocalFilters(clearedFilters);
setSearchValue('');
onFiltersChange?.(clearedFilters);
}, [onFiltersChange]);
// Handle delete confirmation
const handleDeleteClick = useCallback((item: IngredientResponse) => {
setItemToDelete(item);
setShowDeleteDialog(true);
}, []);
const handleDeleteConfirm = useCallback(() => {
if (itemToDelete) {
onDelete?.(itemToDelete);
setItemToDelete(null);
}
setShowDeleteDialog(false);
}, [itemToDelete, onDelete]);
// Handle bulk actions
const handleBulkAction = useCallback((action: string) => {
if (selectedItemsData.length === 0) return;
if (action === 'delete') {
setSelectedBulkAction(action);
setShowBulkActionModal(true);
} else {
onBulkAction?.(action, selectedItemsData);
}
}, [selectedItemsData, onBulkAction]);
const handleBulkActionConfirm = useCallback(() => {
if (selectedBulkAction && selectedItemsData.length > 0) {
onBulkAction?.(selectedBulkAction, selectedItemsData);
}
setShowBulkActionModal(false);
setSelectedBulkAction('');
}, [selectedBulkAction, selectedItemsData, onBulkAction]);
// Get stock level for filtering
const getStockLevel = useCallback((item: IngredientResponse): string => {
if (!item.current_stock || item.current_stock <= 0) return 'out';
if (item.needs_reorder) return 'critical';
if (item.is_low_stock) return 'low';
return 'good';
}, []);
// Apply local filtering for stock levels
const filteredData = useMemo(() => {
const dataArray = data || [];
if (!localFilters.is_low_stock && !localFilters.needs_reorder) return dataArray;
return dataArray.filter(item => {
const stockLevel = getStockLevel(item);
if (localFilters.is_low_stock && stockLevel !== 'low') return false;
if (localFilters.needs_reorder && stockLevel !== 'critical') return false;
return true;
});
}, [data, localFilters.is_low_stock, localFilters.needs_reorder, getStockLevel]);
// Table columns configuration
const columns: TableColumn<IngredientResponse>[] = [
{
key: 'name',
title: 'Nombre',
dataIndex: 'name',
sortable: true,
width: '25%',
render: (value: string, record: IngredientResponse) => (
<div>
<div className="font-medium text-text-primary">{value}</div>
{record.brand && (
<div className="text-sm text-text-secondary">{record.brand}</div>
)}
{record.sku && (
<div className="text-xs text-text-tertiary">SKU: {record.sku}</div>
)}
</div>
),
},
{
key: 'category',
title: 'Categoría',
dataIndex: 'category',
sortable: true,
width: '15%',
render: (value: string) => (
<Badge variant="outline" size="sm">
{BAKERY_CATEGORIES.find(cat => cat.value === value)?.label || value || 'Sin categoría'}
</Badge>
),
},
{
key: 'current_stock',
title: 'Stock Actual',
dataIndex: 'current_stock',
sortable: true,
width: '15%',
align: 'right',
render: (value: number | undefined, record: IngredientResponse) => (
<div className="text-right">
<div className="flex items-center justify-end gap-2">
<span className="font-mono">
{value?.toFixed(2) ?? '0.00'}
</span>
<span className="text-text-tertiary text-sm">
{record.unit_of_measure}
</span>
</div>
<StockLevelIndicator
current={value || 0}
minimum={record.low_stock_threshold}
maximum={record.max_stock_level}
size="sm"
/>
</div>
),
},
{
key: 'thresholds',
title: 'Mín / Máx',
width: '12%',
align: 'center',
render: (_, record: IngredientResponse) => (
<div className="text-center text-sm">
<div className="text-text-secondary">
{record.low_stock_threshold} / {record.max_stock_level || '∞'}
</div>
<div className="text-xs text-text-tertiary">
Reorden: {record.reorder_point}
</div>
</div>
),
},
{
key: 'price',
title: 'Precio',
width: '10%',
align: 'right',
render: (_, record: IngredientResponse) => (
<div className="text-right">
{record.standard_cost && (
<div className="font-medium">
{record.standard_cost.toFixed(2)}
</div>
)}
{record.last_purchase_price && record.last_purchase_price !== record.standard_cost && (
<div className="text-sm text-text-secondary">
Último: {record.last_purchase_price.toFixed(2)}
</div>
)}
</div>
),
},
{
key: 'status',
title: 'Estado',
width: '10%',
align: 'center',
render: (_, record: IngredientResponse) => {
const badges = [];
if (record.needs_reorder) {
badges.push(
<Badge key="reorder" variant="error" size="xs">
Crítico
</Badge>
);
} else if (record.is_low_stock) {
badges.push(
<Badge key="low" variant="warning" size="xs">
Bajo
</Badge>
);
} else {
badges.push(
<Badge key="ok" variant="success" size="xs">
OK
</Badge>
);
}
if (!record.is_active) {
badges.push(
<Badge key="inactive" variant="secondary" size="xs">
Inactivo
</Badge>
);
}
if (record.is_perishable) {
badges.push(
<Badge key="perishable" variant="info" size="xs" title="Producto perecedero">
P
</Badge>
);
}
return (
<div className="flex flex-col gap-1">
{badges}
</div>
);
},
},
{
key: 'actions',
title: 'Acciones',
width: '13%',
align: 'center',
render: (_, record: IngredientResponse) => (
<div className="flex items-center justify-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit?.(record)}
title="Editar ingrediente"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onAdjustStock?.(record)}
title="Ajustar stock"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 4V2a1 1 0 011-1h8a1 1 0 011 1v2m3 0v18a1 1 0 01-1 1H5a1 1 0 01-1-1V4h16zM9 9v1a1 1 0 002 0V9a1 1 0 10-2 0zm4 0v1a1 1 0 002 0V9a1 1 0 10-2 0z" />
</svg>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteClick(record)}
title="Eliminar ingrediente"
className="text-color-error hover:text-color-error"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
),
},
];
const currentSortValue = localFilters.sort_by && localFilters.sort_order
? `${localFilters.sort_by}_${localFilters.sort_order}`
: '';
return (
<div className={clsx('space-y-4', className)}>
{/* Filters and Actions */}
<div className="space-y-4">
{/* Search and Quick Actions */}
<div className="flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
<div className="flex-1 max-w-md">
<Input
placeholder="Buscar ingredientes..."
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
leftIcon={
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
}
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={onRefresh}
disabled={loading}
title="Actualizar datos"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Actualizar
</Button>
<Button
variant="outline"
onClick={onExport}
title="Exportar inventario"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar
</Button>
</div>
</div>
{/* Advanced Filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Select
placeholder="Categoría"
value={localFilters.category || ''}
onChange={(value) => handleFilterChange('category', value)}
options={BAKERY_CATEGORIES}
/>
<Select
placeholder="Nivel de stock"
value={
localFilters.needs_reorder ? 'critical' :
localFilters.is_low_stock ? 'low' : ''
}
onChange={(value) => {
handleFilterChange('needs_reorder', value === 'critical');
handleFilterChange('is_low_stock', value === 'low');
}}
options={STOCK_LEVEL_FILTERS}
/>
<Select
placeholder="Ordenar por"
value={currentSortValue}
onChange={handleSortChange}
options={SORT_OPTIONS}
/>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleClearFilters}
className="flex-1"
>
Limpiar Filtros
</Button>
</div>
</div>
{/* Active Filters Summary */}
{(localFilters.search || localFilters.category || localFilters.is_low_stock || localFilters.needs_reorder) && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-text-secondary">Filtros activos:</span>
{localFilters.search && (
<Badge variant="primary" closable onClose={() => handleFilterChange('search', '')}>
Búsqueda: "{localFilters.search}"
</Badge>
)}
{localFilters.category && (
<Badge variant="primary" closable onClose={() => handleFilterChange('category', '')}>
{BAKERY_CATEGORIES.find(cat => cat.value === localFilters.category)?.label}
</Badge>
)}
{localFilters.is_low_stock && (
<Badge variant="warning" closable onClose={() => handleFilterChange('is_low_stock', false)}>
Stock Bajo
</Badge>
)}
{localFilters.needs_reorder && (
<Badge variant="error" closable onClose={() => handleFilterChange('needs_reorder', false)}>
Stock Crítico
</Badge>
)}
</div>
)}
{/* Bulk Actions */}
{selectedItems.length > 0 && (
<div className="flex items-center justify-between bg-bg-secondary rounded-lg p-4">
<span className="text-sm text-text-secondary">
{selectedItems.length} elemento{selectedItems.length !== 1 ? 's' : ''} seleccionado{selectedItems.length !== 1 ? 's' : ''}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('export')}
>
Exportar Selección
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('adjust_stock')}
>
Ajustar Stock
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('delete')}
className="text-color-error hover:text-color-error"
>
Eliminar
</Button>
</div>
</div>
)}
</div>
{/* Table */}
<Table
columns={columns}
data={filteredData}
loading={loading}
size="md"
hover
sticky
rowSelection={{
selectedRowKeys: selectedItems,
onSelect: (record, selected, selectedRows) => {
const newSelection = selected
? [...selectedItems, record.id]
: selectedItems.filter(id => id !== record.id);
onSelectionChange?.(newSelection);
},
onSelectAll: (selected, selectedRows, changeRows) => {
const newSelection = selected
? filteredData.map(item => item.id)
: [];
onSelectionChange?.(newSelection);
},
}}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`${range[0]}-${range[1]} de ${total} ingredientes`,
onChange: onPageChange,
}}
locale={{
emptyText: 'No se encontraron ingredientes',
selectAll: 'Seleccionar todos',
selectRow: 'Seleccionar fila',
}}
/>
{/* Delete Confirmation Dialog */}
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteConfirm}
title="Eliminar Ingrediente"
description={
itemToDelete
? `¿Estás seguro de que quieres eliminar "${itemToDelete.name}"? Esta acción no se puede deshacer.`
: ''
}
confirmText="Eliminar"
cancelText="Cancelar"
variant="destructive"
/>
{/* Bulk Action Confirmation */}
<Modal
open={showBulkActionModal}
onClose={() => setShowBulkActionModal(false)}
title="Confirmar Acción Masiva"
size="md"
>
<div className="space-y-4">
<p>
¿Estás seguro de que quieres {selectedBulkAction === 'delete' ? 'eliminar' : 'procesar'} {' '}
{selectedItemsData.length} elemento{selectedItemsData.length !== 1 ? 's' : ''}?
</p>
{selectedItemsData.length > 0 && (
<div className="max-h-32 overflow-y-auto">
<ul className="text-sm text-text-secondary space-y-1">
{selectedItemsData.map(item => (
<li key={item.id}> {item.name}</li>
))}
</ul>
</div>
)}
<div className="flex gap-2 justify-end">
<Button
variant="outline"
onClick={() => setShowBulkActionModal(false)}
>
Cancelar
</Button>
<Button
variant={selectedBulkAction === 'delete' ? 'destructive' : 'primary'}
onClick={handleBulkActionConfirm}
>
Confirmar
</Button>
</div>
</div>
</Modal>
</div>
);
};
export default InventoryTable;

View File

@@ -1,411 +0,0 @@
import React, { useMemo } from 'react';
import { clsx } from 'clsx';
import { Tooltip } from '../../ui/Tooltip';
export interface StockLevelIndicatorProps {
current: number;
minimum?: number;
maximum?: number;
reorderPoint?: number;
unit?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
variant?: 'bar' | 'gauge' | 'badge' | 'minimal';
showLabels?: boolean;
showPercentage?: boolean;
showTrend?: boolean;
trend?: 'up' | 'down' | 'stable';
className?: string;
onClick?: () => void;
}
type StockStatus = 'good' | 'low' | 'critical' | 'out' | 'overstocked';
interface StockLevel {
status: StockStatus;
label: string;
color: string;
bgColor: string;
percentage: number;
}
export const StockLevelIndicator: React.FC<StockLevelIndicatorProps> = ({
current,
minimum = 0,
maximum,
reorderPoint,
unit = '',
size = 'md',
variant = 'bar',
showLabels = false,
showPercentage = false,
showTrend = false,
trend = 'stable',
className,
onClick,
}) => {
// Calculate stock level and status
const stockLevel = useMemo<StockLevel>(() => {
// Handle out of stock
if (current <= 0) {
return {
status: 'out',
label: 'Sin Stock',
color: 'text-color-error',
bgColor: 'bg-color-error',
percentage: 0,
};
}
// Calculate percentage based on maximum or use current value relative to minimum
const percentage = maximum
? Math.min((current / maximum) * 100, 100)
: Math.min(((current - minimum) / (minimum || 1)) * 100 + 100, 200);
// Handle overstocked (if maximum is defined)
if (maximum && current > maximum * 1.2) {
return {
status: 'overstocked',
label: 'Sobrestock',
color: 'text-color-info',
bgColor: 'bg-color-info',
percentage: Math.min(percentage, 150),
};
}
// Handle critical level (reorder point or below minimum)
const criticalThreshold = reorderPoint || minimum;
if (current <= criticalThreshold) {
return {
status: 'critical',
label: 'Crítico',
color: 'text-color-error',
bgColor: 'bg-color-error',
percentage: Math.max(percentage, 5), // Minimum visible bar
};
}
// Handle low stock (within 20% above critical threshold)
const lowThreshold = criticalThreshold * 1.2;
if (current <= lowThreshold) {
return {
status: 'low',
label: 'Bajo',
color: 'text-color-warning',
bgColor: 'bg-color-warning',
percentage,
};
}
// Good stock level
return {
status: 'good',
label: 'Normal',
color: 'text-color-success',
bgColor: 'bg-color-success',
percentage,
};
}, [current, minimum, maximum, reorderPoint]);
const sizeClasses = {
xs: {
container: 'h-1',
text: 'text-xs',
badge: 'px-1.5 py-0.5 text-xs',
gauge: 'w-6 h-6',
},
sm: {
container: 'h-2',
text: 'text-sm',
badge: 'px-2 py-0.5 text-xs',
gauge: 'w-8 h-8',
},
md: {
container: 'h-3',
text: 'text-sm',
badge: 'px-2.5 py-1 text-sm',
gauge: 'w-10 h-10',
},
lg: {
container: 'h-4',
text: 'text-base',
badge: 'px-3 py-1.5 text-sm',
gauge: 'w-12 h-12',
},
};
// Trend arrow component
const TrendArrow = () => {
if (!showTrend || trend === 'stable') return null;
const trendClasses = {
up: 'text-color-success transform rotate-0',
down: 'text-color-error transform rotate-180',
stable: 'text-text-tertiary',
};
return (
<svg
className={clsx('w-3 h-3 ml-1', trendClasses[trend])}
fill="currentColor"
viewBox="0 0 20 20"
>
<path fillRule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 5.414V17a1 1 0 11-2 0V5.414L6.707 7.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
);
};
// Tooltip content
const tooltipContent = (
<div className="text-sm space-y-1">
<div className="font-medium">Estado: {stockLevel.label}</div>
<div>Stock actual: {current.toFixed(2)} {unit}</div>
{minimum > 0 && <div>Mínimo: {minimum.toFixed(2)} {unit}</div>}
{maximum && <div>Máximo: {maximum.toFixed(2)} {unit}</div>}
{reorderPoint && <div>Punto de reorden: {reorderPoint.toFixed(2)} {unit}</div>}
{showPercentage && maximum && (
<div>Nivel: {((current / maximum) * 100).toFixed(1)}%</div>
)}
{showTrend && trend !== 'stable' && (
<div>Tendencia: {trend === 'up' ? 'Subiendo' : 'Bajando'}</div>
)}
</div>
);
// Badge variant
if (variant === 'badge') {
const badgeContent = (
<span
className={clsx(
'inline-flex items-center font-medium rounded-full',
stockLevel.color,
stockLevel.bgColor.replace('bg-', 'bg-opacity-10 bg-'),
'border border-current border-opacity-20',
sizeClasses[size].badge,
onClick && 'cursor-pointer hover:bg-opacity-20',
className
)}
onClick={onClick}
>
{stockLevel.label}
{showPercentage && maximum && ` (${((current / maximum) * 100).toFixed(0)}%)`}
<TrendArrow />
</span>
);
return (
<Tooltip content={tooltipContent}>
{badgeContent}
</Tooltip>
);
}
// Minimal variant (just colored dot)
if (variant === 'minimal') {
const minimalContent = (
<div
className={clsx(
'inline-flex items-center gap-2',
onClick && 'cursor-pointer',
className
)}
onClick={onClick}
>
<div
className={clsx(
'rounded-full flex-shrink-0',
stockLevel.bgColor,
size === 'xs' ? 'w-2 h-2' :
size === 'sm' ? 'w-3 h-3' :
size === 'md' ? 'w-3 h-3' : 'w-4 h-4'
)}
/>
{showLabels && (
<span className={clsx(sizeClasses[size].text, stockLevel.color)}>
{stockLevel.label}
</span>
)}
<TrendArrow />
</div>
);
return (
<Tooltip content={tooltipContent}>
{minimalContent}
</Tooltip>
);
}
// Gauge variant (circular progress)
if (variant === 'gauge') {
const radius = size === 'xs' ? 8 : size === 'sm' ? 12 : size === 'md' ? 16 : 20;
const circumference = 2 * Math.PI * radius;
const strokeDasharray = `${(stockLevel.percentage / 100) * circumference} ${circumference}`;
const gaugeContent = (
<div
className={clsx(
'relative inline-flex items-center justify-center',
sizeClasses[size].gauge,
onClick && 'cursor-pointer',
className
)}
onClick={onClick}
>
<svg
className="transform -rotate-90"
width="100%"
height="100%"
viewBox={`0 0 ${radius * 2 + 8} ${radius * 2 + 8}`}
>
{/* Background circle */}
<circle
cx={radius + 4}
cy={radius + 4}
r={radius}
stroke="currentColor"
strokeWidth="2"
fill="none"
className="text-bg-tertiary"
/>
{/* Progress circle */}
<circle
cx={radius + 4}
cy={radius + 4}
r={radius}
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeDasharray={strokeDasharray}
strokeLinecap="round"
className={stockLevel.color}
/>
</svg>
{(showLabels || showPercentage) && (
<div className="absolute inset-0 flex items-center justify-center">
<span className={clsx('font-medium', sizeClasses[size].text, stockLevel.color)}>
{showPercentage && maximum
? `${Math.round((current / maximum) * 100)}%`
: showLabels
? stockLevel.label.charAt(0)
: ''
}
</span>
</div>
)}
<TrendArrow />
</div>
);
return (
<Tooltip content={tooltipContent}>
{gaugeContent}
</Tooltip>
);
}
// Default bar variant
const barContent = (
<div
className={clsx(
'w-full space-y-1',
onClick && 'cursor-pointer',
className
)}
onClick={onClick}
>
{(showLabels || showPercentage) && (
<div className="flex items-center justify-between">
{showLabels && (
<span className={clsx(sizeClasses[size].text, stockLevel.color, 'font-medium')}>
{stockLevel.label}
</span>
)}
<div className="flex items-center">
{showPercentage && maximum && (
<span className={clsx(sizeClasses[size].text, 'text-text-secondary')}>
{((current / maximum) * 100).toFixed(1)}%
</span>
)}
<TrendArrow />
</div>
</div>
)}
<div
className={clsx(
'w-full bg-bg-tertiary rounded-full overflow-hidden',
sizeClasses[size].container
)}
>
<div
className={clsx(
'h-full rounded-full transition-all duration-300 ease-out',
stockLevel.bgColor
)}
style={{ width: `${Math.min(Math.max(stockLevel.percentage, 2), 100)}%` }}
/>
</div>
{/* Threshold indicators for bar variant */}
{size !== 'xs' && (minimum > 0 || reorderPoint || maximum) && (
<div className="relative">
{/* Minimum threshold */}
{minimum > 0 && maximum && (
<div
className="absolute top-0 w-0.5 h-2 bg-color-warning opacity-60"
style={{ left: `${(minimum / maximum) * 100}%` }}
title={`Mínimo: ${minimum} ${unit}`}
/>
)}
{/* Reorder point */}
{reorderPoint && maximum && reorderPoint !== minimum && (
<div
className="absolute top-0 w-0.5 h-2 bg-color-error opacity-60"
style={{ left: `${(reorderPoint / maximum) * 100}%` }}
title={`Reorden: ${reorderPoint} ${unit}`}
/>
)}
</div>
)}
</div>
);
return (
<Tooltip content={tooltipContent}>
{barContent}
</Tooltip>
);
};
// Helper hook for multiple stock indicators
export const useStockLevels = (items: Array<{ current: number; minimum?: number; maximum?: number; reorderPoint?: number }>) => {
return useMemo(() => {
const levels = {
good: 0,
low: 0,
critical: 0,
out: 0,
overstocked: 0,
};
items.forEach(item => {
const { current, minimum = 0, maximum, reorderPoint } = item;
if (current <= 0) {
levels.out++;
} else if (maximum && current > maximum * 1.2) {
levels.overstocked++;
} else if (current <= (reorderPoint || minimum)) {
levels.critical++;
} else if (current <= (reorderPoint || minimum) * 1.2) {
levels.low++;
} else {
levels.good++;
}
});
return levels;
}, [items]);
};
export default StockLevelIndicator;

View File

@@ -1,22 +1,18 @@
// Inventory Domain Components // Inventory Domain Components
export { default as InventoryTable, type InventoryTableProps } from './InventoryTable';
export { default as StockLevelIndicator, type StockLevelIndicatorProps, useStockLevels } from './StockLevelIndicator';
export { default as InventoryForm, type InventoryFormProps } from './InventoryForm';
export { default as LowStockAlert, type LowStockAlertProps } from './LowStockAlert'; export { default as LowStockAlert, type LowStockAlertProps } from './LowStockAlert';
export { default as InventoryModal, type InventoryModalProps } from './InventoryModal'; export { default as InventoryItemModal, type InventoryItemModalProps } from './InventoryItemModal';
// Re-export related types from inventory types // Re-export related types from inventory types
export type { export type {
InventoryFilters, InventoryFilter,
IngredientResponse, IngredientResponse,
StockAlert, StockMovementResponse,
IngredientFormData, IngredientCreate,
IngredientUpdate,
UnitOfMeasure, UnitOfMeasure,
ProductType, ProductType,
AlertSeverity, PaginatedResponse,
AlertType, } from '../../../api/types/inventory';
SortOrder,
} from '../../../types/inventory.types';
// Utility exports for common inventory operations // Utility exports for common inventory operations
export const INVENTORY_CONSTANTS = { export const INVENTORY_CONSTANTS = {

View File

@@ -38,22 +38,23 @@ export interface StatusModalProps {
onClose: () => void; onClose: () => void;
mode: 'view' | 'edit'; mode: 'view' | 'edit';
onModeChange?: (mode: 'view' | 'edit') => void; onModeChange?: (mode: 'view' | 'edit') => void;
// Content // Content
title: string; title: string;
subtitle?: string; subtitle?: string;
statusIndicator?: StatusIndicatorConfig; statusIndicator?: StatusIndicatorConfig;
image?: string; image?: string;
sections: StatusModalSection[]; sections: StatusModalSection[];
// Actions // Actions
actions?: StatusModalAction[]; actions?: StatusModalAction[];
showDefaultActions?: boolean; showDefaultActions?: boolean;
actionsPosition?: 'header' | 'footer'; // New prop for positioning actions
onEdit?: () => void; onEdit?: () => void;
onSave?: () => Promise<void>; onSave?: () => Promise<void>;
onCancel?: () => void; onCancel?: () => void;
onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => void; onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => void;
// Layout // Layout
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl'; size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
loading?: boolean; loading?: boolean;
@@ -224,6 +225,13 @@ const renderEditableField = (
/** /**
* StatusModal - Unified modal component for viewing/editing card details * StatusModal - Unified modal component for viewing/editing card details
* Follows UX best practices for modal dialogs (2024) * Follows UX best practices for modal dialogs (2024)
*
* Features:
* - Supports actions in header (tab-style navigation) or footer (default)
* - Tab-style navigation in header improves discoverability for multi-view modals
* - Maintains backward compatibility - existing modals continue working unchanged
* - Responsive design with horizontal scroll for many tabs on mobile
* - Active state indicated by disabled=true for navigation actions
*/ */
export const StatusModal: React.FC<StatusModalProps> = ({ export const StatusModal: React.FC<StatusModalProps> = ({
isOpen, isOpen,
@@ -237,6 +245,7 @@ export const StatusModal: React.FC<StatusModalProps> = ({
sections, sections,
actions = [], actions = [],
showDefaultActions = true, showDefaultActions = true,
actionsPosition = 'footer',
onEdit, onEdit,
onSave, onSave,
onCancel, onCancel,
@@ -305,6 +314,48 @@ export const StatusModal: React.FC<StatusModalProps> = ({
const allActions = [...actions, ...defaultActions]; const allActions = [...actions, ...defaultActions];
// Render top navigation actions (tab-like style)
const renderTopActions = () => {
if (actionsPosition !== 'header' || allActions.length === 0) return null;
return (
<div className="border-b border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="px-6 py-1">
<div className="flex gap-0 overflow-x-auto scrollbar-hide" style={{scrollbarWidth: 'none', msOverflowStyle: 'none'}}>
{allActions.map((action, index) => (
<button
key={index}
onClick={action.disabled || loading ? undefined : action.onClick}
disabled={loading}
className={`
relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200
min-w-fit whitespace-nowrap border-b-2 -mb-px
${loading
? 'opacity-50 cursor-not-allowed text-[var(--text-tertiary)] border-transparent'
: action.disabled
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--color-primary)]/8 cursor-default'
: action.variant === 'danger'
? 'text-red-600 border-transparent hover:border-red-300 hover:bg-red-50'
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]'
}
`}
>
{action.loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
) : (
<>
{action.icon && <action.icon className="w-4 h-4" />}
<span>{action.label}</span>
</>
)}
</button>
))}
</div>
</div>
</div>
);
};
return ( return (
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
@@ -319,26 +370,26 @@ export const StatusModal: React.FC<StatusModalProps> = ({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Status indicator */} {/* Status indicator */}
{statusIndicator && ( {statusIndicator && (
<div <div
className="flex-shrink-0 p-2 rounded-lg" className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${statusIndicator.color}15` }} style={{ backgroundColor: `${statusIndicator.color}15` }}
> >
{StatusIcon && ( {StatusIcon && (
<StatusIcon <StatusIcon
className="w-5 h-5" className="w-5 h-5"
style={{ color: statusIndicator.color }} style={{ color: statusIndicator.color }}
/> />
)} )}
</div> </div>
)} )}
{/* Title and status */} {/* Title and status */}
<div> <div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]"> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
{title} {title}
</h2> </h2>
{statusIndicator && ( {statusIndicator && (
<div <div
className="text-sm font-medium mt-1" className="text-sm font-medium mt-1"
style={{ color: statusIndicator.color }} style={{ color: statusIndicator.color }}
> >
@@ -363,6 +414,9 @@ export const StatusModal: React.FC<StatusModalProps> = ({
onClose={onClose} onClose={onClose}
/> />
{/* Top Navigation Actions */}
{renderTopActions()}
<ModalBody> <ModalBody>
{loading && ( {loading && (
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10"> <div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
@@ -423,7 +477,7 @@ export const StatusModal: React.FC<StatusModalProps> = ({
</div> </div>
</ModalBody> </ModalBody>
{allActions.length > 0 && ( {allActions.length > 0 && actionsPosition === 'footer' && (
<ModalFooter justify="end"> <ModalFooter justify="end">
<div className="flex gap-3"> <div className="flex gap-3">
{allActions.map((action, index) => ( {allActions.map((action, index) => (

View File

@@ -2,7 +2,7 @@
export { default as Button } from './Button'; export { default as Button } from './Button';
export { default as Input } from './Input'; export { default as Input } from './Input';
export { default as Card, CardHeader, CardBody, CardFooter } from './Card'; export { default as Card, CardHeader, CardBody, CardFooter } from './Card';
export { default as Modal } from './Modal'; export { default as Modal, ModalHeader, ModalBody, ModalFooter } from './Modal';
export { default as Table } from './Table'; export { default as Table } from './Table';
export { default as Badge } from './Badge'; export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar'; export { default as Avatar } from './Avatar';
@@ -22,7 +22,7 @@ export { TenantSwitcher } from './TenantSwitcher';
export type { ButtonProps } from './Button'; export type { ButtonProps } from './Button';
export type { InputProps } from './Input'; export type { InputProps } from './Input';
export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card'; export type { CardProps, CardHeaderProps, CardBodyProps, CardFooterProps } from './Card';
export type { ModalProps } from './Modal'; export type { ModalProps, ModalHeaderProps, ModalBodyProps, ModalFooterProps } from './Modal';
export type { TableProps, TableColumn, TableRow } from './Table'; export type { TableProps, TableColumn, TableRow } from './Table';
export type { BadgeProps } from './Badge'; export type { BadgeProps } from './Badge';
export type { AvatarProps } from './Avatar'; export type { AvatarProps } from './Avatar';

View File

@@ -1,20 +1,18 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Edit, Clock, Euro, ArrowRight } from 'lucide-react'; import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight } from 'lucide-react';
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui'; import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/shared'; import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets'; import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { LowStockAlert, InventoryModal } from '../../../../components/domain/inventory'; import { LowStockAlert, InventoryItemModal } from '../../../../components/domain/inventory';
import { useIngredients, useStockAnalytics, useUpdateIngredient, useCreateIngredient } from '../../../../api/hooks/inventory'; import { useIngredients, useStockAnalytics } from '../../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useCurrentTenant } from '../../../../stores/tenant.store';
import { IngredientResponse, IngredientUpdate, IngredientCreate, UnitOfMeasure, IngredientCategory, ProductType } from '../../../../api/types/inventory'; import { IngredientResponse } from '../../../../api/types/inventory';
const InventoryPage: React.FC = () => { const InventoryPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false); const [showItemModal, setShowItemModal] = useState(false);
const [modalMode, setModalMode] = useState<'create' | 'view' | 'edit'>('view');
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null); const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
const [formData, setFormData] = useState<Partial<IngredientCreate & IngredientUpdate & { initial_stock?: number; product_type?: string }>>({});
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || ''; const tenantId = currentTenant?.id || '';
@@ -34,9 +32,6 @@ const InventoryPage: React.FC = () => {
isLoading: analyticsLoading isLoading: analyticsLoading
} = useStockAnalytics(tenantId); } = useStockAnalytics(tenantId);
// Mutations
const updateIngredientMutation = useUpdateIngredient();
const createIngredientMutation = useCreateIngredient();
const ingredients = ingredientsData || []; const ingredients = ingredientsData || [];
const lowStockItems = ingredients.filter(ingredient => ingredient.stock_status === 'low_stock'); const lowStockItems = ingredients.filter(ingredient => ingredient.stock_status === 'low_stock');
@@ -82,123 +77,6 @@ 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(() => { const filteredItems = useMemo(() => {
if (!searchTerm) return ingredients; if (!searchTerm) return ingredients;
@@ -231,31 +109,15 @@ const InventoryPage: React.FC = () => {
return categoryMappings[category?.toLowerCase() || ''] || category || 'Sin categoría'; return categoryMappings[category?.toLowerCase() || ''] || category || 'Sin categoría';
}; };
// Simplified item action handler // Item action handler
const handleItemAction = (ingredient: any, action: 'view' | 'edit') => { const handleViewItem = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient); setSelectedItem(ingredient);
setModalMode(action); setShowItemModal(true);
setFormData({});
setShowForm(true);
}; };
// Handle new item creation // Handle new item creation - TODO: Implement create functionality
const handleNewItem = () => { const handleNewItem = () => {
setSelectedItem(null); console.log('Create new item functionality to be implemented');
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);
}; };
@@ -442,14 +304,8 @@ const InventoryPage: React.FC = () => {
{ {
label: 'Ver', label: 'Ver',
icon: Eye, icon: Eye,
variant: 'outline',
onClick: () => handleItemAction(ingredient, 'view')
},
{
label: 'Editar',
icon: Edit,
variant: 'primary', variant: 'primary',
onClick: () => handleItemAction(ingredient, 'edit') onClick: () => handleViewItem(ingredient)
} }
]} ]}
/> />
@@ -479,24 +335,15 @@ const InventoryPage: React.FC = () => {
</div> </div>
)} )}
{/* Unified Inventory Modal */} {/* Inventory Item Modal */}
{showForm && ( {showItemModal && selectedItem && (
<InventoryModal <InventoryItemModal
isOpen={showForm} isOpen={showItemModal}
onClose={() => { onClose={() => {
setShowForm(false); setShowItemModal(false);
setSelectedItem(null); setSelectedItem(null);
setModalMode('view');
setFormData({});
}} }}
mode={modalMode} ingredient={selectedItem}
onModeChange={(mode) => setModalMode(mode as 'create' | 'view' | 'edit')}
selectedItem={selectedItem}
formData={formData}
onFieldChange={handleFieldChange}
onSave={handleSave}
onCancel={handleCancel}
loading={updateIngredientMutation.isPending || createIngredientMutation.isPending}
/> />
)} )}
</div> </div>

View File

@@ -7,6 +7,7 @@ from typing import List, Optional
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import structlog
from app.core.database import get_db from app.core.database import get_db
from app.services.inventory_service import InventoryService from app.services.inventory_service import InventoryService
@@ -20,6 +21,7 @@ from app.schemas.inventory import (
) )
from shared.auth.decorators import get_current_user_dep from shared.auth.decorators import get_current_user_dep
logger = structlog.get_logger()
router = APIRouter(tags=["stock"]) router = APIRouter(tags=["stock"])
# Helper function to extract user ID from user object # Helper function to extract user ID from user object
@@ -180,6 +182,37 @@ async def get_stock(
) )
@router.get("/tenants/{tenant_id}/stock/movements", response_model=List[StockMovementResponse])
async def get_stock_movements(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
db: AsyncSession = Depends(get_db)
):
"""Get stock movements with filtering"""
logger.info("🌐 API endpoint reached!",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
skip=skip,
limit=limit)
try:
service = InventoryService()
movements = await service.get_stock_movements(
tenant_id, skip, limit, ingredient_id, movement_type
)
logger.info("📈 Returning movements", count=len(movements))
return movements
except Exception as e:
logger.error("❌ Failed to get stock movements", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get stock movements"
)
@router.get("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse) @router.get("/tenants/{tenant_id}/stock/{stock_id}", response_model=StockResponse)
async def get_stock_entry( async def get_stock_entry(
stock_id: UUID = Path(..., description="Stock entry ID"), stock_id: UUID = Path(..., description="Stock entry ID"),
@@ -294,24 +327,3 @@ async def create_stock_movement(
) )
@router.get("/tenants/{tenant_id}/stock/movements", response_model=List[StockMovementResponse])
async def get_stock_movements(
tenant_id: UUID = Path(..., description="Tenant ID"),
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient"),
movement_type: Optional[str] = Query(None, description="Filter by movement type"),
db: AsyncSession = Depends(get_db)
):
"""Get stock movements with filtering"""
try:
service = InventoryService()
movements = await service.get_stock_movements(
tenant_id, skip, limit, ingredient_id, movement_type
)
return movements
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get stock movements"
)

View File

@@ -141,6 +141,52 @@ class StockMovementRepository(BaseRepository[StockMovement, StockMovementCreate,
logger.error("Failed to get recent movements", error=str(e), tenant_id=tenant_id) logger.error("Failed to get recent movements", error=str(e), tenant_id=tenant_id)
raise raise
async def get_movements(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
ingredient_id: Optional[UUID] = None,
movement_type: Optional[str] = None
) -> List[StockMovement]:
"""Get stock movements with filtering"""
logger.info("🔍 Repository getting movements",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
skip=skip,
limit=limit)
try:
query = select(self.model).where(self.model.tenant_id == tenant_id)
# Add filters
if ingredient_id:
query = query.where(self.model.ingredient_id == ingredient_id)
logger.info("🎯 Filtering by ingredient_id", ingredient_id=ingredient_id)
if movement_type:
# Convert string to enum
try:
movement_type_enum = StockMovementType(movement_type)
query = query.where(self.model.movement_type == movement_type_enum)
logger.info("🏷️ Filtering by movement_type", movement_type=movement_type)
except ValueError:
logger.warning("⚠️ Invalid movement type", movement_type=movement_type)
# Invalid movement type, skip filter
pass
# Order by date (newest first) and apply pagination
query = query.order_by(desc(self.model.movement_date)).offset(skip).limit(limit)
result = await self.session.execute(query)
movements = result.scalars().all()
logger.info("🔢 Repository found movements", count=len(movements))
return movements
except Exception as e:
logger.error("❌ Repository failed to get movements", error=str(e), tenant_id=tenant_id)
raise
async def get_movements_by_reference( async def get_movements_by_reference(
self, self,
tenant_id: UUID, tenant_id: UUID,

View File

@@ -370,6 +370,55 @@ class InventoryService:
logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id) logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id)
raise raise
async def get_stock_movements(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
ingredient_id: Optional[UUID] = None,
movement_type: Optional[str] = None
) -> List[StockMovementResponse]:
"""Get stock movements with filtering"""
logger.info("📈 Getting stock movements",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
skip=skip,
limit=limit)
try:
async with get_db_transaction() as db:
movement_repo = StockMovementRepository(db)
ingredient_repo = IngredientRepository(db)
# Get filtered movements
movements = await movement_repo.get_movements(
tenant_id=tenant_id,
skip=skip,
limit=limit,
ingredient_id=ingredient_id,
movement_type=movement_type
)
logger.info("📊 Found movements", count=len(movements))
responses = []
for movement in movements:
response = StockMovementResponse(**movement.to_dict())
# Include ingredient information if needed
if movement.ingredient_id:
ingredient = await ingredient_repo.get_by_id(movement.ingredient_id)
if ingredient:
response.ingredient = IngredientResponse(**ingredient.to_dict())
responses.append(response)
logger.info("✅ Returning movements", response_count=len(responses))
return responses
except Exception as e:
logger.error("❌ Failed to get stock movements", error=str(e), tenant_id=tenant_id)
raise
# ===== ALERTS AND NOTIFICATIONS ===== # ===== ALERTS AND NOTIFICATIONS =====
async def check_low_stock_alerts(self, tenant_id: UUID) -> List[Dict[str, Any]]: async def check_low_stock_alerts(self, tenant_id: UUID) -> List[Dict[str, Any]]: