Files
bakery-ia/frontend/src/components/domain/inventory/BatchModal.tsx
2025-09-26 07:46:25 +02:00

450 lines
16 KiB
TypeScript

import React, { useState } from 'react';
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
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 (
<EditViewModal
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;