1098 lines
46 KiB
TypeScript
1098 lines
46 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save, ChevronDown, ChevronUp, XCircle } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
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';
|
|
import { useSuppliers } from '../../../api/hooks/suppliers';
|
|
|
|
interface BatchModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
ingredient: IngredientResponse;
|
|
batches: StockResponse[];
|
|
loading?: boolean;
|
|
tenantId: string;
|
|
onAddBatch?: () => void;
|
|
onEditBatch?: (batchId: string, updateData: StockUpdate) => Promise<void>;
|
|
onMarkAsWaste?: (batchId: string) => Promise<void>;
|
|
// Wait-for-refetch support
|
|
waitForRefetch?: boolean;
|
|
isRefetching?: boolean;
|
|
onSaveComplete?: () => 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,
|
|
tenantId,
|
|
onAddBatch,
|
|
onEditBatch,
|
|
onMarkAsWaste,
|
|
waitForRefetch,
|
|
isRefetching,
|
|
onSaveComplete
|
|
}) => {
|
|
const { t } = useTranslation(['inventory', 'common']);
|
|
const [editingBatch, setEditingBatch] = useState<string | null>(null);
|
|
const [editData, setEditData] = useState<StockUpdate>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
|
|
|
// Collapsible state - start with all batches collapsed for better UX
|
|
const [collapsedBatches, setCollapsedBatches] = useState<Set<string>>(new Set());
|
|
|
|
// Initialize all batches as collapsed when batches change or modal opens
|
|
useEffect(() => {
|
|
if (isOpen && batches.length > 0) {
|
|
setCollapsedBatches(new Set(batches.map(b => b.id)));
|
|
}
|
|
}, [isOpen, batches]);
|
|
|
|
// Fetch suppliers for the dropdown
|
|
const { data: suppliersData } = useSuppliers(tenantId, {
|
|
limit: 100
|
|
}, {
|
|
enabled: !!tenantId && isOpen
|
|
});
|
|
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
|
|
|
// Get production stage options using direct i18n
|
|
const productionStageOptions = Object.values(ingredient.product_type === 'finished_product' ? [] : [
|
|
{ value: 'raw_ingredient', label: t(`enums.production_stage.raw_ingredient`) },
|
|
{ value: 'par_baked', label: t(`enums.production_stage.par_baked`) },
|
|
{ value: 'fully_baked', label: t(`enums.production_stage.fully_baked`) },
|
|
{ value: 'prepared_dough', label: t(`enums.production_stage.prepared_dough`) },
|
|
{ value: 'frozen_product', label: t(`enums.production_stage.frozen_product`) }
|
|
]);
|
|
|
|
// Create quality status options (matches backend: good, damaged, expired, quarantined)
|
|
const qualityStatusOptions = [
|
|
{ value: 'good', label: 'Bueno' },
|
|
{ value: 'damaged', label: 'Dañado' },
|
|
{ value: 'expired', label: 'Vencido' },
|
|
{ value: 'quarantined', label: 'En Cuarentena' }
|
|
];
|
|
|
|
// Create storage location options (predefined common locations)
|
|
const storageLocationOptions = [
|
|
{ value: '', label: 'Sin ubicación específica' },
|
|
{ value: 'estante-a1', label: 'Estante A-1' },
|
|
{ value: 'estante-a2', label: 'Estante A-2' },
|
|
{ value: 'estante-a3', label: 'Estante A-3' },
|
|
{ value: 'estante-b1', label: 'Estante B-1' },
|
|
{ value: 'estante-b2', label: 'Estante B-2' },
|
|
{ value: 'frigorifico', label: 'Frigorífico' },
|
|
{ value: 'congelador', label: 'Congelador' },
|
|
{ value: 'almacen-principal', label: 'Almacén Principal' },
|
|
{ value: 'zona-recepcion', label: 'Zona de Recepción' }
|
|
];
|
|
|
|
// Create warehouse zone options
|
|
const warehouseZoneOptions = [
|
|
{ value: '', label: 'Sin zona específica' },
|
|
{ value: 'zona-a', label: 'Zona A' },
|
|
{ value: 'zona-b', label: 'Zona B' },
|
|
{ value: 'zona-c', label: 'Zona C' },
|
|
{ value: 'refrigerado', label: 'Refrigerado' },
|
|
{ value: 'congelado', label: 'Congelado' },
|
|
{ value: 'ambiente', label: 'Temperatura Ambiente' }
|
|
];
|
|
|
|
// Create refrigeration requirement options
|
|
const refrigerationOptions = [
|
|
{ value: 'no', label: 'No requiere refrigeración' },
|
|
{ value: 'yes', label: 'Requiere refrigeración' },
|
|
{ value: 'recommended', label: 'Refrigeración recomendada' }
|
|
];
|
|
|
|
// Create freezing requirement options
|
|
const freezingOptions = [
|
|
{ value: 'no', label: 'No requiere congelación' },
|
|
{ value: 'yes', label: 'Requiere congelación' },
|
|
{ value: 'recommended', label: 'Congelación recomendada' }
|
|
];
|
|
|
|
// Helper function to get translated category display name
|
|
const getCategoryDisplayName = (category?: string | null): string => {
|
|
if (!category) return t('categories.all', 'Sin categoría');
|
|
|
|
// Try ingredient category translation first
|
|
const ingredientTranslation = t(`enums.ingredient_category.${category}`, { defaultValue: '' });
|
|
if (ingredientTranslation) return ingredientTranslation;
|
|
|
|
// Try product category translation
|
|
const productTranslation = t(`enums.product_category.${category}`, { defaultValue: '' });
|
|
if (productTranslation) return productTranslation;
|
|
|
|
// Fallback to raw category if no translation found
|
|
return category;
|
|
};
|
|
|
|
// Toggle batch collapse state
|
|
const toggleBatchCollapse = (batchId: string) => {
|
|
setCollapsedBatches(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(batchId)) {
|
|
next.delete(batchId);
|
|
} else {
|
|
next.add(batchId);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Expand all batches
|
|
const expandAll = () => {
|
|
setCollapsedBatches(new Set());
|
|
};
|
|
|
|
// Collapse all batches
|
|
const collapseAll = () => {
|
|
setCollapsedBatches(new Set(batches.map(b => b.id)));
|
|
};
|
|
|
|
// 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);
|
|
// Auto-expand when editing
|
|
setCollapsedBatches(prev => {
|
|
const next = new Set(prev);
|
|
next.delete(batch.id);
|
|
return next;
|
|
});
|
|
setEditData({
|
|
supplier_id: batch.supplier_id || '',
|
|
batch_number: batch.batch_number || '',
|
|
lot_number: batch.lot_number || '',
|
|
supplier_batch_ref: batch.supplier_batch_ref || '',
|
|
production_stage: batch.production_stage,
|
|
transformation_reference: batch.transformation_reference || '',
|
|
current_quantity: batch.current_quantity,
|
|
reserved_quantity: batch.reserved_quantity,
|
|
received_date: batch.received_date,
|
|
expiration_date: batch.expiration_date,
|
|
best_before_date: batch.best_before_date,
|
|
original_expiration_date: batch.original_expiration_date,
|
|
transformation_date: batch.transformation_date,
|
|
final_expiration_date: batch.final_expiration_date,
|
|
unit_cost: batch.unit_cost !== null ? batch.unit_cost : undefined,
|
|
storage_location: batch.storage_location || '',
|
|
warehouse_zone: batch.warehouse_zone || '',
|
|
shelf_position: batch.shelf_position || '',
|
|
is_available: batch.is_available,
|
|
quality_status: batch.quality_status,
|
|
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;
|
|
|
|
// CRITICAL: Capture editData IMMEDIATELY before any async operations
|
|
const dataToSave = { ...editData };
|
|
|
|
// Validate we have data to save
|
|
if (Object.keys(dataToSave).length === 0) {
|
|
console.error('BatchModal: No edit data to save for batch', batchId);
|
|
return;
|
|
}
|
|
|
|
console.log('BatchModal: Saving batch data:', dataToSave);
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
// Execute the update mutation
|
|
await onEditBatch(batchId, dataToSave);
|
|
|
|
// If waitForRefetch is enabled, wait for data to refresh
|
|
if (waitForRefetch && onSaveComplete) {
|
|
setIsWaitingForRefetch(true);
|
|
|
|
// Trigger the refetch
|
|
await onSaveComplete();
|
|
|
|
// Wait for isRefetching to become false (with timeout)
|
|
const startTime = Date.now();
|
|
const refetchTimeout = 3000;
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const interval = setInterval(() => {
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
if (elapsed >= refetchTimeout) {
|
|
clearInterval(interval);
|
|
console.warn('Refetch timeout reached for batch update');
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
if (!isRefetching) {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}
|
|
}, 100);
|
|
});
|
|
|
|
setIsWaitingForRefetch(false);
|
|
}
|
|
|
|
// Clear editing state after save (and optional refetch) completes
|
|
setEditingBatch(null);
|
|
setEditData({});
|
|
} catch (error) {
|
|
console.error('Error updating batch:', error);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
setIsWaitingForRefetch(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 gap-3">
|
|
{/* Left side: clickable area for collapse/expand */}
|
|
<button
|
|
onClick={() => !isEditing && toggleBatchCollapse(batch.id)}
|
|
disabled={isEditing}
|
|
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity disabled:cursor-default disabled:hover:opacity-100"
|
|
aria-expanded={!collapsedBatches.has(batch.id)}
|
|
aria-label={`${collapsedBatches.has(batch.id) ? 'Expandir' : 'Colapsar'} lote ${batch.batch_number || 'sin número'}`}
|
|
>
|
|
<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 className="flex-1 min-w-0">
|
|
<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>
|
|
{/* Inline summary when collapsed */}
|
|
{collapsedBatches.has(batch.id) && (
|
|
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
|
{batch.current_quantity} {ingredient.unit_of_measure}
|
|
{batch.expiration_date && (
|
|
<> • Vence: {new Date(batch.expiration_date).toLocaleDateString('es-ES')}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Right side: action buttons */}
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{/* Collapse/Expand chevron */}
|
|
{!isEditing && (
|
|
<button
|
|
onClick={() => toggleBatchCollapse(batch.id)}
|
|
className="p-2 rounded-md hover:bg-[var(--surface-tertiary)] transition-colors"
|
|
aria-label={collapsedBatches.has(batch.id) ? 'Expandir' : 'Colapsar'}
|
|
>
|
|
{collapsedBatches.has(batch.id) ? (
|
|
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
|
|
) : (
|
|
<ChevronUp className="w-5 h-5 text-[var(--text-secondary)]" />
|
|
)}
|
|
</button>
|
|
)}
|
|
|
|
{!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 - Only show when expanded */}
|
|
{!collapsedBatches.has(batch.id) && (
|
|
<div className="p-4 space-y-4 transition-all duration-200 ease-in-out">
|
|
{/* Quantities Section */}
|
|
<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">
|
|
Cantidad Reservada
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
value={editData.reserved_quantity || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, reserved_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-sm text-[var(--text-secondary)]">
|
|
{batch.reserved_quantity} {ingredient.unit_of_measure}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Cantidad Disponible
|
|
</label>
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{batch.available_quantity} {ingredient.unit_of_measure}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Costo Unitario
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
value={editData.unit_cost || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, unit_cost: 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-sm text-[var(--text-secondary)]">
|
|
{batch.unit_cost ? formatters.currency(Number(batch.unit_cost)) : 'N/A'}
|
|
</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>
|
|
|
|
{/* Batch Identification */}
|
|
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Número de Lote
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editData.lot_number || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, lot_number: e.target.value }))}
|
|
placeholder="Número de 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.lot_number || 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Ref. Proveedor
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editData.supplier_batch_ref || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, supplier_batch_ref: e.target.value }))}
|
|
placeholder="Ref. del proveedor"
|
|
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.supplier_batch_ref || 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Estado de Calidad
|
|
</label>
|
|
{isEditing ? (
|
|
<select
|
|
value={editData.quality_status || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, quality_status: 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"
|
|
>
|
|
{qualityStatusOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{qualityStatusOptions.find(opt => opt.value === batch.quality_status)?.label || batch.quality_status}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Supplier and 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">
|
|
Proveedor
|
|
</label>
|
|
{isEditing ? (
|
|
<select
|
|
value={editData.supplier_id || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, supplier_id: e.target.value || null }))}
|
|
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"
|
|
>
|
|
<option value="">Sin proveedor</option>
|
|
{suppliers.map(supplier => (
|
|
<option key={supplier.id} value={supplier.id}>
|
|
{supplier.name} {supplier.supplier_code ? `(${supplier.supplier_code})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{batch.supplier_id
|
|
? suppliers.find(s => s.id === batch.supplier_id)?.name || 'Proveedor desconocido'
|
|
: 'Sin proveedor'
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Fecha de Recepción
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="date"
|
|
value={editData.received_date ? new Date(editData.received_date).toISOString().split('T')[0] : ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, received_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.received_date
|
|
? new Date(batch.received_date).toLocaleDateString('es-ES')
|
|
: 'N/A'
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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">
|
|
Mejor Antes De
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="date"
|
|
value={editData.best_before_date ? new Date(editData.best_before_date).toISOString().split('T')[0] : ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, best_before_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.best_before_date
|
|
? new Date(batch.best_before_date).toLocaleDateString('es-ES')
|
|
: 'N/A'
|
|
}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Storage Locations */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Ubicación de Almacenamiento
|
|
</label>
|
|
{isEditing ? (
|
|
<select
|
|
value={editData.storage_location || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, storage_location: 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"
|
|
>
|
|
{storageLocationOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{storageLocationOptions.find(opt => opt.value === batch.storage_location)?.label || batch.storage_location || 'No especificada'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Zona de Almacén
|
|
</label>
|
|
{isEditing ? (
|
|
<select
|
|
value={editData.warehouse_zone || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, warehouse_zone: 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"
|
|
>
|
|
{warehouseZoneOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{warehouseZoneOptions.find(opt => opt.value === batch.warehouse_zone)?.label || batch.warehouse_zone || 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Posición en Estantería
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
value={editData.shelf_position || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, shelf_position: e.target.value }))}
|
|
placeholder="Posición"
|
|
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.shelf_position || 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Production Stage Information */}
|
|
{(batch.production_stage !== 'raw_ingredient' || batch.transformation_reference) && (
|
|
<div className="pt-3 border-t border-[var(--border-secondary)]">
|
|
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
|
|
Información de Transformación
|
|
</div>
|
|
<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">
|
|
Etapa de Producción
|
|
</label>
|
|
{isEditing && productionStageOptions.length > 0 ? (
|
|
<select
|
|
value={(editData.production_stage || batch.production_stage) as string}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, production_stage: e.target.value as any }))}
|
|
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"
|
|
>
|
|
{productionStageOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{t(`enums.production_stage.${batch.production_stage}`, { defaultValue: batch.production_stage })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{batch.transformation_reference && (
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Referencia de Transformación
|
|
</label>
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{batch.transformation_reference}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{batch.original_expiration_date && (
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Vencimiento Original
|
|
</label>
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{new Date(batch.original_expiration_date).toLocaleDateString('es-ES')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{batch.transformation_date && (
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Fecha de Transformación
|
|
</label>
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{new Date(batch.transformation_date).toLocaleDateString('es-ES')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{batch.final_expiration_date && (
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Vencimiento Final
|
|
</label>
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{new Date(batch.final_expiration_date).toLocaleDateString('es-ES')}
|
|
</div>
|
|
</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)]">
|
|
Requisitos de Almacenamiento
|
|
</span>
|
|
</div>
|
|
|
|
<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">
|
|
Requiere Refrigeración
|
|
</label>
|
|
{isEditing ? (
|
|
<select
|
|
value={editData.requires_refrigeration ? 'yes' : 'no'}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, requires_refrigeration: e.target.value === 'yes' }))}
|
|
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"
|
|
>
|
|
{refrigerationOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{batch.requires_refrigeration ? 'Sí' : 'No'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Requiere Congelación
|
|
</label>
|
|
{isEditing ? (
|
|
<select
|
|
value={editData.requires_freezing ? 'yes' : 'no'}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, requires_freezing: e.target.value === 'yes' }))}
|
|
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"
|
|
>
|
|
{freezingOptions.map(option => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
{batch.requires_freezing ? 'Sí' : 'No'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Vida Útil (días)
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
value={editData.shelf_life_days || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, shelf_life_days: Number(e.target.value) || null }))}
|
|
placeholder="Días"
|
|
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.shelf_life_days ? `${batch.shelf_life_days} días` : 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Temperatura Mínima (°C)
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={editData.storage_temperature_min || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, storage_temperature_min: Number(e.target.value) || null }))}
|
|
placeholder="°C"
|
|
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_temperature_min !== null ? `${batch.storage_temperature_min}°C` : 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Temperatura Máxima (°C)
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={editData.storage_temperature_max || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, storage_temperature_max: Number(e.target.value) || null }))}
|
|
placeholder="°C"
|
|
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_temperature_max !== null ? `${batch.storage_temperature_max}°C` : 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Humedad Máxima (%)
|
|
</label>
|
|
{isEditing ? (
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
value={editData.storage_humidity_max || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, storage_humidity_max: Number(e.target.value) || null }))}
|
|
placeholder="%"
|
|
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_humidity_max !== null ? `≤${batch.storage_humidity_max}%` : 'N/A'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
|
|
Instrucciones de Almacenamiento
|
|
</label>
|
|
{isEditing ? (
|
|
<textarea
|
|
value={editData.storage_instructions || ''}
|
|
onChange={(e) => setEditData(prev => ({ ...prev, storage_instructions: e.target.value }))}
|
|
placeholder="Instrucciones especiales..."
|
|
rows={2}
|
|
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"
|
|
/>
|
|
) : (
|
|
batch.storage_instructions ? (
|
|
<div className="p-2 bg-[var(--surface-tertiary)] rounded-md">
|
|
<div className="text-xs text-[var(--text-secondary)] italic">
|
|
"{batch.storage_instructions}"
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-[var(--text-secondary)]">
|
|
Sin instrucciones especiales
|
|
</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 = [];
|
|
|
|
// Only show "Agregar Lote" button
|
|
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={ingredient.name}
|
|
subtitle={`${getCategoryDisplayName(ingredient.category)} • ${batches.length} lotes`}
|
|
statusIndicator={statusConfig}
|
|
sections={sections}
|
|
size="lg"
|
|
loading={loading || isSubmitting || isWaitingForRefetch}
|
|
showDefaultActions={false}
|
|
actions={actions}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default BatchModal; |