Improve the frontend 2

This commit is contained in:
Urtzi Alfaro
2025-10-29 06:58:05 +01:00
parent 858d985c92
commit 36217a2729
98 changed files with 6652 additions and 4230 deletions

View File

@@ -0,0 +1,151 @@
import React from 'react';
import { BaseDeleteModal } from '../../ui';
import { Equipment } from '../../../api/types/equipment';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useEquipmentDeletionSummary } from '../../../api/hooks/equipment';
interface DeleteEquipmentModalProps {
isOpen: boolean;
onClose: () => void;
equipment: Equipment;
onSoftDelete: (equipmentId: string) => Promise<void>;
onHardDelete: (equipmentId: string) => Promise<void>;
isLoading?: boolean;
}
/**
* Modal for equipment deletion with soft/hard delete options
* - Soft delete: Deactivate equipment (reversible)
* - Hard delete: Permanent deletion with dependency checking
*/
export const DeleteEquipmentModal: React.FC<DeleteEquipmentModalProps> = ({
isOpen,
onClose,
equipment,
onSoftDelete,
onHardDelete,
isLoading = false,
}) => {
const currentTenant = useCurrentTenant();
// Fetch deletion summary for dependency checking
const { data: deletionSummary, isLoading: summaryLoading } = useEquipmentDeletionSummary(
currentTenant?.id || '',
equipment?.id || '',
{
enabled: isOpen && !!equipment,
}
);
if (!equipment) return null;
// Build dependency check warnings
const dependencyWarnings: string[] = [];
if (deletionSummary) {
if (deletionSummary.production_batches_count > 0) {
dependencyWarnings.push(
`${deletionSummary.production_batches_count} lote(s) de producción utilizan este equipo`
);
}
if (deletionSummary.maintenance_records_count > 0) {
dependencyWarnings.push(
`${deletionSummary.maintenance_records_count} registro(s) de mantenimiento`
);
}
if (deletionSummary.temperature_logs_count > 0) {
dependencyWarnings.push(
`${deletionSummary.temperature_logs_count} registro(s) de temperatura`
);
}
if (deletionSummary.warnings && deletionSummary.warnings.length > 0) {
dependencyWarnings.push(...deletionSummary.warnings);
}
}
// Build hard delete warning items
const hardDeleteItems = [
'El equipo y toda su información',
'Todo el historial de mantenimiento',
'Las alertas relacionadas',
];
if (deletionSummary) {
if (deletionSummary.production_batches_count > 0) {
hardDeleteItems.push(
`Referencias en ${deletionSummary.production_batches_count} lote(s) de producción`
);
}
}
// Get equipment type label
const getEquipmentTypeLabel = (type: string): string => {
const typeLabels: Record<string, string> = {
oven: 'Horno',
mixer: 'Batidora',
proofer: 'Fermentadora',
freezer: 'Congelador',
packaging: 'Empaquetado',
other: 'Otro',
};
return typeLabels[type] || type;
};
return (
<BaseDeleteModal
isOpen={isOpen}
onClose={onClose}
entity={equipment}
onSoftDelete={onSoftDelete}
onHardDelete={onHardDelete}
isLoading={isLoading}
title="Eliminar Equipo"
getEntityId={(eq) => eq.id}
getEntityDisplay={(eq) => ({
primaryText: eq.name,
secondaryText: `Tipo: ${getEquipmentTypeLabel(eq.type)} • Ubicación: ${eq.location || 'No especificada'}`,
})}
softDeleteOption={{
title: 'Desactivar (Recomendado)',
description: 'El equipo se marca como inactivo pero conserva todo su historial de mantenimiento. Ideal para equipos temporalmente fuera de servicio.',
benefits: '✓ Reversible • ✓ Conserva historial • ✓ Conserva alertas',
}}
hardDeleteOption={{
title: 'Eliminar Permanentemente',
description: 'Elimina completamente el equipo y todos sus datos asociados. Use solo para datos erróneos o pruebas.',
benefits: '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos',
enabled: true,
}}
softDeleteWarning={{
title: ' Esta acción desactivará el equipo:',
items: [
'El equipo se marcará como inactivo',
'No aparecerá en listas activas',
'Se conserva todo el historial de mantenimiento',
'Se puede reactivar posteriormente',
],
}}
hardDeleteWarning={{
title: '⚠️ Esta acción eliminará permanentemente:',
items: hardDeleteItems,
footer: 'Esta acción NO se puede deshacer',
}}
dependencyCheck={{
isLoading: summaryLoading,
canDelete: deletionSummary?.can_delete !== false,
warnings: dependencyWarnings,
}}
requireConfirmText={true}
confirmText="ELIMINAR"
showSuccessScreen={true}
successTitle="Equipo Procesado"
getSuccessMessage={(eq, mode) =>
mode === 'hard'
? `${eq.name} ha sido eliminado permanentemente`
: `${eq.name} ha sido desactivado`
}
autoCloseDelay={1500}
/>
);
};
export default DeleteEquipmentModal;

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react';
import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit, FileText } from 'lucide-react';
import { EditViewModal, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal';
import { Equipment } from '../../../api/types/equipment';
@@ -39,6 +39,9 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
uptime: 100,
energyUsage: 0,
utilizationToday: 0,
temperature: 0,
targetTemperature: 0,
notes: '',
alerts: [],
maintenanceHistory: [],
specifications: {
@@ -95,7 +98,10 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
[t('fields.specifications.weight')]: 'weight',
[t('fields.specifications.width')]: 'width',
[t('fields.specifications.height')]: 'height',
[t('fields.specifications.depth')]: 'depth'
[t('fields.specifications.depth')]: 'depth',
[t('fields.current_temperature')]: 'temperature',
[t('fields.target_temperature')]: 'targetTemperature',
[t('fields.notes')]: 'notes'
};
const propertyName = fieldLabelKeyMap[field.label];
@@ -303,6 +309,39 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
placeholder: '0'
}
]
},
{
title: t('sections.temperature_monitoring'),
icon: Thermometer,
fields: [
{
label: t('fields.current_temperature'),
value: equipment.temperature || 0,
type: 'number',
editable: true,
placeholder: '0'
},
{
label: t('fields.target_temperature'),
value: equipment.targetTemperature || 0,
type: 'number',
editable: true,
placeholder: '0'
}
]
},
{
title: t('sections.notes'),
icon: FileText,
fields: [
{
label: t('fields.notes'),
value: equipment.notes || '',
type: 'textarea',
editable: true,
placeholder: t('placeholders.notes')
}
]
}
];
};

View File

@@ -0,0 +1,202 @@
import React from 'react';
import { Clock, Wrench, AlertTriangle, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
import { Equipment, MaintenanceHistory } from '../../../api/types/equipment';
import { statusColors } from '../../../styles/colors';
interface MaintenanceHistoryModalProps {
isOpen: boolean;
onClose: () => void;
equipment: Equipment;
loading?: boolean;
}
/**
* MaintenanceHistoryModal - Modal for viewing equipment maintenance history
* Shows maintenance records with color-coded types and detailed information
*/
export const MaintenanceHistoryModal: React.FC<MaintenanceHistoryModalProps> = ({
isOpen,
onClose,
equipment,
loading = false
}) => {
const { t } = useTranslation(['equipment', 'common']);
// Get maintenance type display info with colors and icons
const getMaintenanceTypeInfo = (type: MaintenanceHistory['type']) => {
switch (type) {
case 'preventive':
return {
label: t('maintenance.type.preventive', 'Preventivo'),
icon: Wrench,
color: statusColors.inProgress.primary,
bgColor: `${statusColors.inProgress.primary}15`
};
case 'corrective':
return {
label: t('maintenance.type.corrective', 'Correctivo'),
icon: AlertTriangle,
color: statusColors.pending.primary,
bgColor: `${statusColors.pending.primary}15`
};
case 'emergency':
return {
label: t('maintenance.type.emergency', 'Emergencia'),
icon: Zap,
color: statusColors.out.primary,
bgColor: `${statusColors.out.primary}15`
};
default:
return {
label: type,
icon: Wrench,
color: statusColors.normal.primary,
bgColor: `${statusColors.normal.primary}15`
};
}
};
// Process maintenance history for display
const maintenanceRecords = equipment.maintenanceHistory || [];
const sortedRecords = [...maintenanceRecords].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
const statusConfig = {
color: statusColors.inProgress.primary,
text: `${maintenanceRecords.length} ${t('maintenance.records', 'registros')}`,
icon: Clock
};
// Create maintenance list display
const maintenanceList = sortedRecords.length > 0 ? (
<div className="space-y-3 max-h-96 overflow-y-auto">
{sortedRecords.map((record) => {
const typeInfo = getMaintenanceTypeInfo(record.type);
const MaintenanceIcon = typeInfo.icon;
return (
<div
key={record.id}
className="flex items-start gap-3 p-4 bg-[var(--surface-secondary)] rounded-lg hover:bg-[var(--surface-tertiary)] transition-colors border border-[var(--border-primary)]"
>
{/* Icon and type */}
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: typeInfo.bgColor }}
>
<MaintenanceIcon
className="w-5 h-5"
style={{ color: typeInfo.color }}
/>
</div>
{/* Main content */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-[var(--text-primary)]">
{record.description}
</span>
<span
className="px-2 py-1 text-xs font-medium rounded"
style={{
backgroundColor: typeInfo.bgColor,
color: typeInfo.color
}}
>
{typeInfo.label}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm text-[var(--text-secondary)] mb-2">
<div>
<span className="text-[var(--text-tertiary)]">{t('fields.date', 'Fecha')}:</span>
<span className="ml-1">
{new Date(record.date).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">{t('fields.technician', 'Técnico')}:</span>
<span className="ml-1">{record.technician}</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">{t('common:actions.cost', 'Coste')}:</span>
<span className="ml-1 font-medium">{record.cost.toFixed(2)}</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">{t('fields.downtime', 'Parada')}:</span>
<span className="ml-1 font-medium">{record.downtime}h</span>
</div>
</div>
{record.partsUsed && record.partsUsed.length > 0 && (
<div className="mt-2">
<span className="text-xs text-[var(--text-tertiary)]">
{t('fields.parts', 'Repuestos')}:
</span>
<div className="flex flex-wrap gap-1 mt-1">
{record.partsUsed.map((part, index) => (
<span
key={index}
className="px-2 py-0.5 bg-[var(--bg-tertiary)] text-xs rounded border border-[var(--border-primary)]"
>
{part}
</span>
))}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-[var(--text-secondary)]">
<Wrench className="w-16 h-16 mx-auto mb-4 opacity-30" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
{t('maintenance.no_history', 'No hay historial de mantenimiento')}
</h3>
<p className="text-sm">
{t('maintenance.no_history_description', 'Los registros de mantenimiento aparecerán aquí cuando se realicen operaciones')}
</p>
</div>
);
const sections = [
{
title: t('maintenance.history', 'Historial de Mantenimiento'),
icon: Clock,
fields: [
{
label: '',
value: maintenanceList,
span: 2 as const
}
]
}
];
return (
<EditViewModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={equipment.name}
subtitle={`${equipment.model || equipment.type}${maintenanceRecords.length} ${t('maintenance.records', 'registros')}`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading}
showDefaultActions={false}
/>
);
};
export default MaintenanceHistoryModal;

View File

@@ -0,0 +1,176 @@
import React from 'react';
import { Calendar, Wrench } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { AddModal, AddModalSection } from '../../ui/AddModal/AddModal';
import { Equipment } from '../../../api/types/equipment';
import { statusColors } from '../../../styles/colors';
interface ScheduleMaintenanceModalProps {
isOpen: boolean;
onClose: () => void;
equipment: Equipment;
onSchedule: (equipmentId: string, maintenanceData: MaintenanceScheduleData) => Promise<void>;
isLoading?: boolean;
}
export interface MaintenanceScheduleData {
type: 'preventive' | 'corrective' | 'emergency';
scheduledDate: string;
scheduledTime: string;
estimatedDuration: number;
technician: string;
partsNeeded: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
description: string;
}
/**
* ScheduleMaintenanceModal - Modal for scheduling equipment maintenance
* Uses AddModal component for consistent UX across the application
*/
export const ScheduleMaintenanceModal: React.FC<ScheduleMaintenanceModalProps> = ({
isOpen,
onClose,
equipment,
onSchedule,
isLoading = false
}) => {
const { t } = useTranslation(['equipment', 'common']);
const handleSave = async (formData: Record<string, any>) => {
const maintenanceData: MaintenanceScheduleData = {
type: formData.type as MaintenanceScheduleData['type'],
scheduledDate: formData.scheduledDate,
scheduledTime: formData.scheduledTime,
estimatedDuration: Number(formData.estimatedDuration),
technician: formData.technician,
partsNeeded: formData.partsNeeded || '',
priority: formData.priority as MaintenanceScheduleData['priority'],
description: formData.description
};
await onSchedule(equipment.id, maintenanceData);
};
const sections: AddModalSection[] = [
{
title: t('sections.maintenance_info', 'Información de Mantenimiento'),
icon: Wrench,
columns: 2,
fields: [
{
label: t('fields.maintenance_type', 'Tipo de Mantenimiento'),
name: 'type',
type: 'select',
required: true,
defaultValue: 'preventive',
options: [
{ label: t('maintenance.type.preventive', 'Preventivo'), value: 'preventive' },
{ label: t('maintenance.type.corrective', 'Correctivo'), value: 'corrective' },
{ label: t('maintenance.type.emergency', 'Emergencia'), value: 'emergency' }
]
},
{
label: t('fields.priority', 'Prioridad'),
name: 'priority',
type: 'select',
required: true,
defaultValue: 'medium',
options: [
{ label: t('priority.low', 'Baja'), value: 'low' },
{ label: t('priority.medium', 'Media'), value: 'medium' },
{ label: t('priority.high', 'Alta'), value: 'high' },
{ label: t('priority.urgent', 'Urgente'), value: 'urgent' }
]
}
]
},
{
title: t('sections.scheduling', 'Programación'),
icon: Calendar,
columns: 2,
fields: [
{
label: t('fields.scheduled_date', 'Fecha Programada'),
name: 'scheduledDate',
type: 'date',
required: true,
defaultValue: new Date().toISOString().split('T')[0]
},
{
label: t('fields.time', 'Hora'),
name: 'scheduledTime',
type: 'text',
required: false,
defaultValue: '09:00',
placeholder: 'HH:MM'
},
{
label: t('fields.technician', 'Técnico Asignado'),
name: 'technician',
type: 'text',
required: true,
placeholder: t('placeholders.technician', 'Nombre del técnico'),
span: 2
},
{
label: t('fields.duration', 'Duración (horas)'),
name: 'estimatedDuration',
type: 'number',
required: true,
defaultValue: 2,
validation: (value: number) => {
if (value <= 0) {
return t('validation.must_be_positive', 'Debe ser mayor que 0');
}
return null;
}
}
]
},
{
title: t('sections.details', 'Detalles'),
icon: Wrench,
columns: 1,
fields: [
{
label: t('fields.description', 'Descripción'),
name: 'description',
type: 'textarea',
required: true,
placeholder: t('placeholders.maintenance_description', 'Descripción del trabajo a realizar'),
span: 2
},
{
label: t('fields.parts_needed', 'Repuestos Necesarios'),
name: 'partsNeeded',
type: 'textarea',
required: false,
placeholder: t('placeholders.parts_needed', 'Lista de repuestos y materiales necesarios'),
span: 2
}
]
}
];
return (
<AddModal
isOpen={isOpen}
onClose={onClose}
title={t('actions.schedule_maintenance', 'Programar Mantenimiento')}
subtitle={`${equipment.name}${equipment.model || equipment.type}`}
statusIndicator={{
color: statusColors.inProgress.primary,
text: t('maintenance.scheduled', 'Programado'),
icon: Calendar,
isHighlight: true
}}
sections={sections}
onSave={handleSave}
size="lg"
loading={isLoading}
/>
);
};
export default ScheduleMaintenanceModal;

View File

@@ -0,0 +1,384 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
RotateCcw,
Sparkles,
Calendar,
Settings,
TrendingUp,
Sun,
Box,
Zap,
Info
} from 'lucide-react';
import { EditViewModal, EditViewModalAction, EditViewModalSection } from '../../ui/EditViewModal/EditViewModal';
import { Button } from '../../ui/Button';
import { IngredientResponse } from '../../../api/types/inventory';
import { TrainedModelResponse, SingleProductTrainingRequest } from '../../../api/types/training';
interface RetrainModelModalProps {
isOpen: boolean;
onClose: () => void;
ingredient: IngredientResponse;
currentModel?: TrainedModelResponse | null;
onRetrain: (settings: SingleProductTrainingRequest) => Promise<void>;
isLoading?: boolean;
}
type RetrainMode = 'quick' | 'preset' | 'advanced';
interface TrainingPreset {
id: string;
name: string;
description: string;
icon: typeof Sparkles;
settings: Partial<SingleProductTrainingRequest>;
recommended?: boolean;
}
export const RetrainModelModal: React.FC<RetrainModelModalProps> = ({
isOpen,
onClose,
ingredient,
currentModel,
onRetrain,
isLoading = false
}) => {
const { t } = useTranslation(['models', 'common']);
const [mode, setMode] = useState<RetrainMode>('quick');
const [selectedPreset, setSelectedPreset] = useState<string>('standard');
const [advancedSettings, setAdvancedSettings] = useState<SingleProductTrainingRequest>({
seasonality_mode: 'additive',
daily_seasonality: true,
weekly_seasonality: true,
yearly_seasonality: false,
});
// Define training presets - memoized to prevent recreation
const presets: TrainingPreset[] = React.useMemo(() => [
{
id: 'standard',
name: t('models:presets.standard.name', 'Panadería Estándar'),
description: t('models:presets.standard.description', 'Recomendado para productos con patrones semanales y ciclos diarios. Ideal para pan y productos horneados diarios.'),
icon: TrendingUp,
settings: {
seasonality_mode: 'additive',
daily_seasonality: true,
weekly_seasonality: true,
yearly_seasonality: false,
},
recommended: true
},
{
id: 'seasonal',
name: t('models:presets.seasonal.name', 'Productos Estacionales'),
description: t('models:presets.seasonal.description', 'Para productos con demanda estacional o de temporada. Incluye patrones anuales para festividades y eventos especiales.'),
icon: Sun,
settings: {
seasonality_mode: 'additive',
daily_seasonality: true,
weekly_seasonality: true,
yearly_seasonality: true,
}
},
{
id: 'stable',
name: t('models:presets.stable.name', 'Demanda Estable'),
description: t('models:presets.stable.description', 'Para ingredientes básicos con demanda constante. Mínima estacionalidad.'),
icon: Box,
settings: {
seasonality_mode: 'additive',
daily_seasonality: false,
weekly_seasonality: true,
yearly_seasonality: false,
}
},
{
id: 'custom',
name: t('models:presets.custom.name', 'Personalizado'),
description: t('models:presets.custom.description', 'Configuración avanzada con control total sobre los parámetros.'),
icon: Settings,
settings: advancedSettings
}
], [t, advancedSettings]);
const handleRetrain = async () => {
let settings: SingleProductTrainingRequest;
switch (mode) {
case 'quick':
// Use existing model's hyperparameters if available
settings = currentModel?.hyperparameters || {
seasonality_mode: 'additive',
daily_seasonality: true,
weekly_seasonality: true,
yearly_seasonality: false,
};
break;
case 'preset':
const preset = presets.find(p => p.id === selectedPreset);
settings = preset?.settings || presets[0].settings;
break;
case 'advanced':
settings = advancedSettings;
break;
default:
settings = advancedSettings;
}
await onRetrain(settings);
};
// Build sections based on current mode - memoized to prevent recreation
const sections = React.useMemo((): EditViewModalSection[] => {
const result: EditViewModalSection[] = [];
if (mode === 'quick') {
result.push({
title: t('models:retrain.quick.title', 'Reentrenamiento Rápido'),
icon: Zap,
fields: [
{
label: t('models:retrain.quick.ingredient', 'Ingrediente'),
value: ingredient.name,
type: 'text',
highlight: true
},
{
label: t('models:retrain.quick.current_accuracy', 'Precisión Actual'),
value: currentModel?.training_metrics?.mape
? `${(100 - currentModel.training_metrics.mape).toFixed(1)}%`
: t('common:not_available', 'N/A'),
type: 'text'
},
{
label: t('models:retrain.quick.last_training', 'Último Entrenamiento'),
value: currentModel?.created_at || t('common:not_available', 'N/A'),
type: 'date'
},
{
label: t('models:retrain.quick.description', 'Descripción'),
value: t('models:retrain.quick.description_text', 'El reentrenamiento rápido utiliza la misma configuración del modelo actual pero con los datos más recientes. Esto mantiene la precisión del modelo actualizada sin cambiar su comportamiento.'),
type: 'text',
span: 2
}
]
});
}
if (mode === 'preset') {
// Show preset selection
result.push({
title: t('models:retrain.preset.title', 'Seleccionar Configuración'),
icon: Sparkles,
fields: [
{
label: t('models:retrain.preset.ingredient', 'Ingrediente'),
value: ingredient.name,
type: 'text',
highlight: true
},
{
label: t('models:retrain.preset.select', 'Tipo de Producto'),
value: selectedPreset,
type: 'select',
editable: true,
options: presets.map(p => ({ label: p.name, value: p.id })),
span: 2
}
]
});
// Show description of selected preset
const currentPreset = presets.find(p => p.id === selectedPreset);
if (currentPreset) {
result.push({
title: currentPreset.name,
icon: currentPreset.icon,
fields: [
{
label: t('models:retrain.preset.description', 'Descripción'),
value: currentPreset.description,
type: 'text',
span: 2
},
{
label: t('models:retrain.preset.seasonality_mode', 'Modo de Estacionalidad'),
value: currentPreset.settings.seasonality_mode === 'additive'
? t('models:seasonality.additive', 'Aditivo')
: t('models:seasonality.multiplicative', 'Multiplicativo'),
type: 'text'
},
{
label: t('models:retrain.preset.daily', 'Estacionalidad Diaria'),
value: currentPreset.settings.daily_seasonality
? t('common:yes', 'Sí')
: t('common:no', 'No'),
type: 'text'
},
{
label: t('models:retrain.preset.weekly', 'Estacionalidad Semanal'),
value: currentPreset.settings.weekly_seasonality
? t('common:yes', 'Sí')
: t('common:no', 'No'),
type: 'text'
},
{
label: t('models:retrain.preset.yearly', 'Estacionalidad Anual'),
value: currentPreset.settings.yearly_seasonality
? t('common:yes', 'Sí')
: t('common:no', 'No'),
type: 'text'
}
]
});
}
}
if (mode === 'advanced') {
result.push({
title: t('models:retrain.advanced.title', 'Configuración Avanzada'),
icon: Settings,
fields: [
{
label: t('models:retrain.advanced.ingredient', 'Ingrediente'),
value: ingredient.name,
type: 'text',
highlight: true,
span: 2
},
{
label: t('models:retrain.advanced.start_date', 'Fecha de Inicio'),
value: advancedSettings.start_date || '',
type: 'date',
editable: true,
helpText: t('models:retrain.advanced.start_date_help', 'Dejar vacío para usar todos los datos disponibles')
},
{
label: t('models:retrain.advanced.end_date', 'Fecha de Fin'),
value: advancedSettings.end_date || '',
type: 'date',
editable: true,
helpText: t('models:retrain.advanced.end_date_help', 'Dejar vacío para usar hasta la fecha actual')
},
{
label: t('models:retrain.advanced.seasonality_mode', 'Modo de Estacionalidad'),
value: advancedSettings.seasonality_mode || 'additive',
type: 'select',
editable: true,
options: [
{ label: t('models:seasonality.additive', 'Aditivo'), value: 'additive' },
{ label: t('models:seasonality.multiplicative', 'Multiplicativo'), value: 'multiplicative' }
],
helpText: t('models:retrain.advanced.seasonality_mode_help', 'Aditivo: cambios constantes. Multiplicativo: cambios proporcionales.')
}
]
});
// Seasonality options
result.push({
title: t('models:retrain.advanced.seasonality_patterns', 'Patrones Estacionales'),
icon: Calendar,
fields: [
{
label: t('models:retrain.advanced.daily_seasonality', 'Estacionalidad Diaria'),
value: advancedSettings.daily_seasonality ? t('common:enabled', 'Activado') : t('common:disabled', 'Desactivado'),
type: 'text',
helpText: t('models:retrain.advanced.daily_seasonality_help', 'Patrones que se repiten cada día')
},
{
label: t('models:retrain.advanced.weekly_seasonality', 'Estacionalidad Semanal'),
value: advancedSettings.weekly_seasonality ? t('common:enabled', 'Activado') : t('common:disabled', 'Desactivado'),
type: 'text',
helpText: t('models:retrain.advanced.weekly_seasonality_help', 'Patrones que se repiten cada semana')
},
{
label: t('models:retrain.advanced.yearly_seasonality', 'Estacionalidad Anual'),
value: advancedSettings.yearly_seasonality ? t('common:enabled', 'Activado') : t('common:disabled', 'Desactivado'),
type: 'text',
helpText: t('models:retrain.advanced.yearly_seasonality_help', 'Patrones que se repiten cada año (festividades, temporadas)')
}
]
});
}
return result;
}, [mode, t, ingredient, currentModel, presets, selectedPreset, advancedSettings]);
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
const field = sections[sectionIndex]?.fields[fieldIndex];
if (!field) return;
// Handle preset selection
if (mode === 'preset' && field.label === t('models:retrain.preset.select', 'Tipo de Producto')) {
setSelectedPreset(value as string);
return;
}
// Handle advanced settings
if (mode === 'advanced') {
const label = field.label;
if (label === t('models:retrain.advanced.start_date', 'Fecha de Inicio')) {
setAdvancedSettings(prev => ({ ...prev, start_date: value as string || null }));
} else if (label === t('models:retrain.advanced.end_date', 'Fecha de Fin')) {
setAdvancedSettings(prev => ({ ...prev, end_date: value as string || null }));
} else if (label === t('models:retrain.advanced.seasonality_mode', 'Modo de Estacionalidad')) {
setAdvancedSettings(prev => ({ ...prev, seasonality_mode: value as string }));
}
}
};
// Define tab-style actions for header navigation - memoized
const actions: EditViewModalAction[] = React.useMemo(() => [
{
label: t('models:retrain.modes.quick', 'Rápido'),
icon: Zap,
onClick: () => setMode('quick'),
variant: 'outline',
disabled: mode === 'quick'
},
{
label: t('models:retrain.modes.preset', 'Preconfigurado'),
icon: Sparkles,
onClick: () => setMode('preset'),
variant: 'outline',
disabled: mode === 'preset'
},
{
label: t('models:retrain.modes.advanced', 'Avanzado'),
icon: Settings,
onClick: () => setMode('advanced'),
variant: 'outline',
disabled: mode === 'advanced'
}
], [t, mode]);
return (
<EditViewModal
isOpen={isOpen}
onClose={onClose}
mode="edit"
title={t('models:retrain.title', 'Reentrenar Modelo')}
subtitle={ingredient.name}
statusIndicator={{
color: '#F59E0B',
text: t('models:status.retraining', 'Reentrenamiento'),
icon: RotateCcw,
isCritical: false,
isHighlight: true
}}
size="lg"
sections={sections}
actions={actions}
actionsPosition="header"
showDefaultActions={true}
onSave={handleRetrain}
onFieldChange={handleFieldChange}
loading={isLoading}
/>
);
};
export default RetrainModelModal;

View File

@@ -4,6 +4,7 @@ export { default as ForecastTable } from './ForecastTable';
export { default as SeasonalityIndicator } from './SeasonalityIndicator';
export { default as AlertsPanel } from './AlertsPanel';
export { default as ModelDetailsModal } from './ModelDetailsModal';
export { default as RetrainModelModal } from './RetrainModelModal';
// Export component props for type checking
export type { DemandChartProps } from './DemandChart';

View File

@@ -1,10 +1,7 @@
import React, { useState } from 'react';
import { Trash2, AlertTriangle, Info, X } from 'lucide-react';
import { Modal, Button } from '../../ui';
import React from 'react';
import { BaseDeleteModal } from '../../ui';
import { IngredientResponse, DeletionSummary } from '../../../api/types/inventory';
type DeleteMode = 'soft' | 'hard';
interface DeleteIngredientModalProps {
isOpen: boolean;
onClose: () => void;
@@ -25,307 +22,62 @@ export const DeleteIngredientModal: React.FC<DeleteIngredientModalProps> = ({
onHardDelete,
isLoading = false,
}) => {
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
const [showConfirmation, setShowConfirmation] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [deletionResult, setDeletionResult] = useState<DeletionSummary | null>(null);
const handleDeleteModeSelect = (mode: DeleteMode) => {
setSelectedMode(mode);
setShowConfirmation(true);
setConfirmText('');
};
const handleConfirmDelete = async () => {
try {
if (selectedMode === 'hard') {
const result = await onHardDelete(ingredient.id);
setDeletionResult(result);
// Close modal immediately after successful hard delete
onClose();
} else {
await onSoftDelete(ingredient.id);
// Close modal immediately after successful soft delete
onClose();
}
} catch (error) {
console.error('Error deleting ingredient:', error);
// Handle error (could show a toast or error message)
}
};
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)]">
Eliminación Completada
</h3>
<p className="text-sm text-[var(--text-secondary)]">
El artículo {deletionResult.ingredient_name} ha sido eliminado permanentemente
</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">Resumen de eliminación:</h4>
<div className="space-y-2 text-sm text-[var(--text-secondary)]">
<div className="flex justify-between">
<span>Lotes de stock eliminados:</span>
<span className="font-medium">{deletionResult.deleted_stock_entries}</span>
</div>
<div className="flex justify-between">
<span>Movimientos eliminados:</span>
<span className="font-medium">{deletionResult.deleted_stock_movements}</span>
</div>
<div className="flex justify-between">
<span>Alertas eliminadas:</span>
<span className="font-medium">{deletionResult.deleted_stock_alerts}</span>
</div>
</div>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={handleClose}>
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 ? 'Confirmación de Eliminación Permanente' : '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)]">{ingredient.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
Categoría: {ingredient.category} Stock actual: {ingredient.current_stock || 0}
</p>
</div>
{isHardDelete ? (
<div className="text-red-600 dark:text-red-400 mb-4">
<p className="font-medium mb-2"> Esta acción eliminará permanentemente:</p>
<ul className="text-sm space-y-1 ml-4">
<li> El artículo y toda su información</li>
<li> Todos los lotes de stock asociados</li>
<li> Todo el historial de movimientos</li>
<li> Las alertas relacionadas</li>
</ul>
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
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"> Esta acción desactivará el artículo:</p>
<ul className="text-sm space-y-1 ml-4">
<li> El artículo se marcará como inactivo</li>
<li> No aparecerá en listas activas</li>
<li> Se conserva todo el historial y stock</li>
<li> 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">
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="Escriba ELIMINAR"
autoComplete="off"
/>
</div>
)}
</div>
</div>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmation(false)}
disabled={isLoading}
>
Volver
</Button>
<Button
variant={isHardDelete ? 'danger' : 'warning'}
onClick={handleConfirmDelete}
disabled={isConfirmDisabled || isLoading}
isLoading={isLoading}
>
{isHardDelete ? 'Eliminar Permanentemente' : 'Desactivar Artículo'}
</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)]">
Eliminar Artículo
</h2>
</div>
<div className="mb-6">
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
<p className="font-medium text-[var(--text-primary)]">{ingredient.name}</p>
<p className="text-sm text-[var(--text-secondary)]">
Categoría: {ingredient.category} Stock actual: {ingredient.current_stock || 0}
</p>
</div>
</div>
<div className="mb-6">
<p className="text-[var(--text-primary)] mb-4">
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">
Desactivar (Recomendado)
</h3>
<p className="text-sm text-[var(--text-secondary)]">
El artículo se marca como inactivo pero conserva todo su historial.
Ideal para artículos temporalmente fuera del catálogo.
</p>
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
Reversible Conserva historial Conserva stock
</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">
Eliminar Permanentemente
<AlertTriangle className="w-4 h-4 text-red-500" />
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Elimina completamente el artículo 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">
No reversible Elimina historial Elimina stock
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex gap-3 justify-end">
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button
variant={selectedMode === 'hard' ? 'danger' : 'warning'}
onClick={() => handleDeleteModeSelect(selectedMode)}
>
<Trash2 className="w-4 h-4 mr-2" />
Continuar
</Button>
</div>
</div>
</Modal>
<BaseDeleteModal<IngredientResponse, DeletionSummary>
isOpen={isOpen}
onClose={onClose}
entity={ingredient}
onSoftDelete={onSoftDelete}
onHardDelete={onHardDelete}
isLoading={isLoading}
title="Eliminar Artículo"
getEntityId={(ing) => ing.id}
getEntityDisplay={(ing) => ({
primaryText: ing.name,
secondaryText: `Categoría: ${ing.category} • Stock actual: ${ing.current_stock || 0}`,
})}
softDeleteOption={{
title: 'Desactivar (Recomendado)',
description: 'El artículo se marca como inactivo pero conserva todo su historial. Ideal para artículos temporalmente fuera del catálogo.',
benefits: '✓ Reversible • ✓ Conserva historial • ✓ Conserva stock',
}}
hardDeleteOption={{
title: 'Eliminar Permanentemente',
description: 'Elimina completamente el artículo y todos sus datos asociados. Use solo para datos erróneos o pruebas.',
benefits: '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina stock',
enabled: true,
}}
softDeleteWarning={{
title: ' Esta acción desactivará el artículo:',
items: [
'El artículo se marcará como inactivo',
'No aparecerá en listas activas',
'Se conserva todo el historial y stock',
'Se puede reactivar posteriormente',
],
}}
hardDeleteWarning={{
title: '⚠️ Esta acción eliminará permanentemente:',
items: [
'El artículo y toda su información',
'Todos los lotes de stock asociados',
'Todo el historial de movimientos',
'Las alertas relacionadas',
],
footer: 'Esta acción NO se puede deshacer',
}}
requireConfirmText={true}
confirmText="ELIMINAR"
showSuccessScreen={false}
showDeletionSummary={true}
deletionSummaryTitle="Eliminación Completada"
formatDeletionSummary={(summary) => ({
'Lotes de stock eliminados': summary.deleted_stock_entries,
'Movimientos eliminados': summary.deleted_stock_movements,
'Alertas eliminadas': summary.deleted_stock_alerts,
})}
/>
);
};
export default DeleteIngredientModal;
export default DeleteIngredientModal;

View File

@@ -1,12 +1,14 @@
import React, { useState, useEffect } from 'react';
import { Plus, Settings, ClipboardCheck, Target, Cog } from 'lucide-react';
import { AddModal } from '../../ui/AddModal/AddModal';
import { Select } from '../../ui/Select';
import {
QualityCheckType,
ProcessStage,
type QualityCheckTemplateCreate
} from '../../../api/types/qualityTemplates';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { useAuthUser } from '../../../stores/auth.store';
import { useQuery } from '@tanstack/react-query';
import { recipesService } from '../../../api/services/recipes';
import type { RecipeResponse } from '../../../api/types/recipes';
@@ -33,7 +35,8 @@ const QUALITY_CHECK_TYPE_OPTIONS = [
{ 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' }
{ value: QualityCheckType.TIMING, label: 'Tiempo - Control temporal' },
{ value: QualityCheckType.CHECKLIST, label: 'Checklist - Lista de verificación' }
];
const PROCESS_STAGE_OPTIONS = [
@@ -72,17 +75,19 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
isLoading: externalLoading = false,
initialRecipe
}) => {
const { t } = useTranslation();
const { t } = useTranslation(['production', 'common']);
const currentTenant = useCurrentTenant();
const user = useAuthUser();
const [loading, setLoading] = useState(false);
const [selectedStages, setSelectedStages] = useState<ProcessStage[]>([]);
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | null>(null);
// Helper function to get translated category label
const getCategoryLabel = (category: string | null | undefined): string => {
if (!category) return 'Sin categoría';
const translationKey = `production.quality.categories.${category}`;
const translated = t(translationKey);
return translated === translationKey ? category : translated;
if (!category) return t('production:quality.categories.no_category', 'Sin categoría');
const translationKey = `quality.categories.${category}`;
const translated = t(`production:${translationKey}`);
return translated === `production:${translationKey}` ? category : translated;
};
// Build category options with translations
@@ -109,6 +114,22 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
setLoading(true);
try {
// Type-specific validation
if (formData.check_type === QualityCheckType.VISUAL) {
const hasAnyScoring = formData.scoring_excellent_min || formData.scoring_excellent_max ||
formData.scoring_good_min || formData.scoring_good_max ||
formData.scoring_acceptable_min || formData.scoring_acceptable_max;
if (!hasAnyScoring) {
throw new Error('Los criterios de puntuación son requeridos para controles visuales');
}
}
if ([QualityCheckType.MEASUREMENT, QualityCheckType.TEMPERATURE, QualityCheckType.WEIGHT].includes(formData.check_type)) {
if (!formData.unit?.trim()) {
throw new Error('La unidad es requerida para controles de medición, temperatura y peso');
}
}
// Process applicable stages - convert string back to array
const applicableStages = formData.applicable_stages
? (typeof formData.applicable_stages === 'string'
@@ -116,6 +137,29 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
: formData.applicable_stages)
: [];
// Build scoring criteria for visual checks
let scoringCriteria: Record<string, any> | undefined;
if (formData.check_type === QualityCheckType.VISUAL) {
scoringCriteria = {
excellent: {
min: formData.scoring_excellent_min ? Number(formData.scoring_excellent_min) : undefined,
max: formData.scoring_excellent_max ? Number(formData.scoring_excellent_max) : undefined
},
good: {
min: formData.scoring_good_min ? Number(formData.scoring_good_min) : undefined,
max: formData.scoring_good_max ? Number(formData.scoring_good_max) : undefined
},
acceptable: {
min: formData.scoring_acceptable_min ? Number(formData.scoring_acceptable_min) : undefined,
max: formData.scoring_acceptable_max ? Number(formData.scoring_acceptable_max) : undefined
},
fail: {
below: formData.scoring_fail_below ? Number(formData.scoring_fail_below) : undefined,
above: formData.scoring_fail_above ? Number(formData.scoring_fail_above) : undefined
}
};
}
const templateData: QualityCheckTemplateCreate = {
name: formData.name,
template_code: formData.template_code || '',
@@ -128,13 +172,15 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
is_critical: formData.is_critical || false,
weight: Number(formData.weight) || 1.0,
applicable_stages: applicableStages.length > 0 ? applicableStages as ProcessStage[] : undefined,
created_by: currentTenant?.id || '',
created_by: user?.id || '',
// Measurement fields
min_value: formData.min_value ? Number(formData.min_value) : undefined,
max_value: formData.max_value ? Number(formData.max_value) : undefined,
target_value: formData.target_value ? Number(formData.target_value) : undefined,
unit: formData.unit || undefined,
tolerance_percentage: formData.tolerance_percentage ? Number(formData.tolerance_percentage) : undefined
unit: formData.unit && formData.unit.trim() ? formData.unit.trim() : undefined,
tolerance_percentage: formData.tolerance_percentage ? Number(formData.tolerance_percentage) : undefined,
// Scoring criteria (for visual checks)
scoring_criteria: scoringCriteria
};
// Handle recipe associations if provided
@@ -170,15 +216,16 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
isHighlight: true
};
// Determine if measurement fields should be shown based on check type
const showMeasurementFields = (checkType: string) => [
QualityCheckType.MEASUREMENT,
QualityCheckType.TEMPERATURE,
QualityCheckType.WEIGHT
].includes(checkType as QualityCheckType);
// Handler for field changes to track check_type selection
const handleFieldChange = (fieldName: string, value: any) => {
if (fieldName === 'check_type') {
setSelectedCheckType(value as QualityCheckType);
}
};
const sections = [
{
// Function to build sections dynamically based on selected check type
const getSections = () => {
const basicInfoSection = {
title: 'Información Básica',
icon: ClipboardCheck,
fields: [
@@ -204,8 +251,9 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
name: 'check_type',
type: 'select' as const,
required: true,
defaultValue: QualityCheckType.VISUAL,
options: QUALITY_CHECK_TYPE_OPTIONS
placeholder: 'Selecciona un tipo de control...',
options: QUALITY_CHECK_TYPE_OPTIONS,
helpText: 'Los campos de configuración cambiarán según el tipo seleccionado'
},
{
label: 'Categoría',
@@ -230,8 +278,9 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
helpText: 'Pasos específicos que debe seguir el operario'
}
]
},
{
};
const measurementSection = {
title: 'Configuración de Medición',
icon: Target,
fields: [
@@ -257,11 +306,12 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
helpText: 'Valor ideal que se busca alcanzar'
},
{
label: 'Unidad',
label: 'Unidad *',
name: 'unit',
type: 'text' as const,
required: true,
placeholder: '°C / g / cm',
helpText: 'Unidad de medida (ej: °C para temperatura)'
helpText: 'REQUERIDO para este tipo de control (ej: °C, g, cm)'
},
{
label: 'Tolerancia (%)',
@@ -271,22 +321,93 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
helpText: 'Porcentaje de tolerancia permitido'
}
]
},
{
};
const scoringSection = {
title: 'Criterios de Puntuación (Controles Visuales)',
icon: Target,
fields: [
{
label: 'Excelente - Mínimo',
name: 'scoring_excellent_min',
type: 'number' as const,
placeholder: '9.0',
helpText: 'Puntuación mínima para nivel excelente'
},
{
label: 'Excelente - Máximo',
name: 'scoring_excellent_max',
type: 'number' as const,
placeholder: '10.0',
helpText: 'Puntuación máxima para nivel excelente'
},
{
label: 'Bueno - Mínimo',
name: 'scoring_good_min',
type: 'number' as const,
placeholder: '7.0',
helpText: 'Puntuación mínima para nivel bueno'
},
{
label: 'Bueno - Máximo',
name: 'scoring_good_max',
type: 'number' as const,
placeholder: '8.9',
helpText: 'Puntuación máxima para nivel bueno'
},
{
label: 'Aceptable - Mínimo',
name: 'scoring_acceptable_min',
type: 'number' as const,
placeholder: '5.0',
helpText: 'Puntuación mínima para nivel aceptable'
},
{
label: 'Aceptable - Máximo',
name: 'scoring_acceptable_max',
type: 'number' as const,
placeholder: '6.9',
helpText: 'Puntuación máxima para nivel aceptable'
},
{
label: 'Fallo - Por Debajo',
name: 'scoring_fail_below',
type: 'number' as const,
placeholder: '5.0',
helpText: 'Valor por debajo del cual se considera fallo'
},
{
label: 'Fallo - Por Encima',
name: 'scoring_fail_above',
type: 'number' as const,
placeholder: '10.0',
helpText: 'Valor por encima del cual se considera fallo (opcional)'
}
]
};
const stagesSection = {
title: 'Etapas del Proceso',
icon: Settings,
fields: [
{
label: 'Etapas Aplicables',
name: 'applicable_stages',
type: 'text' as const,
placeholder: 'Se seleccionarán las etapas donde aplicar',
helpText: 'Las etapas se configuran mediante la selección múltiple',
type: 'component' as const,
component: Select,
componentProps: {
options: PROCESS_STAGE_OPTIONS,
multiple: true,
placeholder: 'Seleccionar etapas del proceso',
searchable: true
},
helpText: 'Selecciona las etapas donde se aplicará este control de calidad',
span: 2 as const
}
]
},
{
};
const recipesSection = {
title: 'Asociación con Recetas',
icon: Plus,
fields: [
@@ -300,8 +421,9 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
span: 2 as const
}
]
},
{
};
const advancedSection = {
title: 'Configuración Avanzada',
icon: Cog,
fields: [
@@ -350,8 +472,28 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
helpText: 'Si es crítico, bloquea la producción si falla'
}
]
};
// Build sections array based on selected check type
const sections = [basicInfoSection];
// Add type-specific configuration sections
if (selectedCheckType === QualityCheckType.VISUAL) {
sections.push(scoringSection);
} else if ([QualityCheckType.MEASUREMENT, QualityCheckType.TEMPERATURE, QualityCheckType.WEIGHT].includes(selectedCheckType as QualityCheckType)) {
sections.push(measurementSection);
}
];
// For BOOLEAN, TIMING, CHECKLIST - no special configuration sections yet
// Always add these sections
sections.push(stagesSection);
sections.push(recipesSection);
sections.push(advancedSection);
return sections;
};
const sections = getSections();
return (
<>
@@ -365,15 +507,8 @@ export const CreateQualityTemplateModal: React.FC<CreateQualityTemplateModalProp
size="xl"
loading={loading || externalLoading}
onSave={handleSave}
onFieldChange={handleFieldChange}
/>
{/* TODO: Stage selection would need a custom component or enhanced AddModal field types */}
{isOpen && (
<div style={{ display: 'none' }}>
<p>Nota: La selección de etapas del proceso requiere un componente personalizado no implementado en esta versión simplificada.</p>
<p>Las etapas actualmente se manejan mediante un campo de texto que debería ser reemplazado por un selector múltiple.</p>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { BaseDeleteModal } from '../../ui';
import { QualityCheckTemplate } from '../../../api/types/qualityTemplates';
import { useTranslation } from 'react-i18next';
interface DeleteQualityTemplateModalProps {
isOpen: boolean;
onClose: () => void;
template: QualityCheckTemplate | null;
onSoftDelete: (templateId: string) => Promise<void>;
onHardDelete: (templateId: string) => Promise<void>;
isLoading?: boolean;
}
/**
* Modal for quality template deletion with soft/hard delete options
* - Soft delete: Mark as inactive (reversible)
* - Hard delete: Permanent deletion with dependency checking
*/
export const DeleteQualityTemplateModal: React.FC<DeleteQualityTemplateModalProps> = ({
isOpen,
onClose,
template,
onSoftDelete,
onHardDelete,
isLoading = false,
}) => {
const { t } = useTranslation(['production', 'common']);
if (!template) return null;
return (
<BaseDeleteModal<QualityCheckTemplate>
isOpen={isOpen}
onClose={onClose}
entity={template}
onSoftDelete={onSoftDelete}
onHardDelete={onHardDelete}
isLoading={isLoading}
title={t('production:quality.delete.title', 'Eliminar Plantilla de Calidad')}
getEntityId={(temp) => temp.id}
getEntityDisplay={(temp) => ({
primaryText: temp.name,
secondaryText: `${t('production:quality.delete.template_code', 'Código')}: ${temp.template_code || 'N/A'}${t('production:quality.delete.check_type', 'Tipo')}: ${temp.check_type}`,
})}
softDeleteOption={{
title: t('production:quality.delete.soft_delete', 'Desactivar (Recomendado)'),
description: t('production:quality.delete.soft_explanation', 'La plantilla se marca como inactiva pero conserva todo su historial. Ideal para plantillas temporalmente fuera de uso.'),
benefits: t('production:quality.delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos'),
}}
hardDeleteOption={{
title: t('production:quality.delete.hard_delete', 'Eliminar Permanentemente'),
description: t('production:quality.delete.hard_explanation', 'Elimina completamente la plantilla y todos sus datos asociados. Use solo para datos erróneos o pruebas.'),
benefits: t('production:quality.delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos'),
enabled: true,
}}
softDeleteWarning={{
title: t('production:quality.delete.soft_info_title', ' Esta acción desactivará la plantilla:'),
items: [
t('production:quality.delete.soft_info_1', 'La plantilla se marcará como inactiva'),
t('production:quality.delete.soft_info_2', 'No aparecerá en listas activas'),
t('production:quality.delete.soft_info_3', 'Se conserva todo el historial y datos'),
t('production:quality.delete.soft_info_4', 'Se puede reactivar posteriormente'),
],
}}
hardDeleteWarning={{
title: t('production:quality.delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:'),
items: [
t('production:quality.delete.hard_warning_1', 'La plantilla y toda su información'),
t('production:quality.delete.hard_warning_2', 'Todas las configuraciones de calidad asociadas'),
t('production:quality.delete.hard_warning_3', 'Todo el historial de controles de calidad'),
t('production:quality.delete.hard_warning_4', 'Las alertas y métricas relacionadas'),
],
footer: t('production:quality.delete.irreversible', 'Esta acción NO se puede deshacer'),
}}
requireConfirmText={true}
confirmText="ELIMINAR"
showSuccessScreen={true}
successTitle={t('production:quality.delete.success_soft_title', 'Plantilla Desactivada')}
getSuccessMessage={(temp, mode) =>
mode === 'hard'
? t('production:quality.delete.template_deleted', { name: temp.name })
: t('production:quality.delete.template_deactivated', { name: temp.name })
}
autoCloseDelay={1500}
/>
);
};
export default DeleteQualityTemplateModal;

View File

@@ -3,8 +3,6 @@ import {
Plus,
Search,
Filter,
Edit,
Copy,
Trash2,
Eye,
CheckCircle,
@@ -32,8 +30,7 @@ import {
useQualityTemplates,
useCreateQualityTemplate,
useUpdateQualityTemplate,
useDeleteQualityTemplate,
useDuplicateQualityTemplate
useDeleteQualityTemplate
} from '../../../api/hooks/qualityTemplates';
import {
QualityCheckType,
@@ -45,6 +42,7 @@ import {
import { CreateQualityTemplateModal } from './CreateQualityTemplateModal';
import { EditQualityTemplateModal } from './EditQualityTemplateModal';
import { ViewQualityTemplateModal } from './ViewQualityTemplateModal';
import { DeleteQualityTemplateModal } from './DeleteQualityTemplateModal';
import { useTranslation } from 'react-i18next';
interface QualityTemplateManagerProps {
@@ -114,10 +112,12 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
const [selectedCheckType, setSelectedCheckType] = useState<QualityCheckType | ''>('');
const [selectedStage, setSelectedStage] = useState<ProcessStage | ''>('');
const [showActiveOnly, setShowActiveOnly] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [showCreateModal, setShowCreateModal] = useState<boolean>(false);
const [showEditModal, setShowEditModal] = useState<boolean>(false);
const [showViewModal, setShowViewModal] = useState<boolean>(false);
const [showDeleteModal, setShowDeleteModal] = useState<boolean>(false);
const [selectedTemplate, setSelectedTemplate] = useState<QualityCheckTemplate | null>(null);
const [templateToDelete, setTemplateToDelete] = useState<QualityCheckTemplate | null>(null);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
@@ -146,7 +146,6 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
const createTemplateMutation = useCreateQualityTemplate(tenantId);
const updateTemplateMutation = useUpdateQualityTemplate(tenantId);
const deleteTemplateMutation = useDeleteQualityTemplate(tenantId);
const duplicateTemplateMutation = useDuplicateQualityTemplate(tenantId);
// Filtered templates
const filteredTemplates = useMemo(() => {
@@ -214,25 +213,25 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
}
};
const handleDeleteTemplate = async (templateId: string) => {
if (!confirm('¿Estás seguro de que quieres eliminar esta plantilla?')) return;
const handleSoftDelete = async (templateId: string) => {
try {
await deleteTemplateMutation.mutateAsync(templateId);
} catch (error) {
console.error('Error deleting template:', error);
throw error;
}
};
const handleDuplicateTemplate = async (templateId: string) => {
const handleHardDelete = async (templateId: string) => {
try {
await duplicateTemplateMutation.mutateAsync(templateId);
await deleteTemplateMutation.mutateAsync(templateId);
} catch (error) {
console.error('Error duplicating template:', error);
console.error('Error deleting template:', error);
throw error;
}
};
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
const getTemplateStatusConfig = (template: QualityCheckTemplate) => {
const typeConfigs = QUALITY_CHECK_TYPE_CONFIG(t);
const typeConfig = typeConfigs[template.check_type];
@@ -241,7 +240,7 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
text: typeConfig.label,
icon: typeConfig.icon
};
};
};
if (isLoading) {
return (
@@ -406,27 +405,15 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
setShowViewModal(true);
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedTemplate(template);
setShowEditModal(true);
}
},
{
label: 'Duplicar',
icon: Copy,
priority: 'secondary',
onClick: () => handleDuplicateTemplate(template.id)
},
{
label: 'Eliminar',
icon: Trash2,
destructive: true,
priority: 'secondary',
onClick: () => handleDeleteTemplate(template.id)
onClick: () => {
setTemplateToDelete(template);
setShowDeleteModal(true);
}
}
]}
/>
@@ -491,6 +478,21 @@ export const QualityTemplateManager: React.FC<QualityTemplateManagerProps> = ({
}}
/>
)}
{/* Delete Template Modal */}
{templateToDelete && (
<DeleteQualityTemplateModal
isOpen={showDeleteModal}
onClose={() => {
setShowDeleteModal(false);
setTemplateToDelete(null);
}}
template={templateToDelete}
onSoftDelete={handleSoftDelete}
onHardDelete={handleHardDelete}
isLoading={deleteTemplateMutation.isPending}
/>
)}
</div>
);
};

View File

@@ -1,13 +1,10 @@
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 React from 'react';
import { BaseDeleteModal } from '../../ui';
import { RecipeResponse } 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;
@@ -32,345 +29,121 @@ export const DeleteRecipeModal: React.FC<DeleteRecipeModalProps> = ({
}) => {
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
// Fetch deletion summary for dependency checking
const { data: deletionSummary, isLoading: summaryLoading } = useRecipeDeletionSummary(
currentTenant?.id || '',
recipe?.id || '',
{
enabled: isOpen && !!recipe && selectedMode === 'hard' && showConfirmation,
enabled: isOpen && !!recipe,
}
);
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
// Build dependency check warnings
const dependencyWarnings: string[] = [];
if (deletionSummary) {
if (deletionSummary.production_batches_count > 0) {
dependencyWarnings.push(
t('recipes:delete.batches_affected',
{ count: deletionSummary.production_batches_count },
`${deletionSummary.production_batches_count} lotes de producción afectados`
)
);
}
if (deletionSummary.affected_orders_count > 0) {
dependencyWarnings.push(
t('recipes:delete.orders_affected',
{ count: deletionSummary.affected_orders_count },
`${deletionSummary.affected_orders_count} pedidos afectados`
)
);
}
if (deletionSummary.warnings && deletionSummary.warnings.length > 0) {
dependencyWarnings.push(...deletionSummary.warnings);
}
};
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);
// Build hard delete warning items
const hardDeleteItems = [
t('recipes:delete.hard_warning_1', 'La receta y toda su información'),
t('recipes:delete.hard_warning_2', 'Todos los ingredientes asociados'),
];
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>
);
if (deletionSummary) {
if (deletionSummary.production_batches_count > 0) {
hardDeleteItems.push(
t('recipes:delete.batches_affected',
{ count: deletionSummary.production_batches_count },
`${deletionSummary.production_batches_count} lotes de producción`
)
);
}
if (deletionSummary.affected_orders_count > 0) {
hardDeleteItems.push(
t('recipes:delete.orders_affected',
{ count: deletionSummary.affected_orders_count },
`${deletionSummary.affected_orders_count} pedidos afectados`
)
);
}
}
// 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>
<BaseDeleteModal
isOpen={isOpen}
onClose={onClose}
entity={recipe}
onSoftDelete={onSoftDelete}
onHardDelete={onHardDelete}
isLoading={isLoading}
title={t('recipes:delete.title', 'Eliminar Receta')}
getEntityId={(rec) => rec.id}
getEntityDisplay={(rec) => ({
primaryText: rec.name,
secondaryText: `${t('recipes:delete.recipe_code', 'Código')}: ${rec.recipe_code || 'N/A'}${t('recipes:delete.recipe_category', 'Categoría')}: ${rec.category}`,
})}
softDeleteOption={{
title: t('recipes:delete.soft_delete', 'Archivar (Recomendado)'),
description: t('recipes:delete.soft_explanation', 'La receta se marca como archivada pero conserva todo su historial. Ideal para recetas fuera de uso temporal.'),
benefits: t('recipes:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos'),
}}
hardDeleteOption={{
title: t('recipes:delete.hard_delete', 'Eliminar Permanentemente'),
description: t('recipes:delete.hard_explanation', 'Elimina completamente la receta y todos sus datos asociados. Use solo para datos erróneos o pruebas.'),
benefits: t('recipes:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos'),
enabled: true,
}}
softDeleteWarning={{
title: t('recipes:delete.soft_info_title', ' Esta acción archivará la receta:'),
items: [
t('recipes:delete.soft_info_1', 'La receta cambiará a estado ARCHIVADO'),
t('recipes:delete.soft_info_2', 'No aparecerá en listas activas'),
t('recipes:delete.soft_info_3', 'Se conserva todo el historial y datos'),
t('recipes:delete.soft_info_4', 'Se puede reactivar posteriormente'),
],
}}
hardDeleteWarning={{
title: t('recipes:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:'),
items: hardDeleteItems,
footer: t('recipes:delete.irreversible', 'Esta acción NO se puede deshacer'),
}}
dependencyCheck={{
isLoading: summaryLoading,
canDelete: deletionSummary?.can_delete !== false,
warnings: dependencyWarnings,
}}
requireConfirmText={true}
confirmText="ELIMINAR"
showSuccessScreen={true}
successTitle={t('recipes:delete.success_soft_title', 'Receta Archivada')}
getSuccessMessage={(rec, mode) =>
mode === 'hard'
? t('recipes:delete.recipe_deleted', { name: rec.name })
: t('recipes:delete.recipe_archived', { name: rec.name })
}
autoCloseDelay={1500}
/>
);
};

View File

@@ -1,11 +1,8 @@
import React, { useState } from 'react';
import { Trash2, AlertTriangle, Info } from 'lucide-react';
import { Modal, Button } from '../../ui';
import React from 'react';
import { BaseDeleteModal } from '../../ui';
import { SupplierResponse, SupplierDeletionSummary } from '../../../api/types/suppliers';
import { useTranslation } from 'react-i18next';
type DeleteMode = 'soft' | 'hard';
interface DeleteSupplierModalProps {
isOpen: boolean;
onClose: () => void;
@@ -29,323 +26,65 @@ export const DeleteSupplierModal: React.FC<DeleteSupplierModalProps> = ({
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>
<BaseDeleteModal<SupplierResponse, SupplierDeletionSummary>
isOpen={isOpen}
onClose={onClose}
entity={supplier}
onSoftDelete={onSoftDelete}
onHardDelete={onHardDelete}
isLoading={isLoading}
title={t('suppliers:delete.title', 'Eliminar Proveedor')}
getEntityId={(sup) => sup.id}
getEntityDisplay={(sup) => ({
primaryText: sup.name,
secondaryText: `${t('suppliers:delete.supplier_code', 'Código')}: ${sup.supplier_code || 'N/A'}${t('suppliers:delete.supplier_type', 'Tipo')}: ${sup.supplier_type}`,
})}
softDeleteOption={{
title: t('suppliers:delete.soft_delete', 'Desactivar (Recomendado)'),
description: t('suppliers:delete.soft_explanation', 'El proveedor se marca como inactivo pero conserva todo su historial. Ideal para proveedores temporalmente fuera del catálogo.'),
benefits: t('suppliers:delete.soft_benefits', '✓ Reversible • ✓ Conserva historial • ✓ Conserva datos'),
}}
hardDeleteOption={{
title: t('suppliers:delete.hard_delete', 'Eliminar Permanentemente'),
description: t('suppliers:delete.hard_explanation', 'Elimina completamente el proveedor y todos sus datos asociados. Use solo para datos erróneos o pruebas.'),
benefits: t('suppliers:delete.hard_risks', '⚠️ No reversible • ⚠️ Elimina historial • ⚠️ Elimina todos los datos'),
enabled: true,
}}
softDeleteWarning={{
title: t('suppliers:delete.soft_info_title', ' Esta acción desactivará el proveedor:'),
items: [
t('suppliers:delete.soft_info_1', 'El proveedor se marcará como inactivo'),
t('suppliers:delete.soft_info_2', 'No aparecerá en listas activas'),
t('suppliers:delete.soft_info_3', 'Se conserva todo el historial y datos'),
t('suppliers:delete.soft_info_4', 'Se puede reactivar posteriormente'),
],
}}
hardDeleteWarning={{
title: t('suppliers:delete.hard_warning_title', '⚠️ Esta acción eliminará permanentemente:'),
items: [
t('suppliers:delete.hard_warning_1', 'El proveedor y toda su información'),
t('suppliers:delete.hard_warning_2', 'Todas las listas de precios asociadas'),
t('suppliers:delete.hard_warning_3', 'Todo el historial de calidad y rendimiento'),
t('suppliers:delete.hard_warning_4', 'Las alertas y scorecards relacionados'),
],
footer: t('suppliers:delete.irreversible', 'Esta acción NO se puede deshacer'),
}}
requireConfirmText={true}
confirmText="ELIMINAR"
showSuccessScreen={false}
showDeletionSummary={true}
deletionSummaryTitle={t('suppliers:delete.summary_title', 'Eliminación Completada')}
formatDeletionSummary={(summary) => ({
[t('suppliers:delete.deleted_price_lists', 'Listas de precios eliminadas')]: summary.deleted_price_lists,
[t('suppliers:delete.deleted_quality_reviews', 'Revisiones de calidad eliminadas')]: summary.deleted_quality_reviews,
[t('suppliers:delete.deleted_performance_metrics', 'Métricas de rendimiento eliminadas')]: summary.deleted_performance_metrics,
[t('suppliers:delete.deleted_alerts', 'Alertas eliminadas')]: summary.deleted_alerts,
[t('suppliers:delete.deleted_scorecards', 'Scorecards eliminados')]: summary.deleted_scorecards,
})}
/>
);
};

View File

@@ -0,0 +1,328 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { DollarSign, Package, Calendar, Info } from 'lucide-react';
import { AddModal, AddModalSection } from '../../ui/AddModal/AddModal';
import { ProductSelector } from './ProductSelector';
import {
SupplierPriceListCreate,
SupplierPriceListUpdate,
SupplierPriceListResponse,
} from '../../../api/types/suppliers';
import { IngredientResponse, UnitOfMeasure } from '../../../api/types/inventory';
interface PriceListModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (priceListData: SupplierPriceListCreate | SupplierPriceListUpdate) => Promise<void>;
mode: 'create' | 'edit';
initialData?: SupplierPriceListResponse;
loading?: boolean;
excludeProductIds?: string[];
// Wait-for-refetch support
waitForRefetch?: boolean;
isRefetching?: boolean;
onSaveComplete?: () => Promise<void>;
}
export const PriceListModal: React.FC<PriceListModalProps> = ({
isOpen,
onClose,
onSave,
mode,
initialData,
loading = false,
excludeProductIds = [],
waitForRefetch,
isRefetching,
onSaveComplete,
}) => {
const { t } = useTranslation(['suppliers', 'common']);
const [selectedProduct, setSelectedProduct] = useState<IngredientResponse | undefined>();
const [formData, setFormData] = useState<Record<string, any>>({});
// Initialize form data when modal opens or initialData changes
useEffect(() => {
if (isOpen) {
if (mode === 'edit' && initialData) {
setFormData({
inventory_product_id: initialData.inventory_product_id,
product_code: initialData.product_code || '',
unit_price: initialData.unit_price,
unit_of_measure: initialData.unit_of_measure,
minimum_order_quantity: initialData.minimum_order_quantity || '',
price_per_unit: initialData.price_per_unit,
effective_date: initialData.effective_date?.split('T')[0] || new Date().toISOString().split('T')[0],
expiry_date: initialData.expiry_date?.split('T')[0] || '',
is_active: initialData.is_active ? 'true' : 'false',
brand: initialData.brand || '',
packaging_size: initialData.packaging_size || '',
origin_country: initialData.origin_country || '',
shelf_life_days: initialData.shelf_life_days || '',
storage_requirements: initialData.storage_requirements || '',
});
} else {
// Reset form for create mode
setFormData({
inventory_product_id: '',
product_code: '',
unit_price: '',
unit_of_measure: 'kg',
minimum_order_quantity: '',
price_per_unit: '',
effective_date: new Date().toISOString().split('T')[0],
expiry_date: '',
is_active: 'true',
brand: '',
packaging_size: '',
origin_country: '',
shelf_life_days: '',
storage_requirements: '',
});
setSelectedProduct(undefined);
}
}
}, [isOpen, mode, initialData]);
const handleProductChange = (productId: string, product?: IngredientResponse) => {
setSelectedProduct(product);
setFormData(prev => ({
...prev,
inventory_product_id: productId,
// Auto-fill some fields from product if available
unit_of_measure: product?.unit_of_measure || prev.unit_of_measure,
brand: product?.brand || prev.brand,
}));
};
const handleFieldChange = (fieldName: string, value: any) => {
setFormData(prev => ({
...prev,
[fieldName]: value,
}));
};
const handleSave = async (data: Record<string, any>) => {
// Clean and prepare the data
const priceListData: SupplierPriceListCreate | SupplierPriceListUpdate = {
inventory_product_id: data.inventory_product_id,
product_code: data.product_code || null,
unit_price: parseFloat(data.unit_price),
unit_of_measure: data.unit_of_measure,
minimum_order_quantity: data.minimum_order_quantity ? parseFloat(data.minimum_order_quantity) : null,
price_per_unit: parseFloat(data.price_per_unit),
effective_date: data.effective_date || undefined,
expiry_date: data.expiry_date || null,
is_active: data.is_active === 'true' || data.is_active === true,
brand: data.brand || null,
packaging_size: data.packaging_size || null,
origin_country: data.origin_country || null,
shelf_life_days: data.shelf_life_days ? parseInt(data.shelf_life_days) : null,
storage_requirements: data.storage_requirements || null,
};
await onSave(priceListData);
};
const unitOptions = [
{ label: t('common:units.kg'), value: UnitOfMeasure.KILOGRAMS },
{ label: t('common:units.g'), value: UnitOfMeasure.GRAMS },
{ label: t('common:units.l'), value: UnitOfMeasure.LITERS },
{ label: t('common:units.ml'), value: UnitOfMeasure.MILLILITERS },
{ label: t('common:units.units'), value: UnitOfMeasure.UNITS },
{ label: t('common:units.pieces'), value: UnitOfMeasure.PIECES },
{ label: t('common:units.packages'), value: UnitOfMeasure.PACKAGES },
{ label: t('common:units.bags'), value: UnitOfMeasure.BAGS },
{ label: t('common:units.boxes'), value: UnitOfMeasure.BOXES },
];
const sections: AddModalSection[] = [
{
title: t('price_list.sections.product_selection'),
icon: Package,
columns: 1,
fields: [
{
name: 'inventory_product_id',
label: t('price_list.fields.product'),
type: 'component',
required: true,
component: ProductSelector,
componentProps: {
value: formData.inventory_product_id,
onChange: handleProductChange,
excludeIds: mode === 'create' ? excludeProductIds : [],
disabled: mode === 'edit', // Can't change product in edit mode
isRequired: true,
},
helpText: mode === 'edit'
? t('price_list.help.product_locked')
: t('price_list.help.select_product'),
},
{
name: 'product_code',
label: t('price_list.fields.product_code'),
type: 'text',
required: false,
placeholder: t('price_list.placeholders.product_code'),
helpText: t('price_list.help.product_code'),
},
],
},
{
title: t('price_list.sections.pricing'),
icon: DollarSign,
columns: 2,
fields: [
{
name: 'unit_price',
label: t('price_list.fields.unit_price'),
type: 'number',
required: true,
placeholder: '0.00',
validation: (value) => {
const num = parseFloat(value as string);
if (isNaN(num) || num <= 0) {
return t('price_list.validation.price_positive');
}
return null;
},
helpText: t('price_list.help.unit_price'),
},
{
name: 'price_per_unit',
label: t('price_list.fields.price_per_unit'),
type: 'number',
required: true,
placeholder: '0.00',
validation: (value) => {
const num = parseFloat(value as string);
if (isNaN(num) || num <= 0) {
return t('price_list.validation.price_positive');
}
return null;
},
helpText: t('price_list.help.price_per_unit'),
},
{
name: 'unit_of_measure',
label: t('price_list.fields.unit_of_measure'),
type: 'select',
required: true,
options: unitOptions,
helpText: t('price_list.help.unit_of_measure'),
},
{
name: 'minimum_order_quantity',
label: t('price_list.fields.minimum_order'),
type: 'number',
required: false,
placeholder: '0',
helpText: t('price_list.help.minimum_order'),
},
],
},
{
title: t('price_list.sections.validity'),
icon: Calendar,
columns: 2,
fields: [
{
name: 'effective_date',
label: t('price_list.fields.effective_date'),
type: 'date',
required: false,
defaultValue: new Date().toISOString().split('T')[0],
helpText: t('price_list.help.effective_date'),
},
{
name: 'expiry_date',
label: t('price_list.fields.expiry_date'),
type: 'date',
required: false,
helpText: t('price_list.help.expiry_date'),
},
{
name: 'is_active',
label: t('price_list.fields.is_active'),
type: 'select',
required: false,
defaultValue: 'true',
options: [
{ label: t('common:yes'), value: 'true' },
{ label: t('common:no'), value: 'false' }
],
helpText: t('price_list.help.is_active'),
},
],
},
{
title: t('price_list.sections.product_details'),
icon: Info,
columns: 2,
fields: [
{
name: 'brand',
label: t('price_list.fields.brand'),
type: 'text',
required: false,
placeholder: t('price_list.placeholders.brand'),
},
{
name: 'packaging_size',
label: t('price_list.fields.packaging_size'),
type: 'text',
required: false,
placeholder: t('price_list.placeholders.packaging_size'),
helpText: t('price_list.help.packaging_size'),
},
{
name: 'origin_country',
label: t('price_list.fields.origin_country'),
type: 'text',
required: false,
placeholder: t('price_list.placeholders.origin_country'),
},
{
name: 'shelf_life_days',
label: t('price_list.fields.shelf_life_days'),
type: 'number',
required: false,
placeholder: '0',
helpText: t('price_list.help.shelf_life_days'),
},
{
name: 'storage_requirements',
label: t('price_list.fields.storage_requirements'),
type: 'textarea',
required: false,
placeholder: t('price_list.placeholders.storage_requirements'),
span: 2,
},
],
},
];
return (
<AddModal
isOpen={isOpen}
onClose={onClose}
title={mode === 'create'
? t('price_list.modal.title_create')
: t('price_list.modal.title_edit')
}
subtitle={mode === 'create'
? t('price_list.modal.subtitle_create')
: t('price_list.modal.subtitle_edit')
}
sections={sections}
onSave={handleSave}
onCancel={onClose}
size="xl"
loading={loading || isRefetching}
initialData={formData}
onFieldChange={handleFieldChange}
waitForRefetch={waitForRefetch}
showSuccessState={true}
/>
);
};

View File

@@ -0,0 +1,85 @@
import React, { useMemo } from 'react';
import { Select, SelectOption } from '../../ui/Select';
import { useIngredients } from '../../../api/hooks/inventory';
import { useCurrentTenant } from '../../../stores/tenant.store';
import { IngredientResponse } from '../../../api/types/inventory';
interface ProductSelectorProps {
value?: string;
onChange: (productId: string, product?: IngredientResponse) => void;
placeholder?: string;
error?: string;
disabled?: boolean;
excludeIds?: string[];
label?: string;
isRequired?: boolean;
}
export function ProductSelector({
value,
onChange,
placeholder = 'Select product...',
error,
disabled = false,
excludeIds = [],
label = 'Product',
isRequired = false,
}: ProductSelectorProps) {
const currentTenant = useCurrentTenant();
// Fetch all active ingredients
const { data: ingredients, isLoading } = useIngredients(
currentTenant?.id || '',
{ is_active: true },
{ enabled: !!currentTenant?.id }
);
// Convert ingredients to select options
const productOptions: SelectOption[] = useMemo(() => {
if (!ingredients) return [];
return ingredients
.filter(ingredient => !excludeIds.includes(ingredient.id))
.map(ingredient => ({
value: ingredient.id,
label: ingredient.name,
description: ingredient.category
? `${ingredient.category}${ingredient.subcategory ? ` - ${ingredient.subcategory}` : ''}`
: undefined,
group: ingredient.category || 'Other',
}))
.sort((a, b) => {
// Sort by group first, then by label
const groupCompare = (a.group || '').localeCompare(b.group || '');
return groupCompare !== 0 ? groupCompare : a.label.localeCompare(b.label);
});
}, [ingredients, excludeIds]);
const handleChange = (selectedValue: string | number | Array<string | number>) => {
if (typeof selectedValue === 'string') {
const selectedProduct = ingredients?.find(ing => ing.id === selectedValue);
onChange(selectedValue, selectedProduct);
}
};
return (
<Select
label={label}
value={value}
onChange={handleChange}
options={productOptions}
placeholder={placeholder}
error={error}
disabled={disabled || isLoading}
loading={isLoading}
searchable
clearable
isRequired={isRequired}
isInvalid={!!error}
loadingMessage="Loading products..."
noOptionsMessage="No products available"
size="md"
variant="outline"
/>
);
}

View File

@@ -0,0 +1,731 @@
import React, { useState, useEffect } from 'react';
import { Package, DollarSign, Calendar, Info, Plus, Edit, Trash2, CheckCircle, X, Save, ChevronDown, ChevronUp, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
import { SupplierResponse, SupplierPriceListResponse, SupplierPriceListUpdate } from '../../../api/types/suppliers';
import { formatters } from '../../ui/Stats/StatsPresets';
import { statusColors } from '../../../styles/colors';
import { Button } from '../../ui/Button';
import { useIngredients } from '../../../api/hooks/inventory';
interface SupplierPriceListViewModalProps {
isOpen: boolean;
onClose: () => void;
supplier: SupplierResponse;
priceLists: SupplierPriceListResponse[];
loading?: boolean;
tenantId: string;
onAddPrice?: () => void;
onEditPrice?: (priceId: string, updateData: SupplierPriceListUpdate) => Promise<void>;
onDeletePrice?: (priceId: string) => Promise<void>;
// Wait-for-refetch support
waitForRefetch?: boolean;
isRefetching?: boolean;
onSaveComplete?: () => Promise<void>;
}
/**
* SupplierPriceListViewModal - Card-based price list management modal
* Follows the same UI/UX pattern as BatchModal for inventory stock management
*/
export const SupplierPriceListViewModal: React.FC<SupplierPriceListViewModalProps> = ({
isOpen,
onClose,
supplier,
priceLists = [],
loading = false,
tenantId,
onAddPrice,
onEditPrice,
onDeletePrice,
waitForRefetch,
isRefetching,
onSaveComplete
}) => {
const { t } = useTranslation(['suppliers', 'common']);
const [editingPrice, setEditingPrice] = useState<string | null>(null);
const [editData, setEditData] = useState<SupplierPriceListUpdate>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isWaitingForRefetch, setIsWaitingForRefetch] = useState(false);
// Collapsible state - start with all price entries collapsed for better UX
const [collapsedPrices, setCollapsedPrices] = useState<Set<string>>(new Set());
// Initialize all prices as collapsed when prices change or modal opens
useEffect(() => {
if (isOpen && priceLists.length > 0) {
setCollapsedPrices(new Set(priceLists.map(p => p.id)));
}
}, [isOpen, priceLists]);
// Fetch ingredients for product name display
const { data: ingredientsData } = useIngredients(
tenantId,
{},
{ enabled: !!tenantId && isOpen }
);
const ingredients = ingredientsData || [];
// Helper to get product name by ID
const getProductName = (productId: string): string => {
const product = ingredients.find(ing => ing.id === productId);
return product?.name || 'Producto desconocido';
};
// Toggle price entry collapse state
const togglePriceCollapse = (priceId: string) => {
setCollapsedPrices(prev => {
const next = new Set(prev);
if (next.has(priceId)) {
next.delete(priceId);
} else {
next.add(priceId);
}
return next;
});
};
// Get price status based on validity dates and active state
const getPriceStatus = (price: SupplierPriceListResponse) => {
if (!price.is_active) {
return {
label: 'Inactivo',
color: statusColors.cancelled.primary,
icon: X,
isCritical: false
};
}
const today = new Date();
const effectiveDate = price.effective_date ? new Date(price.effective_date) : null;
const expiryDate = price.expiry_date ? new Date(price.expiry_date) : null;
// Check if not yet effective
if (effectiveDate && effectiveDate > today) {
return {
label: 'Programado',
color: statusColors.inProgress.primary,
icon: Calendar,
isCritical: false
};
}
// Check if expired
if (expiryDate && expiryDate < today) {
return {
label: 'Vencido',
color: statusColors.expired.primary,
icon: AlertTriangle,
isCritical: true
};
}
// Check if expiring soon (within 30 days)
if (expiryDate) {
const daysUntilExpiry = Math.ceil((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
if (daysUntilExpiry <= 30) {
return {
label: 'Próximo a Vencer',
color: statusColors.pending.primary,
icon: AlertTriangle,
isCritical: false
};
}
}
return {
label: 'Activo',
color: statusColors.completed.primary,
icon: CheckCircle,
isCritical: false
};
};
const handleEditStart = (price: SupplierPriceListResponse) => {
setEditingPrice(price.id);
// Auto-expand when editing
setCollapsedPrices(prev => {
const next = new Set(prev);
next.delete(price.id);
return next;
});
setEditData({
unit_price: price.unit_price,
unit_of_measure: price.unit_of_measure,
minimum_order_quantity: price.minimum_order_quantity,
effective_date: price.effective_date?.split('T')[0],
expiry_date: price.expiry_date?.split('T')[0] || undefined,
is_active: price.is_active,
brand: price.brand || undefined,
packaging_size: price.packaging_size || undefined,
origin_country: price.origin_country || undefined,
shelf_life_days: price.shelf_life_days || undefined,
storage_requirements: price.storage_requirements || undefined
});
};
const handleEditCancel = () => {
setEditingPrice(null);
setEditData({});
};
const handleEditSave = async (priceId: string) => {
if (!onEditPrice) 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('SupplierPriceListViewModal: No edit data to save for price', priceId);
return;
}
console.log('SupplierPriceListViewModal: Saving price data:', dataToSave);
setIsSubmitting(true);
try {
// Execute the update mutation
await onEditPrice(priceId, 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 price update');
resolve();
return;
}
if (!isRefetching) {
clearInterval(interval);
resolve();
}
}, 100);
});
setIsWaitingForRefetch(false);
}
// Clear editing state after save (and optional refetch) completes
setEditingPrice(null);
setEditData({});
} catch (error) {
console.error('Error updating price:', error);
} finally {
setIsSubmitting(false);
setIsWaitingForRefetch(false);
}
};
const handleDelete = async (priceId: string) => {
if (!onDeletePrice) return;
const confirmed = window.confirm('¿Está seguro que desea eliminar este precio? Esta acción no se puede deshacer.');
if (!confirmed) return;
setIsSubmitting(true);
try {
await onDeletePrice(priceId);
} catch (error) {
console.error('Error deleting price:', error);
} finally {
setIsSubmitting(false);
}
};
const statusConfig = {
color: statusColors.inProgress.primary,
text: `${priceLists.length} precios`,
icon: DollarSign
};
// Create card-based price list
const priceCards = priceLists.length > 0 ? (
<div className="space-y-4">
{priceLists.map((price) => {
const status = getPriceStatus(price);
const StatusIcon = status.icon;
const isEditing = editingPrice === price.id;
const productName = getProductName(price.inventory_product_id);
return (
<div
key={price.id}
className="bg-[var(--surface-secondary)] rounded-lg border border-[var(--border-primary)] overflow-hidden"
style={{
borderColor: status.isCritical ? `${status.color}40` : undefined,
backgroundColor: status.isCritical ? `${status.color}05` : undefined
}}
>
{/* Header */}
<div className="p-4 border-b border-[var(--border-secondary)]">
<div className="flex items-center justify-between gap-3">
{/* Left side: clickable area for collapse/expand */}
<button
onClick={() => !isEditing && togglePriceCollapse(price.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={!collapsedPrices.has(price.id)}
aria-label={`${collapsedPrices.has(price.id) ? 'Expandir' : 'Colapsar'} precio de ${productName}`}
>
<div
className="flex-shrink-0 p-2 rounded-lg"
style={{ backgroundColor: `${status.color}15` }}
>
<StatusIcon
className="w-5 h-5"
style={{ color: status.color }}
/>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-[var(--text-primary)]">
{productName}
</h3>
<div
className="text-sm font-medium"
style={{ color: status.color }}
>
{status.label}
</div>
{/* Inline summary when collapsed */}
{collapsedPrices.has(price.id) && (
<div className="text-xs text-[var(--text-secondary)] mt-1">
{formatters.currency(price.unit_price)} / {price.unit_of_measure}
{price.expiry_date && (
<> Vence: {new Date(price.expiry_date).toLocaleDateString('es-ES')}</>
)}
</div>
)}
</div>
</button>
{/* Right side: action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
{/* Collapse/Expand chevron */}
{!isEditing && (
<button
onClick={() => togglePriceCollapse(price.id)}
className="p-2 rounded-md hover:bg-[var(--surface-tertiary)] transition-colors"
aria-label={collapsedPrices.has(price.id) ? 'Expandir' : 'Colapsar'}
>
{collapsedPrices.has(price.id) ? (
<ChevronDown className="w-5 h-5 text-[var(--text-secondary)]" />
) : (
<ChevronUp className="w-5 h-5 text-[var(--text-secondary)]" />
)}
</button>
)}
{!isEditing && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleEditStart(price)}
disabled={isSubmitting}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(price.id)}
disabled={isSubmitting}
className="text-red-600 border-red-300 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
{isEditing && (
<>
<Button
variant="outline"
size="sm"
onClick={handleEditCancel}
disabled={isSubmitting}
>
<X className="w-4 h-4" />
</Button>
<Button
variant="primary"
size="sm"
onClick={() => handleEditSave(price.id)}
disabled={isSubmitting}
isLoading={isSubmitting}
>
<Save className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Content - Only show when expanded */}
{!collapsedPrices.has(price.id) && (
<div className="p-4 space-y-4 transition-all duration-200 ease-in-out">
{/* Pricing Information Section */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Precio Unitario
</label>
{isEditing ? (
<input
type="number"
step="0.01"
value={editData.unit_price || ''}
onChange={(e) => setEditData(prev => ({ ...prev, unit_price: Number(e.target.value) }))}
className="w-full px-3 py-2 border border-[var(--border-color)] bg-[var(--background-primary)] text-[var(--text-primary)] rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div className="text-lg font-bold text-[var(--text-primary)]">
{formatters.currency(price.unit_price)}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Precio por Unidad
</label>
<div className="text-sm text-[var(--text-secondary)]">
{formatters.currency(price.price_per_unit)}
</div>
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Unidad de Medida
</label>
{isEditing ? (
<input
type="text"
value={editData.unit_of_measure || ''}
onChange={(e) => setEditData(prev => ({ ...prev, unit_of_measure: 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)]">
{price.unit_of_measure}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Cantidad Mínima de Pedido
</label>
{isEditing ? (
<input
type="number"
value={editData.minimum_order_quantity || ''}
onChange={(e) => setEditData(prev => ({ ...prev, minimum_order_quantity: Number(e.target.value) || null }))}
placeholder="Opcional"
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)]">
{price.minimum_order_quantity || 'N/A'}
</div>
)}
</div>
</div>
{/* Product Details Section */}
{(price.product_code || price.brand || price.packaging_size) && (
<div className="pt-3 border-t border-[var(--border-secondary)]">
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
Detalles del Producto
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{price.product_code && (
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Código de Producto
</label>
<div className="text-sm text-[var(--text-secondary)]">
{price.product_code}
</div>
</div>
)}
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Marca
</label>
{isEditing ? (
<input
type="text"
value={editData.brand || ''}
onChange={(e) => setEditData(prev => ({ ...prev, brand: e.target.value || null }))}
placeholder="Marca"
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)]">
{price.brand || 'N/A'}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Tamaño del Empaque
</label>
{isEditing ? (
<input
type="text"
value={editData.packaging_size || ''}
onChange={(e) => setEditData(prev => ({ ...prev, packaging_size: e.target.value || null }))}
placeholder="Ej: 25kg"
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)]">
{price.packaging_size || 'N/A'}
</div>
)}
</div>
{price.origin_country && (
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
País de Origen
</label>
{isEditing ? (
<input
type="text"
value={editData.origin_country || ''}
onChange={(e) => setEditData(prev => ({ ...prev, origin_country: e.target.value || null }))}
placeholder="País"
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)]">
{price.origin_country}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Validity Section */}
<div className="pt-3 border-t border-[var(--border-secondary)]">
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
Vigencia
</div>
<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">
Fecha de Vigencia
</label>
{isEditing ? (
<input
type="date"
value={editData.effective_date ? new Date(editData.effective_date).toISOString().split('T')[0] : ''}
onChange={(e) => setEditData(prev => ({ ...prev, effective_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)]">
{price.effective_date
? new Date(price.effective_date).toLocaleDateString('es-ES')
: 'N/A'
}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Fecha de Vencimiento
</label>
{isEditing ? (
<input
type="date"
value={editData.expiry_date ? new Date(editData.expiry_date).toISOString().split('T')[0] : ''}
onChange={(e) => setEditData(prev => ({ ...prev, expiry_date: 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"
/>
) : (
<div className="text-sm text-[var(--text-secondary)]">
{price.expiry_date
? new Date(price.expiry_date).toLocaleDateString('es-ES')
: 'Sin vencimiento'
}
</div>
)}
</div>
<div>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Estado
</label>
{isEditing ? (
<select
value={editData.is_active ? 'active' : 'inactive'}
onChange={(e) => setEditData(prev => ({ ...prev, is_active: e.target.value === 'active' }))}
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="active">Activo</option>
<option value="inactive">Inactivo</option>
</select>
) : (
<div className="text-sm text-[var(--text-secondary)]">
{price.is_active ? 'Activo' : 'Inactivo'}
</div>
)}
</div>
</div>
</div>
{/* Storage Section */}
{(price.shelf_life_days || price.storage_requirements) && (
<div className="pt-3 border-t border-[var(--border-secondary)]">
<div className="text-sm font-medium text-[var(--text-tertiary)] mb-3">
Almacenamiento
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{price.shelf_life_days && (
<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)]">
{price.shelf_life_days} días
</div>
)}
</div>
)}
{(price.storage_requirements || isEditing) && (
<div className={price.shelf_life_days ? '' : 'col-span-2'}>
<label className="text-xs font-medium text-[var(--text-tertiary)] mb-1 block">
Requisitos de Almacenamiento
</label>
{isEditing ? (
<textarea
value={editData.storage_requirements || ''}
onChange={(e) => setEditData(prev => ({ ...prev, storage_requirements: e.target.value || null }))}
placeholder="Requisitos 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"
/>
) : (
price.storage_requirements ? (
<div className="p-2 bg-[var(--surface-tertiary)] rounded-md">
<div className="text-xs text-[var(--text-secondary)] italic">
"{price.storage_requirements}"
</div>
</div>
) : (
<div className="text-sm text-[var(--text-secondary)]">
N/A
</div>
)
)}
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-[var(--text-secondary)]">
<DollarSign className="w-16 h-16 mx-auto mb-4 opacity-30" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay precios registrados
</h3>
<p className="text-sm mb-6">
Agregue precios para los productos que suministra este proveedor
</p>
{onAddPrice && (
<Button
variant="primary"
onClick={onAddPrice}
className="inline-flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Agregar Primer Precio
</Button>
)}
</div>
);
const sections = [
{
title: 'Lista de Precios',
icon: DollarSign,
fields: [
{
label: '',
value: priceCards,
span: 2 as const
}
]
}
];
const actions = [];
// Only show "Agregar Precio" button when there are existing prices
if (onAddPrice && priceLists.length > 0) {
actions.push({
label: 'Agregar Precio',
icon: Plus,
variant: 'primary' as const,
onClick: onAddPrice
});
}
return (
<EditViewModal
isOpen={isOpen}
onClose={onClose}
mode="view"
title={`Lista de Precios - ${supplier.name}`}
subtitle={`${priceLists.length} precios registrados`}
statusIndicator={statusConfig}
sections={sections}
size="lg"
loading={loading || isSubmitting || isWaitingForRefetch}
showDefaultActions={false}
actions={actions}
/>
);
};
export default SupplierPriceListViewModal;

View File

@@ -1,7 +1,7 @@
/**
* Supplier Domain Components
* Export all supplier-related components
*/
export { CreateSupplierForm } from './CreateSupplierForm';
export { DeleteSupplierModal } from './DeleteSupplierModal';
export { PriceListModal } from './PriceListModal';
export { ProductSelector } from './ProductSelector';
export { SupplierPriceListViewModal } from './SupplierPriceListViewModal';
// Export types