Fix UI for inventory page 3
This commit is contained in:
@@ -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
|
||||||
|
};
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
1317
frontend/src/components/domain/inventory/InventoryItemModal.tsx
Normal file
1317
frontend/src/components/domain/inventory/InventoryItemModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]]:
|
||||||
|
|||||||
Reference in New Issue
Block a user