Quiero mostrar siempre los mensajes de error y de éxito en un popup de shadcn/ui que esté en la misma página donde se cargaron los datos. En el caso del odontograma, el popup tiene que aparecer encima del canvas donde se dibuja el odontograma. Necesito que en vez de hacer un post que cambie de página, se quede en la misma página, y recién después de aceptar el mensaje de éxito, salga de la página donde estaba. ¿Es una estrategia de UI/UX bastante común?
Esta arquitectura te ofrece la mejor experiencia de usuario para procesos críticos como el guardado de datos médicos.
¿Puedo combinar esta estrategia con el validador de Laravel que muestra un error en cada campo del formulario que corresponda?
El secreto está en cambiar cómo Laravel responde a la solicitud. Cuando se usan formularios tradicionales, Laravel automáticamente detecta un error de validación, guarda los errores en la sesión y redirige de vuelta. Al usar AJAX, le pides a Laravel que devuelva los errores en formato JSON y los manejas en el frontend.
Aquí te explico la estrategia y luego te actualizo el componente React para manejar ambos casos: el modal de éxito/error general y los errores por campo.
Laravel detecta automáticamente si la solicitud es una petición AJAX (generalmente, si la cabecera X-Requested-With: XMLHttpRequest
está presente).
Tu código JavaScript (React) debe interceptar la respuesta HTTP y actuar según el código de estado:
import React, { useState, useCallback } from 'react';
// --- Iconos simples de Lucide React ---
const CheckCircle = ({ className = "" }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="M9 11l3 3L22 4"/></svg>
);
const XCircle = ({ className = "" }) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/></svg>
);
// Componente auxiliar para mostrar errores de validación de campo
const FieldError = ({ fieldName, errors }) => {
const errorMessages = errors[fieldName];
if (!errorMessages || errorMessages.length === 0) return null;
return (
<div className="mt-1 text-sm text-red-500 font-medium space-y-0.5">
{errorMessages.map((msg, index) => (
<p key={index}>{msg}</p>
))}
</div>
);
};
// Componente: StatusDialog (Emulación de shadcn/ui Dialog/Alert)
const StatusDialog = ({ isOpen, status, message, onClose, onConfirmNavigation }) => {
if (!isOpen) return null;
const isSuccess = status === 'success';
const title = isSuccess ? 'Operación Exitosa' : 'Error General del Servidor';
const icon = isSuccess ? <CheckCircle className="w-10 h-10 text-white" /> : <XCircle className="w-10 h-10 text-white" />;
const bgColor = isSuccess ? 'bg-green-600' : 'bg-red-600';
const handleConfirm = () => {
if (isSuccess) {
onConfirmNavigation(); // Navega solo si fue exitoso
} else {
onClose(); // Cierra el diálogo para permitir corrección o reintento
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
<div className={`w-full max-w-sm rounded-xl p-6 shadow-2xl ${bgColor} text-white transform transition-all duration-300 scale-100`}>
<div className="flex flex-col items-center justify-center text-center space-y-4">
<div className="rounded-full bg-white bg-opacity-20 p-3">
{icon}
</div>
<h3 className="text-xl font-bold">{title}</h3>
<p className="text-sm opacity-90">{message}</p>
<button
onClick={handleConfirm}
className="w-full mt-4 py-2 px-4 bg-white text-gray-800 font-semibold rounded-lg hover:bg-gray-100 transition-colors shadow-md"
>
{isSuccess ? 'Aceptar y Salir' : 'Cerrar'}
</button>
</div>
</div>
</div>
);
};
// Componente: OdontogramCanvas (Placeholder)
const OdontogramCanvas = () => {
return (
<div className="relative w-full h-[300px] md:h-[450px] border-4 border-blue-200 bg-white rounded-xl shadow-inner flex items-center justify-center">
<p className="text-gray-500 text-lg font-mono">ODONTOGRAMA (Canvas de Dibujo)</p>
</div>
);
};
// Componente Principal de la Aplicación
export default function App() {
// --- ESTADOS ---
const [formState, setFormState] = useState({
pacienteId: 'P123',
piezaDental: '16',
observaciones: 'Buena condición general.',
});
const [isProcessing, setIsProcessing] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogStatus, setDialogStatus] = useState(null);
const [dialogMessage, setDialogMessage] = useState('');
const [isSaved, setIsSaved] = useState(false); // Simula el estado del odontograma
// NUEVO: Estado para almacenar errores de validación de campo (código 422 de Laravel)
const [validationErrors, setValidationErrors] = useState({});
// --- MANEJADORES ---
const handleChange = (e) => {
const { name, value } = e.target;
setFormState(prev => ({ ...prev, [name]: value }));
// Opcional: Limpiar error al empezar a escribir
if (validationErrors[name]) {
setValidationErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const simulateSave = useCallback(async (scenario = 'success') => {
setIsProcessing(true);
// Limpiar estados de UI antes de la nueva solicitud
setValidationErrors({});
setDialogOpen(false);
setDialogStatus(null);
setDialogMessage('');
// Simulación de una llamada POST asíncrona (AJAX)
try {
await new Promise(resolve => setTimeout(resolve, 1500)); // Simula latencia
if (scenario === 'success') {
// 1. ÉXITO (Simula HTTP 200)
setDialogStatus('success');
setDialogMessage('¡El odontograma se ha guardado correctamente! Preparando la salida...');
setDialogOpen(true);
setIsSaved(true);
// Simulación de regeneración de trabajo no guardado
setTimeout(()=>setIsSaved(false),5000);
} else if (scenario === 'validation_error') {
// 2. ERROR DE VALIDACIÓN (Simula HTTP 422 de Laravel)
const mockLaravelErrors = {
pacienteId: ['El ID del paciente es obligatorio y debe ser numérico.'],
piezaDental: ['La pieza dental debe ser un número válido entre 11 y 48.'],
observaciones: ['Las observaciones son muy largas (máx 250 caracteres).']
};
setValidationErrors(mockLaravelErrors);
// NOTA IMPORTANTE: En este escenario, NO mostramos el Modal.
// El usuario debe ver los errores junto a los campos.
} else if (scenario === 'server_error') {
// 3. ERROR DE SERVIDOR (Simula HTTP 500)
throw new Error('Ocurrió un error inesperado en el servidor. Intente más tarde.');
}
} catch (error) {
// Esto manejaría errores de red o cualquier excepción no controlada
setDialogStatus('error');
setDialogMessage(`Error de conexión: ${error.message || 'No se pudo conectar al servidor.'}`);
setDialogOpen(true);
} finally {
setIsProcessing(false);
}
}, []);
// Lógica para navegar después de que el usuario acepta el éxito
const handleConfirmNavigation = () => {
setDialogOpen(false);
console.log('NAVEGANDO FUERA DE LA PÁGINA...');
// Aquí iría la redirección a /pacientes o similar
alert("Navegación simulada: Saliendo de la página del Odontograma.");
};
// Lógica para cerrar el diálogo (solo si es error general)
const handleCloseDialog = () => {
setDialogOpen(false);
setDialogStatus(null);
setDialogMessage('');
};
return (
<div className="min-h-screen bg-gray-50 p-4 md:p-8 font-sans antialiased">
<style>
{`
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap');
body { font-family: 'Inter', sans-serif; }
`}
</style>
<div className="max-w-4xl mx-auto space-y-8">
<header className="text-center">
<h1 className="text-3xl font-bold text-gray-800">Guardar Datos de Odontograma</h1>
<p className="text-gray-600">Proceso de guardado mediante AJAX con manejo de errores de validación de Laravel.</p>
</header>
{/* Área del Odontograma (Canvas) - El Modal se superpondrá aquí */}
<OdontogramCanvas />
{/* Formulario de Datos Adicionales */}
<form className="bg-white p-6 md:p-8 rounded-xl shadow-lg space-y-6">
<h2 className="text-xl font-semibold text-gray-700 border-b pb-2">Datos de la Sesión</h2>
{/* Campo Paciente ID */}
<div>
<label htmlFor="pacienteId" className="block text-sm font-medium text-gray-700 mb-1">ID del Paciente</label>
<input
type="text"
id="pacienteId"
name="pacienteId"
value={formState.pacienteId}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 ${
validationErrors.pacienteId ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Ej. P12345"
/>
<FieldError fieldName="pacienteId" errors={validationErrors} />
</div>
{/* Campo Pieza Dental */}
<div>
<label htmlFor="piezaDental" className="block text-sm font-medium text-gray-700 mb-1">Pieza Dental Modificada</label>
<input
type="text"
id="piezaDental"
name="piezaDental"
value={formState.piezaDental}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 ${
validationErrors.piezaDental ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Ej. 16"
/>
<FieldError fieldName="piezaDental" errors={validationErrors} />
</div>
{/* Campo Observaciones */}
<div>
<label htmlFor="observaciones" className="block text-sm font-medium text-gray-700 mb-1">Observaciones</label>
<textarea
id="observaciones"
name="observaciones"
value={formState.observaciones}
onChange={handleChange}
rows="3"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 ${
validationErrors.observaciones ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'
}`}
placeholder="Detalles sobre el procedimiento..."
/>
<FieldError fieldName="observaciones" errors={validationErrors} />
</div>
</form>
{/* Controles y Botones de Guardar */}
<div className="flex flex-col sm:flex-row justify-center space-y-4 sm:space-y-0 sm:space-x-4 p-4 rounded-xl">
<button
onClick={() => simulateSave('success')}
disabled={isProcessing}
className="flex items-center justify-center px-6 py-3 text-lg font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 transition-all shadow-lg hover:shadow-xl disabled:bg-green-400"
>
{isProcessing ? 'Guardando...' : 'Guardar (Éxito 200)'}
</button>
<button
onClick={() => simulateSave('validation_error')}
disabled={isProcessing}
className="flex items-center justify-center px-6 py-3 text-lg font-semibold text-white bg-yellow-600 rounded-lg hover:bg-yellow-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
>
{isProcessing ? 'Guardando...' : 'Guardar (Validación Fallida 422)'}
</button>
<button
onClick={() => simulateSave('server_error')}
disabled={isProcessing}
className="flex items-center justify-center px-6 py-3 text-lg font-semibold text-white bg-red-600 rounded-lg hover:bg-red-700 transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
>
{isProcessing ? 'Guardando...' : 'Guardar (Error Servidor 500)'}
</button>
</div>
{/* Mensaje de resumen de validación si hay errores */}
{Object.keys(validationErrors).length > 0 && (
<div className="p-4 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm font-medium shadow-md mx-auto max-w-lg">
<p className="font-bold">¡Validación Fallida!</p>
<p>Por favor, revisa y corrige los errores marcados en el formulario.</p>
</div>
)}
</div>
{/* Diálogo de Estatus (Solo para Éxito o Error General) */}
<StatusDialog
isOpen={dialogOpen}
status={dialogStatus}
message={dialogMessage}
onClose={handleCloseDialog}
onConfirmNavigation={handleConfirmNavigation}
/>
{/* Indicador de Estado para debugging */}
<div className="fixed bottom-4 right-4 bg-gray-800 text-white p-3 rounded-lg text-sm shadow-xl">
<p>Estado de guardado: <span className={`font-bold ${isSaved ? 'text-green-400' : 'text-yellow-400'}`}>{isSaved ? 'Guardado' : 'Pendiente'}</span></p>
</div>
</div>
);
}
Esta combinación ofrece la mejor experiencia: retroalimentación visual inmediata en los campos para la validación, y un flujo de navegación controlado y sin recargas para el éxito.