Primer commit: estado actual del servidor
This commit is contained in:
commit
378591d6b5
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
|
|
@ -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/
|
||||
216
INSTRUCCIONES_INSTALACION.md
Normal file
216
INSTRUCCIONES_INSTALACION.md
Normal file
|
|
@ -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.
|
||||
129
LICENSE
Normal file
129
LICENSE
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/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.
|
||||
172
README.md
Normal file
172
README.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Del Austral
|
||||
|
||||
[](./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).
|
||||
0
adjuntos/.gitkeep
Normal file
0
adjuntos/.gitkeep
Normal file
11
adjuntos/.htaccess
Normal file
11
adjuntos/.htaccess
Normal file
|
|
@ -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.
|
||||
|
||||
<FilesMatch "\.(php|php3|php4|php5|phtml|pl|py|cgi|sh)$">
|
||||
Order Allow,Deny
|
||||
Deny from all
|
||||
</FilesMatch>
|
||||
|
||||
# Evita que se liste el contenido de la carpeta desde el navegador.
|
||||
Options -Indexes
|
||||
270
api/adjuntos.php
Normal file
270
api/adjuntos.php
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
requiereSesion();
|
||||
requiereProfesionalActivo();
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$metodo = $_SERVER['REQUEST_METHOD'];
|
||||
$accion = $_GET['accion'] ?? '';
|
||||
$profesionalActivoId = idProfesionalActivo();
|
||||
|
||||
define('CARPETA_ADJUNTOS', __DIR__ . '/../adjuntos/');
|
||||
define('TIPOS_PERMITIDOS', [
|
||||
'application/pdf' => '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.']);
|
||||
291
api/admin.php
Normal file
291
api/admin.php
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
requiereSesion();
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$accion = $_GET['accion'] ?? '';
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// HISTORIAL DE CAMBIOS (GET ?accion=historial&pagina=1)
|
||||
// Solo se muestran cambios sobre pacientes/sesiones que
|
||||
// pertenecen al profesional activo.
|
||||
// ------------------------------------------------------------
|
||||
if ($accion === 'historial') {
|
||||
requiereSesion();
|
||||
$rol = $_SESSION['rol'] ?? '';
|
||||
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
|
||||
$porPagina = 40;
|
||||
$offset = ($pagina - 1) * $porPagina;
|
||||
$soloEntidad = $_GET['entidad'] ?? '';
|
||||
|
||||
if ($rol === 'desarrollador') {
|
||||
// El Desarrollador ve TODO el historial, sin acotar a
|
||||
// ningún profesional. Puede filtrar opcionalmente por
|
||||
// tipo de entidad (ej: "usuario" para ver solo altas,
|
||||
// bajas y cambios de licencia de profesionales/administrativas).
|
||||
$filtroEntidad = $soloEntidad !== '' ? 'WHERE h.entidad = ?' : '';
|
||||
$params = $soloEntidad !== '' ? [$soloEntidad] : [];
|
||||
|
||||
$sql = "SELECT h.* FROM historial_cambios h $filtroEntidad ORDER BY h.creado_en DESC LIMIT $porPagina OFFSET $offset";
|
||||
$stmt = $pdo->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.']);
|
||||
1210
api/auth.php
Normal file
1210
api/auth.php
Normal file
File diff suppressed because it is too large
Load Diff
362
api/citas.php
Normal file
362
api/citas.php
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
requiereSesion();
|
||||
requiereProfesionalActivo();
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$metodo = $_SERVER['REQUEST_METHOD'];
|
||||
$accion = $_GET['accion'] ?? '';
|
||||
$profesionalActivoId = idProfesionalActivo();
|
||||
|
||||
function calcularEdadCita($fechaNacimiento) {
|
||||
try {
|
||||
$nacimiento = new DateTime($fechaNacimiento);
|
||||
$hoy = new DateTime();
|
||||
return $hoy->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.']);
|
||||
43
api/obras_sociales.php
Normal file
43
api/obras_sociales.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
requiereSesion();
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$metodo = $_SERVER['REQUEST_METHOD'];
|
||||
|
||||
if ($metodo === 'GET') {
|
||||
$stmt = $pdo->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.']);
|
||||
519
api/pacientes.php
Normal file
519
api/pacientes.php
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
requiereSesion();
|
||||
requiereProfesionalActivo();
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$metodo = $_SERVER['REQUEST_METHOD'];
|
||||
$accion = $_GET['accion'] ?? '';
|
||||
$profesionalActivoId = idProfesionalActivo();
|
||||
|
||||
function validarPaciente($d) {
|
||||
$errores = [];
|
||||
if (empty($d['nombre'])) $errores[] = 'El nombre es obligatorio.';
|
||||
if (empty($d['apellido'])) $errores[] = 'El apellido es obligatorio.';
|
||||
if (empty($d['dni'])) $errores[] = 'El DNI es obligatorio.';
|
||||
if (empty($d['fecha_nacimiento'])) $errores[] = 'La fecha de nacimiento es obligatoria.';
|
||||
if (empty($d['sexo']) || !in_array($d['sexo'], ['Femenino', 'Masculino', 'Otro'])) $errores[] = 'El sexo es obligatorio.';
|
||||
return $errores;
|
||||
}
|
||||
|
||||
function calcularEdad($fechaNacimiento) {
|
||||
try {
|
||||
$nacimiento = new DateTime($fechaNacimiento);
|
||||
$hoy = new DateTime();
|
||||
return $hoy->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.']);
|
||||
59
api/plantillas.php
Normal file
59
api/plantillas.php
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
requiereSesion();
|
||||
requiereProfesionalActivo();
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$metodo = $_SERVER['REQUEST_METHOD'];
|
||||
$accion = $_GET['accion'] ?? '';
|
||||
$profesionalActivoId = idProfesionalActivo();
|
||||
|
||||
if ($metodo === 'GET') {
|
||||
$stmt = $pdo->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.']);
|
||||
1788
assets/css/estilos.css
Normal file
1788
assets/css/estilos.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
assets/icons/favicon-32.png
Normal file
BIN
assets/icons/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 254 B |
BIN
assets/icons/icon-192.png
Normal file
BIN
assets/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/icons/icon-512.png
Normal file
BIN
assets/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
assets/icons/icon-apple-touch.png
Normal file
BIN
assets/icons/icon-apple-touch.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/icon-maskable-512.png
Normal file
BIN
assets/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
3346
assets/js/app.js
Normal file
3346
assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
171
confirmar_turno.php
Normal file
171
confirmar_turno.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
|
||||
$pdo = obtenerConexion();
|
||||
$token = $_GET['token'] ?? '';
|
||||
$accionPost = $_POST['accion'] ?? '';
|
||||
|
||||
function e($texto) {
|
||||
return htmlspecialchars($texto ?? '', ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function fechaLegiblePublica($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];
|
||||
}
|
||||
|
||||
$stmt = $pdo->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';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Confirmar turno — Del Austral</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #F7F4ED;
|
||||
color: #1C2421;
|
||||
margin: 0;
|
||||
padding: 24px 16px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.tarjeta {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 36px 28px;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-shadow: 0 12px 40px rgba(28,36,33,0.12);
|
||||
text-align: center;
|
||||
}
|
||||
.icono-marca { width: 44px; height: 44px; color: #3D6B63; margin-bottom: 10px; }
|
||||
h1 { font-size: 1.3rem; margin: 0 0 4px; }
|
||||
.subtitulo { color: #4A5650; font-size: 0.88rem; margin-bottom: 24px; display: block; }
|
||||
.dato-turno {
|
||||
background: #F7F4ED;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.dato-turno .fecha { font-weight: 700; font-size: 1.05rem; color: #2A4B45; }
|
||||
.dato-turno .motivo { color: #4A5650; font-size: 0.9rem; margin-top: 4px; }
|
||||
.estado-pill {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.estado-pendiente { background: #E4EDE9; color: #2A4B45; }
|
||||
.estado-confirmada { background: #DCE8D8; color: #3D6B63; }
|
||||
.estado-cancelada { background: #F5E3DC; color: #C4654A; }
|
||||
.botones { display: flex; flex-direction: column; gap: 10px; }
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 13px 20px;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-confirmar { background: #3D6B63; color: white; }
|
||||
.btn-cancelar { background: white; color: #C4654A; border: 1.5px solid #C4654A; }
|
||||
.mensaje {
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.mensaje.exito { background: #DCE8D8; color: #2A4B45; }
|
||||
.mensaje.info { background: #EFEAE0; color: #4A5650; }
|
||||
.error-pagina { color: #4A5650; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="tarjeta">
|
||||
<svg class="icono-marca" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 24h4l3-12 4 24 4-30 4 24 4-18 3 12h6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<h1>Del Austral</h1>
|
||||
<span class="subtitulo">Confirmación de turno</span>
|
||||
|
||||
<?php if (!$cita): ?>
|
||||
<p class="error-pagina">No encontramos este turno. El link puede haber expirado o ser incorrecto.</p>
|
||||
<?php else: ?>
|
||||
|
||||
<?php if ($mensaje): ?>
|
||||
<div class="mensaje <?= e($tipoMensaje) ?>"><?= e($mensaje) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="dato-turno">
|
||||
<div class="fecha"><?= fechaLegiblePublica($cita['fecha']) ?><?= $cita['hora'] ? ' · ' . substr($cita['hora'], 0, 5) : '' ?></div>
|
||||
<?php if ($cita['motivo']): ?>
|
||||
<div class="motivo"><?= e($cita['motivo']) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$etiquetas = [
|
||||
'pendiente' => $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'];
|
||||
?>
|
||||
<span class="estado-pill estado-<?= e($claseEstado) ?>"><?= e($textoEstado) ?></span>
|
||||
</div>
|
||||
|
||||
<?php if ($cita['estado'] === 'pendiente' && !$cita['confirmada_por_paciente']): ?>
|
||||
<form method="POST" class="botones">
|
||||
<button type="submit" name="accion" value="confirmar" class="btn-confirmar">Confirmar turno</button>
|
||||
<button type="submit" name="accion" value="cancelar" class="btn-cancelar">No voy a poder asistir</button>
|
||||
</form>
|
||||
<?php elseif ($cita['estado'] === 'pendiente' && $cita['confirmada_por_paciente']): ?>
|
||||
<form method="POST" class="botones">
|
||||
<button type="submit" name="accion" value="cancelar" class="btn-cancelar">Necesito cancelar este turno</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
251
database.sql
Normal file
251
database.sql
Normal file
|
|
@ -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;
|
||||
267
exportar.php
Normal file
267
exportar.php
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/config/config.php';
|
||||
|
||||
if (empty($_SESSION['autenticado']) || ($_SESSION['rol'] ?? '') !== 'profesional') {
|
||||
http_response_code(403);
|
||||
echo '<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><title>Acceso no disponible</title></head>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 480px; margin: 80px auto; text-align: center; color: #1C2421;">
|
||||
<h2>No tenés permiso para ver este documento</h2>
|
||||
<p style="color:#4A5650;">La exportación de legajos clínicos está disponible solo para el usuario profesional.</p>
|
||||
</body></html>';
|
||||
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']);
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Legajo — <?= e($paciente['apellido'] . ', ' . $paciente['nombre']) ?></title>
|
||||
<style>
|
||||
@media print {
|
||||
@page { margin: 16mm 16mm; }
|
||||
.barra-exportar { display: none !important; }
|
||||
body { padding: 0 !important; }
|
||||
.encabezado-doc, .bloque-pdf, .sesion-pdf, .bloque-firma {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
color: #1C2421;
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
padding: 30px 24px 60px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.barra-exportar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #3D6B63;
|
||||
color: white;
|
||||
padding: 14px 20px;
|
||||
margin: -30px -24px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.barra-exportar button {
|
||||
background: white;
|
||||
color: #2A4B45;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.encabezado-doc {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
border-bottom: 3px solid #3D6B63;
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.encabezado-doc h1 { font-size: 1.5rem; margin: 0 0 4px; color: #2A4B45; }
|
||||
.encabezado-doc .profesional { font-size: 0.85rem; color: #4A5650; }
|
||||
.encabezado-doc .fecha-emision { font-size: 0.8rem; color: #4A5650; text-align: right; }
|
||||
|
||||
h2.titulo-paciente { font-size: 1.3rem; margin: 0 0 4px; }
|
||||
.meta-paciente { color: #4A5650; font-size: 0.88rem; margin-bottom: 20px; }
|
||||
|
||||
.grilla-datos-pdf {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.dato-pdf {
|
||||
background: #F7F4ED;
|
||||
border: 1px solid #DDD6C7;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.dato-pdf .etq { font-size: 0.68rem; text-transform: uppercase; color: #4A5650; font-weight: 700; letter-spacing: 0.04em; }
|
||||
.dato-pdf .val { font-size: 0.95rem; font-weight: 600; margin-top: 2px; }
|
||||
|
||||
.bloque-pdf { margin-bottom: 18px; }
|
||||
.bloque-pdf h3 {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #2A4B45;
|
||||
border-bottom: 1px solid #DDD6C7;
|
||||
padding-bottom: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bloque-pdf p { margin: 0; white-space: pre-wrap; font-size: 0.92rem; }
|
||||
.bloque-pdf p.vacio { color: #4A5650; font-style: italic; }
|
||||
|
||||
.titulo-sesiones { font-size: 1.05rem; margin: 30px 0 14px; border-top: 2px solid #DDD6C7; padding-top: 20px; }
|
||||
|
||||
.sesion-pdf { margin-bottom: 16px; padding-left: 14px; border-left: 3px solid #3D6B63; }
|
||||
.sesion-pdf .fecha-pdf { font-weight: 700; font-size: 0.88rem; color: #2A4B45; margin-bottom: 3px; }
|
||||
.sesion-pdf .desc-pdf { font-size: 0.9rem; white-space: pre-wrap; }
|
||||
.sesion-pdf .evol-pdf { font-size: 0.85rem; color: #4A5650; margin-top: 4px; white-space: pre-wrap; }
|
||||
|
||||
.bloque-firma {
|
||||
margin-top: 50px;
|
||||
width: 260px;
|
||||
text-align: center;
|
||||
}
|
||||
.linea-firma { border-top: 1.5px solid #1C2421; margin-bottom: 8px; }
|
||||
.nombre-firma { font-weight: 700; font-size: 0.92rem; }
|
||||
.aclaracion-firma { font-size: 0.75rem; color: #4A5650; margin-top: 2px; }
|
||||
|
||||
.pie-pdf {
|
||||
margin-top: 40px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #DDD6C7;
|
||||
font-size: 0.72rem;
|
||||
color: #4A5650;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="barra-exportar" id="barra-exportar-pdf">
|
||||
<span>Vista de exportación — usá Ctrl/Cmd + P y elegí "Guardar como PDF"</span>
|
||||
<button onclick="window.print()">Imprimir / Guardar como PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="encabezado-doc">
|
||||
<div>
|
||||
<h1>Del Austral</h1>
|
||||
<div class="profesional"><?= $nombreProfesional ? e($nombreProfesional) : 'Historial clínico digital' ?></div>
|
||||
</div>
|
||||
<div class="fecha-emision">Documento generado el<br><?= fechaLegible(date('Y-m-d')) ?></div>
|
||||
</div>
|
||||
|
||||
<h2 class="titulo-paciente"><?= e($paciente['apellido'] . ', ' . $paciente['nombre']) ?></h2>
|
||||
<div class="meta-paciente">Legajo clínico — paciente desde <?= fechaLegible($paciente['creado_en']) ?></div>
|
||||
|
||||
<div class="grilla-datos-pdf">
|
||||
<div class="dato-pdf"><div class="etq">DNI</div><div class="val"><?= e($paciente['dni']) ?></div></div>
|
||||
<div class="dato-pdf"><div class="etq">Edad</div><div class="val"><?= $edad !== null ? $edad . ' años' : '—' ?></div></div>
|
||||
<div class="dato-pdf"><div class="etq">Sexo</div><div class="val"><?= e($paciente['sexo']) ?></div></div>
|
||||
<div class="dato-pdf"><div class="etq">Obra social</div><div class="val"><?= e($paciente['obra_social_nombre'] ?: 'Sin especificar') ?></div></div>
|
||||
</div>
|
||||
|
||||
<div class="bloque-pdf">
|
||||
<h3>Motivo de consulta</h3>
|
||||
<p class="<?= $paciente['motivo_consulta'] ? '' : 'vacio' ?>"><?= e($paciente['motivo_consulta'] ?: 'No se registró información.') ?></p>
|
||||
</div>
|
||||
<div class="bloque-pdf">
|
||||
<h3>Patología</h3>
|
||||
<p class="<?= $paciente['patologia'] ? '' : 'vacio' ?>"><?= e($paciente['patologia'] ?: 'No se registró información.') ?></p>
|
||||
</div>
|
||||
<div class="bloque-pdf">
|
||||
<h3>Síntomas</h3>
|
||||
<p class="<?= $paciente['sintomas'] ? '' : 'vacio' ?>"><?= e($paciente['sintomas'] ?: 'No se registró información.') ?></p>
|
||||
</div>
|
||||
<div class="bloque-pdf">
|
||||
<h3>Observaciones generales</h3>
|
||||
<p class="<?= $paciente['observaciones_generales'] ? '' : 'vacio' ?>"><?= e($paciente['observaciones_generales'] ?: 'No se registró información.') ?></p>
|
||||
</div>
|
||||
|
||||
<div class="titulo-sesiones">Historial de sesiones (<?= count($sesiones) ?>)</div>
|
||||
|
||||
<?php if (empty($sesiones)): ?>
|
||||
<p class="vacio" style="color:#4A5650; font-style: italic;">Todavía no se registraron sesiones para este paciente.</p>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sesiones as $s): ?>
|
||||
<div class="sesion-pdf">
|
||||
<div class="fecha-pdf"><?= fechaLegible($s['fecha_sesion']) ?></div>
|
||||
<div class="desc-pdf"><?= e($s['descripcion']) ?></div>
|
||||
<?php if ($s['evolucion']): ?>
|
||||
<div class="evol-pdf"><?= e($s['evolucion']) ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($nombreProfesional): ?>
|
||||
<div class="bloque-firma">
|
||||
<div class="linea-firma"></div>
|
||||
<div class="nombre-firma"><?= e($nombreProfesional) ?></div>
|
||||
<div class="aclaracion-firma">Profesional responsable del seguimiento clínico</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="pie-pdf">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Respaldo independiente del CSS @media print: algunos
|
||||
// navegadores o motores de "guardar como PDF" no respetan
|
||||
// bien los media queries, así que ocultamos la barra
|
||||
// directamente con JS antes de imprimir, y la mostramos
|
||||
// de nuevo después (por si el usuario cancela y reintenta).
|
||||
var barraExportar = document.getElementById('barra-exportar-pdf');
|
||||
window.addEventListener('beforeprint', function () {
|
||||
if (barraExportar) barraExportar.style.display = 'none';
|
||||
});
|
||||
window.addEventListener('afterprint', function () {
|
||||
if (barraExportar) barraExportar.style.display = 'flex';
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1332
index.html
Normal file
1332
index.html
Normal file
File diff suppressed because it is too large
Load Diff
32
manifest.json
Normal file
32
manifest.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
66
migracion_v10.sql
Normal file
66
migracion_v10.sql
Normal file
|
|
@ -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;
|
||||
33
migracion_v11.sql
Normal file
33
migracion_v11.sql
Normal file
|
|
@ -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'));
|
||||
86
migracion_v2.sql
Normal file
86
migracion_v2.sql
Normal file
|
|
@ -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.
|
||||
-- ------------------------------------------------------------
|
||||
56
migracion_v3.sql
Normal file
56
migracion_v3.sql
Normal file
|
|
@ -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;
|
||||
30
migracion_v4.sql
Normal file
30
migracion_v4.sql
Normal file
|
|
@ -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.
|
||||
139
migracion_v5.sql
Normal file
139
migracion_v5.sql
Normal file
|
|
@ -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.
|
||||
-- ============================================================
|
||||
24
migracion_v6.sql
Normal file
24
migracion_v6.sql
Normal file
|
|
@ -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;
|
||||
40
migracion_v7.sql
Normal file
40
migracion_v7.sql
Normal file
|
|
@ -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;
|
||||
25
migracion_v8.sql
Normal file
25
migracion_v8.sql
Normal file
|
|
@ -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;
|
||||
36
migracion_v9.sql
Normal file
36
migracion_v9.sql
Normal file
|
|
@ -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);
|
||||
67
reiniciar_todo.sql
Normal file
67
reiniciar_todo.sql
Normal file
|
|
@ -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.
|
||||
-- ============================================================
|
||||
23
sw.js
Normal file
23
sw.js
Normal file
|
|
@ -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.
|
||||
17
version.json
Normal file
17
version.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user