From 378591d6b55a61f973687535d8a71f43a84a6a67 Mon Sep 17 00:00:00 2001 From: Servidor Neptuno Date: Tue, 30 Jun 2026 05:43:56 +0000 Subject: [PATCH] Primer commit: estado actual del servidor --- .gitignore | 17 + INSTRUCCIONES_INSTALACION.md | 216 ++ LICENSE | 129 ++ README.md | 172 ++ adjuntos/.gitkeep | 0 adjuntos/.htaccess | 11 + api/adjuntos.php | 270 +++ api/admin.php | 291 +++ api/auth.php | 1210 ++++++++++ api/citas.php | 362 +++ api/obras_sociales.php | 43 + api/pacientes.php | 519 +++++ api/plantillas.php | 59 + assets/css/estilos.css | 1788 +++++++++++++++ assets/icons/favicon-32.png | Bin 0 -> 254 bytes assets/icons/icon-192.png | Bin 0 -> 1297 bytes assets/icons/icon-512.png | Bin 0 -> 4037 bytes assets/icons/icon-apple-touch.png | Bin 0 -> 1208 bytes assets/icons/icon-maskable-512.png | Bin 0 -> 3729 bytes assets/js/app.js | 3346 ++++++++++++++++++++++++++++ confirmar_turno.php | 171 ++ database.sql | 251 +++ exportar.php | 267 +++ index.html | 1332 +++++++++++ manifest.json | 32 + migracion_v10.sql | 66 + migracion_v11.sql | 33 + migracion_v2.sql | 86 + migracion_v3.sql | 56 + migracion_v4.sql | 30 + migracion_v5.sql | 139 ++ migracion_v6.sql | 24 + migracion_v7.sql | 40 + migracion_v8.sql | 25 + migracion_v9.sql | 36 + reiniciar_todo.sql | 67 + sw.js | 23 + version.json | 17 + 38 files changed, 11128 insertions(+) create mode 100644 .gitignore create mode 100644 INSTRUCCIONES_INSTALACION.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 adjuntos/.gitkeep create mode 100644 adjuntos/.htaccess create mode 100644 api/adjuntos.php create mode 100644 api/admin.php create mode 100644 api/auth.php create mode 100644 api/citas.php create mode 100644 api/obras_sociales.php create mode 100644 api/pacientes.php create mode 100644 api/plantillas.php create mode 100644 assets/css/estilos.css create mode 100644 assets/icons/favicon-32.png create mode 100644 assets/icons/icon-192.png create mode 100644 assets/icons/icon-512.png create mode 100644 assets/icons/icon-apple-touch.png create mode 100644 assets/icons/icon-maskable-512.png create mode 100644 assets/js/app.js create mode 100644 confirmar_turno.php create mode 100644 database.sql create mode 100644 exportar.php create mode 100644 index.html create mode 100644 manifest.json create mode 100644 migracion_v10.sql create mode 100644 migracion_v11.sql create mode 100644 migracion_v2.sql create mode 100644 migracion_v3.sql create mode 100644 migracion_v4.sql create mode 100644 migracion_v5.sql create mode 100644 migracion_v6.sql create mode 100644 migracion_v7.sql create mode 100644 migracion_v8.sql create mode 100644 migracion_v9.sql create mode 100644 reiniciar_todo.sql create mode 100644 sw.js create mode 100644 version.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0fa7067 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Archivos subidos por usuarios (PDF, imágenes de pacientes). +# Nunca deben versionarse: son datos reales de personas. +adjuntos/* +!adjuntos/.htaccess +!adjuntos/.gitkeep + +# config/config.php tiene credenciales REALES de la base de +# datos de este servidor: nunca debe subirse a Gitea. +config/config.php + +# Archivos de sistema operativo / editores +.DS_Store +Thumbs.db +*.swp +*.swo +.vscode/ +.idea/ diff --git a/INSTRUCCIONES_INSTALACION.md b/INSTRUCCIONES_INSTALACION.md new file mode 100644 index 0000000..2b52daf --- /dev/null +++ b/INSTRUCCIONES_INSTALACION.md @@ -0,0 +1,216 @@ +# Del Austral — Historial Clínico Digital +### Guía de instalación en tu hosting cPanel + +--- + +## ¿Ya tenés el sistema instalado? Empezá acá + +Si ya estabas usando una versión anterior con pacientes cargados, **no necesitás reinstalar todo desde cero**, pero esta actualización es la más grande hasta ahora: agrega sedes, varios profesionales con datos separados entre sí, y un nuevo nivel de acceso ("Desarrollador"). Seguí estos pasos en orden, sin saltar ninguno: + +1. **Migrá la base de datos** (una sola vez, en este orden exacto): en phpMyAdmin, entrá a tu base de datos → pestaña **"SQL"** (no "Importar"): + - Si nunca corriste `migracion_v2.sql`, pegalo y ejecutalo (agrega citas, archivos adjuntos y plantillas). + - Si nunca corriste `migracion_v3.sql`, pegalo y ejecutalo (agrega usuarios con roles e historial de cambios). + - Si nunca corriste `migracion_v4.sql`, pegalo y ejecutalo (cambia el acceso de patrón dibujado a PIN numérico). + - Por último, pegá y ejecutá **`migracion_v5.sql`** (agrega sedes, separa los pacientes por profesional, agrega el rol Desarrollador y la confirmación de turnos por el paciente). Este script crea una "Sede principal" automática y te asigna ahí todo lo que ya tenías cargado, para no perder nada. + - Si nunca corriste `migracion_v6.sql`, pegalo y ejecutalo también (agrega el aviso de confirmaciones/cancelaciones de turno). + - Por último, pegá y ejecutá **`migracion_v7.sql`** (agrega a la papelera el profesional y la sede original de cada legajo eliminado, necesario para que el Desarrollador pueda recuperarlos). + - Si nunca corriste `migracion_v8.sql`, pegalo y ejecutalo también (agrega el aviso de "legajo recuperado de otro profesional" en la ficha del paciente). + - Por último, pegá y ejecutá **`migracion_v9.sql`** (el DNI pasa a ser único por profesional, no global — así dos profesionales distintos pueden tener cada uno un paciente con el mismo número de DNI, sin chocar entre sí). + - Ninguno de los cinco scripts borra pacientes, sesiones, citas ni adjuntos. +2. **Subí la carpeta `adjuntos/`** completa (si todavía no la tenías) al mismo nivel que `index.html`. +3. **Reemplazá** estos archivos por sus versiones nuevas: `index.html`, toda la carpeta `assets/`, toda la carpeta `api/`, `exportar.php`, y agregá el archivo nuevo **`confirmar_turno.php`**. No toques `config/config.php` — ya tiene tus credenciales y no cambió. + +**Importante — cómo queda tu acceso después de esta migración.** Tu usuario profesional y tu PIN siguen funcionando igual que antes, pero el login cambió de forma: ahora antes de poner el PIN vas a tener que elegir una sede (te va a aparecer "Sede principal", que es la que creó la migración) y después tu nombre en la lista. Es un paso más, pero el PIN es el mismo de siempre. + +**Sobre el nuevo rol Desarrollador.** Es un nivel de acceso por encima de todo, pensado para que solo vos (o quien administre el sistema técnicamente) pueda crear sedes nuevas y dar de alta o baja a profesionales y administrativas — así un médico no puede, por error o sin permiso, crear accesos para otros médicos. La migración **no te crea automáticamente** una clave de Desarrollador: la primera vez que entres después de migrar, vas a ver el botón "Soy el Desarrollador" en la pantalla de selección de sede. Tocalo y vas a poder crear esa clave (también de 4 números) en ese momento. Una vez creada, desde el panel de Desarrollador podés organizar mejor tus sedes y agregar a los demás profesionales si tenés más de uno. + +Si en tu consultorio **solo hay un profesional** (vos) y no necesitás nada de sedes múltiples ni separar pacientes entre médicos, no es obligatorio que uses el rol Desarrollador para el día a día — solo lo vas a necesitar la primera vez para crear la sede y, si querés, agregar una administrativa. + +--- + +## Instalación desde cero (primera vez) + +## 1. Crear la base de datos en cPanel + +1. Entrá a **cPanel** → buscá **"Bases de datos MySQL"**. +2. En **"Crear nueva base de datos"**, escribí por ejemplo `legajos` y hacé clic en **Crear base de datos**. + - cPanel le va a agregar tu usuario de hosting como prefijo, por ejemplo: `tuusuario_legajos`. Anotá ese nombre completo. +3. Bajá hasta **"Usuarios MySQL"** → **"Añadir nuevo usuario"**. Elegí un usuario (ej: `admin`) y una contraseña segura. Anotala. El nombre final va a ser algo como `tuusuario_admin`. +4. Bajá hasta **"Añadir usuario a la base de datos"**. Seleccioná el usuario y la base que creaste, hacé clic en **Añadir**, y en la pantalla de privilegios marcá **"ALL PRIVILEGES"**. Guardá. + +Con esto ya tenés: nombre de base de datos, usuario y contraseña. Los vas a necesitar en el paso 3. + +--- + +## 2. Importar la estructura de tablas + +1. En cPanel, abrí **phpMyAdmin**. +2. En la columna izquierda, hacé clic en la base de datos que creaste (`tuusuario_legajos`). +3. Arriba, hacé clic en la pestaña **"Importar"**. +4. Elegí el archivo **`database.sql`** (incluido en este proyecto) y hacé clic en **Continuar / Importar** abajo de todo. +5. Deberías ver un mensaje de éxito y, a la izquierda, las tablas: `sedes`, `desarrollador`, `usuarios`, `usuarios_sedes`, `obras_sociales`, `pacientes`, `sesiones`, `legajos_eliminados`, `citas`, `archivos_adjuntos`, `plantillas_evolucion`, `historial_cambios`. + +--- + +## 3. Configurar la conexión + +1. Abrí el archivo **`config/config.php`** con el editor de texto que prefieras. +2. Completá estas 4 líneas con tus datos reales del paso 1: + +```php +define('DB_HOST', 'localhost'); +define('DB_NAME', 'tuusuario_legajos'); +define('DB_USER', 'tuusuario_admin'); +define('DB_PASS', 'tu_contraseña_real'); +``` + +3. Cambiá también esta línea por cualquier texto largo e inventado (solo una vez, antes de subir el sitio): + +```php +define('APP_SECRET', 'escribí-aquí-cualquier-texto-largo-y-random-unico'); +``` + +Guardá el archivo. + +--- + +## 4. Subir los archivos + +1. En cPanel, abrí **"Administrador de archivos"** (File Manager) o usá un cliente FTP (FileZilla, etc.). +2. Entrá a la carpeta donde se publica tu sitio (normalmente `public_html`, o una subcarpeta si querés que viva en `tudominio.com/legajos`). +3. Subí **todo el contenido** de esta carpeta del proyecto (manteniendo la estructura: `index.html`, `exportar.php`, `confirmar_turno.php`, `manifest.json`, `sw.js`, `version.json`, carpetas `api/`, `assets/` —incluida `assets/icons/`—, `config/`, `adjuntos/`). + +**Importante:** la carpeta `adjuntos/` necesita permisos de escritura para que el sitio pueda guardar los archivos que subas. Si al subir un archivo te da error de permisos, entrá al Administrador de archivos, clic derecho sobre la carpeta `adjuntos` → Permisos → poné **755** (o 775 si 755 no alcanza). + +**Sobre `config/`:** no necesita estar accesible desde el navegador directamente, pero no es grave si lo está porque PHP no expone el código fuente, solo lo ejecuta. + +--- + +## 5. Probar — primer ingreso + +1. Entrá a `https://tudominio.com/` (o la ruta donde lo subiste). +2. La primera vez te va a pedir crear la **clave de Desarrollador** (4 números). Es la llave maestra para configurar el sistema — guardala en un lugar seguro, separado de los PIN de los profesionales. +3. Después de crear la clave, te va a aparecer una pantalla para crear de una vez tu **primera sede** y tu **primer profesional** (con su propio PIN de 4 números). Completá los tres datos y tocá "Crear sede y profesional". +4. Listo. Cerrá sesión y volvé a entrar: ahora vas a ver la pantalla normal de acceso — elegís la sede, elegís tu nombre, y poné tu PIN. + +Si en algún momento necesitás agregar otra sede, otro profesional, o una administrativa, entrá con la clave de Desarrollador (botón "Soy el Desarrollador" en la pantalla de selección de sede) y gestionalo desde ahí. + +--- + +## ¿Qué hace cada parte del sistema? + +- **Crear legajo**: registra un paciente nuevo con sus datos, obra social y, opcionalmente, las primeras sesiones. El paciente queda asociado automáticamente a tu usuario (solo vos lo vas a poder ver) y a la sede donde iniciaste sesión. +- **Acceder a legajos**: buscá por DNI, nombre y apellido, fecha de atención, obra social, o **sede**. Desde la ficha del paciente podés: + - **Editar sus datos** en cualquier momento con el botón "Editar datos". + - **Cambiarlo de sede** con el botón "Cambiar de sede", si el paciente empezó a atenderse en otro lugar. + - **Agregar, editar o eliminar sesiones**: cada sesión de la línea de tiempo tiene sus propios botones de lápiz (editar) y tacho (eliminar), por si te equivocaste al escribir algo. + - Usar una **plantilla de evolución** al agregar una sesión (texto reutilizable, propio de cada profesional — no se comparten entre médicos distintos). + - **Agendar y gestionar citas** del paciente (marcarlas como atendidas, ausentes o cancelarlas). El sistema no te deja agendar dos turnos a la misma fecha y hora para el mismo profesional, sin importar en qué sede sea. + - **Enviar un recordatorio por WhatsApp** con un mensaje pre-armado que incluye un link para que el paciente confirme o cancele el turno con un toque, sin necesidad de loguearse a nada. + - **Copiar el link de confirmación** del turno (ícono de "copiar"), por si preferís mandarlo por otro medio que no sea WhatsApp. + - **Subir y descargar archivos adjuntos** (PDF o imágenes), hasta 15 MB cada uno. + - **Exportar el legajo completo a PDF**, con tu nombre y título como firma al final. +- **Agenda**: calendario mensual completo con tus citas. Desde el panel principal también ves un resumen de "próximas citas" de los próximos 7 días. +- **"Hoy tenés X consultas"**: en el panel principal, una franja te muestra cuántas consultas de hoy todavía no llegó su hora y a qué hora es la próxima. El número baja automáticamente a medida que pasan las horas agendadas, sin que tengas que tocar nada — no depende de que marques "Atendida". +- **Aviso de confirmaciones y cancelaciones**: cuando un paciente confirma o cancela su turno desde el link que le mandaste, en el panel principal te aparece un cartel con la cantidad de novedades. Tocalo para ver el detalle (quién confirmó, quién canceló) y marcarlas como vistas. +- **Pacientes sin sesiones recientes** y **próximos cumpleaños**: resúmenes en el panel principal. +- **Estadísticas**: pacientes totales, sesiones del mes, citas por estado, distribución por obra social — siempre de **tus propios pacientes**, no de otros profesionales. Desde ahí también podés tocar "Descargar backup de todos mis legajos" para bajar un archivo con todos tus pacientes, sus sesiones, citas y la lista de adjuntos — útil como respaldo propio, fuera de la base de datos. +- **Eliminar legajos**: el legajo desaparece de las búsquedas normales, pero queda guardado completo en la base histórica. +- **Obras sociales**: catálogo compartido entre todos los profesionales (no es información de un paciente puntual, así que no hace falta separarlo). +- **Instalar el sistema como app**: tanto en celular como en computadora, podés "instalar" Del Austral para que tenga su propio ícono y se abra como una app, sin pasar por el navegador cada vez. Ver la sección [Instalar como app](#instalar-como-app) más abajo. +- **Verificación de versión** (solo Desarrollador): una pestaña en el panel del Desarrollador que revisa si los archivos del servidor coinciden con la última actualización que te entregamos, para detectar de un vistazo si algo quedó con una versión vieja después de subir archivos nuevos. +- **Reportes por sede** (solo Desarrollador): otra pestaña del panel que muestra, sede por sede, cuántos profesionales y administrativas atienden ahí, cuántos pacientes hay en total, y la actividad (sesiones y citas) de este mes. Útil para tener una foto general si el consultorio crece a varias sucursales. + +--- + +## Sedes, profesionales y roles + +Este sistema soporta un consultorio con **varias sedes** y **varios profesionales**, donde cada profesional ve únicamente sus propios pacientes — ni siquiera otro profesional de la misma sede puede verlos. + +### Los tres niveles de acceso + +| | **Desarrollador** | **Profesional** | **Administrativa** | +|---|---|---|---| +| Crear/desactivar sedes | Sí | No | No | +| Crear/desactivar usuarios | Sí | No | No | +| Ver pacientes y agenda | No | Sí (los propios) | Sí (de un profesional elegido) | +| Crear pacientes (contacto) | No | Sí | Sí | +| Ver motivo, patología, síntomas, sesiones | No | Sí | **No** | +| Editar / eliminar legajos, sesiones | No | Sí | No | +| Exportar PDF, ver estadísticas e historial | No | Sí | No | + +- **El Desarrollador** entra por una puerta separada (botón "Soy el Desarrollador" en la pantalla de acceso) con su propia clave de 4 números. No ve ningún paciente — su única función es organizar sedes y dar de alta o baja a las personas que sí van a usar el sistema día a día. +- **El profesional** entra eligiendo su sede y su nombre, y después su PIN. Ve y gestiona solo los pacientes que él mismo creó (o que el Desarrollador le haya dejado asociados). +- **La administrativa** entra de la misma forma, pero además tiene que indicar **a nombre de qué profesional** está trabajando en ese momento (si en la sede hay varios médicos, esto evita que el sistema confunda a quién pertenecen los pacientes que ella gestiona). Ve la agenda y los datos de contacto de los pacientes de ese profesional, pero nunca el contenido clínico. Una vez adentro, en la topbar y en el cartel de bienvenida aparece el nombre del profesional (no el de la administrativa) — así no se "mezclan" visualmente las cuentas. Internamente el sistema sigue sabiendo que fue ella quien hizo cada acción, para que el historial de cambios siga siendo preciso. + +### Gestionar sedes y usuarios (como Desarrollador) + +Desde el panel de Desarrollador (al que entrás con tu clave) tenés varias pestañas: + +- **Sedes**: crear sedes nuevas o desactivar las que ya no usás. +- **Usuarios**: agregar profesionales o administrativas, elegir en qué sede(s) atienden, y asignarles su PIN. También podés quitarles el acceso cuando haga falta (no se borra su historial, solo deja de poder entrar), o gestionar a qué sedes pertenece cada uno. +- **Historial de cambios**: acá el Desarrollador ve **todo** el historial de todos los profesionales (es el único rol con esa vista global; cada profesional, desde su propio panel, solo ve el historial de sus propios pacientes). +- **Versión del sistema**: compara los archivos del servidor contra la última actualización entregada. +- **Reportes por sede**: resumen de cada sede (profesionales, pacientes, actividad del mes). +- **Papelera**: ver los legajos que eliminó cualquier profesional y, si hace falta, recuperarlos asignándolos a otro profesional de la misma sede — por ejemplo, si un profesional deja de trabajar en una sede y otro va a continuar atendiendo a sus pacientes. Para usarla: elegí primero la sede, después el profesional que eliminó el legajo, y vas a ver su papelera. Al tocar "Recuperar" en un paciente, elegís a cuál de los profesionales de esa misma sede se lo querés asignar — el legajo vuelve a aparecer activo, con todas sus sesiones, a nombre del profesional que elegiste. Si lo asignaste a alguien distinto del profesional que lo tenía antes, le va a quedar un aviso permanente en la ficha del paciente, indicando de qué profesional venía ese legajo. +- **Legajos huérfanos**: distinto de la papelera — acá ves los pacientes que **siguen activos** (nunca se eliminaron) de un profesional al que le **quitaste el acceso** ("Quitar acceso" en la pestaña Usuarios). Como ese profesional ya no puede entrar al sistema, sus pacientes quedarían sin nadie que los gestione, a menos que los transfieras a otro profesional de la misma sede desde esta pestaña. Funciona igual que la papelera: elegís el profesional desactivado, ves sus pacientes, y tocás "Transferir" para asignárselos a otro. + +--- + +## Instalar como app + +Del Austral se puede "instalar" tanto en celular como en computadora, para que tenga su propio ícono y se abra como una aplicación, sin las barras del navegador. No es una app de tienda (no pasa por Google Play ni App Store) — se instala directo desde el navegador, y funciona exactamente igual que la versión web (necesita conexión a internet, no funciona sin ella). + +**En Android (Chrome):** +1. Entrá al sitio normalmente. +2. Va a aparecer un cartelito abajo ofreciendo "Instalar app", o podés tocar los tres puntos (⋮) arriba a la derecha → **"Instalar app"** o **"Agregar a pantalla principal"**. +3. Confirmá. Va a aparecer el ícono de Del Austral entre tus apps, como cualquier otra. + +**En iPhone/iPad (Safari):** +1. Entrá al sitio normalmente. +2. Tocá el botón de compartir (el cuadrado con la flecha hacia arriba). +3. Buscá **"Agregar a pantalla de inicio"** y confirmá. + +**En computadora (Chrome o Edge):** +1. Entrá al sitio. +2. En la barra de direcciones, a la derecha, va a aparecer un ícono de instalación (una pantalla con una flecha). Hacé clic ahí, o desde el menú (⋮) buscá **"Instalar Del Austral"**. +3. Se va a abrir en su propia ventana, con su propio ícono en el escritorio o en la barra de tareas. + +Si no te aparece la opción de instalar, puede ser que el sitio no esté funcionando bajo HTTPS (revisá que tengas el candadito en la barra de direcciones; si no, activá el AutoSSL de cPanel mencionado en la sección de seguridad). + +--- + +## Verificación de versión (para el Desarrollador) + +Cada vez que te entreguemos una actualización, vas a recibir junto con los archivos un **`version.json`** nuevo. Subilo siempre junto con el resto — es el que le dice al sistema "esta es la versión correcta esperada". + +Para revisar si todo quedó bien actualizado: + +1. Entrá como Desarrollador. +2. En el panel, pestaña **"Versión del sistema"**. +3. Vas a ver un cartel verde si todo coincide con la última actualización, o uno rojo si algún archivo quedó con una versión vieja — con el detalle de cuál archivo específico tiene el problema. + +Esto compara el contenido real de los archivos del servidor contra lo que te entregamos, así que es la forma más confiable de confirmar una actualización sin tener que ir abriendo archivo por archivo en el editor de cPanel (como tuvimos que hacer alguna vez antes de tener esta pantalla). + +--- + +## Si falla la subida de archivos adjuntos + +El sistema permite adjuntar PDF o imágenes de hasta 15 MB por archivo, pero ese límite también depende de la configuración propia de PHP en tu hosting (`upload_max_filesize` y `post_max_size`). Si tu hosting tiene un límite más bajo (algo común en planes básicos, donde el default suele ser 2 MB u 8 MB), vas a ver un mensaje claro indicando el límite real del servidor. + +Para subirlo, en cPanel buscá **"Seleccionar versión de PHP"** o **"MultiPHP INI Editor"** → ahí podés aumentar `upload_max_filesize` y `post_max_size` a, por ejemplo, 20M cada uno (siempre poné `post_max_size` igual o más grande que `upload_max_filesize`). Si no encontrás esa opción, contactá al soporte de tu hosting y pedíselo directamente. + +--- + +## Sobre la seguridad de los datos + +Esto maneja datos clínicos de pacientes, así que algunas recomendaciones: + +- Activá el **certificado SSL gratuito** de tu cPanel (sección "SSL/TLS Status" → "Run AutoSSL") para que el sitio funcione con `https://` y no `http://`. +- Hacé backups periódicos de la base de datos desde phpMyAdmin, y también de la carpeta `adjuntos/` (los archivos subidos no están en la base de datos). +- Los PIN y la clave de Desarrollador se guardan encriptados (hash), nunca en texto plano. +- El aislamiento entre profesionales no depende solo de la pantalla: está aplicado en el servidor, así que aunque alguien intente forzar una URL con el ID de un paciente de otro profesional, el sistema no lo va a mostrar. +- La carpeta `adjuntos/` tiene un archivo `.htaccess` que impide que cualquier archivo subido se ejecute como código. +- El link de confirmación de turno (`confirmar_turno.php`) es público a propósito —es lo que permite que el paciente confirme sin loguearse— pero solo expone los datos de esa cita puntual (fecha, hora, motivo), nunca información clínica. Cada link es único e impredecible (un código largo generado al azar), así que no se puede adivinar el de otro paciente. + +Cualquier ajuste que necesites lo podemos ir sumando. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2f6129a --- /dev/null +++ b/LICENSE @@ -0,0 +1,129 @@ +PolyForm Noncommercial License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only distribute the +software according to [Distribution License](#distribution-license) +and make changes or new works based on the software according +to [Changes and New Works License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license to +distribute copies of the software. Your license to distribute +covers distributing the software with changes and new works +permitted by [Changes and New Works License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of the +software from you also gets a copy of these terms or the URL +for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Del Austral contributors (https://github.com/) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the +benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, is +use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, +public research organization, public safety or health +organization, environmental protection organization, or +government institution is use for a permitted purpose +regardless of the source of funding or obligations resulting +from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. +These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply any +other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, and +take practical steps to correct past violations, within 32 days +of receiving notice. Otherwise, all your licenses end +immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use or +nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, or +other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0b44ac --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# Del Austral + +[![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/license-PolyForm%20Noncommercial%201.0.0-blue.svg)](./LICENSE) + +**Historial clínico digital multi-sede para consultorios de salud.** Sistema web autoalojado (PHP + MySQL) pensado para profesionales de la salud —médicos, fonoaudiólogos, nutricionistas, psicólogos y afines— que necesitan reemplazar las fichas de papel sin depender de un SaaS de terceros ni pagar licencias mensuales. Soporta varias sedes y varios profesionales con aislamiento total de datos entre ellos. + +Pensado para correr en hosting compartido tipo cPanel, sin necesidad de acceso SSH, Composer ni extensiones especiales del servidor. + +--- + +## Índice + +- [Características](#características) +- [Stack técnico](#stack-técnico) +- [Estructura del proyecto](#estructura-del-proyecto) +- [Instalación](#instalación) +- [Sedes y roles de usuario](#sedes-y-roles-de-usuario) +- [Modelo de datos](#modelo-de-datos) +- [Seguridad](#seguridad) +- [Hoja de ruta](#hoja-de-ruta) +- [Licencia](#licencia) + +--- + +## Características + +- 🔐 **Acceso por PIN numérico** (4 dígitos) en vez de usuario/contraseña tradicional. +- 🏥 **Multi-sede**: un consultorio puede tener varias sucursales; cada paciente pertenece a una sede y se puede migrar a otra. +- 👥 **Multi-profesional con aislamiento total**: cada profesional ve únicamente sus propios pacientes, incluso compartiendo sede con otros médicos. El filtrado se aplica en el servidor, no solo en la interfaz. +- 🛠️ **Rol Desarrollador**: nivel de acceso separado, sin visibilidad de pacientes, dedicado exclusivamente a crear sedes y dar de alta/baja profesionales y administrativas. +- 🧑‍💼 **Rol Administrativa**: gestiona agenda y contacto de los pacientes de un profesional elegido al iniciar sesión, sin acceso a contenido clínico. +- 📋 **Legajos de pacientes** completos: datos personales, obra social (catálogo editable), motivo de consulta, patología, síntomas, observaciones. +- 🗓️ **Sesiones clínicas** editables y eliminables, con historial cronológico, y **plantillas de evolución** propias de cada profesional. +- 📅 **Agenda con calendario mensual** y **detección de choque de horario** (no se puede agendar dos turnos a la misma fecha/hora para el mismo profesional, sin importar la sede). +- ✅ **Confirmación de turno por el paciente**: link público único (sin login) donde el paciente confirma o cancela su cita. +- 💬 **Recordatorio de turnos por WhatsApp**, con el link de confirmación incluido en el mensaje. +- 📎 **Archivos adjuntos** por paciente (PDF e imágenes, hasta 15 MB), servidos desde una carpeta protegida. +- 📄 **Exportación de legajo a PDF** vía vista de impresión del navegador. +- 🎂 Resumen de **próximos cumpleaños** de pacientes. +- 📊 **Dashboard de estadísticas** por profesional: pacientes activos, sesiones por mes, distribución por obra social, citas por estado. +- 🧾 **Historial de cambios (auditoría)**: quién creó, editó o eliminó cada legajo, y cuándo — acotado a los propios pacientes de cada profesional. +- 🗑️ **Papelera / base histórica**: los legajos eliminados quedan archivados como JSON y son consultables. +- ⚖️ Aviso de protección de datos personales conforme a la **Ley 25.326** (Argentina). +- 📱 **App instalable (PWA)**: se puede instalar en celular o computadora con su propio ícono, sin pasar por una tienda de aplicaciones. +- 🔍 **Verificación de versión**: el Desarrollador puede comparar los archivos del servidor contra la última actualización entregada, para detectar archivos desactualizados tras una subida incompleta. +- 📊 **Reportes por sede**: el Desarrollador ve, por cada sede activa, cuántos profesionales/administrativas atienden ahí, pacientes totales y actividad del mes. +- 💾 **Exportación masiva**: cada profesional puede descargar un backup propio en JSON con todos sus pacientes, sesiones, citas y metadata de adjuntos. +- ♻️ **Papelera global** (Desarrollador): ver y recuperar legajos eliminados por cualquier profesional, asignándolos a otro profesional de la misma sede — útil cuando un profesional deja la sede y otro continúa con sus pacientes. + +## Stack técnico + +| Capa | Tecnología | +|---|---| +| Backend | PHP 7.4+ puro (sin frameworks, sin Composer) | +| Base de datos | MySQL / MariaDB (InnoDB, utf8mb4) | +| Frontend | HTML, CSS y JavaScript vanilla (sin build step, sin npm) | +| Autenticación | PIN hasheado con `password_hash` (bcrypt) + sesiones nativas de PHP | +| Exportación PDF | Vista HTML de impresión (`window.print()`), sin librerías externas | + +No requiere `composer install`, `npm install` ni proceso de build. Se sube por FTP o el Administrador de archivos de cPanel y funciona. + +## Estructura del proyecto + +``` +del-austral/ +├── index.html # SPA: toda la interfaz vive acá (templates + vistas) +├── exportar.php # Vista de impresión/exportación de legajo a PDF +├── confirmar_turno.php # Página pública de confirmación de turno (sin login) +├── manifest.json # Metadata de la PWA (nombre, iconos, colores) +├── sw.js # Service worker mínimo, solo para habilitar la instalación +├── version.json # Hashes de referencia para la verificación de versión +├── database.sql # Esquema completo (instalación nueva, desde cero) +├── migracion_v2.sql # Agrega: citas, archivos adjuntos, plantillas +├── migracion_v3.sql # Agrega: usuarios con roles, historial de cambios +├── migracion_v4.sql # Cambia el acceso de patrón dibujado a PIN numérico +├── migracion_v5.sql # Agrega: sedes, aislamiento por profesional, rol Desarrollador +├── migracion_v6.sql # Agrega: aviso de confirmaciones/cancelaciones de turno +├── migracion_v7.sql # Agrega: profesional/sede original a la papelera (recuperación cruzada) +├── config/ +│ └── config.php # Credenciales de BD + helpers de sesión/rol/auditoría +├── api/ +│ ├── auth.php # Login multi-paso, Desarrollador, sedes, usuarios +│ ├── pacientes.php # CRUD de legajos, sesiones, búsqueda, migración de sede +│ ├── citas.php # Agenda, choque de horario, cumpleaños, inactivos +│ ├── adjuntos.php # Subida/descarga de archivos por paciente +│ ├── plantillas.php # CRUD de plantillas de evolución (por profesional) +│ ├── obras_sociales.php # Catálogo de obras sociales +│ └── admin.php # Estadísticas, historial de cambios, verificación de versión +├── assets/ +│ ├── css/estilos.css # Toda la hoja de estilos +│ ├── js/app.js # Toda la lógica de frontend +│ └── icons/ # Íconos de la PWA en distintos tamaños +└── adjuntos/ # Carpeta de archivos subidos (protegida con .htaccess) +``` + +## Instalación + +Guía completa paso a paso (creación de base de datos en cPanel, configuración de `config.php`, subida de archivos, migración desde versiones anteriores) en **[`INSTRUCCIONES_INSTALACION.md`](./INSTRUCCIONES_INSTALACION.md)**. + +Resumen rápido para una instalación nueva: + +1. Creá una base de datos MySQL en tu hosting e importá `database.sql` desde phpMyAdmin. +2. Completá `config/config.php` con tus credenciales de base de datos y un `APP_SECRET` propio. +3. Subí todo el proyecto a tu hosting (FTP o Administrador de archivos de cPanel), incluyendo la carpeta `adjuntos/`. +4. Abrí el sitio: la primera vez te va a pedir crear la clave de Desarrollador, y luego tu primera sede y profesional. + +Si venís de una versión anterior con pacientes ya cargados, corré las migraciones en orden (`migracion_v2.sql` → `migracion_v3.sql` → `migracion_v4.sql` → `migracion_v5.sql`), sin saltarte ninguna. Ningún script borra pacientes, sesiones, citas ni adjuntos. + +## Sedes y roles de usuario + +| | Desarrollador | Profesional | Administrativa | +|---|---|---|---| +| Crear/desactivar sedes y usuarios | ✅ | ❌ | ❌ | +| Ver pacientes y agenda | ❌ | ✅ (propios) | ✅ (de un profesional elegido) | +| Crear paciente (datos de contacto) | ❌ | ✅ | ✅ | +| Motivo, patología, síntomas, sesiones | ❌ | ✅ | ❌ | +| Editar / eliminar legajo o sesión | ❌ | ✅ | ❌ | +| Exportar PDF, estadísticas, historial | ❌ | ✅ | ❌ | + +- El **Desarrollador** entra con una clave separada (no elige sede ni aparece en el login normal) y solo gestiona sedes y altas/bajas de usuarios. +- Cada **profesional** ve exclusivamente los pacientes que le pertenecen (`pacientes.profesional_id`), incluso si comparte sede con otros profesionales. +- La **administrativa**, al iniciar sesión, además de elegir sede y PIN debe indicar a qué profesional representa — eso determina qué pacientes ve (filtrados, sin contenido clínico). + +El filtrado por rol y por dueño de cada paciente se aplica en cada endpoint de `api/`, no solo ocultando elementos del HTML — un profesional no puede ver ni manipular los pacientes de otro aunque conozca o adivine su ID. + +## Modelo de datos + +Tablas principales (ver `database.sql` para el esquema completo con índices y claves foráneas): + +- `sedes` — sucursales del consultorio +- `desarrollador` — clave única del rol Desarrollador +- `usuarios` — accesos al sistema (PIN hasheado, rol, estado activo/inactivo) +- `usuarios_sedes` — relación N a N entre usuarios y las sedes donde atienden +- `pacientes` — legajo principal, con `profesional_id` (dueño) y `sede_id` +- `sesiones` — historial clínico cronológico por paciente, editable +- `citas` — agenda, con `profesional_id`, token de confirmación pública y detección de choque de horario +- `archivos_adjuntos` — metadata de archivos subidos (PDF/imágenes) +- `plantillas_evolucion` — textos reutilizables por profesional (no compartidos) +- `obras_sociales` — catálogo editable de coberturas de salud, compartido +- `legajos_eliminados` — papelera / base histórica (JSON del legajo completo al eliminarlo) +- `historial_cambios` — auditoría de acciones (quién, qué, cuándo) + +## Seguridad + +- Los PIN y la clave de Desarrollador se almacenan con `password_hash()` (bcrypt) combinados con un secreto de aplicación (`APP_SECRET`); nunca en texto plano. +- Protección básica contra fuerza bruta: bloqueo temporal tras 5 intentos fallidos, tanto para el login normal como para el de Desarrollador. +- Todos los endpoints en `api/` requieren sesión autenticada; los que exponen datos clínicos exigen además rol *profesional*, y siempre filtran por el profesional dueño de cada registro. +- El link público de confirmación de turno usa un token aleatorio de 20 bytes — no expone contenido clínico, solo fecha/hora/motivo de la cita puntual. +- La carpeta `adjuntos/` incluye un `.htaccess` que impide la ejecución de scripts, aunque se suba un archivo con extensión engañosa. +- Validación de tipo MIME real (no solo por extensión) al subir archivos. +- Se recomienda servir el sitio bajo HTTPS (AutoSSL de cPanel es gratuito) dado que se transmiten datos de salud. + +> Este proyecto no ha pasado por una auditoría de seguridad profesional ni un pentest formal. Es razonablemente seguro para el caso de uso de un consultorio pequeño o mediano, pero quien lo despliegue es responsable de evaluar si cumple los requisitos normativos de su jurisdicción (en Argentina, Ley 25.326 de Protección de Datos Personales) antes de usarlo con datos reales de pacientes. + +## Hoja de ruta + +Ideas pendientes, sin compromiso de implementación: + +- [ ] Firma con imagen escaneada en el PDF exportado (hoy es solo nombre y título en texto) +- [ ] Recuperación de PIN olvidado sin pasar por phpMyAdmin +- [ ] Buscador único en el panel principal (DNI/nombre → resultado directo) +- [ ] Notificaciones push o por email para recordatorios de turno +- [ ] Exportación de estadísticas a Excel/CSV +- [ ] Internacionalización (hoy todos los textos están en español rioplatense) + +## Licencia + +[PolyForm Noncommercial 1.0.0](./LICENSE) — código fuente disponible para cualquier uso **no comercial**: estudio personal, proyectos propios sin fines de lucro, instituciones educativas, organizaciones de salud pública, ONGs y organismos de gobierno. Para uso comercial (ofrecerlo como servicio, instalarlo a terceros a cambio de un pago, integrarlo en un producto comercial) se necesita una licencia aparte — abrí un issue o contactá a quien mantiene este repositorio. + +--- + +Construido de forma iterativa junto a [Claude](https://claude.ai) (Anthropic). diff --git a/adjuntos/.gitkeep b/adjuntos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/adjuntos/.htaccess b/adjuntos/.htaccess new file mode 100644 index 0000000..34b5833 --- /dev/null +++ b/adjuntos/.htaccess @@ -0,0 +1,11 @@ +# Esta carpeta guarda archivos subidos (PDF e imágenes) de los legajos. +# Por seguridad, se impide que cualquier archivo se ejecute como script, +# incluso si alguien lograra subir un archivo con extensión engañosa. + + + Order Allow,Deny + Deny from all + + +# Evita que se liste el contenido de la carpeta desde el navegador. +Options -Indexes diff --git a/api/adjuntos.php b/api/adjuntos.php new file mode 100644 index 0000000..afe0920 --- /dev/null +++ b/api/adjuntos.php @@ -0,0 +1,270 @@ + 'pdf', + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', +]); +define('TAMANIO_MAXIMO', 15 * 1024 * 1024); // 15 MB + +function pacienteEsDelProfesional($pdo, $pacienteId, $profesionalActivoId) { + $stmt = $pdo->prepare('SELECT 1 FROM pacientes WHERE id = ? AND profesional_id = ?'); + $stmt->execute([$pacienteId, $profesionalActivoId]); + return (bool) $stmt->fetch(); +} + +/** + * Convierte un valor de php.ini como "8M", "2G" o "512K" a bytes. + * Si ya es un número plano, lo devuelve tal cual. + */ +function convertirAByte($valor) { + $valor = trim((string) $valor); + if ($valor === '' || $valor === '-1') return 0; // sin límite configurado + $unidad = strtoupper(substr($valor, -1)); + $numero = (float) $valor; + switch ($unidad) { + case 'G': return (int) ($numero * 1024 * 1024 * 1024); + case 'M': return (int) ($numero * 1024 * 1024); + case 'K': return (int) ($numero * 1024); + default: return (int) $valor; + } +} + +/** + * Da un texto legible para humanos a partir de una cantidad de bytes. + */ +function formatearBytesLegible($bytes) { + if ($bytes <= 0) return 'sin límite definido'; + if ($bytes >= 1024 * 1024 * 1024) return round($bytes / (1024 * 1024 * 1024), 1) . ' GB'; + if ($bytes >= 1024 * 1024) return round($bytes / (1024 * 1024), 1) . ' MB'; + return round($bytes / 1024, 1) . ' KB'; +} + +// ------------------------------------------------------------ +// SUBIR ARCHIVO (POST ?accion=subir) — multipart/form-data +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'subir') { + header('Content-Type: application/json; charset=utf-8'); + requiereRolProfesional(); + + // Si el archivo (o el POST completo) superó los límites del + // propio servidor (php.ini: upload_max_filesize / post_max_size), + // PHP vacía $_FILES sin avisar el motivo real. Lo detectamos + // comparando contra esos límites para poder dar un mensaje claro, + // en vez de un error confuso de "no se recibió ningún archivo". + if (empty($_FILES) && empty($_POST) && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) { + $limitePost = convertirAByte(ini_get('post_max_size')); + http_response_code(413); + echo json_encode([ + 'ok' => false, + 'error' => 'El archivo es demasiado grande para este servidor (el límite actual es ' . formatearBytesLegible($limitePost) . '). Probá con un archivo más chico, o pedile a quien administre el hosting que aumente el límite de subida en PHP.', + ]); + exit; + } + + if (empty($_FILES['archivo'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'No se recibió ningún archivo.']); + exit; + } + $pacienteId = $_POST['paciente_id'] ?? 0; + $sesionId = $_POST['sesion_id'] ?? null; + $descripcion = trim($_POST['descripcion'] ?? ''); + + if (!$pacienteId) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta el paciente al que pertenece el archivo.']); + exit; + } + + if (!pacienteEsDelProfesional($pdo, $pacienteId, $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $archivo = $_FILES['archivo']; + if ($archivo['error'] === UPLOAD_ERR_INI_SIZE || $archivo['error'] === UPLOAD_ERR_FORM_SIZE) { + $limiteUpload = convertirAByte(ini_get('upload_max_filesize')); + http_response_code(413); + echo json_encode([ + 'ok' => false, + 'error' => 'El archivo supera el límite de subida configurado en este servidor (' . formatearBytesLegible($limiteUpload) . '). Probá con un archivo más chico.', + ]); + exit; + } + if ($archivo['error'] !== UPLOAD_ERR_OK) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Error al subir el archivo. Intentá nuevamente.']); + exit; + } + if ($archivo['size'] > TAMANIO_MAXIMO) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El archivo supera el tamaño máximo permitido (15 MB).']); + exit; + } + + if (!function_exists('finfo_open')) { + http_response_code(500); + echo json_encode([ + 'ok' => false, + 'error' => 'El servidor no tiene habilitada la extensión PHP "fileinfo", necesaria para verificar el tipo de archivo. Pedile a tu hosting que la active (suele estar en "Seleccionar versión de PHP" → extensiones).', + ]); + exit; + } + + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo === false) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo verificar el tipo de archivo en este servidor. Intentá nuevamente o avisá a soporte.']); + exit; + } + $mimeReal = finfo_file($finfo, $archivo['tmp_name']); + finfo_close($finfo); + + if ($mimeReal === false) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo leer el archivo subido. Probá nuevamente.']); + exit; + } + + if (!isset(TIPOS_PERMITIDOS[$mimeReal])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Tipo de archivo no permitido. Solo se aceptan PDF, JPG, PNG o WEBP.']); + exit; + } + + $extension = TIPOS_PERMITIDOS[$mimeReal]; + $nombreArchivo = bin2hex(random_bytes(16)) . '.' . $extension; + $rutaDestino = CARPETA_ADJUNTOS . $nombreArchivo; + + if (!is_dir(CARPETA_ADJUNTOS)) { + mkdir(CARPETA_ADJUNTOS, 0755, true); + } + + if (!move_uploaded_file($archivo['tmp_name'], $rutaDestino)) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo guardar el archivo en el servidor.']); + exit; + } + + $stmt = $pdo->prepare(' + INSERT INTO archivos_adjuntos (paciente_id, sesion_id, nombre_original, nombre_archivo, tipo_mime, tamanio_bytes, descripcion) + VALUES (?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $pacienteId, + $sesionId ?: null, + $archivo['name'], + $nombreArchivo, + $mimeReal, + $archivo['size'], + $descripcion ?: null, + ]); + + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId(), 'nombre_archivo' => $nombreArchivo]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR ADJUNTOS DE UN PACIENTE (GET ?accion=listar&paciente_id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'listar') { + header('Content-Type: application/json; charset=utf-8'); + requiereRolProfesional(); + $pacienteId = $_GET['paciente_id'] ?? 0; + + if (!pacienteEsDelProfesional($pdo, $pacienteId, $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $stmt = $pdo->prepare('SELECT * FROM archivos_adjuntos WHERE paciente_id = ? ORDER BY subido_en DESC'); + $stmt->execute([$pacienteId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// DESCARGAR / VER ARCHIVO (GET ?accion=ver&id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'ver') { + requiereRolProfesional(); + $id = $_GET['id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT a.* FROM archivos_adjuntos a + INNER JOIN pacientes p ON p.id = a.paciente_id + WHERE a.id = ? AND p.profesional_id = ? + '); + $stmt->execute([$id, $profesionalActivoId]); + $archivo = $stmt->fetch(); + + if (!$archivo) { + http_response_code(404); + echo 'Archivo no encontrado.'; + exit; + } + + $ruta = CARPETA_ADJUNTOS . $archivo['nombre_archivo']; + if (!file_exists($ruta)) { + http_response_code(404); + echo 'El archivo ya no está disponible en el servidor.'; + exit; + } + + header('Content-Type: ' . $archivo['tipo_mime']); + header('Content-Disposition: inline; filename="' . basename($archivo['nombre_original']) . '"'); + header('Content-Length: ' . filesize($ruta)); + readfile($ruta); + exit; +} + +// ------------------------------------------------------------ +// ELIMINAR ADJUNTO (POST ?accion=eliminar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'eliminar') { + header('Content-Type: application/json; charset=utf-8'); + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $id = $d['id'] ?? 0; + + $stmt = $pdo->prepare(' + SELECT a.* FROM archivos_adjuntos a + INNER JOIN pacientes p ON p.id = a.paciente_id + WHERE a.id = ? AND p.profesional_id = ? + '); + $stmt->execute([$id, $profesionalActivoId]); + $archivo = $stmt->fetch(); + + if (!$archivo) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Archivo no encontrado.']); + exit; + } + + $ruta = CARPETA_ADJUNTOS . $archivo['nombre_archivo']; + if (file_exists($ruta)) { + unlink($ruta); + } + + $stmtBorrar = $pdo->prepare('DELETE FROM archivos_adjuntos WHERE id = ?'); + $stmtBorrar->execute([$id]); + + echo json_encode(['ok' => true]); + exit; +} + +header('Content-Type: application/json; charset=utf-8'); +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/admin.php b/api/admin.php new file mode 100644 index 0000000..3352fba --- /dev/null +++ b/api/admin.php @@ -0,0 +1,291 @@ +prepare($sql); + $stmt->execute($params); + $datos = $stmt->fetchAll(); + + $sqlTotal = "SELECT COUNT(*) AS total FROM historial_cambios h $filtroEntidad"; + $stmtTotal = $pdo->prepare($sqlTotal); + $stmtTotal->execute($params); + $total = $stmtTotal->fetch()['total']; + + echo json_encode(['ok' => true, 'datos' => $datos, 'total' => (int) $total, 'pagina' => $pagina]); + exit; + } + + requiereRolProfesional(); + $profesionalActivoId = idProfesionalActivo(); + + $sql = " + SELECT h.* FROM historial_cambios h + WHERE + (h.entidad = 'paciente' AND h.entidad_id IN (SELECT id FROM pacientes WHERE profesional_id = ?)) + OR (h.entidad = 'sesion' AND h.entidad_id IN ( + SELECT s.id FROM sesiones s INNER JOIN pacientes p ON p.id = s.paciente_id WHERE p.profesional_id = ? + )) + OR h.usuario_id = ? + ORDER BY h.creado_en DESC + LIMIT $porPagina OFFSET $offset + "; + $stmt = $pdo->prepare($sql); + $stmt->execute([$profesionalActivoId, $profesionalActivoId, $_SESSION['usuario_id']]); + $datos = $stmt->fetchAll(); + + $sqlTotal = " + SELECT COUNT(*) AS total FROM historial_cambios h + WHERE + (h.entidad = 'paciente' AND h.entidad_id IN (SELECT id FROM pacientes WHERE profesional_id = ?)) + OR (h.entidad = 'sesion' AND h.entidad_id IN ( + SELECT s.id FROM sesiones s INNER JOIN pacientes p ON p.id = s.paciente_id WHERE p.profesional_id = ? + )) + OR h.usuario_id = ? + "; + $stmtTotal = $pdo->prepare($sqlTotal); + $stmtTotal->execute([$profesionalActivoId, $profesionalActivoId, $_SESSION['usuario_id']]); + $total = $stmtTotal->fetch()['total']; + + echo json_encode(['ok' => true, 'datos' => $datos, 'total' => (int)$total, 'pagina' => $pagina]); + exit; +} + +// ------------------------------------------------------------ +// DASHBOARD DE ESTADÍSTICAS (GET ?accion=estadisticas) +// Acotado siempre al profesional activo. +// ------------------------------------------------------------ +if ($accion === 'estadisticas') { + requiereRolProfesional(); + $profesionalActivoId = idProfesionalActivo(); + $stmtTotal = $pdo->prepare('SELECT COUNT(*) AS t FROM pacientes WHERE profesional_id = ?'); + $stmtTotal->execute([$profesionalActivoId]); + $totalPacientes = $stmtTotal->fetch()['t']; + + $stmtObras = $pdo->prepare(' + SELECT COALESCE(o.nombre, "Sin especificar") AS nombre, COUNT(*) AS total + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + WHERE p.profesional_id = ? + GROUP BY o.id + ORDER BY total DESC + LIMIT 8 + '); + $stmtObras->execute([$profesionalActivoId]); + $porObraSocial = $stmtObras->fetchAll(); + + $stmtSesionesMes = $pdo->prepare(" + SELECT COUNT(*) AS t FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.profesional_id = ? AND s.fecha_sesion BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + "); + $stmtSesionesMes->execute([$profesionalActivoId]); + $sesionesEsteMes = $stmtSesionesMes->fetch()['t']; + + $stmtSesionesMesAnterior = $pdo->prepare(" + SELECT COUNT(*) AS t FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.profesional_id = ? + AND s.fecha_sesion BETWEEN DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') + AND LAST_DAY(DATE_SUB(CURDATE(), INTERVAL 1 MONTH)) + "); + $stmtSesionesMesAnterior->execute([$profesionalActivoId]); + $sesionesMesAnterior = $stmtSesionesMesAnterior->fetch()['t']; + + $stmtCitasEstado = $pdo->prepare(" + SELECT estado, COUNT(*) AS total FROM citas + WHERE profesional_id = ? AND fecha BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + GROUP BY estado + "); + $stmtCitasEstado->execute([$profesionalActivoId]); + $citasPorEstadoEsteMes = $stmtCitasEstado->fetchAll(); + + $stmtNuevos = $pdo->prepare(" + SELECT COUNT(*) AS t FROM pacientes + WHERE profesional_id = ? AND creado_en BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + "); + $stmtNuevos->execute([$profesionalActivoId]); + $pacientesNuevosEsteMes = $stmtNuevos->fetch()['t']; + + $stmtUltimos6 = $pdo->prepare(" + SELECT DATE_FORMAT(s.fecha_sesion, '%Y-%m') AS mes, COUNT(*) AS total + FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.profesional_id = ? AND s.fecha_sesion >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) + GROUP BY mes + ORDER BY mes ASC + "); + $stmtUltimos6->execute([$profesionalActivoId]); + $sesionesUltimos6Meses = $stmtUltimos6->fetchAll(); + + echo json_encode([ + 'ok' => true, + 'datos' => [ + 'total_pacientes' => (int) $totalPacientes, + 'pacientes_nuevos_mes' => (int) $pacientesNuevosEsteMes, + 'por_obra_social' => $porObraSocial, + 'sesiones_este_mes' => (int) $sesionesEsteMes, + 'sesiones_mes_anterior' => (int) $sesionesMesAnterior, + 'citas_por_estado_mes' => $citasPorEstadoEsteMes, + 'sesiones_ultimos_6_meses' => $sesionesUltimos6Meses, + ], + ]); + exit; +} + +// ------------------------------------------------------------ +// REPORTES POR SEDE (GET ?accion=reporte_sedes) — solo +// desarrollador. Resumen agregado de cada sede activa: cuántos +// profesionales atienden ahí, total de pacientes, sesiones de +// este mes y citas por estado de este mes. +// ------------------------------------------------------------ +// ------------------------------------------------------------ +// LICENCIAS POR VENCER (GET ?accion=licencias_por_vencer) — +// solo desarrollador. Profesionales activos cuya licencia +// vence dentro de los próximos 7 días, para tener el aviso a +// tiempo y no que la suspensión los agarre de sorpresa. +// ------------------------------------------------------------ +if ($accion === 'licencias_por_vencer') { + requiereDesarrollador(); + $stmt = $pdo->query(" + SELECT u.id, u.nombre_completo, + DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) AS vencimiento, + DATEDIFF(DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY), CURDATE()) AS dias_restantes + FROM usuarios u + WHERE u.rol = 'profesional' AND u.activo = 1 AND u.estado_licencia = 'activo' + AND u.licencia_dias IS NOT NULL + AND DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY) + ORDER BY vencimiento ASC + "); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +if ($accion === 'reporte_sedes') { + requiereDesarrollador(); + + $stmtSedes = $pdo->query('SELECT id, nombre FROM sedes WHERE activa = 1 ORDER BY nombre ASC'); + $sedes = $stmtSedes->fetchAll(); + + $stmtProfesionales = $pdo->prepare(" + SELECT COUNT(DISTINCT u.id) AS t FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE us.sede_id = ? AND u.rol = 'profesional' AND u.activo = 1 + "); + $stmtAdministrativas = $pdo->prepare(" + SELECT COUNT(DISTINCT u.id) AS t FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE us.sede_id = ? AND u.rol = 'administrativa' AND u.activo = 1 + "); + $stmtPacientes = $pdo->prepare('SELECT COUNT(*) AS t FROM pacientes WHERE sede_id = ?'); + $stmtSesionesMes = $pdo->prepare(" + SELECT COUNT(*) AS t FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE p.sede_id = ? AND s.fecha_sesion BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + "); + $stmtCitasMes = $pdo->prepare(" + SELECT c.estado, COUNT(*) AS total FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE p.sede_id = ? AND c.fecha BETWEEN DATE_FORMAT(CURDATE(), '%Y-%m-01') AND LAST_DAY(CURDATE()) + GROUP BY c.estado + "); + + $resultado = []; + foreach ($sedes as $sede) { + $stmtProfesionales->execute([$sede['id']]); + $stmtAdministrativas->execute([$sede['id']]); + $stmtPacientes->execute([$sede['id']]); + $stmtSesionesMes->execute([$sede['id']]); + $stmtCitasMes->execute([$sede['id']]); + + $resultado[] = [ + 'id' => $sede['id'], + 'nombre' => $sede['nombre'], + 'profesionales' => (int) $stmtProfesionales->fetch()['t'], + 'administrativas' => (int) $stmtAdministrativas->fetch()['t'], + 'pacientes' => (int) $stmtPacientes->fetch()['t'], + 'sesiones_mes' => (int) $stmtSesionesMes->fetch()['t'], + 'citas_por_estado_mes' => $stmtCitasMes->fetchAll(), + ]; + } + + echo json_encode(['ok' => true, 'datos' => $resultado]); + exit; +} + +// ------------------------------------------------------------ +// VERIFICAR VERSIÓN (GET ?accion=verificar_version) — solo +// desarrollador. Compara el contenido real de los archivos +// críticos del servidor contra los hashes de referencia que +// vinieron en la última entrega (version.json), para detectar +// de un vistazo si algún archivo quedó con una versión vieja +// después de una actualización a medio subir. +// ------------------------------------------------------------ +if ($accion === 'verificar_version') { + requiereDesarrollador(); + $rutaVersion = __DIR__ . '/../version.json'; + if (!file_exists($rutaVersion)) { + echo json_encode(['ok' => true, 'sin_version_json' => true]); + exit; + } + + $referencia = json_decode(file_get_contents($rutaVersion), true); + $resultado = []; + $hayDesactualizados = false; + + foreach ($referencia['archivos_criticos'] as $rutaRelativa => $hashEsperado) { + $rutaAbsoluta = __DIR__ . '/../' . $rutaRelativa; + if (!file_exists($rutaAbsoluta)) { + $resultado[] = ['archivo' => $rutaRelativa, 'estado' => 'falta', 'hash_esperado' => $hashEsperado, 'hash_real' => null]; + $hayDesactualizados = true; + continue; + } + $hashReal = md5_file($rutaAbsoluta); + $actualizado = ($hashReal === $hashEsperado); + if (!$actualizado) $hayDesactualizados = true; + $resultado[] = [ + 'archivo' => $rutaRelativa, + 'estado' => $actualizado ? 'actualizado' : 'desactualizado', + 'hash_esperado' => $hashEsperado, + 'hash_real' => $hashReal, + ]; + } + + echo json_encode([ + 'ok' => true, + 'version' => $referencia['version'] ?? '?', + 'fecha' => $referencia['fecha'] ?? null, + 'descripcion' => $referencia['descripcion'] ?? null, + 'hay_desactualizados' => $hayDesactualizados, + 'archivos' => $resultado, + ]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..11faab7 --- /dev/null +++ b/api/auth.php @@ -0,0 +1,1210 @@ +prepare(' + SELECT u.* FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE us.sede_id = ? AND u.activo = 1 AND u.rol IN ("profesional", "administrativa") + ORDER BY u.rol ASC, u.nombre_completo ASC + '); + $stmt->execute([$sedeId]); + return $stmt->fetchAll(); +} + +function pinValido($pin) { + return is_string($pin) && preg_match('/^\d{' . LARGO_PIN . '}$/', $pin); +} + +function generarNumeroLegajo($pdo) { + $anio = date('Y'); + $stmt = $pdo->prepare("SELECT COUNT(*) AS total FROM profesionales_legajos WHERE numero_legajo LIKE ?"); + $stmt->execute(["LG-$anio-%"]); + $total = (int) $stmt->fetch()['total']; + return "LG-$anio-" . str_pad($total + 1, 3, '0', STR_PAD_LEFT); +} + +function aplicarBloqueoFuerzaBruta($claveSesionIntentos, $claveSesionBloqueo) { + if (!isset($_SESSION[$claveSesionIntentos])) $_SESSION[$claveSesionIntentos] = 0; + if (!isset($_SESSION[$claveSesionBloqueo])) $_SESSION[$claveSesionBloqueo] = 0; + + if (time() < $_SESSION[$claveSesionBloqueo]) { + $espera = $_SESSION[$claveSesionBloqueo] - time(); + http_response_code(429); + echo json_encode(['ok' => false, 'error' => "Demasiados intentos. Esperá $espera segundos."]); + exit; + } +} + +function registrarIntentoFallido($claveSesionIntentos, $claveSesionBloqueo) { + $_SESSION[$claveSesionIntentos]++; + if ($_SESSION[$claveSesionIntentos] >= 5) { + $_SESSION[$claveSesionBloqueo] = time() + 30; + $_SESSION[$claveSesionIntentos] = 0; + } +} + +// ------------------------------------------------------------ +// ESTADO INICIAL: en qué etapa de configuración está el sistema. +// ------------------------------------------------------------ +if ($accion === 'estado') { + $hayDesarrollador = $pdo->query('SELECT COUNT(*) AS t FROM desarrollador')->fetch()['t'] > 0; + if (!$hayDesarrollador) { + echo json_encode(['ok' => true, 'etapa' => 'sin_desarrollador', 'largo_pin' => LARGO_PIN]); + exit; + } + + $sedesConUsuarios = $pdo->query(' + SELECT COUNT(*) AS t FROM sedes s + INNER JOIN usuarios_sedes us ON us.sede_id = s.id + INNER JOIN usuarios u ON u.id = us.usuario_id AND u.activo = 1 + WHERE s.activa = 1 + ')->fetch()['t']; + + if ($sedesConUsuarios == 0) { + echo json_encode(['ok' => true, 'etapa' => 'sin_sedes_o_usuarios', 'largo_pin' => LARGO_PIN]); + exit; + } + + echo json_encode(['ok' => true, 'etapa' => 'listo', 'largo_pin' => LARGO_PIN]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR SEDES ACTIVAS (paso 1 del login normal). +// ------------------------------------------------------------ +if ($accion === 'listar_sedes_login') { + $stmt = $pdo->query(' + SELECT s.id, s.nombre FROM sedes s + INNER JOIN usuarios_sedes us ON us.sede_id = s.id + INNER JOIN usuarios u ON u.id = us.usuario_id AND u.activo = 1 + WHERE s.activa = 1 + GROUP BY s.id + ORDER BY s.nombre ASC + '); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR USUARIOS DE UNA SEDE (paso 2 del login normal). +// ------------------------------------------------------------ +if ($accion === 'listar_usuarios_sede_login') { + $sedeId = $input['sede_id'] ?? 0; + $usuarios = obtenerUsuariosActivosPorSede($pdo, $sedeId); + $datos = array_map(function ($u) { + return ['id' => $u['id'], 'nombre_completo' => $u['nombre_completo'], 'rol' => $u['rol']]; + }, $usuarios); + echo json_encode(['ok' => true, 'datos' => $datos]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR PROFESIONALES DE UNA SEDE (para que la administrativa +// elija a quién representa, dentro del paso 3 del login). +// ------------------------------------------------------------ +if ($accion === 'listar_profesionales_sede_login') { + $sedeId = $input['sede_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT u.id, u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + ORDER BY u.nombre_completo ASC + '); + $stmt->execute([$sedeId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// VERIFICAR PIN (paso 3 del login normal). +// ------------------------------------------------------------ +if ($accion === 'verificar') { + aplicarBloqueoFuerzaBruta('intentos_fallidos', 'bloqueado_hasta'); + + $sedeId = $input['sede_id'] ?? 0; + $usuarioId = $input['usuario_id'] ?? 0; + $pin = trim($input['pin'] ?? ''); + $profesionalActivoId = $input['profesional_activo_id'] ?? null; + + $stmt = $pdo->prepare('SELECT * FROM usuarios WHERE id = ? AND activo = 1'); + $stmt->execute([$usuarioId]); + $usuario = $stmt->fetch(); + + if (!$usuario || !password_verify($pin . APP_SECRET, $usuario['patron_hash'])) { + registrarIntentoFallido('intentos_fallidos', 'bloqueado_hasta'); + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'PIN incorrecto.']); + exit; + } + + $stmtSede = $pdo->prepare('SELECT 1 FROM usuarios_sedes WHERE usuario_id = ? AND sede_id = ?'); + $stmtSede->execute([$usuarioId, $sedeId]); + if (!$stmtSede->fetch()) { + http_response_code(403); + echo json_encode(['ok' => false, 'error' => 'Este usuario no pertenece a la sede elegida.']); + exit; + } + + // Verificar licencia para profesionales: si venció el período + // contratado, bloqueamos el acceso y marcamos como suspendido. + if ($usuario['rol'] === 'profesional') { + $estadoLicencia = $usuario['estado_licencia'] ?? 'activo'; + + // Si tiene días definidos, chequear si el período venció. + if ($estadoLicencia === 'activo' && !empty($usuario['licencia_dias']) && !empty($usuario['licencia_inicio'])) { + $inicio = new DateTime($usuario['licencia_inicio']); + $vencimiento = clone $inicio; + $vencimiento->modify('+' . (int) $usuario['licencia_dias'] . ' days'); + if (new DateTime() > $vencimiento) { + $estadoLicencia = 'suspendido'; + $pdo->prepare('UPDATE usuarios SET estado_licencia = "suspendido" WHERE id = ?')->execute([$usuarioId]); + } + } + + if ($estadoLicencia !== 'activo') { + $mensajes = [ + 'suspendido' => 'Tu licencia de acceso venció. Comunicarte con el administrador del sistema para renovarla.', + 'pausado' => 'Tu cuenta está pausada temporalmente. Comunicarte con el administrador del sistema.', + 'prohibido' => 'Tu cuenta está inhabilitada. Comunicarte con el administrador del sistema.', + ]; + http_response_code(403); + echo json_encode(['ok' => false, 'error' => $mensajes[$estadoLicencia] ?? 'No podés acceder al sistema en este momento.']); + exit; + } + } + + $_SESSION['autenticado'] = true; + $_SESSION['usuario_id'] = $usuario['id']; + $_SESSION['nombre_usuario'] = $usuario['nombre_completo']; + $_SESSION['rol'] = $usuario['rol']; + $_SESSION['sede_id'] = (int) $sedeId; + $_SESSION['intentos_fallidos'] = 0; + + $nombreParaMostrar = $usuario['nombre_completo']; + + if ($usuario['rol'] === 'administrativa') { + if (!$profesionalActivoId) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta indicar a qué profesional representa esta administrativa.']); + exit; + } + $stmtProf = $pdo->prepare(' + SELECT u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.id = ? AND u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + '); + $stmtProf->execute([$profesionalActivoId, $sedeId]); + $profesionalRepresentado = $stmtProf->fetch(); + if (!$profesionalRepresentado) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El profesional elegido no pertenece a esta sede.']); + exit; + } + $_SESSION['profesional_activo_id'] = (int) $profesionalActivoId; + // En pantalla se muestra el nombre del profesional, nunca el + // de la administrativa, para que no se "mezclen" las cuentas + // visualmente. Internamente (auditoría, etc.) se sigue + // registrando que fue la administrativa quien actuó. + $nombreParaMostrar = $profesionalRepresentado['nombre_completo']; + } + + echo json_encode([ + 'ok' => true, + 'nombre_usuario' => $nombreParaMostrar, + 'rol' => $usuario['rol'], + ]); + exit; +} + +// ============================================================ +// DESARROLLADOR +// ============================================================ + +if ($accion === 'crear_desarrollador') { + $hay = $pdo->query('SELECT COUNT(*) AS t FROM desarrollador')->fetch()['t']; + if ($hay > 0) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe una clave de desarrollador configurada.']); + exit; + } + $clave = trim($input['clave'] ?? ''); + if (!pinValido($clave)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'La clave debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + $hash = password_hash($clave . APP_SECRET, PASSWORD_BCRYPT); + $pdo->prepare('INSERT INTO desarrollador (clave_hash) VALUES (?)')->execute([$hash]); + + $_SESSION['autenticado'] = true; + $_SESSION['usuario_id'] = null; + $_SESSION['nombre_usuario'] = 'Desarrollador'; + $_SESSION['rol'] = 'desarrollador'; + + echo json_encode(['ok' => true, 'rol' => 'desarrollador', 'nombre_usuario' => 'Desarrollador']); + exit; +} + +if ($accion === 'verificar_desarrollador') { + aplicarBloqueoFuerzaBruta('dev_intentos_fallidos', 'dev_bloqueado_hasta'); + + $clave = trim($input['clave'] ?? ''); + $fila = $pdo->query('SELECT * FROM desarrollador ORDER BY id ASC LIMIT 1')->fetch(); + + if (!$fila || !password_verify($clave . APP_SECRET, $fila['clave_hash'])) { + registrarIntentoFallido('dev_intentos_fallidos', 'dev_bloqueado_hasta'); + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'Clave incorrecta.']); + exit; + } + + $_SESSION['autenticado'] = true; + $_SESSION['usuario_id'] = null; + $_SESSION['nombre_usuario'] = 'Desarrollador'; + $_SESSION['rol'] = 'desarrollador'; + $_SESSION['dev_intentos_fallidos'] = 0; + + echo json_encode(['ok' => true, 'rol' => 'desarrollador', 'nombre_usuario' => 'Desarrollador']); + exit; +} + +// ------------------------------------------------------------ +// GESTIÓN DE SEDES (solo desarrollador) +// ------------------------------------------------------------ +if ($accion === 'crear_sede') { + requiereDesarrollador(); + $nombre = trim($input['nombre'] ?? ''); + if ($nombre === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre de la sede.']); + exit; + } + + // Si ya existe una sede con ese nombre pero está desactivada, + // la reactivamos en vez de fallar por el nombre duplicado. + $stmtExistente = $pdo->prepare('SELECT id, activa FROM sedes WHERE nombre = ?'); + $stmtExistente->execute([$nombre]); + $existente = $stmtExistente->fetch(); + + if ($existente && $existente['activa']) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe una sede activa con ese nombre.']); + exit; + } + + if ($existente && !$existente['activa']) { + $pdo->prepare('UPDATE sedes SET activa = 1 WHERE id = ?')->execute([$existente['id']]); + registrarAuditoria($pdo, 'crear', 'sede', $existente['id'], "Se reactivó la sede \"$nombre\"."); + echo json_encode(['ok' => true, 'id' => $existente['id']]); + exit; + } + + $pdo->prepare('INSERT INTO sedes (nombre) VALUES (?)')->execute([$nombre]); + registrarAuditoria($pdo, 'crear', 'sede', $pdo->lastInsertId(), "Se creó la sede \"$nombre\"."); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId()]); + exit; +} + +// ------------------------------------------------------------ +// RENOMBRAR SEDE — solo desarrollador. Como pacientes y +// profesionales se vinculan por sede_id (no por el nombre como +// texto copiado), cambiar el nombre aquí actualiza automática- +// mente cómo se ve en todos lados, sin tocar ni perder ningún +// legajo existente. +// ------------------------------------------------------------ +if ($accion === 'renombrar_sede') { + requiereDesarrollador(); + $id = $input['id'] ?? 0; + $nombreNuevo = trim($input['nombre'] ?? ''); + + if ($nombreNuevo === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nuevo nombre de la sede.']); + exit; + } + + $stmt = $pdo->prepare('SELECT nombre FROM sedes WHERE id = ?'); + $stmt->execute([$id]); + $sedeActual = $stmt->fetch(); + if (!$sedeActual) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Esa sede no existe.']); + exit; + } + + $stmtDuplicado = $pdo->prepare('SELECT id FROM sedes WHERE nombre = ? AND activa = 1 AND id != ?'); + $stmtDuplicado->execute([$nombreNuevo, $id]); + if ($stmtDuplicado->fetch()) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe otra sede activa con ese nombre.']); + exit; + } + + $pdo->prepare('UPDATE sedes SET nombre = ? WHERE id = ?')->execute([$nombreNuevo, $id]); + registrarAuditoria($pdo, 'editar', 'sede', $id, "Se renombró la sede de \"{$sedeActual['nombre']}\" a \"$nombreNuevo\"."); + echo json_encode(['ok' => true]); + exit; +} + +if ($accion === 'listar_sedes') { + requiereDesarrollador(); + $stmt = $pdo->query('SELECT * FROM sedes WHERE activa = 1 ORDER BY nombre ASC'); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +if ($accion === 'desactivar_sede') { + requiereDesarrollador(); + $id = $input['id'] ?? 0; + $pdo->prepare('UPDATE sedes SET activa = 0 WHERE id = ?')->execute([$id]); + registrarAuditoria($pdo, 'desactivar', 'sede', $id, 'Se desactivó una sede.'); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// PAPELERA (DESARROLLADOR): listar profesionales de una sede, +// para elegir de quién ver la papelera y a quién reasignar. +// (Reutiliza la misma idea que listar_profesionales_sede_login, +// pero accesible solo para el Desarrollador.) +// ------------------------------------------------------------ +if ($accion === 'listar_profesionales_sede_dev') { + requiereDesarrollador(); + $sedeId = $input['sede_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT u.id, u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + ORDER BY u.nombre_completo ASC + '); + $stmt->execute([$sedeId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// PAPELERA (DESARROLLADOR): ver los legajos eliminados de un +// profesional específico (GET vía POST con accion en el body, +// igual que el resto de este archivo). +// ------------------------------------------------------------ +if ($accion === 'listar_papelera_dev') { + requiereDesarrollador(); + $profesionalId = $input['profesional_id'] ?? 0; + $sedeId = $input['sede_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT id, paciente_id_original, nombre_completo, dni, eliminado_en, sede_id_original + FROM legajos_eliminados + WHERE profesional_id_original = ? AND sede_id_original = ? + ORDER BY eliminado_en DESC + '); + $stmt->execute([$profesionalId, $sedeId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// PAPELERA (DESARROLLADOR): recuperar un legajo eliminado, +// asignándolo a un profesional de la MISMA sede donde estaba +// originalmente (no se permite recuperar hacia otra sede, para +// evitar que un paciente "viaje" de sede sin que el Desarrollador +// lo haga a propósito desde la ficha del paciente ya recuperado). +// ------------------------------------------------------------ +if ($accion === 'recuperar_legajo_dev') { + requiereDesarrollador(); + $idPapelera = $input['id'] ?? 0; + $nuevoProfesionalId = $input['profesional_id'] ?? 0; + + $stmt = $pdo->prepare('SELECT * FROM legajos_eliminados WHERE id = ?'); + $stmt->execute([$idPapelera]); + $registro = $stmt->fetch(); + if (!$registro) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese registro de la papelera no existe.']); + exit; + } + + $sedeOriginalId = $registro['sede_id_original']; + + // Confirmar que el nuevo profesional realmente atienda en esa misma sede. + $stmtCheck = $pdo->prepare(' + SELECT u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.id = ? AND u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + '); + $stmtCheck->execute([$nuevoProfesionalId, $sedeOriginalId]); + $nuevoProfesional = $stmtCheck->fetch(); + if (!$nuevoProfesional) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ese profesional no atiende en la sede donde estaba este legajo.']); + exit; + } + + $datos = json_decode($registro['datos_json'], true); + $sesionesGuardadas = $datos['sesiones'] ?? []; + unset($datos['sesiones'], $datos['edad']); // no son columnas de la tabla pacientes + + // Si el legajo se está asignando a un profesional DISTINTO del + // que lo tenía antes de eliminarlo, guardamos el nombre del + // profesional original para que quede visible en la ficha. + $nombreProfesionalAnterior = null; + $profesionalOriginalId = $registro['profesional_id_original']; + if ($profesionalOriginalId && (int) $profesionalOriginalId !== (int) $nuevoProfesionalId) { + $stmtAnterior = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmtAnterior->execute([$profesionalOriginalId]); + $filaAnterior = $stmtAnterior->fetch(); + if ($filaAnterior) { + $nombreProfesionalAnterior = $filaAnterior['nombre_completo']; + } + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare(' + INSERT INTO pacientes + (profesional_id, sede_id, recuperado_de_profesional, nombre, apellido, dni, fecha_nacimiento, sexo, obra_social_id, numero_afiliado, + telefono, email, direccion, motivo_consulta, patologia, sintomas, observaciones_generales) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $nuevoProfesionalId, + $sedeOriginalId, + $nombreProfesionalAnterior, + $datos['nombre'] ?? '', + $datos['apellido'] ?? '', + $datos['dni'] ?? '', + $datos['fecha_nacimiento'] ?? null, + $datos['sexo'] ?? 'Otro', + $datos['obra_social_id'] ?? null, + $datos['numero_afiliado'] ?? null, + $datos['telefono'] ?? null, + $datos['email'] ?? null, + $datos['direccion'] ?? null, + $datos['motivo_consulta'] ?? null, + $datos['patologia'] ?? null, + $datos['sintomas'] ?? null, + $datos['observaciones_generales'] ?? null, + ]); + $nuevoPacienteId = $pdo->lastInsertId(); + + if (!empty($sesionesGuardadas) && is_array($sesionesGuardadas)) { + $stmtSesion = $pdo->prepare('INSERT INTO sesiones (paciente_id, fecha_sesion, descripcion, evolucion, proxima_cita) VALUES (?, ?, ?, ?, ?)'); + foreach ($sesionesGuardadas as $s) { + $stmtSesion->execute([ + $nuevoPacienteId, + $s['fecha_sesion'] ?? date('Y-m-d'), + $s['descripcion'] ?? '', + $s['evolucion'] ?? null, + $s['proxima_cita'] ?? null, + ]); + } + } + + $pdo->prepare('DELETE FROM legajos_eliminados WHERE id = ?')->execute([$idPapelera]); + + $pdo->commit(); + registrarAuditoria( + $pdo, 'crear', 'paciente', $nuevoPacienteId, + "Se recuperó de la papelera el legajo de {$datos['nombre']} {$datos['apellido']}, asignado a {$nuevoProfesional['nombre_completo']}." + ); + echo json_encode(['ok' => true, 'id' => $nuevoPacienteId]); + } catch (Exception $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo recuperar el legajo.']); + } + exit; +} + +// ------------------------------------------------------------ +// BUSCAR PROFESIONALES (DESARROLLADOR) — búsqueda unificada +// por nombre, apellido, DNI o número de legajo. +// ------------------------------------------------------------ +if ($accion === 'buscar_profesionales') { + requiereDesarrollador(); + $q = trim($input['q'] ?? ''); + if ($q === '') { + echo json_encode(['ok' => true, 'datos' => []]); + exit; + } + $like = "%$q%"; + $stmt = $pdo->prepare(" + SELECT u.id, u.nombre_completo, u.activo, u.estado_licencia, u.creado_en, + u.licencia_dias, u.licencia_inicio, + CASE WHEN u.licencia_dias IS NULL THEN NULL + ELSE DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) + END AS licencia_vencimiento, + pl.numero_legajo, pl.titulo, pl.especialidad, pl.dni + FROM usuarios u + INNER JOIN profesionales_legajos pl ON pl.usuario_id = u.id + WHERE u.rol = 'profesional' + AND ( + u.nombre_completo LIKE ? + OR pl.nombre LIKE ? + OR pl.apellido LIKE ? + OR pl.dni LIKE ? + OR pl.numero_legajo LIKE ? + ) + ORDER BY pl.apellido ASC, pl.nombre ASC + LIMIT 30 + "); + $stmt->execute([$like, $like, $like, $like, $like]); + $resultados = $stmt->fetchAll(); + + $stmtSedes = $pdo->prepare('SELECT s.id, s.nombre FROM sedes s INNER JOIN usuarios_sedes us ON us.sede_id = s.id WHERE us.usuario_id = ?'); + foreach ($resultados as &$r) { + $stmtSedes->execute([$r['id']]); + $r['sedes'] = $stmtSedes->fetchAll(); + } + + echo json_encode(['ok' => true, 'datos' => $resultados]); + exit; +} + +// ------------------------------------------------------------ +// PROFESIONALES DESACTIVADOS (DESARROLLADOR): listar usuarios +// con rol profesional que ya no tienen acceso (activo = 0), para +// poder ver y reasignar los legajos que les quedaron a cargo. +// ------------------------------------------------------------ +if ($accion === 'listar_profesionales_desactivados') { + requiereDesarrollador(); + $stmt = $pdo->query(" + SELECT u.id, u.nombre_completo, + (SELECT COUNT(*) FROM pacientes p WHERE p.profesional_id = u.id) AS total_pacientes + FROM usuarios u + WHERE u.rol = 'profesional' AND u.activo = 0 + HAVING total_pacientes > 0 + ORDER BY u.nombre_completo ASC + "); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// LEGAJOS ACTIVOS DE UN PROFESIONAL DESACTIVADO (DESARROLLADOR) +// ------------------------------------------------------------ +if ($accion === 'listar_legajos_huerfanos') { + requiereDesarrollador(); + $profesionalId = $input['profesional_id'] ?? 0; + $stmt = $pdo->prepare(' + SELECT p.id, p.nombre, p.apellido, p.dni, p.sede_id, s.nombre AS sede_nombre + FROM pacientes p + LEFT JOIN sedes s ON s.id = p.sede_id + WHERE p.profesional_id = ? + ORDER BY p.apellido ASC, p.nombre ASC + '); + $stmt->execute([$profesionalId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// REASIGNAR LEGAJO HUÉRFANO (DESARROLLADOR): transfiere un +// paciente activo a otro profesional de la MISMA sede donde +// está el paciente (misma regla que la papelera: no se permite +// cambiar de sede de paso, solo de profesional). +// ------------------------------------------------------------ +if ($accion === 'reasignar_legajo_huerfano') { + requiereDesarrollador(); + $pacienteId = $input['paciente_id'] ?? 0; + $nuevoProfesionalId = $input['profesional_id'] ?? 0; + + $stmtPaciente = $pdo->prepare('SELECT * FROM pacientes WHERE id = ?'); + $stmtPaciente->execute([$pacienteId]); + $paciente = $stmtPaciente->fetch(); + if (!$paciente) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese paciente no existe.']); + exit; + } + + $stmtCheck = $pdo->prepare(' + SELECT u.nombre_completo FROM usuarios u + INNER JOIN usuarios_sedes us ON us.usuario_id = u.id + WHERE u.id = ? AND u.rol = "profesional" AND u.activo = 1 AND us.sede_id = ? + '); + $stmtCheck->execute([$nuevoProfesionalId, $paciente['sede_id']]); + $nuevoProfesional = $stmtCheck->fetch(); + if (!$nuevoProfesional) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ese profesional no atiende en la sede de este paciente.']); + exit; + } + + // Si el profesional anterior queda registrado, lo guardamos + // como "recuperado_de_profesional" igual que en la papelera, + // para que quede el mismo aviso en la ficha del paciente. + $nombreProfesionalAnterior = null; + if ($paciente['profesional_id'] && (int) $paciente['profesional_id'] !== (int) $nuevoProfesionalId) { + $stmtAnterior = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmtAnterior->execute([$paciente['profesional_id']]); + $filaAnterior = $stmtAnterior->fetch(); + if ($filaAnterior) $nombreProfesionalAnterior = $filaAnterior['nombre_completo']; + } + + try { + $pdo->prepare('UPDATE pacientes SET profesional_id = ?, recuperado_de_profesional = ? WHERE id = ?') + ->execute([$nuevoProfesionalId, $nombreProfesionalAnterior, $pacienteId]); + + registrarAuditoria( + $pdo, 'editar', 'paciente', $pacienteId, + "Se transfirió el legajo de {$paciente['nombre']} {$paciente['apellido']} a {$nuevoProfesional['nombre_completo']} (profesional anterior desactivado)." + ); + echo json_encode(['ok' => true]); + } catch (PDOException $e) { + if ($e->getCode() === '23000') { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'El profesional elegido ya tiene un paciente con ese mismo DNI.']); + } else { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo transferir el legajo.']); + } + } + exit; +} + +// ------------------------------------------------------------ +// CONFIGURACIÓN INICIAL: crea la primera sede y el primer +// profesional en un solo paso, justo después de crear la +// clave de Desarrollador. +// ------------------------------------------------------------ +if ($accion === 'crear_setup_inicial') { + requiereDesarrollador(); + $nombreSede = trim($input['nombre_sede'] ?? ''); + $nombreProfesional = trim($input['nombre_profesional'] ?? ''); + $pin = trim($input['pin'] ?? ''); + + if ($nombreSede === '' || $nombreProfesional === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Completá el nombre de la sede y del profesional.']); + exit; + } + if (!pinValido($pin)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + + $pdo->beginTransaction(); + try { + $pdo->prepare('INSERT INTO sedes (nombre) VALUES (?)')->execute([$nombreSede]); + $sedeId = $pdo->lastInsertId(); + + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + $pdo->prepare('INSERT INTO usuarios (nombre_completo, rol, patron_hash) VALUES (?, "profesional", ?)') + ->execute([$nombreProfesional, $hash]); + $usuarioId = $pdo->lastInsertId(); + + $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)')->execute([$usuarioId, $sedeId]); + + $pdo->commit(); + registrarAuditoria($pdo, 'crear', 'sede', $sedeId, "Configuración inicial: sede \"$nombreSede\" y profesional \"$nombreProfesional\"."); + echo json_encode(['ok' => true, 'sede_id' => $sedeId, 'usuario_id' => $usuarioId]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo completar la configuración inicial.']); + } + exit; +} + +// ------------------------------------------------------------ +// CREAR USUARIO (profesional o administrativa) — solo desarrollador +// ------------------------------------------------------------ +// ------------------------------------------------------------ +// CREAR PROFESIONAL (legajo completo) — solo desarrollador. +// Reemplaza el alta simple anterior: ahora incluye datos +// personales, especialidad, sede(s), PIN y tipo de licencia. +// Para administrativas se mantiene el alta simple de antes. +// ------------------------------------------------------------ +if ($accion === 'crear_usuario') { + requiereDesarrollador(); + $rol = $input['rol'] ?? ''; + $pin = trim($input['pin'] ?? ''); + $sedeIds = $input['sede_ids'] ?? []; + + if (!in_array($rol, ['profesional', 'administrativa'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Rol no válido.']); + exit; + } + if (!pinValido($pin)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + if (empty($sedeIds) || !is_array($sedeIds)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Elegí al menos una sede.']); + exit; + } + + if ($rol === 'profesional') { + $titulo = $input['titulo'] ?? 'Dr.'; + $nombre = trim($input['nombre'] ?? ''); + $apellido = trim($input['apellido'] ?? ''); + $dni = trim($input['dni'] ?? '') ?: null; + $fechaNac = $input['fecha_nacimiento'] ?? null ?: null; + $lugarNac = trim($input['lugar_nacimiento'] ?? '') ?: null; + $especialidad = trim($input['especialidad'] ?? '') ?: null; + $email = trim($input['email'] ?? '') ?: null; + $telefono = trim($input['telefono'] ?? '') ?: null; + $licenciaDias = $input['licencia_dias'] !== '' ? (int) $input['licencia_dias'] : null; + + if ($nombre === '' || $apellido === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre y apellido del profesional.']); + exit; + } + + $titulos = ['Dr.', 'Dra.', 'Lic.', 'Tec.', 'Mg.', 'Prof.', 'Otro']; + if (!in_array($titulo, $titulos)) $titulo = 'Dr.'; + + $nombreCompleto = "$titulo $nombre $apellido"; + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('INSERT INTO usuarios (nombre_completo, rol, patron_hash, estado_licencia, licencia_dias, licencia_inicio) VALUES (?, "profesional", ?, "activo", ?, CURDATE())'); + $stmt->execute([$nombreCompleto, $hash, $licenciaDias]); + $nuevoId = $pdo->lastInsertId(); + + $numeroLegajo = generarNumeroLegajo($pdo); + $stmtLegajo = $pdo->prepare('INSERT INTO profesionales_legajos (usuario_id, numero_legajo, titulo, nombre, apellido, dni, fecha_nacimiento, lugar_nacimiento, especialidad, email, telefono) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); + $stmtLegajo->execute([$nuevoId, $numeroLegajo, $titulo, $nombre, $apellido, $dni, $fechaNac, $lugarNac, $especialidad, $email, $telefono]); + + $stmtSede = $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)'); + foreach ($sedeIds as $sid) $stmtSede->execute([$nuevoId, $sid]); + + $pdo->commit(); + registrarAuditoria($pdo, 'crear', 'usuario', $nuevoId, "Se creó el legajo del profesional \"$nombreCompleto\"."); + echo json_encode(['ok' => true, 'id' => $nuevoId]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo crear el legajo.']); + } + } else { + // Administrativa: alta simple como antes + $nombre = trim($input['nombre_completo'] ?? ''); + if ($nombre === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre de la administrativa.']); + exit; + } + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare('INSERT INTO usuarios (nombre_completo, rol, patron_hash) VALUES (?, "administrativa", ?)'); + $stmt->execute([$nombre, $hash]); + $nuevoId = $pdo->lastInsertId(); + $stmtSede = $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)'); + foreach ($sedeIds as $sid) $stmtSede->execute([$nuevoId, $sid]); + $pdo->commit(); + registrarAuditoria($pdo, 'crear', 'usuario', $nuevoId, "Se creó la administrativa \"$nombre\"."); + echo json_encode(['ok' => true, 'id' => $nuevoId]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo crear la administrativa.']); + } + } + exit; +} + +// ------------------------------------------------------------ +// OBTENER LEGAJO DE UN PROFESIONAL — Desarrollador y el propio +// profesional pueden consultar. +// ------------------------------------------------------------ +// ------------------------------------------------------------ +// EDITAR LEGAJO DE PROFESIONAL — solo desarrollador. Actualiza +// los datos personales y mantiene sincronizado el nombre que +// se muestra en el login (nombre_completo de usuarios). +// ------------------------------------------------------------ +if ($accion === 'editar_legajo_profesional') { + requiereDesarrollador(); + $usuarioId = (int) ($input['usuario_id'] ?? 0); + $titulo = $input['titulo'] ?? 'Dr.'; + $nombre = trim($input['nombre'] ?? ''); + $apellido = trim($input['apellido'] ?? ''); + $dni = trim($input['dni'] ?? '') ?: null; + $fechaNac = $input['fecha_nacimiento'] ?? null ?: null; + $lugarNac = trim($input['lugar_nacimiento'] ?? '') ?: null; + $especialidad = trim($input['especialidad'] ?? '') ?: null; + $email = trim($input['email'] ?? '') ?: null; + $telefono = trim($input['telefono'] ?? '') ?: null; + + if ($nombre === '' || $apellido === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Ingresá el nombre y apellido del profesional.']); + exit; + } + + $titulos = ['Dr.', 'Dra.', 'Lic.', 'Tec.', 'Mg.', 'Prof.', 'Otro']; + if (!in_array($titulo, $titulos)) $titulo = 'Dr.'; + + $stmtCheck = $pdo->prepare('SELECT id FROM profesionales_legajos WHERE usuario_id = ?'); + $stmtCheck->execute([$usuarioId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese legajo no existe.']); + exit; + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare(' + UPDATE profesionales_legajos + SET titulo = ?, nombre = ?, apellido = ?, dni = ?, fecha_nacimiento = ?, + lugar_nacimiento = ?, especialidad = ?, email = ?, telefono = ? + WHERE usuario_id = ? + '); + $stmt->execute([$titulo, $nombre, $apellido, $dni, $fechaNac, $lugarNac, $especialidad, $email, $telefono, $usuarioId]); + + $nombreCompleto = "$titulo $nombre $apellido"; + $pdo->prepare('UPDATE usuarios SET nombre_completo = ? WHERE id = ?')->execute([$nombreCompleto, $usuarioId]); + + $pdo->commit(); + registrarAuditoria($pdo, 'editar', 'usuario', $usuarioId, "Se editó el legajo de \"$nombreCompleto\"."); + echo json_encode(['ok' => true]); + } catch (PDOException $e) { + $pdo->rollBack(); + if ($e->getCode() === '23000') { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe un profesional con ese DNI.']); + } else { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo guardar el legajo.']); + } + } + exit; +} + +if ($accion === 'obtener_legajo_profesional') { + requiereSesion(); + $rol = $_SESSION['rol'] ?? ''; + $usuarioId = (int) ($input['usuario_id'] ?? 0); + + // El profesional solo puede ver su propio legajo. + if ($rol === 'profesional') { + $usuarioId = (int) $_SESSION['usuario_id']; + } elseif ($rol !== 'desarrollador') { + http_response_code(403); + echo json_encode(['ok' => false, 'error' => 'Sin permiso.']); + exit; + } + + $stmt = $pdo->prepare(' + SELECT pl.*, u.estado_licencia, u.licencia_dias, u.licencia_inicio, u.activo, + CASE + WHEN u.licencia_dias IS NULL THEN NULL + ELSE DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) + END AS licencia_vencimiento + FROM profesionales_legajos pl + INNER JOIN usuarios u ON u.id = pl.usuario_id + WHERE pl.usuario_id = ? + '); + $stmt->execute([$usuarioId]); + $legajo = $stmt->fetch(); + if (!$legajo) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Legajo no encontrado.']); + exit; + } + echo json_encode(['ok' => true, 'datos' => $legajo]); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR LICENCIA — solo desarrollador. +// Permite activar (con duración), pausar, prohibir el acceso. +// ------------------------------------------------------------ +if ($accion === 'actualizar_licencia') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $nuevoEstado = $input['estado'] ?? ''; + $diasLicencia = isset($input['dias']) && $input['dias'] !== '' ? (int) $input['dias'] : null; + + $estadosValidos = ['activo', 'pausado', 'prohibido']; + if (!in_array($nuevoEstado, $estadosValidos)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Estado no válido.']); + exit; + } + + $licenciaInicio = $nuevoEstado === 'activo' ? date('Y-m-d') : null; + $pdo->prepare('UPDATE usuarios SET estado_licencia = ?, licencia_dias = ?, licencia_inicio = ? WHERE id = ?') + ->execute([$nuevoEstado, $nuevoEstado === 'activo' ? $diasLicencia : null, $licenciaInicio, $usuarioId]); + + registrarAuditoria($pdo, 'editar', 'usuario', $usuarioId, "Se cambió el estado de licencia a \"$nuevoEstado\"" . ($licenciaInicio && $diasLicencia ? " por $diasLicencia días." : ".")); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR USUARIOS (con sus sedes) — solo desarrollador. +// Solo se muestran los activos: los desactivados quedan +// fuera de este listado (pero siguen existiendo en la base, +// así que el historial de cambios sigue siendo legible). +// ------------------------------------------------------------ +if ($accion === 'listar_usuarios') { + requiereDesarrollador(); + $stmt = $pdo->query(" + SELECT u.id, u.nombre_completo, u.rol, u.activo, u.creado_en, + u.estado_licencia, u.licencia_dias, u.licencia_inicio, + CASE + WHEN u.licencia_dias IS NULL THEN NULL + ELSE DATE_ADD(u.licencia_inicio, INTERVAL u.licencia_dias DAY) + END AS licencia_vencimiento, + pl.titulo, pl.especialidad + FROM usuarios u + LEFT JOIN profesionales_legajos pl ON pl.usuario_id = u.id + WHERE u.activo = 1 + ORDER BY u.creado_en ASC + "); + $usuarios = $stmt->fetchAll(); + + $stmtSedes = $pdo->prepare('SELECT s.id, s.nombre FROM sedes s INNER JOIN usuarios_sedes us ON us.sede_id = s.id WHERE us.usuario_id = ?'); + foreach ($usuarios as &$u) { + $stmtSedes->execute([$u['id']]); + $u['sedes'] = $stmtSedes->fetchAll(); + } + + echo json_encode(['ok' => true, 'datos' => $usuarios]); + exit; +} + +// ------------------------------------------------------------ +// DESACTIVAR USUARIO — solo desarrollador +// ------------------------------------------------------------ +if ($accion === 'desactivar_usuario') { + requiereDesarrollador(); + $idDesactivar = $input['id'] ?? 0; + $pdo->prepare('UPDATE usuarios SET activo = 0 WHERE id = ?')->execute([$idDesactivar]); + registrarAuditoria($pdo, 'desactivar', 'usuario', $idDesactivar, 'Se desactivó el acceso de un usuario.'); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// REACTIVAR USUARIO — solo desarrollador. Le devuelve el +// acceso a alguien al que se le había quitado con "Quitar +// acceso". No toca licencia ni sedes, esas se gestionan aparte. +// ------------------------------------------------------------ +if ($accion === 'reactivar_usuario') { + requiereDesarrollador(); + $idReactivar = $input['id'] ?? 0; + $stmt = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmt->execute([$idReactivar]); + $fila = $stmt->fetch(); + if (!$fila) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese usuario no existe.']); + exit; + } + $pdo->prepare('UPDATE usuarios SET activo = 1 WHERE id = ?')->execute([$idReactivar]); + registrarAuditoria($pdo, 'editar', 'usuario', $idReactivar, "Se restauró el acceso de \"{$fila['nombre_completo']}\"."); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// CAMBIAR PIN — solo desarrollador. Reasigna el PIN de acceso +// de un usuario, sin tener que desactivarlo y crearlo de nuevo. +// ------------------------------------------------------------ +if ($accion === 'cambiar_pin_usuario') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $pin = trim($input['pin'] ?? ''); + + if (!pinValido($pin)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + + $stmt = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmt->execute([$usuarioId]); + $fila = $stmt->fetch(); + if (!$fila) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Ese usuario no existe.']); + exit; + } + + $hash = password_hash($pin . APP_SECRET, PASSWORD_BCRYPT); + $pdo->prepare('UPDATE usuarios SET patron_hash = ? WHERE id = ?')->execute([$hash, $usuarioId]); + registrarAuditoria($pdo, 'editar', 'usuario', $usuarioId, "Se cambió el PIN de acceso de \"{$fila['nombre_completo']}\"."); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// PREVISUALIZAR CAMBIO DE SEDES — solo desarrollador. +// Antes de aplicar el cambio real, le dice al Desarrollador +// cuántos legajos se van a borrar definitivamente por cada +// sede que se le esté quitando al usuario. +// ------------------------------------------------------------ +if ($accion === 'previsualizar_cambio_sedes') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $sedeIdsNuevas = $input['sede_ids'] ?? []; + + $stmtActuales = $pdo->prepare('SELECT sede_id FROM usuarios_sedes WHERE usuario_id = ?'); + $stmtActuales->execute([$usuarioId]); + $sedeIdsActuales = array_column($stmtActuales->fetchAll(), 'sede_id'); + + $sedeIdsQueSeQuitan = array_diff($sedeIdsActuales, $sedeIdsNuevas); + + $detalle = []; + $totalPacientesABorrar = 0; + if (!empty($sedeIdsQueSeQuitan)) { + $stmtSede = $pdo->prepare('SELECT nombre FROM sedes WHERE id = ?'); + $stmtConteo = $pdo->prepare('SELECT COUNT(*) AS t FROM pacientes WHERE profesional_id = ? AND sede_id = ?'); + foreach ($sedeIdsQueSeQuitan as $sedeId) { + $stmtSede->execute([$sedeId]); + $nombreSede = $stmtSede->fetch()['nombre'] ?? 'Sede desconocida'; + $stmtConteo->execute([$usuarioId, $sedeId]); + $cantidad = (int) $stmtConteo->fetch()['t']; + $totalPacientesABorrar += $cantidad; + $detalle[] = ['sede_id' => (int) $sedeId, 'nombre' => $nombreSede, 'pacientes' => $cantidad]; + } + } + + echo json_encode(['ok' => true, 'sedes_que_se_quitan' => $detalle, 'total_pacientes_a_borrar' => $totalPacientesABorrar]); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR SEDES DE UN USUARIO — solo desarrollador. +// Aplica el nuevo conjunto de sedes. Si se le quita una sede +// donde el usuario es profesional y tenía pacientes, esos +// legajos (con sus sesiones, citas y adjuntos) se eliminan +// definitivamente, sin pasar por la papelera. +// ------------------------------------------------------------ +if ($accion === 'actualizar_sedes_usuario') { + requiereDesarrollador(); + $usuarioId = $input['usuario_id'] ?? 0; + $sedeIdsNuevas = $input['sede_ids'] ?? []; + + if (empty($sedeIdsNuevas) || !is_array($sedeIdsNuevas)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Elegí al menos una sede para esta persona.']); + exit; + } + + $stmtUsuario = $pdo->prepare('SELECT nombre_completo, rol FROM usuarios WHERE id = ?'); + $stmtUsuario->execute([$usuarioId]); + $usuario = $stmtUsuario->fetch(); + if (!$usuario) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Usuario no encontrado.']); + exit; + } + + $stmtActuales = $pdo->prepare('SELECT sede_id FROM usuarios_sedes WHERE usuario_id = ?'); + $stmtActuales->execute([$usuarioId]); + $sedeIdsActuales = array_column($stmtActuales->fetchAll(), 'sede_id'); + $sedeIdsQueSeQuitan = array_diff($sedeIdsActuales, $sedeIdsNuevas); + + $pdo->beginTransaction(); + try { + // Si es profesional, borrar definitivamente sus legajos + // de cada sede que se le esté quitando. + $pacientesBorrados = 0; + if ($usuario['rol'] === 'profesional' && !empty($sedeIdsQueSeQuitan)) { + $stmtPacientesSede = $pdo->prepare('SELECT id FROM pacientes WHERE profesional_id = ? AND sede_id = ?'); + $stmtBorrarPaciente = $pdo->prepare('DELETE FROM pacientes WHERE id = ?'); + foreach ($sedeIdsQueSeQuitan as $sedeId) { + $stmtPacientesSede->execute([$usuarioId, $sedeId]); + $pacientesDeEstaSede = $stmtPacientesSede->fetchAll(); + foreach ($pacientesDeEstaSede as $p) { + // Las sesiones, citas y adjuntos de cada paciente se + // borran en cascada por las claves foráneas (ON DELETE CASCADE). + $stmtBorrarPaciente->execute([$p['id']]); + $pacientesBorrados++; + } + } + } + + $pdo->prepare('DELETE FROM usuarios_sedes WHERE usuario_id = ?')->execute([$usuarioId]); + $stmtInsertSede = $pdo->prepare('INSERT INTO usuarios_sedes (usuario_id, sede_id) VALUES (?, ?)'); + foreach ($sedeIdsNuevas as $sedeId) { + $stmtInsertSede->execute([$usuarioId, $sedeId]); + } + + $pdo->commit(); + + $descripcion = "Se actualizaron las sedes de \"{$usuario['nombre_completo']}\"."; + if ($pacientesBorrados > 0) { + $descripcion .= " Se eliminaron $pacientesBorrados legajo(s) de forma definitiva por quitarle acceso a su sede."; + } + registrarAuditoria($pdo, 'editar', 'usuario', $usuarioId, $descripcion); + + echo json_encode(['ok' => true, 'pacientes_borrados' => $pacientesBorrados]); + } catch (PDOException $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo actualizar las sedes del usuario.']); + } + exit; +} + +// ------------------------------------------------------------ +// CAMBIAR MI PROPIO PIN (cualquier usuario logueado, incluido +// el desarrollador) +// ------------------------------------------------------------ +if ($accion === 'cambiar') { + requiereSesion(); + $pinNuevo = trim($input['pin_nuevo'] ?? ''); + if (!pinValido($pinNuevo)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El PIN debe tener exactamente ' . LARGO_PIN . ' números.']); + exit; + } + $hash = password_hash($pinNuevo . APP_SECRET, PASSWORD_BCRYPT); + if (esDesarrollador()) { + $pdo->prepare('UPDATE desarrollador SET clave_hash = ?')->execute([$hash]); + } else { + $pdo->prepare('UPDATE usuarios SET patron_hash = ? WHERE id = ?')->execute([$hash, $_SESSION['usuario_id']]); + } + echo json_encode(['ok' => true, 'mensaje' => 'PIN actualizado.']); + exit; +} + +// ------------------------------------------------------------ +// QUIÉN SOY +// ------------------------------------------------------------ +if ($accion === 'quien_soy') { + requiereSesion(); + $nombreParaMostrar = $_SESSION['nombre_usuario'] ?? ''; + if (($_SESSION['rol'] ?? '') === 'administrativa' && !empty($_SESSION['profesional_activo_id'])) { + $stmt = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); + $stmt->execute([$_SESSION['profesional_activo_id']]); + $fila = $stmt->fetch(); + if ($fila) $nombreParaMostrar = $fila['nombre_completo']; + } + echo json_encode([ + 'ok' => true, + 'nombre_usuario' => $nombreParaMostrar, + 'rol' => $_SESSION['rol'] ?? '', + ]); + exit; +} + +if ($accion === 'cerrar_sesion') { + $_SESSION = []; + session_destroy(); + echo json_encode(['ok' => true]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Acción no reconocida.']); diff --git a/api/citas.php b/api/citas.php new file mode 100644 index 0000000..d0b412e --- /dev/null +++ b/api/citas.php @@ -0,0 +1,362 @@ +diff($nacimiento)->y; + } catch (Exception $e) { + return null; + } +} + +function pacientePerteneceAlProfesional($pdo, $pacienteId, $profesionalActivoId) { + $stmt = $pdo->prepare('SELECT 1 FROM pacientes WHERE id = ? AND profesional_id = ?'); + $stmt->execute([$pacienteId, $profesionalActivoId]); + return (bool) $stmt->fetch(); +} + +/** + * Devuelve la cita en choque (si existe) para ese profesional, + * fecha y hora exactas, entre citas pendientes. Si se pasa + * $excluirCitaId, esa cita no se cuenta (permite editar sin + * chocar contra sí misma). + */ +function hayChoqueDeHorario($pdo, $profesionalId, $fecha, $hora, $excluirCitaId = null) { + if (!$hora) return false; + $sql = ' + SELECT c.id, p.nombre, p.apellido FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? AND c.fecha = ? AND c.hora = ? AND c.estado = "pendiente" + '; + $params = [$profesionalId, $fecha, $hora]; + if ($excluirCitaId) { + $sql .= ' AND c.id != ?'; + $params[] = $excluirCitaId; + } + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + return $stmt->fetch(); +} + +// ------------------------------------------------------------ +// CREAR CITA (POST ?accion=crear) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'crear') { + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['paciente_id']) || empty($d['fecha'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Faltan datos de la cita (paciente y fecha son obligatorios).']); + exit; + } + + if (!pacientePerteneceAlProfesional($pdo, $d['paciente_id'], $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $hora = $d['hora'] ?: null; + $choque = hayChoqueDeHorario($pdo, $profesionalActivoId, $d['fecha'], $hora); + if ($choque) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => "Ese horario ya está ocupado con {$choque['nombre']} {$choque['apellido']}. Elegí otro horario."]); + exit; + } + + $token = bin2hex(random_bytes(20)); + + $stmt = $pdo->prepare(' + INSERT INTO citas (paciente_id, profesional_id, fecha, hora, motivo, notas, estado, token_confirmacion) + VALUES (?, ?, ?, ?, ?, ?, "pendiente", ?) + '); + $stmt->execute([ + $d['paciente_id'], + $profesionalActivoId, + $d['fecha'], + $hora, + $d['motivo'] ?? null, + $d['notas'] ?? null, + $token, + ]); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId(), 'token_confirmacion' => $token]); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR CITA (POST ?accion=actualizar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'actualizar') { + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta el ID de la cita.']); + exit; + } + + $stmtCheck = $pdo->prepare('SELECT * FROM citas WHERE id = ? AND profesional_id = ?'); + $stmtCheck->execute([$d['id'], $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Cita no encontrada.']); + exit; + } + + $hora = $d['hora'] ?: null; + $choque = hayChoqueDeHorario($pdo, $profesionalActivoId, $d['fecha'], $hora, $d['id']); + if ($choque) { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => "Ese horario ya está ocupado con {$choque['nombre']} {$choque['apellido']}. Elegí otro horario."]); + exit; + } + + $stmt = $pdo->prepare(' + UPDATE citas SET fecha = ?, hora = ?, motivo = ?, notas = ?, estado = ? + WHERE id = ? + '); + $stmt->execute([ + $d['fecha'], + $hora, + $d['motivo'] ?? null, + $d['notas'] ?? null, + $d['estado'] ?? 'pendiente', + $d['id'], + ]); + echo json_encode(['ok' => true, 'mensaje' => 'Cita actualizada.']); + exit; +} + +// ------------------------------------------------------------ +// CAMBIAR SOLO EL ESTADO (POST ?accion=cambiar_estado) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'cambiar_estado') { + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id']) || empty($d['estado'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Faltan datos.']); + exit; + } + if (!in_array($d['estado'], ['pendiente', 'atendida', 'cancelada', 'ausente'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Estado no válido.']); + exit; + } + + $stmtCheck = $pdo->prepare('SELECT 1 FROM citas WHERE id = ? AND profesional_id = ?'); + $stmtCheck->execute([$d['id'], $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Cita no encontrada.']); + exit; + } + + $stmt = $pdo->prepare('UPDATE citas SET estado = ? WHERE id = ?'); + $stmt->execute([$d['estado'], $d['id']]); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// ELIMINAR CITA (POST ?accion=eliminar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'eliminar') { + $d = json_decode(file_get_contents('php://input'), true); + $id = $d['id'] ?? 0; + + $stmtCheck = $pdo->prepare('SELECT 1 FROM citas WHERE id = ? AND profesional_id = ?'); + $stmtCheck->execute([$id, $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Cita no encontrada.']); + exit; + } + + $pdo->prepare('DELETE FROM citas WHERE id = ?')->execute([$id]); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR PRÓXIMAS CITAS (GET ?accion=proximas&dias=7) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'proximas') { + $dias = (int) ($_GET['dias'] ?? 7); + $stmt = $pdo->prepare(" + SELECT c.*, p.nombre, p.apellido, p.telefono + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? + AND c.fecha BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND c.estado = 'pendiente' + ORDER BY c.fecha ASC, c.hora ASC + "); + $stmt->execute([$profesionalActivoId, $dias]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// LISTAR CITAS POR RANGO (GET ?accion=rango&desde=&hasta=) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'rango') { + $desde = $_GET['desde'] ?? date('Y-m-01'); + $hasta = $_GET['hasta'] ?? date('Y-m-t'); + $stmt = $pdo->prepare(' + SELECT c.*, p.nombre, p.apellido, p.telefono + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? AND c.fecha BETWEEN ? AND ? + ORDER BY c.fecha ASC, c.hora ASC + '); + $stmt->execute([$profesionalActivoId, $desde, $hasta]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// CITAS DE UN PACIENTE (GET ?accion=por_paciente&paciente_id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'por_paciente') { + $id = $_GET['paciente_id'] ?? 0; + + if (!pacientePerteneceAlProfesional($pdo, $id, $profesionalActivoId)) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Paciente no encontrado.']); + exit; + } + + $stmt = $pdo->prepare('SELECT * FROM citas WHERE paciente_id = ? ORDER BY fecha DESC, hora DESC'); + $stmt->execute([$id]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// PACIENTES INACTIVOS (GET ?accion=inactivos&dias=30) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'inactivos') { + requiereRolProfesional(); + $dias = (int) ($_GET['dias'] ?? 30); + $stmt = $pdo->prepare(" + SELECT p.id, p.nombre, p.apellido, p.dni, p.telefono, + MAX(s.fecha_sesion) AS ultima_sesion, + p.creado_en + FROM pacientes p + LEFT JOIN sesiones s ON s.paciente_id = p.id + WHERE p.profesional_id = ? + GROUP BY p.id + HAVING (ultima_sesion IS NOT NULL AND ultima_sesion < DATE_SUB(CURDATE(), INTERVAL ? DAY)) + OR (ultima_sesion IS NULL AND p.creado_en < DATE_SUB(NOW(), INTERVAL ? DAY)) + ORDER BY ultima_sesion ASC + "); + $stmt->execute([$profesionalActivoId, $dias, $dias]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// CUMPLEAÑOS PRÓXIMOS (GET ?accion=cumpleanios&dias=14) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'cumpleanios') { + $dias = (int) ($_GET['dias'] ?? 14); + $stmt = $pdo->prepare(" + SELECT id, nombre, apellido, telefono, fecha_nacimiento, + DATE_FORMAT(fecha_nacimiento, '%m-%d') AS mes_dia + FROM pacientes + WHERE profesional_id = ? + HAVING mes_dia BETWEEN DATE_FORMAT(CURDATE(), '%m-%d') AND DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL ? DAY), '%m-%d') + OR (DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL ? DAY), '%m-%d') < DATE_FORMAT(CURDATE(), '%m-%d') + AND (mes_dia >= DATE_FORMAT(CURDATE(), '%m-%d') OR mes_dia <= DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL ? DAY), '%m-%d'))) + ORDER BY mes_dia ASC + "); + $stmt->execute([$profesionalActivoId, $dias, $dias, $dias]); + $resultados = $stmt->fetchAll(); + foreach ($resultados as &$r) { + $r['edad_que_cumple'] = calcularEdadCita($r['fecha_nacimiento']) + 1; + } + echo json_encode(['ok' => true, 'datos' => $resultados]); + exit; +} + +// ------------------------------------------------------------ +// AVISOS SIN REVISAR (GET ?accion=avisos_pendientes) +// Cuenta cuántas citas tienen un cambio del paciente (confirmó +// o canceló) que el profesional todavía no vio. +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'avisos_pendientes') { + $stmt = $pdo->prepare(' + SELECT COUNT(*) AS total FROM citas + WHERE profesional_id = ? AND revisada_por_profesional = 0 + '); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true, 'total' => (int) $stmt->fetch()['total']]); + exit; +} + +// ------------------------------------------------------------ +// DETALLE DE AVISOS SIN REVISAR (GET ?accion=listar_avisos) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'listar_avisos') { + $stmt = $pdo->prepare(" + SELECT c.id, c.fecha, c.hora, c.estado, c.confirmada_por_paciente, p.nombre, p.apellido + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.profesional_id = ? AND c.revisada_por_profesional = 0 + ORDER BY c.fecha ASC, c.hora ASC + "); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// MARCAR AVISOS COMO VISTOS (POST ?accion=marcar_avisos_vistos) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'marcar_avisos_vistos') { + $stmt = $pdo->prepare('UPDATE citas SET revisada_por_profesional = 1 WHERE profesional_id = ?'); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true]); + exit; +} + +// ------------------------------------------------------------ +// RESUMEN DE HOY (GET ?accion=resumen_hoy) +// Cuántas consultas quedan por pasar hoy (la hora de la cita +// todavía no llegó) y a qué hora es la próxima. +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'resumen_hoy') { + $stmt = $pdo->prepare(" + SELECT hora FROM citas + WHERE profesional_id = ? AND fecha = CURDATE() AND estado = 'pendiente' + ORDER BY hora ASC + "); + $stmt->execute([$profesionalActivoId]); + $todasHoy = $stmt->fetchAll(); + + $horaActual = date('H:i:s'); + $restantes = array_filter($todasHoy, function ($c) use ($horaActual) { + // Una cita sin hora especificada se cuenta como "restante" + // todo el día, ya que no hay forma de saber si ya pasó. + return $c['hora'] === null || $c['hora'] >= $horaActual; + }); + + echo json_encode([ + 'ok' => true, + 'total_hoy' => count($todasHoy), + 'restantes_hoy' => count($restantes), + 'proxima_hora' => !empty($restantes) ? reset($restantes)['hora'] : null, + ]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/obras_sociales.php b/api/obras_sociales.php new file mode 100644 index 0000000..d248c4d --- /dev/null +++ b/api/obras_sociales.php @@ -0,0 +1,43 @@ +query('SELECT id, nombre FROM obras_sociales ORDER BY es_predefinida DESC, nombre ASC'); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +if ($metodo === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + $nombre = trim($input['nombre'] ?? ''); + if ($nombre === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'El nombre de la obra social no puede estar vacío.']); + exit; + } + try { + $stmt = $pdo->prepare('INSERT INTO obras_sociales (nombre, es_predefinida) VALUES (?, 0)'); + $stmt->execute([$nombre]); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId(), 'nombre' => $nombre]); + } catch (PDOException $e) { + if ($e->getCode() === '23000') { + // Ya existe: la devolvemos igual para que el front la pueda usar + $stmt = $pdo->prepare('SELECT id, nombre FROM obras_sociales WHERE nombre = ?'); + $stmt->execute([$nombre]); + $fila = $stmt->fetch(); + echo json_encode(['ok' => true, 'id' => $fila['id'], 'nombre' => $fila['nombre'], 'ya_existia' => true]); + } else { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'Error al guardar la obra social.']); + } + } + exit; +} + +http_response_code(405); +echo json_encode(['ok' => false, 'error' => 'Método no permitido.']); diff --git a/api/pacientes.php b/api/pacientes.php new file mode 100644 index 0000000..4eace69 --- /dev/null +++ b/api/pacientes.php @@ -0,0 +1,519 @@ +diff($nacimiento)->y; + } catch (Exception $e) { + return null; + } +} + +/** + * Quita los campos clínicos sensibles de un registro de paciente + * cuando quien consulta no es el profesional. + */ +function filtrarSegunRol($paciente) { + if (esProfesional()) { + return $paciente; + } + unset( + $paciente['motivo_consulta'], + $paciente['patologia'], + $paciente['sintomas'], + $paciente['observaciones_generales'], + $paciente['sesiones'] + ); + return $paciente; +} + +/** + * Confirma que el paciente con este ID pertenece al profesional + * activo de la sesión. Si no, corta con 404 (no se revela ni + * siquiera que el paciente existe, para no filtrar información + * de otros profesionales). + */ +function obtenerPacienteDelProfesionalActivo($pdo, $id, $profesionalActivoId) { + $stmt = $pdo->prepare(' + SELECT p.*, o.nombre AS obra_social_nombre, s.nombre AS sede_nombre + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + LEFT JOIN sedes s ON s.id = p.sede_id + WHERE p.id = ? AND p.profesional_id = ? + '); + $stmt->execute([$id, $profesionalActivoId]); + $paciente = $stmt->fetch(); + if (!$paciente) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Legajo no encontrado.']); + exit; + } + return $paciente; +} + +// ------------------------------------------------------------ +// CREAR LEGAJO (POST ?accion=crear) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'crear') { + $d = json_decode(file_get_contents('php://input'), true); + $errores = validarPaciente($d); + if (!empty($errores)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => implode(' ', $errores)]); + exit; + } + + $esAdmin = !esProfesional(); + $sedeId = $_SESSION['sede_id'] ?? null; + + try { + $stmt = $pdo->prepare(' + INSERT INTO pacientes + (profesional_id, sede_id, nombre, apellido, dni, fecha_nacimiento, sexo, obra_social_id, numero_afiliado, + telefono, email, direccion, motivo_consulta, patologia, sintomas, observaciones_generales) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + '); + $stmt->execute([ + $profesionalActivoId, + $sedeId, + trim($d['nombre']), + trim($d['apellido']), + trim($d['dni']), + $d['fecha_nacimiento'], + $d['sexo'], + $d['obra_social_id'] ?: null, + $d['numero_afiliado'] ?? null, + $d['telefono'] ?? null, + $d['email'] ?? null, + $d['direccion'] ?? null, + $esAdmin ? null : ($d['motivo_consulta'] ?? null), + $esAdmin ? null : ($d['patologia'] ?? null), + $esAdmin ? null : ($d['sintomas'] ?? null), + $esAdmin ? null : ($d['observaciones_generales'] ?? null), + ]); + $id = $pdo->lastInsertId(); + + if (!$esAdmin && !empty($d['sesiones']) && is_array($d['sesiones'])) { + $stmtS = $pdo->prepare('INSERT INTO sesiones (paciente_id, fecha_sesion, descripcion, evolucion, proxima_cita) VALUES (?, ?, ?, ?, ?)'); + $stmtC = $pdo->prepare("INSERT INTO citas (paciente_id, profesional_id, fecha, motivo, estado) VALUES (?, ?, ?, 'Próxima cita agendada desde sesión anterior', 'pendiente')"); + foreach ($d['sesiones'] as $s) { + if (empty($s['fecha_sesion']) || empty($s['descripcion'])) continue; + $stmtS->execute([$id, $s['fecha_sesion'], $s['descripcion'], $s['evolucion'] ?? null, $s['proxima_cita'] ?? null]); + if (!empty($s['proxima_cita'])) { + $stmtC->execute([$id, $profesionalActivoId, $s['proxima_cita']]); + } + } + } + + registrarAuditoria($pdo, 'crear', 'paciente', $id, "Se creó el legajo de {$d['nombre']} {$d['apellido']}."); + + echo json_encode(['ok' => true, 'id' => $id, 'mensaje' => 'Legajo creado correctamente.']); + } catch (PDOException $e) { + if ($e->getCode() === '23000') { + http_response_code(409); + echo json_encode(['ok' => false, 'error' => 'Ya existe un legajo con ese DNI.']); + } else { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'Error al guardar el legajo.']); + } + } + exit; +} + +// ------------------------------------------------------------ +// AGREGAR SESIÓN A LEGAJO EXISTENTE (POST ?accion=agregar_sesion) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'agregar_sesion') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['paciente_id']) || empty($d['fecha_sesion']) || empty($d['descripcion'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Faltan datos de la sesión.']); + exit; + } + + obtenerPacienteDelProfesionalActivo($pdo, $d['paciente_id'], $profesionalActivoId); + + $stmt = $pdo->prepare('INSERT INTO sesiones (paciente_id, fecha_sesion, descripcion, evolucion, proxima_cita) VALUES (?, ?, ?, ?, ?)'); + $stmt->execute([$d['paciente_id'], $d['fecha_sesion'], $d['descripcion'], $d['evolucion'] ?? null, $d['proxima_cita'] ?? null]); + + if (!empty($d['proxima_cita'])) { + $stmtC = $pdo->prepare("INSERT INTO citas (paciente_id, profesional_id, fecha, motivo, estado) VALUES (?, ?, ?, 'Próxima cita agendada desde sesión anterior', 'pendiente')"); + $stmtC->execute([$d['paciente_id'], $profesionalActivoId, $d['proxima_cita']]); + } + + registrarAuditoria($pdo, 'crear', 'sesion', $pdo->lastInsertId(), "Se agregó una sesión del " . $d['fecha_sesion'] . "."); + + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId()]); + exit; +} + +// ------------------------------------------------------------ +// EDITAR SESIÓN EXISTENTE (POST ?accion=editar_sesion) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'editar_sesion') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id']) || empty($d['fecha_sesion']) || empty($d['descripcion'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Faltan datos de la sesión.']); + exit; + } + + $stmtCheck = $pdo->prepare(' + SELECT s.id FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE s.id = ? AND p.profesional_id = ? + '); + $stmtCheck->execute([$d['id'], $profesionalActivoId]); + if (!$stmtCheck->fetch()) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Sesión no encontrada.']); + exit; + } + + $stmt = $pdo->prepare('UPDATE sesiones SET fecha_sesion = ?, descripcion = ?, evolucion = ? WHERE id = ?'); + $stmt->execute([$d['fecha_sesion'], $d['descripcion'], $d['evolucion'] ?? null, $d['id']]); + + registrarAuditoria($pdo, 'editar', 'sesion', $d['id'], "Se editó una sesión del " . $d['fecha_sesion'] . "."); + + echo json_encode(['ok' => true, 'mensaje' => 'Sesión actualizada.']); + exit; +} + +// ------------------------------------------------------------ +// ELIMINAR SESIÓN (POST ?accion=eliminar_sesion) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'eliminar_sesion') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $idSesion = $d['id'] ?? 0; + + $stmtCheck = $pdo->prepare(' + SELECT s.id, s.fecha_sesion FROM sesiones s + INNER JOIN pacientes p ON p.id = s.paciente_id + WHERE s.id = ? AND p.profesional_id = ? + '); + $stmtCheck->execute([$idSesion, $profesionalActivoId]); + $sesion = $stmtCheck->fetch(); + if (!$sesion) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Sesión no encontrada.']); + exit; + } + + $pdo->prepare('DELETE FROM sesiones WHERE id = ?')->execute([$idSesion]); + registrarAuditoria($pdo, 'eliminar', 'sesion', $idSesion, "Se eliminó una sesión del " . $sesion['fecha_sesion'] . "."); + + echo json_encode(['ok' => true, 'mensaje' => 'Sesión eliminada.']); + exit; +} + +// ------------------------------------------------------------ +// ACTUALIZAR DATOS DE LEGAJO (POST ?accion=actualizar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'actualizar') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta el ID del legajo.']); + exit; + } + $errores = validarPaciente($d); + if (!empty($errores)) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => implode(' ', $errores)]); + exit; + } + + obtenerPacienteDelProfesionalActivo($pdo, $d['id'], $profesionalActivoId); + + try { + $stmt = $pdo->prepare(' + UPDATE pacientes SET + nombre = ?, apellido = ?, dni = ?, fecha_nacimiento = ?, sexo = ?, + obra_social_id = ?, numero_afiliado = ?, telefono = ?, email = ?, direccion = ?, + motivo_consulta = ?, patologia = ?, sintomas = ?, observaciones_generales = ? + WHERE id = ? AND profesional_id = ? + '); + $stmt->execute([ + trim($d['nombre']), trim($d['apellido']), trim($d['dni']), $d['fecha_nacimiento'], $d['sexo'], + $d['obra_social_id'] ?: null, $d['numero_afiliado'] ?? null, $d['telefono'] ?? null, + $d['email'] ?? null, $d['direccion'] ?? null, $d['motivo_consulta'] ?? null, + $d['patologia'] ?? null, $d['sintomas'] ?? null, $d['observaciones_generales'] ?? null, + $d['id'], $profesionalActivoId + ]); + registrarAuditoria($pdo, 'editar', 'paciente', $d['id'], "Se editaron los datos de {$d['nombre']} {$d['apellido']}."); + echo json_encode(['ok' => true, 'mensaje' => 'Legajo actualizado.']); + } catch (PDOException $e) { + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'Error al actualizar. Verificá que el DNI no esté repetido.']); + } + exit; +} + +// ------------------------------------------------------------ +// MIGRAR PACIENTE A OTRA SEDE (POST ?accion=migrar_sede) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'migrar_sede') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $id = $d['id'] ?? 0; + $sedeNuevaId = $d['sede_id'] ?? 0; + + $paciente = obtenerPacienteDelProfesionalActivo($pdo, $id, $profesionalActivoId); + + $stmtSede = $pdo->prepare('SELECT nombre FROM sedes WHERE id = ? AND activa = 1'); + $stmtSede->execute([$sedeNuevaId]); + $sedeNueva = $stmtSede->fetch(); + if (!$sedeNueva) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Sede no válida.']); + exit; + } + + $pdo->prepare('UPDATE pacientes SET sede_id = ? WHERE id = ?')->execute([$sedeNuevaId, $id]); + registrarAuditoria($pdo, 'editar', 'paciente', $id, "Se migró a {$paciente['nombre']} {$paciente['apellido']} a la sede \"{$sedeNueva['nombre']}\"."); + + echo json_encode(['ok' => true, 'mensaje' => 'Paciente migrado a la nueva sede.']); + exit; +} + +// ------------------------------------------------------------ +// LISTAR SEDES DISPONIBLES PARA MIGRAR (GET ?accion=sedes_disponibles) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'sedes_disponibles') { + $stmt = $pdo->prepare(' + SELECT s.id, s.nombre FROM sedes s + INNER JOIN usuarios_sedes us ON us.sede_id = s.id + WHERE us.usuario_id = ? AND s.activa = 1 + ORDER BY s.nombre ASC + '); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// BUSCAR LEGAJOS (GET ?accion=buscar&tipo=dni|nombre|fecha|obra_social|sede&...) +// Siempre acotado al profesional_id activo de la sesión. +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'buscar') { + $tipo = $_GET['tipo'] ?? ''; + $sqlBase = ' + SELECT p.*, o.nombre AS obra_social_nombre, s.nombre AS sede_nombre + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + LEFT JOIN sedes s ON s.id = p.sede_id + '; + $condiciones = ['p.profesional_id = ?']; + $params = [$profesionalActivoId]; + + if ($tipo === 'dni') { + $condiciones[] = 'p.dni LIKE ?'; + $params[] = '%' . trim($_GET['valor'] ?? '') . '%'; + } elseif ($tipo === 'nombre') { + $condiciones[] = '(p.nombre LIKE ? OR p.apellido LIKE ? OR CONCAT(p.nombre, " ", p.apellido) LIKE ?)'; + $valor = '%' . trim($_GET['valor'] ?? '') . '%'; + $params[] = $valor; $params[] = $valor; $params[] = $valor; + } elseif ($tipo === 'fecha') { + $desde = $_GET['desde'] ?? null; + $hasta = $_GET['hasta'] ?? null; + $sqlBase = ' + SELECT DISTINCT p.*, o.nombre AS obra_social_nombre, s.nombre AS sede_nombre + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + LEFT JOIN sedes s ON s.id = p.sede_id + INNER JOIN sesiones se ON se.paciente_id = p.id + '; + if ($desde) { $condiciones[] = 'se.fecha_sesion >= ?'; $params[] = $desde; } + if ($hasta) { $condiciones[] = 'se.fecha_sesion <= ?'; $params[] = $hasta; } + } elseif ($tipo === 'obra_social') { + $condiciones[] = 'p.obra_social_id = ?'; + $params[] = $_GET['obra_social_id'] ?? 0; + } elseif ($tipo === 'sede') { + $condiciones[] = 'p.sede_id = ?'; + $params[] = $_GET['sede_id'] ?? 0; + } + + $sql = $sqlBase . ' WHERE ' . implode(' AND ', $condiciones) . ' ORDER BY p.apellido ASC, p.nombre ASC'; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $resultados = $stmt->fetchAll(); + + foreach ($resultados as &$r) { + $r['edad'] = calcularEdad($r['fecha_nacimiento']); + $r = filtrarSegunRol($r); + } + + echo json_encode(['ok' => true, 'datos' => $resultados, 'total' => count($resultados)]); + exit; +} + +// ------------------------------------------------------------ +// VER DETALLE DE UN LEGAJO (GET ?accion=detalle&id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'detalle') { + $id = $_GET['id'] ?? 0; + $paciente = obtenerPacienteDelProfesionalActivo($pdo, $id, $profesionalActivoId); + $paciente['edad'] = calcularEdad($paciente['fecha_nacimiento']); + + if (esProfesional()) { + $stmtSesiones = $pdo->prepare('SELECT * FROM sesiones WHERE paciente_id = ? ORDER BY fecha_sesion DESC'); + $stmtSesiones->execute([$id]); + $paciente['sesiones'] = $stmtSesiones->fetchAll(); + } + + $paciente = filtrarSegunRol($paciente); + + echo json_encode(['ok' => true, 'datos' => $paciente]); + exit; +} + +// ------------------------------------------------------------ +// ELIMINAR LEGAJO (POST ?accion=eliminar) +// ------------------------------------------------------------ +if ($metodo === 'POST' && $accion === 'eliminar') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $id = $d['id'] ?? 0; + + $paciente = obtenerPacienteDelProfesionalActivo($pdo, $id, $profesionalActivoId); + + $stmtSesiones = $pdo->prepare('SELECT * FROM sesiones WHERE paciente_id = ?'); + $stmtSesiones->execute([$id]); + $paciente['sesiones'] = $stmtSesiones->fetchAll(); + + $pdo->beginTransaction(); + try { + $stmtArchivo = $pdo->prepare(' + INSERT INTO legajos_eliminados (paciente_id_original, profesional_id_original, sede_id_original, nombre_completo, dni, datos_json) + VALUES (?, ?, ?, ?, ?, ?) + '); + $stmtArchivo->execute([ + $id, + $paciente['profesional_id'], + $paciente['sede_id'], + $paciente['nombre'] . ' ' . $paciente['apellido'], + $paciente['dni'], + json_encode($paciente, JSON_UNESCAPED_UNICODE) + ]); + + $stmtBorrar = $pdo->prepare('DELETE FROM pacientes WHERE id = ?'); + $stmtBorrar->execute([$id]); + + $pdo->commit(); + registrarAuditoria($pdo, 'eliminar', 'paciente', $id, "Se eliminó el legajo de {$paciente['nombre']} {$paciente['apellido']}."); + echo json_encode(['ok' => true, 'mensaje' => 'Legajo eliminado del sistema activo. Queda resguardado en la base histórica.']); + } catch (Exception $e) { + $pdo->rollBack(); + http_response_code(500); + echo json_encode(['ok' => false, 'error' => 'No se pudo eliminar el legajo.']); + } + exit; +} + +// ------------------------------------------------------------ +// VER PAPELERA / BASE HISTÓRICA (GET ?accion=papelera) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'papelera') { + requiereRolProfesional(); + $stmt = $pdo->query('SELECT id, paciente_id_original, nombre_completo, dni, eliminado_en FROM legajos_eliminados ORDER BY eliminado_en DESC'); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +// ------------------------------------------------------------ +// VER DETALLE DE UN LEGAJO ELIMINADO (GET ?accion=papelera_detalle&id=X) +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'papelera_detalle') { + requiereRolProfesional(); + $id = $_GET['id'] ?? 0; + $stmt = $pdo->prepare('SELECT * FROM legajos_eliminados WHERE id = ?'); + $stmt->execute([$id]); + $fila = $stmt->fetch(); + if (!$fila) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'Registro no encontrado.']); + exit; + } + $fila['datos'] = json_decode($fila['datos_json'], true); + unset($fila['datos_json']); + echo json_encode(['ok' => true, 'datos' => $fila]); + exit; +} + +// ------------------------------------------------------------ +// EXPORTACIÓN MASIVA (GET ?accion=exportar_todo) — descarga un +// único archivo .json con todos los legajos activos del +// profesional, cada uno con sus sesiones, citas y la lista de +// adjuntos (metadata, no los archivos físicos en sí). Pensado +// como backup propio, fuera de la base de datos. +// ------------------------------------------------------------ +if ($metodo === 'GET' && $accion === 'exportar_todo') { + requiereRolProfesional(); + + $stmtPacientes = $pdo->prepare(' + SELECT p.*, o.nombre AS obra_social_nombre, s.nombre AS sede_nombre + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + LEFT JOIN sedes s ON s.id = p.sede_id + WHERE p.profesional_id = ? + ORDER BY p.apellido ASC, p.nombre ASC + '); + $stmtPacientes->execute([$profesionalActivoId]); + $pacientes = $stmtPacientes->fetchAll(); + + $stmtSesiones = $pdo->prepare('SELECT * FROM sesiones WHERE paciente_id = ? ORDER BY fecha_sesion ASC'); + $stmtCitas = $pdo->prepare('SELECT fecha, hora, motivo, estado, notas, confirmada_por_paciente FROM citas WHERE paciente_id = ? ORDER BY fecha ASC'); + $stmtAdjuntos = $pdo->prepare('SELECT nombre_original, tipo_mime, tamanio_bytes, descripcion, subido_en FROM archivos_adjuntos WHERE paciente_id = ?'); + + foreach ($pacientes as &$p) { + $p['edad'] = calcularEdad($p['fecha_nacimiento']); + $stmtSesiones->execute([$p['id']]); + $p['sesiones'] = $stmtSesiones->fetchAll(); + $stmtCitas->execute([$p['id']]); + $p['citas'] = $stmtCitas->fetchAll(); + $stmtAdjuntos->execute([$p['id']]); + $p['adjuntos'] = $stmtAdjuntos->fetchAll(); + } + + $exportacion = [ + 'sistema' => 'Del Austral', + 'tipo' => 'Exportación completa de legajos', + 'generado_en' => date('Y-m-d H:i:s'), + 'total_pacientes' => count($pacientes), + 'pacientes' => $pacientes, + ]; + + $nombreArchivo = 'del-austral-export-' . date('Y-m-d') . '.json'; + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . $nombreArchivo . '"'); + echo json_encode($exportacion, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/api/plantillas.php b/api/plantillas.php new file mode 100644 index 0000000..675f57e --- /dev/null +++ b/api/plantillas.php @@ -0,0 +1,59 @@ +prepare('SELECT * FROM plantillas_evolucion WHERE profesional_id = ? ORDER BY nombre ASC'); + $stmt->execute([$profesionalActivoId]); + echo json_encode(['ok' => true, 'datos' => $stmt->fetchAll()]); + exit; +} + +if ($metodo === 'POST' && $accion === 'crear') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $nombre = trim($d['nombre'] ?? ''); + $contenido = trim($d['contenido'] ?? ''); + if ($nombre === '' || $contenido === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'La plantilla necesita un nombre y un contenido.']); + exit; + } + $stmt = $pdo->prepare('INSERT INTO plantillas_evolucion (profesional_id, nombre, contenido) VALUES (?, ?, ?)'); + $stmt->execute([$profesionalActivoId, $nombre, $contenido]); + echo json_encode(['ok' => true, 'id' => $pdo->lastInsertId()]); + exit; +} + +if ($metodo === 'POST' && $accion === 'actualizar') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + if (empty($d['id'])) { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'Falta el ID de la plantilla.']); + exit; + } + $stmt = $pdo->prepare('UPDATE plantillas_evolucion SET nombre = ?, contenido = ? WHERE id = ? AND profesional_id = ?'); + $stmt->execute([trim($d['nombre']), trim($d['contenido']), $d['id'], $profesionalActivoId]); + echo json_encode(['ok' => true]); + exit; +} + +if ($metodo === 'POST' && $accion === 'eliminar') { + requiereRolProfesional(); + $d = json_decode(file_get_contents('php://input'), true); + $stmt = $pdo->prepare('DELETE FROM plantillas_evolucion WHERE id = ? AND profesional_id = ?'); + $stmt->execute([$d['id'] ?? 0, $profesionalActivoId]); + echo json_encode(['ok' => true]); + exit; +} + +http_response_code(400); +echo json_encode(['ok' => false, 'error' => 'Solicitud no válida.']); diff --git a/assets/css/estilos.css b/assets/css/estilos.css new file mode 100644 index 0000000..86a2d5f --- /dev/null +++ b/assets/css/estilos.css @@ -0,0 +1,1788 @@ +/* ============================================================ + FONOFLOW · Historial Clínico Fonoaudiológico + Sistema visual: cálido, clínico-humano, basado en ondas sonoras + ============================================================ */ + +@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500&display=swap'); + +:root { + /* Paleta */ + --crema: #F7F4ED; + --crema-alto: #FFFFFF; + --tinta: #1C2421; + --tinta-suave: #4A5650; + --salvia: #3D6B63; + --salvia-oscuro: #2A4B45; + --salvia-claro: #E4EDE9; + --coral: #C4654A; + --coral-claro: #F5E3DC; + --arena: #C9A84C; + --linea: #DDD6C7; + --sombra: 0 8px 30px rgba(28, 36, 33, 0.08); + --sombra-fuerte: 0 20px 60px rgba(28, 36, 33, 0.18); + + --fuente-display: 'Fraunces', serif; + --fuente-cuerpo: 'Inter', sans-serif; + --fuente-mono: 'JetBrains Mono', monospace; + + --radio: 14px; + --radio-chico: 8px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--crema); + color: var(--tinta); + font-family: var(--fuente-cuerpo); + -webkit-font-smoothing: antialiased; +} + +body { + min-height: 100vh; +} + +h1, h2, h3, h4 { + font-family: var(--fuente-display); + font-weight: 600; + margin: 0; + letter-spacing: -0.01em; +} + +p { margin: 0; line-height: 1.5; } + +button { + font-family: var(--fuente-cuerpo); + cursor: pointer; + border: none; + background: none; +} + +input, select, textarea { + font-family: var(--fuente-cuerpo); +} + +a { color: var(--salvia); text-decoration: none; } + +::selection { background: var(--arena); color: var(--tinta); } + +/* Scrollbar discreto */ +::-webkit-scrollbar { width: 10px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--linea); border-radius: 10px; } +::-webkit-scrollbar-thumb:hover { background: var(--salvia); } + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} + +/* ============================================================ + Fondo de ondas ambientales (decorativo, fijo) + ============================================================ */ +.fondo-ondas { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.5; +} + +/* ============================================================ + PANTALLA DE ACCESO (patrón tipo L) + ============================================================ */ +.pantalla-acceso { + position: relative; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 20px; + z-index: 1; +} + +.tarjeta-acceso { + position: relative; + z-index: 1; + width: 100%; + max-width: 420px; + background: var(--crema-alto); + border-radius: 24px; + box-shadow: var(--sombra-fuerte); + padding: 44px 36px 36px; + text-align: center; + border: 1px solid var(--linea); +} + +.marca-acceso { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin-bottom: 28px; +} + +.marca-acceso .icono-marca { + width: 56px; + height: 56px; + color: var(--salvia); + margin-bottom: 6px; +} + +.marca-acceso h1 { + font-size: 1.7rem; + color: var(--tinta); +} + +.marca-acceso .subtitulo { + font-size: 0.92rem; + color: var(--tinta-suave); + font-weight: 500; +} + +.instruccion-patron { + font-size: 0.95rem; + color: var(--tinta-suave); + margin-bottom: 22px; + min-height: 22px; + transition: color 0.2s; +} + +.instruccion-patron.error { color: var(--coral); font-weight: 600; } +.instruccion-patron.exito { color: var(--salvia); font-weight: 600; } + +/* ------------------------------------------------------------ + Indicadores de PIN (los 4 puntos que se rellenan al tipear) + ------------------------------------------------------------ */ +.indicadores-pin { + display: flex; + justify-content: center; + gap: 18px; + margin: 0 auto 28px; +} + +.punto-pin { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2.5px solid var(--linea); + background: var(--crema); + transition: all 0.15s ease; +} + +.punto-pin.relleno { + background: var(--salvia); + border-color: var(--salvia); + transform: scale(1.15); +} + +.punto-pin.error { + background: var(--coral); + border-color: var(--coral); + animation: sacudir-pin 0.4s ease; +} + +@keyframes sacudir-pin { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-6px); } + 75% { transform: translateX(6px); } +} + +/* ------------------------------------------------------------ + Campo de PIN: visualmente discreto, los puntitos de arriba + son los que dan el feedback visual real. + ------------------------------------------------------------ */ +.input-pin-oculto { + display: block; + width: 160px; + margin: 0 auto 24px; + padding: 12px 14px; + text-align: center; + font-family: var(--fuente-mono); + font-size: 1.4rem; + letter-spacing: 0.5em; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + background: var(--crema); + color: var(--tinta); + caret-color: var(--salvia); +} + +.input-pin-oculto:focus { + outline: none; + border-color: var(--salvia); + background: var(--crema-alto); +} + +.input-pin-texto { + text-align: center; + font-family: var(--fuente-mono); + font-size: 1.3rem; + letter-spacing: 0.4em; +} + +/* ------------------------------------------------------------ + Pasos del login multi-etapa (sede → usuario → PIN, etc.) + ------------------------------------------------------------ */ +.paso-login { width: 100%; } + +.lista-opciones-login { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 18px; + max-height: 320px; + overflow-y: auto; +} + +.opcion-login { + width: 100%; + padding: 14px 18px; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + background: var(--crema); + font-size: 0.95rem; + font-weight: 600; + color: var(--tinta); + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + transition: border-color 0.15s, background 0.15s; +} + +.opcion-login:hover { + border-color: var(--salvia); + background: var(--salvia-claro); +} + +.etiqueta-rol-login { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--tinta-suave); + background: var(--crema-alto); + padding: 3px 9px; + border-radius: 999px; + flex-shrink: 0; +} + +.texto-vacio-login { + color: var(--tinta-suave); + font-size: 0.88rem; + text-align: center; + padding: 20px 10px; +} + +/* ------------------------------------------------------------ + Pantalla de configuración inicial del Desarrollador + ------------------------------------------------------------ */ +#vista-setup-inicial .marca-acceso .subtitulo { + display: block; + margin-top: 4px; + line-height: 1.5; +} + +.acciones-patron { + display: flex; + justify-content: center; + gap: 16px; +} + +.campo-nombre-profesional { + text-align: left; + margin-bottom: 22px; +} + +.campo-nombre-profesional label { + font-size: 0.82rem; + font-weight: 700; + color: var(--tinta); + display: block; + margin-bottom: 6px; +} + +.campo-nombre-profesional input { + width: 100%; + padding: 12px 14px; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + font-size: 0.95rem; + background: var(--crema); + color: var(--tinta); + margin-bottom: 6px; +} + +.campo-nombre-profesional input:focus { + outline: none; + border-color: var(--salvia); + background: var(--crema-alto); +} + +.campo-nombre-profesional .ayuda { + font-size: 0.78rem; + color: var(--tinta-suave); + display: block; +} + +.modal-bienvenida { + text-align: center; + max-width: 420px; +} + +.modal-bienvenida .icono-marca { + width: 48px; + height: 48px; + color: var(--salvia); + margin-bottom: 14px; +} + +.modal-bienvenida h3 { + font-size: 1.4rem; + margin-bottom: 10px; +} + +.btn-texto { + font-size: 0.88rem; + color: var(--tinta-suave); + font-weight: 600; + padding: 6px 10px; + border-radius: var(--radio-chico); + transition: color 0.15s, background 0.15s; +} +.btn-texto:hover { color: var(--salvia-oscuro); background: var(--salvia-claro); } + +/* ------------------------------------------------------------ + Botón discreto de acceso al rol Desarrollador, sin revelar + que existe — se ve como un link administrativo cualquiera. + ------------------------------------------------------------ */ +.btn-mantenimiento { + position: fixed; + top: 18px; + right: 22px; + z-index: 5; + background: none; + border: none; + font-size: 0.74rem; + color: var(--tinta-suave); + opacity: 0.45; + cursor: pointer; + font-family: inherit; + padding: 6px 10px; + border-radius: var(--radio-chico); + transition: opacity 0.15s; +} +.btn-mantenimiento:hover { opacity: 0.85; } + +/* ============================================================ + BOTONES GENERALES + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 13px 24px; + border-radius: 999px; + font-weight: 700; + font-size: 0.95rem; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.btn-primario { + background: var(--salvia); + color: var(--crema-alto); + box-shadow: 0 6px 16px rgba(61, 107, 99, 0.3); +} +.btn-primario:hover { background: var(--salvia-oscuro); transform: translateY(-1px); } +.btn-primario:active { transform: translateY(0); } +.btn-primario:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } + +.btn-secundario { + background: var(--crema-alto); + color: var(--salvia-oscuro); + border: 2px solid var(--linea); +} +.btn-secundario:hover { border-color: var(--salvia); } + +.btn-peligro { + background: var(--coral); + color: var(--crema-alto); + box-shadow: 0 6px 16px rgba(196, 101, 74, 0.3); +} +.btn-peligro:hover { background: #A8503A; transform: translateY(-1px); } + +.btn-chico { padding: 9px 16px; font-size: 0.85rem; border-radius: 999px; } + +.btn[disabled] { cursor: not-allowed; } + +/* ============================================================ + APP PRINCIPAL (post-login) + ============================================================ */ +.app { + position: relative; + z-index: 1; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 18px 36px; + background: var(--crema-alto); + border-bottom: 1px solid var(--linea); + position: sticky; + top: 0; + z-index: 20; +} + +.topbar .marca { + display: flex; + align-items: center; + gap: 10px; +} + +.topbar .marca .icono-marca { width: 30px; height: 30px; color: var(--salvia); } + +.topbar .marca h2 { + font-size: 1.25rem; + color: var(--tinta); +} + +.topbar-acciones { + display: flex; + align-items: center; + gap: 10px; +} + +.contenido { + flex: 1; + padding: 40px 36px 80px; + max-width: 1180px; + margin: 0 auto; + width: 100%; +} + +/* ============================================================ + MENÚ PRINCIPAL (las 3 opciones) + ============================================================ */ +.encabezado-menu { margin-bottom: 40px; text-align: center; } + +.encabezado-menu .eyebrow { + font-family: var(--fuente-mono); + font-size: 0.78rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--salvia); + font-weight: 500; + margin-bottom: 10px; + display: block; +} + +.encabezado-menu h1 { font-size: 2.1rem; margin-bottom: 8px; } +.encabezado-menu p { color: var(--tinta-suave); font-size: 1.02rem; } + +/* ------------------------------------------------------------ + Franja de resumen del día y avisos de turnos + ------------------------------------------------------------ */ +.franja-resumen-hoy { + background: var(--salvia-claro); + color: var(--salvia-oscuro); + border-radius: var(--radio-chico); + padding: 14px 20px; + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 14px; +} + +.franja-avisos-turnos { + display: block; + width: 100%; + text-align: left; + background: var(--arena-claro, #F5EBD2); + color: var(--tinta); + border: none; + border-radius: var(--radio-chico); + padding: 14px 20px; + font-size: 0.95rem; + font-family: inherit; + font-weight: 600; + margin-bottom: 24px; + cursor: pointer; + transition: filter 0.15s; +} +.franja-avisos-turnos:hover { filter: brightness(0.97); } + +.etiqueta-aviso { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 3px 10px; + border-radius: 999px; + background: var(--crema-alto); + color: var(--tinta-suave); + flex-shrink: 0; +} +.etiqueta-aviso.confirmado { background: var(--salvia-claro); color: var(--salvia-oscuro); } +.etiqueta-aviso.cancelado { background: var(--coral-claro); color: var(--coral); } + +/* ------------------------------------------------------------ + Panel resumen (agenda + inactivos) arriba de la grilla + ------------------------------------------------------------ */ +.panel-resumen-menu { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 20px; + margin-bottom: 32px; +} + +.resumen-columna { + background: var(--crema-alto); + border: 1px solid var(--linea); + border-radius: var(--radio); + padding: 20px 22px; + box-shadow: var(--sombra); +} + +.resumen-titulo { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: 0.92rem; + color: var(--salvia-oscuro); + margin-bottom: 14px; +} +.resumen-titulo svg { width: 18px; height: 18px; } +.resumen-titulo.alerta { color: var(--coral); } + +.lista-resumen { display: flex; flex-direction: column; gap: 6px; min-height: 24px; } + +.item-resumen { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 9px 12px; + border-radius: var(--radio-chico); + text-align: left; + width: 100%; + transition: background 0.15s; +} +.item-resumen:hover { background: var(--crema); } + +.item-resumen-fecha { + font-size: 0.78rem; + font-weight: 700; + color: var(--salvia-oscuro); + flex-shrink: 0; + font-family: var(--fuente-mono); +} +.item-resumen-fecha.alerta { color: var(--coral); font-family: var(--fuente-cuerpo); } + +.item-resumen-nombre { + font-size: 0.88rem; + font-weight: 600; + color: var(--tinta); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resumen-vacio { + font-size: 0.85rem; + color: var(--tinta-suave); + padding: 4px 2px; +} + +.cargando-pagina.chico { padding: 12px 0; } + +.grilla-menu { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; +} + +.tarjeta-menu { + background: var(--crema-alto); + border: 1px solid var(--linea); + border-radius: var(--radio); + padding: 32px 26px; + text-align: left; + display: flex; + flex-direction: column; + gap: 14px; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + box-shadow: var(--sombra); +} + +.tarjeta-menu:hover { + transform: translateY(-4px); + border-color: var(--salvia); + box-shadow: var(--sombra-fuerte); +} + +.tarjeta-menu .icono-tarjeta { + width: 46px; + height: 46px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; +} + +.tarjeta-menu .icono-tarjeta svg { width: 24px; height: 24px; } + +.tarjeta-menu.crear .icono-tarjeta { background: var(--salvia-claro); color: var(--salvia-oscuro); } +.tarjeta-menu.acceder .icono-tarjeta { background: #EAE2C5; color: #8C6F1E; } +.tarjeta-menu.agenda .icono-tarjeta { background: #DCE8F5; color: #2C5F8A; } +.tarjeta-menu.borrar .icono-tarjeta { background: var(--coral-claro); color: var(--coral); } + +.tarjeta-menu h3 { font-size: 1.25rem; } +.tarjeta-menu p { color: var(--tinta-suave); font-size: 0.92rem; } + +.tarjeta-menu .ir { + margin-top: auto; + font-weight: 700; + font-size: 0.85rem; + color: var(--salvia-oscuro); + display: flex; + align-items: center; + gap: 6px; +} + +/* ============================================================ + ENCABEZADO DE SECCIÓN (dentro de cada vista) + ============================================================ */ +.encabezado-seccion { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 20px; + flex-wrap: wrap; +} + +.encabezado-seccion .volver { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + font-weight: 700; + color: var(--tinta-suave); + margin-bottom: 12px; +} +.encabezado-seccion .volver:hover { color: var(--salvia-oscuro); } +.encabezado-seccion .volver svg { width: 16px; height: 16px; } + +.encabezado-seccion h2 { font-size: 1.7rem; } +.encabezado-seccion .desc { color: var(--tinta-suave); margin-top: 4px; font-size: 0.95rem; } + +/* ============================================================ + AVISO LEGAL (Ley 25.326 — protección de datos personales) + ============================================================ */ +.franja-aviso-legal { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + background: #FBF6E6; + border: 1.5px solid #E9D9A0; + border-radius: 999px; + padding: 10px 18px; + margin-bottom: 24px; + text-align: left; + transition: border-color 0.15s, background 0.15s; +} +.franja-aviso-legal:hover { background: #F6EFD6; border-color: #DCC57E; } + +.franja-aviso-legal svg { + width: 18px; + height: 18px; + color: #8C6F1E; + flex-shrink: 0; +} + +.franja-aviso-legal span:first-of-type { + font-size: 0.85rem; + font-weight: 700; + color: #6B5414; + flex: 1; +} + +.franja-aviso-legal-ver { + font-size: 0.8rem; + font-weight: 700; + color: #8C6F1E; + flex-shrink: 0; +} + +.modal-aviso-legal { + position: relative; + max-width: 480px; + text-align: left; +} + +.modal-aviso-legal .aviso-legal-icono { + width: 44px; + height: 44px; + border-radius: 50%; + background: #EFE2B4; + color: #8C6F1E; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; +} +.modal-aviso-legal .aviso-legal-icono svg { width: 22px; height: 22px; } + +.modal-aviso-legal .aviso-legal-subtitulo { + font-size: 0.8rem; + font-weight: 700; + color: #8C6F1E; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 14px !important; +} + +.modal-aviso-legal p { + font-size: 0.9rem; + line-height: 1.6; + margin-bottom: 14px; +} +.modal-aviso-legal p:last-of-type { margin-bottom: 22px; } + +.cerrar-modal-x { + position: absolute; + top: 18px; + right: 18px; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--tinta-suave); + transition: background 0.15s, color 0.15s; +} +.cerrar-modal-x:hover { background: var(--crema); color: var(--tinta); } +.cerrar-modal-x svg { width: 16px; height: 16px; } + +/* ============================================================ + FORMULARIOS + ============================================================ */ +.panel { + background: var(--crema-alto); + border: 1px solid var(--linea); + border-radius: var(--radio); + box-shadow: var(--sombra); + padding: 32px; +} + +.panel + .panel { margin-top: 24px; } + +.panel-titulo { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 10px; +} + +.panel-titulo .num { + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--salvia-claro); + color: var(--salvia-oscuro); + font-family: var(--fuente-mono); + font-size: 0.78rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.panel-subtitulo { color: var(--tinta-suave); font-size: 0.88rem; margin-bottom: 22px; } + +.grilla-campos { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 18px; +} + +.campo { display: flex; flex-direction: column; gap: 6px; } +.campo.ancho-completo { grid-column: 1 / -1; } + +.campo label { + font-size: 0.82rem; + font-weight: 700; + color: var(--tinta); +} + +.campo .opcional { + font-weight: 500; + color: var(--tinta-suave); + text-transform: none; +} + +.campo input, .campo select, .campo textarea { + padding: 12px 14px; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + font-size: 0.95rem; + background: var(--crema); + color: var(--tinta); + transition: border-color 0.15s, background 0.15s; +} + +.campo input:focus, .campo select:focus, .campo textarea:focus { + outline: none; + border-color: var(--salvia); + background: var(--crema-alto); +} + +.campo textarea { resize: vertical; min-height: 90px; font-family: var(--fuente-cuerpo); } + +.campo .ayuda { font-size: 0.78rem; color: var(--tinta-suave); } + +/* Selector de obra social con opción de agregar */ +.fila-obra-social { display: flex; gap: 10px; } +.fila-obra-social select { flex: 1; } +.btn-agregar-obra { + flex-shrink: 0; + width: 44px; + height: 44px; + border-radius: var(--radio-chico); + border: 1.5px solid var(--linea); + background: var(--crema); + color: var(--salvia-oscuro); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, background 0.15s; +} +.btn-agregar-obra:hover { border-color: var(--salvia); background: var(--salvia-claro); } +.btn-agregar-obra svg { width: 18px; height: 18px; } + +/* Sesiones dinámicas dentro del form de creación */ +.lista-sesiones-form { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; } + +.sesion-form-item { + border: 1.5px dashed var(--linea); + border-radius: var(--radio-chico); + padding: 18px; + position: relative; + background: var(--crema); +} + +.sesion-form-item .quitar-sesion { + position: absolute; + top: 12px; + right: 12px; + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--coral-claro); + color: var(--coral); + display: flex; + align-items: center; + justify-content: center; +} +.sesion-form-item .quitar-sesion:hover { background: var(--coral); color: white; } +.sesion-form-item .quitar-sesion svg { width: 14px; height: 14px; } + +.btn-agregar-dia { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.88rem; + font-weight: 700; + color: var(--salvia-oscuro); + padding: 10px 16px; + border-radius: 999px; + border: 1.5px dashed var(--salvia); + background: var(--salvia-claro); +} +.btn-agregar-dia:hover { background: var(--salvia); color: white; border-style: solid; } +.btn-agregar-dia svg { width: 16px; height: 16px; } + +.barra-acciones-form { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 28px; +} + +/* ============================================================ + BUSCADOR (acceder legajos) + ============================================================ */ +.tabs-busqueda { + display: flex; + gap: 8px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.tab-busqueda { + padding: 10px 18px; + border-radius: 999px; + font-size: 0.88rem; + font-weight: 700; + color: var(--tinta-suave); + background: var(--crema-alto); + border: 1.5px solid var(--linea); + transition: all 0.15s; +} + +.tab-busqueda.activo { + background: var(--salvia); + color: white; + border-color: var(--salvia); +} + +.fila-buscador { + display: flex; + gap: 12px; + margin-bottom: 28px; + flex-wrap: wrap; +} + +.fila-buscador input, .fila-buscador select { + flex: 1; + min-width: 200px; + padding: 13px 16px; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + font-size: 0.95rem; + background: var(--crema-alto); +} +.fila-buscador input:focus, .fila-buscador select:focus { outline: none; border-color: var(--salvia); } + +/* ============================================================ + LISTA / TARJETAS DE RESULTADOS + ============================================================ */ +.lista-resultados { display: flex; flex-direction: column; gap: 12px; } + +.tarjeta-paciente { + background: var(--crema-alto); + border: 1px solid var(--linea); + border-radius: var(--radio-chico); + padding: 18px 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + transition: border-color 0.15s, box-shadow 0.15s; + flex-wrap: wrap; +} + +.tarjeta-paciente:hover { border-color: var(--salvia); box-shadow: var(--sombra); } + +.tarjeta-paciente .info-principal { display: flex; align-items: center; gap: 16px; } + +.avatar-iniciales { + width: 46px; + height: 46px; + border-radius: 50%; + background: var(--salvia-claro); + color: var(--salvia-oscuro); + font-weight: 700; + font-family: var(--fuente-display); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.tarjeta-paciente .nombre { font-weight: 700; font-size: 1rem; } +.tarjeta-paciente .meta { color: var(--tinta-suave); font-size: 0.85rem; margin-top: 2px; } + +.etiqueta { + display: inline-flex; + align-items: center; + padding: 4px 11px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + background: var(--salvia-claro); + color: var(--salvia-oscuro); +} + +.etiqueta.particular { background: #EFEAE0; color: var(--tinta-suave); } + +.tarjeta-paciente .acciones-tarjeta { display: flex; gap: 8px; } + +.btn-icono { + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: var(--crema); + border: 1.5px solid var(--linea); + color: var(--tinta-suave); + transition: all 0.15s; +} +.btn-icono svg { width: 17px; height: 17px; } +.btn-icono:hover { border-color: var(--salvia); color: var(--salvia-oscuro); } +.btn-icono.peligro:hover { border-color: var(--coral); color: var(--coral); background: var(--coral-claro); } + +.estado-vacio { + text-align: center; + padding: 60px 20px; + color: var(--tinta-suave); +} +.estado-vacio .icono-vacio { width: 56px; height: 56px; color: var(--linea); margin: 0 auto 16px; } +.estado-vacio h3 { color: var(--tinta); font-size: 1.15rem; margin-bottom: 6px; } +.estado-vacio p { font-size: 0.92rem; max-width: 360px; margin: 0 auto; } + +/* ============================================================ + FICHA DE DETALLE DE PACIENTE + ============================================================ */ +.ficha-cabecera { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + margin-bottom: 28px; +} + +.ficha-identidad { display: flex; align-items: center; gap: 18px; } + +.avatar-grande { + width: 64px; + height: 64px; + border-radius: 50%; + background: var(--salvia-claro); + color: var(--salvia-oscuro); + font-weight: 700; + font-family: var(--fuente-display); + font-size: 1.4rem; + display: flex; + align-items: center; + justify-content: center; +} + +.ficha-identidad h2 { font-size: 1.5rem; } +.ficha-identidad .meta-linea { color: var(--tinta-suave); font-size: 0.92rem; margin-top: 4px; } + +.grilla-datos { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.dato-box { + background: var(--crema); + border-radius: var(--radio-chico); + padding: 14px 16px; + border: 1px solid var(--linea); +} +.dato-box .etiqueta-dato { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--tinta-suave); + font-weight: 700; + margin-bottom: 4px; +} +.dato-box .valor-dato { font-size: 1rem; font-weight: 600; } + +.bloque-texto { margin-bottom: 20px; } +.bloque-texto h4 { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--salvia-oscuro); + margin-bottom: 8px; +} +.bloque-texto p { color: var(--tinta); white-space: pre-wrap; } +.bloque-texto p.vacio { color: var(--tinta-suave); font-style: italic; } + +.linea-tiempo { display: flex; flex-direction: column; gap: 18px; margin-top: 8px; } + +.sesion-item { + position: relative; + padding-left: 28px; + border-left: 2px solid var(--linea); + padding-bottom: 4px; +} +.sesion-item::before { + content: ''; + position: absolute; + left: -6px; + top: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--salvia); + border: 2px solid var(--crema-alto); +} +.sesion-item .fecha-sesion { + font-family: var(--fuente-mono); + font-size: 0.8rem; + font-weight: 700; + color: var(--salvia-oscuro); + margin-bottom: 4px; +} + +.encabezado-sesion-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} +.encabezado-sesion-item .fecha-sesion { margin-bottom: 0; } + +.acciones-sesion-item { + display: flex; + gap: 6px; +} + +.btn-icono-chico { + width: 28px; + height: 28px; +} +.btn-icono-chico svg { width: 14px; height: 14px; } + +.sesion-item .desc-sesion { font-size: 0.93rem; color: var(--tinta); white-space: pre-wrap; } +.sesion-item .evolucion-sesion { font-size: 0.85rem; color: var(--tinta-suave); margin-top: 6px; white-space: pre-wrap; } +.sesion-item .proxima { font-size: 0.78rem; color: var(--arena); font-weight: 700; margin-top: 6px; } + +/* ============================================================ + MODALES + ============================================================ */ +.overlay-modal { + position: fixed; + inset: 0; + background: rgba(28, 36, 33, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 20px; + backdrop-filter: blur(2px); +} + +.modal { + background: var(--crema-alto); + border-radius: var(--radio); + padding: 32px; + max-width: 460px; + width: 100%; + box-shadow: var(--sombra-fuerte); +} + +.modal h3 { font-size: 1.25rem; margin-bottom: 10px; } +.modal p { color: var(--tinta-suave); margin-bottom: 22px; } +.modal .acciones-modal { display: flex; justify-content: flex-end; gap: 10px; } + +.modal.modal-peligro .icono-alerta { + width: 48px; height: 48px; + border-radius: 50%; + background: var(--coral-claro); + color: var(--coral); + display: flex; align-items: center; justify-content: center; + margin-bottom: 16px; +} +.modal.modal-peligro .icono-alerta svg { width: 24px; height: 24px; } + +.aviso-doble-confirmacion { + background: var(--coral-claro); + border-radius: var(--radio-chico); + padding: 12px 14px; + font-size: 0.85rem !important; + color: var(--coral) !important; + font-weight: 600; + margin-bottom: 14px !important; +} +.aviso-doble-confirmacion strong { font-weight: 800; letter-spacing: 0.02em; } + +.input-confirmar-texto { + width: 100%; + padding: 12px 14px; + border: 1.5px solid var(--coral); + border-radius: var(--radio-chico); + font-size: 0.95rem; + background: var(--crema); + color: var(--tinta); + margin-bottom: 22px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.input-confirmar-texto::placeholder { text-transform: none; font-weight: 400; letter-spacing: normal; } +.input-confirmar-texto:focus { outline: none; box-shadow: 0 0 0 3px var(--coral-claro); } + +/* ============================================================ + TOASTS + ============================================================ */ +.toast-contenedor { + position: fixed; + bottom: 28px; + right: 28px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 10px; +} + +.toast { + background: var(--tinta); + color: var(--crema-alto); + padding: 14px 20px; + border-radius: var(--radio-chico); + font-size: 0.9rem; + font-weight: 600; + box-shadow: var(--sombra-fuerte); + display: flex; + align-items: center; + gap: 10px; + animation: entrada-toast 0.25s ease; + max-width: 360px; +} + +.toast.exito { background: var(--salvia-oscuro); } +.toast.error { background: var(--coral); } + +@keyframes entrada-toast { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ============================================================ + UTILIDADES + ============================================================ */ +.oculto { display: none !important; } + +.spinner { + width: 18px; height: 18px; + border: 2.5px solid rgba(255,255,255,0.4); + border-top-color: white; + border-radius: 50%; + animation: girar 0.7s linear infinite; +} +@keyframes girar { to { transform: rotate(360deg); } } + +.cargando-pagina { + display: flex; + align-items: center; + justify-content: center; + padding: 80px 0; + color: var(--tinta-suave); + gap: 12px; +} +.cargando-pagina .spinner { border-color: var(--linea); border-top-color: var(--salvia); width: 24px; height: 24px; } + +/* ============================================================ + ENCABEZADO DE PANEL CON ACCIÓN (título + botón a la derecha) + ============================================================ */ +.encabezado-panel-accion { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 18px; +} +.encabezado-panel-accion .panel-titulo { margin-bottom: 4px; } +.encabezado-panel-accion .panel-subtitulo { margin-bottom: 0; } + +/* ============================================================ + AGENDA / CALENDARIO + ============================================================ */ +.calendario-nav { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 18px; +} + +.calendario-mes-titulo { + font-family: var(--fuente-display); + font-size: 1.2rem; + font-weight: 600; + text-transform: capitalize; +} + +.calendario-dias-semana { + display: grid; + grid-template-columns: repeat(7, 1fr); + text-align: center; + font-size: 0.75rem; + font-weight: 700; + color: var(--tinta-suave); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.calendario-grilla { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; +} + +.celda-calendario { + position: relative; + aspect-ratio: 1; + border-radius: var(--radio-chico); + border: 1.5px solid var(--linea); + background: var(--crema); + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.15s, background 0.15s; + min-height: 44px; +} +.celda-calendario:hover { border-color: var(--salvia); background: var(--salvia-claro); } +.celda-calendario.vacia { border: none; background: none; cursor: default; } +.celda-calendario.vacia:hover { background: none; } + +.celda-calendario.hoy { border-color: var(--arena); border-width: 2px; } +.celda-calendario.seleccionada { background: var(--salvia); border-color: var(--salvia); } +.celda-calendario.seleccionada .celda-numero { color: white; } + +.celda-numero { font-weight: 700; font-size: 0.92rem; } + +.celda-puntito { + position: absolute; + bottom: 4px; + right: 4px; + background: var(--coral); + color: white; + font-size: 0.62rem; + font-weight: 700; + border-radius: 999px; + min-width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 3px; +} + +/* ============================================================ + AUTOCOMPLETAR DE PACIENTE (modal de nueva cita) + ============================================================ */ +.resultados-autocompletar { + margin-top: 6px; + border: 1px solid var(--linea); + border-radius: var(--radio-chico); + overflow: hidden; + max-height: 180px; + overflow-y: auto; +} +.resultados-autocompletar:empty { border: none; } + +.item-autocompletar { + display: block; + width: 100%; + text-align: left; + padding: 10px 14px; + font-size: 0.88rem; + background: var(--crema-alto); + border-bottom: 1px solid var(--linea); + transition: background 0.15s; +} +.item-autocompletar:last-child { border-bottom: none; } +.item-autocompletar:hover { background: var(--salvia-claro); } +.item-autocompletar.vacio { color: var(--tinta-suave); font-style: italic; cursor: default; } +.item-autocompletar.vacio:hover { background: var(--crema-alto); } + +/* ============================================================ + ARCHIVOS ADJUNTOS + ============================================================ */ +.lista-adjuntos { display: flex; flex-direction: column; gap: 10px; } + +.item-adjunto { + display: flex; + align-items: center; + gap: 14px; + background: var(--crema); + border: 1px solid var(--linea); + border-radius: var(--radio-chico); + padding: 12px 16px; +} + +.item-adjunto-icono { + width: 38px; + height: 38px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.item-adjunto-icono svg { width: 19px; height: 19px; } +.item-adjunto-icono.pdf { background: var(--coral-claro); color: var(--coral); } +.item-adjunto-icono.imagen { background: var(--salvia-claro); color: var(--salvia-oscuro); } + +.item-adjunto-info { flex: 1; min-width: 0; } +.item-adjunto-nombre { font-weight: 700; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item-adjunto-meta { font-size: 0.78rem; color: var(--tinta-suave); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ============================================================ + PLANTILLAS DE EVOLUCIÓN + ============================================================ */ +.lista-plantillas { display: flex; flex-direction: column; gap: 10px; margin-bottom: 22px; max-height: 220px; overflow-y: auto; } + +.item-plantilla { + display: flex; + align-items: flex-start; + gap: 12px; + background: var(--crema); + border: 1px solid var(--linea); + border-radius: var(--radio-chico); + padding: 12px 14px; +} +.item-plantilla-info { flex: 1; min-width: 0; } +.item-plantilla-nombre { font-weight: 700; font-size: 0.9rem; margin-bottom: 3px; } +.item-plantilla-contenido { + font-size: 0.8rem; + color: var(--tinta-suave); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.panel-plantilla-nueva { + border-top: 1.5px dashed var(--linea); + padding-top: 18px; + margin-bottom: 18px; +} + +/* ============================================================ + CUMPLEAÑOS Y WHATSAPP (resumen menú + botón de citas) + ============================================================ */ +.resumen-titulo.cumple { color: var(--arena); } +.item-resumen-fecha.cumple { color: #8C6F1E; font-family: var(--fuente-cuerpo); font-weight: 700; } + +.btn-icono.whatsapp { color: #25D366; } +.btn-icono.whatsapp:hover { border-color: #25D366; background: #E9FBEF; color: #1DA851; } +.btn-icono.whatsapp svg { width: 17px; height: 17px; } + +/* ============================================================ + DASHBOARD DE ESTADÍSTICAS + ============================================================ */ +.grilla-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.tarjeta-stat { + background: var(--crema-alto); + border: 1px solid var(--linea); + border-radius: var(--radio); + padding: 22px 20px; + box-shadow: var(--sombra); +} +.tarjeta-stat-valor { + font-family: var(--fuente-display); + font-size: 2.1rem; + font-weight: 600; + color: var(--salvia-oscuro); + line-height: 1; +} +.tarjeta-stat-etiqueta { font-weight: 700; font-size: 0.85rem; margin-top: 6px; } +.tarjeta-stat-detalle { font-size: 0.78rem; color: var(--tinta-suave); margin-top: 3px; } + +.barras-obras-sociales { display: flex; flex-direction: column; gap: 12px; } +.fila-barra-stat { + display: grid; + grid-template-columns: 160px 1fr 40px; + align-items: center; + gap: 12px; +} +.fila-barra-etiqueta { font-size: 0.85rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.fila-barra-fondo { background: var(--crema); border-radius: 999px; height: 10px; overflow: hidden; } +.fila-barra-relleno { background: var(--salvia); height: 100%; border-radius: 999px; transition: width 0.4s ease; } +.fila-barra-valor { font-size: 0.85rem; font-weight: 700; text-align: right; color: var(--salvia-oscuro); } + +.barras-meses { + display: flex; + align-items: flex-end; + justify-content: space-around; + gap: 12px; + height: 170px; + padding-top: 10px; +} +.columna-mes { display: flex; flex-direction: column; align-items: center; gap: 6px; flex: 1; } +.columna-mes-valor { font-size: 0.78rem; font-weight: 700; color: var(--salvia-oscuro); } +.columna-mes-barra { width: 28px; background: var(--salvia-claro); border-radius: 6px 6px 0 0; border: 1.5px solid var(--salvia); transition: height 0.4s ease; } +.columna-mes-etiqueta { font-size: 0.75rem; color: var(--tinta-suave); text-transform: capitalize; } + +/* ============================================================ + CONFIGURACIÓN: USUARIOS E HISTORIAL + ============================================================ */ +.fila-buscador-usuarios { + margin-bottom: 18px; +} +.fila-buscador-usuarios input { + width: 100%; + padding: 10px 14px; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + font-size: 0.92rem; + font-family: inherit; + background: var(--crema); + color: var(--tinta); + transition: border-color 0.15s; +} +.fila-buscador-usuarios input:focus { + outline: none; + border-color: var(--salvia); + background: var(--crema-alto); +} + +/* ------------------------------------------------------------ + Aviso de licencias por vencer (panel Desarrollador) + ------------------------------------------------------------ */ +.aviso-licencias-vencer { + display: flex; + align-items: flex-start; + gap: 12px; + background: #F5EBD2; + border: 1.5px solid var(--arena); + border-radius: var(--radio-chico); + padding: 14px 18px; + margin-bottom: 20px; + font-size: 0.88rem; + color: var(--tinta); +} +.aviso-licencias-vencer svg { + width: 22px; + height: 22px; + color: #B8862E; + flex-shrink: 0; + margin-top: 1px; +} +.aviso-licencias-vencer strong { + display: block; + margin-bottom: 3px; +} + +.lista-checkboxes-sedes { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 160px; + overflow-y: auto; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + padding: 10px 14px; + margin-bottom: 4px; +} + +.checkbox-sede-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; +} + +.checkbox-sede-item input { width: 16px; height: 16px; accent-color: var(--salvia); cursor: pointer; } + +.lista-historial { display: flex; flex-direction: column; gap: 10px; max-height: 480px; overflow-y: auto; } + +/* ------------------------------------------------------------ + Verificación de versión del sistema (panel Desarrollador) + ------------------------------------------------------------ */ +.resumen-version { margin-bottom: 18px; } + +.aviso-version { + border-radius: var(--radio-chico); + padding: 14px 18px; + font-size: 0.92rem; + font-weight: 600; +} +.aviso-version-ok { background: var(--salvia-claro); color: var(--salvia-oscuro); } +.aviso-version-mal { background: var(--coral-claro); color: var(--coral); } + +.descripcion-version { + font-weight: 400; + font-size: 0.85rem; + margin-top: 6px; + opacity: 0.9; +} + +.lista-archivos-version { + display: flex; + flex-direction: column; + gap: 8px; +} + +.fila-archivo-version { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px; + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + font-size: 0.88rem; +} + +.ruta-archivo-version { + font-family: var(--fuente-mono); + color: var(--tinta); +} + +.estado-archivo-version { + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; +} +.estado-archivo-version.ok { color: var(--salvia-oscuro); } +.estado-archivo-version.mal { color: var(--coral); } + +/* ------------------------------------------------------------ + Reportes por sede (panel Desarrollador) + ------------------------------------------------------------ */ +.lista-reportes-sede { + display: flex; + flex-direction: column; + gap: 14px; +} + +.tarjeta-reporte-sede { + border: 1.5px solid var(--linea); + border-radius: var(--radio-chico); + padding: 18px 20px; +} + +.encabezado-reporte-sede { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} +.encabezado-reporte-sede svg { width: 20px; height: 20px; color: var(--salvia-oscuro); flex-shrink: 0; } +.nombre-sede-reporte { font-weight: 700; font-size: 1.02rem; color: var(--tinta); } + +.grilla-metricas-sede { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 14px; + margin-bottom: 12px; +} + +.metrica-sede { + display: flex; + flex-direction: column; + gap: 2px; +} +.metrica-sede .valor-metrica { font-family: var(--fuente-display); font-size: 1.6rem; font-weight: 700; color: var(--salvia-oscuro); } +.metrica-sede .etiqueta-metrica { font-size: 0.78rem; color: var(--tinta-suave); } + +.citas-mes-reporte-sede { + font-size: 0.85rem; + color: var(--tinta-suave); + padding-top: 10px; + border-top: 1px solid var(--linea); +} + +/* ------------------------------------------------------------ + Papelera global (panel Desarrollador) + ------------------------------------------------------------ */ +.fila-selectores-papelera { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 24px; +} + +@media (max-width: 600px) { + .fila-selectores-papelera { grid-template-columns: 1fr; } +} + +/* ------------------------------------------------------------ + Aviso de legajo recuperado de otro profesional + ------------------------------------------------------------ */ +.aviso-legajo-recuperado { + background: var(--arena-claro, #F5EBD2); + color: var(--tinta); + border-radius: var(--radio-chico); + padding: 12px 18px; + font-size: 0.88rem; + margin-bottom: 20px; +} + +.item-historial { + display: flex; + flex-direction: column; + gap: 3px; + padding: 12px 14px; + border-left: 2px solid var(--linea); + background: var(--crema); + border-radius: var(--radio-chico); +} +.item-historial-fecha { font-family: var(--fuente-mono); font-size: 0.72rem; color: var(--tinta-suave); } +.item-historial-texto { font-size: 0.88rem; } +.item-historial-texto strong { color: var(--salvia-oscuro); } + +.paginado-historial { display: flex; gap: 6px; justify-content: center; margin-top: 18px; flex-wrap: wrap; } +.btn-pagina { + width: 32px; height: 32px; + border-radius: 50%; + font-size: 0.82rem; + font-weight: 700; + background: var(--crema); + border: 1.5px solid var(--linea); + color: var(--tinta-suave); +} +.btn-pagina:hover { border-color: var(--salvia); } +.btn-pagina.activo { background: var(--salvia); color: white; border-color: var(--salvia); } + + +/* ============================================================ + RESPONSIVE + ============================================================ */ +@media (max-width: 900px) { + .grilla-menu { grid-template-columns: repeat(2, 1fr); } + .grilla-campos { grid-template-columns: 1fr; } + .grilla-datos { grid-template-columns: repeat(2, 1fr); } + .panel-resumen-menu { grid-template-columns: 1fr; } + .grilla-stats { grid-template-columns: repeat(2, 1fr); } + .fila-barra-stat { grid-template-columns: 110px 1fr 36px; } + .contenido { padding: 28px 18px 60px; } + .topbar { padding: 16px 18px; } +} + +@media (max-width: 480px) { + .tarjeta-acceso { padding: 32px 22px 28px; } + .input-pin-oculto { width: 140px; font-size: 1.2rem; } + .grilla-datos { grid-template-columns: 1fr; } + .grilla-menu { grid-template-columns: 1fr; } + .grilla-stats { grid-template-columns: 1fr; } + .fila-barra-stat { grid-template-columns: 90px 1fr 32px; gap: 8px; } + .barras-meses { gap: 4px; } + .tarjeta-paciente { flex-direction: column; align-items: stretch; } + .tarjeta-paciente .acciones-tarjeta { justify-content: flex-end; } + .celda-calendario { min-height: 36px; } + .celda-numero { font-size: 0.8rem; } +} diff --git a/assets/icons/favicon-32.png b/assets/icons/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..ff26adad2fad278cebed9b57a452c2b85e6d2b52 GIT binary patch literal 254 zcmV>a2CoZWCQvQ_It^cdtSZzX~{pt89&-K(Y z&50kAr}-xjk^){3!u|kLdwM4xjM1a~Gt(jPMgE|QeK&wu_C& zZwH-n6VYln^{`Zw4m@?}$$>WZ3o*Zfu9p5h*ZIc5*!I)^u+YWVe!RPKVY~eO|66M6 ze|`A%r|9~tz5Lb5htEm5y9R#s<*`-0(zEasw{%U`3PGof;wC?27N&5x#+`U@*{M_D z-S>SB50_6B+Pqzx;jn*pb$Y>Nv7`?#FHTdw_)7M`hs&#VH}BK}%DlgJ?!_Yp9{a@+ z#;d!T4F2?_y*zV|=fDT!RjQkJ|31%{kP^; z7(2d&GlbQrmF8`G%i!``q~YrPo3(Qbtr;%f1L~Rl=<%A{j2&tWi|)?%>$87SIlB@l z!{T7VDB#QBvb&hs#|VipjF6%s^Iv8y!wf?*7 z_qWHt=U)>y+!TE{&-}$~;L^Sc@XyRBS(U|v%T4OhN+xO@|hH7F_>-Ibl#D{ z1?p0n1Am?N!#vhqE0T2n@oD!tFKy#$drrtM_etWMl*FuI zyJTyhcAsPEQML=#&GkK+%pSW>mdv@8cZwlle)7`{y6mJteKyus7{zzs>3AFOCK<+Su6UNj2PT zTXyC7&0oJQC$ISLddal9PN*oiqpD)jl+$gu7^_k;jsw#d=K>>Zm;0ayC^_VJv5nzh zI)gE1Lk820gX;|KK&g#=$JXE*p0@;nY3zgJ*{MG+Wk>(LEp(vK?eFUu#?r5%71I)4 z{7z!OaY6wWs~=hy`-*`RDspN(z*ZMx#m_h=@jx-h1Ezlcbb$k+a`E%#>mOI0B@46= zm@+`=9b~t{t-r57pN@Uq-5?e>KmOetcw!NF`>*8J`?LjLTMtaUmv)a2lpo&OG8|V3 zs+m&?%n)|03E~N5@#%N?9JcId+Q4nFx_EOgyTYxT3~!juJiFu*e}~cK04N(?Ri3~h zad)j`8Nb7p*^D<>C9Y1o8M}qKqv4{?+?z+;6$A~sOgHa(CFj8MCGTy|x_z8tjC*ex z&EDO1)7<-Vvu*dMb@I)(cr$X}^St|4%piEdtMjI|)t){VnT1alMW=ohV=}#BksD&W zkE2m2OKvm2_5SWg)4pBXbE=rNx%`%Iv-kKZ-mRN`r+KxO46vwU@O1TaS?83{1OUew BG5i1k literal 0 HcmV?d00001 diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..f61d06b8b4181ad21b9d9c735bca4ba91b037de2 GIT binary patch literal 4037 zcmeHK`9q9*7eCL;n5Dfzrl^LpOQq1FW@sUiP$PvqwvsF`c2pPDxE;mLx6_n@*x7*eV%{?$MM!mB z#vQW50z_woQ0ImAky&8kwvc2UZH*czMS|zO?p5x$-8ojJu*1!JF_)(eMY?EXx@qb; zDw^Ol3mQ4RjqCTjruoqk;RT*9ZB+r0`-PbCfaleC7BL#25mB#li`pAa7tRmrBWy(z zEBbj)ilC3ef57u>oq!nC(h&5rC+|=Zk$Y}D0?T<@KPfAtnoa=GMj>PsMYKkdg4Wnq zKADPOaRrg~6;F$Zm_J%?U;fNSw(5zw9DjTq+NKJ8`p&C~vR6GhOs0W=62g=+h|yrw1i)~?i&J$tqA5aODUXCV~q zYcEukK{k|aY}UsiX7;JP0!!J7bE>gN8-SWM7g|P$0!FA;-#ajpBHT2q%mzJtW43H# zN>1etO8~XbF7W-V3jU1?E`9)D^KTLatt=?-YSi3|KgnteeEf=V_f?hemZ7i6&_2Nj zJ)03g*+E1coz@ZGC_@)cmZ4XF_RPa`&<9{vS3t&yD)@x%KPU6pAoFvKc)7b7oW zL>+QQ5&k)!%vKcu5*^EG1)jR zEAh2Bgs8!~B(FceClCi%p7>*MUs*<{enCj+l0YoE|JwV%;h(kopR9-YfEZz){)H8> zW#kPjax}0n9=f(zhmZ7!ikGqja>=6}%fRd;pfNhy()~1qzql9JGd+%vzK)zER40JN zE@OcmRG*RaLd)lnXx}90w3E|Fc`$E#t|Ew;z*j%2<#s|2(%=RmyH`r}=zc+ThyRi6!Z6t^ zKZ@3R4?6~2C6_B1A~9~myOYQJUJ8QqPcCn@C}KLb`5&<$K~XkCDatX@dS;3p2*_m3 z3=2fyY(Tknr6snC)~zn@rsvpa*Sj$`YxSv<4#~m8O z2At15Ciq?ibY{8M#7vz@MurGHoHm1b6of`(#oW9_!r-xHo@LDuLM~=$NjYa4nlKO#LdFg~Spy z=tH}0Zb>94Oa30e#T`6;F^j{sMA(bT>K^%ld=>(2N*>SgNh0qKbpD>XnTbAB2_2p` zSU7X0*v{24@tudj<70$&IUlN%3Rb!wFj&fxr$+7gsCAzPf9NY0VtG`J2UFL*oh}|# zC&S3q$K~IbJR}`y?zDj;G+^vx{rL>IzyfiU$b|S z0OP60;>F!GT?e9kRWCH5uaJ0QXHhFE&xR2w(H>10G)tNiH= z<>wY$&HKbM?mE*3fVhd+vt9uyU3 zXx`ahuR50wb`^cs^AZee16qdDtRqKr#Mxg`$`(F|*!b&m7&kqv?bE+n?j`#C=1N7k zg$<=uX}3$HQ|=QG=M^1>mlSO7J<$r@)RDY=w;I^F4Ba=@um8K#D?!TR4CP;&fjFav zypbfEkOO;-=YBs}=x{1EtMI%u?MwP@(H~7AmtDI=XR(Tvbz0emIk4u3^KHh-YiWjB zdU6*L(AkW*!J+7_^JP}FI6Y#+;LvbxM}2i!nrLU$f$I^kPxyF7$bF(3{Wi2a-6kjN zMb89!Juyb#=%qF z1E2SQEwcKxB5X5fc;mE-Koypq(Ac!R63Gt6=8)_(r9<`FQwH0cE7__9$a6f#wj!X@ zHCMn-og+J2HH`W%QF=YttFhr~Y<8GOgL69tggrCmEwb-^83EHY__*V$e|&cM+nk(N zvDsL#2=ho$n=!EtT4wxcDA3sEp|eXqkOloM6LUpL%fjV_!h`UQn?(Wz63n|1#HBKx zGi82!QcyZnsY3>T%VWK3OstEXZk}mV^~PYgI|PfQQ)2;*Qd-BINC1txX=b*HJLqsW z-s1VEG|Ryx9k`s3+qm%_|afi`xYugD&PTnL@7k5(>o?Ju1B{>az3b} z*^Zfh9{g8T$YA^eNz?xSDqbxGix6!}#$T>P_|V~*vEiO!-f@teEEXlpg#KN={{CH^4X zH|aM@6r4iAxw#|lAA6my5UdKYJH+xWE>?fUl<2`jOs!^by7=LTtiy!nc7T+&x>d3e z`^e~K0JrAQ%$DzIb+$qu1EB8U{wSHi#Ki1;7!$Kr6Ce-)u5xVU9+~uQgA7Fg|4>hk z483L?9`Jl^rb!bXFvJ4h6F{&jr`!`q?^+KH;{Y|^bEy>1sQR7EdHV-maX}zp#EHHa zL=MIKERceSF~ekg^9~A|1Oj9CiWbjU93%eAuTC!am7xHjdU=m#a~O_cdsun;Brb<( z08O0>*B9dDdmu{(zeap`Kx7htd-1hN*03I62X~eEwm_YJ6_g5S$&`gAs-ZMz>^Z|R zNeRclYT#Dcb!AkGk1g>#%+yF(BKXB-Yb13A3F7v(&zmAi1gs@**;fhLA{BzgG(fLb z7!5~J1YM>jCjiweUodckiV2O&;|Wk$ZrXFhJkBxs&xj!_2?O{EJo0FxsU;+OE?Em1 z#c3vcUx3-AcUD`WF9o}oWl;i4^$_4J7!a~922zAC6=u@01`hD7qa(f+>Hf=*=h-jQ z+~2Vc59xzo6-P_zRrVe>x&LiTbgsQ^;$3?5&mD^aRp)3+y&QF=)2D(!oue!Dnyzuc zfeO_&36CQ=yNwf%0T{SU#>|rfFms08Uzj=M0B(*=vHnsuP2&(|_Fer#$U5cz-eVgu zJ({W?O(^JdTn0?Xe$2{3_Mbq|3rZvPNzs-hL~ub#`q|uk4V?u33;L4Z?pVlFfS8>P ky&_WJ3>C;RZXcFRdPPg^$f`}i`X9i-&dK)9GVYOo1A}c(F#rGn literal 0 HcmV?d00001 diff --git a/assets/icons/icon-apple-touch.png b/assets/icons/icon-apple-touch.png new file mode 100644 index 0000000000000000000000000000000000000000..d6ee50dc2a47c9be634d692d40d4ba5929e1acf9 GIT binary patch literal 1208 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6V5#?XaSW-L^Y)H)nQ){?+r>%Y z{@qhL1tuMqP$+-VIiV_{H;EZ<&CSg#y^yqQs_xshTv2DXY}-!mst|*^TJj? zQr@(1Z% zRa>P0T*5Wvf@$r)e{bKFo=#fhe}P@NG`302%jd#kzeA?>d8V(PUvu)g@coNS-+V=# z^jQmL9u9Gw-SShZNpG*#o!=GD%X@6gC+WV=?vGPfdQt5*Y&XozFkMQ{IQr@H8X7bxxyM~uHO0dggN&bIh;Gy1jN3sdn+@m2F}^X+fVx~*S$ z%zaj$CJV6n4EZb>@@fz#v-2`)i_S=B{t~w-%gK zfg}{Y6Mc8@U2(k57x3ZuY^xW_G2LNjN;!Tp?EN?83{Trmi&BtpSI6=lgwav(8Uu>0sy#Yf#MH|z2#$0@TNXJ7jAEN*hS`j!+UUd_0^8Pb^FVFr@-Lb*PfZ#pH|Gus$8-)w(!^93Yo<77w&wu z*^=p-Sv^}zY)R%>xutE%8^gJLv*kiJ%{{DSvJqGyFh-=V>YAOp^0UUh6kmZKGEr+5 b{%3kJA*QB0?;)^2Vqoxe^>bP0l+XkKWgtc4 literal 0 HcmV?d00001 diff --git a/assets/icons/icon-maskable-512.png b/assets/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..58f838ecafd141bb491123e1e54173e6408745d5 GIT binary patch literal 3729 zcmeHK>0eV<6P|l>1%fC6Y>R*_QmaysU{M4Gf>MQAS(MVsQi2L)39F*4(?wOf0Gv~~6o*Z^@ z+B8pDOBn#>IXc*H1;CMrgOURJ8Hs){0Wkk7M|(TB=&Tn)Yj^F<+6P@bx&lwER9hdZ znVh|L#6ByQ-73hH(2ecNA9%lZ&#tMf8g#z&$J2G*>aA6@s;h~y@h=kQf#JlP&K~cf zVD9(4D$G!|F>_5twsytI62*MS1(!Sh6Rx+@(i|D_cm9Z}Z>QaHWK?(g>!jJRw9FW~ zkfNvW8L4!mCj6|bz%tN`@oL9BTiE>b0l|prUm{v~dh-T$%Lm_#VUMq0Hed>mu9U!l zZyAHzeN*#W@Zc^XQWDit^hn)}s!6`4Z6cb>wR{87Iq@8?x-pu|&3vA@8$T^J18ZRZ z6)0dXg$^NlSR4RH3)+RCgl*VUIsTyHM3P=mV{B$=#c4d_%A^csb2{|)lv>wukp zk>~zA;HT$XaMHvrc5~hxIytP!-g~;N&>q~b8bI-RBVLm=Tx?Z>fOFSJqvnyy767rm zHAzA|PdrzVY>N%`Jp)WBh&B~%;pn3Mq3wA|`WBjCj3$UFu=r&=XePqgSGNl|>eOOp zApiR+MYIVS<-zGG#Mlz7-6}4uH&iCAjtdPG`^-^+((=s^J)ua34Dc`TWac3+N(y)i zlw$&?ro@H@khFR(I8Q-wkxBX93@U}jTkgEL-PC>o!AD=bUI?JHXPYdHO{lQ_5|t9m zr<_w#4xr!o1=ilD2SF8kGqM3#c@O1kr9V%mPOo}Y{pmy*KLycjNOQ_s3`iEblDM}D)K3Ak?08u#9e}XXh8!uzo_}Vw zkW$sDAh{4OQH9->i8R3^idcTXU?18biI@)K zK-FTJnyd;q>quw!+XT=yv%XYD!f`bgG;|_A{D0Q|{qSzD{_V$XKYZeRpt!Xt?8!ap z>(nznx4y*F1_z~m!+i0lpjTxs8KPtCG~*X>Y5QiP15k@k>@!+A<5PCePQS z&)7rbgd)kKANz-MB=2{&K9|B|1P)6d&3`#O6-{HTGb_G zDPxctq7c-5Vs|3jhMFuoJPjG&W!1T@aFVuL9d1m#eoM9V0{FLRFjBT8cC zdIb$Qf`vS71LBPm{8Xx=)2ZP0ksKJLkwKUxYS<;_*n5STIvsXsYLQb2IZ$f^$L?lZ zxmh31kM$PV5YU)CMvfAorlKP7#Nqm3!-fy#F+R(}-6|9u76bR|A>Y0?PZX&W*{}oh z*UP(ms^b9Bq9zN;T;+U_9-h$WX^?#ZPdI)gI=GwjM6pv~_m3ypQV(+@AHHByf0qz$c%!Qw^BQlIOOf z(k(O$nY!|HRW7$=?Bn-iD>Pt_A2Yw9X$hSGFE&xwS$j>BfUF1|vz?=ryTuI&$rA(! z7tS>re(j))vFN93``oW)ZuaW5*;wsgkqLQe%1`1y_NwzZT~zs!Ut{qX|N8p`oEw?= zNFsX4fx_ZvIqBhHmwfaF?7+q8cGxY$wEM;v+xm|*5hjS3VBvTBEV`_v_c+0|eYVx> zCV#C92^g~do8JT%2C{IYdlW9ci#bd&29YV+MG6D&g?0%v3j9^LqPEl`um6F z1Hx9%`8{bBdKb{M#AG;+3)~4X-W5L;uE(DLUyh59^(37`j$8p5BX1x3V2-mOr#8ui z4%|C+w_mL&(Pyz&$X&P(GMxuX^#gcTVu0&X2US@|!J3lb9;(aq=uWRGd5; z!`?NU^344M^6pC?F#@xuk>UN#^T6b#+V1gutNY}oxizv^|%VD1f-uwi2oH;>R|u#6O{_$g zS1nW^^J2B1pXtD59wa7dYX%_1c2kGPWFZC+57(iLBOj- literal 0 HcmV?d00001 diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..4f93a90 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,3346 @@ +/* ============================================================ + DEL AUSTRAL · Lógica principal de la aplicación + ============================================================ */ + +const API = { + auth: 'api/auth.php', + pacientes: 'api/pacientes.php', + obrasSociales: 'api/obras_sociales.php', + citas: 'api/citas.php', + adjuntos: 'api/adjuntos.php', + plantillas: 'api/plantillas.php', + admin: 'api/admin.php', +}; + +let CACHE_OBRAS_SOCIALES = []; +let CACHE_PLANTILLAS = []; +let TIPO_BUSQUEDA_ACTUAL = 'dni'; +let VISTA_BUSQUEDA_ORIGEN = 'acceder'; // 'acceder' | 'borrar', para volver bien desde el detalle +let MODO_FORM_LEGAJO = 'crear'; // 'crear' | 'editar' +let MES_AGENDA_ACTUAL = new Date(); // mes que se muestra en el calendario +let DIA_SELECCIONADO_AGENDA = null; // 'YYYY-MM-DD' +let ARCHIVO_PENDIENTE_SUBIR = null; +let PACIENTE_ID_ACTUAL_DETALLE = null; +let ROL_ACTUAL = null; // 'profesional' | 'administrativa' +let NOMBRE_USUARIO_ACTUAL = null; + +document.addEventListener('DOMContentLoaded', () => { + inicializarAcceso(); + vincularBotonesGlobales(); + registrarServiceWorker(); +}); + +/** + * Registra el service worker mínimo que permite "instalar" el + * sitio como app. Si el navegador no lo soporta (algunos viejos + * o ciertos modos privados), simplemente no pasa nada — el + * sistema sigue funcionando igual, solo no se podría instalar. + */ +function registrarServiceWorker() { + if (!('serviceWorker' in navigator)) return; + navigator.serviceWorker.register('sw.js').catch(() => { + // Si falla el registro (ej. el sitio no está en HTTPS), + // no rompemos nada: la app sigue funcionando como sitio web normal. + }); +} + +/* ============================================================ + TOASTS + ============================================================ */ +function mostrarToast(mensaje, tipo = 'info') { + const cont = document.getElementById('toast-contenedor'); + const toast = document.createElement('div'); + toast.className = `toast ${tipo}`; + toast.textContent = mensaje; + cont.appendChild(toast); + setTimeout(() => toast.remove(), 4200); +} + +/** + * Descarga un archivo desde una URL del propio backend (que + * responde con Content-Disposition: attachment), sin abrir + * pestañas nuevas. Usamos fetch + blob en vez de window.open + * porque los navegadores suelen bloquear popups que disparan + * una descarga inmediata, fallando en silencio sin avisar nada. + * Esta forma también nos deja mostrar un error legible si la + * descarga falla (por ejemplo, sesión vencida o error del server). + */ +async function descargarArchivoDesdeUrl(url, nombrePorDefecto = 'descarga.json') { + try { + const respuesta = await fetch(url, { method: 'GET' }); + if (!respuesta.ok) { + let mensaje = 'No se pudo generar la descarga.'; + try { + const datos = await respuesta.json(); + if (datos && datos.error) mensaje = datos.error; + } catch (_) { + // La respuesta de error no era JSON; nos quedamos con el mensaje genérico. + } + throw new Error(mensaje); + } + + const disposicion = respuesta.headers.get('Content-Disposition') || ''; + const coincidencia = disposicion.match(/filename="?([^"]+)"?/); + const nombreArchivo = coincidencia ? coincidencia[1] : nombrePorDefecto; + + const blob = await respuesta.blob(); + const urlObjeto = URL.createObjectURL(blob); + const enlace = document.createElement('a'); + enlace.href = urlObjeto; + enlace.download = nombreArchivo; + document.body.appendChild(enlace); + enlace.click(); + enlace.remove(); + URL.revokeObjectURL(urlObjeto); + } catch (e) { + mostrarToast(e.message || 'No se pudo descargar el archivo.', 'error'); + } +} + +/* ============================================================ + PETICIONES A LA API + ============================================================ */ +async function llamarApi(url, opciones = {}) { + try { + const respuesta = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...opciones, + }); + const datos = await respuesta.json(); + if (!respuesta.ok || !datos.ok) { + throw new Error(datos.error || 'Ocurrió un error inesperado.'); + } + return datos; + } catch (err) { + throw err; + } +} + +/* ============================================================ + ACCESO CON PIN — flujo multi-paso + ============================================================ */ +const LARGO_PIN = 4; +let ETAPA_SISTEMA = 'listo'; // 'sin_desarrollador' | 'sin_sedes_o_usuarios' | 'listo' +let SEDE_ELEGIDA_ID = null; +let SEDE_ELEGIDA_NOMBRE = null; +let USUARIO_ELEGIDO_ID = null; +let USUARIO_ELEGIDO_ROL = null; +let PROFESIONAL_ACTIVO_ELEGIDO_ID = null; + +/** + * Motor genérico de captura de PIN: conecta un oculto con + * sus puntitos indicadores, y llama el callback al completar los + * 4 dígitos. Se reutiliza para los 3 inputs de PIN distintos que + * hay en el flujo de acceso. + */ +function crearCapturadorPin(idInput, idIndicadores, alCompletar) { + let valorActual = ''; + const input = document.getElementById(idInput); + + function actualizarIndicadores() { + const puntos = document.querySelectorAll(`#${idIndicadores} .punto-pin`); + puntos.forEach((p, i) => { + p.classList.toggle('relleno', i < valorActual.length); + p.classList.remove('error'); + }); + } + + function marcarError() { + document.querySelectorAll(`#${idIndicadores} .punto-pin`).forEach(p => p.classList.add('error')); + } + + function limpiar() { + valorActual = ''; + input.value = ''; + input.focus(); + actualizarIndicadores(); + } + + input.addEventListener('input', () => { + valorActual = input.value.replace(/\D/g, '').slice(0, LARGO_PIN); + input.value = valorActual; + actualizarIndicadores(); + if (valorActual.length === LARGO_PIN) { + alCompletar(valorActual); + } + }); + + return { limpiar, marcarError, focus: () => input.focus() }; +} + +let CAPTURADOR_PIN_LOGIN = null; +let CAPTURADOR_PIN_DEV_CREAR = null; +let CAPTURADOR_CLAVE_DEV = null; + +async function inicializarAcceso() { + try { + const res = await llamarApi(`${API.auth}?accion=estado`, { + method: 'POST', + body: JSON.stringify({ accion: 'estado' }), + }); + ETAPA_SISTEMA = res.etapa; + } catch (e) { + const t = document.getElementById('texto-instruccion'); + t.classList.remove('oculto'); + t.textContent = 'No se pudo conectar con el servidor. Revisá config.php.'; + t.classList.add('error'); + return; + } + + vincularPasosLogin(); + + if (ETAPA_SISTEMA === 'sin_desarrollador') { + mostrarPasoLogin('paso-crear-desarrollador'); + } else { + // Tanto si faltan sedes/usuarios como si está todo listo, el + // punto de partida visible es "elegir sede" — desde ahí hay un + // link para entrar como Desarrollador si hace falta configurar. + cargarSedesLogin(); + mostrarPasoLogin('paso-elegir-sede'); + } +} + +function mostrarPasoLogin(idPaso) { + document.querySelectorAll('.paso-login').forEach(p => p.classList.add('oculto')); + document.getElementById(idPaso).classList.remove('oculto'); +} + +function vincularPasosLogin() { + // --- Crear clave de desarrollador (primera vez) --- + CAPTURADOR_PIN_DEV_CREAR = crearCapturadorPin('input-pin-dev-crear', 'indicadores-pin-dev-crear', async (clave) => { + try { + await llamarApi(API.auth, { method: 'POST', body: JSON.stringify({ accion: 'crear_desarrollador', clave }) }); + mostrarVistaSetupInicial(); + } catch (e) { + mostrarToast(e.message, 'error'); + CAPTURADOR_PIN_DEV_CREAR.marcarError(); + setTimeout(() => CAPTURADOR_PIN_DEV_CREAR.limpiar(), 600); + } + }); + + // --- Acceso discreto al rol Desarrollador --- + document.getElementById('btn-acceso-mantenimiento').addEventListener('click', () => { + mostrarPasoLogin('paso-clave-desarrollador'); + CAPTURADOR_CLAVE_DEV.focus(); + }); + + // --- Elegir usuario dentro de la sede --- + document.getElementById('btn-volver-sede').addEventListener('click', () => mostrarPasoLogin('paso-elegir-sede')); + + // --- Elegir profesional activo (administrativa) --- + document.getElementById('btn-volver-usuario-desde-prof').addEventListener('click', () => mostrarPasoLogin('paso-elegir-usuario')); + + // --- Ingresar PIN normal --- + document.getElementById('btn-volver-usuario').addEventListener('click', () => { + mostrarPasoLogin(USUARIO_ELEGIDO_ROL === 'administrativa' ? 'paso-elegir-profesional-activo' : 'paso-elegir-usuario'); + }); + document.getElementById('btn-reintentar-patron').addEventListener('click', () => { + document.getElementById('btn-reintentar-patron').classList.add('oculto'); + CAPTURADOR_PIN_LOGIN.limpiar(); + }); + CAPTURADOR_PIN_LOGIN = crearCapturadorPin('input-pin-oculto', 'indicadores-pin', manejarPinLoginCompletado); + + // --- Clave de desarrollador (login normal) --- + document.getElementById('btn-volver-sede-desde-dev').addEventListener('click', () => mostrarPasoLogin('paso-elegir-sede')); + CAPTURADOR_CLAVE_DEV = crearCapturadorPin('input-clave-dev', 'indicadores-pin-dev', async (clave) => { + try { + const res = await llamarApi(API.auth, { method: 'POST', body: JSON.stringify({ accion: 'verificar_desarrollador', clave }) }); + entrarAlApp(res.nombre_usuario, res.rol); + } catch (e) { + mostrarToast(e.message, 'error'); + CAPTURADOR_CLAVE_DEV.marcarError(); + setTimeout(() => CAPTURADOR_CLAVE_DEV.limpiar(), 600); + } + }); + + // --- Setup inicial (sede + profesional, justo después de crear desarrollador) --- + document.getElementById('btn-crear-setup-inicial').addEventListener('click', crearSetupInicial); + document.getElementById('btn-ir-panel-dev').addEventListener('click', () => entrarAlApp('Desarrollador', 'desarrollador')); +} + +function mostrarVistaSetupInicial() { + document.getElementById('vista-acceso').classList.add('oculto'); + document.getElementById('vista-setup-inicial').classList.remove('oculto'); + document.getElementById('btn-acceso-mantenimiento').classList.add('oculto'); +} + +async function crearSetupInicial() { + const nombreSede = document.getElementById('input-primera-sede').value.trim(); + const nombreProfesional = document.getElementById('input-primer-profesional').value.trim(); + const pin = document.getElementById('input-pin-primer-profesional').value.trim(); + const btn = document.getElementById('btn-crear-setup-inicial'); + + if (!nombreSede || !nombreProfesional) { + mostrarToast('Completá el nombre de la sede y del profesional.', 'error'); + return; + } + if (!/^\d{4}$/.test(pin)) { + mostrarToast('El PIN del profesional debe tener 4 números.', 'error'); + return; + } + + btn.disabled = true; + btn.innerHTML = ' Creando...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ accion: 'crear_setup_inicial', nombre_sede: nombreSede, nombre_profesional: nombreProfesional, pin }), + }); + mostrarToast('Sede y profesional creados. Ya podés entrar al panel.', 'exito'); + entrarAlApp('Desarrollador', 'desarrollador'); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Crear sede y profesional'; + } +} + +async function cargarSedesLogin() { + const cont = document.getElementById('lista-sedes-login'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_sedes_login`, { method: 'POST', body: JSON.stringify({ accion: 'listar_sedes_login' }) }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = ''; + return; + } + res.datos.forEach(s => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'opcion-login'; + btn.textContent = s.nombre; + btn.addEventListener('click', () => { + SEDE_ELEGIDA_ID = s.id; + SEDE_ELEGIDA_NOMBRE = s.nombre; + cargarUsuariosLogin(s.id); + mostrarPasoLogin('paso-elegir-usuario'); + }); + cont.appendChild(btn); + }); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +async function cargarUsuariosLogin(sedeId) { + const cont = document.getElementById('lista-usuarios-login'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_usuarios_sede_login`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_usuarios_sede_login', sede_id: sedeId }), + }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = ''; + return; + } + res.datos.forEach(u => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'opcion-login'; + const etiquetaRol = u.rol === 'profesional' ? 'Profesional' : 'Administrativa'; + btn.innerHTML = `${escaparHtml(u.nombre_completo)} `; + btn.addEventListener('click', () => { + USUARIO_ELEGIDO_ID = u.id; + USUARIO_ELEGIDO_ROL = u.rol; + if (u.rol === 'administrativa') { + cargarProfesionalesLogin(sedeId); + mostrarPasoLogin('paso-elegir-profesional-activo'); + } else { + PROFESIONAL_ACTIVO_ELEGIDO_ID = null; + irAPasoPin(u.nombre_completo); + } + }); + cont.appendChild(btn); + }); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +async function cargarProfesionalesLogin(sedeId) { + const cont = document.getElementById('lista-profesionales-login'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_profesionales_sede_login`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_profesionales_sede_login', sede_id: sedeId }), + }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = ''; + return; + } + res.datos.forEach(p => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'opcion-login'; + btn.textContent = p.nombre_completo; + btn.addEventListener('click', () => { + PROFESIONAL_ACTIVO_ELEGIDO_ID = p.id; + irAPasoPin(`Administrativa de ${p.nombre_completo}`); + }); + cont.appendChild(btn); + }); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function irAPasoPin(textoContexto) { + document.getElementById('texto-instruccion-pin').textContent = `Ingresá tu PIN — ${textoContexto}`; + mostrarPasoLogin('paso-ingresar-pin'); + setTimeout(() => CAPTURADOR_PIN_LOGIN.focus(), 50); +} + +async function manejarPinLoginCompletado(pin) { + try { + const res = await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ + accion: 'verificar', + sede_id: SEDE_ELEGIDA_ID, + usuario_id: USUARIO_ELEGIDO_ID, + pin, + profesional_activo_id: PROFESIONAL_ACTIVO_ELEGIDO_ID, + }), + }); + entrarAlApp(res.nombre_usuario, res.rol); + } catch (e) { + mostrarToast(e.message, 'error'); + CAPTURADOR_PIN_LOGIN.marcarError(); + document.getElementById('btn-reintentar-patron').classList.remove('oculto'); + setTimeout(() => CAPTURADOR_PIN_LOGIN.limpiar(), 600); + } +} + +function entrarAlApp(nombreUsuario, rol) { + ROL_ACTUAL = rol; + NOMBRE_USUARIO_ACTUAL = nombreUsuario; + document.getElementById('vista-acceso').classList.add('oculto'); + document.getElementById('vista-setup-inicial').classList.add('oculto'); + document.getElementById('vista-app').classList.remove('oculto'); + document.getElementById('btn-acceso-mantenimiento').classList.add('oculto'); + irAVista(rol === 'desarrollador' ? 'configuracion' : 'menu'); + mostrarCartelBienvenida(nombreUsuario); +} + +function mostrarCartelBienvenida(nombreUsuario) { + const modal = document.getElementById('modal-bienvenida'); + const texto = document.getElementById('texto-bienvenida'); + texto.textContent = nombreUsuario + ? `Bienvenido/a, ${nombreUsuario}` + : '¡Bienvenido/a!'; + modal.classList.remove('oculto'); + + const cerrar = () => modal.classList.add('oculto'); + document.getElementById('btn-cerrar-bienvenida').onclick = cerrar; +} + +/** + * Oculta del DOM cualquier elemento marcado con data-rol="profesional" + * cuando el usuario logueado es administrativa. Se llama después de + * montar cada vista que pueda tener este tipo de elementos. + */ +function aplicarVisibilidadPorRol(contenedor) { + if (ROL_ACTUAL === 'profesional') return; + contenedor.querySelectorAll('[data-rol="profesional"]').forEach(el => el.remove()); +} + +function vincularBotonesGlobales() { + document.getElementById('btn-inicio').addEventListener('click', () => irAVista(ROL_ACTUAL === 'desarrollador' ? 'configuracion' : 'menu')); + document.getElementById('btn-cerrar-sesion').addEventListener('click', async () => { + await llamarApi(`${API.auth}`, { method: 'POST', body: JSON.stringify({ accion: 'cerrar_sesion' }) }); + location.reload(); + }); +} + +/* ============================================================ + NAVEGACIÓN ENTRE VISTAS + ============================================================ */ +function irAVista(nombre, datos = {}) { + const contenido = document.getElementById('contenido'); + contenido.innerHTML = ''; + + if (nombre === 'menu') { + contenido.appendChild(clonarPlantilla('tpl-menu')); + aplicarVisibilidadPorRol(contenido); + adaptarTextosMenuSegunRol(contenido); + contenido.querySelectorAll('.tarjeta-menu').forEach(btn => { + btn.addEventListener('click', () => irAVista(btn.dataset.vista)); + }); + cargarResumenMenu(); + } else if (nombre === 'crear') { + MODO_FORM_LEGAJO = 'crear'; + montarVistaCrear(contenido); + } else if (nombre === 'editar') { + MODO_FORM_LEGAJO = 'editar'; + montarVistaCrear(contenido, datos.id); + } else if (nombre === 'acceder') { + VISTA_BUSQUEDA_ORIGEN = 'acceder'; + montarVistaBusqueda(contenido, 'acceder'); + } else if (nombre === 'borrar') { + VISTA_BUSQUEDA_ORIGEN = 'borrar'; + montarVistaBusqueda(contenido, 'borrar'); + } else if (nombre === 'detalle') { + montarVistaDetalle(contenido, datos.id); + } else if (nombre === 'agenda') { + montarVistaAgenda(contenido); + } else if (nombre === 'dashboard') { + montarVistaDashboard(contenido); + } else if (nombre === 'mi-legajo') { + montarVistaMiLegajo(contenido); + } else if (nombre === 'configuracion') { + montarVistaConfiguracion(contenido); + } + + contenido.querySelectorAll('[data-volver]').forEach(b => b.addEventListener('click', () => irAVista(ROL_ACTUAL === 'desarrollador' ? 'configuracion' : 'menu'))); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +/** + * Ajusta textos del menú principal y de algunas tarjetas para que + * tengan sentido cuando quien entra es la administrativa (no ve + * contenido clínico, así que el copy no debe prometerlo). + */ +function adaptarTextosMenuSegunRol(contenido) { + if (ROL_ACTUAL === 'profesional') return; + + const titulo = contenido.querySelector('#titulo-menu'); + const desc = contenido.querySelector('#desc-menu'); + if (titulo) titulo.textContent = `¿Qué necesitás hacer, ${NOMBRE_USUARIO_ACTUAL || ''}?`; + if (desc) desc.textContent = 'Gestioná turnos y datos de contacto de los pacientes.'; + + const descCrear = contenido.querySelector('#desc-crear-legajo'); + if (descCrear) descCrear.textContent = 'Registrá los datos de contacto de un paciente nuevo para poder agendarlo.'; + + const descAcceder = contenido.querySelector('#desc-acceder-legajos'); + if (descAcceder) descAcceder.textContent = 'Buscá por DNI, nombre, fecha de atención u obra social para ver sus datos de contacto.'; +} + +function clonarPlantilla(idPlantilla) { + const tpl = document.getElementById(idPlantilla); + const clon = tpl.content.cloneNode(true); + const envoltorio = document.createElement('div'); + envoltorio.appendChild(clon); + return envoltorio; +} + +/* ============================================================ + UTILIDADES DE FECHA + ============================================================ */ +function formatearFechaCorta(fechaIso) { + if (!fechaIso) return '—'; + return new Date(fechaIso + 'T00:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function formatearFechaLarga(fechaIso) { + if (!fechaIso) return '—'; + return new Date(fechaIso + 'T00:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: 'long', year: 'numeric' }); +} + +function aFechaIso(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/* ============================================================ + RESUMEN DEL MENÚ PRINCIPAL (agenda + inactivos) + ============================================================ */ +async function cargarResumenMenu() { + cargarResumenCitas(); + cargarResumenCumpleanios(); + if (ROL_ACTUAL === 'profesional') { + cargarResumenInactivos(); + cargarResumenHoy(); + cargarAvisosTurnos(); + } +} + +async function cargarResumenHoy() { + const franja = document.getElementById('franja-resumen-hoy'); + if (!franja) return; + try { + const res = await llamarApi(`${API.citas}?accion=resumen_hoy`, { method: 'GET' }); + if (res.total_hoy === 0) { + franja.classList.add('oculto'); + return; + } + const horaTexto = res.proxima_hora ? ` — la próxima es a las ${res.proxima_hora.slice(0, 5)}` : ''; + if (res.restantes_hoy === 0) { + franja.innerHTML = `✅ Ya atendiste todas tus consultas de hoy (${res.total_hoy} en total).`; + } else { + franja.innerHTML = `Hoy tenés ${res.restantes_hoy} consulta${res.restantes_hoy === 1 ? '' : 's'} por delante de ${res.total_hoy}${horaTexto}.`; + } + franja.classList.remove('oculto'); + } catch (e) { + franja.classList.add('oculto'); + } +} + +async function cargarAvisosTurnos() { + const boton = document.getElementById('franja-avisos-turnos'); + if (!boton) return; + try { + const res = await llamarApi(`${API.citas}?accion=avisos_pendientes`, { method: 'GET' }); + if (res.total === 0) { + boton.classList.add('oculto'); + return; + } + boton.innerHTML = `🔔 Tenés ${res.total} novedad${res.total === 1 ? '' : 'es'} de turnos (confirmaciones o cancelaciones) — tocá para ver`; + boton.classList.remove('oculto'); + boton.onclick = abrirModalAvisosTurnos; + } catch (e) { + boton.classList.add('oculto'); + } +} + +async function abrirModalAvisosTurnos() { + const modalEnv = clonarPlantilla('tpl-modal-avisos-turnos'); + document.body.appendChild(modalEnv); + const cont = document.getElementById('lista-avisos-turnos'); + cont.innerHTML = '
'; + + try { + const res = await llamarApi(`${API.citas}?accion=listar_avisos`, { method: 'GET' }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

No hay novedades.

'; + } else { + res.datos.forEach(c => { + const item = document.createElement('div'); + item.className = 'item-resumen'; + const fechaTexto = `${formatearFechaCorta(c.fecha)}${c.hora ? ' · ' + c.hora.slice(0, 5) : ''}`; + let estadoTexto; + if (c.estado === 'cancelada') { + estadoTexto = 'Canceló el turno'; + } else if (c.confirmada_por_paciente === 1) { + estadoTexto = 'Confirmó el turno'; + } else { + estadoTexto = 'Sin cambios de estado'; + } + item.innerHTML = ` + ${fechaTexto} + ${escaparHtml(c.nombre + ' ' + c.apellido)} + ${estadoTexto} + `; + cont.appendChild(item); + }); + } + } catch (e) { + cont.innerHTML = '

No se pudieron cargar las novedades.

'; + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-marcar-avisos-vistos').addEventListener('click', async () => { + try { + await llamarApi(`${API.citas}?accion=marcar_avisos_vistos`, { method: 'POST', body: JSON.stringify({}) }); + cerrar(); + document.getElementById('franja-avisos-turnos').classList.add('oculto'); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); +} + +async function cargarResumenCitas() { + const cont = document.getElementById('lista-resumen-citas'); + if (!cont) return; + try { + const res = await llamarApi(`${API.citas}?accion=proximas&dias=7`, { method: 'GET' }); + if (!res.datos.length) { + cont.innerHTML = '

No hay citas agendadas para los próximos 7 días.

'; + return; + } + cont.innerHTML = ''; + res.datos.slice(0, 6).forEach(c => { + const item = document.createElement('button'); + item.className = 'item-resumen'; + item.type = 'button'; + const esHoy = c.fecha === aFechaIso(new Date()); + item.innerHTML = ` + ${esHoy ? 'Hoy' : formatearFechaCorta(c.fecha)}${c.hora ? ' · ' + c.hora.slice(0,5) : ''} + ${escaparHtml(c.nombre + ' ' + c.apellido)} + `; + item.addEventListener('click', () => irAVista('detalle', { id: c.paciente_id })); + cont.appendChild(item); + }); + } catch (e) { + cont.innerHTML = '

No se pudo cargar la agenda.

'; + } +} + +async function cargarResumenCumpleanios() { + const cont = document.getElementById('lista-resumen-cumple'); + if (!cont) return; + try { + const res = await llamarApi(`${API.citas}?accion=cumpleanios&dias=14`, { method: 'GET' }); + if (!res.datos.length) { + cont.innerHTML = '

No hay cumpleaños en los próximos 14 días.

'; + return; + } + cont.innerHTML = ''; + res.datos.slice(0, 6).forEach(p => { + const item = document.createElement('button'); + item.className = 'item-resumen'; + item.type = 'button'; + const fechaIso = `${new Date().getFullYear()}-${p.mes_dia}`; + const esHoy = aFechaIso(new Date()).slice(5) === p.mes_dia; + item.innerHTML = ` + ${esHoy ? 'Hoy' : formatearFechaCorta(fechaIso)} · ${p.edad_que_cumple} años + ${escaparHtml(p.nombre + ' ' + p.apellido)} + `; + item.addEventListener('click', () => irAVista('detalle', { id: p.id })); + cont.appendChild(item); + }); + } catch (e) { + cont.innerHTML = '

No se pudo cargar la información.

'; + } +} + +async function cargarResumenInactivos() { + const cont = document.getElementById('lista-resumen-inactivos'); + if (!cont) return; + try { + const res = await llamarApi(`${API.citas}?accion=inactivos&dias=30`, { method: 'GET' }); + if (!res.datos.length) { + cont.innerHTML = '

Todos tus pacientes tuvieron sesiones en los últimos 30 días.

'; + return; + } + cont.innerHTML = ''; + res.datos.slice(0, 6).forEach(p => { + const item = document.createElement('button'); + item.className = 'item-resumen'; + item.type = 'button'; + const texto = p.ultima_sesion + ? `Última sesión: ${formatearFechaCorta(p.ultima_sesion)}` + : 'Sin sesiones registradas'; + item.innerHTML = ` + ${texto} + ${escaparHtml(p.nombre + ' ' + p.apellido)} + `; + item.addEventListener('click', () => irAVista('detalle', { id: p.id })); + cont.appendChild(item); + }); + } catch (e) { + cont.innerHTML = '

No se pudo cargar la información.

'; + } +} + +/* ============================================================ + OBRAS SOCIALES (carga + selector + alta rápida) + ============================================================ */ +async function cargarObrasSociales() { + const res = await llamarApi(API.obrasSociales, { method: 'GET' }); + CACHE_OBRAS_SOCIALES = res.datos; + return CACHE_OBRAS_SOCIALES; +} + +function poblarSelectObrasSociales(select, valorSeleccionado = null) { + select.innerHTML = ''; + CACHE_OBRAS_SOCIALES.forEach(o => { + const opt = document.createElement('option'); + opt.value = o.id; + opt.textContent = o.nombre; + if (valorSeleccionado && Number(valorSeleccionado) === Number(o.id)) opt.selected = true; + select.appendChild(opt); + }); +} + +function abrirModalAgregarObra(callbackAlAgregar) { + const modalEnv = clonarPlantilla('tpl-modal-obra-social'); + document.body.appendChild(modalEnv); + const input = document.getElementById('input-nueva-obra'); + input.focus(); + + function cerrar() { modalEnv.remove(); } + + document.getElementById('btn-cancelar-obra').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-obra').addEventListener('click', async () => { + const nombre = input.value.trim(); + if (!nombre) { mostrarToast('Escribí un nombre para la obra social.', 'error'); return; } + try { + const res = await llamarApi(API.obrasSociales, { + method: 'POST', + body: JSON.stringify({ nombre }), + }); + await cargarObrasSociales(); + mostrarToast(res.ya_existia ? 'Esa obra social ya existía, la seleccionamos.' : 'Obra social agregada.', 'exito'); + cerrar(); + if (callbackAlAgregar) callbackAlAgregar(res.id); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') document.getElementById('btn-confirmar-obra').click(); + }); +} + +/* ============================================================ + VISTA: CREAR / EDITAR LEGAJO (comparten el mismo formulario) + ============================================================ */ +async function montarVistaCrear(contenido, idEditar = null) { + contenido.appendChild(clonarPlantilla('tpl-crear')); + + const esEdicion = MODO_FORM_LEGAJO === 'editar'; + + document.getElementById('titulo-form-crear').textContent = esEdicion ? 'Editar legajo' : 'Crear legajo nuevo'; + document.getElementById('desc-form-crear').textContent = esEdicion + ? 'Actualizá los datos del paciente. Las sesiones se gestionan desde su ficha.' + : 'Completá los datos del paciente. Podés agregar sesiones más adelante desde su ficha.'; + document.getElementById('btn-guardar-legajo').textContent = esEdicion ? 'Guardar cambios' : 'Guardar legajo'; + + if (esEdicion) { + document.getElementById('panel-sesiones-iniciales').classList.add('oculto'); + } else { + document.getElementById('btn-abrir-aviso-legal').addEventListener('click', abrirModalAvisoLegal); + if (!sessionStorage.getItem('aviso_legal_visto')) { + abrirModalAvisoLegal(); + sessionStorage.setItem('aviso_legal_visto', '1'); + } + } + + if (ROL_ACTUAL !== 'profesional') { + document.getElementById('panel-cuadro-clinico').classList.add('oculto'); + document.getElementById('panel-sesiones-iniciales').classList.add('oculto'); + } + + await cargarObrasSociales(); + poblarSelectObrasSociales(document.getElementById('select-obra-social')); + + document.getElementById('btn-agregar-obra').addEventListener('click', () => { + abrirModalAgregarObra((nuevoId) => { + poblarSelectObrasSociales(document.getElementById('select-obra-social'), nuevoId); + }); + }); + + document.getElementById('input-fecha-nac').addEventListener('change', (e) => { + const edad = calcularEdadDesde(e.target.value); + document.getElementById('input-edad-calculada').value = edad !== null ? `${edad} años` : ''; + }); + + const listaSesiones = document.getElementById('lista-sesiones-form'); + document.getElementById('btn-agregar-dia').addEventListener('click', () => { + const item = clonarPlantilla('tpl-sesion-form-item').firstElementChild; + listaSesiones.appendChild(item); + item.querySelector('.quitar-sesion').addEventListener('click', () => item.remove()); + }); + + // Si es edición, precargamos los datos del paciente + if (esEdicion && idEditar) { + const form = document.getElementById('form-crear'); + try { + const res = await llamarApi(`${API.pacientes}?accion=detalle&id=${idEditar}`, { method: 'GET' }); + const p = res.datos; + form.querySelector('[name="id"]').value = p.id; + form.querySelector('[name="nombre"]').value = p.nombre || ''; + form.querySelector('[name="apellido"]').value = p.apellido || ''; + form.querySelector('[name="dni"]').value = p.dni || ''; + form.querySelector('[name="sexo"]').value = p.sexo || ''; + form.querySelector('[name="fecha_nacimiento"]').value = (p.fecha_nacimiento || '').slice(0, 10); + document.getElementById('input-edad-calculada').value = p.edad !== null ? `${p.edad} años` : ''; + form.querySelector('[name="telefono"]').value = p.telefono || ''; + form.querySelector('[name="email"]').value = p.email || ''; + form.querySelector('[name="direccion"]').value = p.direccion || ''; + poblarSelectObrasSociales(document.getElementById('select-obra-social'), p.obra_social_id); + form.querySelector('[name="numero_afiliado"]').value = p.numero_afiliado || ''; + form.querySelector('[name="motivo_consulta"]').value = p.motivo_consulta || ''; + form.querySelector('[name="patologia"]').value = p.patologia || ''; + form.querySelector('[name="sintomas"]').value = p.sintomas || ''; + form.querySelector('[name="observaciones_generales"]').value = p.observaciones_generales || ''; + } catch (e) { + mostrarToast(e.message, 'error'); + irAVista('menu'); + return; + } + } + + document.getElementById('form-crear').addEventListener('submit', async (ev) => { + ev.preventDefault(); + const btnGuardar = document.getElementById('btn-guardar-legajo'); + const form = ev.target; + const datos = Object.fromEntries(new FormData(form).entries()); + + if (!esEdicion) { + const sesiones = []; + listaSesiones.querySelectorAll('.sesion-form-item').forEach(item => { + sesiones.push({ + fecha_sesion: item.querySelector('.campo-fecha-sesion').value, + proxima_cita: item.querySelector('.campo-proxima-cita').value || null, + descripcion: item.querySelector('.campo-descripcion-sesion').value, + evolucion: item.querySelector('.campo-evolucion-sesion').value || null, + }); + }); + datos.sesiones = sesiones; + } + + btnGuardar.disabled = true; + btnGuardar.innerHTML = ' Guardando...'; + try { + if (esEdicion) { + await llamarApi(`${API.pacientes}?accion=actualizar`, { + method: 'POST', + body: JSON.stringify(datos), + }); + mostrarToast('Legajo actualizado correctamente.', 'exito'); + irAVista('detalle', { id: datos.id }); + } else { + await llamarApi(`${API.pacientes}?accion=crear`, { + method: 'POST', + body: JSON.stringify(datos), + }); + mostrarToast('Legajo creado correctamente.', 'exito'); + irAVista('menu'); + } + } catch (e) { + mostrarToast(e.message, 'error'); + btnGuardar.disabled = false; + btnGuardar.textContent = esEdicion ? 'Guardar cambios' : 'Guardar legajo'; + } + }); +} + +function abrirModalAvisoLegal() { + const modalEnv = clonarPlantilla('tpl-modal-aviso-legal'); + document.body.appendChild(modalEnv); + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cerrar-aviso-legal').addEventListener('click', cerrar); + document.getElementById('btn-cerrar-aviso-legal-x').addEventListener('click', cerrar); + modalEnv.querySelector('.overlay-modal').addEventListener('click', (ev) => { + if (ev.target.classList.contains('overlay-modal')) cerrar(); + }); +} + +function calcularEdadDesde(fechaIso) { + if (!fechaIso) return null; + const nacimiento = new Date(fechaIso); + const hoy = new Date(); + let edad = hoy.getFullYear() - nacimiento.getFullYear(); + const aunNoCumple = (hoy.getMonth() < nacimiento.getMonth()) || + (hoy.getMonth() === nacimiento.getMonth() && hoy.getDate() < nacimiento.getDate()); + if (aunNoCumple) edad--; + return edad; +} + +/* ============================================================ + VISTA: BUSCAR (acceder / borrar comparten estructura) + ============================================================ */ +async function montarVistaBusqueda(contenido, modo) { + const idPlantilla = modo === 'acceder' ? 'tpl-acceder' : 'tpl-borrar'; + contenido.appendChild(clonarPlantilla(idPlantilla)); + TIPO_BUSQUEDA_ACTUAL = 'dni'; + + if (modo === 'borrar') { + document.getElementById('btn-ver-papelera').addEventListener('click', abrirPapelera); + } + + await cargarObrasSociales(); + + const tabs = contenido.parentElement.querySelectorAll('.tab-busqueda'); + tabs.forEach(tab => { + tab.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('activo')); + tab.classList.add('activo'); + TIPO_BUSQUEDA_ACTUAL = tab.dataset.tipo; + renderizarFilaBuscador(modo); + document.getElementById('resultados-busqueda').innerHTML = ''; + }); + }); + + renderizarFilaBuscador(modo); +} + +function renderizarFilaBuscador(modo) { + const fila = document.getElementById('fila-buscador'); + fila.innerHTML = ''; + + if (TIPO_BUSQUEDA_ACTUAL === 'dni') { + fila.innerHTML = ` + `; + } else if (TIPO_BUSQUEDA_ACTUAL === 'nombre') { + fila.innerHTML = ` + `; + } else if (TIPO_BUSQUEDA_ACTUAL === 'fecha') { + fila.innerHTML = ` + + + `; + } else if (TIPO_BUSQUEDA_ACTUAL === 'obra_social') { + fila.innerHTML = ` + `; + poblarSelectObrasSociales(document.getElementById('input-busqueda-obra')); + } else if (TIPO_BUSQUEDA_ACTUAL === 'sede') { + fila.innerHTML = ` + `; + poblarSelectSedesBusqueda(document.getElementById('input-busqueda-sede')); + } + + document.getElementById('btn-buscar').addEventListener('click', () => ejecutarBusqueda(modo)); + + fila.querySelectorAll('input').forEach(inp => { + inp.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') ejecutarBusqueda(modo); }); + }); +} + +async function poblarSelectSedesBusqueda(select) { + select.innerHTML = ''; + try { + const res = await llamarApi(`${API.pacientes}?accion=sedes_disponibles`, { method: 'GET' }); + select.innerHTML = ''; + res.datos.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.nombre; + select.appendChild(opt); + }); + } catch (e) { + select.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +async function ejecutarBusqueda(modo) { + const params = new URLSearchParams({ accion: 'buscar', tipo: TIPO_BUSQUEDA_ACTUAL }); + + if (TIPO_BUSQUEDA_ACTUAL === 'dni') { + params.set('valor', document.getElementById('input-busqueda-dni').value.trim()); + } else if (TIPO_BUSQUEDA_ACTUAL === 'nombre') { + params.set('valor', document.getElementById('input-busqueda-nombre').value.trim()); + } else if (TIPO_BUSQUEDA_ACTUAL === 'fecha') { + const desde = document.getElementById('input-busqueda-desde').value; + const hasta = document.getElementById('input-busqueda-hasta').value; + if (desde) params.set('desde', desde); + if (hasta) params.set('hasta', hasta); + } else if (TIPO_BUSQUEDA_ACTUAL === 'obra_social') { + params.set('obra_social_id', document.getElementById('input-busqueda-obra').value); + } else if (TIPO_BUSQUEDA_ACTUAL === 'sede') { + params.set('sede_id', document.getElementById('input-busqueda-sede').value); + } + + const contRes = document.getElementById('resultados-busqueda'); + contRes.innerHTML = '
Buscando...
'; + + try { + const res = await llamarApi(`${API.pacientes}?${params.toString()}`, { method: 'GET' }); + renderizarResultados(res.datos, modo); + } catch (e) { + contRes.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function renderizarResultados(lista, modo) { + const contRes = document.getElementById('resultados-busqueda'); + contRes.innerHTML = ''; + + if (!lista.length) { + const vacio = document.createElement('div'); + vacio.className = 'estado-vacio'; + vacio.innerHTML = ` + +

No encontramos resultados

+

Probá con otro criterio de búsqueda o revisá que el dato esté bien escrito.

`; + contRes.appendChild(vacio); + return; + } + + const contenedorLista = document.createElement('div'); + contenedorLista.className = 'lista-resultados'; + + lista.forEach(p => { + const tarjeta = clonarPlantilla('tpl-tarjeta-paciente').firstElementChild; + tarjeta.dataset.id = p.id; + tarjeta.querySelector('.avatar-iniciales').textContent = (p.nombre[0] + p.apellido[0]).toUpperCase(); + tarjeta.querySelector('.nombre').textContent = `${p.apellido}, ${p.nombre}`; + tarjeta.querySelector('.meta').innerHTML = `DNI ${p.dni} · ${p.edad} años${p.sede_nombre ? ' · ' + escaparHtml(p.sede_nombre) : ''} · `; + + const etiqueta = document.createElement('span'); + etiqueta.className = 'etiqueta' + (p.obra_social_nombre && p.obra_social_nombre.includes('Particular') ? ' particular' : ''); + etiqueta.textContent = p.obra_social_nombre || 'Sin obra social'; + tarjeta.querySelector('.meta').appendChild(etiqueta); + + const acciones = tarjeta.querySelector('.acciones-tarjeta'); + + if (modo === 'acceder') { + const btnVer = document.createElement('button'); + btnVer.className = 'btn-icono'; + btnVer.title = 'Ver legajo'; + btnVer.innerHTML = ``; + btnVer.addEventListener('click', () => irAVista('detalle', { id: p.id })); + acciones.appendChild(btnVer); + } else if (modo === 'borrar') { + const btnBorrar = document.createElement('button'); + btnBorrar.className = 'btn-icono peligro'; + btnBorrar.title = 'Eliminar legajo'; + btnBorrar.innerHTML = ``; + btnBorrar.addEventListener('click', () => confirmarEliminarLegajo(p.id, `${p.nombre} ${p.apellido}`, modo)); + acciones.appendChild(btnBorrar); + } + + contenedorLista.appendChild(tarjeta); + }); + + contRes.appendChild(contenedorLista); +} + +function confirmarEliminarLegajo(id, nombreCompleto) { + const modalEnv = clonarPlantilla('tpl-modal-confirmar-borrado'); + document.body.appendChild(modalEnv); + document.getElementById('texto-confirmar-borrado').textContent = + `Vas a eliminar el legajo de ${nombreCompleto} del sistema activo. Quedará guardado en la base histórica, pero no aparecerá más en las búsquedas normales.`; + + const inputConfirmar = document.getElementById('input-confirmar-borrado'); + const btnConfirmar = document.getElementById('btn-confirmar-borrado'); + inputConfirmar.focus(); + + inputConfirmar.addEventListener('input', () => { + btnConfirmar.disabled = inputConfirmar.value.trim().toUpperCase() !== 'ELIMINAR'; + }); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-borrado').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-borrado').addEventListener('click', async () => { + if (inputConfirmar.value.trim().toUpperCase() !== 'ELIMINAR') return; + btnConfirmar.disabled = true; + btnConfirmar.innerHTML = ' Eliminando...'; + try { + await llamarApi(`${API.pacientes}?accion=eliminar`, { + method: 'POST', + body: JSON.stringify({ id }), + }); + mostrarToast('Legajo eliminado del sistema activo.', 'exito'); + cerrar(); + ejecutarBusqueda('borrar'); + } catch (e) { + mostrarToast(e.message, 'error'); + btnConfirmar.disabled = false; + btnConfirmar.textContent = 'Sí, eliminar definitivamente'; + } + }); +} + +/* ============================================================ + BASE HISTÓRICA (papelera) + ============================================================ */ +async function abrirPapelera() { + const modalEnv = clonarPlantilla('tpl-papelera'); + document.body.appendChild(modalEnv); + document.getElementById('btn-cerrar-papelera').addEventListener('click', () => modalEnv.remove()); + + const lista = document.getElementById('lista-papelera'); + lista.innerHTML = '
Cargando...
'; + + try { + const res = await llamarApi(`${API.pacientes}?accion=papelera`, { method: 'GET' }); + lista.innerHTML = ''; + if (!res.datos.length) { + lista.innerHTML = '

Todavía no eliminaste ningún legajo.

'; + return; + } + res.datos.forEach(item => { + const fecha = new Date(item.eliminado_en).toLocaleDateString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + fila.innerHTML = ` +
+
${item.nombre_completo.split(' ').map(p => p[0]).slice(0,2).join('').toUpperCase()}
+
+
${item.nombre_completo}
+
DNI ${item.dni} · Eliminado el ${fecha}
+
+
`; + lista.appendChild(fila); + }); + } catch (e) { + lista.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +/* ============================================================ + VISTA: DETALLE DE PACIENTE + ============================================================ */ +async function montarVistaDetalle(contenido, id) { + contenido.innerHTML = '
Cargando legajo...
'; + PACIENTE_ID_ACTUAL_DETALLE = id; + + let p; + try { + const res = await llamarApi(`${API.pacientes}?accion=detalle&id=${id}`, { method: 'GET' }); + p = res.datos; + } catch (e) { + contenido.innerHTML = ''; + mostrarToast(e.message, 'error'); + irAVista('menu'); + return; + } + + contenido.innerHTML = ''; + contenido.appendChild(clonarPlantilla('tpl-detalle-paciente')); + + document.querySelector('[data-volver-busqueda]').addEventListener('click', () => irAVista(VISTA_BUSQUEDA_ORIGEN)); + + document.getElementById('detalle-avatar').textContent = (p.nombre[0] + p.apellido[0]).toUpperCase(); + document.getElementById('detalle-nombre').textContent = `${p.nombre} ${p.apellido}`; + document.getElementById('detalle-meta').textContent = `Paciente desde ${new Date(p.creado_en).toLocaleDateString('es-AR')}`; + + document.getElementById('dato-dni').textContent = p.dni; + document.getElementById('dato-edad').textContent = `${p.edad} años`; + document.getElementById('dato-sexo').textContent = p.sexo; + document.getElementById('dato-obra').textContent = p.obra_social_nombre || 'Sin especificar'; + document.getElementById('dato-sede').textContent = p.sede_nombre || 'Sin asignar'; + + const avisoRecuperado = document.getElementById('aviso-legajo-recuperado'); + if (p.recuperado_de_profesional) { + avisoRecuperado.innerHTML = `📋 Este legajo perteneció antes a ${escaparHtml(p.recuperado_de_profesional)} y fue recuperado de la papelera.`; + avisoRecuperado.classList.remove('oculto'); + } else { + avisoRecuperado.classList.add('oculto'); + } + + const esProfesionalActual = ROL_ACTUAL === 'profesional'; + + if (esProfesionalActual) { + rellenarTextoOVacio('dato-motivo', p.motivo_consulta); + rellenarTextoOVacio('dato-patologia', p.patologia); + rellenarTextoOVacio('dato-sintomas', p.sintomas); + rellenarTextoOVacio('dato-observaciones', p.observaciones_generales); + + const linea = document.getElementById('linea-tiempo'); + linea.innerHTML = ''; + if (!p.sesiones || !p.sesiones.length) { + linea.innerHTML = '

Todavía no se registraron sesiones para este paciente.

'; + } else { + p.sesiones.forEach(s => { + const item = document.createElement('div'); + item.className = 'sesion-item'; + const fecha = new Date(s.fecha_sesion + 'T00:00:00').toLocaleDateString('es-AR', { day: '2-digit', month: 'long', year: 'numeric' }); + item.innerHTML = ` +
+
${fecha}
+
+ + +
+
+
${escaparHtml(s.descripcion)}
+ ${s.evolucion ? `
${escaparHtml(s.evolucion)}
` : ''} + `; + item.querySelector('[data-accion="editar-sesion"]').addEventListener('click', () => abrirModalEditarSesion(s, p.id)); + item.querySelector('[data-accion="eliminar-sesion"]').addEventListener('click', () => confirmarEliminarSesion(s.id, fecha, p.id)); + linea.appendChild(item); + }); + } + + document.getElementById('btn-nueva-sesion').addEventListener('click', () => abrirModalNuevaSesion(p.id)); + document.getElementById('btn-editar-legajo').addEventListener('click', () => irAVista('editar', { id: p.id })); + document.getElementById('btn-exportar-pdf').addEventListener('click', () => { + window.open(`exportar.php?id=${p.id}`, '_blank'); + }); + document.getElementById('btn-migrar-sede').addEventListener('click', () => abrirModalMigrarSede(p.id, `${p.nombre} ${p.apellido}`, p.sede_nombre)); + cargarAdjuntosPaciente(p.id); + vincularSubidaAdjunto(p.id); + } else { + // La administrativa no ve contenido clínico: se ocultan los + // bloques de motivo/patología/síntomas/observaciones, el historial + // de sesiones y los adjuntos, junto con las acciones que los tocan. + document.querySelectorAll('.bloque-texto').forEach(el => el.remove()); + document.getElementById('btn-editar-legajo').remove(); + document.getElementById('btn-exportar-pdf').remove(); + document.getElementById('btn-migrar-sede').remove(); + const panelSesiones = document.getElementById('linea-tiempo').closest('.panel'); + if (panelSesiones) panelSesiones.remove(); + const panelAdjuntos = document.getElementById('lista-adjuntos').closest('.panel'); + if (panelAdjuntos) panelAdjuntos.remove(); + } + + document.getElementById('btn-agendar-cita-paciente').addEventListener('click', () => abrirModalNuevaCita(p.id, `${p.nombre} ${p.apellido}`)); + + cargarCitasPaciente(p.id, p.nombre, p.apellido, p.telefono); +} + +async function abrirModalMigrarSede(pacienteId, nombrePaciente, sedeActualNombre) { + const modalEnv = clonarPlantilla('tpl-modal-migrar-sede'); + document.body.appendChild(modalEnv); + document.getElementById('texto-paciente-migrar-sede').textContent = + `${nombrePaciente} está actualmente en "${sedeActualNombre || 'sin sede asignada'}". Elegí la nueva sede.`; + + const select = document.getElementById('select-nueva-sede'); + select.innerHTML = ''; + try { + const res = await llamarApi(`${API.pacientes}?accion=sedes_disponibles`, { method: 'GET' }); + select.innerHTML = ''; + res.datos.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.nombre; + select.appendChild(opt); + }); + } catch (e) { + select.innerHTML = ''; + mostrarToast(e.message, 'error'); + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-migrar-sede').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-migrar-sede').addEventListener('click', async () => { + const sedeId = select.value; + if (!sedeId) { mostrarToast('Elegí una sede.', 'error'); return; } + try { + await llamarApi(`${API.pacientes}?accion=migrar_sede`, { + method: 'POST', + body: JSON.stringify({ id: pacienteId, sede_id: sedeId }), + }); + mostrarToast('Paciente migrado correctamente.', 'exito'); + cerrar(); + irAVista('detalle', { id: pacienteId }); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); +} + +function rellenarTextoOVacio(idElemento, texto) { + const el = document.getElementById(idElemento); + if (texto && texto.trim() !== '') { + el.textContent = texto; + el.classList.remove('vacio'); + } else { + el.textContent = 'No se registró información.'; + el.classList.add('vacio'); + } +} + +function escaparHtml(texto) { + const div = document.createElement('div'); + div.textContent = texto; + return div.innerHTML; +} + +/* ============================================================ + PLANTILLAS DE EVOLUCIÓN + ============================================================ */ +async function cargarPlantillas() { + const res = await llamarApi(API.plantillas, { method: 'GET' }); + CACHE_PLANTILLAS = res.datos; + return CACHE_PLANTILLAS; +} + +function poblarSelectPlantillas(select) { + select.innerHTML = ''; + CACHE_PLANTILLAS.forEach(pl => { + const opt = document.createElement('option'); + opt.value = pl.id; + opt.textContent = pl.nombre; + select.appendChild(opt); + }); +} + +function abrirModalGestionarPlantillas(alCerrarCallback) { + const modalEnv = clonarPlantilla('tpl-modal-gestionar-plantillas'); + document.body.appendChild(modalEnv); + + function cerrar() { + modalEnv.remove(); + if (alCerrarCallback) alCerrarCallback(); + } + + function renderizarLista() { + const lista = document.getElementById('lista-plantillas'); + lista.innerHTML = ''; + if (!CACHE_PLANTILLAS.length) { + lista.innerHTML = '

Todavía no creaste ninguna plantilla.

'; + return; + } + CACHE_PLANTILLAS.forEach(pl => { + const item = document.createElement('div'); + item.className = 'item-plantilla'; + item.innerHTML = ` +
+
${escaparHtml(pl.nombre)}
+
${escaparHtml(pl.contenido)}
+
+ + `; + item.querySelector('.btn-borrar-plantilla').addEventListener('click', async () => { + try { + await llamarApi(`${API.plantillas}?accion=eliminar`, { method: 'POST', body: JSON.stringify({ id: pl.id }) }); + await cargarPlantillas(); + renderizarLista(); + mostrarToast('Plantilla eliminada.', 'exito'); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + lista.appendChild(item); + }); + } + + renderizarLista(); + + document.getElementById('btn-guardar-plantilla').addEventListener('click', async () => { + const nombre = document.getElementById('input-nombre-plantilla').value.trim(); + const texto = document.getElementById('input-contenido-plantilla').value.trim(); + if (!nombre || !texto) { + mostrarToast('Completá el nombre y el contenido de la plantilla.', 'error'); + return; + } + try { + await llamarApi(`${API.plantillas}?accion=crear`, { + method: 'POST', + body: JSON.stringify({ nombre, contenido: texto }), + }); + await cargarPlantillas(); + renderizarLista(); + document.getElementById('input-nombre-plantilla').value = ''; + document.getElementById('input-contenido-plantilla').value = ''; + mostrarToast('Plantilla guardada.', 'exito'); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + + document.getElementById('btn-cerrar-gestionar-plantillas').addEventListener('click', cerrar); +} + +/* ============================================================ + SESIONES (agregar nueva, con plantillas) + ============================================================ */ +async function abrirModalNuevaSesion(pacienteId) { + const modalEnv = clonarPlantilla('tpl-modal-nueva-sesion'); + document.body.appendChild(modalEnv); + + await cargarPlantillas(); + const selectPlantilla = document.getElementById('select-plantilla-sesion'); + poblarSelectPlantillas(selectPlantilla); + + const inputDescripcion = document.getElementById('input-descripcion-nueva-sesion'); + selectPlantilla.addEventListener('change', () => { + const plantilla = CACHE_PLANTILLAS.find(pl => String(pl.id) === selectPlantilla.value); + if (plantilla) { + inputDescripcion.value = plantilla.contenido; + } + }); + + document.getElementById('btn-gestionar-plantillas').addEventListener('click', () => { + abrirModalGestionarPlantillas(() => { + poblarSelectPlantillas(selectPlantilla); + }); + }); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-nueva-sesion').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-nueva-sesion').addEventListener('click', async () => { + const fecha = document.getElementById('input-fecha-nueva-sesion').value; + const descripcion = inputDescripcion.value.trim(); + const proxima = document.getElementById('input-proxima-nueva-sesion').value || null; + const evolucion = document.getElementById('input-evolucion-nueva-sesion').value.trim() || null; + + if (!fecha || !descripcion) { + mostrarToast('Completá la fecha y la descripción de la sesión.', 'error'); + return; + } + try { + await llamarApi(`${API.pacientes}?accion=agregar_sesion`, { + method: 'POST', + body: JSON.stringify({ paciente_id: pacienteId, fecha_sesion: fecha, descripcion, proxima_cita: proxima, evolucion }), + }); + mostrarToast('Sesión agregada correctamente.', 'exito'); + cerrar(); + irAVista('detalle', { id: pacienteId }); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); +} + +function abrirModalEditarSesion(sesion, pacienteId) { + const modalEnv = clonarPlantilla('tpl-modal-editar-sesion'); + document.body.appendChild(modalEnv); + + document.getElementById('input-fecha-editar-sesion').value = sesion.fecha_sesion.slice(0, 10); + document.getElementById('input-descripcion-editar-sesion').value = sesion.descripcion; + document.getElementById('input-evolucion-editar-sesion').value = sesion.evolucion || ''; + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-editar-sesion').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-editar-sesion').addEventListener('click', async () => { + const fecha = document.getElementById('input-fecha-editar-sesion').value; + const descripcion = document.getElementById('input-descripcion-editar-sesion').value.trim(); + const evolucion = document.getElementById('input-evolucion-editar-sesion').value.trim() || null; + + if (!fecha || !descripcion) { + mostrarToast('Completá la fecha y la descripción de la sesión.', 'error'); + return; + } + + const btn = document.getElementById('btn-confirmar-editar-sesion'); + btn.disabled = true; + btn.innerHTML = ' Guardando...'; + try { + await llamarApi(`${API.pacientes}?accion=editar_sesion`, { + method: 'POST', + body: JSON.stringify({ id: sesion.id, fecha_sesion: fecha, descripcion, evolucion }), + }); + mostrarToast('Sesión actualizada.', 'exito'); + cerrar(); + irAVista('detalle', { id: pacienteId }); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Guardar cambios'; + } + }); +} + +function confirmarEliminarSesion(sesionId, fechaTexto, pacienteId) { + if (!confirm(`¿Eliminar la sesión del ${fechaTexto}? Esta acción no se puede deshacer.`)) return; + llamarApi(`${API.pacientes}?accion=eliminar_sesion`, { + method: 'POST', + body: JSON.stringify({ id: sesionId }), + }).then(() => { + mostrarToast('Sesión eliminada.', 'exito'); + irAVista('detalle', { id: pacienteId }); + }).catch(e => { + mostrarToast(e.message, 'error'); + }); +} + +/* ============================================================ + CITAS DENTRO DE LA FICHA DEL PACIENTE + ============================================================ */ +async function cargarCitasPaciente(pacienteId, nombrePaciente, apellidoPaciente, telefonoPaciente) { + const cont = document.getElementById('lista-citas-paciente'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.citas}?accion=por_paciente&paciente_id=${pacienteId}`, { method: 'GET' }); + cont.innerHTML = ''; + const pendientes = res.datos.filter(c => c.estado === 'pendiente'); + if (!pendientes.length) { + cont.innerHTML = '

No hay citas pendientes agendadas.

'; + return; + } + pendientes.forEach(c => cont.appendChild(crearFilaCita(c, pacienteId, nombrePaciente, apellidoPaciente, telefonoPaciente))); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +/** + * Genera un link de WhatsApp (wa.me) con un mensaje de recordatorio + * pre-armado. No envía nada automáticamente: abre WhatsApp con el + * texto listo para que el profesional/administrativa lo revise y + * lo mande con un toque. + */ +function generarLinkWhatsapp(telefono, nombrePaciente, fechaIso, hora, tokenConfirmacion) { + if (!telefono) return null; + const telefonoLimpio = telefono.replace(/[^\d+]/g, ''); + const fechaTexto = formatearFechaLarga(fechaIso); + const horaTexto = hora ? ` a las ${hora.slice(0, 5)}` : ''; + let mensaje = `Hola ${nombrePaciente}, te recuerdo tu turno del ${fechaTexto}${horaTexto}. ¡Te esperamos!`; + if (tokenConfirmacion) { + const linkConfirmacion = `${location.origin}${location.pathname.replace(/index\.html$/, '')}confirmar_turno.php?token=${tokenConfirmacion}`; + mensaje += `\n\nPodés confirmar o cancelar tu turno acá: ${linkConfirmacion}`; + } + return `https://wa.me/${telefonoLimpio}?text=${encodeURIComponent(mensaje)}`; +} + +function copiarLinkConfirmacion(token) { + const link = `${location.origin}${location.pathname.replace(/index\.html$/, '')}confirmar_turno.php?token=${token}`; + navigator.clipboard.writeText(link) + .then(() => mostrarToast('Link copiado al portapapeles.', 'exito')) + .catch(() => mostrarToast('No se pudo copiar el link. Copialo manualmente: ' + link, 'error')); +} + +function crearFilaCita(c, pacienteId, nombrePaciente, apellidoPaciente, telefonoPaciente) { + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + fila.innerHTML = ` +
+
+ +
+
+
${formatearFechaLarga(c.fecha)}${c.hora ? ' · ' + c.hora.slice(0,5) : ''}
+
${escaparHtml(c.motivo || 'Sin motivo especificado')}
+
+
+
+ `; + const acciones = fila.querySelector('.acciones-tarjeta'); + + const linkWhatsapp = generarLinkWhatsapp(telefonoPaciente, nombrePaciente, c.fecha, c.hora, c.token_confirmacion); + if (linkWhatsapp) { + const btnWhatsapp = document.createElement('a'); + btnWhatsapp.className = 'btn-icono whatsapp'; + btnWhatsapp.title = 'Enviar recordatorio por WhatsApp'; + btnWhatsapp.href = linkWhatsapp; + btnWhatsapp.target = '_blank'; + btnWhatsapp.innerHTML = ``; + acciones.appendChild(btnWhatsapp); + } + + if (c.token_confirmacion) { + const btnCopiarLink = document.createElement('button'); + btnCopiarLink.className = 'btn-icono'; + btnCopiarLink.title = 'Copiar link de confirmación'; + btnCopiarLink.innerHTML = ``; + btnCopiarLink.addEventListener('click', () => copiarLinkConfirmacion(c.token_confirmacion)); + acciones.appendChild(btnCopiarLink); + } + + const btnAtendida = document.createElement('button'); + btnAtendida.className = 'btn-icono'; + btnAtendida.title = 'Marcar como atendida'; + btnAtendida.innerHTML = ``; + btnAtendida.addEventListener('click', () => cambiarEstadoCita(c.id, 'atendida', pacienteId)); + + const btnCancelar = document.createElement('button'); + btnCancelar.className = 'btn-icono peligro'; + btnCancelar.title = 'Cancelar cita'; + btnCancelar.innerHTML = ``; + btnCancelar.addEventListener('click', () => cambiarEstadoCita(c.id, 'cancelada', pacienteId)); + + acciones.appendChild(btnAtendida); + acciones.appendChild(btnCancelar); + return fila; +} + +async function cambiarEstadoCita(citaId, estado, pacienteId) { + try { + await llamarApi(`${API.citas}?accion=cambiar_estado`, { + method: 'POST', + body: JSON.stringify({ id: citaId, estado }), + }); + mostrarToast(estado === 'atendida' ? 'Cita marcada como atendida.' : 'Cita cancelada.', 'exito'); + if (pacienteId) cargarCitasPaciente(pacienteId); + } catch (e) { + mostrarToast(e.message, 'error'); + } +} + +function abrirModalNuevaCita(pacienteIdFijo = null, nombreFijo = null) { + const modalEnv = clonarPlantilla('tpl-modal-nueva-cita'); + document.body.appendChild(modalEnv); + + const inputBuscar = document.getElementById('input-buscar-paciente-cita'); + const inputPacienteId = document.getElementById('input-paciente-id-cita'); + const resultadosBox = document.getElementById('resultados-buscar-paciente-cita'); + + if (pacienteIdFijo) { + inputBuscar.value = nombreFijo; + inputBuscar.disabled = true; + inputPacienteId.value = pacienteIdFijo; + } else { + let timeoutBusqueda = null; + inputBuscar.addEventListener('input', () => { + clearTimeout(timeoutBusqueda); + const valor = inputBuscar.value.trim(); + inputPacienteId.value = ''; + if (valor.length < 2) { resultadosBox.innerHTML = ''; return; } + timeoutBusqueda = setTimeout(() => buscarPacientesParaCita(valor, resultadosBox, inputBuscar, inputPacienteId), 300); + }); + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-nueva-cita').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-nueva-cita').addEventListener('click', async () => { + const pacienteId = inputPacienteId.value; + const fecha = document.getElementById('input-fecha-cita').value; + const hora = document.getElementById('input-hora-cita').value || null; + const motivo = document.getElementById('input-motivo-cita').value.trim() || null; + + if (!pacienteId) { mostrarToast('Elegí un paciente de la lista.', 'error'); return; } + if (!fecha) { mostrarToast('Elegí una fecha para la cita.', 'error'); return; } + + try { + await llamarApi(`${API.citas}?accion=crear`, { + method: 'POST', + body: JSON.stringify({ paciente_id: pacienteId, fecha, hora, motivo }), + }); + mostrarToast('Cita agendada correctamente.', 'exito'); + cerrar(); + if (PACIENTE_ID_ACTUAL_DETALLE && String(PACIENTE_ID_ACTUAL_DETALLE) === String(pacienteId)) { + cargarCitasPaciente(pacienteId); + } + if (document.getElementById('calendario-grilla')) { + renderizarCalendario(); + } + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); +} + +async function buscarPacientesParaCita(valor, resultadosBox, inputBuscar, inputPacienteId) { + try { + const esNumerico = /^\d+$/.test(valor); + const tipo = esNumerico ? 'dni' : 'nombre'; + const res = await llamarApi(`${API.pacientes}?accion=buscar&tipo=${tipo}&valor=${encodeURIComponent(valor)}`, { method: 'GET' }); + resultadosBox.innerHTML = ''; + if (!res.datos.length) { + resultadosBox.innerHTML = '
No se encontraron pacientes.
'; + return; + } + res.datos.slice(0, 6).forEach(p => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'item-autocompletar'; + item.textContent = `${p.apellido}, ${p.nombre} — DNI ${p.dni}`; + item.addEventListener('click', () => { + inputBuscar.value = `${p.apellido}, ${p.nombre}`; + inputPacienteId.value = p.id; + resultadosBox.innerHTML = ''; + }); + resultadosBox.appendChild(item); + }); + } catch (e) { + resultadosBox.innerHTML = ''; + } +} + +/* ============================================================ + ARCHIVOS ADJUNTOS + ============================================================ */ +async function cargarAdjuntosPaciente(pacienteId) { + const cont = document.getElementById('lista-adjuntos'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.adjuntos}?accion=listar&paciente_id=${pacienteId}`, { method: 'GET' }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

No hay archivos adjuntos todavía.

'; + return; + } + res.datos.forEach(a => cont.appendChild(crearItemAdjunto(a, pacienteId))); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function crearItemAdjunto(a, pacienteId) { + const esImagen = a.tipo_mime.startsWith('image/'); + const item = document.createElement('div'); + item.className = 'item-adjunto'; + const pesoKb = Math.round(a.tamanio_bytes / 1024); + const pesoTexto = pesoKb > 1024 ? `${(pesoKb / 1024).toFixed(1)} MB` : `${pesoKb} KB`; + + item.innerHTML = ` +
+ ${esImagen + ? '' + : ''} +
+
+
${escaparHtml(a.descripcion || a.nombre_original)}
+
${escaparHtml(a.nombre_original)} · ${pesoTexto} · ${formatearFechaCorta(a.subido_en.slice(0,10))}
+
+
+ `; + const acciones = item.querySelector('.acciones-tarjeta'); + + const btnVer = document.createElement('a'); + btnVer.className = 'btn-icono'; + btnVer.title = 'Ver / descargar'; + btnVer.href = `${API.adjuntos}?accion=ver&id=${a.id}`; + btnVer.target = '_blank'; + btnVer.innerHTML = ``; + + const btnBorrar = document.createElement('button'); + btnBorrar.className = 'btn-icono peligro'; + btnBorrar.title = 'Eliminar archivo'; + btnBorrar.innerHTML = ``; + btnBorrar.addEventListener('click', async () => { + if (!confirm('¿Eliminar este archivo adjunto? Esta acción no se puede deshacer.')) return; + try { + await llamarApi(`${API.adjuntos}?accion=eliminar`, { method: 'POST', body: JSON.stringify({ id: a.id }) }); + mostrarToast('Archivo eliminado.', 'exito'); + cargarAdjuntosPaciente(pacienteId); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + + acciones.appendChild(btnVer); + acciones.appendChild(btnBorrar); + return item; +} + +function vincularSubidaAdjunto(pacienteId) { + const input = document.getElementById('input-subir-adjunto'); + input.addEventListener('change', () => { + if (!input.files || !input.files[0]) return; + const archivo = input.files[0]; + const TAMANIO_MAXIMO_MB = 15; + if (archivo.size > TAMANIO_MAXIMO_MB * 1024 * 1024) { + mostrarToast(`El archivo pesa ${(archivo.size / (1024 * 1024)).toFixed(1)} MB. El máximo permitido es ${TAMANIO_MAXIMO_MB} MB.`, 'error'); + input.value = ''; + return; + } + ARCHIVO_PENDIENTE_SUBIR = archivo; + abrirModalDescripcionAdjunto(pacienteId); + input.value = ''; + }); +} + +function abrirModalDescripcionAdjunto(pacienteId) { + const modalEnv = clonarPlantilla('tpl-modal-descripcion-adjunto'); + document.body.appendChild(modalEnv); + document.getElementById('texto-nombre-archivo-subir').textContent = `Archivo: ${ARCHIVO_PENDIENTE_SUBIR.name}`; + + function cerrar() { modalEnv.remove(); ARCHIVO_PENDIENTE_SUBIR = null; } + document.getElementById('btn-cancelar-subir-adjunto').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-subir-adjunto').addEventListener('click', async () => { + const descripcion = document.getElementById('input-descripcion-adjunto').value.trim(); + const btn = document.getElementById('btn-confirmar-subir-adjunto'); + btn.disabled = true; + btn.innerHTML = ' Subiendo...'; + + const formData = new FormData(); + formData.append('archivo', ARCHIVO_PENDIENTE_SUBIR); + formData.append('paciente_id', pacienteId); + formData.append('descripcion', descripcion); + + try { + const respuesta = await fetch(`${API.adjuntos}?accion=subir`, { method: 'POST', body: formData }); + let datos; + try { + datos = await respuesta.json(); + } catch (errorParseo) { + // El servidor devolvió algo que no es JSON (por ejemplo, una + // página de error de PHP/Apache cuando el archivo supera el + // límite de subida configurado en el hosting). + throw new Error( + respuesta.status === 413 || respuesta.status === 0 + ? 'El archivo es demasiado grande para este servidor. Probá con un archivo más chico.' + : 'No se pudo subir el archivo (el servidor devolvió una respuesta inesperada). Probá con un archivo más chico, o avisá a soporte si sigue pasando.' + ); + } + if (!respuesta.ok || !datos.ok) throw new Error(datos.error || 'No se pudo subir el archivo.'); + mostrarToast('Archivo subido correctamente.', 'exito'); + modalEnv.remove(); + ARCHIVO_PENDIENTE_SUBIR = null; + cargarAdjuntosPaciente(pacienteId); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Subir archivo'; + } + }); +} + +/* ============================================================ + VISTA: AGENDA (calendario completo) + ============================================================ */ +async function montarVistaAgenda(contenido) { + contenido.appendChild(clonarPlantilla('tpl-agenda')); + MES_AGENDA_ACTUAL = new Date(); + DIA_SELECCIONADO_AGENDA = null; + + document.getElementById('btn-mes-anterior').addEventListener('click', () => { + MES_AGENDA_ACTUAL.setMonth(MES_AGENDA_ACTUAL.getMonth() - 1); + renderizarCalendario(); + }); + document.getElementById('btn-mes-siguiente').addEventListener('click', () => { + MES_AGENDA_ACTUAL.setMonth(MES_AGENDA_ACTUAL.getMonth() + 1); + renderizarCalendario(); + }); + document.getElementById('btn-nueva-cita').addEventListener('click', () => abrirModalNuevaCita()); + + await renderizarCalendario(); +} + +async function renderizarCalendario() { + const titulo = document.getElementById('calendario-mes-titulo'); + const grilla = document.getElementById('calendario-grilla'); + if (!titulo || !grilla) return; + + const año = MES_AGENDA_ACTUAL.getFullYear(); + const mes = MES_AGENDA_ACTUAL.getMonth(); + + titulo.textContent = MES_AGENDA_ACTUAL.toLocaleDateString('es-AR', { month: 'long', year: 'numeric' }); + + const primerDiaMes = new Date(año, mes, 1); + const ultimoDiaMes = new Date(año, mes + 1, 0); + const desde = aFechaIso(primerDiaMes); + const hasta = aFechaIso(ultimoDiaMes); + + let citasDelMes = []; + try { + const res = await llamarApi(`${API.citas}?accion=rango&desde=${desde}&hasta=${hasta}`, { method: 'GET' }); + citasDelMes = res.datos; + } catch (e) { + mostrarToast('No se pudo cargar la agenda del mes.', 'error'); + } + + const citasPorDia = {}; + citasDelMes.forEach(c => { + if (!citasPorDia[c.fecha]) citasPorDia[c.fecha] = []; + citasPorDia[c.fecha].push(c); + }); + + grilla.innerHTML = ''; + const diaSemanaInicio = primerDiaMes.getDay(); // 0 = domingo + for (let i = 0; i < diaSemanaInicio; i++) { + const celdaVacia = document.createElement('div'); + celdaVacia.className = 'celda-calendario vacia'; + grilla.appendChild(celdaVacia); + } + + const hoyIso = aFechaIso(new Date()); + + for (let dia = 1; dia <= ultimoDiaMes.getDate(); dia++) { + const fechaIso = aFechaIso(new Date(año, mes, dia)); + const celda = document.createElement('button'); + celda.type = 'button'; + celda.className = 'celda-calendario'; + if (fechaIso === hoyIso) celda.classList.add('hoy'); + if (fechaIso === DIA_SELECCIONADO_AGENDA) celda.classList.add('seleccionada'); + + const citasDia = citasPorDia[fechaIso] || []; + const pendientesDia = citasDia.filter(c => c.estado === 'pendiente').length; + + celda.innerHTML = ` + ${dia} + ${pendientesDia ? `${pendientesDia}` : ''} + `; + celda.addEventListener('click', () => { + DIA_SELECCIONADO_AGENDA = fechaIso; + renderizarCalendario(); + mostrarCitasDelDia(fechaIso, citasDia); + }); + grilla.appendChild(celda); + } + + if (DIA_SELECCIONADO_AGENDA) { + mostrarCitasDelDia(DIA_SELECCIONADO_AGENDA, citasPorDia[DIA_SELECCIONADO_AGENDA] || []); + } else { + document.getElementById('titulo-citas-dia').textContent = 'Citas — elegí un día en el calendario'; + document.getElementById('lista-citas-dia').innerHTML = ''; + } +} + +function mostrarCitasDelDia(fechaIso, citas) { + document.getElementById('titulo-citas-dia').textContent = `Citas — ${formatearFechaLarga(fechaIso)}`; + const lista = document.getElementById('lista-citas-dia'); + lista.innerHTML = ''; + + if (!citas.length) { + lista.innerHTML = '

No hay citas agendadas este día.

'; + return; + } + + citas.sort((a, b) => (a.hora || '').localeCompare(b.hora || '')); + + citas.forEach(c => { + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + const estadoEtiquetas = { + pendiente: 'Pendiente', + atendida: 'Atendida', + cancelada: 'Cancelada', + ausente: 'Ausente', + }; + fila.innerHTML = ` +
+
${(c.nombre[0] + c.apellido[0]).toUpperCase()}
+
+
${escaparHtml(c.nombre + ' ' + c.apellido)}
+
${c.hora ? c.hora.slice(0,5) + ' · ' : ''}${escaparHtml(c.motivo || 'Sin motivo')} ${estadoEtiquetas[c.estado] || ''}
+
+
+
+ `; + const acciones = fila.querySelector('.acciones-tarjeta'); + + const btnVerLegajo = document.createElement('button'); + btnVerLegajo.className = 'btn-icono'; + btnVerLegajo.title = 'Ver legajo'; + btnVerLegajo.innerHTML = ``; + btnVerLegajo.addEventListener('click', () => irAVista('detalle', { id: c.paciente_id })); + acciones.appendChild(btnVerLegajo); + + const linkWhatsappDia = generarLinkWhatsapp(c.telefono, c.nombre, c.fecha, c.hora, c.token_confirmacion); + if (linkWhatsappDia) { + const btnWhatsappDia = document.createElement('a'); + btnWhatsappDia.className = 'btn-icono whatsapp'; + btnWhatsappDia.title = 'Enviar recordatorio por WhatsApp'; + btnWhatsappDia.href = linkWhatsappDia; + btnWhatsappDia.target = '_blank'; + btnWhatsappDia.innerHTML = ``; + acciones.appendChild(btnWhatsappDia); + } + + if (c.estado === 'pendiente') { + const btnAtendida = document.createElement('button'); + btnAtendida.className = 'btn-icono'; + btnAtendida.title = 'Marcar como atendida'; + btnAtendida.innerHTML = ``; + btnAtendida.addEventListener('click', async () => { + await cambiarEstadoCita(c.id, 'atendida'); + renderizarCalendario(); + }); + acciones.appendChild(btnAtendida); + + const btnAusente = document.createElement('button'); + btnAusente.className = 'btn-icono'; + btnAusente.title = 'Marcar como ausente'; + btnAusente.innerHTML = ``; + btnAusente.addEventListener('click', async () => { + await cambiarEstadoCita(c.id, 'ausente'); + renderizarCalendario(); + }); + acciones.appendChild(btnAusente); + + const btnCancelar = document.createElement('button'); + btnCancelar.className = 'btn-icono peligro'; + btnCancelar.title = 'Cancelar cita'; + btnCancelar.innerHTML = ``; + btnCancelar.addEventListener('click', async () => { + await cambiarEstadoCita(c.id, 'cancelada'); + renderizarCalendario(); + }); + acciones.appendChild(btnCancelar); + } + + lista.appendChild(fila); + }); +} + +/* ============================================================ + VISTA: DASHBOARD DE ESTADÍSTICAS (solo profesional) + ============================================================ */ +async function montarVistaDashboard(contenido) { + contenido.appendChild(clonarPlantilla('tpl-dashboard')); + + document.getElementById('btn-exportar-todo').addEventListener('click', () => { + descargarArchivoDesdeUrl(`${API.pacientes}?accion=exportar_todo`); + }); + + const cont = document.getElementById('contenido-dashboard'); + try { + const res = await llamarApi(`${API.admin}?accion=estadisticas`, { method: 'GET' }); + renderizarDashboard(cont, res.datos); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function renderizarDashboard(cont, d) { + cont.innerHTML = ''; + + // Fila de tarjetas numéricas + const filaStats = document.createElement('div'); + filaStats.className = 'grilla-stats'; + + const diferenciaSesiones = d.sesiones_este_mes - d.sesiones_mes_anterior; + const tendenciaTexto = diferenciaSesiones === 0 + ? 'Igual que el mes anterior' + : diferenciaSesiones > 0 + ? `+${diferenciaSesiones} respecto al mes anterior` + : `${diferenciaSesiones} respecto al mes anterior`; + + const stats = [ + { valor: d.total_pacientes, etiqueta: 'Pacientes totales', detalle: `+${d.pacientes_nuevos_mes} este mes` }, + { valor: d.sesiones_este_mes, etiqueta: 'Sesiones este mes', detalle: tendenciaTexto }, + ]; + + const estadosCita = { pendiente: 'Pendientes', atendida: 'Atendidas', cancelada: 'Canceladas', ausente: 'Ausencias' }; + d.citas_por_estado_mes.forEach(c => { + stats.push({ valor: c.total, etiqueta: estadosCita[c.estado] || c.estado, detalle: 'este mes' }); + }); + + stats.forEach(s => { + const tarjeta = clonarPlantilla('tpl-tarjeta-stat').firstElementChild; + tarjeta.querySelector('.tarjeta-stat-valor').textContent = s.valor; + tarjeta.querySelector('.tarjeta-stat-etiqueta').textContent = s.etiqueta; + tarjeta.querySelector('.tarjeta-stat-detalle').textContent = s.detalle; + filaStats.appendChild(tarjeta); + }); + + cont.appendChild(filaStats); + + // Panel de obras sociales + const panelObras = document.createElement('div'); + panelObras.className = 'panel'; + panelObras.innerHTML = ` +
Pacientes por obra social
+

Distribución de tu cartera de pacientes.

+
+ `; + cont.appendChild(panelObras); + + const contBarras = panelObras.querySelector('#barras-obras-sociales'); + const maximoObra = Math.max(...d.por_obra_social.map(o => o.total), 1); + d.por_obra_social.forEach(o => { + const fila = document.createElement('div'); + fila.className = 'fila-barra-stat'; + const porcentaje = Math.round((o.total / maximoObra) * 100); + fila.innerHTML = ` + ${escaparHtml(o.nombre)} +
+ ${o.total} + `; + contBarras.appendChild(fila); + }); + + // Panel de sesiones últimos 6 meses + const panelMeses = document.createElement('div'); + panelMeses.className = 'panel'; + panelMeses.innerHTML = ` +
Sesiones de los últimos 6 meses
+

Volumen de atención mes a mes.

+
+ `; + cont.appendChild(panelMeses); + + const contMeses = panelMeses.querySelector('#barras-meses'); + const maximoMes = Math.max(...d.sesiones_ultimos_6_meses.map(m => m.total), 1); + const nombresMeses = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; + d.sesiones_ultimos_6_meses.forEach(m => { + const [anio, mes] = m.mes.split('-'); + const alturaPx = Math.max(8, Math.round((m.total / maximoMes) * 120)); + const columna = document.createElement('div'); + columna.className = 'columna-mes'; + columna.innerHTML = ` +
${m.total}
+
+
${nombresMeses[parseInt(mes, 10) - 1]}
+ `; + contMeses.appendChild(columna); + }); +} + +/* ============================================================ + VISTA: CONFIGURACIÓN (usuarios + historial de cambios) + ============================================================ */ +async function cargarAvisoLicenciasPorVencer() { + const cont = document.getElementById('aviso-licencias-por-vencer'); + if (!cont) return; + try { + const res = await llamarApi(`${API.admin}?accion=licencias_por_vencer`, { method: 'GET' }); + if (!res.datos.length) { + cont.classList.add('oculto'); + return; + } + const nombres = res.datos.map(p => { + const dias = p.dias_restantes; + const texto = dias === 0 ? 'vence hoy' : dias === 1 ? 'vence mañana' : `vence en ${dias} días`; + return `${escaparHtml(p.nombre_completo)} (${texto})`; + }).join(' · '); + cont.innerHTML = ` +
+ +
+ ${res.datos.length} licencia${res.datos.length === 1 ? '' : 's'} ${res.datos.length === 1 ? 'vence' : 'vencen'} pronto: + ${nombres} +
+
`; + cont.classList.remove('oculto'); + } catch (e) { + cont.classList.add('oculto'); + } +} + +async function montarVistaConfiguracion(contenido) { + contenido.appendChild(clonarPlantilla('tpl-configuracion')); + + // El Desarrollador ya está en su única pantalla: no tiene + // a dónde "volver al panel", así que ese botón no aplica. + if (ROL_ACTUAL === 'desarrollador') { + const btnVolver = contenido.querySelector('[data-volver]'); + if (btnVolver) btnVolver.remove(); + cargarAvisoLicenciasPorVencer(); + } + + const tabs = contenido.querySelectorAll('[data-tab-config]'); + const paneles = { sedes: 'panel-config-sedes', usuarios: 'panel-config-usuarios', historial: 'panel-config-historial', version: 'panel-config-version', reportes: 'panel-config-reportes', papelera: 'panel-config-papelera', huerfanos: 'panel-config-huerfanos' }; + tabs.forEach(tab => { + tab.addEventListener('click', () => { + tabs.forEach(t => t.classList.remove('activo')); + tab.classList.add('activo'); + const destino = tab.dataset.tabConfig; + Object.entries(paneles).forEach(([clave, idPanel]) => { + document.getElementById(idPanel).classList.toggle('oculto', clave !== destino); + }); + if (destino === 'historial') cargarHistorialCambios(1); + if (destino === 'usuarios') cargarListaUsuarios(); + if (destino === 'version') cargarVerificacionVersion(); + if (destino === 'reportes') cargarReportesSede(); + if (destino === 'papelera') inicializarPapeleraDev(); + if (destino === 'huerfanos') inicializarLegajosHuerfanos(); + }); + }); + + document.getElementById('btn-revisar-version').addEventListener('click', cargarVerificacionVersion); + document.getElementById('btn-agregar-sede').addEventListener('click', abrirModalNuevaSede); + document.getElementById('btn-agregar-usuario').addEventListener('click', abrirModalNuevoUsuario); + + await cargarListaSedes(); +} + +let SEDE_PAPELERA_DEV_ID = null; + +async function inicializarPapeleraDev() { + const selectSede = document.getElementById('select-sede-papelera-dev'); + const selectProf = document.getElementById('select-profesional-papelera-dev'); + const lista = document.getElementById('lista-papelera-dev'); + + // Evitamos volver a pedir las sedes si ya se cargaron antes en esta visita al panel. + if (!selectSede.dataset.cargado) { + try { + const res = await llamarApi(`${API.auth}?accion=listar_sedes`, { method: 'POST', body: JSON.stringify({ accion: 'listar_sedes' }) }); + const sedesActivas = res.datos.filter(s => s.activa); + sedesActivas.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.nombre; + selectSede.appendChild(opt); + }); + selectSede.dataset.cargado = '1'; + } catch (e) { + mostrarToast(e.message, 'error'); + } + } + + selectSede.onchange = async () => { + SEDE_PAPELERA_DEV_ID = selectSede.value || null; + selectProf.innerHTML = ''; + selectProf.disabled = true; + lista.innerHTML = ''; + + if (!SEDE_PAPELERA_DEV_ID) { + selectProf.innerHTML = ''; + return; + } + + try { + const res = await llamarApi(`${API.auth}?accion=listar_profesionales_sede_dev`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_profesionales_sede_dev', sede_id: SEDE_PAPELERA_DEV_ID }), + }); + selectProf.innerHTML = ''; + res.datos.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.nombre_completo; + selectProf.appendChild(opt); + }); + selectProf.disabled = false; + } catch (e) { + mostrarToast(e.message, 'error'); + } + }; + + selectProf.onchange = () => { + if (selectProf.value) cargarPapeleraDevDeProfesional(selectProf.value); + else lista.innerHTML = ''; + }; +} + +async function cargarPapeleraDevDeProfesional(profesionalId) { + const lista = document.getElementById('lista-papelera-dev'); + lista.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_papelera_dev`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_papelera_dev', profesional_id: profesionalId, sede_id: SEDE_PAPELERA_DEV_ID }), + }); + lista.innerHTML = ''; + if (!res.datos.length) { + lista.innerHTML = '

Este profesional no tiene legajos eliminados en esta sede.

'; + return; + } + res.datos.forEach(reg => lista.appendChild(crearFilaPapeleraDev(reg))); + } catch (e) { + lista.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function crearFilaPapeleraDev(reg) { + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + const fecha = new Date(reg.eliminado_en).toLocaleDateString('es-AR'); + fila.innerHTML = ` +
+
+ +
+
+
${escaparHtml(reg.nombre_completo)}
+
DNI ${escaparHtml(reg.dni)} · Eliminado el ${fecha}
+
+
+
+ `; + const acciones = fila.querySelector('.acciones-tarjeta'); + const btnRecuperar = document.createElement('button'); + btnRecuperar.className = 'btn btn-secundario btn-chico'; + btnRecuperar.textContent = 'Recuperar'; + btnRecuperar.addEventListener('click', () => abrirModalRecuperarLegajo(reg)); + acciones.appendChild(btnRecuperar); + return fila; +} + +async function abrirModalRecuperarLegajo(reg) { + const modalEnv = clonarPlantilla('tpl-modal-recuperar-legajo'); + document.body.appendChild(modalEnv); + document.getElementById('texto-paciente-recuperar').textContent = + `${reg.nombre_completo} (DNI ${reg.dni}) va a volver a aparecer como legajo activo, a nombre del profesional que elijas.`; + + const select = document.getElementById('select-nuevo-profesional-recuperar'); + select.innerHTML = ''; + try { + const res = await llamarApi(`${API.auth}?accion=listar_profesionales_sede_dev`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_profesionales_sede_dev', sede_id: reg.sede_id_original }), + }); + select.innerHTML = ''; + if (!res.datos.length) { + select.innerHTML = ''; + } else { + res.datos.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.nombre_completo; + select.appendChild(opt); + }); + } + } catch (e) { + select.innerHTML = ''; + mostrarToast(e.message, 'error'); + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-recuperar-legajo').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-recuperar-legajo').addEventListener('click', async () => { + const nuevoProfesionalId = select.value; + if (!nuevoProfesionalId) { mostrarToast('Elegí a qué profesional asignarlo.', 'error'); return; } + + const btn = document.getElementById('btn-confirmar-recuperar-legajo'); + btn.disabled = true; + btn.innerHTML = ' Recuperando...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ accion: 'recuperar_legajo_dev', id: reg.id, profesional_id: nuevoProfesionalId }), + }); + mostrarToast('Legajo recuperado correctamente.', 'exito'); + cerrar(); + const selectProf = document.getElementById('select-profesional-papelera-dev'); + if (selectProf.value) cargarPapeleraDevDeProfesional(selectProf.value); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Recuperar legajo'; + } + }); +} + +async function inicializarLegajosHuerfanos() { + const select = document.getElementById('select-profesional-huerfano'); + const lista = document.getElementById('lista-huerfanos-dev'); + lista.innerHTML = ''; + + select.innerHTML = ''; + try { + const res = await llamarApi(`${API.auth}?accion=listar_profesionales_desactivados`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_profesionales_desactivados' }), + }); + select.innerHTML = ''; + if (!res.datos.length) { + select.innerHTML = ''; + } else { + res.datos.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = `${p.nombre_completo} (${p.total_pacientes} paciente${p.total_pacientes === 1 ? '' : 's'})`; + select.appendChild(opt); + }); + } + } catch (e) { + select.innerHTML = ''; + mostrarToast(e.message, 'error'); + } + + select.onchange = () => { + if (select.value) cargarLegajosHuerfanosDeProfesional(select.value); + else lista.innerHTML = ''; + }; +} + +async function cargarLegajosHuerfanosDeProfesional(profesionalId) { + const lista = document.getElementById('lista-huerfanos-dev'); + lista.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_legajos_huerfanos`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_legajos_huerfanos', profesional_id: profesionalId }), + }); + lista.innerHTML = ''; + if (!res.datos.length) { + lista.innerHTML = '

Este profesional no tiene pacientes activos.

'; + return; + } + res.datos.forEach(p => lista.appendChild(crearFilaLegajoHuerfano(p))); + } catch (e) { + lista.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function crearFilaLegajoHuerfano(p) { + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + fila.innerHTML = ` +
+
${(p.nombre[0] + p.apellido[0]).toUpperCase()}
+
+
${escaparHtml(p.nombre)} ${escaparHtml(p.apellido)}
+
DNI ${escaparHtml(p.dni)} · ${escaparHtml(p.sede_nombre || 'Sin sede')}
+
+
+
+ `; + const acciones = fila.querySelector('.acciones-tarjeta'); + const btnTransferir = document.createElement('button'); + btnTransferir.className = 'btn btn-secundario btn-chico'; + btnTransferir.textContent = 'Transferir'; + btnTransferir.addEventListener('click', () => abrirModalTransferirHuerfano(p)); + acciones.appendChild(btnTransferir); + return fila; +} + +async function abrirModalTransferirHuerfano(p) { + const modalEnv = clonarPlantilla('tpl-modal-transferir-huerfano'); + document.body.appendChild(modalEnv); + document.getElementById('texto-paciente-transferir').textContent = + `${p.nombre} ${p.apellido} (DNI ${p.dni}) va a pasar a estar a cargo del profesional que elijas.`; + + const select = document.getElementById('select-nuevo-profesional-transferir'); + select.innerHTML = ''; + try { + const res = await llamarApi(`${API.auth}?accion=listar_profesionales_sede_dev`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_profesionales_sede_dev', sede_id: p.sede_id }), + }); + select.innerHTML = ''; + if (!res.datos.length) { + select.innerHTML = ''; + } else { + res.datos.forEach(prof => { + const opt = document.createElement('option'); + opt.value = prof.id; + opt.textContent = prof.nombre_completo; + select.appendChild(opt); + }); + } + } catch (e) { + select.innerHTML = ''; + mostrarToast(e.message, 'error'); + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-transferir-huerfano').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-transferir-huerfano').addEventListener('click', async () => { + const nuevoProfesionalId = select.value; + if (!nuevoProfesionalId) { mostrarToast('Elegí a qué profesional asignarlo.', 'error'); return; } + + const btn = document.getElementById('btn-confirmar-transferir-huerfano'); + btn.disabled = true; + btn.innerHTML = ' Transfiriendo...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ accion: 'reasignar_legajo_huerfano', paciente_id: p.id, profesional_id: nuevoProfesionalId }), + }); + mostrarToast('Legajo transferido correctamente.', 'exito'); + cerrar(); + const selectProf = document.getElementById('select-profesional-huerfano'); + if (selectProf.value) cargarLegajosHuerfanosDeProfesional(selectProf.value); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Transferir legajo'; + } + }); +} + +async function cargarReportesSede() { + const cont = document.getElementById('lista-reportes-sede'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.admin}?accion=reporte_sedes`, { method: 'GET' }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

Todavía no hay sedes activas para reportar.

'; + return; + } + res.datos.forEach(s => cont.appendChild(crearTarjetaReporteSede(s))); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function crearTarjetaReporteSede(s) { + const tarjeta = document.createElement('div'); + tarjeta.className = 'tarjeta-reporte-sede'; + + const etiquetasEstado = { pendiente: 'Pendientes', atendida: 'Atendidas', cancelada: 'Canceladas', ausente: 'Ausentes' }; + const citasTexto = (s.citas_por_estado_mes || []).length + ? s.citas_por_estado_mes.map(c => `${etiquetasEstado[c.estado] || c.estado}: ${c.total}`).join(' · ') + : 'Sin citas registradas este mes'; + + tarjeta.innerHTML = ` +
+ + ${escaparHtml(s.nombre)} +
+
+
${s.profesionales}Profesional${s.profesionales === 1 ? '' : 'es'}
+
${s.administrativas}Administrativa${s.administrativas === 1 ? '' : 's'}
+
${s.pacientes}Paciente${s.pacientes === 1 ? '' : 's'} en total
+
${s.sesiones_mes}Sesiones este mes
+
+
${escaparHtml(citasTexto)}
+ `; + return tarjeta; +} + +async function cargarVerificacionVersion() { + const resumen = document.getElementById('resumen-version'); + const lista = document.getElementById('lista-archivos-version'); + resumen.innerHTML = '
'; + lista.innerHTML = ''; + + try { + const res = await llamarApi(`${API.admin}?accion=verificar_version`, { method: 'GET' }); + + if (res.sin_version_json) { + resumen.innerHTML = `

No se encontró el archivo version.json en el servidor. Subilo junto con la próxima actualización para poder usar esta verificación.

`; + return; + } + + if (res.hay_desactualizados) { + resumen.innerHTML = ` +
+ ⚠ Hay archivos desactualizados. Versión esperada: ${escaparHtml(res.version)}${res.fecha ? ' (' + escaparHtml(res.fecha) + ')' : ''}. + ${res.descripcion ? `
${escaparHtml(res.descripcion)}
` : ''} +
`; + } else { + resumen.innerHTML = ` +
+ ✅ Todo está actualizado a la versión ${escaparHtml(res.version)}${res.fecha ? ' (' + escaparHtml(res.fecha) + ')' : ''}. + ${res.descripcion ? `
${escaparHtml(res.descripcion)}
` : ''} +
`; + } + + lista.innerHTML = ''; + res.archivos.forEach(a => { + const fila = document.createElement('div'); + fila.className = 'fila-archivo-version'; + let etiqueta, clase; + if (a.estado === 'actualizado') { + etiqueta = '✅ Actualizado'; clase = 'ok'; + } else if (a.estado === 'falta') { + etiqueta = '❌ Falta en el servidor'; clase = 'mal'; + } else { + etiqueta = '⚠ Versión vieja'; clase = 'mal'; + } + fila.innerHTML = ` + ${escaparHtml(a.archivo)} + ${etiqueta} + `; + lista.appendChild(fila); + }); + } catch (e) { + resumen.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +async function cargarListaSedes() { + const cont = document.getElementById('lista-sedes-config'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_sedes`, { method: 'POST', body: JSON.stringify({ accion: 'listar_sedes' }) }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

Todavía no creaste ninguna sede.

'; + return; + } + res.datos.forEach(s => cont.appendChild(crearFilaSede(s))); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function crearFilaSede(s) { + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + const fecha = new Date(s.creado_en).toLocaleDateString('es-AR'); + fila.innerHTML = ` +
+
+ +
+
+
${escaparHtml(s.nombre)} ${s.activa ? '' : 'Desactivada'}
+
Desde ${fecha}
+
+
+
+ `; + if (s.activa) { + const acciones = fila.querySelector('.acciones-tarjeta'); + + const btnRenombrar = document.createElement('button'); + btnRenombrar.className = 'btn-icono'; + btnRenombrar.title = 'Renombrar sede'; + btnRenombrar.innerHTML = ``; + btnRenombrar.addEventListener('click', async () => { + const nombreNuevo = prompt(`Nuevo nombre para "${s.nombre}":`, s.nombre); + if (nombreNuevo === null) return; + const limpio = nombreNuevo.trim(); + if (!limpio || limpio === s.nombre) return; + try { + await llamarApi(API.auth, { method: 'POST', body: JSON.stringify({ accion: 'renombrar_sede', id: s.id, nombre: limpio }) }); + mostrarToast('Sede renombrada. Los legajos existentes se actualizaron automáticamente.', 'exito'); + cargarListaSedes(); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + acciones.appendChild(btnRenombrar); + + const btnDesactivar = document.createElement('button'); + btnDesactivar.className = 'btn-icono peligro'; + btnDesactivar.title = 'Desactivar sede'; + btnDesactivar.innerHTML = ``; + btnDesactivar.addEventListener('click', async () => { + if (!confirm(`¿Desactivar la sede "${s.nombre}"? Ya no va a aparecer en el login.`)) return; + try { + await llamarApi(API.auth, { method: 'POST', body: JSON.stringify({ accion: 'desactivar_sede', id: s.id }) }); + mostrarToast('Sede desactivada.', 'exito'); + cargarListaSedes(); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + acciones.appendChild(btnDesactivar); + } + return fila; +} + +function abrirModalNuevaSede() { + const modalEnv = clonarPlantilla('tpl-modal-nueva-sede'); + document.body.appendChild(modalEnv); + const input = document.getElementById('input-nombre-nueva-sede'); + input.focus(); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-nueva-sede').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-nueva-sede').addEventListener('click', async () => { + const nombre = input.value.trim(); + if (!nombre) { mostrarToast('Escribí el nombre de la sede.', 'error'); return; } + try { + await llamarApi(API.auth, { method: 'POST', body: JSON.stringify({ accion: 'crear_sede', nombre }) }); + mostrarToast('Sede creada.', 'exito'); + cerrar(); + cargarListaSedes(); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') document.getElementById('btn-confirmar-nueva-sede').click(); }); +} + +async function cargarListaUsuarios() { + const cont = document.getElementById('lista-usuarios'); + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_usuarios`, { + method: 'POST', + body: JSON.stringify({ accion: 'listar_usuarios' }), + }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

Todavía no creaste ningún usuario.

'; + return; + } + res.datos.forEach(u => cont.appendChild(crearFilaUsuario(u))); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } + + const input = document.getElementById('input-buscar-profesional'); + if (input && !input.dataset.conectado) { + input.dataset.conectado = '1'; + let temporizador = null; + input.addEventListener('input', () => { + clearTimeout(temporizador); + temporizador = setTimeout(() => buscarProfesionalesDev(input.value.trim()), 300); + }); + } +} + +async function buscarProfesionalesDev(q) { + const cont = document.getElementById('lista-usuarios'); + if (!q) { cargarListaUsuarios(); return; } + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=buscar_profesionales`, { + method: 'POST', + body: JSON.stringify({ accion: 'buscar_profesionales', q }), + }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

No se encontró ningún profesional con esa búsqueda.

'; + return; + } + res.datos.forEach(u => { + u.nombre_completo = u.nombre_completo || `${u.titulo} ${u.nombre_completo}`; + u.rol = 'profesional'; + u.sedes = u.sedes || []; + const fila = crearFilaUsuario(u); + if (u.numero_legajo) { + const meta = fila.querySelector('.meta'); + if (meta) meta.innerHTML += ` · ${escaparHtml(u.numero_legajo)}`; + } + cont.appendChild(fila); + }); + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +function crearFilaUsuario(u) { + const fila = document.createElement('div'); + fila.className = 'tarjeta-paciente'; + const rolEtiqueta = u.rol === 'profesional' + ? 'Profesional' + : 'Administrativa'; + const fecha = new Date(u.creado_en).toLocaleDateString('es-AR'); + const nombresSedes = (u.sedes || []).map(s => s.nombre).join(', ') || 'Sin sede asignada'; + + let licenciaTexto = ''; + if (u.rol === 'profesional') { + const colorEstado = { activo: 'var(--salvia-oscuro)', suspendido: 'var(--coral)', pausado: 'var(--arena)', prohibido: 'var(--coral)' }; + const estado = u.estado_licencia || 'activo'; + const color = colorEstado[estado] || 'var(--salvia-oscuro)'; + let diasInfo = ''; + if (estado === 'activo' && u.licencia_vencimiento) { + diasInfo = ` · Vence ${formatearFechaCorta(u.licencia_vencimiento)}`; + } else if (estado === 'activo' && !u.licencia_dias) { + diasInfo = ' · Indeterminada'; + } + licenciaTexto = ` · ${estado}${diasInfo}`; + } + + fila.innerHTML = ` +
+
${u.nombre_completo.split(' ').map(p => p[0]).slice(0,2).join('').toUpperCase()}
+
+
${escaparHtml(u.nombre_completo)}${u.especialidad ? ' ' : ''}
+
${rolEtiqueta} · ${escaparHtml(nombresSedes)}${licenciaTexto} · Desde ${fecha}
+
+
+
+ `; + if (u.activo && u.id !== undefined) { + const acciones = fila.querySelector('.acciones-tarjeta'); + + if (u.rol === 'profesional') { + const btnEditarLegajo = document.createElement('button'); + btnEditarLegajo.className = 'btn-icono'; + btnEditarLegajo.title = 'Editar legajo'; + btnEditarLegajo.innerHTML = ``; + btnEditarLegajo.addEventListener('click', () => abrirModalEditarLegajoProfesional(u)); + acciones.appendChild(btnEditarLegajo); + + const btnLicencia = document.createElement('button'); + btnLicencia.className = 'btn-icono'; + btnLicencia.title = 'Gestionar licencia'; + btnLicencia.innerHTML = ``; + btnLicencia.addEventListener('click', () => abrirModalGestionarLicencia(u)); + acciones.appendChild(btnLicencia); + } + + const btnCambiarPin = document.createElement('button'); + btnCambiarPin.className = 'btn-icono'; + btnCambiarPin.title = 'Cambiar PIN'; + btnCambiarPin.innerHTML = ``; + btnCambiarPin.addEventListener('click', () => abrirModalCambiarPin(u)); + acciones.appendChild(btnCambiarPin); + + const btnGestionarSedes = document.createElement('button'); + btnGestionarSedes.className = 'btn-icono'; + btnGestionarSedes.title = 'Gestionar sedes'; + btnGestionarSedes.innerHTML = ``; + btnGestionarSedes.addEventListener('click', () => abrirModalGestionarSedesUsuario(u)); + acciones.appendChild(btnGestionarSedes); + + const btnDesactivar = document.createElement('button'); + btnDesactivar.className = 'btn-icono peligro'; + btnDesactivar.title = 'Quitar acceso'; + btnDesactivar.innerHTML = ``; + btnDesactivar.addEventListener('click', async () => { + if (!confirm(`¿Quitar el acceso de ${u.nombre_completo}? Va a dejar de poder entrar al sistema.`)) return; + try { + await llamarApi(`${API.auth}`, { + method: 'POST', + body: JSON.stringify({ accion: 'desactivar_usuario', id: u.id }), + }); + mostrarToast('Acceso desactivado.', 'exito'); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + acciones.appendChild(btnDesactivar); + } else if (!u.activo && u.id !== undefined) { + const acciones = fila.querySelector('.acciones-tarjeta'); + const etiquetaDesactivado = document.createElement('span'); + etiquetaDesactivado.className = 'etiqueta'; + etiquetaDesactivado.style.background = 'var(--coral-claro)'; + etiquetaDesactivado.style.color = 'var(--coral)'; + etiquetaDesactivado.textContent = 'Sin acceso'; + acciones.appendChild(etiquetaDesactivado); + + const btnRestaurar = document.createElement('button'); + btnRestaurar.className = 'btn btn-secundario btn-chico'; + btnRestaurar.textContent = 'Restaurar acceso'; + btnRestaurar.addEventListener('click', async () => { + if (!confirm(`¿Restaurar el acceso de ${u.nombre_completo}? Va a poder volver a entrar con su PIN.`)) return; + try { + await llamarApi(`${API.auth}`, { + method: 'POST', + body: JSON.stringify({ accion: 'reactivar_usuario', id: u.id }), + }); + mostrarToast('Acceso restaurado.', 'exito'); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + } + }); + acciones.appendChild(btnRestaurar); + } + return fila; +} + +async function abrirModalNuevoUsuario() { + // Preguntamos qué tipo de persona se quiere agregar, + // para abrir el formulario correcto. + const tipo = await new Promise(resolve => { + const div = document.createElement('div'); + div.className = 'overlay-modal'; + div.innerHTML = ` + `; + document.body.appendChild(div); + div.querySelector('#elegir-profesional').onclick = () => { div.remove(); resolve('profesional'); }; + div.querySelector('#elegir-administrativa').onclick = () => { div.remove(); resolve('administrativa'); }; + div.querySelector('#elegir-cancelar').onclick = () => { div.remove(); resolve(null); }; + }); + + if (!tipo) return; + if (tipo === 'profesional') abrirModalNuevoProfesional(); + else abrirModalNuevaAdministrativa(); +} + +async function cargarSedesEnCheckboxes(contenedorId, claseCheckbox) { + const cont = document.getElementById(contenedorId); + if (!cont) return; + cont.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_sedes`, { method: 'POST', body: JSON.stringify({ accion: 'listar_sedes' }) }); + const sedesActivas = res.datos.filter(s => s.activa); + cont.innerHTML = ''; + if (!sedesActivas.length) { + cont.innerHTML = ''; + } else { + sedesActivas.forEach(s => { + const label = document.createElement('label'); + label.className = 'checkbox-sede-item'; + label.innerHTML = ` ${escaparHtml(s.nombre)}`; + cont.appendChild(label); + }); + } + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} + +async function abrirModalNuevoProfesional() { + const modalEnv = clonarPlantilla('tpl-modal-nuevo-profesional'); + document.body.appendChild(modalEnv); + await cargarSedesEnCheckboxes('lista-checkboxes-sedes-prof', 'checkbox-sede-prof'); + + const inputPin = document.getElementById('input-pin-prof'); + inputPin.addEventListener('input', () => { inputPin.value = inputPin.value.replace(/\D/g, '').slice(0, 4); }); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-nuevo-profesional').addEventListener('click', cerrar); + + document.getElementById('btn-confirmar-nuevo-profesional').addEventListener('click', async () => { + const titulo = document.getElementById('input-titulo-prof').value; + const nombre = document.getElementById('input-nombre-prof').value.trim(); + const apellido = document.getElementById('input-apellido-prof').value.trim(); + const dni = document.getElementById('input-dni-prof').value.trim(); + const fechaNac = document.getElementById('input-fechanac-prof').value; + const lugarNac = document.getElementById('input-lugarnac-prof').value.trim(); + const especialidad = document.getElementById('input-especialidad-prof').value.trim(); + const email = document.getElementById('input-email-prof').value.trim(); + const tel = document.getElementById('input-tel-prof').value.trim(); + const pin = inputPin.value.trim(); + const licenciaDias = document.getElementById('input-licencia-dias-prof').value; + const sedeIds = Array.from(document.querySelectorAll('.checkbox-sede-prof:checked')).map(c => parseInt(c.value, 10)); + + if (!nombre || !apellido) { mostrarToast('Ingresá el nombre y apellido del profesional.', 'error'); return; } + if (!/^\d{4}$/.test(pin)) { mostrarToast('El PIN tiene que tener exactamente 4 números.', 'error'); return; } + if (!sedeIds.length) { mostrarToast('Elegí al menos una sede.', 'error'); return; } + + const btn = document.getElementById('btn-confirmar-nuevo-profesional'); + btn.disabled = true; + btn.innerHTML = ' Creando...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ + accion: 'crear_usuario', rol: 'profesional', titulo, nombre, apellido, + dni, fecha_nacimiento: fechaNac, lugar_nacimiento: lugarNac, especialidad, + email, telefono: tel, pin, sede_ids: sedeIds, licencia_dias: licenciaDias, + }), + }); + mostrarToast('Legajo creado correctamente.', 'exito'); + cerrar(); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Crear legajo'; + } + }); +} + +async function abrirModalNuevaAdministrativa() { + const modalEnv = clonarPlantilla('tpl-modal-nueva-administrativa'); + document.body.appendChild(modalEnv); + await cargarSedesEnCheckboxes('lista-checkboxes-sedes-admin', 'checkbox-sede-admin'); + + const inputPin = document.getElementById('input-pin-nueva-admin'); + inputPin.addEventListener('input', () => { inputPin.value = inputPin.value.replace(/\D/g, '').slice(0, 4); }); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-nueva-admin').addEventListener('click', cerrar); + + document.getElementById('btn-confirmar-nueva-admin').addEventListener('click', async () => { + const nombre = document.getElementById('input-nombre-nueva-admin').value.trim(); + const pin = inputPin.value.trim(); + const sedeIds = Array.from(document.querySelectorAll('.checkbox-sede-admin:checked')).map(c => parseInt(c.value, 10)); + + if (!nombre) { mostrarToast('Ingresá el nombre de la administrativa.', 'error'); return; } + if (!/^\d{4}$/.test(pin)) { mostrarToast('El PIN tiene que tener exactamente 4 números.', 'error'); return; } + if (!sedeIds.length) { mostrarToast('Elegí al menos una sede.', 'error'); return; } + + const btn = document.getElementById('btn-confirmar-nueva-admin'); + btn.disabled = true; + btn.innerHTML = ' Creando...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ accion: 'crear_usuario', rol: 'administrativa', nombre_completo: nombre, pin, sede_ids: sedeIds }), + }); + mostrarToast('Acceso creado correctamente.', 'exito'); + cerrar(); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Crear acceso'; + } + }); +} + +async function abrirModalEditarLegajoProfesional(usuario) { + const modalEnv = clonarPlantilla('tpl-modal-editar-legajo-profesional'); + document.body.appendChild(modalEnv); + + const campos = ['titulo', 'nombre', 'apellido', 'dni', 'fechanac', 'lugarnac', 'especialidad', 'email', 'tel']; + campos.forEach(c => { document.getElementById(`input-${c}-editar-prof`).disabled = true; }); + + try { + const res = await llamarApi(`${API.auth}?accion=obtener_legajo_profesional`, { + method: 'POST', + body: JSON.stringify({ accion: 'obtener_legajo_profesional', usuario_id: usuario.id }), + }); + const l = res.datos; + document.getElementById('input-titulo-editar-prof').value = l.titulo || 'Dr.'; + document.getElementById('input-nombre-editar-prof').value = l.nombre || ''; + document.getElementById('input-apellido-editar-prof').value = l.apellido || ''; + document.getElementById('input-dni-editar-prof').value = l.dni || ''; + document.getElementById('input-fechanac-editar-prof').value = l.fecha_nacimiento || ''; + document.getElementById('input-lugarnac-editar-prof').value = l.lugar_nacimiento || ''; + document.getElementById('input-especialidad-editar-prof').value = l.especialidad || ''; + document.getElementById('input-email-editar-prof').value = l.email || ''; + document.getElementById('input-tel-editar-prof').value = l.telefono || ''; + campos.forEach(c => { document.getElementById(`input-${c}-editar-prof`).disabled = false; }); + } catch (e) { + mostrarToast(e.message, 'error'); + modalEnv.remove(); + return; + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-editar-profesional').addEventListener('click', cerrar); + + document.getElementById('btn-confirmar-editar-profesional').addEventListener('click', async () => { + const datos = { + accion: 'editar_legajo_profesional', + usuario_id: usuario.id, + titulo: document.getElementById('input-titulo-editar-prof').value, + nombre: document.getElementById('input-nombre-editar-prof').value.trim(), + apellido: document.getElementById('input-apellido-editar-prof').value.trim(), + dni: document.getElementById('input-dni-editar-prof').value.trim(), + fecha_nacimiento: document.getElementById('input-fechanac-editar-prof').value, + lugar_nacimiento: document.getElementById('input-lugarnac-editar-prof').value.trim(), + especialidad: document.getElementById('input-especialidad-editar-prof').value.trim(), + email: document.getElementById('input-email-editar-prof').value.trim(), + telefono: document.getElementById('input-tel-editar-prof').value.trim(), + }; + if (!datos.nombre || !datos.apellido) { mostrarToast('Ingresá el nombre y apellido del profesional.', 'error'); return; } + + const btn = document.getElementById('btn-confirmar-editar-profesional'); + btn.disabled = true; + btn.innerHTML = ' Guardando...'; + try { + await llamarApi(API.auth, { method: 'POST', body: JSON.stringify(datos) }); + mostrarToast('Legajo actualizado correctamente.', 'exito'); + cerrar(); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Guardar cambios'; + } + }); +} + +function abrirModalCambiarPin(usuario) { + const modalEnv = clonarPlantilla('tpl-modal-cambiar-pin'); + document.body.appendChild(modalEnv); + document.getElementById('texto-usuario-cambiar-pin').textContent = + `Vas a definir un PIN nuevo para ${usuario.nombre_completo}. El anterior deja de funcionar.`; + + const inputPin = document.getElementById('input-nuevo-pin'); + inputPin.addEventListener('input', () => { inputPin.value = inputPin.value.replace(/\D/g, '').slice(0, 4); }); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-cambiar-pin').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-cambiar-pin').addEventListener('click', async () => { + const pin = inputPin.value.trim(); + if (!/^\d{4}$/.test(pin)) { mostrarToast('El PIN tiene que tener exactamente 4 números.', 'error'); return; } + + const btn = document.getElementById('btn-confirmar-cambiar-pin'); + btn.disabled = true; + btn.innerHTML = ' Guardando...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ accion: 'cambiar_pin_usuario', usuario_id: usuario.id, pin }), + }); + mostrarToast('PIN actualizado correctamente.', 'exito'); + cerrar(); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Guardar PIN'; + } + }); +} + +function abrirModalGestionarLicencia(usuario) { + const modalEnv = clonarPlantilla('tpl-modal-licencia'); + document.body.appendChild(modalEnv); + + document.getElementById('texto-prof-licencia').textContent = + `${usuario.nombre_completo} — estado actual: ${usuario.estado_licencia || 'activo'}.`; + + const selectEstado = document.getElementById('select-estado-licencia'); + const selectDias = document.getElementById('select-dias-licencia'); + const campoDias = document.getElementById('campo-dias-licencia'); + + selectEstado.value = usuario.estado_licencia || 'activo'; + if (usuario.licencia_dias) selectDias.value = String(usuario.licencia_dias); + + selectEstado.addEventListener('change', () => { + campoDias.style.opacity = selectEstado.value === 'activo' ? '1' : '0.4'; + campoDias.style.pointerEvents = selectEstado.value === 'activo' ? 'auto' : 'none'; + }); + if (selectEstado.value !== 'activo') { + campoDias.style.opacity = '0.4'; + campoDias.style.pointerEvents = 'none'; + } + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-licencia').addEventListener('click', cerrar); + document.getElementById('btn-confirmar-licencia').addEventListener('click', async () => { + const estado = selectEstado.value; + const dias = selectDias.value; + const btn = document.getElementById('btn-confirmar-licencia'); + btn.disabled = true; + btn.innerHTML = ' Guardando...'; + try { + await llamarApi(API.auth, { + method: 'POST', + body: JSON.stringify({ accion: 'actualizar_licencia', usuario_id: usuario.id, estado, dias }), + }); + mostrarToast('Licencia actualizada.', 'exito'); + cerrar(); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + btn.disabled = false; + btn.textContent = 'Guardar'; + } + }); +} + +async function montarVistaMiLegajo(contenido) { + contenido.appendChild(clonarPlantilla('tpl-mi-legajo')); + const cont = document.getElementById('contenido-mi-legajo'); + try { + const res = await llamarApi(`${API.auth}?accion=obtener_legajo_profesional`, { + method: 'POST', body: JSON.stringify({ accion: 'obtener_legajo_profesional' }), + }); + const l = res.datos; + const venc = l.licencia_vencimiento + ? `Vence el ${formatearFechaCorta(l.licencia_vencimiento)}` + : 'Sin vencimiento (indeterminada)'; + const estadoColor = { activo: 'var(--salvia-oscuro)', suspendido: 'var(--coral)', pausado: 'var(--arena)', prohibido: 'var(--coral)' }; + cont.innerHTML = ` +
+
+
+
${escaparHtml(l.titulo + ' ' + l.nombre + ' ' + l.apellido)}
+ ${l.numero_legajo ? `

Legajo ${escaparHtml(l.numero_legajo)}

` : ''} + ${l.especialidad ? `

${escaparHtml(l.especialidad)}

` : ''} +
+ ${l.estado_licencia || 'activo'} +
+
+
DNI
${escaparHtml(l.dni || '—')}
+
Fecha de nacimiento
${l.fecha_nacimiento ? formatearFechaCorta(l.fecha_nacimiento) : '—'}
+
Lugar de nacimiento
${escaparHtml(l.lugar_nacimiento || '—')}
+
Correo
${escaparHtml(l.email || '—')}
+
Celular
${escaparHtml(l.telefono || '—')}
+
Licencia
${venc}
+
+

Si necesitás corregir algún dato, comunicate con el administrador del sistema.

+
`; + } catch (e) { + cont.innerHTML = '

No se encontró tu legajo. Comunicate con el administrador del sistema.

'; + } +} + +async function abrirModalGestionarSedesUsuario(usuario) { + const modalEnv = clonarPlantilla('tpl-modal-gestionar-sedes-usuario'); + document.body.appendChild(modalEnv); + + document.getElementById('texto-nombre-usuario-gestionar-sedes').textContent = + `Marcá o destildá en qué sedes atiende ${usuario.nombre_completo}.`; + + const sedeIdsActuales = (usuario.sedes || []).map(s => s.id); + const contCheckboxes = document.getElementById('lista-checkboxes-sedes-editar'); + const avisoBorrado = document.getElementById('aviso-borrado-sedes'); + const bloqueConfirmar = document.getElementById('bloque-confirmar-borrado-sedes'); + const inputConfirmarTexto = document.getElementById('input-confirmar-borrado-sedes'); + const btnConfirmar = document.getElementById('btn-confirmar-gestionar-sedes'); + + contCheckboxes.innerHTML = '
'; + try { + const res = await llamarApi(`${API.auth}?accion=listar_sedes`, { method: 'POST', body: JSON.stringify({ accion: 'listar_sedes' }) }); + const sedesActivas = res.datos.filter(s => s.activa); + contCheckboxes.innerHTML = ''; + if (!sedesActivas.length) { + contCheckboxes.innerHTML = ''; + } else { + sedesActivas.forEach(s => { + const label = document.createElement('label'); + label.className = 'checkbox-sede-item'; + const marcado = sedeIdsActuales.includes(s.id) ? 'checked' : ''; + label.innerHTML = ` ${escaparHtml(s.nombre)}`; + contCheckboxes.appendChild(label); + }); + } + } catch (e) { + contCheckboxes.innerHTML = ''; + mostrarToast(e.message, 'error'); + } + + let totalPacientesABorrar = 0; + + async function revisarImpacto() { + const sedeIdsElegidas = Array.from(document.querySelectorAll('.checkbox-sede-editar:checked')).map(c => parseInt(c.value, 10)); + try { + const res = await llamarApi(`${API.auth}?accion=previsualizar_cambio_sedes`, { + method: 'POST', + body: JSON.stringify({ accion: 'previsualizar_cambio_sedes', usuario_id: usuario.id, sede_ids: sedeIdsElegidas }), + }); + totalPacientesABorrar = res.total_pacientes_a_borrar; + if (totalPacientesABorrar > 0) { + const detalle = res.sedes_que_se_quitan.map(s => `${s.pacientes} legajo(s) en "${s.nombre}"`).join(', '); + avisoBorrado.textContent = `⚠ Si guardás este cambio, se van a eliminar definitivamente ${totalPacientesABorrar} legajo(s): ${detalle}. Esta acción no se puede deshacer.`; + avisoBorrado.classList.remove('oculto'); + bloqueConfirmar.classList.remove('oculto'); + btnConfirmar.disabled = true; + btnConfirmar.textContent = 'Guardar cambios'; + } else { + avisoBorrado.classList.add('oculto'); + bloqueConfirmar.classList.add('oculto'); + inputConfirmarTexto.value = ''; + btnConfirmar.disabled = false; + btnConfirmar.textContent = 'Guardar cambios'; + } + } catch (e) { + mostrarToast(e.message, 'error'); + } + } + + contCheckboxes.addEventListener('change', revisarImpacto); + inputConfirmarTexto.addEventListener('input', () => { + if (totalPacientesABorrar > 0) { + btnConfirmar.disabled = inputConfirmarTexto.value.trim().toUpperCase() !== 'ELIMINAR'; + } + }); + + function cerrar() { modalEnv.remove(); } + document.getElementById('btn-cancelar-gestionar-sedes').addEventListener('click', cerrar); + + btnConfirmar.addEventListener('click', async () => { + const sedeIdsElegidas = Array.from(document.querySelectorAll('.checkbox-sede-editar:checked')).map(c => parseInt(c.value, 10)); + + if (!sedeIdsElegidas.length) { mostrarToast('Elegí al menos una sede.', 'error'); return; } + if (totalPacientesABorrar > 0 && inputConfirmarTexto.value.trim().toUpperCase() !== 'ELIMINAR') { + mostrarToast('Escribí ELIMINAR para confirmar el borrado de legajos.', 'error'); + return; + } + + btnConfirmar.disabled = true; + btnConfirmar.innerHTML = ' Guardando...'; + try { + const res = await llamarApi(`${API.auth}`, { + method: 'POST', + body: JSON.stringify({ accion: 'actualizar_sedes_usuario', usuario_id: usuario.id, sede_ids: sedeIdsElegidas }), + }); + mostrarToast( + res.pacientes_borrados > 0 + ? `Sedes actualizadas. Se eliminaron ${res.pacientes_borrados} legajo(s).` + : 'Sedes actualizadas correctamente.', + 'exito' + ); + cerrar(); + cargarListaUsuarios(); + } catch (e) { + mostrarToast(e.message, 'error'); + btnConfirmar.disabled = false; + btnConfirmar.textContent = 'Guardar cambios'; + } + }); +} + +async function cargarHistorialCambios(pagina) { + const cont = document.getElementById('lista-historial'); + const selectFiltro = document.getElementById('select-filtro-historial'); + + // El filtro por tipo de entidad solo tiene sentido para el + // Desarrollador (es el único que ve TODO el historial mezclado). + if (selectFiltro) { + const filaFiltro = selectFiltro.closest('.fila-buscador-usuarios'); + if (ROL_ACTUAL !== 'desarrollador') { + if (filaFiltro) filaFiltro.style.display = 'none'; + } else if (filaFiltro) { + filaFiltro.style.display = 'flex'; + if (!selectFiltro.dataset.conectado) { + selectFiltro.dataset.conectado = '1'; + selectFiltro.addEventListener('change', () => cargarHistorialCambios(1)); + } + } + } + + const entidad = selectFiltro ? selectFiltro.value : ''; + cont.innerHTML = '
'; + try { + const url = `${API.admin}?accion=historial&pagina=${pagina}${entidad ? '&entidad=' + entidad : ''}`; + const res = await llamarApi(url, { method: 'GET' }); + cont.innerHTML = ''; + if (!res.datos.length) { + cont.innerHTML = '

Todavía no hay cambios registrados.

'; + document.getElementById('paginado-historial').innerHTML = ''; + return; + } + const accionesTexto = { crear: 'Creó', editar: 'Editó', eliminar: 'Eliminó', desactivar: 'Desactivó' }; + res.datos.forEach(h => { + const fila = document.createElement('div'); + fila.className = 'item-historial'; + const fecha = new Date(h.creado_en).toLocaleString('es-AR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); + fila.innerHTML = ` +
${fecha}
+
+ ${escaparHtml(h.usuario_nombre || 'Usuario eliminado')} + ${(accionesTexto[h.accion] || h.accion).toLowerCase()} + ${escaparHtml(h.descripcion || '')} +
+ `; + cont.appendChild(fila); + }); + + const totalPaginas = Math.max(1, Math.ceil(res.total / 40)); + const paginadoEl = document.getElementById('paginado-historial'); + paginadoEl.innerHTML = ''; + if (totalPaginas > 1) { + for (let i = 1; i <= totalPaginas; i++) { + const btnPagina = document.createElement('button'); + btnPagina.className = 'btn-pagina' + (i === pagina ? ' activo' : ''); + btnPagina.textContent = i; + btnPagina.addEventListener('click', () => cargarHistorialCambios(i)); + paginadoEl.appendChild(btnPagina); + } + } + } catch (e) { + cont.innerHTML = ''; + mostrarToast(e.message, 'error'); + } +} diff --git a/confirmar_turno.php b/confirmar_turno.php new file mode 100644 index 0000000..56c92b6 --- /dev/null +++ b/confirmar_turno.php @@ -0,0 +1,171 @@ +prepare(' + SELECT c.*, p.nombre, p.apellido + FROM citas c + INNER JOIN pacientes p ON p.id = c.paciente_id + WHERE c.token_confirmacion = ? +'); +$stmt->execute([$token]); +$cita = $stmt->fetch(); + +$mensaje = null; +$tipoMensaje = null; + +if ($cita && $_SERVER['REQUEST_METHOD'] === 'POST' && in_array($accionPost, ['confirmar', 'cancelar'])) { + if ($cita['estado'] !== 'pendiente') { + $mensaje = 'Este turno ya no está pendiente, así que no se puede modificar desde acá.'; + $tipoMensaje = 'info'; + } else if ($accionPost === 'confirmar') { + $pdo->prepare('UPDATE citas SET confirmada_por_paciente = 1, revisada_por_profesional = 0 WHERE id = ?')->execute([$cita['id']]); + $mensaje = '¡Listo! Tu turno quedó confirmado.'; + $tipoMensaje = 'exito'; + $cita['confirmada_por_paciente'] = 1; + } else if ($accionPost === 'cancelar') { + $pdo->prepare('UPDATE citas SET estado = "cancelada", revisada_por_profesional = 0 WHERE id = ?')->execute([$cita['id']]); + $mensaje = 'Tu turno quedó cancelado. Si necesitás reprogramarlo, comunicate con el consultorio.'; + $tipoMensaje = 'info'; + $cita['estado'] = 'cancelada'; + } +} +?> + + + + + +Confirmar turno — Del Austral + + + +
+ + + +

Del Austral

+ Confirmación de turno + + +

No encontramos este turno. El link puede haber expirado o ser incorrecto.

+ + + +
+ + +
+
+ +
+ + $cita['confirmada_por_paciente'] ? ['Confirmado', 'confirmada'] : ['Pendiente de confirmar', 'pendiente'], + 'cancelada' => ['Cancelado', 'cancelada'], + 'atendida' => ['Ya atendido', 'confirmada'], + 'ausente' => ['Marcado como ausente', 'cancelada'], + ]; + [$textoEstado, $claseEstado] = $etiquetas[$cita['estado']] ?? ['—', 'pendiente']; + ?> + +
+ + +
+ + +
+ +
+ +
+ + + +
+ + diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..f09a5db --- /dev/null +++ b/database.sql @@ -0,0 +1,251 @@ +-- ============================================================ +-- Base de datos: Del Austral — Historial Clínico Digital +-- Importar este archivo desde phpMyAdmin (pestaña "Importar") +-- dentro de la base de datos que crees en cPanel. +-- +-- Este esquema ya incluye TODO (sedes, múltiples profesionales, +-- rol desarrollador, confirmación de turnos, etc.) — es para una +-- instalación NUEVA, desde cero. Si ya tenías el sistema andando +-- con pacientes cargados, no uses este archivo: usá en cambio +-- las migraciones (migracion_v2.sql en adelante) en orden. +-- ============================================================ + +SET NAMES utf8mb4; +SET time_zone = '-03:00'; + +-- ------------------------------------------------------------ +-- Tabla: sedes (sucursales / lugares de atención) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS sedes ( + id INT PRIMARY KEY AUTO_INCREMENT, + nombre VARCHAR(150) NOT NULL UNIQUE, + activa TINYINT(1) NOT NULL DEFAULT 1, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: desarrollador (contraseña única, separada de "usuarios") +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS desarrollador ( + id INT PRIMARY KEY AUTO_INCREMENT, + clave_hash VARCHAR(255) NOT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: usuarios (profesionales y administrativas; cada uno +-- con su propio PIN de 4 dígitos, hasheado) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS usuarios ( + id INT PRIMARY KEY AUTO_INCREMENT, + nombre_completo VARCHAR(150) NOT NULL, + rol ENUM('desarrollador', 'profesional', 'administrativa') NOT NULL DEFAULT 'profesional', + patron_hash VARCHAR(255) NOT NULL, + activo TINYINT(1) NOT NULL DEFAULT 1, + estado_licencia ENUM('activo','suspendido','pausado','prohibido') NOT NULL DEFAULT 'activo', + licencia_dias SMALLINT UNSIGNED NULL, + licencia_inicio DATE NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: usuarios_sedes (en qué sede(s) atiende cada usuario) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS usuarios_sedes ( + usuario_id INT NOT NULL, + sede_id INT NOT NULL, + PRIMARY KEY (usuario_id, sede_id), + FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE, + FOREIGN KEY (sede_id) REFERENCES sedes(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: obras_sociales (catálogo editable de obras sociales) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS profesionales_legajos ( + id INT PRIMARY KEY AUTO_INCREMENT, + usuario_id INT NOT NULL UNIQUE, + titulo ENUM('Dr.','Dra.','Lic.','Tec.','Mg.','Prof.','Otro') NOT NULL DEFAULT 'Dr.', + nombre VARCHAR(80) NOT NULL, + apellido VARCHAR(80) NOT NULL, + dni VARCHAR(20) NULL, + fecha_nacimiento DATE NULL, + lugar_nacimiento VARCHAR(150) NULL, + especialidad VARCHAR(150) NULL, + email VARCHAR(150) NULL, + telefono VARCHAR(40) NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS obras_sociales ( + id INT PRIMARY KEY AUTO_INCREMENT, + nombre VARCHAR(120) NOT NULL UNIQUE, + es_predefinida TINYINT(1) NOT NULL DEFAULT 0, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO obras_sociales (nombre, es_predefinida) VALUES + ('Particular (sin obra social)', 1), + ('OSDE', 1), + ('Swiss Medical', 1), + ('Galeno', 1), + ('Medifé', 1), + ('IOMA', 1), + ('PAMI', 1), + ('Unión Personal', 1), + ('OSDEPYM', 1), + ('Jerárquicos Salud', 1), + ('Sancor Salud', 1), + ('Seros', 1), + ('Accord Salud', 1), + ('ACA Salud', 1), + ('Apross', 1), + ('OSECAC', 1), + ('Luis Pasteur', 1), + ('Hospital Italiano (Plan de Salud)', 1) +ON DUPLICATE KEY UPDATE nombre = nombre; + +-- ------------------------------------------------------------ +-- Tabla: pacientes (legajo principal) +-- Cada paciente pertenece a UN profesional (aislamiento total +-- entre profesionales) y a UNA sede (puede migrarse después). +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS pacientes ( + id INT PRIMARY KEY AUTO_INCREMENT, + profesional_id INT NULL, + sede_id INT NULL, + recuperado_de_profesional VARCHAR(150) NULL, + nombre VARCHAR(100) NOT NULL, + apellido VARCHAR(100) NOT NULL, + dni VARCHAR(20) NOT NULL, + fecha_nacimiento DATE NOT NULL, + sexo ENUM('Femenino', 'Masculino', 'Otro') NOT NULL, + obra_social_id INT NULL, + numero_afiliado VARCHAR(60) NULL, + telefono VARCHAR(40) NULL, + email VARCHAR(150) NULL, + direccion VARCHAR(200) NULL, + motivo_consulta TEXT NULL, + patologia VARCHAR(255) NULL, + sintomas TEXT NULL, + observaciones_generales TEXT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (profesional_id) REFERENCES usuarios(id) ON DELETE SET NULL, + FOREIGN KEY (sede_id) REFERENCES sedes(id) ON DELETE SET NULL, + FOREIGN KEY (obra_social_id) REFERENCES obras_sociales(id) ON DELETE SET NULL, + UNIQUE INDEX idx_dni_por_profesional (profesional_id, dni), + INDEX idx_apellido_nombre (apellido, nombre), + INDEX idx_obra_social (obra_social_id), + INDEX idx_pacientes_profesional (profesional_id), + INDEX idx_pacientes_sede (sede_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: sesiones (cada día/sesión de atención dentro de un legajo) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS sesiones ( + id INT PRIMARY KEY AUTO_INCREMENT, + paciente_id INT NOT NULL, + fecha_sesion DATE NOT NULL, + descripcion TEXT NOT NULL, + evolucion TEXT NULL, + proxima_cita DATE NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (paciente_id) REFERENCES pacientes(id) ON DELETE CASCADE, + INDEX idx_paciente_fecha (paciente_id, fecha_sesion) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: legajos_eliminados (papelera / base histórica) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS legajos_eliminados ( + id INT PRIMARY KEY AUTO_INCREMENT, + paciente_id_original INT NOT NULL, + profesional_id_original INT NULL, + sede_id_original INT NULL, + nombre_completo VARCHAR(220) NOT NULL, + dni VARCHAR(20) NOT NULL, + datos_json LONGTEXT NOT NULL, + eliminado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_dni_eliminado (dni), + INDEX idx_papelera_profesional (profesional_id_original), + INDEX idx_papelera_sede (sede_id_original) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: citas (agenda). El choque de horario se valida por +-- profesional_id + fecha + hora, sin importar la sede. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS citas ( + id INT PRIMARY KEY AUTO_INCREMENT, + paciente_id INT NOT NULL, + profesional_id INT NULL, + fecha DATE NOT NULL, + hora TIME NULL, + motivo VARCHAR(255) NULL, + estado ENUM('pendiente', 'atendida', 'cancelada', 'ausente') NOT NULL DEFAULT 'pendiente', + notas TEXT NULL, + token_confirmacion VARCHAR(64) NULL, + confirmada_por_paciente TINYINT(1) NOT NULL DEFAULT 0, + revisada_por_profesional TINYINT(1) NOT NULL DEFAULT 1, + sesion_generada_id INT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (paciente_id) REFERENCES pacientes(id) ON DELETE CASCADE, + FOREIGN KEY (profesional_id) REFERENCES usuarios(id) ON DELETE SET NULL, + INDEX idx_citas_fecha (fecha, hora), + INDEX idx_citas_paciente (paciente_id), + INDEX idx_citas_choque (profesional_id, fecha, hora), + UNIQUE INDEX idx_citas_token (token_confirmacion) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: archivos_adjuntos (PDF e imágenes ligados a un legajo) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS archivos_adjuntos ( + id INT PRIMARY KEY AUTO_INCREMENT, + paciente_id INT NOT NULL, + sesion_id INT NULL, + nombre_original VARCHAR(255) NOT NULL, + nombre_archivo VARCHAR(255) NOT NULL, + tipo_mime VARCHAR(100) NOT NULL, + tamanio_bytes INT NOT NULL, + descripcion VARCHAR(255) NULL, + subido_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (paciente_id) REFERENCES pacientes(id) ON DELETE CASCADE, + FOREIGN KEY (sesion_id) REFERENCES sesiones(id) ON DELETE SET NULL, + INDEX idx_adjuntos_paciente (paciente_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: plantillas_evolucion (texto libre reutilizable, +-- cada profesional tiene las suyas, no se comparten) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS plantillas_evolucion ( + id INT PRIMARY KEY AUTO_INCREMENT, + profesional_id INT NULL, + nombre VARCHAR(120) NOT NULL, + contenido TEXT NOT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (profesional_id) REFERENCES usuarios(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- Tabla: historial_cambios (auditoría de quién hizo qué) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS historial_cambios ( + id INT PRIMARY KEY AUTO_INCREMENT, + usuario_id INT NULL, + usuario_nombre VARCHAR(150) NULL, + accion VARCHAR(60) NOT NULL, + entidad VARCHAR(60) NOT NULL, + entidad_id INT NULL, + descripcion VARCHAR(500) NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_historial_entidad (entidad, entidad_id), + INDEX idx_historial_fecha (creado_en) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/exportar.php b/exportar.php new file mode 100644 index 0000000..c94d7fc --- /dev/null +++ b/exportar.php @@ -0,0 +1,267 @@ +Acceso no disponible + +

No tenés permiso para ver este documento

+

La exportación de legajos clínicos está disponible solo para el usuario profesional.

+ '; + exit; +} + +$pdo = obtenerConexion(); +$id = $_GET['id'] ?? 0; +$profesionalActivoId = idProfesionalActivo(); + +function calcularEdadExport($fechaNacimiento) { + try { + $nacimiento = new DateTime($fechaNacimiento); + $hoy = new DateTime(); + return $hoy->diff($nacimiento)->y; + } catch (Exception $e) { + return null; + } +} + +$stmt = $pdo->prepare(' + SELECT p.*, o.nombre AS obra_social_nombre + FROM pacientes p + LEFT JOIN obras_sociales o ON o.id = p.obra_social_id + WHERE p.id = ? AND p.profesional_id = ? +'); +$stmt->execute([$id, $profesionalActivoId]); +$paciente = $stmt->fetch(); + +if (!$paciente) { + http_response_code(404); + echo 'Legajo no encontrado.'; + exit; +} + +$stmtSesiones = $pdo->prepare('SELECT * FROM sesiones WHERE paciente_id = ? ORDER BY fecha_sesion ASC'); +$stmtSesiones->execute([$id]); +$sesiones = $stmtSesiones->fetchAll(); + +$stmtNombreProf = $pdo->prepare('SELECT nombre_completo FROM usuarios WHERE id = ?'); +$stmtNombreProf->execute([$profesionalActivoId]); +$filaProf = $stmtNombreProf->fetch(); +$nombreProfesional = $filaProf ? $filaProf['nombre_completo'] : ''; + +function e($texto) { + return htmlspecialchars($texto ?? '', ENT_QUOTES, 'UTF-8'); +} + +function fechaLegible($fechaIso) { + if (!$fechaIso) return '—'; + $meses = ['enero','febrero','marzo','abril','mayo','junio','julio','agosto','septiembre','octubre','noviembre','diciembre']; + $partes = explode('-', substr($fechaIso, 0, 10)); + if (count($partes) !== 3) return e($fechaIso); + return (int)$partes[2] . ' de ' . $meses[(int)$partes[1] - 1] . ' de ' . $partes[0]; +} + +$edad = calcularEdadExport($paciente['fecha_nacimiento']); +?> + + + + +Legajo — <?= e($paciente['apellido'] . ', ' . $paciente['nombre']) ?> + + + + +
+ Vista de exportación — usá Ctrl/Cmd + P y elegí "Guardar como PDF" + +
+ +
+
+

Del Austral

+
+
+
Documento generado el
+
+ +

+
Legajo clínico — paciente desde
+ +
+
DNI
+
Edad
+
Sexo
+
Obra social
+
+ +
+

Motivo de consulta

+

+
+
+

Patología

+

+
+
+

Síntomas

+

+
+
+

Observaciones generales

+

+
+ +
Historial de sesiones ()
+ + +

Todavía no se registraron sesiones para este paciente.

+ + +
+
+
+ +
+ +
+ + + + +
+
+
+
Profesional responsable del seguimiento clínico
+
+ + +
+ Este documento contiene información clínica protegida por la Ley N.º 25.326 de Protección de Datos Personales. + Su divulgación a terceros está prohibida salvo autorización expresa del paciente o requerimiento legal. +
+ + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..93e102f --- /dev/null +++ b/index.html @@ -0,0 +1,1332 @@ + + + + + +Del Austral · Historial Clínico + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + +

Del Austral

+ Historial clínico digital +
+ + + + + + + + + + + + + + + + + + + +

Cargando...

+
+
+ + +
+
+
+ + + +

¡Bienvenido, Desarrollador!

+ Antes de que el consultorio pueda usar el sistema, creá la primera sede y el primer profesional. +
+ +
+ + +
+ +
+ + +
+
+ + +
+ + + +
+
+ + + + + +
+ +
+
+ + + +

Del Austral

+
+
+ + +
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ebfb9c6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,32 @@ +{ + "name": "Del Austral — Historial Clínico", + "short_name": "Del Austral", + "description": "Historial clínico digital para consultorios de salud.", + "start_url": "./index.html", + "scope": "./", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#F7F4ED", + "theme_color": "#3D6B63", + "lang": "es-AR", + "icons": [ + { + "src": "assets/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "assets/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "assets/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/migracion_v10.sql b/migracion_v10.sql new file mode 100644 index 0000000..4055920 --- /dev/null +++ b/migracion_v10.sql @@ -0,0 +1,66 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 10) +-- ============================================================ +-- Agrega: +-- 1. Columnas de licencia en "usuarios": estado, duración e +-- inicio, para controlar si un profesional puede acceder. +-- 2. Tabla "profesionales_legajos" con los datos personales +-- completos del profesional (título, DNI, especialidad, etc.) +-- +-- No borra ni modifica pacientes, sesiones, citas ni adjuntos. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +-- Sistema de licencias en la tabla de usuarios. +-- estado_licencia: +-- 'activo' → puede entrar normalmente +-- 'suspendido' → la licencia por días venció automáticamente +-- 'pausado' → el Desarrollador lo pausó manualmente +-- 'prohibido' → el Desarrollador lo prohibió (ej: falta de pago) +-- licencia_dias: NULL = indeterminado (sin vencimiento por tiempo) +-- licencia_inicio: cuándo se activó la licencia actual +ALTER TABLE usuarios ADD COLUMN estado_licencia ENUM('activo','suspendido','pausado','prohibido') NOT NULL DEFAULT 'activo' AFTER activo; +ALTER TABLE usuarios ADD COLUMN licencia_dias SMALLINT UNSIGNED NULL AFTER estado_licencia; +ALTER TABLE usuarios ADD COLUMN licencia_inicio DATE NULL AFTER licencia_dias; + +-- Los usuarios que ya existían quedan con estado 'activo' +-- y sin vencimiento (indeterminado), para no interrumpir nada. +UPDATE usuarios SET estado_licencia = 'activo', licencia_dias = NULL, licencia_inicio = CURDATE() WHERE rol = 'profesional'; + +-- Tabla de legajos de profesionales. +-- Cada fila corresponde a un usuario con rol 'profesional'. +CREATE TABLE IF NOT EXISTS profesionales_legajos ( + id INT PRIMARY KEY AUTO_INCREMENT, + usuario_id INT NOT NULL UNIQUE, + titulo ENUM('Dr.','Dra.','Lic.','Tec.','Mg.','Prof.','Otro') NOT NULL DEFAULT 'Dr.', + nombre VARCHAR(80) NOT NULL, + apellido VARCHAR(80) NOT NULL, + dni VARCHAR(20) NULL, + fecha_nacimiento DATE NULL, + lugar_nacimiento VARCHAR(150) NULL, + especialidad VARCHAR(150) NULL, + email VARCHAR(150) NULL, + telefono VARCHAR(40) NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Para los profesionales que ya existían, creamos un legajo +-- básico usando el nombre que ya tenían en la tabla usuarios, +-- para que aparezcan en la nueva vista sin tener que +-- completarlos manualmente desde cero. +INSERT INTO profesionales_legajos (usuario_id, titulo, nombre, apellido) +SELECT id, + 'Dr.', + SUBSTRING_INDEX(nombre_completo, ' ', 1), + SUBSTRING(nombre_completo, LOCATE(' ', nombre_completo) + 1) +FROM usuarios +WHERE rol = 'profesional' +ON DUPLICATE KEY UPDATE usuario_id = usuario_id; diff --git a/migracion_v11.sql b/migracion_v11.sql new file mode 100644 index 0000000..a04c6ef --- /dev/null +++ b/migracion_v11.sql @@ -0,0 +1,33 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 11) +-- ============================================================ +-- Agrega a "profesionales_legajos" una columna para el número +-- de legajo con formato LG-YYYY-NNN (año + correlativo). +-- Los profesionales ya existentes reciben un número automático. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +ALTER TABLE profesionales_legajos + ADD COLUMN numero_legajo VARCHAR(20) NULL UNIQUE AFTER usuario_id; + +-- Asignar números correlativos a los que ya existen, +-- usando el año de creación y un correlativo por año. +SET @anio = 0; +SET @contador = 0; + +UPDATE profesionales_legajos pl +JOIN ( + SELECT id, + YEAR(creado_en) AS anio, + @contador := IF(@anio = YEAR(creado_en), @contador + 1, 1) AS correlativo, + @anio := YEAR(creado_en) AS anio_actual + FROM profesionales_legajos + ORDER BY creado_en ASC +) ranked ON pl.id = ranked.id +SET pl.numero_legajo = CONCAT('LG-', ranked.anio, '-', LPAD(ranked.correlativo, 3, '0')); diff --git a/migracion_v2.sql b/migracion_v2.sql new file mode 100644 index 0000000..1b27bb1 --- /dev/null +++ b/migracion_v2.sql @@ -0,0 +1,86 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 2) +-- ============================================================ +-- Este script SUMA tablas y columnas nuevas a una base que ya +-- tiene tus pacientes cargados. No borra ni modifica los datos +-- existentes. Es seguro ejecutarlo aunque ya tengas legajos. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------ +-- 1) CITAS (agenda) — independiente de "sesiones" +-- Una cita es algo agendado (puede cumplirse, cancelarse o +-- quedar pendiente). Una sesión es un registro de algo que +-- YA pasó. Antes "proxima_cita" vivía adentro de sesiones; +-- ahora tiene su propia tabla para poder agendar, cancelar +-- y marcar asistencia de forma prolija. +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS citas ( + id INT PRIMARY KEY AUTO_INCREMENT, + paciente_id INT NOT NULL, + fecha DATE NOT NULL, + hora TIME NULL, + motivo VARCHAR(255) NULL, + estado ENUM('pendiente', 'atendida', 'cancelada', 'ausente') NOT NULL DEFAULT 'pendiente', + notas TEXT NULL, + sesion_generada_id INT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (paciente_id) REFERENCES pacientes(id) ON DELETE CASCADE, + INDEX idx_citas_fecha (fecha, hora), + INDEX idx_citas_paciente (paciente_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migrar las "próximas citas" que ya existan dentro de sesiones +-- hacia la nueva tabla citas, para no perder esa información. +INSERT INTO citas (paciente_id, fecha, motivo, estado) +SELECT s.paciente_id, s.proxima_cita, 'Migrada automáticamente desde sesión anterior', 'pendiente' +FROM sesiones s +WHERE s.proxima_cita IS NOT NULL + AND s.proxima_cita >= CURDATE() + AND NOT EXISTS ( + SELECT 1 FROM citas c + WHERE c.paciente_id = s.paciente_id AND c.fecha = s.proxima_cita + ); + +-- ------------------------------------------------------------ +-- 2) ARCHIVOS ADJUNTOS (PDF e imágenes ligados a un legajo +-- y, opcionalmente, a una sesión puntual) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS archivos_adjuntos ( + id INT PRIMARY KEY AUTO_INCREMENT, + paciente_id INT NOT NULL, + sesion_id INT NULL, + nombre_original VARCHAR(255) NOT NULL, + nombre_archivo VARCHAR(255) NOT NULL, + tipo_mime VARCHAR(100) NOT NULL, + tamanio_bytes INT NOT NULL, + descripcion VARCHAR(255) NULL, + subido_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (paciente_id) REFERENCES pacientes(id) ON DELETE CASCADE, + FOREIGN KEY (sesion_id) REFERENCES sesiones(id) ON DELETE SET NULL, + INDEX idx_adjuntos_paciente (paciente_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- 3) PLANTILLAS DE EVOLUCIÓN (texto libre, reutilizable, +-- creadas por el profesional para agilizar las sesiones) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS plantillas_evolucion ( + id INT PRIMARY KEY AUTO_INCREMENT, + nombre VARCHAR(120) NOT NULL, + contenido TEXT NOT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + actualizado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- 4) Comentario informativo: la columna pacientes.actualizado_en +-- ya existe desde el inicio y se sigue usando para "Editar legajo". +-- No se requieren columnas nuevas en pacientes ni sesiones. +-- ------------------------------------------------------------ diff --git a/migracion_v3.sql b/migracion_v3.sql new file mode 100644 index 0000000..d4eaef8 --- /dev/null +++ b/migracion_v3.sql @@ -0,0 +1,56 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 3) +-- ============================================================ +-- Suma: roles de usuario (profesional / administrativa) con +-- patrones propios, e historial de cambios (auditoría). +-- No borra ni modifica datos existentes. Es seguro ejecutarlo +-- aunque ya tengas legajos y un patrón configurado. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------ +-- 1) USUARIOS (reemplaza el patrón único guardado en +-- "configuracion" por una tabla con uno o más usuarios, +-- cada uno con su propio patrón y rol) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS usuarios ( + id INT PRIMARY KEY AUTO_INCREMENT, + nombre_completo VARCHAR(150) NOT NULL, + rol ENUM('profesional', 'administrativa') NOT NULL DEFAULT 'profesional', + patron_hash VARCHAR(255) NOT NULL, + activo TINYINT(1) NOT NULL DEFAULT 1, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migrar el patrón y nombre que ya tenías guardados en "configuracion" +-- hacia la nueva tabla "usuarios", como el primer usuario (profesional). +INSERT INTO usuarios (nombre_completo, rol, patron_hash) +SELECT + COALESCE((SELECT valor FROM configuracion WHERE clave = 'nombre_profesional'), 'Profesional'), + 'profesional', + (SELECT valor FROM configuracion WHERE clave = 'patron_hash') +WHERE + (SELECT valor FROM configuracion WHERE clave = 'patron_hash') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM usuarios WHERE rol = 'profesional'); + +-- ------------------------------------------------------------ +-- 2) HISTORIAL DE CAMBIOS (auditoría) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS historial_cambios ( + id INT PRIMARY KEY AUTO_INCREMENT, + usuario_id INT NULL, + usuario_nombre VARCHAR(150) NULL, + accion VARCHAR(60) NOT NULL, + entidad VARCHAR(60) NOT NULL, + entidad_id INT NULL, + descripcion VARCHAR(500) NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_historial_entidad (entidad, entidad_id), + INDEX idx_historial_fecha (creado_en) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/migracion_v4.sql b/migracion_v4.sql new file mode 100644 index 0000000..dafabb5 --- /dev/null +++ b/migracion_v4.sql @@ -0,0 +1,30 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 4) +-- ============================================================ +-- El sistema de acceso cambió de "patrón dibujado" a "PIN +-- numérico de 4 dígitos". Son datos distintos: un patrón +-- guardado (ej. la secuencia de puntos "0-1-2-4") nunca va a +-- coincidir con un PIN de 4 números, así que hay que vaciar los +-- accesos viejos para que el sistema vuelva a pedir crear un PIN. +-- +-- IMPORTANTE: esto NO borra pacientes, sesiones, citas, adjuntos +-- ni ningún dato clínico. Solo borra las filas de la tabla +-- "usuarios" (los accesos), porque sus patrones ya no sirven. +-- Vas a tener que volver a crear tu PIN (y el de tu administrativa +-- si ya tenías una) la primera vez que entres después de esto. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- 4. Solo corré esto UNA VEZ. Si lo corrés de nuevo después de +-- haber creado tu PIN, vas a tener que volver a crearlo. +-- ============================================================ + +SET NAMES utf8mb4; + +DELETE FROM usuarios; + +-- El historial de cambios y todos los demás datos quedan intactos. +-- Al volver a entrar al sitio, el sistema va a mostrar la pantalla +-- de "creá tu PIN" como la primera vez. diff --git a/migracion_v5.sql b/migracion_v5.sql new file mode 100644 index 0000000..fa717dc --- /dev/null +++ b/migracion_v5.sql @@ -0,0 +1,139 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 5) +-- ============================================================ +-- Suma: sedes (sucursales), aislamiento de pacientes por +-- profesional, rol "desarrollador" por encima de todo, token de +-- confirmación de turno por el paciente, y migración de +-- pacientes entre sedes. +-- +-- No borra pacientes, sesiones, citas ni adjuntos existentes. +-- Los pacientes y citas que ya tenías se asignan automáticamente +-- al primer usuario profesional que encuentre el script y a una +-- sede "Sede principal" creada para no dejar nada huérfano. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +-- ------------------------------------------------------------ +-- 1) SEDES (sucursales / lugares de atención) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS sedes ( + id INT PRIMARY KEY AUTO_INCREMENT, + nombre VARCHAR(150) NOT NULL UNIQUE, + activa TINYINT(1) NOT NULL DEFAULT 1, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Sede por defecto para no dejar huérfano nada de lo que ya existe. +INSERT INTO sedes (nombre) +SELECT 'Sede principal' +WHERE NOT EXISTS (SELECT 1 FROM sedes WHERE nombre = 'Sede principal'); + +-- ------------------------------------------------------------ +-- 2) RELACIÓN profesional ↔ sede (un profesional puede atender +-- en varias sedes; una sede tiene varios profesionales) +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS usuarios_sedes ( + usuario_id INT NOT NULL, + sede_id INT NOT NULL, + PRIMARY KEY (usuario_id, sede_id), + FOREIGN KEY (usuario_id) REFERENCES usuarios(id) ON DELETE CASCADE, + FOREIGN KEY (sede_id) REFERENCES sedes(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Asociar todos los usuarios profesionales/administrativos existentes +-- a la "Sede principal", para que no queden sin sede asignada. +INSERT INTO usuarios_sedes (usuario_id, sede_id) +SELECT u.id, (SELECT id FROM sedes WHERE nombre = 'Sede principal') +FROM usuarios u +WHERE NOT EXISTS (SELECT 1 FROM usuarios_sedes us WHERE us.usuario_id = u.id); + +-- ------------------------------------------------------------ +-- 3) ROL "desarrollador" — se agrega como valor nuevo del ENUM. +-- El desarrollador es el único que crea/desactiva usuarios. +-- ------------------------------------------------------------ +ALTER TABLE usuarios MODIFY COLUMN rol ENUM('desarrollador', 'profesional', 'administrativa') NOT NULL DEFAULT 'profesional'; + +-- ------------------------------------------------------------ +-- 4) CONTRASEÑA DE DESARROLLADOR (vive aparte de "usuarios", +-- porque el desarrollador no entra eligiendo sede/profesional +-- como todos los demás, entra por una puerta separada). +-- ------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS desarrollador ( + id INT PRIMARY KEY AUTO_INCREMENT, + clave_hash VARCHAR(255) NOT NULL, + creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ------------------------------------------------------------ +-- 5) PACIENTES: agregar dueño (profesional_id) y sede (sede_id). +-- Aislamiento total: cada profesional ve solo sus pacientes. +-- ------------------------------------------------------------ +ALTER TABLE pacientes ADD COLUMN profesional_id INT NULL AFTER id; +ALTER TABLE pacientes ADD COLUMN sede_id INT NULL AFTER profesional_id; + +-- Asignar los pacientes existentes al primer profesional activo +-- que encuentre el sistema, y a la Sede principal, para no dejar +-- legajos huérfanos. Si tenés más de un profesional, vas a poder +-- reasignarlos manualmente después desde el sistema. +UPDATE pacientes +SET profesional_id = (SELECT id FROM usuarios WHERE rol = 'profesional' AND activo = 1 ORDER BY id ASC LIMIT 1) +WHERE profesional_id IS NULL; + +UPDATE pacientes +SET sede_id = (SELECT id FROM sedes WHERE nombre = 'Sede principal') +WHERE sede_id IS NULL; + +ALTER TABLE pacientes ADD CONSTRAINT fk_pacientes_profesional + FOREIGN KEY (profesional_id) REFERENCES usuarios(id) ON DELETE SET NULL; +ALTER TABLE pacientes ADD CONSTRAINT fk_pacientes_sede + FOREIGN KEY (sede_id) REFERENCES sedes(id) ON DELETE SET NULL; + +ALTER TABLE pacientes ADD INDEX idx_pacientes_profesional (profesional_id); +ALTER TABLE pacientes ADD INDEX idx_pacientes_sede (sede_id); + +-- ------------------------------------------------------------ +-- 6) CITAS: agregar profesional dueño del turno (para validar +-- choques de horario sin tener que ir a buscarlo via paciente +-- cada vez) y token de confirmación pública (el paciente +-- confirma/cancela su turno sin necesidad de loguearse). +-- ------------------------------------------------------------ +ALTER TABLE citas ADD COLUMN profesional_id INT NULL AFTER paciente_id; + +UPDATE citas c +INNER JOIN pacientes p ON p.id = c.paciente_id +SET c.profesional_id = p.profesional_id +WHERE c.profesional_id IS NULL; + +ALTER TABLE citas ADD CONSTRAINT fk_citas_profesional + FOREIGN KEY (profesional_id) REFERENCES usuarios(id) ON DELETE SET NULL; + +-- Índice clave para detectar choques: mismo profesional, misma +-- fecha y hora, entre turnos pendientes. +ALTER TABLE citas ADD INDEX idx_citas_choque (profesional_id, fecha, hora); + +ALTER TABLE citas ADD COLUMN token_confirmacion VARCHAR(64) NULL AFTER notas; +ALTER TABLE citas ADD COLUMN confirmada_por_paciente TINYINT(1) NOT NULL DEFAULT 0 AFTER token_confirmacion; +ALTER TABLE citas ADD UNIQUE INDEX idx_citas_token (token_confirmacion); + +-- ------------------------------------------------------------ +-- 7) PLANTILLAS DE EVOLUCIÓN: ahora son por profesional, no +-- compartidas entre todos. +-- ------------------------------------------------------------ +ALTER TABLE plantillas_evolucion ADD COLUMN profesional_id INT NULL AFTER id; + +UPDATE plantillas_evolucion +SET profesional_id = (SELECT id FROM usuarios WHERE rol = 'profesional' AND activo = 1 ORDER BY id ASC LIMIT 1) +WHERE profesional_id IS NULL; + +ALTER TABLE plantillas_evolucion ADD CONSTRAINT fk_plantillas_profesional + FOREIGN KEY (profesional_id) REFERENCES usuarios(id) ON DELETE CASCADE; + +-- ============================================================ +-- Fin de la migración v5. +-- ============================================================ diff --git a/migracion_v6.sql b/migracion_v6.sql new file mode 100644 index 0000000..1257b91 --- /dev/null +++ b/migracion_v6.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 6) +-- ============================================================ +-- Suma: aviso al profesional cuando un paciente confirma o +-- cancela su turno desde el link público. El contador de +-- "consultas de hoy" no necesita columnas nuevas, se calcula +-- al vuelo comparando la hora de cada cita con la hora actual. +-- +-- No borra ni modifica pacientes, sesiones, citas ni adjuntos. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +ALTER TABLE citas ADD COLUMN revisada_por_profesional TINYINT(1) NOT NULL DEFAULT 1 AFTER confirmada_por_paciente; + +-- Las citas que ya existían antes de esta migración se marcan +-- como "ya revisadas", para no generar avisos retroactivos de +-- cambios que pasaron antes de esta actualización. +UPDATE citas SET revisada_por_profesional = 1; diff --git a/migracion_v7.sql b/migracion_v7.sql new file mode 100644 index 0000000..53b35b6 --- /dev/null +++ b/migracion_v7.sql @@ -0,0 +1,40 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 7) +-- ============================================================ +-- Agrega a la papelera (legajos_eliminados) las columnas +-- profesional_id_original y sede_id_original, para que el +-- Desarrollador pueda filtrar la papelera por profesional/sede +-- y recuperar un legajo asignándolo a otro profesional. +-- +-- No borra ni modifica pacientes, sesiones, citas ni adjuntos. +-- Los registros de papelera que ya existían antes de esta +-- migración van a intentar rellenarse automáticamente a partir +-- del JSON guardado (ver el UPDATE al final). Si por la versión +-- de tu MySQL ese paso no funciona, esos registros viejos +-- quedan en NULL — todavía se pueden ver, pero no aparecen al +-- filtrar la papelera por un profesional específico. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +ALTER TABLE legajos_eliminados ADD COLUMN profesional_id_original INT NULL AFTER paciente_id_original; +ALTER TABLE legajos_eliminados ADD COLUMN sede_id_original INT NULL AFTER profesional_id_original; + +ALTER TABLE legajos_eliminados ADD INDEX idx_papelera_profesional (profesional_id_original); +ALTER TABLE legajos_eliminados ADD INDEX idx_papelera_sede (sede_id_original); + +-- Intento de mejor esfuerzo: para los registros que ya existían, +-- rellenamos esas columnas a partir del JSON guardado, donde sea +-- posible (MySQL 5.7+/MariaDB 10.2+ con soporte de JSON_EXTRACT). +-- Si tu versión no soporta estas funciones, esta consulta puede +-- fallar — no afecta el resto de la migración, ya corrida arriba. +UPDATE legajos_eliminados +SET + profesional_id_original = JSON_UNQUOTE(JSON_EXTRACT(datos_json, '$.profesional_id')), + sede_id_original = JSON_UNQUOTE(JSON_EXTRACT(datos_json, '$.sede_id')) +WHERE profesional_id_original IS NULL; diff --git a/migracion_v8.sql b/migracion_v8.sql new file mode 100644 index 0000000..2d65895 --- /dev/null +++ b/migracion_v8.sql @@ -0,0 +1,25 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 8) +-- ============================================================ +-- Agrega a "pacientes" una columna para guardar el nombre del +-- profesional anterior, cuando un legajo se recupera de la +-- papelera y se le asigna a un profesional distinto del que lo +-- tenía antes de eliminarlo. Sirve para que el nuevo profesional +-- vea de un vistazo, en la ficha del paciente, que ese legajo +-- viene de otro médico. +-- +-- Se guarda el NOMBRE (texto), no el ID del profesional viejo, +-- porque ese profesional podría desactivarse en el futuro y +-- igual queremos que el dato siga siendo legible. +-- +-- No borra ni modifica pacientes, sesiones, citas ni adjuntos. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +ALTER TABLE pacientes ADD COLUMN recuperado_de_profesional VARCHAR(150) NULL AFTER sede_id; diff --git a/migracion_v9.sql b/migracion_v9.sql new file mode 100644 index 0000000..667b2e7 --- /dev/null +++ b/migracion_v9.sql @@ -0,0 +1,36 @@ +-- ============================================================ +-- Del Austral — Migración de base de datos (versión 9) +-- ============================================================ +-- Cambia la restricción de DNI único: antes era único en TODA +-- la tabla de pacientes, lo cual impedía que dos profesionales +-- distintos cargaran un paciente con el mismo DNI (algo que sí +-- puede pasar legítimamente, ya que cada profesional tiene sus +-- propios pacientes, completamente separados de los demás). +-- +-- Ahora el DNI tiene que ser único solo DENTRO de los pacientes +-- de un mismo profesional — sigue sin poder cargarse el mismo +-- DNI dos veces para el mismo profesional, pero sí puede +-- repetirse entre profesionales distintos. +-- +-- No borra ni modifica pacientes, sesiones, citas ni adjuntos. +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- ============================================================ + +SET NAMES utf8mb4; + +-- Quitamos el índice único global sobre "dni". El nombre del +-- índice suele ser igual al de la columna ("dni"), pero si tu +-- base de datos lo llamó distinto y este comando da error, +-- entrá a phpMyAdmin → tabla "pacientes" → pestaña "Estructura" +-- → abajo, donde dice "Índices", buscá el índice de tipo +-- "UNIQUE" sobre la columna dni y anotá su nombre real para +-- reemplazarlo en la siguiente línea. +ALTER TABLE pacientes DROP INDEX dni; + +-- ...y agregamos uno único compuesto (profesional_id + dni), que +-- es el que realmente queremos: único por profesional, no global. +ALTER TABLE pacientes ADD UNIQUE INDEX idx_dni_por_profesional (profesional_id, dni); diff --git a/reiniciar_todo.sql b/reiniciar_todo.sql new file mode 100644 index 0000000..1bb4fad --- /dev/null +++ b/reiniciar_todo.sql @@ -0,0 +1,67 @@ +-- ============================================================ +-- Del Austral — REINICIO TOTAL DEL SISTEMA +-- ============================================================ +-- Este script borra TODO: usuarios, sedes, clave de Desarrollador, +-- pacientes, sesiones, citas, archivos adjuntos (los registros en +-- la base, no los archivos físicos en el servidor), plantillas de +-- evolución, papelera, e historial de cambios. +-- +-- Después de correr esto, el sistema vuelve a quedar como recién +-- instalado: la primera vez que entres te va a pedir crear la +-- clave de Desarrollador de nuevo. +-- +-- ADVERTENCIA: esta acción NO SE PUEDE DESHACER. Si tenés +-- cualquier duda, hacé un backup antes (en phpMyAdmin: pestaña +-- "Exportar" → guardar el archivo .sql en tu computadora). +-- +-- Cómo aplicarlo: +-- 1. Entrá a phpMyAdmin → tu base de datos. +-- 2. Pestaña "SQL" (no "Importar"). +-- 3. Pegá todo este archivo y ejecutá. +-- +-- Nota técnica: usamos DELETE en vez de TRUNCATE porque algunos +-- hostings (MySQL/MariaDB) no permiten TRUNCATE sobre tablas +-- referenciadas por una clave foránea, ni siquiera con los +-- chequeos desactivados. DELETE sí funciona en todos los casos. +-- ============================================================ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DELETE FROM historial_cambios; +DELETE FROM legajos_eliminados; +DELETE FROM archivos_adjuntos; +DELETE FROM citas; +DELETE FROM sesiones; +DELETE FROM pacientes; +DELETE FROM plantillas_evolucion; +DELETE FROM usuarios_sedes; +DELETE FROM usuarios; +DELETE FROM sedes; +DELETE FROM desarrollador; + +-- Reiniciar los contadores de autoincremento, para que el +-- próximo paciente/usuario/sede que crees vuelva a empezar +-- desde el ID 1 (puramente cosmético, no es obligatorio). +ALTER TABLE historial_cambios AUTO_INCREMENT = 1; +ALTER TABLE legajos_eliminados AUTO_INCREMENT = 1; +ALTER TABLE archivos_adjuntos AUTO_INCREMENT = 1; +ALTER TABLE citas AUTO_INCREMENT = 1; +ALTER TABLE sesiones AUTO_INCREMENT = 1; +ALTER TABLE pacientes AUTO_INCREMENT = 1; +ALTER TABLE plantillas_evolucion AUTO_INCREMENT = 1; +ALTER TABLE usuarios AUTO_INCREMENT = 1; +ALTER TABLE sedes AUTO_INCREMENT = 1; +ALTER TABLE desarrollador AUTO_INCREMENT = 1; + +-- Las obras sociales NO se borran (es un catálogo, no datos de +-- pacientes). Si también querés vaciar las que agregaste a mano +-- y volver a dejar solo las predefinidas, descomentá esta línea: +-- DELETE FROM obras_sociales WHERE es_predefinida = 0; + +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================ +-- Listo. Al refrescar el sitio, debería pedirte crear la clave +-- de Desarrollador como si fuera la primera vez. +-- ============================================================ diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..c8a4245 --- /dev/null +++ b/sw.js @@ -0,0 +1,23 @@ +// ============================================================ +// Del Austral — Service Worker mínimo +// ============================================================ +// Este service worker existe únicamente para que el navegador +// permita "instalar" el sitio como app (es un requisito técnico +// de Chrome/Android). A propósito NO cachea ni intercepta +// ninguna petición: todo sigue yendo siempre a la red, igual +// que si fuera una pestaña normal del navegador. Esto es +// intencional — el sistema maneja datos clínicos, y no +// queremos correr el riesgo de que alguna vez se muestre +// información vieja guardada en caché por error. +// ============================================================ + +self.addEventListener('install', (event) => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +// No hay listener de "fetch": todas las peticiones siguen su +// camino normal hacia la red, sin pasar por este service worker. diff --git a/version.json b/version.json new file mode 100644 index 0000000..0ae235a --- /dev/null +++ b/version.json @@ -0,0 +1,17 @@ +{ + "version": "v21", + "fecha": "2026-06-30", + "descripcion": "El Desarrollador puede renombrar una sede sin perder legajos vinculados, y la pantalla de acceso ya no muestra 'Desarrollador' — ahora hay un botón discreto 'Mantenimiento' fijo arriba", + "archivos_criticos": { + "index.html": "e3c7585d7aeeffa597d277247d77e07e", + "assets/js/app.js": "17c993da060d133fc951c2c73237c053", + "assets/css/estilos.css": "60de9e4c4073cd4a57c0c081577d0d25", + "api/auth.php": "702ce82cdca08bef95a10e0821c4dc52", + "api/citas.php": "7622c3facffd9fb671fa63494be56124", + "api/pacientes.php": "df7de793317e8229120119a5050c44c2", + "api/adjuntos.php": "8da3f85e26239072953298c60dcb5540", + "api/admin.php": "15086c71878647214eedfc18757a4dd1", + "confirmar_turno.php": "ad6a798a41a594bb5dcc797295222cce", + "exportar.php": "ae88278f3ea445afb0394ef514fe1d83" + } +}