Fix UI for inventory page

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

View File

@@ -0,0 +1,389 @@
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

@@ -3,6 +3,7 @@ export { default as InventoryTable, type InventoryTableProps } from './Inventory
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';
// Re-export related types from inventory types
export type {

View File

@@ -9,7 +9,7 @@ import { formatters } from '../Stats/StatsPresets';
export interface StatusModalField {
label: string;
value: string | number | React.ReactNode;
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number';
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select';
highlight?: boolean;
span?: 1 | 2; // For grid layout
editable?: boolean; // Whether this field can be edited
@@ -52,6 +52,7 @@ export interface StatusModalProps {
onEdit?: () => void;
onSave?: () => Promise<void>;
onCancel?: () => void;
onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => void;
// Layout
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
@@ -186,6 +187,26 @@ const renderEditableField = (
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent"
/>
);
case 'select':
return (
<select
value={String(field.value)}
onChange={(e) => onChange?.(e.target.value)}
required={field.required}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-transparent bg-[var(--bg-primary)]"
>
{field.placeholder && (
<option value="" disabled>
{field.placeholder}
</option>
)}
{field.options?.map((option, index) => (
<option key={index} value={option.value}>
{option.label}
</option>
))}
</select>
);
default:
return (
<Input
@@ -219,6 +240,7 @@ export const StatusModal: React.FC<StatusModalProps> = ({
onEdit,
onSave,
onCancel,
onFieldChange,
size = 'lg',
loading = false,
}) => {
@@ -364,11 +386,11 @@ export const StatusModal: React.FC<StatusModalProps> = ({
<div className="space-y-6">
{sections.map((section, sectionIndex) => (
<div key={sectionIndex} className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-[var(--border-primary)]">
<div className="flex items-baseline gap-3 pb-3 border-b border-[var(--border-primary)]">
{section.icon && (
<section.icon className="w-4 h-4 text-[var(--text-tertiary)]" />
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
)}
<h3 className="font-medium text-[var(--text-primary)]">
<h3 className="font-medium text-[var(--text-primary)] leading-tight">
{section.title}
</h3>
</div>
@@ -387,7 +409,11 @@ export const StatusModal: React.FC<StatusModalProps> = ({
? 'font-semibold text-[var(--text-primary)]'
: 'text-[var(--text-primary)]'
}`}>
{renderEditableField(field, mode === 'edit')}
{renderEditableField(
field,
mode === 'edit',
(value: string | number) => onFieldChange?.(sectionIndex, fieldIndex, value)
)}
</dd>
</div>
))}