Improve the frontend modals
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react';
|
||||
import { EditViewModal, StatusModalSection } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { Equipment } from '../../../api/types/equipment';
|
||||
|
||||
interface EquipmentModalProps {
|
||||
@@ -50,10 +50,18 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
} as Equipment
|
||||
);
|
||||
|
||||
// Sync equipment state with initialEquipment prop changes
|
||||
React.useEffect(() => {
|
||||
if (initialEquipment) {
|
||||
setEquipment(initialEquipment);
|
||||
}
|
||||
}, [initialEquipment]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (equipment) {
|
||||
await onSave(equipment);
|
||||
setCurrentMode('view');
|
||||
// Note: Don't manually switch mode here - EditViewModal will handle it
|
||||
// if waitForRefetch is enabled
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,7 +121,7 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getSections = (): StatusModalSection[] => {
|
||||
const getSections = (): EditViewModalSection[] => {
|
||||
if (!equipment) return [];
|
||||
|
||||
const equipmentTypes = [
|
||||
@@ -346,8 +354,8 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
}}
|
||||
mode={currentMode}
|
||||
onModeChange={setCurrentMode}
|
||||
title={isCreating ? t('actions.add_equipment') : equipment?.name || t('common:forms.untitled')}
|
||||
subtitle={isCreating ? t('sections.create_equipment_subtitle') : `${equipment?.model || ''} - ${equipment?.serialNumber || ''}`}
|
||||
title={equipment?.name || t('common:forms.untitled')}
|
||||
subtitle={equipment?.model && equipment?.serialNumber ? `${equipment.model} • ${equipment.serialNumber}` : equipment?.model || equipment?.serialNumber || undefined}
|
||||
statusIndicator={getEquipmentStatusConfig()}
|
||||
size="lg"
|
||||
sections={getSections()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -326,8 +326,8 @@ export const CreatePOSConfigModal: React.FC<CreatePOSConfigModalProps> = ({
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={mode === 'edit' ? 'Editar Sistema POS' : 'Agregar Sistema POS'}
|
||||
subtitle={mode === 'edit' ? 'Actualiza la configuración del sistema POS' : 'Configura un nuevo sistema POS para sincronizar ventas e inventario'}
|
||||
title="Configuración de Sistema POS"
|
||||
subtitle={mode === 'edit' ? 'Actualiza la configuración' : 'Configura un nuevo sistema para sincronizar ventas e inventario'}
|
||||
statusIndicator={{
|
||||
color: statusColors.inProgress.primary,
|
||||
text: mode === 'edit' ? 'Edición' : 'Nueva Configuración',
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Package, Clock, Users, AlertCircle, Plus } from 'lucide-react';
|
||||
import { Package, Clock, Users, AlertCircle, Plus, ClipboardCheck } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import {
|
||||
ProductionBatchCreate,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../api/types/production';
|
||||
import { Card } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { ProcessStage } from '../../../api/types/qualityTemplates';
|
||||
import type { RecipeResponse } from '../../../api/types/recipes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { recipesService } from '../../../api/services/recipes';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
@@ -30,11 +35,43 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
|
||||
const [loadingRecipe, setLoadingRecipe] = useState(false);
|
||||
|
||||
// API Data
|
||||
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
|
||||
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
|
||||
|
||||
// Stage labels for display
|
||||
const STAGE_LABELS: Record<ProcessStage, string> = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
[ProcessStage.PROOFING]: 'Fermentación',
|
||||
[ProcessStage.SHAPING]: 'Formado',
|
||||
[ProcessStage.BAKING]: 'Horneado',
|
||||
[ProcessStage.COOLING]: 'Enfriado',
|
||||
[ProcessStage.PACKAGING]: 'Empaquetado',
|
||||
[ProcessStage.FINISHING]: 'Acabado'
|
||||
};
|
||||
|
||||
// Load recipe details when recipe is selected
|
||||
const handleRecipeChange = async (recipeId: string) => {
|
||||
if (!recipeId) {
|
||||
setSelectedRecipe(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingRecipe(true);
|
||||
try {
|
||||
const recipe = await recipesService.getRecipe(tenantId, recipeId);
|
||||
setSelectedRecipe(recipe);
|
||||
} catch (error) {
|
||||
console.error('Error loading recipe:', error);
|
||||
setSelectedRecipe(null);
|
||||
} finally {
|
||||
setLoadingRecipe(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter finished products (ingredients that are finished products)
|
||||
const finishedProducts = useMemo(() => ingredients.filter(ing =>
|
||||
ing.type === 'finished_product' ||
|
||||
@@ -141,7 +178,8 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
type: 'select' as const,
|
||||
options: recipeOptions,
|
||||
placeholder: 'Seleccionar receta...',
|
||||
span: 2
|
||||
span: 2,
|
||||
onChange: (value: string) => handleRecipeChange(value)
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
@@ -252,6 +290,62 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
}
|
||||
], [productOptions, recipeOptions, t]);
|
||||
|
||||
// Quality Requirements Preview Component
|
||||
const qualityRequirementsPreview = selectedRecipe && (
|
||||
<Card className="mt-4 p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3 flex items-center gap-2">
|
||||
<ClipboardCheck className="w-5 h-5 text-blue-600" />
|
||||
Controles de Calidad Requeridos
|
||||
</h4>
|
||||
{selectedRecipe.quality_check_configuration && selectedRecipe.quality_check_configuration.stages ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(selectedRecipe.quality_check_configuration.stages).map(([stage, config]: [string, any]) => {
|
||||
if (!config.template_ids || config.template_ids.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={stage} className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="info">{STAGE_LABELS[stage as ProcessStage]}</Badge>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{config.template_ids.length} control{config.template_ids.length > 1 ? 'es' : ''}
|
||||
</span>
|
||||
{config.blocking && (
|
||||
<Badge variant="warning" size="sm">Bloqueante</Badge>
|
||||
)}
|
||||
{config.is_required && (
|
||||
<Badge variant="error" size="sm">Requerido</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-3 pt-3 border-t border-blue-200">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Umbral de calidad mínimo:</span>{' '}
|
||||
{selectedRecipe.quality_check_configuration.overall_quality_threshold || 7.0}/10
|
||||
</p>
|
||||
{selectedRecipe.quality_check_configuration.critical_stage_blocking && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<span className="font-medium text-orange-600">⚠️ Bloqueo crítico activado:</span>{' '}
|
||||
El lote no puede avanzar si fallan checks críticos
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
<p className="mb-2">Esta receta no tiene controles de calidad configurados.</p>
|
||||
<a
|
||||
href={`/app/database/recipes`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Configurar controles de calidad →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
@@ -263,6 +357,7 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
size="xl"
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
additionalContent={qualityRequirementsPreview}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -219,14 +219,14 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
name: 'description',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Describe qué evalúa esta plantilla de calidad',
|
||||
span: 2
|
||||
span: 2 as const
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones para el Personal',
|
||||
name: 'instructions',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones detalladas para realizar este control de calidad',
|
||||
span: 2,
|
||||
span: 2 as const,
|
||||
helpText: 'Pasos específicos que debe seguir el operario'
|
||||
}
|
||||
]
|
||||
@@ -282,7 +282,7 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
type: 'text' as const,
|
||||
placeholder: 'Se seleccionarán las etapas donde aplicar',
|
||||
helpText: 'Las etapas se configuran mediante la selección múltiple',
|
||||
span: 2
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -297,7 +297,7 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
options: recipeOptions,
|
||||
placeholder: 'Seleccionar receta para asociar automáticamente',
|
||||
helpText: 'Si seleccionas una receta, esta plantilla se aplicará automáticamente a sus lotes de producción',
|
||||
span: 2
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -322,20 +322,20 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
name: 'is_active',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: true, label: 'Activa' },
|
||||
{ value: false, label: 'Inactiva' }
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' }
|
||||
],
|
||||
defaultValue: true
|
||||
defaultValue: 'true'
|
||||
},
|
||||
{
|
||||
label: 'Control Requerido',
|
||||
name: 'is_required',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: false, label: 'Opcional' },
|
||||
{ value: true, label: 'Requerido' }
|
||||
{ value: 'false', label: 'Opcional' },
|
||||
{ value: 'true', label: 'Requerido' }
|
||||
],
|
||||
defaultValue: false,
|
||||
defaultValue: 'false',
|
||||
helpText: 'Si es requerido, debe completarse obligatoriamente'
|
||||
},
|
||||
{
|
||||
@@ -343,10 +343,10 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
name: 'is_critical',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: false, label: 'Normal' },
|
||||
{ value: true, label: 'Crítico' }
|
||||
{ value: 'false', label: 'Normal' },
|
||||
{ value: 'true', label: 'Crítico' }
|
||||
],
|
||||
defaultValue: false,
|
||||
defaultValue: 'false',
|
||||
helpText: 'Si es crítico, bloquea la producción si falla'
|
||||
}
|
||||
]
|
||||
@@ -378,4 +378,4 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateQualityTemplateModal;
|
||||
export default CreateQualityTemplateModal;
|
||||
|
||||
@@ -18,25 +18,25 @@ interface EditQualityTemplateModalProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const QUALITY_CHECK_TYPE_OPTIONS = [
|
||||
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
|
||||
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
|
||||
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
|
||||
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
|
||||
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
|
||||
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
|
||||
];
|
||||
|
||||
const PROCESS_STAGE_OPTIONS = [
|
||||
{ value: ProcessStage.MIXING, label: 'Mezclado' },
|
||||
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
|
||||
{ value: ProcessStage.PROOFING, label: 'Fermentación' },
|
||||
{ value: ProcessStage.SHAPING, label: 'Formado' },
|
||||
{ value: ProcessStage.BAKING, label: 'Horneado' },
|
||||
{ value: ProcessStage.COOLING, label: 'Enfriado' },
|
||||
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
|
||||
{ value: ProcessStage.PACKAGING, label: 'Empaquetado' },
|
||||
{ value: ProcessStage.FINISHING, label: 'Acabado' }
|
||||
];
|
||||
|
||||
const QUALITY_CHECK_TYPE_OPTIONS = [
|
||||
{ value: QualityCheckType.VISUAL, label: 'Visual - Inspección visual' },
|
||||
{ value: QualityCheckType.MEASUREMENT, label: 'Medición - Medidas precisas' },
|
||||
{ value: QualityCheckType.TEMPERATURE, label: 'Temperatura - Control térmico' },
|
||||
{ value: QualityCheckType.WEIGHT, label: 'Peso - Control de peso' },
|
||||
{ value: QualityCheckType.BOOLEAN, label: 'Sí/No - Verificación binaria' },
|
||||
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' }
|
||||
];
|
||||
|
||||
const CATEGORY_OPTIONS_KEYS = [
|
||||
{ value: '', key: '' },
|
||||
{ value: 'appearance', key: 'appearance' },
|
||||
@@ -104,13 +104,13 @@ export const EditQualityTemplateModal: React.FC<EditQualityTemplateModalProps> =
|
||||
);
|
||||
|
||||
// Helper function to get translated category label
|
||||
const getCategoryLabel = (category: string | null | undefined): string => {
|
||||
if (!category) return 'Sin categoría';
|
||||
const getCategoryLabel = (category: string | null | undefined): string => {
|
||||
if (!category) return t('production.quality.categories.appearance', 'Sin categoría');
|
||||
const translationKey = `production.quality.categories.${category}`;
|
||||
const translated = t(translationKey);
|
||||
// If translation is same as key, it means no translation exists, return the original
|
||||
return translated === translationKey ? category : translated;
|
||||
};
|
||||
};
|
||||
|
||||
// Build category options with translations
|
||||
const getCategoryOptions = () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ProductionBatchResponse } from '../../../api/types/production';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useQualityTemplatesForStage, useExecuteQualityCheck } from '../../../api/hooks/qualityTemplates';
|
||||
import { ProcessStage, type QualityCheckTemplate, type QualityCheckExecutionRequest } from '../../../api/types/qualityTemplates';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface QualityCheckModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -695,4 +696,4 @@ export const QualityCheckModal: React.FC<QualityCheckModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityCheckModal;
|
||||
export default QualityCheckModal;
|
||||
|
||||
@@ -51,50 +51,50 @@ interface QualityTemplateManagerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QUALITY_CHECK_TYPE_CONFIG = {
|
||||
const QUALITY_CHECK_TYPE_CONFIG = (t: (key: string) => string) => ({
|
||||
[QualityCheckType.VISUAL]: {
|
||||
icon: Eye,
|
||||
label: 'Visual',
|
||||
label: t('production.quality.check_types.visual', 'Visual'),
|
||||
color: 'bg-blue-500',
|
||||
description: 'Inspección visual'
|
||||
description: t('production.quality.check_types.visual_description', 'Inspección visual')
|
||||
},
|
||||
[QualityCheckType.MEASUREMENT]: {
|
||||
icon: Settings,
|
||||
label: 'Medición',
|
||||
label: t('production.quality.check_types.measurement', 'Medición'),
|
||||
color: 'bg-green-500',
|
||||
description: 'Mediciones precisas'
|
||||
description: t('production.quality.check_types.measurement_description', 'Mediciones precisas')
|
||||
},
|
||||
[QualityCheckType.TEMPERATURE]: {
|
||||
icon: Thermometer,
|
||||
label: 'Temperatura',
|
||||
label: t('production.quality.check_types.temperature', 'Temperatura'),
|
||||
color: 'bg-red-500',
|
||||
description: 'Control de temperatura'
|
||||
description: t('production.quality.check_types.temperature_description', 'Control de temperatura')
|
||||
},
|
||||
[QualityCheckType.WEIGHT]: {
|
||||
icon: Scale,
|
||||
label: 'Peso',
|
||||
label: t('production.quality.check_types.weight', 'Peso'),
|
||||
color: 'bg-purple-500',
|
||||
description: 'Control de peso'
|
||||
description: t('production.quality.check_types.weight_description', 'Control de peso')
|
||||
},
|
||||
[QualityCheckType.BOOLEAN]: {
|
||||
icon: CheckCircle,
|
||||
label: 'Sí/No',
|
||||
label: t('production.quality.check_types.boolean', 'Sí/No'),
|
||||
color: 'bg-gray-500',
|
||||
description: 'Verificación binaria'
|
||||
description: t('production.quality.check_types.boolean_description', 'Verificación binaria')
|
||||
},
|
||||
[QualityCheckType.TIMING]: {
|
||||
icon: Timer,
|
||||
label: 'Tiempo',
|
||||
label: t('production.quality.check_types.timing', 'Tiempo'),
|
||||
color: 'bg-orange-500',
|
||||
description: 'Control de tiempo'
|
||||
description: t('production.quality.check_types.timing_description', 'Control de tiempo')
|
||||
},
|
||||
[QualityCheckType.CHECKLIST]: {
|
||||
icon: FileCheck,
|
||||
label: 'Lista de verificación',
|
||||
label: t('production.quality.check_types.checklist', 'Lista de verificación'),
|
||||
color: 'bg-indigo-500',
|
||||
description: 'Checklist de verificación'
|
||||
description: t('production.quality.check_types.checklist_description', 'Checklist de verificación')
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const PROCESS_STAGE_LABELS = {
|
||||
[ProcessStage.MIXING]: 'Mezclado',
|
||||
@@ -166,11 +166,22 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
const templateStats = useMemo(() => {
|
||||
const templates = templatesData?.templates || [];
|
||||
|
||||
// Calculate unique categories
|
||||
const uniqueCategories = new Set(templates.map(t => t.category).filter(Boolean));
|
||||
|
||||
// Calculate average weight
|
||||
const activeTemplates = templates.filter(t => t.is_active);
|
||||
const averageWeight = activeTemplates.length > 0
|
||||
? activeTemplates.reduce((sum, t) => sum + (t.weight || 0), 0) / activeTemplates.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: templates.length,
|
||||
active: templates.filter(t => t.is_active).length,
|
||||
critical: templates.filter(t => t.is_critical).length,
|
||||
required: templates.filter(t => t.is_required).length,
|
||||
categories: uniqueCategories.size,
|
||||
averageWeight: parseFloat(averageWeight.toFixed(1)),
|
||||
byType: Object.values(QualityCheckType).map(type => ({
|
||||
type,
|
||||
count: templates.filter(t => t.check_type === type).length
|
||||
@@ -221,8 +232,9 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
|
||||
const typeConfig = QUALITY_CHECK_TYPE_CONFIG[template.check_type];
|
||||
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
|
||||
const typeConfigs = QUALITY_CHECK_TYPE_CONFIG(t);
|
||||
const typeConfig = typeConfigs[template.check_type];
|
||||
|
||||
return {
|
||||
color: template.is_active ? typeConfig.color : '#6b7280',
|
||||
@@ -298,9 +310,21 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
value: templateStats.required,
|
||||
variant: 'warning',
|
||||
icon: Tag
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
value: templateStats.categories,
|
||||
variant: 'info',
|
||||
icon: Tag
|
||||
},
|
||||
{
|
||||
title: 'Peso Promedio',
|
||||
value: templateStats.averageWeight,
|
||||
variant: 'info',
|
||||
icon: Scale
|
||||
}
|
||||
]}
|
||||
columns={4}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
@@ -316,7 +340,7 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
value: selectedCheckType,
|
||||
onChange: (value) => setSelectedCheckType(value as QualityCheckType | ''),
|
||||
placeholder: 'Todos los tipos',
|
||||
options: Object.entries(QUALITY_CHECK_TYPE_CONFIG).map(([type, config]) => ({
|
||||
options: Object.entries(QUALITY_CHECK_TYPE_CONFIG(t)).map(([type, config]) => ({
|
||||
value: type,
|
||||
label: config.label
|
||||
}))
|
||||
@@ -471,4 +495,4 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityTemplateManager;
|
||||
export default QualityTemplateManager;
|
||||
|
||||
@@ -256,6 +256,13 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Debug: Log the ingredients array to understand what's being submitted
|
||||
console.log('=== Recipe Save Debug ===');
|
||||
console.log('formData.ingredients:', JSON.stringify(formData.ingredients, null, 2));
|
||||
console.log('Type of formData.ingredients:', typeof formData.ingredients);
|
||||
console.log('Is array:', Array.isArray(formData.ingredients));
|
||||
console.log('========================');
|
||||
|
||||
// Validate ingredients
|
||||
const ingredientError = validateIngredients(formData.ingredients || []);
|
||||
if (ingredientError) {
|
||||
@@ -284,6 +291,19 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
(Number(formData.cook_time_minutes) || 0) +
|
||||
(Number(formData.rest_time_minutes) || 0);
|
||||
|
||||
// Filter and validate ingredients before creating the recipe
|
||||
const validIngredients = (formData.ingredients || [])
|
||||
.filter((ing: RecipeIngredientCreate) => ing.ingredient_id && ing.ingredient_id.trim() !== '')
|
||||
.map((ing: RecipeIngredientCreate, index: number) => ({
|
||||
...ing,
|
||||
ingredient_order: index + 1
|
||||
}));
|
||||
|
||||
// Ensure we have at least one valid ingredient
|
||||
if (validIngredients.length === 0) {
|
||||
throw new Error('Debe agregar al menos un ingrediente válido con un ingrediente seleccionado');
|
||||
}
|
||||
|
||||
const recipeData: RecipeCreate = {
|
||||
name: formData.name,
|
||||
recipe_code: recipeCode,
|
||||
@@ -300,13 +320,10 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
total_time_minutes: totalTime,
|
||||
rest_time_minutes: Number(formData.rest_time_minutes) || 0,
|
||||
target_margin_percentage: Number(formData.target_margin_percentage) || 30,
|
||||
instructions: formData.preparation_notes ? { steps: formData.preparation_notes } : null,
|
||||
instructions: formData.instructions_text ? { steps: formData.instructions_text } : null,
|
||||
preparation_notes: formData.preparation_notes || '',
|
||||
storage_instructions: formData.storage_instructions || '',
|
||||
quality_standards: formData.quality_standards || '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: Number(formData.serves_count) || 1,
|
||||
is_seasonal: formData.is_seasonal || false,
|
||||
season_start_month: formData.is_seasonal ? Number(formData.season_start_month) : undefined,
|
||||
@@ -320,14 +337,16 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
allergen_info: formData.allergen_info_text ? { allergens: formData.allergen_info_text.split(',').map((a: string) => a.trim()) } : null,
|
||||
dietary_tags: formData.dietary_tags_text ? { tags: formData.dietary_tags_text.split(',').map((t: string) => t.trim()) } : null,
|
||||
nutritional_info: formData.nutritional_info_text ? { info: formData.nutritional_info_text } : null,
|
||||
// Use the ingredients from form data
|
||||
ingredients: (formData.ingredients || []).filter((ing: RecipeIngredientCreate) => ing.ingredient_id.trim() !== '')
|
||||
.map((ing: RecipeIngredientCreate, index: number) => ({
|
||||
...ing,
|
||||
ingredient_order: index + 1
|
||||
}))
|
||||
// Use the validated ingredients list
|
||||
ingredients: validIngredients
|
||||
};
|
||||
|
||||
// Debug: Log the final payload before sending to API
|
||||
console.log('=== Final Recipe Payload ===');
|
||||
console.log('recipeData:', JSON.stringify(recipeData, null, 2));
|
||||
console.log('ingredients count:', recipeData.ingredients.length);
|
||||
console.log('===========================');
|
||||
|
||||
if (onCreateRecipe) {
|
||||
await onCreateRecipe(recipeData);
|
||||
}
|
||||
@@ -572,7 +591,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Instrucciones y Calidad',
|
||||
title: 'Instrucciones',
|
||||
icon: FileText,
|
||||
fields: [
|
||||
{
|
||||
@@ -590,14 +609,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
placeholder: 'Como conservar el producto terminado...',
|
||||
span: 2,
|
||||
helpText: 'Condiciones de almacenamiento del producto final'
|
||||
},
|
||||
{
|
||||
label: 'Estándares de calidad',
|
||||
name: 'quality_standards',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Criterios de calidad que debe cumplir...',
|
||||
span: 2,
|
||||
helpText: 'Criterios que debe cumplir el producto final'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -621,6 +632,21 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Instrucciones Detalladas',
|
||||
icon: FileText,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Instrucciones de preparación',
|
||||
name: 'instructions_text',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Describir paso a paso el proceso de elaboración...',
|
||||
helpText: 'Instrucciones detalladas para la preparación',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Nutricional',
|
||||
icon: Package,
|
||||
|
||||
376
frontend/src/components/domain/recipes/DeleteRecipeModal.tsx
Normal file
376
frontend/src/components/domain/recipes/DeleteRecipeModal.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import { RecipeResponse, RecipeDeletionSummary } from '../../../api/types/recipes';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useRecipeDeletionSummary } from '../../../api/hooks/recipes';
|
||||
|
||||
type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
interface DeleteRecipeModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
recipe: RecipeResponse | null;
|
||||
onSoftDelete: (recipeId: string) => Promise<void>;
|
||||
onHardDelete: (recipeId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for recipe deletion with soft/hard delete options
|
||||
* - Soft delete: Archive recipe (reversible)
|
||||
* - Hard delete: Permanent deletion with dependency checking
|
||||
*/
|
||||
export const DeleteRecipeModal: React.FC<DeleteRecipeModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
recipe,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['recipes', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionComplete, setDeletionComplete] = useState(false);
|
||||
|
||||
// Fetch deletion summary when modal opens for hard delete
|
||||
const { data: deletionSummary, isLoading: summaryLoading } = useRecipeDeletionSummary(
|
||||
currentTenant?.id || '',
|
||||
recipe?.id || '',
|
||||
{
|
||||
enabled: isOpen && !!recipe && selectedMode === 'hard' && showConfirmation,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionComplete(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!recipe) return null;
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard') {
|
||||
await onHardDelete(recipe.id);
|
||||
} else {
|
||||
await onSoftDelete(recipe.id);
|
||||
}
|
||||
setDeletionComplete(true);
|
||||
// Auto-close after 1.5 seconds
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipe:', error);
|
||||
// Error handling is done by parent component
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionComplete(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR';
|
||||
|
||||
// Show deletion success
|
||||
if (deletionComplete) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{selectedMode === 'hard'
|
||||
? t('recipes:delete.success_hard_title', 'Receta Eliminada')
|
||||
: t('recipes:delete.success_soft_title', 'Receta Archivada')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{selectedMode === 'hard'
|
||||
? t('recipes:delete.recipe_deleted', { name: recipe.name })
|
||||
: t('recipes:delete.recipe_archived', { name: recipe.name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
const canDelete = !isHardDelete || (deletionSummary?.can_delete !== false);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete
|
||||
? t('recipes:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
|
||||
: t('recipes:delete.confirm_soft_title', 'Confirmación de Archivo')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<>
|
||||
{summaryLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--text-primary)]"></div>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.checking_dependencies', 'Verificando dependencias...')}
|
||||
</p>
|
||||
</div>
|
||||
) : deletionSummary && !canDelete ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||
<p className="font-medium text-red-700 dark:text-red-400 mb-2">
|
||||
{t('recipes:delete.cannot_delete', '⚠️ No se puede eliminar esta receta')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
|
||||
{deletionSummary.warnings.map((warning, idx) => (
|
||||
<li key={idx}>• {warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('recipes:delete.hard_warning_1', '• La receta y toda su información')}</li>
|
||||
<li>{t('recipes:delete.hard_warning_2', '• Todos los ingredientes asociados')}</li>
|
||||
{deletionSummary && (
|
||||
<>
|
||||
{deletionSummary.production_batches_count > 0 && (
|
||||
<li>{t('recipes:delete.batches_affected', { count: deletionSummary.production_batches_count }, `• ${deletionSummary.production_batches_count} lotes de producción`)}</li>
|
||||
)}
|
||||
{deletionSummary.affected_orders_count > 0 && (
|
||||
<li>{t('recipes:delete.orders_affected', { count: deletionSummary.affected_orders_count }, `• ${deletionSummary.affected_orders_count} pedidos afectados`)}</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('recipes:delete.soft_info_title', 'ℹ️ Esta acción archivará la receta:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('recipes:delete.soft_info_1', '• La receta cambiará a estado ARCHIVADO')}</li>
|
||||
<li>{t('recipes:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
|
||||
<li>{t('recipes:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
|
||||
<li>{t('recipes:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && canDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('recipes:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(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-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
|
||||
placeholder={t('recipes:delete.type_placeholder', 'Escriba ELIMINAR')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common:back', 'Volver')}
|
||||
</Button>
|
||||
{(!isHardDelete || canDelete) && (
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading || summaryLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete
|
||||
? t('recipes:delete.confirm_hard', 'Eliminar Permanentemente')
|
||||
: t('recipes:delete.confirm_soft', 'Archivar Receta')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{t('recipes:delete.title', 'Eliminar Receta')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{recipe.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.recipe_code', 'Código')}: {recipe.recipe_code || 'N/A'} • {t('recipes:delete.recipe_category', 'Categoría')}: {recipe.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
{t('recipes:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('recipes:delete.soft_delete', 'Archivar (Recomendado)')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{t('recipes:delete.hard_delete', 'Eliminar Permanentemente')}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common:continue', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -59,7 +59,11 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
return existing || {
|
||||
stages: {},
|
||||
global_parameters: {},
|
||||
default_templates: []
|
||||
default_templates: [],
|
||||
overall_quality_threshold: 7.0,
|
||||
critical_stage_blocking: true,
|
||||
auto_create_quality_checks: true,
|
||||
quality_manager_approval_required: false
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,7 +80,11 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
setConfiguration(existing || {
|
||||
stages: {},
|
||||
global_parameters: {},
|
||||
default_templates: []
|
||||
default_templates: [],
|
||||
overall_quality_threshold: 7.0,
|
||||
critical_stage_blocking: true,
|
||||
auto_create_quality_checks: true,
|
||||
quality_manager_approval_required: false
|
||||
});
|
||||
}, [recipe]);
|
||||
|
||||
@@ -149,6 +157,16 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
});
|
||||
};
|
||||
|
||||
const handleGlobalSettingChange = (
|
||||
setting: 'overall_quality_threshold' | 'critical_stage_blocking' | 'auto_create_quality_checks' | 'quality_manager_approval_required',
|
||||
value: number | boolean
|
||||
) => {
|
||||
setConfiguration(prev => ({
|
||||
...prev,
|
||||
[setting]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await onSaveConfiguration(configuration);
|
||||
@@ -225,6 +243,86 @@ export const QualityCheckConfigurationModal: React.FC<QualityCheckConfigurationM
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-4 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Configuración Global
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Umbral de Calidad Mínimo
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={configuration.overall_quality_threshold || 7.0}
|
||||
onChange={(e) => handleGlobalSettingChange('overall_quality_threshold', parseFloat(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
Puntuación mínima requerida (0-10)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={configuration.critical_stage_blocking || false}
|
||||
onChange={(e) => handleGlobalSettingChange('critical_stage_blocking', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Bloqueo en Etapas Críticas
|
||||
</span>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Bloquear progreso si fallan checks críticos
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={configuration.auto_create_quality_checks || false}
|
||||
onChange={(e) => handleGlobalSettingChange('auto_create_quality_checks', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Auto-crear Controles
|
||||
</span>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Crear automáticamente checks al iniciar lote
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={configuration.quality_manager_approval_required || false}
|
||||
onChange={(e) => handleGlobalSettingChange('quality_manager_approval_required', e.target.checked)}
|
||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)]"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Aprobación Requerida
|
||||
</span>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Requiere aprobación del gerente de calidad
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Process Stages Configuration */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { CreateRecipeModal } from './CreateRecipeModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
export { QualityCheckConfigurationModal } from './QualityCheckConfigurationModal';
|
||||
export { DeleteRecipeModal } from './DeleteRecipeModal';
|
||||
@@ -6,7 +6,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from '../../ui/Select';
|
||||
import { Button } from '../../ui/Button/Button';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { SupplierType, SupplierStatus, PaymentTerms } from '../../../api/types/suppliers';
|
||||
|
||||
interface CreateSupplierFormProps {
|
||||
|
||||
351
frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx
Normal file
351
frontend/src/components/domain/suppliers/DeleteSupplierModal.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import { SupplierResponse, SupplierDeletionSummary } from '../../../api/types/suppliers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
interface DeleteSupplierModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
supplier: SupplierResponse | null;
|
||||
onSoftDelete: (supplierId: string) => Promise<void>;
|
||||
onHardDelete: (supplierId: string) => Promise<SupplierDeletionSummary>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for supplier deletion with soft/hard delete options
|
||||
* - Soft delete: Mark as inactive (reversible)
|
||||
* - Hard delete: Permanent deletion with GDPR compliance
|
||||
*/
|
||||
export const DeleteSupplierModal: React.FC<DeleteSupplierModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
supplier,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<SupplierDeletionSummary | null>(null);
|
||||
|
||||
if (!supplier) return null;
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard') {
|
||||
const result = await onHardDelete(supplier.id);
|
||||
setDeletionResult(result);
|
||||
// Close modal immediately after successful hard delete
|
||||
onClose();
|
||||
} else {
|
||||
await onSoftDelete(supplier.id);
|
||||
// Close modal immediately after soft delete
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting supplier:', error);
|
||||
// Error handling could show a toast notification
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' && confirmText.toUpperCase() !== 'ELIMINAR';
|
||||
|
||||
// Show deletion result for hard delete
|
||||
if (deletionResult) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{t('suppliers:delete.summary_title')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_deleted', { name: deletionResult.supplier_name })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--background-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">
|
||||
{t('suppliers:delete.deletion_summary')}:
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_price_lists')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_price_lists}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_quality_reviews')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_quality_reviews}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_performance_metrics')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_performance_metrics}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_alerts')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_alerts}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>{t('suppliers:delete.deleted_scorecards')}:</span>
|
||||
<span className="font-medium">{deletionResult.deleted_scorecards}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
{t('common:close', 'Entendido')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0">
|
||||
{isHardDelete ? (
|
||||
<AlertTriangle className="w-8 h-8 text-red-500" />
|
||||
) : (
|
||||
<Info className="w-8 h-8 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
{isHardDelete
|
||||
? t('suppliers:delete.confirm_hard_title', 'Confirmación de Eliminación Permanente')
|
||||
: t('suppliers:delete.confirm_soft_title', 'Confirmación de Desactivación')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="bg-[var(--background-secondary)] p-3 rounded-lg mb-4">
|
||||
<p className="font-medium text-[var(--text-primary)]">{supplier.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isHardDelete ? (
|
||||
<div className="text-red-600 dark:text-red-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('suppliers:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('suppliers:delete.hard_warning_1', '• El proveedor y toda su información')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_2', '• Todas las listas de precios asociadas')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_3', '• Todo el historial de calidad y rendimiento')}</li>
|
||||
<li>{t('suppliers:delete.hard_warning_4', '• Las alertas y scorecards relacionados')}</li>
|
||||
</ul>
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{t('suppliers:delete.irreversible', 'Esta acción NO se puede deshacer')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-orange-600 dark:text-orange-400 mb-4">
|
||||
<p className="font-medium mb-2">
|
||||
{t('suppliers:delete.soft_info_title', 'ℹ️ Esta acción desactivará el proveedor:')}
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
<li>{t('suppliers:delete.soft_info_1', '• El proveedor se marcará como inactivo')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_2', '• No aparecerá en listas activas')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_3', '• Se conserva todo el historial y datos')}</li>
|
||||
<li>{t('suppliers:delete.soft_info_4', '• Se puede reactivar posteriormente')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
{t('suppliers:delete.type_to_confirm_label', 'Para confirmar, escriba')} <span className="font-mono bg-[var(--background-secondary)] px-1 rounded text-[var(--text-primary)]">ELIMINAR</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(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-red-500 focus:border-red-500 placeholder:text-[var(--text-tertiary)]"
|
||||
placeholder={t('suppliers:delete.type_placeholder', 'Escriba ELIMINAR')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('common:back', 'Volver')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete
|
||||
? t('suppliers:delete.confirm_hard', 'Eliminar Permanentemente')
|
||||
: t('suppliers:delete.confirm_soft', 'Desactivar Proveedor')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Initial mode selection
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="lg">
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{t('suppliers:delete.title', 'Eliminar Proveedor')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{supplier.name}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.supplier_code', 'Código')}: {supplier.supplier_code || 'N/A'} • {t('suppliers:delete.supplier_type', 'Tipo')}: {supplier.supplier_type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-[var(--text-primary)] mb-4">
|
||||
{t('suppliers:delete.choose_type', 'Elija el tipo de eliminación que desea realizar:')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Soft Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-orange-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('soft')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'soft'
|
||||
? 'border-orange-500 bg-orange-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'soft' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1">
|
||||
{t('suppliers:delete.soft_delete', 'Desactivar (Recomendado)')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.soft_explanation', 'El proveedor se marca como inactivo pero conserva todo su historial. Ideal para proveedores temporalmente fuera del catálogo.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{t('suppliers:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-colors ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`}
|
||||
onClick={() => setSelectedMode('hard')}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={`w-4 h-4 rounded-full border-2 ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && (
|
||||
<div className="w-2 h-2 bg-white rounded-full m-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-[var(--text-primary)] mb-1 flex items-center gap-2">
|
||||
{t('suppliers:delete.hard_delete', 'Eliminar Permanentemente')}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{t('suppliers:delete.hard_explanation', 'Elimina completamente el proveedor y todos sus datos asociados. Use solo para datos erróneos o pruebas.')}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{t('suppliers:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common:cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t('common:continue', 'Continuar')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
7
frontend/src/components/domain/suppliers/index.ts
Normal file
7
frontend/src/components/domain/suppliers/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Supplier Domain Components
|
||||
* Export all supplier-related components
|
||||
*/
|
||||
|
||||
export { CreateSupplierForm } from './CreateSupplierForm';
|
||||
export { DeleteSupplierModal } from './DeleteSupplierModal';
|
||||
@@ -26,14 +26,14 @@ export const DemoBanner: React.FC = () => {
|
||||
setExpiresAt(expires);
|
||||
|
||||
if (demoMode && expires) {
|
||||
const interval = setInterval(() => {
|
||||
const interval = setInterval(async () => {
|
||||
const now = new Date().getTime();
|
||||
const expiryTime = new Date(expires).getTime();
|
||||
const diff = expiryTime - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeRemaining('Sesión expirada');
|
||||
handleExpiration();
|
||||
await handleExpiration();
|
||||
} else {
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
@@ -45,13 +45,22 @@ export const DemoBanner: React.FC = () => {
|
||||
}
|
||||
}, [expiresAt]);
|
||||
|
||||
const handleExpiration = () => {
|
||||
const handleExpiration = async () => {
|
||||
// Clear demo-specific localStorage keys
|
||||
localStorage.removeItem('demo_mode');
|
||||
localStorage.removeItem('demo_session_id');
|
||||
localStorage.removeItem('demo_account_type');
|
||||
localStorage.removeItem('demo_expires_at');
|
||||
localStorage.removeItem('demo_tenant_id');
|
||||
|
||||
// Clear API client demo session ID and tenant ID
|
||||
apiClient.setDemoSessionId(null);
|
||||
apiClient.setTenantId(null);
|
||||
|
||||
// Clear tenant store to remove cached demo tenant data
|
||||
const { useTenantStore } = await import('../../../stores/tenant.store');
|
||||
useTenantStore.getState().clearTenants();
|
||||
|
||||
navigate('/demo');
|
||||
};
|
||||
|
||||
@@ -89,7 +98,7 @@ export const DemoBanner: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error('Error destroying session:', error);
|
||||
} finally {
|
||||
handleExpiration();
|
||||
await handleExpiration();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ import {
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
Search
|
||||
Search,
|
||||
Leaf,
|
||||
ChefHat,
|
||||
ClipboardCheck,
|
||||
BrainCircuit,
|
||||
Cog
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface SidebarProps {
|
||||
@@ -109,6 +114,11 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
settings: Settings,
|
||||
user: User,
|
||||
'credit-card': CreditCard,
|
||||
leaf: Leaf,
|
||||
'chef-hat': ChefHat,
|
||||
'clipboard-check': ClipboardCheck,
|
||||
'brain-circuit': BrainCircuit,
|
||||
cog: Cog,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -260,6 +260,13 @@ export interface AddModalProps {
|
||||
|
||||
// Field change callback for dynamic form behavior
|
||||
onFieldChange?: (fieldName: string, value: any) => void;
|
||||
|
||||
// Wait-for-refetch support (Option A approach)
|
||||
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
||||
isRefetching?: boolean; // External refetch state (from React Query)
|
||||
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
||||
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
||||
showSuccessState?: boolean; // Show brief success state before closing (default: true)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -289,9 +296,18 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
validationErrors = EMPTY_VALIDATION_ERRORS,
|
||||
onValidationError,
|
||||
onFieldChange,
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch = false,
|
||||
isRefetching = false,
|
||||
onSaveComplete,
|
||||
refetchTimeout = 3000,
|
||||
showSuccessState = true,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track if we've initialized the form data for this modal session
|
||||
@@ -337,6 +353,15 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: string | number) => {
|
||||
// Debug logging for ingredients field
|
||||
if (fieldName === 'ingredients') {
|
||||
console.log('=== AddModal Field Change (ingredients) ===');
|
||||
console.log('New value:', value);
|
||||
console.log('Type:', typeof value);
|
||||
console.log('Is array:', Array.isArray(value));
|
||||
console.log('==========================================');
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
@@ -406,11 +431,62 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Execute the save mutation
|
||||
await onSave(formData);
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become true then false, or timeout
|
||||
const startTime = Date.now();
|
||||
const checkRefetch = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Timeout reached
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached, proceeding anyway');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch completed (was true, now false)
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
await checkRefetch();
|
||||
setIsWaitingForRefetch(false);
|
||||
|
||||
// Show success state briefly
|
||||
if (showSuccessState) {
|
||||
setShowSuccess(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setShowSuccess(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal after save (and optional refetch) completes
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving form:', error);
|
||||
// Don't close modal on error - let the parent handle error display
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
setShowSuccess(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -441,7 +517,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(value)}
|
||||
value={value}
|
||||
onChange={(newValue) => handleFieldChange(field.name, newValue)}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
@@ -586,14 +662,15 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
};
|
||||
|
||||
const StatusIcon = defaultStatusIndicator.icon;
|
||||
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
closeOnOverlayClick={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
@@ -601,8 +678,13 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${defaultStatusIndicator.color}15` }}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
||||
defaultStatusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
||||
} ${defaultStatusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${defaultStatusIndicator.color}15`,
|
||||
...(defaultStatusIndicator.isCritical && { ringColor: defaultStatusIndicator.color })
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
@@ -612,25 +694,13 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
{/* Title and subtitle */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: defaultStatusIndicator.color }}
|
||||
>
|
||||
{defaultStatusIndicator.text}
|
||||
{defaultStatusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{defaultStatusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -642,11 +712,28 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
{(loading || isSaving || isWaitingForRefetch || showSuccess) && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.saving', 'Guardando...')}</span>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{!showSuccess ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{isWaitingForRefetch
|
||||
? t('common:modals.refreshing', 'Actualizando datos...')
|
||||
: isSaving
|
||||
? t('common:modals.saving', 'Guardando...')
|
||||
: t('common:modals.loading', 'Cargando...')}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-green-500 text-4xl">✓</div>
|
||||
<span className="text-[var(--text-secondary)] font-medium">
|
||||
{t('common:modals.success', 'Guardado correctamente')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -703,7 +790,7 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
disabled={loading || isSaving || isWaitingForRefetch}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
@@ -711,10 +798,10 @@ export const AddModal: React.FC<AddModalProps> = ({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
disabled={loading || isSaving || isWaitingForRefetch}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{loading ? (
|
||||
{loading || isSaving || isWaitingForRefetch ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
t('common:modals.actions.save', 'Guardar')
|
||||
|
||||
@@ -76,6 +76,12 @@ export interface EditViewModalProps {
|
||||
totalSteps?: number; // Total steps in workflow
|
||||
validationErrors?: Record<string, string>; // Field validation errors
|
||||
onValidationError?: (errors: Record<string, string>) => void; // Validation error handler
|
||||
|
||||
// Wait-for-refetch support (Option A approach)
|
||||
waitForRefetch?: boolean; // Enable wait-for-refetch behavior after save
|
||||
isRefetching?: boolean; // External refetch state (from React Query)
|
||||
onSaveComplete?: () => Promise<void>; // Async callback for triggering refetch
|
||||
refetchTimeout?: number; // Timeout in ms for refetch (default: 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,9 +345,16 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
totalSteps,
|
||||
validationErrors = {},
|
||||
onValidationError,
|
||||
// Wait-for-refetch support
|
||||
waitForRefetch = false,
|
||||
isRefetching = false,
|
||||
onSaveComplete,
|
||||
refetchTimeout = 3000,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = React.useState(false);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
@@ -352,11 +365,59 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
if (!onSave) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Execute the save mutation
|
||||
await onSave();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
|
||||
// If waitForRefetch is enabled, wait for data to refresh
|
||||
if (waitForRefetch && onSaveComplete) {
|
||||
setIsWaitingForRefetch(true);
|
||||
|
||||
// Trigger the refetch
|
||||
await onSaveComplete();
|
||||
|
||||
// Wait for isRefetching to become true then false, or timeout
|
||||
const startTime = Date.now();
|
||||
const checkRefetch = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Timeout reached
|
||||
if (elapsed >= refetchTimeout) {
|
||||
clearInterval(interval);
|
||||
console.warn('Refetch timeout reached, proceeding anyway');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Refetch completed (was true, now false)
|
||||
if (!isRefetching) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
await checkRefetch();
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
|
||||
// Switch to view mode after save (and optional refetch) completes
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving:', error);
|
||||
// Don't switch mode on error
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsWaitingForRefetch(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -371,30 +432,38 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
|
||||
// Default actions based on mode
|
||||
const defaultActions: EditViewModalAction[] = [];
|
||||
const isProcessing = loading || isSaving || isWaitingForRefetch;
|
||||
|
||||
if (showDefaultActions) {
|
||||
if (mode === 'view') {
|
||||
defaultActions.push({
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
icon: Edit,
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: loading,
|
||||
});
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: onClose,
|
||||
disabled: isProcessing,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: isProcessing,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
disabled: isProcessing,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.save', 'Guardar'),
|
||||
variant: 'primary',
|
||||
onClick: handleSave,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
disabled: isProcessing,
|
||||
loading: isProcessing,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -469,8 +538,8 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
closeOnOverlayClick={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
@@ -479,8 +548,13 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
{/* Status indicator */}
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||
className={`flex-shrink-0 p-2 rounded-lg transition-all ${
|
||||
statusIndicator.isCritical ? 'ring-2 ring-offset-2' : ''
|
||||
} ${statusIndicator.isHighlight ? 'shadow-lg' : ''}`}
|
||||
style={{
|
||||
backgroundColor: `${statusIndicator.color}15`,
|
||||
...(statusIndicator.isCritical && { ringColor: statusIndicator.color })
|
||||
}}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
@@ -491,27 +565,13 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
{/* Title and subtitle */}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: statusIndicator.color }}
|
||||
>
|
||||
{statusIndicator.text}
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-0.5">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
@@ -529,11 +589,17 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
{renderTopActions()}
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
{(loading || isSaving || isWaitingForRefetch) && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.loading', 'Cargando...')}</span>
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{isWaitingForRefetch
|
||||
? t('common:modals.refreshing', 'Actualizando datos...')
|
||||
: isSaving
|
||||
? t('common:modals.saving', 'Guardando...')
|
||||
: t('common:modals.loading', 'Cargando...')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,201 +1,120 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button } from '../../ui';
|
||||
import type { ButtonProps } from '../../ui';
|
||||
|
||||
export interface EmptyStateAction {
|
||||
/** Texto del botón */
|
||||
label: string;
|
||||
/** Función al hacer click */
|
||||
onClick: () => void;
|
||||
/** Variante del botón */
|
||||
variant?: ButtonProps['variant'];
|
||||
/** Icono del botón */
|
||||
icon?: React.ReactNode;
|
||||
/** Mostrar loading en el botón */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Icono o ilustración */
|
||||
icon?: React.ReactNode;
|
||||
/** Título del estado vacío */
|
||||
title?: string;
|
||||
/** Descripción del estado vacío */
|
||||
description?: string;
|
||||
/** Variante del estado vacío */
|
||||
variant?: 'no-data' | 'error' | 'search' | 'filter';
|
||||
/** Acción principal */
|
||||
primaryAction?: EmptyStateAction;
|
||||
/** Acción secundaria */
|
||||
secondaryAction?: EmptyStateAction;
|
||||
/** Componente personalizado para ilustración */
|
||||
illustration?: React.ReactNode;
|
||||
/** Clase CSS adicional */
|
||||
/**
|
||||
* Icon component to display (from lucide-react)
|
||||
*/
|
||||
icon: LucideIcon;
|
||||
|
||||
/**
|
||||
* Main title text
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Description text (can be a string or React node for complex content)
|
||||
*/
|
||||
description?: string | React.ReactNode;
|
||||
|
||||
/**
|
||||
* Optional action button label
|
||||
*/
|
||||
actionLabel?: string;
|
||||
|
||||
/**
|
||||
* Optional action button click handler
|
||||
*/
|
||||
onAction?: () => void;
|
||||
|
||||
/**
|
||||
* Optional icon for the action button
|
||||
*/
|
||||
actionIcon?: LucideIcon;
|
||||
|
||||
/**
|
||||
* Optional action button variant
|
||||
*/
|
||||
actionVariant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
|
||||
/**
|
||||
* Optional action button size
|
||||
*/
|
||||
actionSize?: 'sm' | 'md' | 'lg';
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
className?: string;
|
||||
/** Tamaño del componente */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
// Iconos SVG por defecto para cada variante
|
||||
const DefaultIcons = {
|
||||
'no-data': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
'error': (
|
||||
<svg className="w-16 h-16 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
'search': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
'filter': (
|
||||
<svg className="w-16 h-16 text-text-tertiary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.707A1 1 0 013 7V4z" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
// Mensajes por defecto en español para cada variante
|
||||
const DefaultMessages = {
|
||||
'no-data': {
|
||||
title: 'No hay datos disponibles',
|
||||
description: 'Aún no se han registrado elementos en esta sección. Comience agregando su primer elemento.'
|
||||
},
|
||||
'error': {
|
||||
title: 'Ha ocurrido un error',
|
||||
description: 'No se pudieron cargar los datos. Por favor, inténtelo de nuevo más tarde.'
|
||||
},
|
||||
'search': {
|
||||
title: 'Sin resultados de búsqueda',
|
||||
description: 'No se encontraron elementos que coincidan con su búsqueda. Intente con términos diferentes.'
|
||||
},
|
||||
'filter': {
|
||||
title: 'Sin resultados con estos filtros',
|
||||
description: 'No se encontraron elementos que coincidan con los filtros aplicados. Ajuste los filtros para ver más resultados.'
|
||||
}
|
||||
};
|
||||
|
||||
const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(({
|
||||
icon,
|
||||
/**
|
||||
* EmptyState Component
|
||||
*
|
||||
* A reusable component for displaying empty states across the application
|
||||
* with consistent styling and behavior.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EmptyState
|
||||
* icon={Package}
|
||||
* title="No items found"
|
||||
* description="Try adjusting your search or add a new item"
|
||||
* actionLabel="Add Item"
|
||||
* actionIcon={Plus}
|
||||
* onAction={() => setShowModal(true)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
variant = 'no-data',
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
illustration,
|
||||
className,
|
||||
size = 'md',
|
||||
...props
|
||||
}, ref) => {
|
||||
const defaultMessage = DefaultMessages[variant];
|
||||
const displayTitle = title || defaultMessage.title;
|
||||
const displayDescription = description || defaultMessage.description;
|
||||
const displayIcon = illustration || icon || DefaultIcons[variant];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'py-8 px-4',
|
||||
md: 'py-12 px-6',
|
||||
lg: 'py-20 px-8'
|
||||
};
|
||||
|
||||
const titleSizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-xl',
|
||||
lg: 'text-2xl'
|
||||
};
|
||||
|
||||
const descriptionSizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-base',
|
||||
lg: 'text-lg'
|
||||
};
|
||||
|
||||
const iconContainerClasses = {
|
||||
sm: 'mb-4',
|
||||
md: 'mb-6',
|
||||
lg: 'mb-8'
|
||||
};
|
||||
|
||||
const containerClasses = clsx(
|
||||
'flex flex-col items-center justify-center text-center',
|
||||
'min-h-[200px] max-w-md mx-auto',
|
||||
sizeClasses[size],
|
||||
className
|
||||
);
|
||||
|
||||
actionLabel,
|
||||
onAction,
|
||||
actionIcon: ActionIcon,
|
||||
actionVariant = 'primary',
|
||||
actionSize = 'md',
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={containerClasses}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
{...props}
|
||||
>
|
||||
{/* Icono o Ilustración */}
|
||||
{displayIcon && (
|
||||
<div className={clsx('flex-shrink-0', iconContainerClasses[size])}>
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
<div className={`text-center py-12 ${className}`}>
|
||||
{/* Icon */}
|
||||
<Icon className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
||||
|
||||
{/* Título */}
|
||||
{displayTitle && (
|
||||
<h3 className={clsx(
|
||||
'font-semibold text-text-primary mb-2',
|
||||
titleSizeClasses[size]
|
||||
)}>
|
||||
{displayTitle}
|
||||
</h3>
|
||||
)}
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Descripción */}
|
||||
{displayDescription && (
|
||||
<p className={clsx(
|
||||
'text-text-secondary mb-6 leading-relaxed',
|
||||
descriptionSizeClasses[size]
|
||||
)}>
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Acciones */}
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
{primaryAction && (
|
||||
<Button
|
||||
variant={primaryAction.variant || 'primary'}
|
||||
onClick={primaryAction.onClick}
|
||||
isLoading={primaryAction.isLoading}
|
||||
leftIcon={primaryAction.icon}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{secondaryAction && (
|
||||
<Button
|
||||
variant={secondaryAction.variant || 'outline'}
|
||||
onClick={secondaryAction.onClick}
|
||||
isLoading={secondaryAction.isLoading}
|
||||
leftIcon={secondaryAction.icon}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{secondaryAction.label}
|
||||
</Button>
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="text-[var(--text-secondary)] mb-4">
|
||||
{typeof description === 'string' ? (
|
||||
<p>{description}</p>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
onClick={onAction}
|
||||
variant={actionVariant}
|
||||
size={actionSize}
|
||||
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
{ActionIcon && (
|
||||
<ActionIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm sm:text-base">{actionLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
|
||||
export default EmptyState;
|
||||
export default EmptyState;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps, EmptyStateAction } from './EmptyState';
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
export { default } from './EmptyState';
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { ClipboardCheck, X } from 'lucide-react';
|
||||
import { Modal } from '../Modal';
|
||||
import { Button } from '../Button';
|
||||
|
||||
interface QualityPromptDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfigureNow: () => void;
|
||||
onConfigureLater: () => void;
|
||||
recipeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QualityPromptDialog - Prompts user to configure quality checks after creating a recipe
|
||||
*/
|
||||
export const QualityPromptDialog: React.FC<QualityPromptDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfigureNow,
|
||||
onConfigureLater,
|
||||
recipeName
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Configurar Control de Calidad"
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<ClipboardCheck className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
¡Receta creada exitosamente!
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
La receta <strong>{recipeName}</strong> ha sido creada.
|
||||
Para asegurar la calidad durante la producción, te recomendamos configurar
|
||||
los controles de calidad ahora.
|
||||
</p>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">
|
||||
¿Qué son los controles de calidad?
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700">
|
||||
Definir qué verificaciones se deben realizar en cada etapa del proceso de producción
|
||||
(mezclado, fermentación, horneado, etc.) utilizando plantillas de control de calidad
|
||||
reutilizables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onConfigureLater}
|
||||
className="flex-1"
|
||||
>
|
||||
Más Tarde
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfigureNow}
|
||||
className="flex-1"
|
||||
>
|
||||
Configurar Ahora
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QualityPromptDialog;
|
||||
2
frontend/src/components/ui/QualityPromptDialog/index.ts
Normal file
2
frontend/src/components/ui/QualityPromptDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { QualityPromptDialog } from './QualityPromptDialog';
|
||||
export type { } from './QualityPromptDialog';
|
||||
@@ -320,7 +320,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
];
|
||||
|
||||
const triggerClasses = [
|
||||
'flex items-center justify-between w-full px-3 py-2',
|
||||
'flex items-center justify-between w-full px-3 py-2 gap-2',
|
||||
'bg-[var(--bg-primary,#ffffff)] border border-[var(--border-primary,#e5e7eb)] rounded-lg',
|
||||
'text-[var(--text-primary,#111827)] text-left transition-colors duration-200',
|
||||
'focus:border-[var(--color-primary,#3b82f6)] focus:ring-1 focus:ring-[var(--color-primary,#3b82f6)]',
|
||||
@@ -332,9 +332,9 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 text-sm',
|
||||
md: 'h-10 text-base',
|
||||
lg: 'h-12 text-lg',
|
||||
sm: 'min-h-8 text-sm',
|
||||
md: 'min-h-10 text-base',
|
||||
lg: 'min-h-12 text-lg',
|
||||
};
|
||||
|
||||
const dropdownClasses = [
|
||||
@@ -355,28 +355,28 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
|
||||
if (multiple && Array.isArray(currentValue)) {
|
||||
if (currentValue.length === 0) {
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)] break-words">{placeholder}</span>;
|
||||
}
|
||||
|
||||
|
||||
if (currentValue.length === 1) {
|
||||
const option = selectedOptions[0];
|
||||
return option ? option.label : currentValue[0];
|
||||
return option ? <span className="break-words">{option.label}</span> : <span className="break-words">{currentValue[0]}</span>;
|
||||
}
|
||||
|
||||
return <span>{currentValue.length} elementos seleccionados</span>;
|
||||
|
||||
return <span className="break-words">{currentValue.length} elementos seleccionados</span>;
|
||||
}
|
||||
|
||||
const selectedOption = selectedOptions[0];
|
||||
if (selectedOption) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedOption.icon && <span>{selectedOption.icon}</span>}
|
||||
<span>{selectedOption.label}</span>
|
||||
{selectedOption.icon && <span className="flex-shrink-0">{selectedOption.icon}</span>}
|
||||
<span className="break-words">{selectedOption.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)]">{placeholder}</span>;
|
||||
|
||||
return <span className="text-[var(--text-tertiary,#6b7280)] break-words">{placeholder}</span>;
|
||||
};
|
||||
|
||||
const renderMultipleValues = () => {
|
||||
@@ -559,15 +559,15 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
className={clsx(triggerClasses, sizeClasses[size])}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{multiple && Array.isArray(currentValue) && currentValue.length > 0 && currentValue.length <= 3 ? (
|
||||
renderMultipleValues()
|
||||
) : (
|
||||
renderSelectedValue()
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{clearable && currentValue && (multiple ? (Array.isArray(currentValue) && currentValue.length > 0) : true) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface StatusCardProps {
|
||||
onClick: () => void;
|
||||
priority?: 'primary' | 'secondary' | 'tertiary';
|
||||
destructive?: boolean;
|
||||
highlighted?: boolean;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
onClick?: () => void;
|
||||
@@ -180,7 +181,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
: statusIndicator.isHighlight
|
||||
? 'bg-yellow-100 text-yellow-800 ring-1 ring-yellow-300 shadow-sm'
|
||||
: 'ring-1 shadow-sm'
|
||||
} max-w-[140px] sm:max-w-[160px]`}
|
||||
} max-w-[200px] sm:max-w-[220px]`}
|
||||
style={{
|
||||
backgroundColor: statusIndicator.isCritical || statusIndicator.isHighlight
|
||||
? undefined
|
||||
@@ -201,7 +202,7 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
className={`${overflowClasses.truncate} flex-1`}
|
||||
title={statusIndicator.text}
|
||||
>
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 14 : 18)}
|
||||
{safeText(statusIndicator.text, statusIndicator.text, isMobile ? 20 : 28)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -336,7 +337,9 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: action.destructive
|
||||
? 'text-red-500 hover:bg-red-50 hover:text-red-600'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
: action.highlighted
|
||||
? 'text-[var(--color-primary-500)] hover:text-[var(--color-primary-600)] hover:bg-[var(--color-primary-50)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user