Improve the frontend 2
This commit is contained in:
@@ -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;
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
384
frontend/src/components/domain/forecasting/RetrainModelModal.tsx
Normal file
384
frontend/src/components/domain/forecasting/RetrainModelModal.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
328
frontend/src/components/domain/suppliers/PriceListModal.tsx
Normal file
328
frontend/src/components/domain/suppliers/PriceListModal.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
85
frontend/src/components/domain/suppliers/ProductSelector.tsx
Normal file
85
frontend/src/components/domain/suppliers/ProductSelector.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, forwardRef, useMemo } from 'react';
|
||||
import React, { useState, useCallback, forwardRef, useMemo, useEffect } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -7,6 +7,7 @@ import { useCurrentTenantAccess } from '../../../stores/tenant.store';
|
||||
import { useHasAccess } from '../../../hooks/useAccessControl';
|
||||
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
|
||||
import { useSubscriptionAwareRoutes } from '../../../hooks/useSubscriptionAwareRoutes';
|
||||
import { useSubscriptionEvents } from '../../../contexts/SubscriptionEventsContext';
|
||||
import { Button } from '../../ui';
|
||||
import { Badge } from '../../ui';
|
||||
import { Tooltip } from '../../ui';
|
||||
@@ -161,11 +162,18 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
||||
const { subscriptionVersion } = useSubscriptionEvents();
|
||||
|
||||
// Get subscription-aware navigation routes
|
||||
const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []);
|
||||
const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes);
|
||||
|
||||
// Force re-render when subscription changes
|
||||
useEffect(() => {
|
||||
// The subscriptionVersion change will trigger a re-render
|
||||
// This ensures the sidebar picks up new route filtering based on updated subscription
|
||||
}, [subscriptionVersion]);
|
||||
|
||||
// Map route paths to translation keys
|
||||
const getTranslationKey = (routePath: string): string => {
|
||||
const pathMappings: Record<string, string> = {
|
||||
@@ -1079,4 +1087,4 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
|
||||
);
|
||||
});
|
||||
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
Sidebar.displayName = 'Sidebar';
|
||||
|
||||
485
frontend/src/components/ui/BaseDeleteModal/BaseDeleteModal.tsx
Normal file
485
frontend/src/components/ui/BaseDeleteModal/BaseDeleteModal.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Trash2, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Modal, Button } from '../index';
|
||||
|
||||
export type DeleteMode = 'soft' | 'hard';
|
||||
|
||||
export interface EntityDisplayInfo {
|
||||
primaryText: string;
|
||||
secondaryText?: string;
|
||||
}
|
||||
|
||||
export interface DeleteModeOption {
|
||||
mode: DeleteMode;
|
||||
title: string;
|
||||
description: string;
|
||||
benefits: string;
|
||||
enabled: boolean;
|
||||
disabledMessage?: string;
|
||||
}
|
||||
|
||||
export interface DeleteWarning {
|
||||
title: string;
|
||||
items: string[];
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
export interface DeletionSummaryData {
|
||||
[key: string]: string | number;
|
||||
}
|
||||
|
||||
export interface BaseDeleteModalProps<TEntity, TSummary = DeletionSummaryData> {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
entity: TEntity | null;
|
||||
onSoftDelete: (entityId: string) => Promise<void | TSummary>;
|
||||
onHardDelete?: (entityId: string) => Promise<void | TSummary>;
|
||||
isLoading?: boolean;
|
||||
|
||||
// Configuration
|
||||
title: string;
|
||||
getEntityId: (entity: TEntity) => string;
|
||||
getEntityDisplay: (entity: TEntity) => EntityDisplayInfo;
|
||||
|
||||
// Mode configuration
|
||||
softDeleteOption: Omit<DeleteModeOption, 'mode' | 'enabled'>;
|
||||
hardDeleteOption?: Omit<DeleteModeOption, 'mode'>;
|
||||
|
||||
// Warnings
|
||||
softDeleteWarning: DeleteWarning;
|
||||
hardDeleteWarning: DeleteWarning;
|
||||
|
||||
// Optional features
|
||||
requireConfirmText?: boolean;
|
||||
confirmText?: string;
|
||||
showSuccessScreen?: boolean;
|
||||
successTitle?: string;
|
||||
getSuccessMessage?: (entity: TEntity, mode: DeleteMode) => string;
|
||||
|
||||
// Deletion summary
|
||||
showDeletionSummary?: boolean;
|
||||
formatDeletionSummary?: (summary: TSummary) => DeletionSummaryData;
|
||||
deletionSummaryTitle?: string;
|
||||
|
||||
// Dependency checking (for hard delete)
|
||||
dependencyCheck?: {
|
||||
isLoading: boolean;
|
||||
canDelete: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
// Auto-close timing
|
||||
autoCloseDelay?: number;
|
||||
}
|
||||
|
||||
export function BaseDeleteModal<TEntity, TSummary = DeletionSummaryData>({
|
||||
isOpen,
|
||||
onClose,
|
||||
entity,
|
||||
onSoftDelete,
|
||||
onHardDelete,
|
||||
isLoading = false,
|
||||
title,
|
||||
getEntityId,
|
||||
getEntityDisplay,
|
||||
softDeleteOption,
|
||||
hardDeleteOption,
|
||||
softDeleteWarning,
|
||||
hardDeleteWarning,
|
||||
requireConfirmText = true,
|
||||
confirmText: customConfirmText = 'ELIMINAR',
|
||||
showSuccessScreen = false,
|
||||
successTitle,
|
||||
getSuccessMessage,
|
||||
showDeletionSummary = false,
|
||||
formatDeletionSummary,
|
||||
deletionSummaryTitle,
|
||||
dependencyCheck,
|
||||
autoCloseDelay,
|
||||
}: BaseDeleteModalProps<TEntity, TSummary>) {
|
||||
const [selectedMode, setSelectedMode] = useState<DeleteMode>('soft');
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [deletionResult, setDeletionResult] = useState<TSummary | null>(null);
|
||||
const [deletionComplete, setDeletionComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
setDeletionComplete(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!entity) return null;
|
||||
|
||||
const entityDisplay = getEntityDisplay(entity);
|
||||
const entityId = getEntityId(entity);
|
||||
const isHardDeleteEnabled = hardDeleteOption?.enabled !== false;
|
||||
|
||||
const handleDeleteModeSelect = (mode: DeleteMode) => {
|
||||
setSelectedMode(mode);
|
||||
setShowConfirmation(true);
|
||||
setConfirmText('');
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
try {
|
||||
if (selectedMode === 'hard' && onHardDelete) {
|
||||
const result = await onHardDelete(entityId);
|
||||
if (result && showDeletionSummary) {
|
||||
setDeletionResult(result as TSummary);
|
||||
}
|
||||
} else {
|
||||
const result = await onSoftDelete(entityId);
|
||||
if (result && showDeletionSummary) {
|
||||
setDeletionResult(result as TSummary);
|
||||
}
|
||||
}
|
||||
|
||||
if (showSuccessScreen) {
|
||||
setDeletionComplete(true);
|
||||
if (autoCloseDelay) {
|
||||
setTimeout(() => {
|
||||
handleClose();
|
||||
}, autoCloseDelay);
|
||||
}
|
||||
} else {
|
||||
handleClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting entity:', error);
|
||||
// Error handling is done by parent component
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowConfirmation(false);
|
||||
setSelectedMode('soft');
|
||||
setConfirmText('');
|
||||
setDeletionResult(null);
|
||||
setDeletionComplete(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedMode === 'hard' &&
|
||||
requireConfirmText &&
|
||||
confirmText.toUpperCase() !== customConfirmText.toUpperCase();
|
||||
|
||||
// Show deletion result/summary
|
||||
if (deletionResult && showDeletionSummary && formatDeletionSummary) {
|
||||
const formattedSummary = formatDeletionSummary(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)]">
|
||||
{deletionSummaryTitle || 'Eliminación Completada'}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{entityDisplay.primaryText} ha sido eliminado
|
||||
</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)]">
|
||||
{Object.entries(formattedSummary).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span>{key}:</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="primary" onClick={handleClose}>
|
||||
Entendido
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show deletion success
|
||||
if (deletionComplete && showSuccessScreen) {
|
||||
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)]">
|
||||
{successTitle || (selectedMode === 'hard' ? 'Eliminado' : 'Desactivado')}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{getSuccessMessage?.(entity, selectedMode) || `${entityDisplay.primaryText} procesado correctamente`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// Show confirmation step
|
||||
if (showConfirmation) {
|
||||
const isHardDelete = selectedMode === 'hard';
|
||||
const canDelete = !isHardDelete || !dependencyCheck || dependencyCheck.canDelete !== false;
|
||||
const warning = isHardDelete ? hardDeleteWarning : softDeleteWarning;
|
||||
|
||||
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)]">{entityDisplay.primaryText}</p>
|
||||
{entityDisplay.secondaryText && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{entityDisplay.secondaryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && dependencyCheck?.isLoading ? (
|
||||
<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)]">
|
||||
Verificando dependencias...
|
||||
</p>
|
||||
</div>
|
||||
) : isHardDelete && !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">
|
||||
⚠️ No se puede eliminar este elemento
|
||||
</p>
|
||||
<ul className="text-sm space-y-1 text-red-600 dark:text-red-300">
|
||||
{dependencyCheck?.warnings.map((warn, idx) => (
|
||||
<li key={idx}>• {warn}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isHardDelete ? 'text-red-600 dark:text-red-400 mb-4' : 'text-orange-600 dark:text-orange-400 mb-4'}>
|
||||
<p className="font-medium mb-2">{warning.title}</p>
|
||||
<ul className="text-sm space-y-1 ml-4">
|
||||
{warning.items.map((item, idx) => (
|
||||
<li key={idx}>• {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
{warning.footer && (
|
||||
<p className="font-bold mt-3 text-red-700 dark:text-red-300">
|
||||
{warning.footer}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHardDelete && canDelete && requireConfirmText && (
|
||||
<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)]">{customConfirmText}</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 ${customConfirmText}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Volver
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant={isHardDelete ? 'danger' : 'warning'}
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isConfirmDisabled || isLoading || dependencyCheck?.isLoading}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{isHardDelete ? 'Eliminar Permanentemente' : 'Desactivar'}
|
||||
</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)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="bg-[var(--background-secondary)] p-4 rounded-lg">
|
||||
<p className="font-medium text-[var(--text-primary)]">{entityDisplay.primaryText}</p>
|
||||
{entityDisplay.secondaryText && (
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{entityDisplay.secondaryText}
|
||||
</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">
|
||||
{softDeleteOption.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{softDeleteOption.description}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-orange-600 dark:text-orange-400">
|
||||
{softDeleteOption.benefits}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard Delete Option */}
|
||||
{hardDeleteOption && (
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 transition-colors ${
|
||||
!isHardDeleteEnabled
|
||||
? 'opacity-50 cursor-not-allowed border-[var(--border-color)] bg-[var(--background-tertiary)]'
|
||||
: `cursor-pointer ${
|
||||
selectedMode === 'hard'
|
||||
? 'border-red-500 bg-red-50 dark:bg-red-900/10'
|
||||
: 'border-[var(--border-color)] hover:border-red-300'
|
||||
}`
|
||||
}`}
|
||||
onClick={() => isHardDeleteEnabled && setSelectedMode('hard')}
|
||||
title={!isHardDeleteEnabled ? hardDeleteOption.disabledMessage : undefined}
|
||||
>
|
||||
<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' && isHardDeleteEnabled
|
||||
? 'border-red-500 bg-red-500'
|
||||
: 'border-[var(--border-color)]'
|
||||
}`}>
|
||||
{selectedMode === 'hard' && isHardDeleteEnabled && (
|
||||
<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">
|
||||
{hardDeleteOption.title}
|
||||
<AlertTriangle className="w-4 h-4 text-red-500" />
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{hardDeleteOption.description}
|
||||
</p>
|
||||
<div className={`mt-2 text-xs ${
|
||||
isHardDeleteEnabled
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-[var(--text-tertiary)]'
|
||||
}`}>
|
||||
{isHardDeleteEnabled ? hardDeleteOption.benefits : `ℹ️ ${hardDeleteOption.disabledMessage}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedMode === 'hard' && isHardDeleteEnabled ? 'danger' : 'warning'}
|
||||
onClick={() => handleDeleteModeSelect(selectedMode)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Continuar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default BaseDeleteModal;
|
||||
9
frontend/src/components/ui/BaseDeleteModal/index.ts
Normal file
9
frontend/src/components/ui/BaseDeleteModal/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { BaseDeleteModal } from './BaseDeleteModal';
|
||||
export type {
|
||||
DeleteMode,
|
||||
EntityDisplayInfo,
|
||||
DeleteModeOption,
|
||||
DeleteWarning,
|
||||
DeletionSummaryData,
|
||||
BaseDeleteModalProps,
|
||||
} from './BaseDeleteModal';
|
||||
@@ -144,11 +144,7 @@ const renderEditableField = (
|
||||
onChange?: (value: string | number) => void,
|
||||
validationError?: string
|
||||
): React.ReactNode => {
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
}
|
||||
|
||||
// Handle custom components
|
||||
// Handle custom components FIRST - they work in both view and edit modes
|
||||
if (field.type === 'component' && field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
@@ -160,6 +156,11 @@ const renderEditableField = (
|
||||
);
|
||||
}
|
||||
|
||||
// Then check if we should render as view or edit
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
||||
onChange?.(value);
|
||||
@@ -355,6 +356,16 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isWaitingForRefetch, setIsWaitingForRefetch] = React.useState(false);
|
||||
const [collapsedSections, setCollapsedSections] = React.useState<Record<number, boolean>>({});
|
||||
|
||||
// Initialize collapsed states when sections change
|
||||
React.useEffect(() => {
|
||||
const initialCollapsed: Record<number, boolean> = {};
|
||||
sections.forEach((section, index) => {
|
||||
initialCollapsed[index] = section.collapsed || false;
|
||||
});
|
||||
setCollapsedSections(initialCollapsed);
|
||||
}, [sections]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
@@ -616,7 +627,7 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
||||
const isCollapsed = collapsedSections[sectionIndex] || false;
|
||||
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
|
||||
|
||||
// Determine grid classes based on mobile optimization and section columns
|
||||
@@ -642,7 +653,12 @@ export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
className={`flex items-start gap-3 pb-3 border-b border-[var(--border-primary)] ${
|
||||
section.collapsible ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={section.collapsible ? () => setIsCollapsed(!isCollapsed) : undefined}
|
||||
onClick={section.collapsible ? () => {
|
||||
setCollapsedSections(prev => ({
|
||||
...prev,
|
||||
[sectionIndex]: !isCollapsed
|
||||
}));
|
||||
} : undefined}
|
||||
>
|
||||
{section.icon && (
|
||||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -27,6 +27,7 @@ export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { ResponsiveText } from './ResponsiveText';
|
||||
export { SearchAndFilter } from './SearchAndFilter';
|
||||
export { BaseDeleteModal } from './BaseDeleteModal';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -54,4 +55,5 @@ export type { DialogModalProps, DialogModalAction } from './DialogModal';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
export type { ResponsiveTextProps } from './ResponsiveText';
|
||||
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
|
||||
export type { SearchAndFilterProps, FilterConfig, FilterOption } from './SearchAndFilter';
|
||||
export type { BaseDeleteModalProps, DeleteMode, EntityDisplayInfo, DeleteModeOption, DeleteWarning, DeletionSummaryData } from './BaseDeleteModal';
|
||||
Reference in New Issue
Block a user