Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -13,6 +13,10 @@ interface AddStockModalProps {
onClose: () => void;
ingredient: IngredientResponse;
onAddStock?: (stockData: StockCreate) => Promise<void>;
// Wait-for-refetch support
waitForRefetch?: boolean;
isRefetching?: boolean;
onSaveComplete?: () => Promise<void>;
}
/**
@@ -23,7 +27,10 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
isOpen,
onClose,
ingredient,
onAddStock
onAddStock,
waitForRefetch,
isRefetching,
onSaveComplete
}) => {
const [formData, setFormData] = useState<Partial<StockCreate>>({
ingredient_id: ingredient.id,
@@ -66,7 +73,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
// Get production stage options using direct i18n
const productionStageOptions = Object.values(ProductionStage).map(value => ({
value,
label: t(`inventory:production_stage.${value}`)
label: t(`inventory:enums.production_stage.${value}`)
}));
// Create supplier options for select
@@ -78,12 +85,12 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
}))
];
// Create quality status options
// 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: 'returned', label: 'Devuelto' }
{ value: 'quarantined', label: 'En Cuarentena' }
];
// Create storage location options (predefined common locations)
@@ -182,6 +189,33 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
await onAddStock(stockData);
}
// If waitForRefetch is enabled, trigger refetch and wait
if (waitForRefetch && onSaveComplete) {
await onSaveComplete();
// Wait for refetch to complete
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 stock addition');
resolve();
return;
}
if (!isRefetching) {
clearInterval(interval);
resolve();
}
}, 100);
});
}
// Reset form
setFormData({
ingredient_id: ingredient.id,
@@ -393,8 +427,8 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
onClose={onClose}
mode={mode}
onModeChange={setMode}
title={`Agregar Stock: ${ingredient.name}`}
subtitle={`${ingredient.category} Stock actual: ${currentStock} ${ingredient.unit_of_measure}`}
title={ingredient.name}
subtitle={`${ingredient.category}${currentStock} ${ingredient.unit_of_measure}`}
statusIndicator={statusConfig}
sections={sections}
actions={actions}

View File

@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
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;
@@ -12,9 +14,14 @@ interface BatchModalProps {
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>;
}
/**
@@ -27,13 +34,132 @@ export const BatchModal: React.FC<BatchModalProps> = ({
ingredient,
batches = [],
loading = false,
tenantId,
onAddBatch,
onEditBatch,
onMarkAsWaste
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) => {
@@ -101,10 +227,33 @@ export const BatchModal: React.FC<BatchModalProps> = ({
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,
@@ -123,15 +272,62 @@ export const BatchModal: React.FC<BatchModalProps> = ({
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 {
await onEditBatch(batchId, editData);
// 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);
}
};
@@ -176,8 +372,15 @@ export const BatchModal: React.FC<BatchModalProps> = ({
>
{/* 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 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` }}
@@ -187,7 +390,7 @@ export const BatchModal: React.FC<BatchModalProps> = ({
style={{ color: status.color }}
/>
</div>
<div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-[var(--text-primary)]">
Lote #{batch.batch_number || 'Sin número'}
</h3>
@@ -197,10 +400,35 @@ export const BatchModal: React.FC<BatchModalProps> = ({
>
{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>
</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>
)}
<div className="flex items-center gap-2">
{!isEditing && (
<>
<Button
@@ -250,9 +478,10 @@ export const BatchModal: React.FC<BatchModalProps> = ({
</div>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Basic Info */}
{/* 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">
@@ -272,6 +501,52 @@ export const BatchModal: React.FC<BatchModalProps> = ({
)}
</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
@@ -282,8 +557,122 @@ export const BatchModal: React.FC<BatchModalProps> = ({
</div>
</div>
{/* Dates */}
{/* 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
@@ -307,79 +696,336 @@ export const BatchModal: React.FC<BatchModalProps> = ({
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Ubicación
Mejor Antes De
</label>
{isEditing ? (
<input
type="text"
value={editData.storage_location || ''}
onChange={(e) => setEditData(prev => ({ ...prev, storage_location: e.target.value }))}
placeholder="Ubicación del lote"
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.storage_location || 'No especificada'}
{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)]">
Almacenamiento
Requisitos de 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 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>
{(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>
)}
<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>
{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>
)}
<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>
{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>
<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>
{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 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>
);
})}
@@ -421,6 +1067,8 @@ export const BatchModal: React.FC<BatchModalProps> = ({
];
const actions = [];
// Only show "Agregar Lote" button
if (onAddBatch && batches.length > 0) {
actions.push({
label: 'Agregar Lote',
@@ -435,12 +1083,12 @@ export const BatchModal: React.FC<BatchModalProps> = ({
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Lotes de Stock: ${ingredient.name}`}
subtitle={`${ingredient.category}${batches.length} lotes registrados`}
title={ingredient.name}
subtitle={`${getCategoryDisplayName(ingredient.category)}${batches.length} lotes`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading}
loading={loading || isSubmitting || isWaitingForRefetch}
showDefaultActions={false}
actions={actions}
/>

View File

@@ -26,12 +26,12 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
// Get enum options using direct i18n implementation
const ingredientCategoryOptions = Object.values(IngredientCategory).map(value => ({
value,
label: t(`inventory:ingredient_category.${value}`)
label: t(`inventory:enums.ingredient_category.${value}`)
})).sort((a, b) => a.label.localeCompare(b.label));
const productCategoryOptions = Object.values(ProductCategory).map(value => ({
value,
label: t(`inventory:product_category.${value}`)
label: t(`inventory:enums.product_category.${value}`)
}));
const categoryOptions = [
@@ -41,22 +41,27 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
const unitOptions = Object.values(UnitOfMeasure).map(value => ({
value,
label: t(`inventory:unit_of_measure.${value}`)
label: t(`inventory:enums.unit_of_measure.${value}`)
}));
const handleSave = async (formData: Record<string, any>) => {
// Transform form data to IngredientCreate format
const ingredientData: IngredientCreate = {
name: formData.name,
sku: formData.sku || null,
barcode: formData.barcode || null,
brand: formData.brand || null,
description: formData.description || '',
category: formData.category,
unit_of_measure: formData.unit_of_measure,
package_size: formData.package_size ? Number(formData.package_size) : null,
standard_cost: formData.standard_cost ? Number(formData.standard_cost) : null,
low_stock_threshold: Number(formData.low_stock_threshold),
reorder_point: Number(formData.reorder_point),
max_stock_level: Number(formData.max_stock_level),
is_seasonal: false,
average_cost: Number(formData.average_cost) || 0,
notes: formData.notes || ''
reorder_quantity: Number(formData.reorder_quantity),
max_stock_level: formData.max_stock_level ? Number(formData.max_stock_level) : null,
shelf_life_days: formData.shelf_life_days ? Number(formData.shelf_life_days) : null,
is_perishable: formData.is_perishable === 'true' || formData.is_perishable === true
};
setLoading(true);
@@ -90,53 +95,103 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
name: 'name',
type: 'text' as const,
required: true,
placeholder: 'Ej: Harina de trigo 000',
placeholder: t('inventory:validation.name_required', 'Ej: Harina de trigo 000'),
validation: (value: string | number) => {
const str = String(value).trim();
return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null;
return str.length < 2 ? t('inventory:validation.name_required', 'El nombre debe tener al menos 2 caracteres') : null;
}
},
{
label: t('inventory:fields.sku', 'Código SKU'),
name: 'sku',
type: 'text' as const,
placeholder: 'SKU-001'
},
{
label: t('inventory:fields.barcode', 'Código de Barras'),
name: 'barcode',
type: 'text' as const,
placeholder: '1234567890123'
},
{
label: t('inventory:fields.brand', 'Marca'),
name: 'brand',
type: 'text' as const,
placeholder: 'Molinos'
},
{
label: t('inventory:fields.description', 'Descripción'),
name: 'description',
type: 'text' as const,
placeholder: 'Descripción opcional del artículo'
placeholder: t('inventory:fields.description', 'Descripción opcional del artículo'),
span: 2 // Full width
},
{
label: 'Categoría',
label: t('inventory:labels.ingredient_category', 'Categoría'),
name: 'category',
type: 'select' as const,
required: true,
options: categoryOptions,
placeholder: 'Seleccionar categoría...'
placeholder: t('inventory:validation.category_required', 'Seleccionar categoría...')
},
{
label: 'Unidad de Medida',
label: t('inventory:labels.unit_of_measure', 'Unidad de Medida'),
name: 'unit_of_measure',
type: 'select' as const,
required: true,
options: unitOptions,
defaultValue: 'kg'
},
{
label: t('inventory:fields.package_size', 'Tamaño de Paquete'),
name: 'package_size',
type: 'number' as const,
placeholder: '1',
helpText: 'Tamaño por paquete/unidad'
},
{
label: t('inventory:fields.shelf_life_days', 'Días de Vida Útil'),
name: 'shelf_life_days',
type: 'number' as const,
placeholder: '30',
helpText: 'Vida útil predeterminada en días'
},
{
label: t('inventory:fields.is_perishable', '¿Es Perecedero?'),
name: 'is_perishable',
type: 'select' as const,
options: [
{ value: 'false', label: 'No' },
{ value: 'true', label: 'Sí' }
],
defaultValue: 'false',
span: 2 // Full width
}
]
},
{
title: 'Costos y Cantidades',
title: t('inventory:sections.purchase_costs', 'Costos de Compra'),
icon: Calculator,
fields: [
{
label: 'Costo Promedio',
name: 'average_cost',
label: t('inventory:fields.standard_cost', 'Costo Estándar'),
name: 'standard_cost',
type: 'currency' as const,
placeholder: '0.00',
defaultValue: 0,
helpText: t('inventory:help.standard_cost', 'Costo objetivo para presupuesto y análisis de variación'),
validation: (value: string | number) => {
const num = Number(value);
return num < 0 ? 'El costo no puede ser negativo' : null;
return num < 0 ? t('inventory:validation.current_cannot_be_negative', 'El costo no puede ser negativo') : null;
}
},
}
]
},
{
title: t('inventory:sections.stock_management', 'Gestión de Stock'),
icon: Settings,
fields: [
{
label: 'Umbral Stock Bajo',
label: t('inventory:fields.low_stock_threshold', 'Umbral Stock Bajo'),
name: 'low_stock_threshold',
type: 'number' as const,
required: true,
@@ -144,11 +199,11 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
defaultValue: 10,
validation: (value: string | number) => {
const num = Number(value);
return num < 0 ? 'El umbral debe ser un número positivo' : null;
return num < 0 ? t('inventory:validation.min_greater_than_zero', 'El umbral debe ser un número positivo') : null;
}
},
{
label: 'Punto de Reorden',
label: t('inventory:fields.reorder_point', 'Punto de Reorden'),
name: 'reorder_point',
type: 'number' as const,
required: true,
@@ -156,43 +211,41 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
defaultValue: 20,
validation: (value: string | number) => {
const num = Number(value);
return num < 0 ? 'El punto de reorden debe ser un número positivo' : null;
return num < 0 ? t('inventory:validation.min_greater_than_zero', 'El punto de reorden debe ser un número positivo') : null;
}
},
{
label: 'Stock Máximo',
label: t('inventory:fields.reorder_quantity', 'Cantidad de Reorden'),
name: 'reorder_quantity',
type: 'number' as const,
required: true,
placeholder: '50',
defaultValue: 50,
validation: (value: string | number) => {
const num = Number(value);
return num <= 0 ? t('inventory:validation.min_greater_than_zero', 'La cantidad de reorden debe ser mayor a cero') : null;
}
},
{
label: t('inventory:fields.max_stock_level', 'Stock Máximo'),
name: 'max_stock_level',
type: 'number' as const,
placeholder: '100',
defaultValue: 100,
validation: (value: string | number) => {
const num = Number(value);
return num < 0 ? 'El stock máximo debe ser un número positivo' : null;
return num < 0 ? t('inventory:validation.min_greater_than_zero', 'El stock máximo debe ser un número positivo') : null;
}
}
]
},
{
title: 'Información Adicional',
icon: Settings,
fields: [
{
label: 'Notas',
name: 'notes',
type: 'textarea' as const,
placeholder: 'Notas adicionales',
span: 2 // Full width
}
]
}
];
return (
<AddModal
isOpen={isOpen}
onClose={onClose}
title="Crear Nuevo Artículo"
subtitle="Agregar un nuevo artículo al inventario"
title={t('inventory:forms.add_item', 'Crear Nuevo Artículo')}
subtitle={t('inventory:subtitle', 'Agregar un nuevo artículo al inventario')}
statusIndicator={statusConfig}
sections={sections}
size="lg"

View File

@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
import { IngredientResponse } from '../../../api/types/inventory';
import { IngredientResponse, IngredientCategory, ProductCategory, ProductType, UnitOfMeasure } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
@@ -10,6 +11,10 @@ interface ShowInfoModalProps {
onClose: () => void;
ingredient: IngredientResponse;
onSave?: (updatedData: Partial<IngredientResponse>) => Promise<void>;
// Wait-for-refetch support
waitForRefetch?: boolean;
isRefetching?: boolean;
onSaveComplete?: () => Promise<void>;
}
/**
@@ -21,8 +26,12 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
isOpen,
onClose,
ingredient,
onSave
onSave,
waitForRefetch,
isRefetching,
onSaveComplete
}) => {
const { t } = useTranslation(['inventory', 'common']);
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<IngredientResponse>>({});
@@ -38,29 +47,97 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
const handleSave = async () => {
if (onSave) {
await onSave(editData);
setIsEditing(false);
setEditData({});
// CRITICAL: Capture editData IMMEDIATELY before any async operations
// This prevents race conditions where editData might be cleared by React state updates
const dataToSave = { ...editData };
// Validate we have data to save
if (Object.keys(dataToSave).length === 0) {
console.error('ShowInfoModal: No edit data to save');
return;
}
console.log('ShowInfoModal: Saving data:', dataToSave);
await onSave(dataToSave);
// Note: Don't clear edit state here - let EditViewModal handle mode switching
// after refetch completes if waitForRefetch is enabled
if (!waitForRefetch) {
setIsEditing(false);
setEditData({});
}
}
};
// Reset edit state when mode changes to view (after refetch completes)
React.useEffect(() => {
if (!isEditing) {
setEditData({});
}
}, [isEditing]);
// Status configuration based on item status (not stock)
const statusConfig = {
color: ingredient.is_active ? statusColors.normal.primary : statusColors.cancelled.primary,
text: ingredient.is_active ? 'Activo' : 'Inactivo',
text: ingredient.is_active ? t('common:status.active') : t('common:status.inactive'),
icon: ingredient.is_active ? CheckCircle : AlertTriangle,
isCritical: !ingredient.is_active
};
const currentData = isEditing ? editData : ingredient;
// Helper function to translate enum values
const translateEnum = (enumType: string, value: string | undefined) => {
if (!value) return '';
return t(`enums.${enumType}.${value}`, { defaultValue: value });
};
// Helper to get translated category (falls back to common if not in inventory)
const getTranslatedCategory = (category: string | undefined) => {
if (!category) return '';
// Try inventory namespace first, then common namespace
const translated = t(`enums.ingredient_category.${category}`, { defaultValue: '' });
return translated || t(`common:categories.${category}`, { defaultValue: category });
};
// Get category options (combining ingredient and product categories)
const getCategoryOptions = () => {
const ingredientCategories = Object.values(IngredientCategory).map(value => ({
value,
label: t(`enums.ingredient_category.${value}`)
}));
const productCategories = Object.values(ProductCategory).map(value => ({
value,
label: t(`enums.product_category.${value}`)
}));
return [...ingredientCategories, ...productCategories].sort((a, b) => a.label.localeCompare(b.label));
};
// Get product type options
const getProductTypeOptions = () => {
return Object.values(ProductType).map(value => ({
value,
label: t(`enums.product_type.${value}`)
}));
};
// Get unit of measure options
const getUnitOfMeasureOptions = () => {
return Object.values(UnitOfMeasure).map(value => ({
value,
label: t(`enums.unit_of_measure.${value}`)
}));
};
const sections = [
{
title: 'Información Básica',
title: t('fields.basic_info', { defaultValue: 'Información Básica' }),
icon: Info,
fields: [
{
label: 'Nombre',
label: t('common:fields.name'),
value: currentData.name || '',
highlight: true,
span: 2 as const,
@@ -68,92 +145,99 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
required: true
},
{
label: 'Descripción',
label: t('common:fields.description'),
value: currentData.description || '',
span: 2 as const,
editable: true,
placeholder: 'Descripción del producto'
placeholder: t('common:fields.description')
},
{
label: 'Categoría',
value: currentData.category || '',
label: t('labels.ingredient_category'),
value: isEditing ? currentData.category || '' : getTranslatedCategory(currentData.category),
span: 1 as const,
editable: true,
required: true
required: true,
type: 'select' as const,
options: getCategoryOptions(),
placeholder: t('labels.ingredient_category')
},
{
label: 'Subcategoría',
label: t('fields.subcategory', { defaultValue: 'Subcategoría' }),
value: currentData.subcategory || '',
span: 1 as const,
editable: true,
placeholder: 'Subcategoría'
placeholder: t('fields.subcategory', { defaultValue: 'Subcategoría' })
},
{
label: 'Marca',
label: t('fields.brand', { defaultValue: 'Marca' }),
value: currentData.brand || '',
span: 1 as const,
editable: true,
placeholder: 'Marca del producto'
placeholder: t('fields.brand', { defaultValue: 'Marca del producto' })
},
{
label: 'Tipo de Producto',
value: currentData.product_type || '',
label: t('labels.product_type'),
value: isEditing ? currentData.product_type || '' : translateEnum('product_type', currentData.product_type),
span: 1 as const,
editable: true,
placeholder: 'Tipo de producto'
type: 'select' as const,
options: getProductTypeOptions(),
placeholder: t('labels.product_type')
}
]
},
{
title: 'Especificaciones',
title: t('fields.specifications', { defaultValue: 'Especificaciones' }),
icon: Package,
fields: [
{
label: 'Unidad de Medida',
value: currentData.unit_of_measure || '',
label: t('labels.unit_of_measure'),
value: isEditing ? currentData.unit_of_measure || '' : translateEnum('unit_of_measure', currentData.unit_of_measure),
span: 1 as const,
editable: true,
required: true,
placeholder: 'kg, litros, unidades, etc.'
type: 'select' as const,
options: getUnitOfMeasureOptions(),
placeholder: t('labels.unit_of_measure')
},
{
label: 'Tamaño del Paquete',
label: t('fields.package_size', { defaultValue: 'Tamaño del Paquete' }),
value: currentData.package_size || '',
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Tamaño del paquete'
placeholder: t('fields.package_size', { defaultValue: 'Tamaño del paquete' })
},
{
label: 'Es Perecedero',
value: currentData.is_perishable ? 'Sí' : 'No',
label: t('fields.is_perishable', { defaultValue: 'Es Perecedero' }),
value: isEditing ? String(currentData.is_perishable) : (currentData.is_perishable ? t('common:modals.actions.yes') : t('common:modals.actions.no')),
span: 1 as const,
editable: true,
type: 'select' as const,
options: [
{ label: 'Sí', value: 'true' },
{ label: 'No', value: 'false' }
{ label: t('common:modals.actions.yes'), value: 'true' },
{ label: t('common:modals.actions.no'), value: 'false' }
]
},
{
label: 'Es de Temporada',
value: currentData.is_seasonal ? 'Sí' : 'No',
label: t('fields.is_seasonal', { defaultValue: 'Es de Temporada' }),
value: isEditing ? String(currentData.is_seasonal) : (currentData.is_seasonal ? t('common:modals.actions.yes') : t('common:modals.actions.no')),
span: 1 as const,
editable: true,
type: 'select' as const,
options: [
{ label: 'Sí', value: 'true' },
{ label: 'No', value: 'false' }
{ label: t('common:modals.actions.yes'), value: 'true' },
{ label: t('common:modals.actions.no'), value: 'false' }
]
}
]
},
{
title: 'Costos y Precios',
title: t('fields.costs_and_pricing', { defaultValue: 'Costos y Precios' }),
icon: Euro,
fields: [
{
label: 'Costo Promedio',
label: t('fields.average_cost', { defaultValue: 'Costo Promedio' }),
value: Number(currentData.average_cost) || 0,
type: 'currency' as const,
span: 1 as const,
@@ -161,7 +245,7 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
placeholder: '0.00'
},
{
label: 'Último Precio de Compra',
label: t('fields.last_purchase_price', { defaultValue: 'Último Precio de Compra' }),
value: Number(currentData.last_purchase_price) || 0,
type: 'currency' as const,
span: 1 as const,
@@ -169,7 +253,7 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
placeholder: '0.00'
},
{
label: 'Costo Estándar',
label: t('fields.standard_cost', { defaultValue: 'Costo Estándar' }),
value: Number(currentData.standard_cost) || 0,
type: 'currency' as const,
span: 2 as const,
@@ -179,70 +263,47 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
]
},
{
title: 'Parámetros de Inventario',
title: t('fields.inventory_parameters', { defaultValue: 'Parámetros de Inventario' }),
icon: TrendingUp,
fields: [
{
label: 'Umbral Stock Bajo',
label: t('fields.low_stock_threshold', { defaultValue: 'Umbral Stock Bajo' }),
value: currentData.low_stock_threshold || 0,
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Cantidad mínima antes de alerta'
placeholder: t('fields.low_stock_threshold', { defaultValue: 'Cantidad mínima antes de alerta' })
},
{
label: 'Punto de Reorden',
label: t('fields.reorder_point', { defaultValue: 'Punto de Reorden' }),
value: currentData.reorder_point || 0,
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Punto para reordenar'
placeholder: t('fields.reorder_point', { defaultValue: 'Punto para reordenar' })
},
{
label: 'Cantidad de Reorden',
label: t('fields.reorder_quantity', { defaultValue: 'Cantidad de Reorden' }),
value: currentData.reorder_quantity || 0,
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Cantidad a reordenar'
placeholder: t('fields.reorder_quantity', { defaultValue: 'Cantidad a reordenar' })
},
{
label: 'Stock Máximo',
label: t('fields.max_stock_level', { defaultValue: 'Stock Máximo' }),
value: currentData.max_stock_level || '',
span: 1 as const,
editable: true,
type: 'number' as const,
placeholder: 'Cantidad máxima permitida'
placeholder: t('fields.max_stock_level', { defaultValue: 'Cantidad máxima permitida' })
}
]
}
];
// Actions based on edit mode
const actions = [];
if (isEditing) {
actions.push(
{
label: 'Cancelar',
icon: X,
variant: 'outline' as const,
onClick: handleCancel
},
{
label: 'Guardar',
icon: Save,
variant: 'primary' as const,
onClick: handleSave
}
);
} else if (onSave) {
actions.push({
label: 'Editar',
icon: Edit,
variant: 'primary' as const,
onClick: handleEdit
});
}
// Note: We'll let EditViewModal handle default actions (Edit/Save/Cancel)
// by setting showDefaultActions=true instead of providing custom actions
// Handle field changes
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
@@ -263,15 +324,14 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex];
if (!fieldName) return;
let processedValue = value;
let processedValue: string | number | boolean = value;
// Handle boolean fields
if (fieldName === 'is_perishable' || fieldName === 'is_seasonal') {
processedValue = value === 'true';
}
// Handle numeric fields
if (fieldName === 'package_size' || fieldName.includes('cost') || fieldName.includes('price') ||
else if (fieldName === 'package_size' || fieldName.includes('cost') || fieldName.includes('price') ||
fieldName.includes('threshold') || fieldName.includes('point') || fieldName.includes('quantity') ||
fieldName.includes('level')) {
processedValue = Number(value) || 0;
@@ -288,14 +348,25 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
isOpen={isOpen}
onClose={onClose}
mode={isEditing ? "edit" : "view"}
title={`${isEditing ? 'Editar' : 'Detalles'}: ${ingredient.name}`}
subtitle={`${ingredient.category} • Información del artículo`}
onModeChange={(mode) => {
const isEditMode = mode === 'edit';
setIsEditing(isEditMode);
if (isEditMode) {
setEditData(ingredient); // Populate editData when entering edit mode
}
}}
title={ingredient.name}
subtitle={getTranslatedCategory(ingredient.category)}
statusIndicator={statusConfig}
sections={sections}
size="lg"
showDefaultActions={false}
actions={actions}
showDefaultActions={true}
onFieldChange={handleFieldChange}
onSave={handleSave}
onCancel={handleCancel}
waitForRefetch={waitForRefetch}
isRefetching={isRefetching}
onSaveComplete={onSaveComplete}
/>
);
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Clock, TrendingDown, Package, AlertCircle, RotateCcw, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
import { formatters } from '../../ui/Stats/StatsPresets';
@@ -24,10 +25,55 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
movements = [],
loading = false
}) => {
const { t } = useTranslation(['inventory', 'common']);
// 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;
};
// Get movement type display info
const getMovementTypeInfo = (type: string, quantity: number) => {
const isPositive = quantity > 0;
const absQuantity = Math.abs(quantity);
// Determine if movement should be positive or negative based on type
// Some movement types have fixed direction regardless of quantity sign
let isPositive: boolean;
let displayQuantity: string;
switch (type) {
case 'PURCHASE':
case 'INITIAL_STOCK':
isPositive = true;
displayQuantity = `+${absQuantity}`;
break;
case 'PRODUCTION_USE':
case 'WASTE':
isPositive = false;
displayQuantity = `-${absQuantity}`;
break;
case 'ADJUSTMENT':
case 'TRANSFORMATION':
// For these types, follow the actual quantity direction
isPositive = quantity > 0;
displayQuantity = quantity > 0 ? `+${absQuantity}` : `-${absQuantity}`;
break;
default:
// For any other types, follow the quantity direction
isPositive = quantity > 0;
displayQuantity = quantity > 0 ? `+${absQuantity}` : `-${absQuantity}`;
}
switch (type) {
case 'PURCHASE':
@@ -36,7 +82,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
icon: Package,
color: statusColors.completed.primary,
isPositive: true,
quantity: `+${absQuantity}`
quantity: displayQuantity
};
case 'PRODUCTION_USE':
return {
@@ -44,7 +90,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
icon: TrendingDown,
color: statusColors.pending.primary,
isPositive: false,
quantity: `-${absQuantity}`
quantity: displayQuantity
};
case 'ADJUSTMENT':
return {
@@ -52,7 +98,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
icon: AlertCircle,
color: statusColors.inProgress.primary,
isPositive,
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
quantity: displayQuantity
};
case 'WASTE':
return {
@@ -60,7 +106,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
icon: X,
color: statusColors.out.primary,
isPositive: false,
quantity: `-${absQuantity}`
quantity: displayQuantity
};
case 'TRANSFORMATION':
return {
@@ -68,7 +114,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
icon: RotateCcw,
color: statusColors.low.primary,
isPositive,
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
quantity: displayQuantity
};
case 'INITIAL_STOCK':
return {
@@ -76,15 +122,15 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
icon: Package,
color: statusColors.normal.primary,
isPositive: true,
quantity: `+${absQuantity}`
quantity: displayQuantity
};
default:
return {
type: 'Otro',
type: t('enums.stock_movement_type.OTHER', 'Otro'),
icon: Package,
color: statusColors.other.primary,
isPositive,
quantity: isPositive ? `+${absQuantity}` : `-${absQuantity}`
quantity: displayQuantity
};
}
};
@@ -218,8 +264,8 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Historial de Stock: ${ingredient.name}`}
subtitle={`${ingredient.category}${movements.length} movimientos registrados`}
title={ingredient.name}
subtitle={`${getCategoryDisplayName(ingredient.category)}${movements.length} movimientos`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
@@ -229,4 +275,4 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
);
};
export default StockHistoryModal;
export default StockHistoryModal;