¡Esa es una mejora crucial para la experiencia de usuario! Hacer que el modal reaccione a las teclas Escape y Enter (para la acción principal) hace que la aplicación se sienta mucho más fluida.
import React, { useState, createContext, useContext, useEffect, useCallback, useMemo } from 'react';
// Simulamos las dependencias de Inertia y las Props de la página, ya que no estamos en el entorno Laravel/Vite real.
// --- 1. SIMULACIÓN DE INERTIA Y LARAVEL ---
// Simulación de la estructura de las props de la página de Inertia.
interface InertiaPageProps {
props: {
// Simula los errores de validación de Laravel (código 422)
errors: Record<string, string>;
// Simula mensajes flash para redirecciones (código 200/500)
flash: {
success?: string;
error?: string;
};
// Simulación de la URL o ruta actual
url: string;
};
}
// Hook simulado para obtener las props de Inertia
const usePage = (): InertiaPageProps => {
// Simulamos un estado inicial de la página
return {
props: {
errors: {},
flash: {},
url: '/odontogramas/1'
}
};
};
// Componente simulado para el título de la página
const Head = ({ title }: { title: string }) => (
<title>{title}</title>
);
// --- 2. DEFINICIÓN DEL CONTEXTO GLOBAL DE MODAL ---
type ModalType = 'success' | 'error' | 'warning' | 'info';
interface ModalState {
isVisible: boolean;
type: ModalType;
title: string;
message: string;
buttonText: string;
// Callback opcional que se ejecuta al presionar el botón de confirmación/Enter
onConfirmAction: (() => void) | null;
}
interface ModalContextType {
modal: ModalState;
showModal: (type: ModalType, message: string, title?: string, buttonText?: string, onConfirmAction?: (() => void) | null) => void;
hideModal: () => void;
}
// Valor por defecto del contexto
const defaultModalState: ModalState = {
isVisible: false,
type: 'info',
title: 'Información',
message: '',
buttonText: 'Entendido',
onConfirmAction: null,
};
const ModalContext = createContext<ModalContextType | undefined>(undefined);
// Hook personalizado para usar el modal
const useGlobalModal = (): ModalContextType => {
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error('useGlobalModal debe usarse dentro de un ModalProvider');
}
return context;
};
// --- 3. COMPONENTE PROVEEDOR DEL MODAL ---
const GlobalModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [modal, setModal] = useState<ModalState>(defaultModalState);
const showModal = useCallback((
type: ModalType,
message: string,
title?: string,
buttonText?: string,
onConfirmAction: (() => void) | null = null
) => {
let defaultTitle = '';
switch (type) {
case 'success': defaultTitle = 'Éxito'; break;
case 'error': defaultTitle = 'Error Crítico'; break;
case 'warning': defaultTitle = 'Advertencia'; break;
default: defaultTitle = 'Información';
}
setModal({
isVisible: true,
type,
title: title || defaultTitle,
message,
buttonText: buttonText || 'Entendido',
onConfirmAction,
});
}, []);
const hideModal = useCallback(() => {
setModal(defaultModalState);
}, []);
const contextValue = useMemo(() => ({ modal, showModal, hideModal }), [modal, showModal, hideModal]);
return (
<ModalContext.Provider value={contextValue}>
{children}
<GlobalMessageModal />
</ModalContext.Provider>
);
};
// --- 4. COMPONENTE VISUAL DEL MODAL ---
const GlobalMessageModal: React.FC = () => {
const { modal, hideModal } = useGlobalModal();
const confirmAction = () => {
if (modal.onConfirmAction) {
// Si existe una acción, la ejecuta
modal.onConfirmAction();
}
// Luego cierra el modal
hideModal();
};
// Lógica de respuesta al teclado
useEffect(() => {
if (!modal.isVisible) return;
const handleKeyPress = (event: KeyboardEvent) => {
// 1. Cierre con ESCAPE
if (event.key === 'Escape') {
event.stopPropagation();
hideModal();
}
// 2. Acción con ENTER
else if (event.key === 'Enter') {
event.stopPropagation();
confirmAction();
}
};
window.addEventListener('keydown', handleKeyPress);
return () => {
window.removeEventListener('keydown', handleKeyPress);
};
}, [modal.isVisible, confirmAction, hideModal]);
if (!modal.isVisible) return null;
// Clases de estilo basadas en el tipo de modal
const colorMap = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-yellow-500',
info: 'bg-blue-500',
};
const bgColor = colorMap[modal.type] || 'bg-gray-500';
return (
// Overlay oscuro de fondo
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900 bg-opacity-70 transition-opacity duration-300">
<div className={`w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden transform transition-transform duration-300 scale-100`}>
{/* Cabecera y Título */}
<div className={`p-4 ${bgColor} text-white`}>
<h3 className="text-xl font-bold">{modal.title}</h3>
</div>
{/* Contenido del Mensaje */}
<div className="p-6">
<p className="text-gray-700 text-base">{modal.message}</p>
</div>
{/* Footer y Botón de Acción */}
<div className="p-4 bg-gray-50 flex justify-end space-x-3">
<button
onClick={confirmAction}
className={`px-4 py-2 font-semibold text-white rounded-lg transition duration-150 ${bgColor} hover:opacity-90 shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-75`}
>
{modal.buttonText}
</button>
{/* Botón de cierre explícito si no es una acción crítica */}
{modal.type !== 'error' && (
<button
onClick={hideModal}
className="px-4 py-2 font-semibold text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition duration-150 shadow-md focus:outline-none"
>
Cerrar
</button>
)}
</div>
</div>
</div>
);
};
// --- 5. LAYOUT DE LA APLICACIÓN (Persistente) ---
interface AppLayoutProps {
children: React.ReactNode;
}
const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
return (
<GlobalModalProvider>
<div className="min-h-screen bg-gray-100 flex flex-col">
{/* Simulación de Navegación/Header Persistente */}
<header className="bg-white shadow-md p-4 flex justify-between items-center z-10">
<h1 className="text-2xl font-extrabold text-indigo-700">DentalApp Odontograma</h1>
<div className="text-sm text-gray-600">Usuario: UID-12345</div>
</header>
{/* Contenido de la página (el children) */}
<main className="flex-grow p-4 md:p-8">
{children}
</main>
{/* Footer Persistente */}
<footer className="bg-gray-800 text-white p-3 text-center text-xs">
© 2025 Sistema Odontológico Pro. Todos los derechos reservados.
</footer>
</div>
</GlobalModalProvider>
);
};
// --- 6. PÁGINA ESPECÍFICA (Donde se usa el modal) ---
interface OdontogramPageProps {
paciente: {
id: number;
nombre: string;
};
}
// Simulación de los datos del paciente
const mockPaciente: OdontogramPageProps['paciente'] = {
id: 42,
nombre: 'María Gómez',
};
const OdontogramPage: React.FC<OdontogramPageProps> = ({ paciente }) => {
const { showModal } = useGlobalModal();
const page = usePage(); // Simulación para acceder a Inertia props
const [odontogramData, setOdontogramData] = useState<string>('Datos del Canvas (JSON serializado)');
const [isSaving, setIsSaving] = useState(false);
// Simula el envío de datos al backend (AJAX)
const handleSave = () => {
setIsSaving(true);
// Simulación de llamada API
const success = Math.random() > 0.3; // 70% de éxito
// Simulación de la navegación/redirección después de guardar con éxito
const navigateToDashboard = () => {
console.log('--- Navegando fuera de la página de Odontograma después de éxito ---');
// En un entorno real de Inertia, harías: Inertia.get('/dashboard')
};
setTimeout(() => {
setIsSaving(false);
if (success) {
// Simulación de respuesta 200 OK del backend
showModal(
'success',
`El odontograma de ${paciente.nombre} ha sido guardado exitosamente.`,
'Guardado Completo (200 OK)',
'Continuar y Salir', // Botón con acción de redirección
navigateToDashboard // Callback para la acción de confirmación
);
} else {
// Simulación de respuesta 500 o error de negocio
showModal(
'error',
'No se pudo establecer la conexión con la base de datos para guardar el odontograma. Inténtelo de nuevo.',
'Error de Conexión (500)',
'Entendido y Reintentar',
() => console.log('Intento de reenvío...') // Simulación de reintento
);
}
}, 1500);
};
// Efecto para detectar errores de validación (Simulación de 422 de Laravel/Inertia)
useEffect(() => {
const errorKeys = Object.keys(page.props.errors);
if (errorKeys.length > 0) {
const firstErrorKey = errorKeys[0];
const errorMessage = page.props.errors[firstErrorKey];
console.log(`Error de validación detectado en ${firstErrorKey}: ${errorMessage}`);
showModal(
'warning',
`Hemos detectado errores de validación en el formulario (campo "${firstErrorKey}"). Mensaje: ${errorMessage}`,
'Validación Requerida (422)',
'Corregir Formulario'
);
}
}, [page.props.errors, showModal]);
return (
<>
<Head title={`Odontograma de ${paciente.nombre}`} />
<div className="bg-white p-6 rounded-xl shadow-lg">
<h2 className="text-3xl font-bold mb-6 text-gray-800">
Odontograma - {paciente.nombre} (ID: {paciente.id})
</h2>
{/* Placeholder del Canvas/Odontograma */}
<div className="h-96 w-full border-4 border-dashed border-indigo-300 bg-indigo-50 flex items-center justify-center rounded-lg mb-8">
<p className="text-indigo-500 font-medium">
[ZONA DEL CANVAS INTERACTIVO DE ODONTOGRAMA]
<br />
Datos actuales: {odontogramData}
</p>
</div>
{/* Barra de herramientas y botón de guardar */}
<div className="flex justify-end">
<button
onClick={handleSave}
disabled={isSaving}
className={`px-8 py-3 font-bold text-lg text-white rounded-xl transition duration-300 shadow-xl ${isSaving ? 'bg-indigo-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700'}`}
>
{isSaving ? 'Guardando...' : 'Guardar Odontograma'}
</button>
</div>
{/* Botones de prueba para simular los diferentes escenarios */}
<div className="mt-8 pt-4 border-t border-gray-200 flex space-x-4">
<button
onClick={() => showModal('warning', 'Esta es una advertencia local del frontend.', 'Alerta Local', 'Aceptar')}
className="px-4 py-2 bg-yellow-500 text-white rounded-lg shadow-md"
>
Simular Warning Local
</button>
<button
onClick={() => handleSave()}
className="px-4 py-2 bg-pink-500 text-white rounded-lg shadow-md"
>
Simular Guardar (70% Éxito)
</button>
</div>
</div>
</>
);
};
// --- 7. COMPONENTE PRINCIPAL (Punto de Entrada) ---
// Se envuelve la página dentro del Layout para asegurar el contexto.
export default function App() {
return (
<AppLayout>
<OdontogramPage paciente={mockPaciente} />
</AppLayout>
);
}