Fix UI for inventory page
This commit is contained in:
389
frontend/src/components/domain/inventory/InventoryModal.tsx
Normal file
389
frontend/src/components/domain/inventory/InventoryModal.tsx
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user