Add frontend imporvements
This commit is contained in:
@@ -6,126 +6,67 @@ import { Select } from '../../ui';
|
||||
import { Card } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { IngredientFormData, UnitOfMeasure, ProductType, IngredientResponse } from '../../../types/inventory.types';
|
||||
import { inventoryService } from '../../../api/services/inventory.service';
|
||||
import { IngredientCreate, IngredientResponse } from '../../../api/types/inventory';
|
||||
|
||||
export interface InventoryFormProps {
|
||||
item?: IngredientResponse;
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
onSubmit?: (data: IngredientFormData) => Promise<void>;
|
||||
onClassify?: (name: string, description?: string) => Promise<any>;
|
||||
onSubmit?: (data: IngredientCreate) => Promise<void>;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Spanish bakery categories with subcategories
|
||||
const BAKERY_CATEGORIES = {
|
||||
harinas: {
|
||||
label: 'Harinas',
|
||||
subcategories: ['Harina de trigo', 'Harina integral', 'Harina de fuerza', 'Harina de maíz', 'Harina de centeno', 'Harina sin gluten']
|
||||
},
|
||||
levaduras: {
|
||||
label: 'Levaduras',
|
||||
subcategories: ['Levadura fresca', 'Levadura seca', 'Levadura química', 'Masa madre', 'Levadura instantánea']
|
||||
},
|
||||
azucares: {
|
||||
label: 'Azúcares y Endulzantes',
|
||||
subcategories: ['Azúcar blanco', 'Azúcar moreno', 'Azúcar glass', 'Miel', 'Jarabe de arce', 'Stevia', 'Azúcar invertido']
|
||||
},
|
||||
chocolates: {
|
||||
label: 'Chocolates y Cacao',
|
||||
subcategories: ['Chocolate negro', 'Chocolate con leche', 'Chocolate blanco', 'Cacao en polvo', 'Pepitas de chocolate', 'Cobertura']
|
||||
},
|
||||
frutas: {
|
||||
label: 'Frutas y Frutos Secos',
|
||||
subcategories: ['Almendras', 'Nueces', 'Pasas', 'Fruta confitada', 'Mermeladas', 'Frutas frescas', 'Frutos del bosque']
|
||||
},
|
||||
lacteos: {
|
||||
label: 'Lácteos',
|
||||
subcategories: ['Leche entera', 'Leche desnatada', 'Nata', 'Queso mascarpone', 'Yogur', 'Suero de leche']
|
||||
},
|
||||
huevos: {
|
||||
label: 'Huevos',
|
||||
subcategories: ['Huevos frescos', 'Clara de huevo', 'Yema de huevo', 'Huevo pasteurizado']
|
||||
},
|
||||
mantequillas: {
|
||||
label: 'Mantequillas y Grasas',
|
||||
subcategories: ['Mantequilla', 'Margarina', 'Aceite de girasol', 'Aceite de oliva', 'Manteca']
|
||||
},
|
||||
especias: {
|
||||
label: 'Especias y Aromas',
|
||||
subcategories: ['Vainilla', 'Canela', 'Cardamomo', 'Esencias', 'Colorantes', 'Sal', 'Bicarbonato']
|
||||
},
|
||||
conservantes: {
|
||||
label: 'Conservantes y Aditivos',
|
||||
subcategories: ['Ácido ascórbico', 'Lecitina', 'Emulgentes', 'Estabilizantes', 'Antioxidantes']
|
||||
},
|
||||
decoracion: {
|
||||
label: 'Decoración',
|
||||
subcategories: ['Fondant', 'Pasta de goma', 'Perlas de azúcar', 'Sprinkles', 'Moldes', 'Papel comestible']
|
||||
},
|
||||
envases: {
|
||||
label: 'Envases y Embalajes',
|
||||
subcategories: ['Cajas de cartón', 'Bolsas', 'Papel encerado', 'Film transparente', 'Etiquetas']
|
||||
},
|
||||
utensilios: {
|
||||
label: 'Utensilios y Equipos',
|
||||
subcategories: ['Moldes', 'Boquillas', 'Espátulas', 'Batidores', 'Termómetros']
|
||||
},
|
||||
limpieza: {
|
||||
label: 'Limpieza e Higiene',
|
||||
subcategories: ['Detergentes', 'Desinfectantes', 'Guantes', 'Paños', 'Productos sanitarios']
|
||||
},
|
||||
};
|
||||
// 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: UnitOfMeasure.KILOGRAM, label: 'Kilogramo (kg)' },
|
||||
{ value: UnitOfMeasure.GRAM, label: 'Gramo (g)' },
|
||||
{ value: UnitOfMeasure.LITER, label: 'Litro (l)' },
|
||||
{ value: UnitOfMeasure.MILLILITER, label: 'Mililitro (ml)' },
|
||||
{ value: UnitOfMeasure.PIECE, label: 'Pieza (pz)' },
|
||||
{ value: UnitOfMeasure.PACKAGE, label: 'Paquete' },
|
||||
{ value: UnitOfMeasure.BAG, label: 'Bolsa' },
|
||||
{ value: UnitOfMeasure.BOX, label: 'Caja' },
|
||||
{ value: UnitOfMeasure.DOZEN, label: 'Docena' },
|
||||
{ value: UnitOfMeasure.CUP, label: 'Taza' },
|
||||
{ value: UnitOfMeasure.TABLESPOON, label: 'Cucharada' },
|
||||
{ value: UnitOfMeasure.TEASPOON, label: 'Cucharadita' },
|
||||
{ value: UnitOfMeasure.POUND, label: 'Libra (lb)' },
|
||||
{ value: UnitOfMeasure.OUNCE, label: 'Onza (oz)' },
|
||||
{ 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 PRODUCT_TYPES = [
|
||||
{ value: ProductType.INGREDIENT, label: 'Ingrediente' },
|
||||
{ value: ProductType.FINISHED_PRODUCT, label: 'Producto Terminado' },
|
||||
];
|
||||
|
||||
const initialFormData: IngredientFormData = {
|
||||
const initialFormData: IngredientCreate = {
|
||||
name: '',
|
||||
product_type: ProductType.INGREDIENT,
|
||||
sku: '',
|
||||
barcode: '',
|
||||
category: '',
|
||||
subcategory: '',
|
||||
description: '',
|
||||
brand: '',
|
||||
unit_of_measure: UnitOfMeasure.KILOGRAM,
|
||||
package_size: undefined,
|
||||
standard_cost: undefined,
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
max_stock_level: 100,
|
||||
reorder_point: 20,
|
||||
reorder_quantity: 50,
|
||||
max_stock_level: undefined,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
storage_temperature_min: undefined,
|
||||
storage_temperature_max: undefined,
|
||||
storage_humidity_max: undefined,
|
||||
shelf_life_days: undefined,
|
||||
storage_instructions: '',
|
||||
is_perishable: false,
|
||||
allergen_info: {},
|
||||
is_seasonal: false,
|
||||
supplier_id: undefined,
|
||||
average_cost: undefined,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
@@ -133,17 +74,11 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
open = false,
|
||||
onClose,
|
||||
onSubmit,
|
||||
onClassify,
|
||||
loading = false,
|
||||
className,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<IngredientFormData>(initialFormData);
|
||||
const [formData, setFormData] = useState<IngredientCreate>(initialFormData);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [classificationSuggestions, setClassificationSuggestions] = useState<any>(null);
|
||||
const [showClassificationModal, setShowClassificationModal] = useState(false);
|
||||
const [classifying, setClassifying] = useState(false);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
const isEditing = !!item;
|
||||
|
||||
@@ -152,39 +87,28 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
if (item) {
|
||||
setFormData({
|
||||
name: item.name,
|
||||
product_type: item.product_type,
|
||||
sku: item.sku || '',
|
||||
barcode: item.barcode || '',
|
||||
category: item.category || '',
|
||||
subcategory: item.subcategory || '',
|
||||
description: item.description || '',
|
||||
brand: item.brand || '',
|
||||
category: item.category,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
package_size: item.package_size,
|
||||
standard_cost: item.standard_cost,
|
||||
low_stock_threshold: item.low_stock_threshold,
|
||||
reorder_point: item.reorder_point,
|
||||
reorder_quantity: item.reorder_quantity,
|
||||
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,
|
||||
storage_temperature_min: item.storage_temperature_min,
|
||||
storage_temperature_max: item.storage_temperature_max,
|
||||
storage_humidity_max: item.storage_humidity_max,
|
||||
shelf_life_days: item.shelf_life_days,
|
||||
storage_instructions: item.storage_instructions || '',
|
||||
is_perishable: item.is_perishable,
|
||||
allergen_info: item.allergen_info || {},
|
||||
is_seasonal: item.is_seasonal,
|
||||
supplier_id: item.supplier_id,
|
||||
average_cost: item.average_cost,
|
||||
notes: item.notes || '',
|
||||
});
|
||||
} else {
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
setErrors({});
|
||||
setClassificationSuggestions(null);
|
||||
}, [item]);
|
||||
|
||||
// Handle input changes
|
||||
const handleInputChange = useCallback((field: keyof IngredientFormData, value: any) => {
|
||||
const handleInputChange = useCallback((field: keyof IngredientCreate, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error for this field
|
||||
if (errors[field]) {
|
||||
@@ -192,53 +116,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
// Handle image upload
|
||||
const handleImageUpload = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImagePreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-classify product
|
||||
const handleClassifyProduct = useCallback(async () => {
|
||||
if (!formData.name.trim()) return;
|
||||
|
||||
setClassifying(true);
|
||||
try {
|
||||
const suggestions = await onClassify?.(formData.name, formData.description);
|
||||
if (suggestions) {
|
||||
setClassificationSuggestions(suggestions);
|
||||
setShowClassificationModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Classification failed:', error);
|
||||
} finally {
|
||||
setClassifying(false);
|
||||
}
|
||||
}, [formData.name, formData.description, onClassify]);
|
||||
|
||||
// Apply classification suggestions
|
||||
const handleApplyClassification = useCallback(() => {
|
||||
if (!classificationSuggestions) return;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
category: classificationSuggestions.category || prev.category,
|
||||
subcategory: classificationSuggestions.subcategory || prev.subcategory,
|
||||
unit_of_measure: classificationSuggestions.suggested_unit || prev.unit_of_measure,
|
||||
is_perishable: classificationSuggestions.is_perishable ?? prev.is_perishable,
|
||||
requires_refrigeration: classificationSuggestions.storage_requirements?.requires_refrigeration ?? prev.requires_refrigeration,
|
||||
requires_freezing: classificationSuggestions.storage_requirements?.requires_freezing ?? prev.requires_freezing,
|
||||
shelf_life_days: classificationSuggestions.storage_requirements?.estimated_shelf_life_days || prev.shelf_life_days,
|
||||
}));
|
||||
|
||||
setShowClassificationModal(false);
|
||||
}, [classificationSuggestions]);
|
||||
|
||||
// Validate form
|
||||
const validateForm = useCallback((): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
@@ -259,20 +136,12 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
newErrors.reorder_point = 'El punto de reorden no puede ser negativo';
|
||||
}
|
||||
|
||||
if (formData.reorder_quantity < 0) {
|
||||
newErrors.reorder_quantity = 'La cantidad de reorden no puede ser negativa';
|
||||
}
|
||||
|
||||
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.standard_cost !== undefined && formData.standard_cost < 0) {
|
||||
newErrors.standard_cost = 'El precio no puede ser negativo';
|
||||
}
|
||||
|
||||
if (formData.package_size !== undefined && formData.package_size <= 0) {
|
||||
newErrors.package_size = 'El tamaño del paquete debe ser mayor que 0';
|
||||
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) {
|
||||
@@ -296,25 +165,12 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
}
|
||||
}, [formData, validateForm, onSubmit]);
|
||||
|
||||
// Get subcategories for selected category
|
||||
const subcategoryOptions = formData.category && BAKERY_CATEGORIES[formData.category as keyof typeof BAKERY_CATEGORIES]
|
||||
? BAKERY_CATEGORIES[formData.category as keyof typeof BAKERY_CATEGORIES].subcategories.map(sub => ({
|
||||
value: sub,
|
||||
label: sub,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const categoryOptions = Object.entries(BAKERY_CATEGORIES).map(([key, { label }]) => ({
|
||||
value: key,
|
||||
label,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isEditing ? 'Editar Ingrediente' : 'Nuevo Ingrediente'}
|
||||
size="xl"
|
||||
size="lg"
|
||||
className={className}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
@@ -322,177 +178,77 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
{/* Left Column - Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Información Básica</h3>
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Información Básica</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="Nombre del Ingrediente"
|
||||
isRequired
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
placeholder="Ej. Harina de trigo"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClassifyProduct}
|
||||
disabled={!formData.name.trim() || classifying}
|
||||
className="mt-8"
|
||||
title="Clasificar automáticamente el producto"
|
||||
>
|
||||
{classifying ? (
|
||||
<svg className="w-4 h-4 animate-spin" 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>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
label="Nombre del Ingrediente"
|
||||
isRequired
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
error={errors.name}
|
||||
placeholder="Ej. Harina de trigo"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Tipo de Producto"
|
||||
value={formData.product_type}
|
||||
onChange={(value) => handleInputChange('product_type', value)}
|
||||
options={PRODUCT_TYPES}
|
||||
error={errors.product_type}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<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}
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder="Descripción detallada del ingrediente"
|
||||
helperText="Descripción opcional para ayudar con la clasificación automática"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Marca"
|
||||
value={formData.brand}
|
||||
onChange={(e) => handleInputChange('brand', e.target.value)}
|
||||
placeholder="Marca del producto"
|
||||
/>
|
||||
<Input
|
||||
label="SKU"
|
||||
value={formData.sku}
|
||||
onChange={(e) => handleInputChange('sku', e.target.value)}
|
||||
placeholder="Código SKU interno"
|
||||
/>
|
||||
</div>
|
||||
<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="Código de Barras"
|
||||
value={formData.barcode}
|
||||
onChange={(e) => handleInputChange('barcode', e.target.value)}
|
||||
placeholder="Código de barras EAN/UPC"
|
||||
label="Notas"
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => handleInputChange('notes', e.target.value)}
|
||||
placeholder="Notas adicionales sobre el ingrediente"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Image Upload */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Imagen del Producto</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="block w-full text-sm text-text-secondary file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-bg-secondary file:text-text-primary hover:file:bg-bg-tertiary"
|
||||
/>
|
||||
{imagePreview && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="w-32 h-32 object-cover rounded-md border border-border-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Categories and Specifications */}
|
||||
{/* Right Column - Specifications */}
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Categorización</h3>
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-4">Precios y Costos</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Select
|
||||
label="Categoría"
|
||||
value={formData.category}
|
||||
onChange={(value) => handleInputChange('category', value)}
|
||||
options={[{ value: '', label: 'Seleccionar categoría' }, ...categoryOptions]}
|
||||
placeholder="Seleccionar categoría"
|
||||
error={errors.category}
|
||||
<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"
|
||||
/>
|
||||
|
||||
{subcategoryOptions.length > 0 && (
|
||||
<Select
|
||||
label="Subcategoría"
|
||||
value={formData.subcategory}
|
||||
onChange={(value) => handleInputChange('subcategory', value)}
|
||||
options={[{ value: '', label: 'Seleccionar subcategoría' }, ...subcategoryOptions]}
|
||||
placeholder="Seleccionar subcategoría"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Precios y Cantidades</h3>
|
||||
<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="Precio Estándar"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.standard_cost?.toString() || ''}
|
||||
onChange={(e) => handleInputChange('standard_cost', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
error={errors.standard_cost}
|
||||
leftAddon="€"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<Input
|
||||
label="Tamaño del Paquete"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.package_size?.toString() || ''}
|
||||
onChange={(e) => handleInputChange('package_size', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
error={errors.package_size}
|
||||
rightAddon={formData.unit_of_measure}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Gestión de Stock</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Stock Mínimo"
|
||||
isRequired
|
||||
@@ -515,17 +271,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
error={errors.reorder_point}
|
||||
placeholder="20"
|
||||
/>
|
||||
<Input
|
||||
label="Cantidad de Reorden"
|
||||
isRequired
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={formData.reorder_quantity?.toString() || ''}
|
||||
onChange={(e) => handleInputChange('reorder_quantity', parseFloat(e.target.value) || 0)}
|
||||
error={errors.reorder_quantity}
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
@@ -546,122 +291,55 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
|
||||
{/* Storage and Preservation */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Almacenamiento y Conservación</h3>
|
||||
<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_perishable}
|
||||
onChange={(e) => handleInputChange('is_perishable', e.target.checked)}
|
||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
||||
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-text-primary">Producto Perecedero</span>
|
||||
<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}
|
||||
checked={formData.requires_refrigeration || false}
|
||||
onChange={(e) => handleInputChange('requires_refrigeration', e.target.checked)}
|
||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-text-primary">Requiere Refrigeración</span>
|
||||
<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}
|
||||
checked={formData.requires_freezing || false}
|
||||
onChange={(e) => handleInputChange('requires_freezing', e.target.checked)}
|
||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-text-primary">Requiere Congelación</span>
|
||||
<span className="ml-2 text-sm text-[var(--text-primary)]">Requiere Congelación</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{(formData.requires_refrigeration || formData.requires_freezing) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Temperatura Mínima (°C)"
|
||||
type="number"
|
||||
value={formData.storage_temperature_min?.toString() || ''}
|
||||
onChange={(e) => handleInputChange('storage_temperature_min', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="Ej. -18"
|
||||
/>
|
||||
<Input
|
||||
label="Temperatura Máxima (°C)"
|
||||
type="number"
|
||||
value={formData.storage_temperature_max?.toString() || ''}
|
||||
onChange={(e) => handleInputChange('storage_temperature_max', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="Ej. 4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Humedad Máxima (%)"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={formData.storage_humidity_max?.toString() || ''}
|
||||
onChange={(e) => handleInputChange('storage_humidity_max', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
placeholder="Ej. 65"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Input
|
||||
label="Instrucciones de Almacenamiento"
|
||||
value={formData.storage_instructions}
|
||||
onChange={(e) => handleInputChange('storage_instructions', e.target.value)}
|
||||
placeholder="Ej. Mantener en lugar seco y fresco, alejado de la luz solar"
|
||||
helperText="Instrucciones específicas para el almacenamiento del producto"
|
||||
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>
|
||||
|
||||
{/* Allergen Information */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-4">Información de Alérgenos</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ key: 'contains_gluten', label: 'Gluten' },
|
||||
{ key: 'contains_dairy', label: 'Lácteos' },
|
||||
{ key: 'contains_eggs', label: 'Huevos' },
|
||||
{ key: 'contains_nuts', label: 'Frutos Secos' },
|
||||
{ key: 'contains_soy', label: 'Soja' },
|
||||
{ key: 'contains_shellfish', label: 'Mariscos' },
|
||||
].map(({ key, label }) => (
|
||||
<label key={key} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.allergen_info?.[key] || false}
|
||||
onChange={(e) => handleInputChange('allergen_info', {
|
||||
...formData.allergen_info,
|
||||
[key]: e.target.checked,
|
||||
})}
|
||||
className="rounded border-input-border text-color-primary focus:ring-color-primary"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-text-primary">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex gap-4 justify-end pt-4 border-t border-border-primary">
|
||||
<div className="flex gap-4 justify-end pt-4 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -679,76 +357,6 @@ export const InventoryForm: React.FC<InventoryFormProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Classification Suggestions Modal */}
|
||||
<Modal
|
||||
open={showClassificationModal}
|
||||
onClose={() => setShowClassificationModal(false)}
|
||||
title="Sugerencias de Clasificación"
|
||||
size="md"
|
||||
>
|
||||
{classificationSuggestions && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-text-secondary">
|
||||
Se han encontrado las siguientes sugerencias para "{formData.name}":
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Categoría:</span>
|
||||
<Badge variant="primary">
|
||||
{BAKERY_CATEGORIES[classificationSuggestions.category as keyof typeof BAKERY_CATEGORIES]?.label || classificationSuggestions.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{classificationSuggestions.subcategory && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Subcategoría:</span>
|
||||
<Badge variant="outline">{classificationSuggestions.subcategory}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Unidad Sugerida:</span>
|
||||
<Badge variant="secondary">
|
||||
{UNITS_OF_MEASURE.find(u => u.value === classificationSuggestions.suggested_unit)?.label || classificationSuggestions.suggested_unit}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Perecedero:</span>
|
||||
<Badge variant={classificationSuggestions.is_perishable ? "warning" : "success"}>
|
||||
{classificationSuggestions.is_perishable ? 'Sí' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Confianza:</span>
|
||||
<Badge variant={
|
||||
classificationSuggestions.confidence > 0.8 ? "success" :
|
||||
classificationSuggestions.confidence > 0.6 ? "warning" : "error"
|
||||
}>
|
||||
{(classificationSuggestions.confidence * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowClassificationModal(false)}
|
||||
>
|
||||
Ignorar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyClassification}
|
||||
>
|
||||
Aplicar Sugerencias
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,537 +1,158 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Card } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Button } from '../../ui';
|
||||
import { Modal } from '../../ui';
|
||||
import { Input } from '../../ui';
|
||||
import { EmptyState } from '../../shared';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import { StockAlert, IngredientResponse, AlertSeverity } from '../../../types/inventory.types';
|
||||
import { inventoryService } from '../../../api/services/inventory.service';
|
||||
import { StockLevelIndicator } from './StockLevelIndicator';
|
||||
import React from 'react';
|
||||
import { AlertTriangle, Package } from 'lucide-react';
|
||||
import { Card, Button, Badge } from '../../ui';
|
||||
import { IngredientResponse } from '../../../api/types/inventory';
|
||||
|
||||
export interface LowStockAlertProps {
|
||||
alerts?: StockAlert[];
|
||||
autoRefresh?: boolean;
|
||||
refreshInterval?: number;
|
||||
maxItems?: number;
|
||||
showDismissed?: boolean;
|
||||
compact?: boolean;
|
||||
items: IngredientResponse[];
|
||||
className?: string;
|
||||
onReorder?: (item: IngredientResponse) => void;
|
||||
onAdjustMinimums?: (item: IngredientResponse) => void;
|
||||
onDismiss?: (alertId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
onViewAll?: () => void;
|
||||
}
|
||||
|
||||
interface GroupedAlerts {
|
||||
critical: StockAlert[];
|
||||
low: StockAlert[];
|
||||
out: StockAlert[];
|
||||
}
|
||||
|
||||
interface SupplierSuggestion {
|
||||
id: string;
|
||||
name: string;
|
||||
lastPrice?: number;
|
||||
lastOrderDate?: string;
|
||||
reliability: number;
|
||||
onViewDetails?: (item: IngredientResponse) => void;
|
||||
}
|
||||
|
||||
export const LowStockAlert: React.FC<LowStockAlertProps> = ({
|
||||
alerts = [],
|
||||
autoRefresh = false,
|
||||
refreshInterval = 30000,
|
||||
maxItems = 10,
|
||||
showDismissed = false,
|
||||
compact = false,
|
||||
items = [],
|
||||
className,
|
||||
onReorder,
|
||||
onAdjustMinimums,
|
||||
onDismiss,
|
||||
onRefresh,
|
||||
onViewAll,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const [localAlerts, setLocalAlerts] = useState<StockAlert[]>(alerts);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dismissedAlerts, setDismissedAlerts] = useState<Set<string>>(new Set());
|
||||
const [showReorderModal, setShowReorderModal] = useState(false);
|
||||
const [showAdjustModal, setShowAdjustModal] = useState(false);
|
||||
const [selectedAlert, setSelectedAlert] = useState<StockAlert | null>(null);
|
||||
const [reorderQuantity, setReorderQuantity] = useState<number>(0);
|
||||
const [newMinimumThreshold, setNewMinimumThreshold] = useState<number>(0);
|
||||
const [supplierSuggestions, setSupplierSuggestions] = useState<SupplierSuggestion[]>([]);
|
||||
// Filter items that need attention
|
||||
const criticalItems = items.filter(item => item.stock_status === 'out_of_stock');
|
||||
const lowStockItems = items.filter(item => item.stock_status === 'low_stock');
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update local alerts when prop changes
|
||||
useEffect(() => {
|
||||
setLocalAlerts(alerts);
|
||||
}, [alerts]);
|
||||
|
||||
// Auto-refresh functionality
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || refreshInterval <= 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
onRefresh?.();
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, refreshInterval, onRefresh]);
|
||||
|
||||
// Load supplier suggestions when modal opens
|
||||
useEffect(() => {
|
||||
if (showReorderModal && selectedAlert?.ingredient_id) {
|
||||
loadSupplierSuggestions(selectedAlert.ingredient_id);
|
||||
}
|
||||
}, [showReorderModal, selectedAlert]);
|
||||
|
||||
const loadSupplierSuggestions = async (ingredientId: string) => {
|
||||
try {
|
||||
// This would typically call a suppliers API
|
||||
// For now, we'll simulate some data
|
||||
setSupplierSuggestions([
|
||||
{ id: '1', name: 'Proveedor Principal', lastPrice: 2.50, reliability: 95 },
|
||||
{ id: '2', name: 'Proveedor Alternativo', lastPrice: 2.80, reliability: 87 },
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading supplier suggestions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Group alerts by severity
|
||||
const groupedAlerts = React.useMemo((): GroupedAlerts => {
|
||||
const filtered = localAlerts.filter(alert => {
|
||||
if (!alert.is_active) return false;
|
||||
if (!showDismissed && dismissedAlerts.has(alert.id)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return {
|
||||
critical: filtered.filter(alert =>
|
||||
alert.severity === AlertSeverity.CRITICAL ||
|
||||
alert.alert_type === 'out_of_stock'
|
||||
),
|
||||
low: filtered.filter(alert =>
|
||||
alert.severity === AlertSeverity.HIGH &&
|
||||
alert.alert_type === 'low_stock'
|
||||
),
|
||||
out: filtered.filter(alert => alert.alert_type === 'out_of_stock'),
|
||||
};
|
||||
}, [localAlerts, showDismissed, dismissedAlerts]);
|
||||
|
||||
const totalActiveAlerts = groupedAlerts.critical.length + groupedAlerts.low.length;
|
||||
|
||||
// Handle alert dismissal
|
||||
const handleDismiss = useCallback(async (alertId: string, temporary: boolean = true) => {
|
||||
if (temporary) {
|
||||
setDismissedAlerts(prev => new Set(prev).add(alertId));
|
||||
} else {
|
||||
try {
|
||||
await inventoryService.acknowledgeAlert(alertId);
|
||||
onDismiss?.(alertId);
|
||||
} catch (error) {
|
||||
console.error('Error dismissing alert:', error);
|
||||
setError('Error al descartar la alerta');
|
||||
}
|
||||
}
|
||||
}, [onDismiss]);
|
||||
|
||||
// Handle reorder action
|
||||
const handleReorder = useCallback((alert: StockAlert) => {
|
||||
setSelectedAlert(alert);
|
||||
setReorderQuantity(alert.ingredient?.reorder_quantity || 0);
|
||||
setShowReorderModal(true);
|
||||
}, []);
|
||||
|
||||
// Handle adjust minimums action
|
||||
const handleAdjustMinimums = useCallback((alert: StockAlert) => {
|
||||
setSelectedAlert(alert);
|
||||
setNewMinimumThreshold(alert.threshold_value || alert.ingredient?.low_stock_threshold || 0);
|
||||
setShowAdjustModal(true);
|
||||
}, []);
|
||||
|
||||
// Confirm reorder
|
||||
const handleConfirmReorder = useCallback(() => {
|
||||
if (selectedAlert?.ingredient) {
|
||||
onReorder?.(selectedAlert.ingredient);
|
||||
}
|
||||
setShowReorderModal(false);
|
||||
setSelectedAlert(null);
|
||||
}, [selectedAlert, onReorder]);
|
||||
|
||||
// Confirm adjust minimums
|
||||
const handleConfirmAdjust = useCallback(() => {
|
||||
if (selectedAlert?.ingredient) {
|
||||
onAdjustMinimums?.(selectedAlert.ingredient);
|
||||
}
|
||||
setShowAdjustModal(false);
|
||||
setSelectedAlert(null);
|
||||
}, [selectedAlert, onAdjustMinimums]);
|
||||
|
||||
// Get severity badge variant
|
||||
const getSeverityVariant = (severity: AlertSeverity): any => {
|
||||
switch (severity) {
|
||||
case AlertSeverity.CRITICAL:
|
||||
const getSeverityColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'out_of_stock':
|
||||
return 'error';
|
||||
case AlertSeverity.HIGH:
|
||||
case 'low_stock':
|
||||
return 'warning';
|
||||
case AlertSeverity.MEDIUM:
|
||||
return 'info';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
// Render alert item
|
||||
const renderAlertItem = (alert: StockAlert, index: number) => {
|
||||
const ingredient = alert.ingredient;
|
||||
if (!ingredient) return null;
|
||||
|
||||
const isCompact = compact || index >= maxItems;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={clsx(
|
||||
'flex items-center justify-between p-3 border border-border-primary rounded-lg',
|
||||
'hover:bg-bg-secondary transition-colors duration-150',
|
||||
{
|
||||
'bg-color-error/5 border-color-error/20': alert.severity === AlertSeverity.CRITICAL,
|
||||
'bg-color-warning/5 border-color-warning/20': alert.severity === AlertSeverity.HIGH,
|
||||
'py-2': isCompact,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Stock indicator */}
|
||||
<StockLevelIndicator
|
||||
current={alert.current_quantity || 0}
|
||||
minimum={ingredient.low_stock_threshold}
|
||||
maximum={ingredient.max_stock_level}
|
||||
reorderPoint={ingredient.reorder_point}
|
||||
unit={ingredient.unit_of_measure}
|
||||
size={isCompact ? 'xs' : 'sm'}
|
||||
variant="minimal"
|
||||
/>
|
||||
|
||||
{/* Alert info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-text-primary truncate">
|
||||
{ingredient.name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={getSeverityVariant(alert.severity)}
|
||||
size="xs"
|
||||
>
|
||||
{alert.severity === AlertSeverity.CRITICAL ? 'Crítico' :
|
||||
alert.severity === AlertSeverity.HIGH ? 'Bajo' :
|
||||
'Normal'}
|
||||
</Badge>
|
||||
{ingredient.category && (
|
||||
<Badge variant="outline" size="xs">
|
||||
{ingredient.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCompact && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{alert.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-sm text-text-tertiary">
|
||||
Stock: {alert.current_quantity || 0} {ingredient.unit_of_measure}
|
||||
</span>
|
||||
{alert.threshold_value && (
|
||||
<span className="text-sm text-text-tertiary">
|
||||
Mín: {alert.threshold_value} {ingredient.unit_of_measure}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!isCompact && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleReorder(alert)}
|
||||
title="Crear orden de compra"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Reordenar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAdjustMinimums(alert)}
|
||||
title="Ajustar umbrales mínimos"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4" />
|
||||
</svg>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDismiss(alert.id, true)}
|
||||
title="Descartar alerta temporalmente"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const getSeverityText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'out_of_stock':
|
||||
return 'Sin Stock';
|
||||
case 'low_stock':
|
||||
return 'Stock Bajo';
|
||||
default:
|
||||
return 'Normal';
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className={clsx('p-6', className)}>
|
||||
<div className="flex items-center justify-center">
|
||||
<LoadingSpinner size="md" />
|
||||
<span className="ml-2 text-text-secondary">Cargando alertas...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className={clsx('p-6 border-color-error/20 bg-color-error/5', className)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-color-error flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-color-error">Error al cargar alertas</h3>
|
||||
<p className="text-sm text-text-secondary">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (totalActiveAlerts === 0) {
|
||||
return (
|
||||
<Card className={clsx('p-6', className)}>
|
||||
<EmptyState
|
||||
title="Sin alertas de stock"
|
||||
description="Todos los productos tienen niveles de stock adecuados"
|
||||
icon={
|
||||
<svg className="w-12 h-12 text-color-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleAlerts = [...groupedAlerts.critical, ...groupedAlerts.low].slice(0, maxItems);
|
||||
const hasMoreAlerts = totalActiveAlerts > maxItems;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-primary bg-bg-secondary">
|
||||
<Card className={className}>
|
||||
<div className="p-4 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Alertas de Stock
|
||||
</h2>
|
||||
<Badge variant="error" count={groupedAlerts.critical.length} />
|
||||
<Badge variant="warning" count={groupedAlerts.low.length} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onRefresh}
|
||||
title="Actualizar alertas"
|
||||
disabled={loading}
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</Button>
|
||||
|
||||
{hasMoreAlerts && onViewAll && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onViewAll}
|
||||
>
|
||||
Ver Todas ({totalActiveAlerts})
|
||||
</Button>
|
||||
</h3>
|
||||
{criticalItems.length > 0 && (
|
||||
<Badge variant="error">{criticalItems.length} críticos</Badge>
|
||||
)}
|
||||
{lowStockItems.length > 0 && (
|
||||
<Badge variant="warning">{lowStockItems.length} bajos</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts list */}
|
||||
<div className="p-4 space-y-3">
|
||||
{visibleAlerts.map((alert, index) => renderAlertItem(alert, index))}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{hasMoreAlerts && (
|
||||
<div className="px-4 py-3 border-t border-border-primary bg-bg-tertiary">
|
||||
<p className="text-sm text-text-secondary text-center">
|
||||
Mostrando {visibleAlerts.length} de {totalActiveAlerts} alertas
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Reorder Modal */}
|
||||
<Modal
|
||||
open={showReorderModal}
|
||||
onClose={() => setShowReorderModal(false)}
|
||||
title="Crear Orden de Compra"
|
||||
size="md"
|
||||
>
|
||||
{selectedAlert && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-bg-secondary rounded-lg">
|
||||
<h3 className="font-medium text-text-primary">
|
||||
{selectedAlert.ingredient?.name}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Stock actual: {selectedAlert.current_quantity || 0} {selectedAlert.ingredient?.unit_of_measure}
|
||||
</p>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Stock mínimo: {selectedAlert.ingredient?.low_stock_threshold} {selectedAlert.ingredient?.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Cantidad a Ordenar"
|
||||
type="number"
|
||||
min="1"
|
||||
step="0.01"
|
||||
value={reorderQuantity.toString()}
|
||||
onChange={(e) => setReorderQuantity(parseFloat(e.target.value) || 0)}
|
||||
rightAddon={selectedAlert.ingredient?.unit_of_measure}
|
||||
helperText="Cantidad sugerida basada en el punto de reorden configurado"
|
||||
/>
|
||||
|
||||
{supplierSuggestions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-text-primary mb-2">
|
||||
Proveedores Sugeridos
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{supplierSuggestions.map(supplier => (
|
||||
<div key={supplier.id} className="p-2 border border-border-primary rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{supplier.name}</span>
|
||||
<Badge variant="success" size="xs">
|
||||
{supplier.reliability}% confiable
|
||||
</Badge>
|
||||
</div>
|
||||
{supplier.lastPrice && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
Último precio: €{supplier.lastPrice.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="p-4 space-y-3">
|
||||
{items.slice(0, 5).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 border border-[var(--border-primary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
item.stock_status === 'out_of_stock'
|
||||
? 'bg-red-500'
|
||||
: item.stock_status === 'low_stock'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`} />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{item.name}
|
||||
</h4>
|
||||
<Badge
|
||||
variant={getSeverityColor(item.stock_status) as any}
|
||||
size="sm"
|
||||
>
|
||||
{getSeverityText(item.stock_status)}
|
||||
</Badge>
|
||||
{item.category && (
|
||||
<Badge variant="outline" size="sm">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
Stock: {item.current_stock_level} {item.unit_of_measure}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
Mín: {item.low_stock_threshold} {item.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowReorderModal(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmReorder}
|
||||
disabled={reorderQuantity <= 0}
|
||||
>
|
||||
Crear Orden de Compra
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{onViewDetails && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onViewDetails(item)}
|
||||
>
|
||||
Ver
|
||||
</Button>
|
||||
)}
|
||||
{onReorder && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => onReorder(item)}
|
||||
>
|
||||
Reordenar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
))}
|
||||
|
||||
{/* Adjust Minimums Modal */}
|
||||
<Modal
|
||||
open={showAdjustModal}
|
||||
onClose={() => setShowAdjustModal(false)}
|
||||
title="Ajustar Umbrales Mínimos"
|
||||
size="md"
|
||||
>
|
||||
{selectedAlert && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-bg-secondary rounded-lg">
|
||||
<h3 className="font-medium text-text-primary">
|
||||
{selectedAlert.ingredient?.name}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Stock actual: {selectedAlert.current_quantity || 0} {selectedAlert.ingredient?.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Nuevo Umbral Mínimo"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={newMinimumThreshold.toString()}
|
||||
onChange={(e) => setNewMinimumThreshold(parseFloat(e.target.value) || 0)}
|
||||
rightAddon={selectedAlert.ingredient?.unit_of_measure}
|
||||
helperText="Ajusta el nivel mínimo de stock para este producto"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAdjustModal(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmAdjust}
|
||||
disabled={newMinimumThreshold < 0}
|
||||
>
|
||||
Actualizar Umbral
|
||||
</Button>
|
||||
</div>
|
||||
{items.length > 5 && (
|
||||
<div className="text-center pt-2">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
Y {items.length - 5} elementos más...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
Sin alertas de stock
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Todos los productos tienen niveles de stock adecuados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user