Improve the inventory page 3
This commit is contained in:
450
frontend/src/components/domain/inventory/BatchModal.tsx
Normal file
450
frontend/src/components/domain/inventory/BatchModal.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, StockResponse, StockUpdate } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { Button } from '../../ui/Button';
|
||||
|
||||
interface BatchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
ingredient: IngredientResponse;
|
||||
batches: StockResponse[];
|
||||
loading?: boolean;
|
||||
onAddBatch?: () => void;
|
||||
onEditBatch?: (batchId: string, updateData: StockUpdate) => Promise<void>;
|
||||
onMarkAsWaste?: (batchId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* BatchModal - Card-based batch management modal
|
||||
* Mobile-friendly design with edit and waste marking functionality
|
||||
*/
|
||||
export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
ingredient,
|
||||
batches = [],
|
||||
loading = false,
|
||||
onAddBatch,
|
||||
onEditBatch,
|
||||
onMarkAsWaste
|
||||
}) => {
|
||||
const [editingBatch, setEditingBatch] = useState<string | null>(null);
|
||||
const [editData, setEditData] = useState<StockUpdate>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Get batch status based on expiration and availability
|
||||
const getBatchStatus = (batch: StockResponse) => {
|
||||
if (!batch.is_available) {
|
||||
return {
|
||||
label: 'No Disponible',
|
||||
color: statusColors.cancelled.primary,
|
||||
icon: X,
|
||||
isCritical: true
|
||||
};
|
||||
}
|
||||
|
||||
if (batch.is_expired) {
|
||||
return {
|
||||
label: 'Vencido',
|
||||
color: statusColors.expired.primary,
|
||||
icon: AlertTriangle,
|
||||
isCritical: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!batch.expiration_date) {
|
||||
return {
|
||||
label: 'Sin Vencimiento',
|
||||
color: statusColors.other.primary,
|
||||
icon: Archive,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const expirationDate = new Date(batch.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.completed.primary,
|
||||
icon: CheckCircle,
|
||||
isCritical: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditStart = (batch: StockResponse) => {
|
||||
setEditingBatch(batch.id);
|
||||
setEditData({
|
||||
current_quantity: batch.current_quantity,
|
||||
expiration_date: batch.expiration_date,
|
||||
storage_location: batch.storage_location || '',
|
||||
requires_refrigeration: batch.requires_refrigeration,
|
||||
requires_freezing: batch.requires_freezing,
|
||||
storage_temperature_min: batch.storage_temperature_min,
|
||||
storage_temperature_max: batch.storage_temperature_max,
|
||||
storage_humidity_max: batch.storage_humidity_max,
|
||||
shelf_life_days: batch.shelf_life_days,
|
||||
storage_instructions: batch.storage_instructions || ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingBatch(null);
|
||||
setEditData({});
|
||||
};
|
||||
|
||||
const handleEditSave = async (batchId: string) => {
|
||||
if (!onEditBatch) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onEditBatch(batchId, editData);
|
||||
setEditingBatch(null);
|
||||
setEditData({});
|
||||
} catch (error) {
|
||||
console.error('Error updating batch:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAsWaste = async (batchId: string) => {
|
||||
if (!onMarkAsWaste) return;
|
||||
|
||||
const confirmed = window.confirm('¿Está seguro que desea marcar este lote como desperdicio? Esta acción no se puede deshacer.');
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onMarkAsWaste(batchId);
|
||||
} catch (error) {
|
||||
console.error('Error marking batch as waste:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: `${batches.length} lotes`,
|
||||
icon: Package
|
||||
};
|
||||
|
||||
// Create card-based batch list
|
||||
const batchCards = batches.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{batches.map((batch) => {
|
||||
const status = getBatchStatus(batch);
|
||||
const StatusIcon = status.icon;
|
||||
const isEditing = editingBatch === batch.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={batch.id}
|
||||
className="bg-[var(--surface-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden"
|
||||
style={{
|
||||
borderColor: status.isCritical ? `${status.color}40` : undefined,
|
||||
backgroundColor: status.isCritical ? `${status.color}05` : undefined
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border-secondary)]">
|
||||
<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: `${status.color}15` }}
|
||||
>
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-[var(--text-primary)]">
|
||||
Lote #{batch.batch_number || 'Sin número'}
|
||||
</h3>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{ color: status.color }}
|
||||
>
|
||||
{status.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditStart(batch)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{status.isCritical && batch.is_available && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMarkAsWaste(batch.id)}
|
||||
disabled={isSubmitting}
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handleEditSave(batch.id)}
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Cantidad Actual
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editData.current_quantity || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, current_quantity: Number(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-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{batch.current_quantity} {ingredient.unit_of_measure}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Valor Total
|
||||
</label>
|
||||
<div className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{formatters.currency(Number(batch.total_cost || 0))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Fecha de Vencimiento
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
value={editData.expiration_date ? new Date(editData.expiration_date).toISOString().split('T')[0] : ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, expiration_date: 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-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.expiration_date
|
||||
? new Date(batch.expiration_date).toLocaleDateString('es-ES')
|
||||
: 'Sin vencimiento'
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
||||
Ubicación
|
||||
</label>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editData.storage_location || ''}
|
||||
onChange={(e) => setEditData(prev => ({ ...prev, storage_location: e.target.value }))}
|
||||
placeholder="Ubicación del lote"
|
||||
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-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
{batch.storage_location || 'No especificada'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Requirements */}
|
||||
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Thermometer className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<span className="text-sm font-medium text-[var(--text-tertiary)]">
|
||||
Almacenamiento
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Tipo:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{batch.requires_refrigeration ? 'Refrigeración' :
|
||||
batch.requires_freezing ? 'Congelación' : 'Ambiente'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(batch.storage_temperature_min || batch.storage_temperature_max) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Temp:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{batch.storage_temperature_min || '-'}°C a {batch.storage_temperature_max || '-'}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.storage_humidity_max && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Humedad:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
≤{batch.storage_humidity_max}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batch.shelf_life_days && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-[var(--text-tertiary)]">Vida útil:</span>
|
||||
<span className="font-medium text-[var(--text-secondary)]">
|
||||
{batch.shelf_life_days} días
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{batch.storage_instructions && (
|
||||
<div className="mt-2 p-2 bg-[var(--surface-tertiary)] rounded-md">
|
||||
<div className="text-xs text-[var(--text-secondary)] italic">
|
||||
"{batch.storage_instructions}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-[var(--text-secondary)]">
|
||||
<Package className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
No hay lotes registrados
|
||||
</h3>
|
||||
<p className="text-sm mb-6">
|
||||
Los lotes se crean automáticamente al agregar stock
|
||||
</p>
|
||||
{onAddBatch && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onAddBatch}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Agregar Primer Lote
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Lotes de Stock',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: '',
|
||||
value: batchCards,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const actions = [];
|
||||
if (onAddBatch && batches.length > 0) {
|
||||
actions.push({
|
||||
label: 'Agregar Lote',
|
||||
icon: Plus,
|
||||
variant: 'primary' as const,
|
||||
onClick: onAddBatch
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
title={`Lotes de Stock: ${ingredient.name}`}
|
||||
subtitle={`${ingredient.category} • ${batches.length} lotes registrados`}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={false}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BatchModal;
|
||||
Reference in New Issue
Block a user