450 lines
16 KiB
TypeScript
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; |