Fix UI for inventory page 3

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,18 @@
// Inventory Domain Components
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 InventoryModal, type InventoryModalProps } from './InventoryModal';
export { default as InventoryItemModal, type InventoryItemModalProps } from './InventoryItemModal';
// Re-export related types from inventory types
export type {
InventoryFilters,
InventoryFilter,
IngredientResponse,
StockAlert,
IngredientFormData,
StockMovementResponse,
IngredientCreate,
IngredientUpdate,
UnitOfMeasure,
ProductType,
AlertSeverity,
AlertType,
SortOrder,
} from '../../../types/inventory.types';
PaginatedResponse,
} from '../../../api/types/inventory';
// Utility exports for common inventory operations
export const INVENTORY_CONSTANTS = {

View File

@@ -38,22 +38,23 @@ export interface StatusModalProps {
onClose: () => void;
mode: 'view' | 'edit';
onModeChange?: (mode: 'view' | 'edit') => void;
// Content
title: string;
subtitle?: string;
statusIndicator?: StatusIndicatorConfig;
image?: string;
sections: StatusModalSection[];
// Actions
actions?: StatusModalAction[];
showDefaultActions?: boolean;
actionsPosition?: 'header' | 'footer'; // New prop for positioning actions
onEdit?: () => void;
onSave?: () => Promise<void>;
onCancel?: () => void;
onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => void;
// Layout
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
loading?: boolean;
@@ -224,6 +225,13 @@ const renderEditableField = (
/**
* StatusModal - Unified modal component for viewing/editing card details
* 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> = ({
isOpen,
@@ -237,6 +245,7 @@ export const StatusModal: React.FC<StatusModalProps> = ({
sections,
actions = [],
showDefaultActions = true,
actionsPosition = 'footer',
onEdit,
onSave,
onCancel,
@@ -305,6 +314,48 @@ export const StatusModal: React.FC<StatusModalProps> = ({
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 (
<Modal
isOpen={isOpen}
@@ -319,26 +370,26 @@ export const StatusModal: React.FC<StatusModalProps> = ({
<div className="flex items-center gap-3">
{/* Status indicator */}
{statusIndicator && (
<div
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${statusIndicator.color}15` }}
>
{StatusIcon && (
<StatusIcon
className="w-5 h-5"
style={{ color: statusIndicator.color }}
<StatusIcon
className="w-5 h-5"
style={{ color: statusIndicator.color }}
/>
)}
</div>
)}
{/* Title and status */}
<div>
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
{title}
</h2>
{statusIndicator && (
<div
<div
className="text-sm font-medium mt-1"
style={{ color: statusIndicator.color }}
>
@@ -363,6 +414,9 @@ export const StatusModal: React.FC<StatusModalProps> = ({
onClose={onClose}
/>
{/* Top Navigation Actions */}
{renderTopActions()}
<ModalBody>
{loading && (
<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>
</ModalBody>
{allActions.length > 0 && (
{allActions.length > 0 && actionsPosition === 'footer' && (
<ModalFooter justify="end">
<div className="flex gap-3">
{allActions.map((action, index) => (

View File

@@ -2,7 +2,7 @@
export { default as Button } from './Button';
export { default as Input } from './Input';
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 Badge } from './Badge';
export { default as Avatar } from './Avatar';
@@ -22,7 +22,7 @@ export { TenantSwitcher } from './TenantSwitcher';
export type { ButtonProps } from './Button';
export type { InputProps } from './Input';
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 { BadgeProps } from './Badge';
export type { AvatarProps } from './Avatar';