Improve the inventory page
This commit is contained in:
@@ -452,7 +452,7 @@ export const AnalyticsDashboard: React.FC<AnalyticsDashboardProps> = ({
|
||||
<p className="text-2xl font-bold text-teal-600">
|
||||
{bakeryMetrics.inventory.turnover_rate.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Rotación Inventario</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Velocidad de Venta</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
236
frontend/src/components/domain/inventory/AddStockModal.tsx
Normal file
236
frontend/src/components/domain/inventory/AddStockModal.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Package, Euro, Calendar, FileText } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, StockCreate } from '../../../api/types/inventory';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface AddStockModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
onAddStock?: (stockData: StockCreate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AddStockModal - Focused modal for adding new stock
|
||||
* Streamlined form for quick stock entry
|
||||
*/
|
||||
export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
onAddStock
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Partial<StockCreate>>({
|
||||
ingredient_id: ingredient.id,
|
||||
quantity: 0,
|
||||
unit_price: Number(ingredient.average_cost) || 0,
|
||||
expiration_date: '',
|
||||
batch_number: '',
|
||||
supplier_id: '',
|
||||
purchase_order_reference: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
const handleFieldChange = (_sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
const fields = ['quantity', 'unit_price', 'expiration_date', 'batch_number', 'supplier_id', 'purchase_order_reference', 'notes'];
|
||||
const fieldName = fields[fieldIndex] as keyof typeof formData;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.quantity || formData.quantity <= 0) {
|
||||
alert('Por favor, ingresa una cantidad válida');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.unit_price || formData.unit_price <= 0) {
|
||||
alert('Por favor, ingresa un precio unitario válido');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const stockData: StockCreate = {
|
||||
ingredient_id: ingredient.id,
|
||||
quantity: Number(formData.quantity),
|
||||
unit_price: Number(formData.unit_price),
|
||||
expiration_date: formData.expiration_date || undefined,
|
||||
batch_number: formData.batch_number || undefined,
|
||||
supplier_id: formData.supplier_id || undefined,
|
||||
purchase_order_reference: formData.purchase_order_reference || undefined,
|
||||
notes: formData.notes || undefined
|
||||
};
|
||||
|
||||
if (onAddStock) {
|
||||
await onAddStock(stockData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
ingredient_id: ingredient.id,
|
||||
quantity: 0,
|
||||
unit_price: Number(ingredient.average_cost) || 0,
|
||||
expiration_date: '',
|
||||
batch_number: '',
|
||||
supplier_id: '',
|
||||
purchase_order_reference: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error adding stock:', error);
|
||||
alert('Error al agregar stock. Por favor, intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStock = Number(ingredient.current_stock) || 0;
|
||||
const newTotal = currentStock + (Number(formData.quantity) || 0);
|
||||
const totalValue = (Number(formData.quantity) || 0) * (Number(formData.unit_price) || 0);
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.normal.primary,
|
||||
text: 'Agregar Stock',
|
||||
icon: Plus
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información del Stock',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: `Cantidad (${ingredient.unit_of_measure})`,
|
||||
value: formData.quantity || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Ej: 50'
|
||||
},
|
||||
{
|
||||
label: 'Precio Unitario',
|
||||
value: formData.unit_price || 0,
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Ej: 2.50'
|
||||
},
|
||||
{
|
||||
label: 'Fecha de Vencimiento',
|
||||
value: formData.expiration_date || '',
|
||||
type: 'date' as const,
|
||||
editable: true,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Adicional',
|
||||
icon: FileText,
|
||||
fields: [
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
value: formData.batch_number || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ej: LOTE2024001'
|
||||
},
|
||||
{
|
||||
label: 'ID Proveedor',
|
||||
value: formData.supplier_id || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ej: PROV001'
|
||||
},
|
||||
{
|
||||
label: 'Referencia de Pedido',
|
||||
value: formData.purchase_order_reference || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ej: PO-2024-001',
|
||||
span: 2 as const
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Observaciones adicionales...',
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Resumen',
|
||||
icon: Euro,
|
||||
fields: [
|
||||
{
|
||||
label: 'Stock Actual',
|
||||
value: `${currentStock} ${ingredient.unit_of_measure}`,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Nuevo Total',
|
||||
value: `${newTotal} ${ingredient.unit_of_measure}`,
|
||||
highlight: true,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Valor de la Entrada',
|
||||
value: `€${totalValue.toFixed(2)}`,
|
||||
type: 'currency' as const,
|
||||
highlight: true,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Cancelar',
|
||||
variant: 'outline' as const,
|
||||
onClick: onClose,
|
||||
disabled: loading
|
||||
},
|
||||
{
|
||||
label: 'Agregar Stock',
|
||||
variant: 'primary' as const,
|
||||
onClick: handleSave,
|
||||
disabled: loading || !formData.quantity || !formData.unit_price,
|
||||
loading
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
title={`Agregar Stock: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • Stock actual: ${currentStock} ${ingredient.unit_of_measure}`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
actions={actions}
|
||||
onFieldChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddStockModal;
|
||||
355
frontend/src/components/domain/inventory/CreateItemModal.tsx
Normal file
355
frontend/src/components/domain/inventory/CreateItemModal.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Package, Calculator, Settings, Thermometer } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateItemModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreateIngredient?: (ingredientData: IngredientCreate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateItemModal - Modal for creating a new inventory ingredient
|
||||
* Comprehensive form for adding new items to inventory
|
||||
*/
|
||||
export const CreateItemModal: React.FC<CreateItemModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateIngredient
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<IngredientCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
supplier_id: '',
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
// Category options combining ingredient and product categories
|
||||
const categoryOptions = [
|
||||
// Ingredient categories
|
||||
{ label: 'Harinas', value: 'flour' },
|
||||
{ label: 'Levaduras', value: 'yeast' },
|
||||
{ label: 'Lácteos', value: 'dairy' },
|
||||
{ label: 'Huevos', value: 'eggs' },
|
||||
{ label: 'Azúcar', value: 'sugar' },
|
||||
{ label: 'Grasas', value: 'fats' },
|
||||
{ label: 'Sal', value: 'salt' },
|
||||
{ label: 'Especias', value: 'spices' },
|
||||
{ label: 'Aditivos', value: 'additives' },
|
||||
{ label: 'Envases', value: 'packaging' },
|
||||
{ label: 'Limpieza', value: 'cleaning' },
|
||||
// Product categories
|
||||
{ label: 'Pan', value: 'bread' },
|
||||
{ label: 'Croissants', value: 'croissants' },
|
||||
{ label: 'Pastelería', value: 'pastries' },
|
||||
{ label: 'Tartas', value: 'cakes' },
|
||||
{ label: 'Galletas', value: 'cookies' },
|
||||
{ label: 'Muffins', value: 'muffins' },
|
||||
{ label: 'Sandwiches', value: 'sandwiches' },
|
||||
{ label: 'Temporada', value: 'seasonal' },
|
||||
{ label: 'Bebidas', value: 'beverages' },
|
||||
{ label: 'Otros', value: 'other' }
|
||||
];
|
||||
|
||||
const unitOptions = [
|
||||
{ label: 'Kilogramo (kg)', value: 'kg' },
|
||||
{ label: 'Gramo (g)', value: 'g' },
|
||||
{ label: 'Litro (l)', value: 'l' },
|
||||
{ label: 'Mililitro (ml)', value: 'ml' },
|
||||
{ label: 'Unidades', value: 'units' },
|
||||
{ label: 'Piezas', value: 'pcs' },
|
||||
{ label: 'Paquetes', value: 'pkg' },
|
||||
{ label: 'Bolsas', value: 'bags' },
|
||||
{ label: 'Cajas', value: 'boxes' }
|
||||
];
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
const fieldMappings = [
|
||||
// Basic Information section
|
||||
['name', 'description', 'category', 'unit_of_measure'],
|
||||
// Cost and Quantities section
|
||||
['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
|
||||
// Storage Requirements section
|
||||
['requires_refrigeration', 'requires_freezing', 'shelf_life_days', 'is_seasonal'],
|
||||
// Additional Information section
|
||||
['supplier_id', 'notes']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate;
|
||||
if (fieldName) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!formData.name?.trim()) {
|
||||
alert('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
alert('La categoría es requerida');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.unit_of_measure) {
|
||||
alert('La unidad de medida es requerida');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) {
|
||||
alert('El umbral de stock bajo debe ser un número positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reorder_point || formData.reorder_point < 0) {
|
||||
alert('El punto de reorden debe ser un número positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.reorder_point <= formData.low_stock_threshold) {
|
||||
alert('El punto de reorden debe ser mayor que el umbral de stock bajo');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (onCreateIngredient) {
|
||||
await onCreateIngredient(formData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
supplier_id: '',
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating ingredient:', error);
|
||||
alert('Error al crear el artículo. Por favor, intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form to initial values
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
supplier_id: '',
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nuevo Artículo',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: formData.name,
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Ej: Harina de trigo 000'
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
value: formData.description || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Descripción opcional del artículo'
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: formData.category,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: categoryOptions
|
||||
},
|
||||
{
|
||||
label: 'Unidad de Medida',
|
||||
value: formData.unit_of_measure,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: unitOptions
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Costos y Cantidades',
|
||||
icon: Calculator,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
value: formData.average_cost || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: 'Umbral Stock Bajo',
|
||||
value: formData.low_stock_threshold,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: '10'
|
||||
},
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
value: formData.reorder_point,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: '20'
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
value: formData.max_stock_level || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: '100'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Requisitos de Almacenamiento',
|
||||
icon: Thermometer,
|
||||
fields: [
|
||||
{
|
||||
label: 'Requiere Refrigeración',
|
||||
value: formData.requires_refrigeration ? 'Sí' : 'No',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Requiere Congelación',
|
||||
value: formData.requires_freezing ? 'Sí' : 'No',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Vida Útil (días)',
|
||||
value: formData.shelf_life_days || '',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: 'Días de vida útil'
|
||||
},
|
||||
{
|
||||
label: 'Es Estacional',
|
||||
value: formData.is_seasonal ? 'Sí' : 'No',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Adicional',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Proveedor',
|
||||
value: formData.supplier_id || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'ID o nombre del proveedor'
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Notas adicionales'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
title="Crear Nuevo Artículo"
|
||||
subtitle="Agregar un nuevo artículo al inventario"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateItemModal;
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '../../ui';
|
||||
import { DeleteIngredientModal } from './';
|
||||
import { useSoftDeleteIngredient, useHardDeleteIngredient } from '../../../api/hooks/inventory';
|
||||
import { useAuthStore } from '../../../stores/auth.store';
|
||||
import { IngredientResponse } from '../../../api/types/inventory';
|
||||
|
||||
interface DeleteIngredientExampleProps {
|
||||
ingredient: IngredientResponse;
|
||||
onDeleteSuccess?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Example component showing how to use DeleteIngredientModal
|
||||
* This can be integrated into inventory cards, tables, or detail pages
|
||||
*/
|
||||
export const DeleteIngredientExample: React.FC<DeleteIngredientExampleProps> = ({
|
||||
ingredient,
|
||||
onDeleteSuccess
|
||||
}) => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const { tenantId } = useAuthStore();
|
||||
|
||||
// Hook for soft delete
|
||||
const softDeleteMutation = useSoftDeleteIngredient({
|
||||
onSuccess: () => {
|
||||
setShowDeleteModal(false);
|
||||
onDeleteSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Soft delete failed:', error);
|
||||
// Here you could show a toast notification
|
||||
}
|
||||
});
|
||||
|
||||
// Hook for hard delete
|
||||
const hardDeleteMutation = useHardDeleteIngredient({
|
||||
onSuccess: (result) => {
|
||||
console.log('Hard delete completed:', result);
|
||||
onDeleteSuccess?.();
|
||||
// Modal will handle closing itself after showing results
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Hard delete failed:', error);
|
||||
// Here you could show a toast notification
|
||||
}
|
||||
});
|
||||
|
||||
const handleSoftDelete = async (ingredientId: string) => {
|
||||
if (!tenantId) {
|
||||
throw new Error('No tenant ID available');
|
||||
}
|
||||
|
||||
return softDeleteMutation.mutateAsync({
|
||||
tenantId,
|
||||
ingredientId
|
||||
});
|
||||
};
|
||||
|
||||
const handleHardDelete = async (ingredientId: string) => {
|
||||
if (!tenantId) {
|
||||
throw new Error('No tenant ID available');
|
||||
}
|
||||
|
||||
return hardDeleteMutation.mutateAsync({
|
||||
tenantId,
|
||||
ingredientId
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = softDeleteMutation.isPending || hardDeleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Delete Button - This could be in a dropdown menu, action bar, etc. */}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Eliminar
|
||||
</Button>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<DeleteIngredientModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
ingredient={ingredient}
|
||||
onSoftDelete={handleSoftDelete}
|
||||
onHardDelete={handleHardDelete}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteIngredientExample;
|
||||
@@ -0,0 +1,334 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, AlertTriangle, Info, X } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import { IngredientResponse, DeletionSummary } from '../../../api/types/inventory';
|
||||
|
||||
type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
interface DeleteIngredientModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
onSoftDelete: (ingredientId: string) => Promise<void>;
|
||||
onHardDelete: (ingredientId: string) => Promise<DeletionSummary>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for ingredient deletion with soft/hard delete options
|
||||
*/
|
||||
export const DeleteIngredientModal: React.FC<DeleteIngredientModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<DeletionSummary | null>(null);
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard') {
|
||||
const result = await onHardDelete(ingredient.id);
|
||||
setDeletionResult(result);
|
||||
} else {
|
||||
await onSoftDelete(ingredient.id);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting ingredient:', error);
|
||||
// Handle error (could show a toast or error message)
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR';
|
||||
|
||||
// Show deletion result for hard delete
|
||||
if (deletionResult) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Eliminación Completada
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
El artículo {deletionResult.ingredient_name} ha sido eliminado permanentemente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">Resumen de eliminación:</h4>
|
||||
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>Lotes de stock eliminados:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_stock_entries}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Movimientos eliminados:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_stock_movements}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Alertas eliminadas:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_stock_alerts}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
Entendido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete ? 'Confirmación de Eliminación Permanente' : 'Confirmación de Desactivación'}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{ingredient.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Categoría: {ingredient.category} • Stock actual: {ingredient.current_stock || 0}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">⚠️ Esta acción eliminará permanentemente:</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>• El artículo y toda su información</li>
|
||||
<li>• Todos los lotes de stock asociados</li>
|
||||
<li>• Todo el historial de movimientos</li>
|
||||
<li>• Las alertas relacionadas</li>
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
Esta acción NO se puede deshacer
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">ℹ️ Esta acción desactivará el artículo:</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>• El artículo se marcará como inactivo</li>
|
||||
<li>• No aparecerá en listas activas</li>
|
||||
<li>• Se conserva todo el historial y stock</li>
|
||||
<li>• Se puede reactivar posteriormente</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Para confirmar, escriba <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
|
||||
placeholder="Escriba ELIMINAR"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete ? 'Eliminar Permanentemente' : 'Desactivar Artículo'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Eliminar Artículo
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{ingredient.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Categoría: {ingredient.category} • Stock actual: {ingredient.current_stock || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
Elija el tipo de eliminación que desea realizar:
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
Desactivar (Recomendado)
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
El artículo se marca como inactivo pero conserva todo su historial.
|
||||
Ideal para artículos temporalmente fuera del catálogo.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
✓ Reversible • ✓ Conserva historial • ✓ Conserva stock
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
Eliminar Permanentemente
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Elimina completamente el artículo y todos sus datos asociados.
|
||||
Use solo para datos erróneos o pruebas.
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina stock
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteIngredientModal;
|
||||
297
frontend/src/components/domain/inventory/EditItemModal.tsx
Normal file
297
frontend/src/components/domain/inventory/EditItemModal.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Edit, Package, AlertTriangle, Settings, Thermometer } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, IngredientUpdate, IngredientCategory, UnitOfMeasure } from '../../../api/types/inventory';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface EditItemModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
onUpdateIngredient?: (id: string, updateData: IngredientUpdate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* EditItemModal - Focused modal for editing ingredient details
|
||||
* Organized form for updating ingredient properties
|
||||
*/
|
||||
export const EditItemModal: React.FC<EditItemModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
onUpdateIngredient
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<IngredientUpdate>({
|
||||
name: ingredient.name,
|
||||
description: ingredient.description || '',
|
||||
category: ingredient.category,
|
||||
brand: ingredient.brand || '',
|
||||
unit_of_measure: ingredient.unit_of_measure,
|
||||
average_cost: ingredient.average_cost || 0,
|
||||
low_stock_threshold: ingredient.low_stock_threshold,
|
||||
reorder_point: ingredient.reorder_point,
|
||||
max_stock_level: ingredient.max_stock_level || undefined,
|
||||
requires_refrigeration: ingredient.requires_refrigeration,
|
||||
requires_freezing: ingredient.requires_freezing,
|
||||
shelf_life_days: ingredient.shelf_life_days || undefined,
|
||||
storage_instructions: ingredient.storage_instructions || '',
|
||||
is_active: ingredient.is_active,
|
||||
notes: ingredient.notes || ''
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
const fieldMappings = [
|
||||
// Basic Information section
|
||||
['name', 'description', 'category', 'brand'],
|
||||
// Measurements section
|
||||
['unit_of_measure', 'average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
|
||||
// Storage Requirements section
|
||||
['requires_refrigeration', 'requires_freezing', 'shelf_life_days', 'storage_instructions'],
|
||||
// Additional Settings section
|
||||
['is_active', 'notes']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientUpdate;
|
||||
if (fieldName) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name?.trim()) {
|
||||
alert('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) {
|
||||
alert('El umbral de stock bajo debe ser un número positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reorder_point || formData.reorder_point < 0) {
|
||||
alert('El punto de reorden debe ser un número positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (onUpdateIngredient) {
|
||||
await onUpdateIngredient(ingredient.id, formData);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error updating ingredient:', error);
|
||||
alert('Error al actualizar el artículo. Por favor, intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Editar Artículo',
|
||||
icon: Edit
|
||||
};
|
||||
|
||||
const categoryOptions = Object.values(IngredientCategory).map(cat => ({
|
||||
label: cat.charAt(0).toUpperCase() + cat.slice(1),
|
||||
value: cat
|
||||
}));
|
||||
|
||||
const unitOptions = Object.values(UnitOfMeasure).map(unit => ({
|
||||
label: unit,
|
||||
value: unit
|
||||
}));
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: formData.name || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Nombre del artículo'
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
value: formData.description || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Descripción del artículo'
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: formData.category || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: categoryOptions
|
||||
},
|
||||
{
|
||||
label: 'Marca',
|
||||
value: formData.brand || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Marca del producto'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Medidas y Costos',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Unidad de Medida',
|
||||
value: formData.unit_of_measure || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: unitOptions
|
||||
},
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
value: formData.average_cost || 0,
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: 'Umbral Stock Bajo',
|
||||
value: formData.low_stock_threshold || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Cantidad mínima'
|
||||
},
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
value: formData.reorder_point || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Cuando reordenar'
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
value: formData.max_stock_level || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: 'Stock máximo (opcional)'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Requisitos de Almacenamiento',
|
||||
icon: Thermometer,
|
||||
fields: [
|
||||
{
|
||||
label: 'Requiere Refrigeración',
|
||||
value: formData.requires_refrigeration ? 'Sí' : 'No',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Requiere Congelación',
|
||||
value: formData.requires_freezing ? 'Sí' : 'No',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Vida Útil (días)',
|
||||
value: formData.shelf_life_days || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: 'Días de duración'
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones de Almacenamiento',
|
||||
value: formData.storage_instructions || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Instrucciones especiales...',
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración Adicional',
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Estado',
|
||||
value: formData.is_active ? 'Activo' : 'Inactivo',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: [
|
||||
{ label: 'Activo', value: true },
|
||||
{ label: 'Inactivo', value: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Notas adicionales...',
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Cancelar',
|
||||
variant: 'outline' as const,
|
||||
onClick: onClose,
|
||||
disabled: loading
|
||||
},
|
||||
{
|
||||
label: 'Guardar Cambios',
|
||||
variant: 'primary' as const,
|
||||
onClick: handleSave,
|
||||
disabled: loading || !formData.name?.trim(),
|
||||
loading
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
title={`Editar: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • ID: ${ingredient.id.slice(0, 8)}...`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
actions={actions}
|
||||
onFieldChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
size="xl"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditItemModal;
|
||||
244
frontend/src/components/domain/inventory/HistoryModal.tsx
Normal file
244
frontend/src/components/domain/inventory/HistoryModal.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React from 'react';
|
||||
import { Clock, TrendingUp, TrendingDown, Package, AlertCircle, RotateCcw } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface HistoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
movements: StockMovementResponse[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HistoryModal - Focused modal for viewing stock movement history
|
||||
* Clean, scannable list of recent movements
|
||||
*/
|
||||
export const HistoryModal: React.FC<HistoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
movements = [],
|
||||
loading = false
|
||||
}) => {
|
||||
// Group movements by type for better organization
|
||||
const groupedMovements = movements.reduce((acc, movement) => {
|
||||
const type = movement.movement_type;
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(movement);
|
||||
return acc;
|
||||
}, {} as Record<string, StockMovementResponse[]>);
|
||||
|
||||
// Get movement type display info
|
||||
const getMovementTypeInfo = (type: string) => {
|
||||
switch (type) {
|
||||
case 'purchase':
|
||||
return { label: 'Compra', icon: TrendingUp, color: statusColors.normal.primary };
|
||||
case 'production_use':
|
||||
return { label: 'Uso en Producción', icon: TrendingDown, color: statusColors.pending.primary };
|
||||
case 'adjustment':
|
||||
return { label: 'Ajuste', icon: Package, color: statusColors.inProgress.primary };
|
||||
case 'waste':
|
||||
return { label: 'Merma', icon: AlertCircle, color: statusColors.cancelled.primary };
|
||||
case 'transfer':
|
||||
return { label: 'Transferencia', icon: RotateCcw, color: statusColors.bread.primary };
|
||||
case 'return':
|
||||
return { label: 'Devolución', icon: RotateCcw, color: statusColors.inTransit.primary };
|
||||
case 'initial_stock':
|
||||
return { label: 'Stock Inicial', icon: Package, color: statusColors.completed.primary };
|
||||
case 'transformation':
|
||||
return { label: 'Transformación', icon: RotateCcw, color: statusColors.pastry.primary };
|
||||
default:
|
||||
return { label: type, icon: Package, color: statusColors.other.primary };
|
||||
}
|
||||
};
|
||||
|
||||
// Format movement for display
|
||||
const formatMovement = (movement: StockMovementResponse) => {
|
||||
const typeInfo = getMovementTypeInfo(movement.movement_type);
|
||||
const date = new Date(movement.movement_date).toLocaleDateString('es-ES');
|
||||
const time = new Date(movement.movement_date).toLocaleTimeString('es-ES', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const quantity = Number(movement.quantity);
|
||||
const isPositive = quantity > 0;
|
||||
const quantityText = `${isPositive ? '+' : ''}${quantity} ${ingredient.unit_of_measure}`;
|
||||
|
||||
return {
|
||||
id: movement.id,
|
||||
type: typeInfo.label,
|
||||
icon: typeInfo.icon,
|
||||
color: typeInfo.color,
|
||||
quantity: quantityText,
|
||||
isPositive,
|
||||
date: `${date} ${time}`,
|
||||
reference: movement.reference_number || '-',
|
||||
notes: movement.notes || '-',
|
||||
cost: movement.total_cost ? formatters.currency(movement.total_cost) : '-',
|
||||
quantityBefore: movement.quantity_before || 0,
|
||||
quantityAfter: movement.quantity_after || 0
|
||||
};
|
||||
};
|
||||
|
||||
const recentMovements = movements
|
||||
.slice(0, 20) // Show last 20 movements
|
||||
.map(formatMovement);
|
||||
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: `${movements.length} movimientos`,
|
||||
icon: Clock
|
||||
};
|
||||
|
||||
// Create a visual movement list
|
||||
const movementsList = recentMovements.length > 0 ? (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{recentMovements.map((movement) => {
|
||||
const MovementIcon = movement.icon;
|
||||
return (
|
||||
<div
|
||||
key={movement.id}
|
||||
className="flex items-center gap-3 p-3 bg-[var(--surface-secondary)] rounded-lg hover:bg-[var(--surface-tertiary)] transition-colors"
|
||||
>
|
||||
{/* Icon and type */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${movement.color}15` }}
|
||||
>
|
||||
<MovementIcon
|
||||
className="w-4 h-4"
|
||||
style={{ color: movement.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-[var(--text-primary)]">
|
||||
{movement.type}
|
||||
</span>
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{
|
||||
color: movement.isPositive
|
||||
? statusColors.normal.primary
|
||||
: statusColors.pending.primary
|
||||
}}
|
||||
>
|
||||
{movement.quantity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-[var(--text-secondary)] mt-1">
|
||||
<span>{movement.date}</span>
|
||||
<span>{movement.cost}</span>
|
||||
</div>
|
||||
|
||||
{movement.reference !== '-' && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
Ref: {movement.reference}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{movement.notes !== '-' && (
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1 truncate">
|
||||
{movement.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stock levels */}
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-[var(--text-tertiary)]">
|
||||
{movement.quantityBefore} → {movement.quantityAfter}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{ingredient.unit_of_measure}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Clock className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No hay movimientos de stock registrados</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Historial de Movimientos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: '',
|
||||
value: movementsList,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Add summary if we have movements
|
||||
if (movements.length > 0) {
|
||||
const totalIn = movements
|
||||
.filter(m => Number(m.quantity) > 0)
|
||||
.reduce((sum, m) => sum + Number(m.quantity), 0);
|
||||
|
||||
const totalOut = movements
|
||||
.filter(m => Number(m.quantity) < 0)
|
||||
.reduce((sum, m) => sum + Math.abs(Number(m.quantity)), 0);
|
||||
|
||||
const totalValue = movements
|
||||
.reduce((sum, m) => sum + (Number(m.total_cost) || 0), 0);
|
||||
|
||||
sections.unshift({
|
||||
title: 'Resumen de Actividad',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Total Entradas',
|
||||
value: `${totalIn} ${ingredient.unit_of_measure}`,
|
||||
highlight: true,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Total Salidas',
|
||||
value: `${totalOut} ${ingredient.unit_of_measure}`,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Valor Total Movimientos',
|
||||
value: formatters.currency(Math.abs(totalValue)),
|
||||
type: 'currency' as const,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={`Historial: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • Últimos ${Math.min(movements.length, 20)} movimientos`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryModal;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
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 {
|
||||
items: IngredientResponse[];
|
||||
className?: string;
|
||||
onReorder?: (item: IngredientResponse) => void;
|
||||
onViewDetails?: (item: IngredientResponse) => void;
|
||||
}
|
||||
|
||||
export const LowStockAlert: React.FC<LowStockAlertProps> = ({
|
||||
items = [],
|
||||
className,
|
||||
onReorder,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const getSeverityColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'out_of_stock':
|
||||
return 'error';
|
||||
case 'low_stock':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'out_of_stock':
|
||||
return 'Sin Stock';
|
||||
case 'low_stock':
|
||||
return 'Stock Bajo';
|
||||
default:
|
||||
return 'Normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)]" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Alertas de Stock
|
||||
</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>
|
||||
|
||||
<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 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>
|
||||
))}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default LowStockAlert;
|
||||
166
frontend/src/components/domain/inventory/QuickViewModal.tsx
Normal file
166
frontend/src/components/domain/inventory/QuickViewModal.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React from 'react';
|
||||
import { Package, AlertTriangle, CheckCircle, Clock, Euro } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface QuickViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* QuickViewModal - Focused modal for viewing essential stock information
|
||||
* Shows only the most important data users need for quick decisions
|
||||
*/
|
||||
export const QuickViewModal: React.FC<QuickViewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient
|
||||
}) => {
|
||||
// Safe number conversions
|
||||
const currentStock = Number(ingredient.current_stock) || 0;
|
||||
const maxStock = Number(ingredient.max_stock_level) || 0;
|
||||
const averageCost = Number(ingredient.average_cost) || 0;
|
||||
const lowThreshold = Number(ingredient.low_stock_threshold) || 0;
|
||||
const reorderPoint = Number(ingredient.reorder_point) || 0;
|
||||
|
||||
// Calculate derived values
|
||||
const totalValue = currentStock * averageCost;
|
||||
const stockPercentage = maxStock > 0 ? Math.round((currentStock / maxStock) * 100) : 0;
|
||||
const daysOfStock = currentStock > 0 ? Math.round(currentStock / (averageCost || 1)) : 0;
|
||||
|
||||
// Status configuration
|
||||
const getStatusConfig = () => {
|
||||
if (currentStock === 0) {
|
||||
return {
|
||||
color: statusColors.out.primary,
|
||||
text: 'Sin Stock',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
};
|
||||
}
|
||||
if (currentStock <= lowThreshold) {
|
||||
return {
|
||||
color: statusColors.low.primary,
|
||||
text: 'Stock Bajo',
|
||||
icon: AlertTriangle,
|
||||
isHighlight: true
|
||||
};
|
||||
}
|
||||
return {
|
||||
color: statusColors.normal.primary,
|
||||
text: 'Stock Normal',
|
||||
icon: CheckCircle
|
||||
};
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig();
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Estado del Stock',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Cantidad Actual',
|
||||
value: `${currentStock} ${ingredient.unit_of_measure}`,
|
||||
highlight: true,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Valor Total',
|
||||
value: formatters.currency(totalValue),
|
||||
type: 'currency' as const,
|
||||
highlight: true,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Nivel de Stock',
|
||||
value: maxStock > 0 ? `${stockPercentage}%` : 'N/A',
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Días Estimados',
|
||||
value: `~${daysOfStock} días`,
|
||||
span: 1 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Umbrales de Control',
|
||||
icon: AlertTriangle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
value: `${reorderPoint} ${ingredient.unit_of_measure}`,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Stock Mínimo',
|
||||
value: `${lowThreshold} ${ingredient.unit_of_measure}`,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
value: maxStock > 0 ? `${maxStock} ${ingredient.unit_of_measure}` : 'No definido',
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
value: formatters.currency(averageCost),
|
||||
type: 'currency' as const,
|
||||
span: 1 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Add storage requirements if available
|
||||
if (ingredient.requires_refrigeration || ingredient.requires_freezing || ingredient.shelf_life_days) {
|
||||
sections.push({
|
||||
title: 'Requisitos de Almacenamiento',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
...(ingredient.shelf_life_days ? [{
|
||||
label: 'Vida Útil',
|
||||
value: `${ingredient.shelf_life_days} días`,
|
||||
span: 1 as const
|
||||
}] : []),
|
||||
...(ingredient.requires_refrigeration ? [{
|
||||
label: 'Refrigeración',
|
||||
value: 'Requerida',
|
||||
span: 1 as const
|
||||
}] : []),
|
||||
...(ingredient.requires_freezing ? [{
|
||||
label: 'Congelación',
|
||||
value: 'Requerida',
|
||||
span: 1 as const
|
||||
}] : []),
|
||||
...(ingredient.storage_instructions ? [{
|
||||
label: 'Instrucciones',
|
||||
value: ingredient.storage_instructions,
|
||||
span: 2 as const
|
||||
}] : [])
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={ingredient.name}
|
||||
subtitle={`${ingredient.category} • ${ingredient.description || 'Sin descripción'}`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="md"
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickViewModal;
|
||||
311
frontend/src/components/domain/inventory/StockLotsModal.tsx
Normal file
311
frontend/src/components/domain/inventory/StockLotsModal.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React from 'react';
|
||||
import { Package, Calendar, AlertTriangle, CheckCircle, Clock } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, StockResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface StockLotsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
stockLots: StockResponse[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* StockLotsModal - Focused modal for viewing individual stock lots/batches
|
||||
* Shows detailed breakdown of all stock batches with expiration, quantities, etc.
|
||||
*/
|
||||
export const StockLotsModal: React.FC<StockLotsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
stockLots = [],
|
||||
loading = false
|
||||
}) => {
|
||||
|
||||
// Sort stock lots by expiration date (earliest first, then by batch number)
|
||||
// Use current_quantity from API response
|
||||
const sortedLots = stockLots
|
||||
.filter(lot => (lot.current_quantity || lot.quantity || 0) > 0) // Handle both API variations
|
||||
.sort((a, b) => {
|
||||
if (a.expiration_date && b.expiration_date) {
|
||||
return new Date(a.expiration_date).getTime() - new Date(b.expiration_date).getTime();
|
||||
}
|
||||
if (a.expiration_date && !b.expiration_date) return -1;
|
||||
if (!a.expiration_date && b.expiration_date) return 1;
|
||||
return (a.batch_number || '').localeCompare(b.batch_number || '');
|
||||
});
|
||||
|
||||
// Get lot status info using global color system
|
||||
const getLotStatus = (lot: StockResponse) => {
|
||||
if (!lot.expiration_date) {
|
||||
return {
|
||||
label: 'Sin Vencimiento',
|
||||
color: statusColors.other.primary,
|
||||
icon: Package,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const expirationDate = new Date(lot.expiration_date);
|
||||
const daysUntilExpiry = Math.ceil((expirationDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
return {
|
||||
label: 'Vencido',
|
||||
color: statusColors.expired.primary,
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
};
|
||||
} else if (daysUntilExpiry <= 3) {
|
||||
return {
|
||||
label: 'Vence Pronto',
|
||||
color: statusColors.low.primary,
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
};
|
||||
} else if (daysUntilExpiry <= 7) {
|
||||
return {
|
||||
label: 'Por Vencer',
|
||||
color: statusColors.pending.primary,
|
||||
icon: Clock,
|
||||
isCritical: false
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
label: 'Fresco',
|
||||
color: statusColors.normal.primary,
|
||||
icon: CheckCircle,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Format lot for display
|
||||
const formatLot = (lot: StockResponse, index: number) => {
|
||||
const status = getLotStatus(lot);
|
||||
const expirationDate = lot.expiration_date ?
|
||||
new Date(lot.expiration_date).toLocaleDateString('es-ES') : 'N/A';
|
||||
|
||||
const daysUntilExpiry = lot.expiration_date ?
|
||||
Math.ceil((new Date(lot.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)) : null;
|
||||
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lot.id}
|
||||
className="flex items-center gap-3 p-4 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-secondary)] hover:bg-[var(--surface-tertiary)] transition-colors"
|
||||
style={{
|
||||
borderColor: status.isCritical ? `${status.color}40` : undefined,
|
||||
backgroundColor: status.isCritical ? `${status.color}08` : undefined
|
||||
}}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${status.color}15` }}
|
||||
>
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lot information */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-medium text-[var(--text-primary)]">
|
||||
Lote #{index + 1} {lot.batch_number && `(${lot.batch_number})`}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.label} {daysUntilExpiry !== null && daysUntilExpiry >= 0 && `(${daysUntilExpiry} días)`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{lot.current_quantity || lot.quantity} {ingredient.unit_of_measure}
|
||||
</div>
|
||||
{lot.available_quantity !== (lot.current_quantity || lot.quantity) && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
Disponible: {lot.available_quantity}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Vencimiento:</span>
|
||||
<div className="font-medium">{expirationDate}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Precio/unidad:</span>
|
||||
<div className="font-medium">{formatters.currency(lot.unit_cost || lot.unit_price || 0)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Valor total:</span>
|
||||
<div className="font-medium">{formatters.currency(lot.total_cost || lot.total_value || 0)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Etapa:</span>
|
||||
<div className="font-medium capitalize">{lot.production_stage.replace('_', ' ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lot.notes && (
|
||||
<div className="mt-2 text-xs text-[var(--text-secondary)]">
|
||||
📝 {lot.notes}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: loading
|
||||
? 'Cargando...'
|
||||
: stockLots.length === 0
|
||||
? 'Sin datos de lotes'
|
||||
: `${sortedLots.length} de ${stockLots.length} lotes`,
|
||||
icon: Package
|
||||
};
|
||||
|
||||
// Create the lots list
|
||||
const lotsDisplay = loading ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--color-primary)] mx-auto mb-3"></div>
|
||||
<p>Cargando lotes...</p>
|
||||
</div>
|
||||
) : stockLots.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No hay datos de lotes para este ingrediente</p>
|
||||
<p className="text-xs mt-2">Es posible que no se hayan registrado lotes individuales</p>
|
||||
</div>
|
||||
) : sortedLots.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)]">
|
||||
<Package className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No hay lotes disponibles con stock</p>
|
||||
<p className="text-xs mt-2">Total de lotes registrados: {stockLots.length}</p>
|
||||
<div className="text-xs mt-2 text-left max-w-sm mx-auto">
|
||||
<div>Lotes filtrados por:</div>
|
||||
<ul className="list-disc list-inside">
|
||||
<li>Cantidad > 0</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{sortedLots.map((lot, index) => formatLot(lot, index))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: stockLots.length === 0 ? 'Información de Stock' : 'Lotes de Stock Disponibles',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: '',
|
||||
value: stockLots.length === 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center py-6 text-[var(--text-secondary)]">
|
||||
<Package className="w-10 h-10 mx-auto mb-3 opacity-50" />
|
||||
<p>Este ingrediente no tiene lotes individuales registrados</p>
|
||||
<p className="text-xs mt-2">Se muestra información agregada del stock</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-[var(--surface-secondary)] rounded-lg">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-sm">Stock Total:</span>
|
||||
<div className="font-medium">{ingredient.current_stock || 0} {ingredient.unit_of_measure}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-sm">Costo Promedio:</span>
|
||||
<div className="font-medium">€{(ingredient.average_cost || 0).toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-sm">Umbral Mínimo:</span>
|
||||
<div className="font-medium">{ingredient.low_stock_threshold} {ingredient.unit_of_measure}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-sm">Punto de Reorden:</span>
|
||||
<div className="font-medium">{ingredient.reorder_point} {ingredient.unit_of_measure}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : lotsDisplay,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
// Add summary if we have lots
|
||||
if (sortedLots.length > 0) {
|
||||
const totalQuantity = sortedLots.reduce((sum, lot) => sum + (lot.current_quantity || lot.quantity || 0), 0);
|
||||
const totalValue = sortedLots.reduce((sum, lot) => sum + (lot.total_cost || lot.total_value || 0), 0);
|
||||
const expiringSoon = sortedLots.filter(lot => {
|
||||
if (!lot.expiration_date) return false;
|
||||
const daysUntilExpiry = Math.ceil((new Date(lot.expiration_date).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
|
||||
return daysUntilExpiry <= 7;
|
||||
}).length;
|
||||
|
||||
sections.unshift({
|
||||
title: 'Resumen de Lotes',
|
||||
icon: CheckCircle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Total Cantidad',
|
||||
value: `${totalQuantity} ${ingredient.unit_of_measure}`,
|
||||
highlight: true,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Número de Lotes',
|
||||
value: sortedLots.length,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Valor Total',
|
||||
value: formatters.currency(totalValue),
|
||||
type: 'currency' as const,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Por Vencer (7 días)',
|
||||
value: expiringSoon,
|
||||
highlight: expiringSoon > 0,
|
||||
span: 1 as const
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={`Lotes: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • Gestión por lotes y vencimientos`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="xl"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockLotsModal;
|
||||
213
frontend/src/components/domain/inventory/UseStockModal.tsx
Normal file
213
frontend/src/components/domain/inventory/UseStockModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Minus, Package, FileText, AlertTriangle } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, StockMovementCreate } from '../../../api/types/inventory';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface UseStockModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
onUseStock?: (movementData: StockMovementCreate) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* UseStockModal - Focused modal for recording stock consumption
|
||||
* Quick form for production usage tracking
|
||||
*/
|
||||
export const UseStockModal: React.FC<UseStockModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
onUseStock
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
quantity: 0,
|
||||
reference_number: '',
|
||||
notes: '',
|
||||
reason_code: 'production_use'
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
const handleFieldChange = (_sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
const fields = ['quantity', 'reference_number', 'notes', 'reason_code'];
|
||||
const fieldName = fields[fieldIndex] as keyof typeof formData;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const currentStock = Number(ingredient.current_stock) || 0;
|
||||
const requestedQuantity = Number(formData.quantity);
|
||||
|
||||
if (!requestedQuantity || requestedQuantity <= 0) {
|
||||
alert('Por favor, ingresa una cantidad válida');
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestedQuantity > currentStock) {
|
||||
alert(`No hay suficiente stock. Disponible: ${currentStock} ${ingredient.unit_of_measure}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const movementData: StockMovementCreate = {
|
||||
ingredient_id: ingredient.id,
|
||||
movement_type: formData.reason_code === 'waste' ? 'waste' : 'production_use',
|
||||
quantity: -requestedQuantity, // Negative for consumption
|
||||
reference_number: formData.reference_number || undefined,
|
||||
notes: formData.notes || undefined,
|
||||
reason_code: formData.reason_code || undefined
|
||||
};
|
||||
|
||||
if (onUseStock) {
|
||||
await onUseStock(movementData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
quantity: 0,
|
||||
reference_number: '',
|
||||
notes: '',
|
||||
reason_code: 'production_use'
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error using stock:', error);
|
||||
alert('Error al registrar el uso de stock. Por favor, intenta de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentStock = Number(ingredient.current_stock) || 0;
|
||||
const requestedQuantity = Number(formData.quantity) || 0;
|
||||
const remainingStock = Math.max(0, currentStock - requestedQuantity);
|
||||
const isLowStock = remainingStock <= (Number(ingredient.low_stock_threshold) || 0);
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.pending.primary,
|
||||
text: 'Usar Stock',
|
||||
icon: Minus
|
||||
};
|
||||
|
||||
const reasonOptions = [
|
||||
{ label: 'Uso en Producción', value: 'production_use' },
|
||||
{ label: 'Merma/Desperdicio', value: 'waste' },
|
||||
{ label: 'Ajuste de Inventario', value: 'adjustment' },
|
||||
{ label: 'Transferencia', value: 'transfer' }
|
||||
];
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Consumo de Stock',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: `Cantidad a Usar (${ingredient.unit_of_measure})`,
|
||||
value: formData.quantity || 0,
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: `Máx: ${currentStock}`
|
||||
},
|
||||
{
|
||||
label: 'Motivo',
|
||||
value: formData.reason_code,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: reasonOptions
|
||||
},
|
||||
{
|
||||
label: 'Referencia/Pedido',
|
||||
value: formData.reference_number || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ej: PROD-2024-001',
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Detalles Adicionales',
|
||||
icon: FileText,
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Detalles del uso, receta, observaciones...',
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Resumen del Movimiento',
|
||||
icon: isLowStock ? AlertTriangle : Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Stock Actual',
|
||||
value: `${currentStock} ${ingredient.unit_of_measure}`,
|
||||
span: 1 as const
|
||||
},
|
||||
{
|
||||
label: 'Stock Restante',
|
||||
value: `${remainingStock} ${ingredient.unit_of_measure}`,
|
||||
highlight: isLowStock,
|
||||
span: 1 as const
|
||||
},
|
||||
...(isLowStock ? [{
|
||||
label: 'Advertencia',
|
||||
value: '⚠️ El stock quedará por debajo del umbral mínimo',
|
||||
span: 2 as const
|
||||
}] : [])
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
label: 'Cancelar',
|
||||
variant: 'outline' as const,
|
||||
onClick: onClose,
|
||||
disabled: loading
|
||||
},
|
||||
{
|
||||
label: formData.reason_code === 'waste' ? 'Registrar Merma' : 'Usar Stock',
|
||||
variant: formData.reason_code === 'waste' ? 'outline' : 'primary' as const,
|
||||
onClick: handleSave,
|
||||
disabled: loading || !formData.quantity || requestedQuantity > currentStock,
|
||||
loading
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
title={`Usar Stock: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • Disponible: ${currentStock} ${ingredient.unit_of_measure}`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
actions={actions}
|
||||
onFieldChange={handleFieldChange}
|
||||
onSave={handleSave}
|
||||
size="md"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UseStockModal;
|
||||
@@ -1,6 +1,14 @@
|
||||
// Inventory Domain Components
|
||||
export { default as LowStockAlert, type LowStockAlertProps } from './LowStockAlert';
|
||||
export { default as InventoryItemModal, type InventoryItemModalProps } from './InventoryItemModal';
|
||||
|
||||
// Focused Modal Components
|
||||
export { default as CreateItemModal } from './CreateItemModal';
|
||||
export { default as QuickViewModal } from './QuickViewModal';
|
||||
export { default as AddStockModal } from './AddStockModal';
|
||||
export { default as UseStockModal } from './UseStockModal';
|
||||
export { default as HistoryModal } from './HistoryModal';
|
||||
export { default as StockLotsModal } from './StockLotsModal';
|
||||
export { default as EditItemModal } from './EditItemModal';
|
||||
export { default as DeleteIngredientModal } from './DeleteIngredientModal';
|
||||
|
||||
// Re-export related types from inventory types
|
||||
export type {
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ProgressBarProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
animated?: boolean;
|
||||
customColor?: string;
|
||||
}
|
||||
|
||||
const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
|
||||
@@ -21,6 +22,7 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
|
||||
label,
|
||||
className,
|
||||
animated = false,
|
||||
customColor,
|
||||
...props
|
||||
}, ref) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
@@ -58,14 +60,21 @@ const ProgressBar = forwardRef<HTMLDivElement, ProgressBarProps>(({
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'h-full rounded-full transition-all duration-300 ease-out',
|
||||
variantClasses[variant],
|
||||
'h-full rounded-full transition-all duration-300 ease-out relative overflow-hidden',
|
||||
!customColor && variantClasses[variant],
|
||||
{
|
||||
'animate-pulse': animated && percentage < 100,
|
||||
}
|
||||
)}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
style={{
|
||||
width: `${percentage}%`,
|
||||
backgroundColor: customColor || undefined
|
||||
}}
|
||||
>
|
||||
{animated && percentage < 100 && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-20 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Card } from '../Card';
|
||||
import { Button } from '../Button';
|
||||
import { ProgressBar } from '../ProgressBar';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
export interface StatusIndicatorConfig {
|
||||
@@ -107,66 +108,90 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
const secondaryActions = sortedActions.filter(action => action.priority !== 'primary');
|
||||
|
||||
return (
|
||||
<Card
|
||||
<Card
|
||||
className={`
|
||||
p-5 transition-all duration-200 border-l-4
|
||||
${hasInteraction ? 'hover:shadow-md cursor-pointer' : ''}
|
||||
p-6 transition-all duration-200 border-l-4 hover:shadow-lg
|
||||
${hasInteraction ? 'hover:shadow-xl cursor-pointer hover:scale-[1.02]' : ''}
|
||||
${statusIndicator.isCritical
|
||||
? 'ring-2 ring-red-200 shadow-md border-l-8'
|
||||
: statusIndicator.isHighlight
|
||||
? 'ring-1 ring-yellow-200'
|
||||
: ''
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
style={{
|
||||
borderLeftColor: statusIndicator.color,
|
||||
backgroundColor: statusIndicator.isCritical
|
||||
? `${statusIndicator.color}08`
|
||||
: statusIndicator.isHighlight
|
||||
? `${statusIndicator.color}05`
|
||||
backgroundColor: statusIndicator.isCritical
|
||||
? `${statusIndicator.color}08`
|
||||
: statusIndicator.isHighlight
|
||||
? `${statusIndicator.color}05`
|
||||
: undefined
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
{/* Header with status indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div
|
||||
className={`flex-shrink-0 p-3 rounded-xl shadow-sm ${
|
||||
statusIndicator.isCritical ? 'ring-2 ring-white' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: `${statusIndicator.color}20` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-4 h-4"
|
||||
style={{ color: statusIndicator.color }}
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: statusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)] text-base">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[var(--text-primary)] text-lg leading-tight mb-1">
|
||||
{title}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: statusIndicator.color }}
|
||||
>
|
||||
{statusIndicator.text}
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-full text-xs font-semibold transition-all ${
|
||||
statusIndicator.isCritical
|
||||
? 'bg-red-100 text-red-800 ring-2 ring-red-300 shadow-sm animate-pulse'
|
||||
: statusIndicator.isHighlight
|
||||
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
|
||||
: 'ring-1 shadow-sm'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
: `${statusIndicator.color}20`,
|
||||
color: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
: statusIndicator.color,
|
||||
borderColor: `${statusIndicator.color}40`
|
||||
}}
|
||||
>
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="mr-2 text-sm">🚨</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="mr-1.5">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.text}
|
||||
</div>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
<div className="text-sm text-[var(--text-secondary)] truncate">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
<div className="text-right flex-shrink-0 ml-4">
|
||||
<div className="text-3xl font-bold text-[var(--text-primary)] leading-none">
|
||||
{primaryValue}
|
||||
</div>
|
||||
{primaryValueLabel && (
|
||||
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide">
|
||||
<div className="text-xs text-[var(--text-tertiary)] uppercase tracking-wide mt-1">
|
||||
{primaryValueLabel}
|
||||
</div>
|
||||
)}
|
||||
@@ -187,27 +212,26 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
|
||||
{/* Progress indicator */}
|
||||
{progress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{progress.label}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-[var(--text-primary)]">
|
||||
{progress.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(progress.percentage, 100)}%`,
|
||||
backgroundColor: progress.color || statusIndicator.color
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<ProgressBar
|
||||
value={progress.percentage}
|
||||
max={100}
|
||||
size="md"
|
||||
variant="default"
|
||||
showLabel={true}
|
||||
label={progress.label}
|
||||
animated={progress.percentage < 100}
|
||||
customColor={progress.color || statusIndicator.color}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer for alignment when no progress bar */}
|
||||
{!progress && (
|
||||
<div className="h-12" />
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{metadata.length > 0 && (
|
||||
<div className="text-xs text-[var(--text-secondary)] space-y-1">
|
||||
@@ -217,65 +241,69 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Enhanced Mobile-Responsive Actions */}
|
||||
{/* Elegant Action System */}
|
||||
{actions.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-primary)]">
|
||||
{/* Primary Actions - Full width on mobile, inline on desktop */}
|
||||
<div className="pt-5">
|
||||
{/* Primary Actions Row */}
|
||||
{primaryActions.length > 0 && (
|
||||
<div className={`
|
||||
flex gap-2 mb-2
|
||||
${primaryActions.length > 1 ? 'flex-col sm:flex-row' : ''}
|
||||
`}>
|
||||
{primaryActions.map((action, index) => (
|
||||
<Button
|
||||
key={`primary-${index}`}
|
||||
variant={action.destructive ? 'outline' : (action.variant || 'primary')}
|
||||
size="sm"
|
||||
<div className="flex gap-3 mb-3">
|
||||
<Button
|
||||
variant={primaryActions[0].destructive ? 'outline' : 'primary'}
|
||||
size="md"
|
||||
className={`
|
||||
flex-1 h-11 font-medium justify-center
|
||||
${primaryActions[0].destructive
|
||||
? 'border-red-300 text-red-600 hover:bg-red-50 hover:border-red-400'
|
||||
: 'bg-gradient-to-r from-[var(--color-primary-600)] to-[var(--color-primary-500)] hover:from-[var(--color-primary-700)] hover:to-[var(--color-primary-600)] text-white border-transparent shadow-md hover:shadow-lg'
|
||||
}
|
||||
transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98]
|
||||
`}
|
||||
onClick={primaryActions[0].onClick}
|
||||
>
|
||||
{primaryActions[0].icon && React.createElement(primaryActions[0].icon, { className: "w-4 h-4 mr-2" })}
|
||||
<span>{primaryActions[0].label}</span>
|
||||
</Button>
|
||||
|
||||
{/* Secondary Action Button */}
|
||||
{primaryActions.length > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
className="h-11 w-11 p-0 border-[var(--border-secondary)] hover:border-[var(--color-primary-400)] hover:bg-[var(--color-primary-50)] transition-all duration-200"
|
||||
onClick={primaryActions[1].onClick}
|
||||
title={primaryActions[1].label}
|
||||
>
|
||||
{primaryActions[1].icon && React.createElement(primaryActions[1].icon, { className: "w-4 h-4" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary Actions Row - Smaller buttons */}
|
||||
{secondaryActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{secondaryActions.map((action, index) => (
|
||||
<Button
|
||||
key={`secondary-${index}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`
|
||||
${primaryActions.length === 1 ? 'w-full sm:w-auto sm:min-w-[120px]' : 'flex-1'}
|
||||
${action.destructive ? 'text-red-600 border-red-300 hover:bg-red-50 hover:border-red-400' : ''}
|
||||
font-medium transition-all duration-200
|
||||
h-8 px-3 text-xs font-medium border-[var(--border-secondary)]
|
||||
${action.destructive
|
||||
? 'text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--color-primary-300)] hover:bg-[var(--color-primary-50)]'
|
||||
}
|
||||
transition-all duration-200 flex items-center gap-1.5
|
||||
`}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.icon && <action.icon className="w-4 h-4 mr-2 flex-shrink-0" />}
|
||||
<span className="truncate">{action.label}</span>
|
||||
{action.icon && React.createElement(action.icon, { className: "w-3.5 h-3.5" })}
|
||||
<span>{action.label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary Actions - Compact horizontal layout */}
|
||||
{secondaryActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{secondaryActions.map((action, index) => (
|
||||
<Button
|
||||
key={`secondary-${index}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`
|
||||
flex-shrink-0 min-w-0
|
||||
${action.destructive ? 'text-red-600 border-red-300 hover:bg-red-50 hover:border-red-400' : ''}
|
||||
${secondaryActions.length > 3 ? 'text-xs px-2' : 'text-sm px-3'}
|
||||
transition-all duration-200
|
||||
`}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.icon && <action.icon className={`
|
||||
${secondaryActions.length > 3 ? 'w-3 h-3' : 'w-4 h-4'}
|
||||
${action.label ? 'mr-1 sm:mr-2' : ''}
|
||||
flex-shrink-0
|
||||
`} />}
|
||||
<span className={`
|
||||
${secondaryActions.length > 3 ? 'hidden sm:inline' : ''}
|
||||
truncate
|
||||
`}>
|
||||
{action.label}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user