feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -82,8 +82,9 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- Notificaciones in-app
|
- Notificaciones in-app
|
||||||
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
|
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
|
||||||
- Configuración (catálogos: estados, prioridades, tipos de proyecto)
|
- Configuración (catálogos: estados, prioridades, tipos de proyecto)
|
||||||
- Conexiones externas (en curso): datos de otros sistemas vía conectores de solo lectura en el
|
- Conexiones externas: datos de otros sistemas vía conectores de solo lectura en el servidor origen
|
||||||
servidor origen (`data/conectores/`, primero FoxPro) — ver `docs/CONEXIONES-EXTERNAS.md`
|
(`data/conectores/`, FoxPro instalado en Exilaser), pacientes de SAGA y empleados de nómina —
|
||||||
|
ver `docs/CONEXIONES-EXTERNAS.md`
|
||||||
|
|
||||||
## Restricciones tecnicas
|
## Restricciones tecnicas
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ campo en multipart. La API responde `428` si falta o es inválido y `409` si otr
|
|||||||
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
||||||
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
|
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
|
||||||
|
|
||||||
|
## Conexiones externas
|
||||||
|
|
||||||
|
`/api/integrations` (JWT). Superadmin: `GET/POST /connections`, `PUT /connections/:id`,
|
||||||
|
`POST /connections/:id/test`, `GET /connections/:id/browse?source=&table=` (fuentes, tablas,
|
||||||
|
estructura + 20 filas), `PUT /sources/:purpose` (`patients`/`employees`, valida el mapeo contra la
|
||||||
|
estructura real), `POST /sources/patients/sync {full}`. Admin y superadmin: `GET /sources` (el admin
|
||||||
|
solo ve estado), `GET /employees[?available=1]`, `POST /employees/sync`. El token nunca se devuelve.
|
||||||
|
En la Agenda (recepción): `GET /api/clinic/patients/source`, `GET /api/clinic/patients/external?search=`
|
||||||
|
(modo en vivo) y `POST /api/clinic/patients/external/:codigo/import`. `POST /api/users` acepta
|
||||||
|
`employeeId` (Desde nómina).
|
||||||
|
|
||||||
## Agenda médica (v4.7.0)
|
## Agenda médica (v4.7.0)
|
||||||
|
|
||||||
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
|
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de
|
|||||||
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
|
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
|
||||||
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
|
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
|
||||||
|
|
||||||
|
## Conexiones externas (migración 038)
|
||||||
|
|
||||||
|
`038_integrations.sql` (aplicada en dev 2026-10-29): `external_connections` (URL, token cifrado
|
||||||
|
AES-256-GCM), `external_sources` (una por destino `patients`/`employees`: tabla, modo, mapeo,
|
||||||
|
filtro, campo de cambios y estado de sincronización: `last_max_recno`, `last_changed_value`),
|
||||||
|
`employees` (importados de nómina; `user_id` único al crear el usuario desde nómina) y columnas en
|
||||||
|
`clinic_patients`: `external_source`, `external_id` (único con la fuente), `source_name`,
|
||||||
|
`name_split`, `synced_at`. Ver `docs/CONEXIONES-EXTERNAS.md`.
|
||||||
|
|
||||||
## Agenda médica (migración 036, v4.7.0)
|
## Agenda médica (migración 036, v4.7.0)
|
||||||
|
|
||||||
`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
|
`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [4.12.0] - 2026-10-29
|
||||||
|
### Added
|
||||||
|
- Conexiones externas en el sistema (migración 038, `modules/integrations`): Configuración →
|
||||||
|
Conexiones externas (superadmin) con conexiones a conectores (token cifrado, Probar), fuentes de
|
||||||
|
Pacientes y Empleados (vista previa de 20 filas, mapeo de campos, filtro, modo importar/en vivo).
|
||||||
|
- Pacientes desde SAGA: sincronización completa lunes a viernes 05:00 y solo cambios cada 15 min
|
||||||
|
(06:00–21:00); vínculo por cédula (manda SAGA); nombre de SAGA guardado entero hasta que recepción
|
||||||
|
lo separe. En modo en vivo la Agenda busca en SAGA y trae solo el paciente elegido.
|
||||||
|
- Organización → Empleados (admin/superadmin) con "Actualizar empleados" (a pedido); Nuevo usuario →
|
||||||
|
Desde nómina (precarga nombre, cargo, departamento, correo y vincula). Empleados con usuario no se
|
||||||
|
actualizan ni inactivan automáticamente.
|
||||||
|
- Médicos que facturan como proveedores de SAGA cargados como empleados (`origin=provider`) con
|
||||||
|
`backend/src/scripts/import-provider-employees.js` (lee del conector por código).
|
||||||
|
- Conector FoxPro 1.1.0: sincronización incremental (`afterRecno`, `changedField/changedSince`),
|
||||||
|
recarga mínima de 60 s, fechas vacías como `null`, tabla de proveedores (sin datos bancarios).
|
||||||
|
### Fixed
|
||||||
|
- Revisión de código: lotes con códigos repetidos, lectura paginada reiniciada si el origen cambia,
|
||||||
|
usuario no queda huérfano si falla el vínculo con el empleado, auditoría al traer un paciente,
|
||||||
|
fuente con conexión desactivada, campo de cambios solo fecha-hora.
|
||||||
|
|
||||||
## [4.11.0] - 2026-10-29
|
## [4.11.0] - 2026-10-29
|
||||||
### Added
|
### Added
|
||||||
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
|
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
|
||||||
|
|||||||
@@ -13,3 +13,8 @@
|
|||||||
| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
|
| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
|
||||||
| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
|
| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
|
||||||
| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
|
| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
|
||||||
|
| 8 | 2026-10-29 | Pacientes: con conexión activa se pueden crear a mano; al vincular por cédula **manda SAGA** | Pedido del usuario: el paciente puede llegar antes de estar en SAGA, y SAGA es la fuente oficial | Solo crear si no está en SAGA; mandan los datos locales | vigente |
|
||||||
|
| 9 | 2026-10-29 | Sincronización **incremental** (agregados por `_recno` + modificados por `FECHAACT`) y recarga mínima de 60 s en el conector; completa periódica para borrados | `clientes.DBF` ~20 MB y SAGA lo escribe todo el día | Copiar y enviar toda la tabla en cada consulta | vigente |
|
||||||
|
| 10 | 2026-10-29 | Nombre de SAGA (`CLINOM`) **no se separa automáticamente**: se guarda entero y recepción lo separa al editar; desde ahí la sincronización no lo pisa | SAGA no respeta orden apellidos/nombres | Separar por las 2 primeras palabras | vigente |
|
||||||
|
| 11 | 2026-10-29 | Sincronización completa de pacientes **lunes a viernes 05:00**; cambios cada 15 min de 06:00 a 21:00; empleados solo a pedido | Pedido del usuario: carga principal + solo cambios; recarga total temprano | Recarga total frecuente | vigente |
|
||||||
|
| 12 | 2026-10-29 | Médicos: se toman de proveedores de SAGA (y de nómina) como empleados `origin=provider`; los empleados con usuario no se actualizan ni inactivan automáticamente | Casi todos los médicos facturan, no están en nómina; lo que tiene usuario lo administra la clínica | Crear médicos sin vínculo con SAGA | vigente |
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
# ESTADO — project.linkedcorp.net
|
# ESTADO — project.linkedcorp.net
|
||||||
|
|
||||||
Ultima actualizacion: 2026-10-29 (v4.11.0 commit + push, sin desplegar)
|
Ultima actualizacion: 2026-10-29 (v4.12.0 commit + push, sin desplegar)
|
||||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
||||||
autorregistro/aprobacion/SMTP.
|
autorregistro/aprobacion/SMTP.
|
||||||
|
|
||||||
## Conexiones externas (2026-10-29, en curso, sin commit)
|
## Conexiones externas (v4.12.0, en dev, sin desplegar)
|
||||||
|
|
||||||
- Diseño y requisitos acordados en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 en `DECISIONES.md`.
|
- Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea
|
||||||
- Hecho: conector FoxPro 1.0.0 en `data/conectores/foxpro/` (API solo lectura, contrato v1 en
|
`LinkedCorp-Conector-FoxPro`). Token en `~/.config/linkedcorp/conector-foxpro-exilaser.token`.
|
||||||
`data/conectores/README.md`). `npm test` 16/16 OK con DBF temporal. Sin probar con tablas reales
|
- **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job
|
||||||
de SAGA ni los `.ps1` en Windows.
|
`integrationSync`, Configuración → Conexiones externas, Organización → Empleados, Nuevo usuario →
|
||||||
- Siguiente: con permiso del usuario, revisar por SSH `192.168.0.253` las carpetas de nómina y
|
Desde nómina, pacientes de SAGA en la Agenda. Dev quedó con la conexión "SAGA Exilaser", 31
|
||||||
clientes; instalar en `C:\api_project\foxpro`; luego el módulo del sistema.
|
empleados y 31 220 pacientes importados (modo importar). Probado por API y navegador (usuario
|
||||||
|
superadmin temporal, ya borrado), sin errores de consola.
|
||||||
|
- Base de dev **sin datos de prueba de la Agenda** (borrados 2026-10-29).
|
||||||
|
- Revisión `medium` hecha y corregida. Médicos cargados en dev como empleados (9 proveedores + 2 de
|
||||||
|
nómina) con `import-provider-employees.js`; empleados con usuario protegidos de la sincronización.
|
||||||
|
- **Producción está en 2.19.0 (migraciones hasta 027)**: para cargar allá los médicos hay que
|
||||||
|
desplegar 4.12.0 (migraciones 028–038), crear la conexión con el mismo token, las fuentes, y correr
|
||||||
|
el script con los mismos códigos. El firewall del Windows ya permite a `192.168.222.3`.
|
||||||
|
- Detalle en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 y #8–#11 en `DECISIONES.md`.
|
||||||
|
|
||||||
## Agenda médica (v4.7.0, sin desplegar)
|
## Agenda médica (v4.7.0, sin desplegar)
|
||||||
|
|
||||||
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
|
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
|
||||||
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
|
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
|
||||||
- 2026-10-29: BD de dev con datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
|
- (borrados el 2026-10-29) BD de dev tuvo datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
|
||||||
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
|
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
|
||||||
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
|
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
|
||||||
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
|
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
en el detalle. `DATETIME` representa hora civil `America/Guayaquil`.
|
en el detalle. `DATETIME` representa hora civil `America/Guayaquil`.
|
||||||
- El calendario personal muestra al médico sus citas activas y bloqueos en la capa Citas médicas.
|
- El calendario personal muestra al médico sus citas activas y bloqueos en la capa Citas médicas.
|
||||||
|
|
||||||
|
### 7 ter. Conexiones externas
|
||||||
|
- Datos de otros sistemas por conectores de solo lectura (contrato en `data/conectores/README.md`).
|
||||||
|
Requisitos, decisiones y funcionamiento en `docs/CONEXIONES-EXTERNAS.md`: pacientes desde SAGA
|
||||||
|
(importar o en vivo; completa L-V 05:00 + cambios cada 15 min) y empleados de nómina a pedido, con
|
||||||
|
alta de usuario desde nómina.
|
||||||
|
|
||||||
### 8. Gantt
|
### 8. Gantt
|
||||||
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
|
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
|
||||||
desde el gráfico en el MVP.
|
desde el gráfico en el MVP.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- Conexiones externas (conectores de solo lectura, contrato v1 en data/conectores/README.md),
|
||||||
|
-- empleados importados de nómina y vínculo de pacientes con el sistema origen. Idempotente.
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_connections (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
base_url VARCHAR(255) NOT NULL,
|
||||||
|
token_encrypted TEXT NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ext_conn_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Una fuente por destino: qué tabla del conector alimenta Pacientes o Empleados, cómo (importar o
|
||||||
|
-- consultar en vivo), el mapeo de campos y el estado de la última sincronización.
|
||||||
|
CREATE TABLE IF NOT EXISTS external_sources (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
purpose ENUM('patients','employees') NOT NULL UNIQUE,
|
||||||
|
connection_id INT NOT NULL,
|
||||||
|
source_key VARCHAR(64) NOT NULL,
|
||||||
|
table_name VARCHAR(64) NOT NULL,
|
||||||
|
mode ENUM('import','live') NOT NULL DEFAULT 'import',
|
||||||
|
field_map_json TEXT NOT NULL,
|
||||||
|
filter_json TEXT NULL,
|
||||||
|
changed_field VARCHAR(64) NULL,
|
||||||
|
last_full_sync_at DATETIME NULL,
|
||||||
|
last_sync_at DATETIME NULL,
|
||||||
|
last_max_recno INT UNSIGNED NULL,
|
||||||
|
last_changed_value VARCHAR(40) NULL,
|
||||||
|
last_result_json TEXT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ext_source_conn FOREIGN KEY (connection_id) REFERENCES external_connections(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
identification VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
last_names VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
first_names VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(40) NULL,
|
||||||
|
phone2 VARCHAR(40) NULL,
|
||||||
|
birth_date DATE NULL,
|
||||||
|
position VARCHAR(120) NULL,
|
||||||
|
department_name VARCHAR(120) NULL,
|
||||||
|
hire_date DATE NULL,
|
||||||
|
leave_date DATE NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
in_source TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
user_id INT NULL UNIQUE,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_employee_name (last_names, first_names),
|
||||||
|
CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Origen del empleado: nómina (se sincroniza) o proveedor de SAGA cargado a mano (médicos que facturan;
|
||||||
|
-- la sincronización de nómina no los inactiva). Correo: los proveedores lo tienen, la nómina no.
|
||||||
|
ALTER TABLE employees ADD COLUMN IF NOT EXISTS origin ENUM('payroll','provider') NOT NULL DEFAULT 'payroll' AFTER is_active;
|
||||||
|
ALTER TABLE employees ADD COLUMN IF NOT EXISTS email VARCHAR(160) NULL AFTER phone2;
|
||||||
|
|
||||||
|
-- Pacientes vinculados al origen (SAGA): source_name guarda el nombre tal como viene (SAGA no
|
||||||
|
-- separa nombres y apellidos ni respeta un orden); mientras recepción no lo separe a mano, el
|
||||||
|
-- nombre visible es ese. Manda el origen en identificación/teléfono/correo/nombre de origen.
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_source VARCHAR(20) NULL AFTER notes;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_id VARCHAR(40) NULL AFTER external_source;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS source_name VARCHAR(160) NULL AFTER external_id;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS name_split TINYINT(1) NOT NULL DEFAULT 1 AFTER source_name;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS synced_at DATETIME NULL AFTER name_split;
|
||||||
|
ALTER TABLE clinic_patients MODIFY phone VARCHAR(40) NOT NULL DEFAULT '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_clinic_patient_external ON clinic_patients (external_source, external_id);
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import inventoryIngestRoutes from './modules/devices/ingest.js';
|
|||||||
import aiRoutes from './modules/ai/routes.js';
|
import aiRoutes from './modules/ai/routes.js';
|
||||||
import meetingsRoutes from './modules/meetings/routes.js';
|
import meetingsRoutes from './modules/meetings/routes.js';
|
||||||
import clinicRoutes from './modules/clinic/routes.js';
|
import clinicRoutes from './modules/clinic/routes.js';
|
||||||
|
import integrationRoutes from './modules/integrations/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ app.use('/api/devices', devicesRoutes);
|
|||||||
app.use('/api/ai', aiRoutes);
|
app.use('/api/ai', aiRoutes);
|
||||||
app.use('/api/meetings', meetingsRoutes);
|
app.use('/api/meetings', meetingsRoutes);
|
||||||
app.use('/api/clinic', clinicRoutes);
|
app.use('/api/clinic', clinicRoutes);
|
||||||
|
app.use('/api/integrations', integrationRoutes);
|
||||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { scheduledPatientSync } from '../modules/integrations/service.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Pacientes desde el sistema origen (modo importar): completa lunes a viernes a las 05:00 y
|
||||||
|
// incremental cada 15 min de 06:00 a 21:00 (ver integrations/service.scheduledPatientSync).
|
||||||
|
// Mismo patrón que los demás jobs: dentro del proceso del backend, sin cron del SO.
|
||||||
|
export function startIntegrationSyncJob() {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const result = await scheduledPatientSync();
|
||||||
|
if (result) console.log(`[integrations] pacientes ${result.full ? 'completa' : 'incremental'}: ${result.received} recibidos, ${result.created} nuevos, ${result.updated} actualizados, ${result.deactivated} inactivados`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 409) console.error('[integrations] error sincronizando pacientes:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(run, 30000);
|
||||||
|
setInterval(run, CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { requireAuth } from '../../middleware/auth.js';
|
|||||||
import { requireModule } from '../../middleware/modules.js';
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { asyncHandler, ok } from '../../utils/http.js';
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
import * as clinic from './service.js';
|
import * as clinic from './service.js';
|
||||||
|
import * as integrations from '../integrations/service.js';
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth, requireModule('clinic'));
|
router.use(requireAuth, requireModule('clinic'));
|
||||||
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
|
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
|
||||||
@@ -46,6 +47,11 @@ write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, sta
|
|||||||
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
|
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
|
||||||
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
|
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
|
||||||
get('/patients', (q, s) => clinic.patients(q, s));
|
get('/patients', (q, s) => clinic.patients(q, s));
|
||||||
|
// Pacientes del sistema origen (Conexiones externas): estado de la fuente, búsqueda en vivo e
|
||||||
|
// importación del elegido. Solo recepción (gestiona pacientes).
|
||||||
|
get('/patients/source', async (q, s) => { clinic.requireReception(s); return integrations.patientSourceInfo(); });
|
||||||
|
get('/patients/external', async (q, s) => { clinic.requireReception(s); return integrations.searchExternalPatients(q.search); });
|
||||||
|
write('post', '/patients/external/:id/import', async (body, user, staff, n) => { clinic.requireReception(staff); return integrations.importExternalPatient(n, user); });
|
||||||
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
|
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
|
||||||
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
|
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
|
||||||
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
|
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const audit = async (user, action, entityType, entityId, connection = pool) => l
|
|||||||
connection
|
connection
|
||||||
});
|
});
|
||||||
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
|
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
|
||||||
|
export function requireReception(staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
|
||||||
|
}
|
||||||
export function requireConfig(user) {
|
export function requireConfig(user) {
|
||||||
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
|
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
|
||||||
}
|
}
|
||||||
@@ -88,9 +91,9 @@ export async function patients(q, staff) {
|
|||||||
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
|
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
|
||||||
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
|
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
|
||||||
const like = `%${search}%`;
|
const like = `%${search}%`;
|
||||||
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR identification LIKE ? OR phone LIKE ?)';
|
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR source_name LIKE ? OR identification LIKE ? OR phone LIKE ?)';
|
||||||
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like]);
|
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like, like]);
|
||||||
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, pageSize, (page - 1) * pageSize]);
|
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, like, pageSize, (page - 1) * pageSize]);
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
total: count.n,
|
total: count.n,
|
||||||
@@ -122,7 +125,7 @@ export async function savePatient(raw, user, staff, patientId = null) {
|
|||||||
const data = patientData(raw);
|
const data = patientData(raw);
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
|
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,name_split=1,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
|
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
|
// Cliente del contrato v1 de conectores (data/conectores/README.md). Solo GET, con token y tiempo
|
||||||
|
// máximo; los errores del conector se traducen a mensajes para el administrador.
|
||||||
|
const TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export function normalizeBaseUrl(raw) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(raw || '').trim());
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'URL del conector inválida');
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new HttpError(400, 'URL del conector inválida');
|
||||||
|
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectorGet(conn, path, query = {}) {
|
||||||
|
const url = new URL(`${conn.baseUrl}${path}`);
|
||||||
|
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { Authorization: `Bearer ${conn.token}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, err.name === 'TimeoutError' ? 'El conector no respondió a tiempo' : 'No se pudo conectar con el conector');
|
||||||
|
}
|
||||||
|
let body = null;
|
||||||
|
try { body = await res.json(); } catch { /* respuesta no JSON */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const reason = res.status === 401 ? 'token inválido' : res.status === 403 ? 'IP no permitida' : body?.error || `HTTP ${res.status}`;
|
||||||
|
throw new HttpError(502, `Conector: ${reason}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorre todas las páginas de una consulta de filas (máximo 1000 por página en el conector).
|
||||||
|
// Si el origen cambia entre páginas (otra versión del archivo), se reinicia la lectura para no
|
||||||
|
// duplicar ni saltar filas.
|
||||||
|
export async function connectorAllRows(conn, source, table, query = {}) {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const rows = [];
|
||||||
|
let offset = 0, page, version = null, stable = true;
|
||||||
|
do {
|
||||||
|
page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(source)}/tables/${encodeURIComponent(table)}/rows`, { ...query, limit: 1000, offset });
|
||||||
|
if (version !== null && page.version !== version) { stable = false; break; }
|
||||||
|
version = page.version;
|
||||||
|
rows.push(...page.rows);
|
||||||
|
offset += page.rows.length;
|
||||||
|
} while (page.rows.length && offset < page.total);
|
||||||
|
if (stable) return { rows, maxRecno: page.maxRecno, version };
|
||||||
|
}
|
||||||
|
throw new HttpError(503, 'El origen cambió durante la lectura; se reintentará');
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
|
import * as svc from './service.js';
|
||||||
|
|
||||||
|
// Conexiones externas. Permisos en el servicio: configuración solo superadmin; empleados
|
||||||
|
// (listar y "Actualizar empleados") admin y superadmin.
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
const h = (fn, status = 200) => asyncHandler(async (req, res) => ok(res, await fn(req), status));
|
||||||
|
|
||||||
|
router.get('/connections', h((req) => svc.listConnections(req.currentUser)));
|
||||||
|
router.post('/connections', h((req) => svc.saveConnection(req.body, req.currentUser), 201));
|
||||||
|
router.put('/connections/:id', h((req) => svc.saveConnection(req.body, req.currentUser, req.params.id)));
|
||||||
|
router.post('/connections/:id/test', h((req) => svc.testConnection(req.params.id, req.currentUser)));
|
||||||
|
router.get('/connections/:id/browse', h((req) => svc.browse(req.params.id, req.query, req.currentUser)));
|
||||||
|
router.get('/sources', h((req) => svc.listSources(req.currentUser)));
|
||||||
|
router.put('/sources/:purpose', h((req) => svc.saveSource(req.params.purpose, req.body, req.currentUser)));
|
||||||
|
router.post('/sources/patients/sync', h((req) => svc.syncPatients({ full: !!req.body?.full, user: req.currentUser })));
|
||||||
|
router.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
|
||||||
|
router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser)));
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { requireEditVersion } from '../../utils/editVersion.js';
|
||||||
|
import { guayaquilNow } from '../meetings/recurrence.js';
|
||||||
|
import { connectorGet, connectorAllRows, normalizeBaseUrl } from './connector.js';
|
||||||
|
|
||||||
|
// Conexiones externas (ver docs/CONEXIONES-EXTERNAS.md). El sistema nunca escribe en el origen:
|
||||||
|
// solo lee por el conector y copia/consulta lo que el superadmin mapeó.
|
||||||
|
|
||||||
|
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
|
||||||
|
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
const TARGETS = {
|
||||||
|
employees: { required: ['identification'], fields: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] },
|
||||||
|
patients: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuperadmin = (user) => user.roleCode === 'superadmin';
|
||||||
|
const isAdmin = (user) => ['admin', 'superadmin'].includes(user.roleCode);
|
||||||
|
export function requireSuperadmin(user) {
|
||||||
|
if (!isSuperadmin(user)) throw new HttpError(403, 'Solo el superadmin configura las conexiones externas');
|
||||||
|
}
|
||||||
|
export function requireAdmin(user) {
|
||||||
|
if (!isAdmin(user)) throw new HttpError(403, 'No tienes permisos para esta acción');
|
||||||
|
}
|
||||||
|
const num = (v) => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const purposeOf = (p) => {
|
||||||
|
if (!TARGETS[p]) throw new HttpError(400, 'Destino inválido');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const clip = (v, max) => {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
const s = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||||
|
return s ? s.slice(0, max) : null;
|
||||||
|
};
|
||||||
|
const dateOnly = (v) => (typeof v === 'string' && /^\d{4}-\d\d-\d\d/.test(v) ? v.slice(0, 10) : null);
|
||||||
|
const truthy = (v) => v === true || v === 1 || /^(1|s|si|sí|true|t|y|activo)$/i.test(String(v ?? '').trim());
|
||||||
|
|
||||||
|
// --- Conexiones ---
|
||||||
|
|
||||||
|
async function loadConnection(id) {
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM external_connections WHERE id=?', [num(id)]);
|
||||||
|
if (!row) throw new HttpError(404, 'Conexión no encontrada');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const clientFor = (row) => ({ baseUrl: row.base_url, token: decrypt(row.token_encrypted) });
|
||||||
|
const publicConnection = (r) => ({ id: r.id, name: r.name, base_url: r.base_url, is_active: !!r.is_active, edit_version: r.edit_version, created_at: r.created_at, updated_at: r.updated_at });
|
||||||
|
|
||||||
|
export async function listConnections(user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
return (await exec(pool, 'SELECT * FROM external_connections ORDER BY name')).map(publicConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConnection(raw, user, connId = null) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const name = clip(raw.name, 120);
|
||||||
|
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||||
|
const baseUrl = normalizeBaseUrl(raw.baseUrl);
|
||||||
|
const token = raw.token ? String(raw.token).trim() : '';
|
||||||
|
if (connId) {
|
||||||
|
const before = await loadConnection(connId);
|
||||||
|
const version = requireEditVersion(Number(raw.editVersion));
|
||||||
|
const result = await exec(pool, `UPDATE external_connections SET name=?,base_url=?,is_active=?${token ? ',token_encrypted=?' : ''},edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
|
||||||
|
[name, baseUrl, raw.isActive === false ? 0 : 1, ...(token ? [encrypt(token)] : []), before.id, version]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Conexión modificada por otra persona');
|
||||||
|
await logAudit({ userId: user.id, action: 'update', entityType: 'external_connection', entityId: before.id, detail: { name, baseUrl, tokenChanged: !!token } });
|
||||||
|
return publicConnection(await loadConnection(before.id));
|
||||||
|
}
|
||||||
|
if (!token) throw new HttpError(400, 'El token es obligatorio');
|
||||||
|
const result = await exec(pool, 'INSERT INTO external_connections (name,base_url,token_encrypted,created_by) VALUES (?,?,?,?)', [name, baseUrl, encrypt(token), user.id]);
|
||||||
|
await logAudit({ userId: user.id, action: 'create', entityType: 'external_connection', entityId: result.insertId, detail: { name, baseUrl } });
|
||||||
|
return publicConnection(await loadConnection(result.insertId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConnection(connId, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const row = await loadConnection(connId);
|
||||||
|
const health = await connectorGet(clientFor(row), '/v1/health');
|
||||||
|
await logAudit({ userId: user.id, action: 'test', entityType: 'external_connection', entityId: row.id });
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exploración para el superadmin: fuentes, tablas, estructura y una muestra de filas.
|
||||||
|
export async function browse(connId, q, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const conn = clientFor(await loadConnection(connId));
|
||||||
|
if (!q.source) return connectorGet(conn, '/v1/sources');
|
||||||
|
if (!ID_RE.test(q.source)) throw new HttpError(400, 'Fuente inválida');
|
||||||
|
const base = `/v1/sources/${encodeURIComponent(q.source)}/tables`;
|
||||||
|
if (!q.table) return connectorGet(conn, base);
|
||||||
|
if (!ID_RE.test(q.table)) throw new HttpError(400, 'Tabla inválida');
|
||||||
|
const [schema, sample] = await Promise.all([
|
||||||
|
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/schema`),
|
||||||
|
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/rows`, { limit: 20 })
|
||||||
|
]);
|
||||||
|
await logAudit({ userId: user.id, action: 'preview', entityType: 'external_connection', entityId: Number(connId), detail: { source: q.source, table: q.table } });
|
||||||
|
return { schema, sample: sample.rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fuentes (qué alimenta Pacientes / Empleados) ---
|
||||||
|
|
||||||
|
const parseSource = (r) => r && ({ ...r, field_map: JSON.parse(r.field_map_json), filter: r.filter_json ? JSON.parse(r.filter_json) : {}, last_result: r.last_result_json ? JSON.parse(r.last_result_json) : null });
|
||||||
|
async function sourceRow(purpose) {
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM external_sources WHERE purpose=?', [purposeOf(purpose)]);
|
||||||
|
return parseSource(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSources(user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const rows = (await exec(pool, 'SELECT s.*, c.name AS connection_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id')).map(parseSource);
|
||||||
|
// El admin solo ve el estado (para "Actualizar empleados"); el mapeo es del superadmin.
|
||||||
|
return isSuperadmin(user) ? rows : rows.map((r) => ({ purpose: r.purpose, mode: r.mode, connection_name: r.connection_name, last_sync_at: r.last_sync_at, last_full_sync_at: r.last_full_sync_at, last_result: r.last_result }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSource(purpose, raw, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const p = purposeOf(purpose);
|
||||||
|
const conn = await loadConnection(raw.connectionId);
|
||||||
|
const sourceKey = String(raw.sourceKey || ''), tableName = String(raw.tableName || '');
|
||||||
|
if (!ID_RE.test(sourceKey) || !ID_RE.test(tableName)) throw new HttpError(400, 'Fuente o tabla inválida');
|
||||||
|
const mode = p === 'employees' ? 'import' : raw.mode === 'live' ? 'live' : 'import';
|
||||||
|
// El mapeo se valida contra la estructura real que publica el conector.
|
||||||
|
const schema = await connectorGet(clientFor(conn), `/v1/sources/${encodeURIComponent(sourceKey)}/tables/${encodeURIComponent(tableName)}/schema`);
|
||||||
|
const known = new Set(schema.fields.map((f) => f.name));
|
||||||
|
const map = {};
|
||||||
|
for (const target of TARGETS[p].fields) {
|
||||||
|
const field = raw.fieldMap?.[target];
|
||||||
|
if (!field) continue;
|
||||||
|
if (!known.has(field)) throw new HttpError(400, `Campo inexistente en la tabla: ${field}`);
|
||||||
|
map[target] = field;
|
||||||
|
}
|
||||||
|
for (const r of TARGETS[p].required) if (!map[r]) throw new HttpError(400, `Falta mapear el campo obligatorio: ${r}`);
|
||||||
|
const filter = {};
|
||||||
|
for (const [field, value] of Object.entries(raw.filter || {})) {
|
||||||
|
if (!known.has(field)) throw new HttpError(400, `Campo de filtro inexistente: ${field}`);
|
||||||
|
if (String(value).trim()) filter[field] = String(value).trim().slice(0, 60);
|
||||||
|
}
|
||||||
|
const changedField = raw.changedField ? String(raw.changedField) : null;
|
||||||
|
if (changedField && schema.fields.find((f) => f.name === changedField)?.type !== 'T') throw new HttpError(400, 'El campo de cambios debe ser de fecha y hora');
|
||||||
|
const before = await sourceRow(p);
|
||||||
|
if (p === 'patients' && before && before.field_map.externalId !== map.externalId) {
|
||||||
|
const [{ n }] = await exec(pool, 'SELECT COUNT(*) AS n FROM clinic_patients WHERE external_source=?', [patientKey(before)]);
|
||||||
|
if (n) throw new HttpError(409, 'No se puede cambiar el campo "código en el origen" con pacientes ya importados');
|
||||||
|
}
|
||||||
|
const sameTable = before && before.connection_id === conn.id && before.source_key === sourceKey && before.table_name === tableName;
|
||||||
|
if (before) {
|
||||||
|
const result = await exec(pool, `UPDATE external_sources SET connection_id=?,source_key=?,table_name=?,mode=?,field_map_json=?,filter_json=?,changed_field=?,
|
||||||
|
${sameTable ? '' : 'last_full_sync_at=NULL,last_sync_at=NULL,last_max_recno=NULL,last_changed_value=NULL,'}edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
|
||||||
|
[conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField, before.id, requireEditVersion(Number(raw.editVersion))]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Fuente modificada por otra persona');
|
||||||
|
} else {
|
||||||
|
await exec(pool, 'INSERT INTO external_sources (purpose,connection_id,source_key,table_name,mode,field_map_json,filter_json,changed_field) VALUES (?,?,?,?,?,?,?,?)',
|
||||||
|
[p, conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField]);
|
||||||
|
}
|
||||||
|
await logAudit({ userId: user.id, action: before ? 'update' : 'create', entityType: 'external_source', entityId: null, detail: { purpose: p, sourceKey, tableName, mode, map, filter } });
|
||||||
|
return sourceRow(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceClient(purpose) {
|
||||||
|
const src = await sourceRow(purpose);
|
||||||
|
if (!src) throw new HttpError(409, purpose === 'employees' ? 'No hay fuente de nómina configurada' : 'No hay fuente de pacientes configurada');
|
||||||
|
const conn = await loadConnection(src.connection_id);
|
||||||
|
if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada');
|
||||||
|
return { src, conn: clientFor(conn) };
|
||||||
|
}
|
||||||
|
const eqQuery = (filter) => Object.fromEntries(Object.entries(filter || {}).map(([k, v]) => [`eq.${k}`, v]));
|
||||||
|
const fieldsQuery = (src) => [...new Set([...Object.values(src.field_map), ...(src.changed_field ? [src.changed_field] : [])])].join(',');
|
||||||
|
|
||||||
|
async function finishSync(src, values, result) {
|
||||||
|
await exec(pool, `UPDATE external_sources SET last_sync_at=?,${values.full ? 'last_full_sync_at=?,' : ''}last_max_recno=?,last_changed_value=?,last_result_json=? WHERE id=?`,
|
||||||
|
[guayaquilNow(), ...(values.full ? [guayaquilNow()] : []), values.maxRecno ?? src.last_max_recno, values.changedValue ?? src.last_changed_value, JSON.stringify(result), src.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empleados (nómina): siempre importados, a pedido (admin/superadmin) ---
|
||||||
|
|
||||||
|
const running = new Set();
|
||||||
|
async function exclusive(key, fn) {
|
||||||
|
if (running.has(key)) throw new HttpError(409, 'Ya hay una sincronización en curso');
|
||||||
|
running.add(key);
|
||||||
|
try { return await fn(); } finally { running.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncEmployees(user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
return exclusive('employees', async () => {
|
||||||
|
const { src, conn } = await sourceClient('employees');
|
||||||
|
const m = src.field_map;
|
||||||
|
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, { fields: fieldsQuery(src), ...eqQuery(src.filter) });
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const seen = [];
|
||||||
|
let created = 0, updated = 0, protectedRows = 0;
|
||||||
|
// Empleados con usuario en el sistema (p. ej. médicos) no se actualizan ni se inactivan solos.
|
||||||
|
const known = await exec(pool, 'SELECT identification, user_id FROM employees');
|
||||||
|
const withUser = new Set(known.filter((e) => e.user_id).map((e) => e.identification));
|
||||||
|
const existed = new Set(known.map((e) => e.identification));
|
||||||
|
for (const r of rows) {
|
||||||
|
const identification = clip(r[m.identification], 40);
|
||||||
|
if (!identification) continue;
|
||||||
|
seen.push(identification);
|
||||||
|
if (withUser.has(identification)) { protectedRows++; continue; }
|
||||||
|
const leave = m.leaveDate ? dateOnly(r[m.leaveDate]) : null;
|
||||||
|
const active = m.active ? truthy(r[m.active]) : !leave;
|
||||||
|
const values = [clip(m.lastNames && r[m.lastNames], 120) || '', clip(m.firstNames && r[m.firstNames], 120) || '', clip(m.phone && r[m.phone], 40), clip(m.phone2 && r[m.phone2], 40),
|
||||||
|
m.birthDate ? dateOnly(r[m.birthDate]) : null, clip(m.position && r[m.position], 120), clip(m.department && r[m.department], 120), m.hireDate ? dateOnly(r[m.hireDate]) : null, leave, active ? 1 : 0];
|
||||||
|
const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,phone2,birth_date,position,department_name,hire_date,leave_date,is_active,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?) ON DUPLICATE KEY UPDATE last_names=VALUES(last_names),first_names=VALUES(first_names),phone=VALUES(phone),phone2=VALUES(phone2),birth_date=VALUES(birth_date),
|
||||||
|
position=VALUES(position),department_name=VALUES(department_name),hire_date=VALUES(hire_date),leave_date=VALUES(leave_date),is_active=VALUES(is_active),in_source=1,origin='payroll'`,
|
||||||
|
[identification, ...values, now]);
|
||||||
|
// MySQL informa 2 filas afectadas solo cuando el registro existente cambió de verdad.
|
||||||
|
if (!existed.has(identification)) created++;
|
||||||
|
else if (result.affectedRows === 2) updated++;
|
||||||
|
}
|
||||||
|
const synced = seen.filter((x) => !withUser.has(x));
|
||||||
|
if (synced.length) await exec(pool, 'UPDATE employees SET synced_at=? WHERE identification IN (?)', [now, synced]);
|
||||||
|
// Los que ya no están en la nómina quedan inactivos (no se borran: pueden tener usuario).
|
||||||
|
const gone = seen.length
|
||||||
|
? await exec(pool, "UPDATE employees SET in_source=0,is_active=0 WHERE in_source=1 AND origin='payroll' AND user_id IS NULL AND identification NOT IN (?)", [seen])
|
||||||
|
: { affectedRows: 0 };
|
||||||
|
const result = { at: now, full: true, total: rows.length, created, updated, removed: gone.affectedRows, protected: protectedRows };
|
||||||
|
await finishSync(src, { full: true, maxRecno }, result);
|
||||||
|
await logAudit({ userId: user.id, action: 'sync', entityType: 'employees', entityId: null, detail: result });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEmployees(user, q = {}) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const onlyFree = q.available === '1';
|
||||||
|
return exec(pool, `SELECT e.*, u.name AS user_name FROM employees e LEFT JOIN users u ON u.id=e.user_id
|
||||||
|
WHERE 1=1 ${onlyFree ? 'AND e.is_active=1 AND e.user_id IS NULL' : ''} ORDER BY e.last_names, e.first_names`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireFreeEmployee(employeeId) {
|
||||||
|
const [e] = await exec(pool, 'SELECT id FROM employees WHERE id=? AND is_active=1 AND user_id IS NULL', [num(employeeId)]);
|
||||||
|
if (!e) throw new HttpError(409, 'Ese empleado ya tiene usuario, está inactivo o no existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vincula un usuario recién creado con su empleado (desde "Nuevo usuario" → Desde nómina).
|
||||||
|
export async function linkEmployeeToUser(employeeId, userId, connection = pool) {
|
||||||
|
const result = await exec(connection, 'UPDATE employees SET user_id=? WHERE id=? AND user_id IS NULL', [userId, num(employeeId)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pacientes (clientes del origen) ---
|
||||||
|
|
||||||
|
const patientKey = (src) => `src${src.id}`;
|
||||||
|
function patientValues(src, r) {
|
||||||
|
const m = src.field_map;
|
||||||
|
const email = clip(m.email && r[m.email], 160);
|
||||||
|
return {
|
||||||
|
externalId: clip(r[m.externalId], 40),
|
||||||
|
identification: clip(m.identification && r[m.identification], 40),
|
||||||
|
fullName: clip(r[m.fullName], 120),
|
||||||
|
phone: clip(m.phone && r[m.phone], 40) || '',
|
||||||
|
email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert de un lote: manda el origen (cédula, teléfono, correo, nombre de origen). El nombre visible
|
||||||
|
// solo se reemplaza mientras recepción no lo haya separado a mano (name_split=0).
|
||||||
|
async function upsertPatients(src, rows, now) {
|
||||||
|
const key = patientKey(src);
|
||||||
|
const stats = { created: 0, updated: 0, linked: 0, unchanged: 0, skipped: 0, identConflicts: 0 };
|
||||||
|
const seen = new Set();
|
||||||
|
if (!rows.length) return { stats, seen };
|
||||||
|
// Lotes chicos (incremental, importar uno): solo se precarga lo del lote; la completa trae todo.
|
||||||
|
const small = rows.length <= 2000;
|
||||||
|
const batchIds = rows.map((r) => clip(r[src.field_map.externalId], 40)).filter(Boolean);
|
||||||
|
const batchIdents = src.field_map.identification ? rows.map((r) => clip(r[src.field_map.identification], 40)).filter(Boolean) : [];
|
||||||
|
const existing = new Map((small && !batchIds.length ? [] : await exec(pool, `SELECT id,external_id,identification,source_name,phone,email,is_active,name_split FROM clinic_patients WHERE external_source=?${small ? ' AND external_id IN (?)' : ''}`, small ? [key, batchIds] : [key])).map((p) => [p.external_id, p]));
|
||||||
|
const byIdent = new Map((small && !batchIdents.length ? [] : await exec(pool, `SELECT id,identification,external_id FROM clinic_patients WHERE identification IS NOT NULL${small ? ' AND identification IN (?)' : ''}`, small ? [batchIdents] : [])).map((p) => [p.identification, p]));
|
||||||
|
for (const r of rows) {
|
||||||
|
const v = patientValues(src, r);
|
||||||
|
if (v.externalId) seen.add(v.externalId);
|
||||||
|
if (!v.externalId || !v.fullName) { stats.skipped++; continue; }
|
||||||
|
// Código repetido en el lote (duplicado en el origen): se procesa una sola vez.
|
||||||
|
if (existing.get(v.externalId)?.batch) { stats.skipped++; continue; }
|
||||||
|
const owner = v.identification ? byIdent.get(v.identification) : null;
|
||||||
|
const cur = existing.get(v.externalId);
|
||||||
|
// La cédula es única: si otro paciente ya la tiene (duplicado en el origen), no se repite.
|
||||||
|
const identFor = (selfId) => (v.identification && owner && owner.id !== selfId ? (stats.identConflicts++, null) : v.identification);
|
||||||
|
if (cur) {
|
||||||
|
const ident = identFor(cur.id);
|
||||||
|
if (cur.source_name === v.fullName && (cur.identification || null) === ident && cur.phone === v.phone && (cur.email || null) === v.email && cur.is_active) { cur.batch = true; stats.unchanged++; continue; }
|
||||||
|
await exec(pool, 'UPDATE clinic_patients SET source_name=?,first_names=IF(name_split=0,?,first_names),identification=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
|
||||||
|
[v.fullName, v.fullName, ident, v.phone, v.email, now, cur.id]);
|
||||||
|
if (ident) byIdent.set(ident, { id: cur.id, identification: ident });
|
||||||
|
cur.batch = true;
|
||||||
|
stats.updated++;
|
||||||
|
} else if (owner && !owner.external_id) {
|
||||||
|
// Paciente creado a mano con la misma cédula: se vincula y manda el origen (sin tocar su nombre separado).
|
||||||
|
await exec(pool, 'UPDATE clinic_patients SET external_source=?,external_id=?,source_name=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
|
||||||
|
[key, v.externalId, v.fullName, v.phone, v.email, now, owner.id]);
|
||||||
|
owner.external_id = v.externalId;
|
||||||
|
existing.set(v.externalId, { id: owner.id, batch: true });
|
||||||
|
stats.linked++;
|
||||||
|
} else {
|
||||||
|
const result = await exec(pool, `INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,external_source,external_id,source_name,name_split,synced_at)
|
||||||
|
VALUES (?,'',?,?,?,?,?,?,0,?)`, [v.fullName, identFor(null), v.phone, v.email, key, v.externalId, v.fullName, now]);
|
||||||
|
if (v.identification && !owner) byIdent.set(v.identification, { id: result.insertId, identification: v.identification, external_id: v.externalId });
|
||||||
|
existing.set(v.externalId, { id: result.insertId, batch: true });
|
||||||
|
stats.created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { stats, seen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPatients({ full = false, user = null } = {}) {
|
||||||
|
if (user) requireSuperadmin(user);
|
||||||
|
return exclusive('patients', async () => {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
if (src.mode !== 'import') throw new HttpError(409, 'La fuente de pacientes está en modo consulta en vivo');
|
||||||
|
const doFull = full || !src.last_full_sync_at;
|
||||||
|
const query = { fields: fieldsQuery(src), ...eqQuery(src.filter) };
|
||||||
|
if (!doFull) {
|
||||||
|
query.afterRecno = src.last_max_recno ?? 0;
|
||||||
|
if (src.changed_field && src.last_changed_value) Object.assign(query, { changedField: src.changed_field, changedSince: src.last_changed_value });
|
||||||
|
}
|
||||||
|
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, query);
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const { stats, seen } = await upsertPatients(src, rows, now);
|
||||||
|
let deactivated = 0;
|
||||||
|
if (doFull && seen.size) {
|
||||||
|
// Los que ya no están en el origen (borrados) quedan inactivos; su historial se conserva.
|
||||||
|
deactivated = (await exec(pool, 'UPDATE clinic_patients SET is_active=0 WHERE external_source=? AND is_active=1 AND external_id NOT IN (?)', [patientKey(src), [...seen]])).affectedRows;
|
||||||
|
}
|
||||||
|
let changedValue = src.last_changed_value;
|
||||||
|
if (src.changed_field) for (const r of rows) { const c = r[src.changed_field]; if (c && (!changedValue || String(c) > changedValue)) changedValue = String(c).slice(0, 40); }
|
||||||
|
const result = { at: now, full: doFull, received: rows.length, ...stats, deactivated };
|
||||||
|
await finishSync(src, { full: doFull, maxRecno, changedValue }, result);
|
||||||
|
if (user) await logAudit({ userId: user.id, action: 'sync', entityType: 'clinic_patients', entityId: null, detail: result });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo consulta en vivo: buscar en el origen y traer al sistema solo el paciente elegido.
|
||||||
|
export async function patientSourceInfo() {
|
||||||
|
const [src] = await exec(pool, "SELECT s.mode, s.last_sync_at FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1");
|
||||||
|
return src ? { configured: true, mode: src.mode, last_sync_at: src.last_sync_at } : { configured: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchExternalPatients(text) {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
const m = src.field_map;
|
||||||
|
const term = String(text || '').trim();
|
||||||
|
if (term.length < 3) return [];
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
q: term.slice(0, 60), qFields: [m.fullName, m.identification, m.phone].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 20, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
const ids = page.rows.map((r) => clip(r[m.externalId], 40)).filter(Boolean);
|
||||||
|
const local = ids.length ? new Map((await exec(pool, 'SELECT id,external_id FROM clinic_patients WHERE external_source=? AND external_id IN (?)', [patientKey(src), ids])).map((p) => [p.external_id, p.id])) : new Map();
|
||||||
|
return page.rows.map((r) => {
|
||||||
|
const v = patientValues(src, r);
|
||||||
|
return { external_id: v.externalId, identification: v.identification, full_name: v.fullName, phone: v.phone, email: v.email, local_id: local.get(v.externalId) || null };
|
||||||
|
}).filter((p) => p.external_id && p.full_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importExternalPatient(externalId, user) {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
const ext = clip(externalId, 40);
|
||||||
|
if (!ext) throw new HttpError(400, 'Paciente externo inválido');
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
[`eq.${src.field_map.externalId}`]: ext, fields: fieldsQuery(src), limit: 1, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
if (!page.rows.length) throw new HttpError(404, 'El paciente ya no existe en el sistema origen');
|
||||||
|
await upsertPatients(src, page.rows, guayaquilNow());
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_patients WHERE external_source=? AND external_id=?', [patientKey(src), ext]);
|
||||||
|
if (!row) throw new HttpError(409, 'No se pudo traer el paciente');
|
||||||
|
await logAudit({ userId: user.id, action: 'import', entityType: 'clinic_patient', entityId: row.id, detail: { externalId: ext } });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programación (job): completa lunes a viernes a las 05:00; incremental cada 15 min de 06:00 a 21:00.
|
||||||
|
export async function scheduledPatientSync() {
|
||||||
|
const src = await sourceRow('patients');
|
||||||
|
if (!src || src.mode !== 'import') return null;
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const weekday = new Date(`${now.slice(0, 10)}T12:00:00Z`).getUTCDay();
|
||||||
|
const hour = Number(now.slice(11, 13));
|
||||||
|
const fullToday = src.last_full_sync_at && String(src.last_full_sync_at).slice(0, 10) === now.slice(0, 10);
|
||||||
|
if (!src.last_full_sync_at || (weekday >= 1 && weekday <= 5 && hour === 5 && !fullToday)) return syncPatients({ full: true });
|
||||||
|
const minutesSince = src.last_sync_at ? (Date.parse(`${now.replace(' ', 'T')}Z`) - Date.parse(`${String(src.last_sync_at).replace(' ', 'T')}Z`)) / 60000 : Infinity;
|
||||||
|
if (hour >= 6 && hour < 21 && minutesSince >= 15) return syncPatients({ full: false });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { linkEmployeeToUser, requireFreeEmployee } from '../integrations/service.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
@@ -57,10 +58,20 @@ export async function createUser(payload, actor) {
|
|||||||
const existing = await repo.findByEmail(payload.email);
|
const existing = await repo.findByEmail(payload.email);
|
||||||
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
||||||
await checkRole(payload.roleId, actor);
|
await checkRole(payload.roleId, actor);
|
||||||
|
// "Desde nómina": el empleado debe existir, estar activo y no tener usuario (se valida antes de crear).
|
||||||
|
if (payload.employeeId) await requireFreeEmployee(payload.employeeId);
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(payload.password, 12);
|
const passwordHash = await bcrypt.hash(payload.password, 12);
|
||||||
const user = await repo.create({ ...payload, passwordHash });
|
const user = await repo.create({ ...payload, passwordHash });
|
||||||
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
|
if (payload.employeeId) {
|
||||||
|
try {
|
||||||
|
await linkEmployeeToUser(payload.employeeId, user.id);
|
||||||
|
} catch (err) {
|
||||||
|
await repo.remove(user.id);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id, detail: payload.employeeId ? { employeeId: Number(payload.employeeId) } : undefined });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Carga como empleados (origin=provider) a proveedores de SAGA que trabajan en la clínica pero no
|
||||||
|
// están en nómina (médicos que facturan). Lee del conector configurado en Conexiones externas, así
|
||||||
|
// no se guardan datos personales en el repositorio.
|
||||||
|
// node src/scripts/import-provider-employees.js --position=MEDICO 137+031 142+033 809 ...
|
||||||
|
// Cada argumento es una persona; "+" une códigos duplicados de la misma persona (el primero da el
|
||||||
|
// nombre; teléfono y correo se toman del primero que los tenga). Si la cédula ya existe como
|
||||||
|
// empleado (p. ej. de nómina) solo completa teléfono y correo vacíos.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { decrypt } from '../utils/crypto.js';
|
||||||
|
import { connectorGet } from '../modules/integrations/connector.js';
|
||||||
|
|
||||||
|
const SOURCE = 'clientes', TABLE = 'PROVEDOR';
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const position = (args.find((a) => a.startsWith('--position=')) || '--position=MEDICO').split('=')[1];
|
||||||
|
const people = args.filter((a) => !a.startsWith('--')).map((a) => a.split('+'));
|
||||||
|
if (!people.length) { console.error('Uso: import-provider-employees.js [--position=X] COD[+COD] ...'); process.exit(1); }
|
||||||
|
|
||||||
|
const [conn] = (await pool.query("SELECT c.base_url, c.token_encrypted FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1"))[0];
|
||||||
|
if (!conn) { console.error('No hay conexión activa (Configuración → Conexiones externas)'); process.exit(1); }
|
||||||
|
const client = { baseUrl: conn.base_url, token: decrypt(conn.token_encrypted) };
|
||||||
|
const valid = (v) => v && !/^9+$/.test(v);
|
||||||
|
|
||||||
|
for (const codes of people) {
|
||||||
|
const rows = [];
|
||||||
|
for (const code of codes) {
|
||||||
|
const page = await connectorGet(client, `/v1/sources/${SOURCE}/tables/${TABLE}/rows`, { [`eq.PROVCOD`]: code, limit: 1 });
|
||||||
|
if (page.rows[0]) rows.push(page.rows[0]);
|
||||||
|
}
|
||||||
|
if (!rows.length) { console.log(`${codes.join('+')}: no existe en proveedores`); continue; }
|
||||||
|
const words = String(rows[0].PROVNOM).replace(/^DRA?\.?\s+/i, '').trim().split(/\s+/);
|
||||||
|
const identification = String(rows[0].PROVRUC || '').slice(0, 10);
|
||||||
|
if (!/^\d{10}$/.test(identification)) { console.log(`${codes.join('+')}: cédula inválida`); continue; }
|
||||||
|
const phone = rows.map((r) => r.PROVTEL).find(valid) || null;
|
||||||
|
const email = rows.map((r) => r.PROVEMAIL).find((e) => e && /@/.test(e)) || null;
|
||||||
|
const last = words.slice(0, 2).join(' '), first = words.slice(2).join(' ');
|
||||||
|
const [res] = await pool.query(`INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,1,'provider',0,NOW()) ON DUPLICATE KEY UPDATE phone=COALESCE(phone,VALUES(phone)),email=COALESCE(email,VALUES(email))`,
|
||||||
|
[identification, last, first, phone, email, position]);
|
||||||
|
console.log(`${codes.join('+')}: ${last} ${first} → ${res.affectedRows === 1 ? 'creado' : res.affectedRows === 2 ? 'ya existía (completado)' : 'ya existía'}`);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
@@ -2,6 +2,7 @@ import { app } from './app.js';
|
|||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
|
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
|
||||||
import { startMeetingReminderJob } from './jobs/meetingReminders.js';
|
import { startMeetingReminderJob } from './jobs/meetingReminders.js';
|
||||||
|
import { startIntegrationSyncJob } from './jobs/integrationSync.js';
|
||||||
|
|
||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
|
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
|
||||||
@@ -9,3 +10,4 @@ app.listen(env.port, () => {
|
|||||||
|
|
||||||
startRecurringTaskReminderJob();
|
startRecurringTaskReminderJob();
|
||||||
startMeetingReminderJob();
|
startMeetingReminderJob();
|
||||||
|
startIntegrationSyncJob();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro,
|
|||||||
|
|
||||||
| Conector | Carpeta | Estado |
|
| Conector | Carpeta | Estado |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.0.0 |
|
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.1.0 |
|
||||||
|
|
||||||
## Reglas que todo conector debe cumplir
|
## Reglas que todo conector debe cumplir
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro,
|
|||||||
| `GET /v1/sources` | `{ sources: [{ id, name }] }` — carpetas/bases configuradas |
|
| `GET /v1/sources` | `{ sources: [{ id, name }] }` — carpetas/bases configuradas |
|
||||||
| `GET /v1/sources/:source/tables` | `{ tables: [{ name, records, fields, version, modifiedAt }] }` |
|
| `GET /v1/sources/:source/tables` | `{ tables: [{ name, records, fields, version, modifiedAt }] }` |
|
||||||
| `GET /v1/sources/:source/tables/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` |
|
| `GET /v1/sources/:source/tables/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` |
|
||||||
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, total, offset, limit, fields, rows }` |
|
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, maxRecno, total, offset, limit, fields, rows }` |
|
||||||
|
|
||||||
Parámetros de `rows`:
|
Parámetros de `rows`:
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ Parámetros de `rows`:
|
|||||||
- `q=texto` y `qFields=A,B`: contiene, sin distinguir mayúsculas ni tildes.
|
- `q=texto` y `qFields=A,B`: contiene, sin distinguir mayúsculas ni tildes.
|
||||||
- `eq.CAMPO=valor`: igualdad exacta (sin distinguir mayúsculas). Se pueden combinar varios.
|
- `eq.CAMPO=valor`: igualdad exacta (sin distinguir mayúsculas). Se pueden combinar varios.
|
||||||
- `includeDeleted=1`: incluye los registros marcados como borrados (`_deleted`).
|
- `includeDeleted=1`: incluye los registros marcados como borrados (`_deleted`).
|
||||||
|
- Sincronización incremental (v1.1): `afterRecno=N` devuelve solo lo agregado después del registro N;
|
||||||
|
`changedField=CAMPO&changedSince=valor` solo lo modificado después de ese valor (campo de fecha/hora
|
||||||
|
del origen, comparado tal como lo devuelve el conector). Con ambos, basta cumplir uno. La respuesta
|
||||||
|
trae `maxRecno` para guardar como próximo `afterRecno`. Una sincronización completa periódica
|
||||||
|
recoge los borrados.
|
||||||
|
|
||||||
Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica:
|
Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica:
|
||||||
el sistema la compara para saber si hace falta volver a sincronizar.
|
el sistema la compara para saber si hace falta volver a sincronizar.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Conector FoxPro 1.0.0
|
# Conector FoxPro 1.1.0
|
||||||
|
|
||||||
API de solo lectura para tablas FoxPro/Visual FoxPro (`.dbf` con memo `.fpt`), según el contrato
|
API de solo lectura para tablas FoxPro/Visual FoxPro (`.dbf` con memo `.fpt`), según el contrato
|
||||||
v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única dependencia: `dbffile`.
|
v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única dependencia: `dbffile`.
|
||||||
@@ -7,14 +7,16 @@ v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única
|
|||||||
copia, así no choca con los bloqueos de SAGA. Si FoxPro tiene la tabla en uso exclusivo → `423`.
|
copia, así no choca con los bloqueos de SAGA. Si FoxPro tiene la tabla en uso exclusivo → `423`.
|
||||||
- El índice `.cdx` no se usa (se recorre la tabla). Las tablas se guardan en caché y la caché se
|
- El índice `.cdx` no se usa (se recorre la tabla). Las tablas se guardan en caché y la caché se
|
||||||
renueva sola cuando cambia el archivo.
|
renueva sola cuando cambia el archivo.
|
||||||
|
- Tablas que el origen escribe todo el día: no se recopian más de una vez cada `limits.minReloadSeconds`
|
||||||
|
(60 por defecto).
|
||||||
- Tipos soportados: C, N, F, Y, I, L, D, T, B, M. Otros (V, Q, W…) se omiten.
|
- Tipos soportados: C, N, F, Y, I, L, D, T, B, M. Otros (V, Q, W…) se omiten.
|
||||||
|
|
||||||
## Instalación en el Windows (`C:\api_project\foxpro`)
|
## Instalación en el Windows (`C:\api_project\foxpro`)
|
||||||
|
|
||||||
1. Copiar esta carpeta a `C:\api_project\foxpro` **con** `node_modules` (no requiere internet en el
|
1. Copiar esta carpeta a `C:\api_project\foxpro` **con** `node_modules` (no requiere internet en el
|
||||||
server). Sin `config.json` ni `logs` de otro equipo.
|
server). Sin `config.json` ni `logs` de otro equipo.
|
||||||
2. Node: copiar `node.exe` portable (Node 20 LTS o superior, Windows x64) a
|
2. Node: usa el Node instalado en el servidor (Exilaser tiene Node 24); si no hay, copiar `node.exe`
|
||||||
`C:\api_project\foxpro\node\node.exe`. Así no hay que instalar Node en el servidor.
|
portable (Node 20+ x64) a `C:\api_project\foxpro\node\node.exe`.
|
||||||
3. Token: `node\node.exe scripts\generar-token.js`. El **TOKEN** se carga en el sistema; el
|
3. Token: `node\node.exe scripts\generar-token.js`. El **TOKEN** se carga en el sistema; el
|
||||||
**tokenSha256** va en `config.json`.
|
**tokenSha256** va en `config.json`.
|
||||||
4. `config.json` (a partir de `config.example.json`):
|
4. `config.json` (a partir de `config.example.json`):
|
||||||
@@ -40,5 +42,5 @@ Logs: `logs\acceso-AAAA-MM-DD.log` (sin tokens ni valores buscados).
|
|||||||
|
|
||||||
## Desarrollo
|
## Desarrollo
|
||||||
|
|
||||||
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (16 pruebas).
|
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (19 pruebas).
|
||||||
- `npm run token`: genera token + hash.
|
- `npm run token`: genera token + hash.
|
||||||
|
|||||||
@@ -1,24 +1,75 @@
|
|||||||
{
|
{
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8455,
|
"port": 8455,
|
||||||
"tls": { "pfx": "", "passphraseEnv": "CONECTOR_TLS_PASS" },
|
"tls": {
|
||||||
|
"pfx": "",
|
||||||
|
"passphraseEnv": "CONECTOR_TLS_PASS"
|
||||||
|
},
|
||||||
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
|
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
|
||||||
"allowIps": ["192.168.222.3", "192.168.222.40"],
|
"allowIps": [
|
||||||
|
"192.168.222.3",
|
||||||
|
"192.168.222.40"
|
||||||
|
],
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"id": "nomina",
|
"id": "nomina",
|
||||||
"name": "Nómina",
|
"name": "Nómina",
|
||||||
"path": "C:\\Sistemas\\nomina",
|
"path": "C:\\Sistema\\Nomina\\Datos\\Exilaser",
|
||||||
"encoding": "cp1252",
|
"encoding": "cp1252",
|
||||||
"tables": "*",
|
"tables": {
|
||||||
"denyFields": ["SUELDO", "SALARIO", "CLAVE", "PASSWORD"]
|
"EMPLEADO": {
|
||||||
|
"fields": [
|
||||||
|
"EMPCED",
|
||||||
|
"EMPAPE",
|
||||||
|
"EMPNOM",
|
||||||
|
"EMPTEL",
|
||||||
|
"EMPTEL1",
|
||||||
|
"EMPFECNAC",
|
||||||
|
"EMPCARGO",
|
||||||
|
"EMPDEPART",
|
||||||
|
"EMPINGRESO",
|
||||||
|
"EMPSALIDA",
|
||||||
|
"EMPACTIVO"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "clientes",
|
"id": "clientes",
|
||||||
"name": "Clientes SAGA",
|
"name": "SAGA (clientes y proveedores)",
|
||||||
"path": "C:\\Sistemas\\saga",
|
"path": "C:\\Sistema\\Datos\\exilaser",
|
||||||
"encoding": "cp1252",
|
"encoding": "cp850",
|
||||||
"tables": { "CLIENTES": { "fields": ["CODIGO", "CEDULA", "NOMBRE", "TELEFONO", "EMAIL", "FECNAC"] } }
|
"tables": {
|
||||||
|
"CLIENTES": {
|
||||||
|
"fields": [
|
||||||
|
"CLICOD",
|
||||||
|
"CLIRUC",
|
||||||
|
"CLINOM",
|
||||||
|
"CLITEL",
|
||||||
|
"CLIEMAIL",
|
||||||
|
"CLIDIR",
|
||||||
|
"CLICIU",
|
||||||
|
"CLIPERSONA",
|
||||||
|
"CLITIPRUC",
|
||||||
|
"CLISEXO",
|
||||||
|
"INACTIVO",
|
||||||
|
"CLIESTADO",
|
||||||
|
"FECHAACT"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PROVEDOR": {
|
||||||
|
"fields": [
|
||||||
|
"PROVCOD",
|
||||||
|
"PROVNOM",
|
||||||
|
"PROVRUC",
|
||||||
|
"PROVTEL",
|
||||||
|
"PROVEMAIL",
|
||||||
|
"PROVCIU",
|
||||||
|
"PROVTIPO",
|
||||||
|
"INACTIVO"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -28,7 +79,8 @@
|
|||||||
"maxQueuedReads": 10,
|
"maxQueuedReads": 10,
|
||||||
"cacheMaxRecordsPerTable": 100000,
|
"cacheMaxRecordsPerTable": 100000,
|
||||||
"cacheMaxTables": 10,
|
"cacheMaxTables": 10,
|
||||||
"requestTimeoutMs": 30000
|
"requestTimeoutMs": 30000,
|
||||||
|
"minReloadSeconds": 60
|
||||||
},
|
},
|
||||||
"logDir": "logs"
|
"logDir": "logs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkedcorp-conector-foxpro",
|
"name": "linkedcorp-conector-foxpro",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
|
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async function main() {
|
|||||||
allowIps: ['127.0.0.1'],
|
allowIps: ['127.0.0.1'],
|
||||||
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
|
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
|
||||||
logDir: path.join(dir, 'logs'),
|
logDir: path.join(dir, 'logs'),
|
||||||
|
limits: { minReloadSeconds: 0 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
|
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||||
@@ -103,6 +104,16 @@ async function main() {
|
|||||||
assert.strictEqual(r.rows.length, 1);
|
assert.strictEqual(r.rows.length, 1);
|
||||||
assert.strictEqual(r.rows[0]._recno, 2);
|
assert.strictEqual(r.rows[0]._recno, 2);
|
||||||
});
|
});
|
||||||
|
await check('incremental: afterRecno', async () => {
|
||||||
|
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?afterRecno=1')).body;
|
||||||
|
assert.deepStrictEqual(r.rows.map((x) => x._recno), [2]);
|
||||||
|
assert.strictEqual(r.maxRecno, 3);
|
||||||
|
});
|
||||||
|
await check('incremental: changedSince', async () => {
|
||||||
|
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?changedField=INGRESO&changedSince=2021-01-01')).body;
|
||||||
|
assert.deepStrictEqual(r.rows.map((x) => x.CEDULA), ['0911122233']);
|
||||||
|
});
|
||||||
|
await check('incremental: changedField sin changedSince → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?changedField=INGRESO')).status, 400));
|
||||||
await check('versión cambia al modificar la tabla', async () => {
|
await check('versión cambia al modificar la tabla', async () => {
|
||||||
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
|
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
|
||||||
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
|
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function load() {
|
|||||||
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
|
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
|
||||||
cacheMaxTables: Number(limits.cacheMaxTables || 10),
|
cacheMaxTables: Number(limits.cacheMaxTables || 10),
|
||||||
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
|
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
|
||||||
|
minReloadSeconds: Number(limits.minReloadSeconds ?? 60),
|
||||||
},
|
},
|
||||||
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
|
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ async function withSnapshot(ref, fn) {
|
|||||||
function toPlain(value, type) {
|
function toPlain(value, type) {
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
if (Number.isNaN(value.getTime())) return null;
|
// Fecha vacía en FoxPro: la librería la convierte en el día juliano 0 (año -4713).
|
||||||
|
if (Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1000) return null;
|
||||||
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
|
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@@ -122,7 +123,10 @@ async function loadTable(source, ref, limits) {
|
|||||||
const key = `${source.id}/${ref.table}`;
|
const key = `${source.id}/${ref.table}`;
|
||||||
const sig = await signature(ref);
|
const sig = await signature(ref);
|
||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (hit && hit.version === sig.version) {
|
// Tablas que el sistema origen escribe todo el día: no se vuelve a copiar más de una vez cada
|
||||||
|
// minReloadSeconds aunque la firma haya cambiado.
|
||||||
|
const fresh = hit && Date.now() - hit.loadedAt < limits.minReloadSeconds * 1000;
|
||||||
|
if (hit && (hit.version === sig.version || fresh)) {
|
||||||
cache.delete(key);
|
cache.delete(key);
|
||||||
cache.set(key, hit); // LRU
|
cache.set(key, hit); // LRU
|
||||||
return hit;
|
return hit;
|
||||||
@@ -138,7 +142,7 @@ async function loadTable(source, ref, limits) {
|
|||||||
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
|
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
|
||||||
records.push(row);
|
records.push(row);
|
||||||
}
|
}
|
||||||
return { version: sig.version, modifiedAt: sig.modifiedAt, fields, records };
|
return { version: sig.version, modifiedAt: sig.modifiedAt, loadedAt: Date.now(), fields, records };
|
||||||
});
|
});
|
||||||
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
|
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -206,9 +210,17 @@ async function readRows(source, tableName, q, limits) {
|
|||||||
.filter(([k]) => k.startsWith('eq.'))
|
.filter(([k]) => k.startsWith('eq.'))
|
||||||
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
|
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
|
||||||
const text = q.q ? fold(q.q).trim() : '';
|
const text = q.q ? fold(q.q).trim() : '';
|
||||||
|
// Sincronización incremental: solo lo agregado (afterRecno) y/o lo modificado desde una fecha en
|
||||||
|
// un campo de fecha/hora del origen (changedField + changedSince). Si se dan ambos, cualquiera basta.
|
||||||
|
const afterRecno = q.afterRecno !== undefined ? Math.max(parseInt(q.afterRecno, 10) || 0, 0) : null;
|
||||||
|
const changedField = q.changedField ? pick(q.changedField, 'changedField')[0] : null;
|
||||||
|
const changedSince = q.changedSince ? String(q.changedSince) : null;
|
||||||
|
if (!!changedField !== !!changedSince) throw new HttpError(400, 'changedField y changedSince van juntos');
|
||||||
|
const incremental = afterRecno !== null || changedField;
|
||||||
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
|
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
|
||||||
|
|
||||||
const matches = data.records.filter((r) => {
|
const matches = data.records.filter((r) => {
|
||||||
|
if (incremental && !((afterRecno !== null && r._recno > afterRecno) || (changedField && r[changedField] !== null && String(r[changedField]) > changedSince))) return false;
|
||||||
if (!includeDeleted && r._deleted) return false;
|
if (!includeDeleted && r._deleted) return false;
|
||||||
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
|
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
|
||||||
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
|
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
|
||||||
@@ -221,7 +233,7 @@ async function readRows(source, tableName, q, limits) {
|
|||||||
for (const f of fields) o[f] = r[f];
|
for (const f of fields) o[f] = r[f];
|
||||||
return o;
|
return o;
|
||||||
});
|
});
|
||||||
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, total: matches.length, offset, limit, fields, rows };
|
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, maxRecno: data.records.length, total: matches.length, offset, limit, fields, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listTables, getSchema, readRows, HttpError };
|
module.exports = { listTables, getSchema, readRows, HttpError };
|
||||||
|
|||||||
+76
-11
@@ -4,8 +4,9 @@
|
|||||||
> nómina, FoxPro u otra tecnología). No leer si: la tarea no toca datos externos.
|
> nómina, FoxPro u otra tecnología). No leer si: la tarea no toca datos externos.
|
||||||
> Contrato técnico de los conectores: [`data/conectores/README.md`](../data/conectores/README.md).
|
> Contrato técnico de los conectores: [`data/conectores/README.md`](../data/conectores/README.md).
|
||||||
|
|
||||||
Estado (2026-10-29): **conector FoxPro 1.0.0 hecho** (`data/conectores/foxpro/`, 16 pruebas locales
|
Estado (2026-10-29): **conector FoxPro 1.1.0 instalado y funcionando en Exilaser** (ver abajo).
|
||||||
OK, sin probar con tablas reales ni en Windows). **Módulo del sistema: pendiente** (solo diseño).
|
**Módulo del sistema hecho en dev** (migración 038, `backend/src/modules/integrations`, job
|
||||||
|
`integrationSync`), sin commit ni producción.
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
|
|
||||||
@@ -16,8 +17,22 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
|
|||||||
|
|
||||||
- Primer caso: Exilaser, Windows Server `192.168.0.253` (SAGA/SistViso en FoxPro), alcanzado por
|
- Primer caso: Exilaser, Windows Server `192.168.0.253` (SAGA/SistViso en FoxPro), alcanzado por
|
||||||
la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`.
|
la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`.
|
||||||
- Fuentes previstas: `Sistemas\nomina` (empleados) y la tabla de clientes de SAGA (pacientes de la
|
- Fuentes reales (revisadas por SSH, solo estructura, 2026-10-29):
|
||||||
Agenda médica). Nombres reales de tablas y campos: **por confirmar** en el servidor.
|
|
||||||
|
| Fuente | Ruta | Tabla | Registros | Codificación | Campos útiles |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Nómina | `C:\Sistema\Nomina\Datos\Exilaser` | `empleado.DBF` (VFP 0x30, sin memo) | 31 | cp1252 | `EMPCED`, `EMPAPE`, `EMPNOM`, `EMPTEL`, `EMPTEL1`, `EMPFECNAC`, `EMPCARGO`, `EMPDEPART`, `EMPINGRESO`, `EMPSALIDA`, `EMPACTIVO` |
|
||||||
|
| SAGA clientes | `C:\Sistema\Datos\exilaser` | `clientes.DBF` (VFP 0x30, sin memo, ~20 MB) | 32 985 | **cp850** | `CLICOD`, `CLIRUC`, `CLINOM`, `CLITEL`, `CLIEMAIL`, `CLIDIR`, `CLICIU`, `CLIPERSONA`, `CLITIPRUC`, `CLISEXO`, `INACTIVO`, `CLIESTADO`, `FECHAACT` |
|
||||||
|
|
||||||
|
- **No exponer** de nómina: `EMPSUELDO`, `EMPBANCO`, `EMPCUENT`, `EMPTIPOCUE`, `EMPNUMIESS`,
|
||||||
|
`EMPLET*`, `FIJO1-5`, `EMPCARGAS`, `EMPESTADO` (es estado civil) (van en `denyFields` o se usa lista de campos permitidos).
|
||||||
|
- Nómina **no tiene correo**: al crear un usuario desde nómina el correo se escribe a mano.
|
||||||
|
- Clientes: el nombre viene en **un solo campo** (`CLINOM`), sin nacimiento; incluye empresas
|
||||||
|
(RUC) — para pacientes filtrar personas naturales (`CLIPERSONA`/`CLITIPRUC`). El orden
|
||||||
|
apellidos/nombres de `CLINOM` se confirma con una muestra al instalar.
|
||||||
|
- `exilaser14`…`exilaser25` son cierres de años anteriores: **no** se exponen.
|
||||||
|
- `clientes.DBF` cambia todo el día (SAGA escribe): conviene modo **importar** o caché con
|
||||||
|
recarga mínima, para no copiar 20 MB en cada consulta.
|
||||||
|
|
||||||
## Requisitos acordados con el usuario
|
## Requisitos acordados con el usuario
|
||||||
|
|
||||||
@@ -50,12 +65,62 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
|
|||||||
- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría
|
- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría
|
||||||
de pruebas de conexión, importaciones y consultas.
|
de pruebas de conexión, importaciones y consultas.
|
||||||
|
|
||||||
|
## Instalación en Exilaser (2026-10-29)
|
||||||
|
|
||||||
|
- `C:\api_project\foxpro`, Node 24 del servidor, tarea programada `LinkedCorp-Conector-FoxPro`
|
||||||
|
(al arranque, usuario SYSTEM, reinicio si cae), regla de firewall del mismo nombre: TCP 8455 solo
|
||||||
|
desde `192.168.222.3` (prod) y `192.168.222.40` (dev).
|
||||||
|
- URL desde LinkedCorp: `http://192.168.0.253:8455` (por la VPN). Token: archivo
|
||||||
|
`~/.config/linkedcorp/conector-foxpro-exilaser.token` (600) en el equipo de desarrollo; en el
|
||||||
|
Windows solo está su SHA-256.
|
||||||
|
- Probado desde dev: sin token → 401; nómina 31 empleados (11 campos permitidos); clientes 32 988
|
||||||
|
(13 campos); primera carga de clientes ~8 s, luego caché (recarga como mínimo cada 60 s);
|
||||||
|
incremental `changedField=FECHAACT` OK; tildes/ñ correctas (cp850).
|
||||||
|
- `CLINOM` viene como **APELLIDOS NOMBRES** (ej. "GONZALEZ JIMENEZ RAUL VICENTE"): al importar se
|
||||||
|
separa en las 2 primeras palabras = apellidos, resto = nombres (los apellidos compuestos se
|
||||||
|
corrigen a mano). Pacientes = `CLIPERSONA = Natural` (en una muestra de 1000: 988 naturales).
|
||||||
|
- Mejora a futuro: correr la tarea con una cuenta de dominio de solo lectura en vez de SYSTEM.
|
||||||
|
- Actualizar el conector: copiar `src/` a `C:\api_project\foxpro\src`, detener la tarea, matar el
|
||||||
|
`node` del conector y volver a iniciar la tarea.
|
||||||
|
|
||||||
|
## Decisiones del usuario sobre pacientes (2026-10-29)
|
||||||
|
|
||||||
|
- Con conexión activa **se permite crear pacientes a mano** (quedan como locales).
|
||||||
|
- Al vincular por cédula, si los datos difieren **manda SAGA** (el cambio queda en auditoría).
|
||||||
|
|
||||||
## Pendientes
|
## Pendientes
|
||||||
|
|
||||||
- [ ] Revisar en el server (SSH, solo lectura, con permiso del usuario) las carpetas de nómina y
|
- [x] Revisar en el server las carpetas de nómina y clientes (estructura, codificación).
|
||||||
clientes: tablas, campos, versión de los DBF y codificación (cp1252/cp850).
|
- [x] Instalar el conector en `C:\api_project\foxpro` y probar desde dev.
|
||||||
- [ ] Instalar el conector en `C:\api_project\foxpro` (node.exe portable + `config.json` + token +
|
- [x] Módulo del sistema (ver "Cómo quedó en el sistema").
|
||||||
`instalar-servicio.ps1`) y probar desde el backend.
|
- [ ] Commit, revisión y producción (migraciones 036–038, conexión y fuentes a configurar allá).
|
||||||
- [ ] Módulo del sistema: migración (conexiones, fuentes/mapeos, empleados, vínculo usuario↔empleado),
|
|
||||||
backend `modules/integrations`, pantalla en Configuración, botón en Organización, alta de
|
## Cómo quedó en el sistema (2026-10-29)
|
||||||
usuario desde nómina, búsqueda de pacientes externa en la Agenda.
|
|
||||||
|
- **Configuración → Conexiones externas** (superadmin): conexiones (URL + token cifrado, Probar) y
|
||||||
|
fuentes Pacientes/Empleados: conexión → fuente → tabla, vista previa de 20 filas, mapeo de campos,
|
||||||
|
filtro (ej. `CLIPERSONA = Natural`), campo de cambios (`FECHAACT`) y modo (pacientes: importar o en
|
||||||
|
vivo). Botones "Traer cambios" y "Sincronización completa".
|
||||||
|
- **Programación** (job en el backend, cada 5 min revisa): pacientes en modo importar → completa
|
||||||
|
**lunes a viernes a las 05:00** (recoge borrados: quedan inactivos) e incremental cada 15 min de
|
||||||
|
06:00 a 21:00 (agregados por `_recno` + modificados por `FECHAACT`). La primera vez hace la completa.
|
||||||
|
El conector solo copia el DBF cuando el sistema le pide datos (como mínimo cada 60 s).
|
||||||
|
- **Organización → Empleados** (admin/superadmin): listado y **"Actualizar empleados"** (a pedido).
|
||||||
|
Los que salen de la nómina quedan inactivos. **Nuevo usuario → Desde nómina** precarga nombre,
|
||||||
|
cargo y departamento (si coincide por nombre) y vincula el usuario con el empleado.
|
||||||
|
- **Médicos (2026-10-29)**: casi todos facturan como **proveedores** de SAGA (`provedor.DBF`, expuesto
|
||||||
|
en el conector solo con código, nombre, RUC, teléfono, correo, ciudad, tipo e inactivo). Se cargan
|
||||||
|
como empleados con `origin=provider` con `backend/src/scripts/import-provider-employees.js` (lee
|
||||||
|
del conector por código de proveedor; sin datos personales en Git). En dev: 9 proveedores + 2 que ya
|
||||||
|
eran de nómina (Torres Palacios Nataly, Rodríguez Nava Christian). Códigos usados:
|
||||||
|
`137+031 142+033 809 234 040 794 817 565 540 405 676` (`--position=MEDICO`).
|
||||||
|
- **Protección**: un empleado con usuario en el sistema (p. ej. médico) **no se actualiza ni se
|
||||||
|
inactiva** al "Actualizar empleados"; los de origen proveedor tampoco los toca la nómina.
|
||||||
|
- **Agenda**: en modo importar se busca entre los importados (también por nombre de origen); en modo
|
||||||
|
en vivo el selector agrega los del origen ("Del sistema origen") y al elegir uno se trae solo ese.
|
||||||
|
El detalle del paciente muestra su origen.
|
||||||
|
- **Nombres de SAGA**: `CLINOM` no tiene orden fijo (a veces nombre primero), así que no se separa:
|
||||||
|
se guarda entero como nombre visible (`name_split=0`). Al editar el paciente, recepción separa
|
||||||
|
apellidos y nombres; desde ahí la sincronización ya no pisa su nombre (sí cédula/teléfono/correo).
|
||||||
|
- Prueba en dev con datos reales: 31 empleados (24 activos), 31 220 pacientes en 24 s; repetir la
|
||||||
|
completa sin cambios 1,7 s; 14 cédulas repetidas en SAGA quedan sin cédula en el segundo registro.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dompurify": "^3.4.16",
|
"dompurify": "^3.4.16",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.11.0",
|
"version": "4.12.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -663,6 +663,69 @@ export const messages = {
|
|||||||
|
|
||||||
org_departments: 'Departamentos',
|
org_departments: 'Departamentos',
|
||||||
org_users: 'Usuarios',
|
org_users: 'Usuarios',
|
||||||
|
config_integrations: 'Conexiones externas',
|
||||||
|
int_connections: 'Conexiones',
|
||||||
|
int_new_connection: 'Nueva conexión',
|
||||||
|
int_url: 'URL del conector',
|
||||||
|
int_token: 'Token del conector',
|
||||||
|
int_token_keep: 'Déjalo vacío para conservar el token guardado.',
|
||||||
|
int_test: 'Probar conexión',
|
||||||
|
int_test_ok: 'Conexión correcta',
|
||||||
|
int_sources: 'Fuentes de datos',
|
||||||
|
int_purpose_patients: 'Pacientes (Agenda médica)',
|
||||||
|
int_purpose_employees: 'Empleados (nómina)',
|
||||||
|
int_not_configured: 'Sin configurar',
|
||||||
|
int_configure: 'Configurar',
|
||||||
|
int_connection: 'Conexión',
|
||||||
|
int_source: 'Fuente',
|
||||||
|
int_table: 'Tabla',
|
||||||
|
int_mode: 'Modo',
|
||||||
|
int_mode_import: 'Importar (copia en el sistema)',
|
||||||
|
int_mode_live: 'Consulta en vivo',
|
||||||
|
int_preview: 'Vista previa (20 filas)',
|
||||||
|
int_mapping: 'Campos a usar',
|
||||||
|
int_filter: 'Filtro (solo registros con este valor)',
|
||||||
|
int_filter_field: 'Campo',
|
||||||
|
int_filter_value: 'Valor',
|
||||||
|
int_changed_field: 'Campo de última modificación (para traer solo cambios)',
|
||||||
|
int_last_sync: 'Última sincronización',
|
||||||
|
int_sync_now: 'Traer cambios',
|
||||||
|
int_sync_full: 'Sincronización completa',
|
||||||
|
int_schedule_hint: 'Automático: completa de lunes a viernes a las 05:00 y solo cambios cada 15 min de 06:00 a 21:00.',
|
||||||
|
int_received: 'recibidos',
|
||||||
|
int_created: 'nuevos',
|
||||||
|
int_updated: 'actualizados',
|
||||||
|
int_deactivated: 'inactivados',
|
||||||
|
int_target_identification: 'Cédula / identificación',
|
||||||
|
int_target_lastNames: 'Apellidos',
|
||||||
|
int_target_firstNames: 'Nombres',
|
||||||
|
int_target_phone: 'Teléfono',
|
||||||
|
int_target_phone2: 'Teléfono 2',
|
||||||
|
int_target_birthDate: 'Nacimiento',
|
||||||
|
int_target_position: 'Cargo',
|
||||||
|
int_target_department: 'Departamento',
|
||||||
|
int_target_hireDate: 'Ingreso',
|
||||||
|
int_target_leaveDate: 'Salida',
|
||||||
|
int_target_active: 'Activo',
|
||||||
|
int_target_externalId: 'Código en el origen',
|
||||||
|
int_target_fullName: 'Nombre completo',
|
||||||
|
int_target_email: 'Correo',
|
||||||
|
int_required: 'obligatorio',
|
||||||
|
org_employees: 'Empleados',
|
||||||
|
emp_origin: 'Origen',
|
||||||
|
emp_origin_payroll: 'Nómina',
|
||||||
|
emp_origin_provider: 'Proveedor (SAGA)',
|
||||||
|
emp_protected_hint: 'Los empleados con usuario en el sistema no se actualizan ni se inactivan al actualizar.',
|
||||||
|
emp_update: 'Actualizar empleados',
|
||||||
|
emp_never: 'Nunca actualizado',
|
||||||
|
emp_user: 'Usuario del sistema',
|
||||||
|
emp_no_source: 'No hay fuente de nómina configurada (Configuración → Conexiones externas).',
|
||||||
|
user_from_payroll: 'Desde nómina',
|
||||||
|
clinic_origin: 'Origen',
|
||||||
|
clinic_origin_local: 'Creado aquí',
|
||||||
|
clinic_source_name: 'Nombre en el origen',
|
||||||
|
clinic_name_unsplit: 'Nombre traído del origen sin separar: al editar, separa apellidos y nombres.',
|
||||||
|
clinic_external_results: 'Del sistema origen',
|
||||||
department_new: 'Nuevo departamento',
|
department_new: 'Nuevo departamento',
|
||||||
department_parent: 'Departamento padre',
|
department_parent: 'Departamento padre',
|
||||||
user_new: 'Nuevo usuario',
|
user_new: 'Nuevo usuario',
|
||||||
@@ -1357,6 +1420,69 @@ export const messages = {
|
|||||||
|
|
||||||
org_departments: 'Departments',
|
org_departments: 'Departments',
|
||||||
org_users: 'Users',
|
org_users: 'Users',
|
||||||
|
config_integrations: 'External connections',
|
||||||
|
int_connections: 'Connections',
|
||||||
|
int_new_connection: 'New connection',
|
||||||
|
int_url: 'Connector URL',
|
||||||
|
int_token: 'Connector token',
|
||||||
|
int_token_keep: 'Leave empty to keep the saved token.',
|
||||||
|
int_test: 'Test connection',
|
||||||
|
int_test_ok: 'Connection OK',
|
||||||
|
int_sources: 'Data sources',
|
||||||
|
int_purpose_patients: 'Patients (Medical schedule)',
|
||||||
|
int_purpose_employees: 'Employees (payroll)',
|
||||||
|
int_not_configured: 'Not configured',
|
||||||
|
int_configure: 'Configure',
|
||||||
|
int_connection: 'Connection',
|
||||||
|
int_source: 'Source',
|
||||||
|
int_table: 'Table',
|
||||||
|
int_mode: 'Mode',
|
||||||
|
int_mode_import: 'Import (copy into the system)',
|
||||||
|
int_mode_live: 'Live lookup',
|
||||||
|
int_preview: 'Preview (20 rows)',
|
||||||
|
int_mapping: 'Fields to use',
|
||||||
|
int_filter: 'Filter (only records with this value)',
|
||||||
|
int_filter_field: 'Field',
|
||||||
|
int_filter_value: 'Value',
|
||||||
|
int_changed_field: 'Last-modified field (to fetch only changes)',
|
||||||
|
int_last_sync: 'Last sync',
|
||||||
|
int_sync_now: 'Fetch changes',
|
||||||
|
int_sync_full: 'Full sync',
|
||||||
|
int_schedule_hint: 'Automatic: full sync Monday to Friday at 05:00 and changes only every 15 min from 06:00 to 21:00.',
|
||||||
|
int_received: 'received',
|
||||||
|
int_created: 'new',
|
||||||
|
int_updated: 'updated',
|
||||||
|
int_deactivated: 'deactivated',
|
||||||
|
int_target_identification: 'ID number',
|
||||||
|
int_target_lastNames: 'Last names',
|
||||||
|
int_target_firstNames: 'First names',
|
||||||
|
int_target_phone: 'Phone',
|
||||||
|
int_target_phone2: 'Phone 2',
|
||||||
|
int_target_birthDate: 'Birth date',
|
||||||
|
int_target_position: 'Position',
|
||||||
|
int_target_department: 'Department',
|
||||||
|
int_target_hireDate: 'Hire date',
|
||||||
|
int_target_leaveDate: 'Leave date',
|
||||||
|
int_target_active: 'Active',
|
||||||
|
int_target_externalId: 'Source code',
|
||||||
|
int_target_fullName: 'Full name',
|
||||||
|
int_target_email: 'Email',
|
||||||
|
int_required: 'required',
|
||||||
|
org_employees: 'Employees',
|
||||||
|
emp_origin: 'Source',
|
||||||
|
emp_origin_payroll: 'Payroll',
|
||||||
|
emp_origin_provider: 'Supplier (SAGA)',
|
||||||
|
emp_protected_hint: 'Employees with a system user are not updated or deactivated by the update.',
|
||||||
|
emp_update: 'Update employees',
|
||||||
|
emp_never: 'Never updated',
|
||||||
|
emp_user: 'System user',
|
||||||
|
emp_no_source: 'No payroll source configured (Settings → External connections).',
|
||||||
|
user_from_payroll: 'From payroll',
|
||||||
|
clinic_origin: 'Source',
|
||||||
|
clinic_origin_local: 'Created here',
|
||||||
|
clinic_source_name: 'Name in source',
|
||||||
|
clinic_name_unsplit: 'Name imported without split: when editing, separate last and first names.',
|
||||||
|
clinic_external_results: 'From source system',
|
||||||
department_new: 'New department',
|
department_new: 'New department',
|
||||||
department_parent: 'Parent department',
|
department_parent: 'Parent department',
|
||||||
user_new: 'New user',
|
user_new: 'New user',
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export default function AgendaMedicaPage() {
|
|||||||
[patientTotal, setPatientTotal] = useState(0);
|
[patientTotal, setPatientTotal] = useState(0);
|
||||||
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
|
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
|
||||||
const [apptPatients, setApptPatients] = useState([]),
|
const [apptPatients, setApptPatients] = useState([]),
|
||||||
[apptSearch, setApptSearch] = useState('');
|
[apptSearch, setApptSearch] = useState(''),
|
||||||
|
// Pacientes del sistema origen (Conexiones externas, modo consulta en vivo): aún no importados.
|
||||||
|
[externalPatients, setExternalPatients] = useState([]),
|
||||||
|
[patientSource, setPatientSource] = useState(null);
|
||||||
const [popup, setPopup] = useState(null),
|
const [popup, setPopup] = useState(null),
|
||||||
[editing, setEditing] = useState(false),
|
[editing, setEditing] = useState(false),
|
||||||
[conflict, setConflict] = useState(''),
|
[conflict, setConflict] = useState(''),
|
||||||
@@ -98,6 +101,7 @@ export default function AgendaMedicaPage() {
|
|||||||
api.clinicMe().then(async staff => {
|
api.clinicMe().then(async staff => {
|
||||||
setMe(staff);
|
setMe(staff);
|
||||||
await loadDoctors();
|
await loadDoctors();
|
||||||
|
if (staff.scope === 'all') api.clinicPatientSource().then(setPatientSource).catch(() => setPatientSource(null));
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
if (isAdmin) setTab('config');
|
if (isAdmin) setTab('config');
|
||||||
else setError(e.message);
|
else setError(e.message);
|
||||||
@@ -169,6 +173,7 @@ export default function AgendaMedicaPage() {
|
|||||||
if (kind === 'appointment' && isReception) {
|
if (kind === 'appointment' && isReception) {
|
||||||
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
||||||
setApptSearch('');
|
setApptSearch('');
|
||||||
|
setExternalPatients([]);
|
||||||
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
|
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
|
||||||
setApptPatients(current);
|
setApptPatients(current);
|
||||||
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
|
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
|
||||||
@@ -180,6 +185,9 @@ export default function AgendaMedicaPage() {
|
|||||||
function searchApptPatients(text) {
|
function searchApptPatients(text) {
|
||||||
setApptSearch(text);
|
setApptSearch(text);
|
||||||
const seq = ++searchSeq.current;
|
const seq = ++searchSeq.current;
|
||||||
|
if (patientSource?.mode === 'live' && text.trim().length >= 3) {
|
||||||
|
api.clinicExternalPatients(text).then(rows => { if (seq === searchSeq.current) setExternalPatients(rows.filter(r => !r.local_id)); }).catch(() => setExternalPatients([]));
|
||||||
|
} else setExternalPatients([]);
|
||||||
api.clinicPatients(text, 1).then(x => {
|
api.clinicPatients(text, 1).then(x => {
|
||||||
if (seq !== searchSeq.current) return;
|
if (seq !== searchSeq.current) return;
|
||||||
setApptPatients(prev => {
|
setApptPatients(prev => {
|
||||||
@@ -188,6 +196,18 @@ export default function AgendaMedicaPage() {
|
|||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
// Elegir paciente; si es del sistema origen (ext:código) se trae primero al sistema.
|
||||||
|
async function pickApptPatient(value) {
|
||||||
|
if (!String(value).startsWith('ext:')) return draft.setField('patientId', value);
|
||||||
|
try {
|
||||||
|
const p = await api.clinicImportExternalPatient(String(value).slice(4));
|
||||||
|
setApptPatients(list => [p, ...list.filter(x => x.id !== p.id)]);
|
||||||
|
setExternalPatients(list => list.filter(x => `ext:${x.external_id}` !== value));
|
||||||
|
draft.setField('patientId', p.id);
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
|
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
|
||||||
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
|
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
|
||||||
function newPatientFromAppointment() {
|
function newPatientFromAppointment() {
|
||||||
@@ -403,7 +423,9 @@ export default function AgendaMedicaPage() {
|
|||||||
] : popup.kind === 'patient' ? [
|
] : popup.kind === 'patient' ? [
|
||||||
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
|
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
|
||||||
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
|
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
|
||||||
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes]
|
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes],
|
||||||
|
[t('clinic_origin'), it.external_source ? `${t('clinic_external_results')} (${it.external_id})` : t('clinic_origin_local')],
|
||||||
|
[t('clinic_source_name'), it.external_source && it.name_split ? it.source_name : null]
|
||||||
] : [
|
] : [
|
||||||
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
|
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
|
||||||
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
|
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
|
||||||
@@ -520,8 +542,8 @@ export default function AgendaMedicaPage() {
|
|||||||
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
</> : <>
|
</> : <>
|
||||||
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} externalPatients={externalPatients} onPatientChange={x => void pickApptPatient(x)} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
||||||
{popup.kind === 'patient' && <PatientForm draft={draft} />}
|
{popup.kind === 'patient' && <PatientForm draft={draft} unsplit={!!it && !it.name_split} />}
|
||||||
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
||||||
<div className="toolbar-left clinic-actions">
|
<div className="toolbar-left clinic-actions">
|
||||||
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { plus } from './clinicUtils.js';
|
|||||||
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
|
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
|
||||||
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
|
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
|
||||||
|
|
||||||
const patientLabel = (p) => `${p.last_names} ${p.first_names}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
const patientLabel = (p) => `${`${p.last_names} ${p.first_names}`.trim()}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
||||||
|
|
||||||
export function AppointmentForm({ draft, doctors, types, patients, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
export function AppointmentForm({ draft, doctors, types, patients, externalPatients = [], onPatientChange, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const v = draft.values;
|
const v = draft.values;
|
||||||
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
|
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
|
||||||
@@ -23,7 +23,7 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
|
|||||||
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
|
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
|
||||||
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
|
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
|
||||||
<div className="clinic-inline">
|
<div className="clinic-inline">
|
||||||
<ComboBox value={v.patientId} onChange={(x) => draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) }))]} />
|
<ComboBox value={v.patientId} onChange={onPatientChange} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) })), ...externalPatients.map((p) => ({ value: `ext:${p.external_id}`, label: `${p.full_name}${p.identification ? ` · ${p.identification}` : ''} — ${t('clinic_external_results')}` }))]} />
|
||||||
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
|
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -46,11 +46,12 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
|
|||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientForm({ draft }) {
|
export function PatientForm({ draft, unsplit = false }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const v = draft.values;
|
const v = draft.values;
|
||||||
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
|
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
|
||||||
return <>
|
return <>
|
||||||
|
{unsplit && <InfoBar tone="warning">{t('clinic_name_unsplit')}</InfoBar>}
|
||||||
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
|
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
|
||||||
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
|
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles } from 'lucide-react';
|
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles, Plug } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { Expander } from '../../components/Expander.jsx';
|
import { Expander } from '../../components/Expander.jsx';
|
||||||
@@ -13,6 +13,7 @@ import AiUsagePanel from './AiUsagePanel.jsx';
|
|||||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||||
import UpdaterPanel from './UpdaterPanel.jsx';
|
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||||
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
||||||
|
import ExternalConnectionsPanel from './ExternalConnectionsPanel.jsx';
|
||||||
|
|
||||||
export default function ConfiguracionPage() {
|
export default function ConfiguracionPage() {
|
||||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||||
@@ -117,6 +118,10 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
<Expander icon={Plug} title={t('config_integrations')} draftForms={['ext-connection', 'ext-source']}>
|
||||||
|
<ExternalConnectionsPanel />
|
||||||
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
||||||
<SmtpSettingsPanel />
|
<SmtpSettingsPanel />
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* Conexiones externas: mismo esquema que los formularios de la Agenda (pares de campos, acciones al pie). */
|
||||||
|
.ext-panel h3:not(.mt-0){margin:20px 0 4px}
|
||||||
|
.ext-panel h3.mt-0{margin-bottom:4px}
|
||||||
|
.ext-hint{font-size:.78rem;margin:8px 0 0}
|
||||||
|
.ext-form{display:grid;gap:12px;align-content:start}
|
||||||
|
.ext-form .field{min-width:0}
|
||||||
|
.ext-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
|
||||||
|
.ext-detail p{margin:5px 0;overflow-wrap:anywhere}
|
||||||
|
.ext-actions{margin-top:18px;flex-wrap:wrap}
|
||||||
|
.ext-preview{overflow:auto;max-height:240px;border:1px solid rgb(var(--line));border-radius:var(--r-sm)}
|
||||||
|
.ext-preview .list-table{font-size:.75rem;white-space:nowrap}
|
||||||
|
.ext-subtitle{margin:4px 0 0;font-size:.9rem}
|
||||||
|
.ext-map{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px 12px}
|
||||||
|
@media(max-width:640px){.ext-detail{grid-template-columns:1fr}}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Pencil, PlugZap, RefreshCw, DatabaseZap } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
import * as api from '../../services/integrations.js';
|
||||||
|
import './ExternalConnectionsPanel.css';
|
||||||
|
|
||||||
|
// Conexiones externas (superadmin): conectores de solo lectura y qué tabla alimenta Pacientes y
|
||||||
|
// Empleados. Ver docs/CONEXIONES-EXTERNAS.md. Listado → popup en lectura → Editar.
|
||||||
|
|
||||||
|
const TARGETS = {
|
||||||
|
patients: ['externalId', 'identification', 'fullName', 'phone', 'email'],
|
||||||
|
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active']
|
||||||
|
};
|
||||||
|
const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'] };
|
||||||
|
const connForm = (c) => (c ? { name: c.name, baseUrl: c.base_url, token: '', isActive: c.is_active, editVersion: c.edit_version } : { name: '', baseUrl: '', token: '', isActive: true });
|
||||||
|
const sourceForm = (purpose, s) => {
|
||||||
|
const [filterField = '', filterValue = ''] = Object.entries(s?.filter || {})[0] || [];
|
||||||
|
return s
|
||||||
|
? { connectionId: s.connection_id, sourceKey: s.source_key, tableName: s.table_name, mode: s.mode, fieldMap: s.field_map, filterField, filterValue, changedField: s.changed_field || '', editVersion: s.edit_version }
|
||||||
|
: { connectionId: '', sourceKey: '', tableName: '', mode: 'import', fieldMap: {}, filterField: '', filterValue: '', changedField: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function syncSummary(t, r) {
|
||||||
|
if (!r) return t('emp_never');
|
||||||
|
return `${String(r.at).slice(0, 16)} · ${r.received ?? r.total} ${t('int_received')} · ${r.created} ${t('int_created')} · ${r.updated} ${t('int_updated')}${r.deactivated || r.removed ? ` · ${r.deactivated ?? r.removed} ${t('int_deactivated')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalConnectionsPanel() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const [connections, setConnections] = useState([]), [sources, setSources] = useState([]);
|
||||||
|
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||||
|
const [remote, setRemote] = useState({ sources: [], tables: [], schema: null, sample: [] });
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const [c, s] = await Promise.all([api.listConnections(), api.listSources()]);
|
||||||
|
setConnections(c); setSources(s); setError('');
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void reload(); }, []);
|
||||||
|
|
||||||
|
const sourceOf = (purpose) => sources.find((s) => s.purpose === purpose);
|
||||||
|
const initial = !popup ? {} : popup.kind === 'connection' ? connForm(popup.item) : sourceForm(popup.purpose, sourceOf(popup.purpose));
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: popup?.kind === 'source' ? 'ext-source' : 'ext-connection',
|
||||||
|
entityId: popup?.kind === 'source' ? popup.purpose : popup?.item?.id,
|
||||||
|
initial,
|
||||||
|
label: popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : t('int_connections'),
|
||||||
|
enabled: !!popup && editing,
|
||||||
|
exclude: ['token'],
|
||||||
|
onDiscard: () => { if (popup?.item || popup?.kind === 'source') setEditing(false); else setPopup(null); }
|
||||||
|
});
|
||||||
|
const v = draft.values;
|
||||||
|
|
||||||
|
// Exploración del conector mientras se edita una fuente: fuentes → tablas → estructura + muestra.
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId) return undefined;
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId).then((x) => active && setRemote((r) => ({ ...r, sources: x.sources }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey) return undefined;
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId, v.sourceKey).then((x) => active && setRemote((r) => ({ ...r, tables: x.tables }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId, v.sourceKey]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey || !v.tableName) { setRemote((r) => ({ ...r, schema: null, sample: [] })); return undefined; }
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId, v.sourceKey, v.tableName).then((x) => active && setRemote((r) => ({ ...r, schema: x.schema, sample: x.sample }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId, v.sourceKey, v.tableName]);
|
||||||
|
|
||||||
|
function open(kind, item = null, extra = {}) {
|
||||||
|
setError('');
|
||||||
|
setRemote({ sources: [], tables: [], schema: null, sample: [] });
|
||||||
|
setPopup({ kind, item, ...extra });
|
||||||
|
setEditing(kind === 'connection' && !item);
|
||||||
|
}
|
||||||
|
const close = () => void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); });
|
||||||
|
|
||||||
|
async function run(fn, okMessage = t('common_saved')) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
pushToast({ tone: 'success', message: okMessage });
|
||||||
|
return result;
|
||||||
|
} catch (e) { setError(e.message); return null; } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const saved = await run(async () => {
|
||||||
|
if (popup.kind === 'connection') return api.saveConnection({ ...v, token: v.token || undefined }, popup.item?.id);
|
||||||
|
return api.saveSource(popup.purpose, {
|
||||||
|
connectionId: Number(v.connectionId), sourceKey: v.sourceKey, tableName: v.tableName, mode: v.mode, fieldMap: v.fieldMap,
|
||||||
|
filter: v.filterField && v.filterValue ? { [v.filterField]: v.filterValue } : {}, changedField: v.changedField || null, editVersion: v.editVersion
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!saved) return;
|
||||||
|
draft.clear();
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
const test = (id) => run(() => api.testConnection(id), t('int_test_ok'));
|
||||||
|
async function sync(kind) {
|
||||||
|
const r = await run(() => (kind === 'employees' ? api.syncEmployees() : api.syncPatients(kind === 'full')), t('common_saved'));
|
||||||
|
if (r) { await reload(); setPopup((p) => p && { ...p }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = remote.schema?.fields?.map((f) => f.name) || [];
|
||||||
|
const fieldOptions = [{ value: '', label: '—' }, ...fields.map((f) => ({ value: f, label: f }))];
|
||||||
|
const canSave = popup?.kind === 'connection'
|
||||||
|
? !!(v.name?.trim() && v.baseUrl?.trim() && (popup.item || v.token))
|
||||||
|
: !!(v.connectionId && v.sourceKey && v.tableName && REQUIRED[popup?.purpose || 'patients'].every((k) => v.fieldMap?.[k]));
|
||||||
|
const src = popup?.kind === 'source' ? sourceOf(popup.purpose) : null;
|
||||||
|
|
||||||
|
return <div className="ext-panel">
|
||||||
|
{error && !popup && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
<div className="toolbar"><h3 className="mt-0">{t('int_connections')}</h3><Button variant="accent" icon={Plus} onClick={() => open('connection')}>{t('int_new_connection')}</Button></div>
|
||||||
|
<ListView getRowKey={(c) => c.id} rows={connections} onRowClick={(c) => open('connection', c)}
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', label: t('common_name') },
|
||||||
|
{ key: 'base_url', label: t('int_url') },
|
||||||
|
{ key: 'is_active', label: t('common_status'), render: (c) => <Badge color={c.is_active ? 'success' : 'muted'}>{t(c.is_active ? 'common_active' : 'common_inactive')}</Badge> }
|
||||||
|
]}
|
||||||
|
rowActions={(c) => [{ label: t('int_test'), icon: PlugZap, onClick: () => void test(c.id) }, { label: t('common_edit'), icon: Pencil, onClick: () => { open('connection', c); setEditing(true); } }]} />
|
||||||
|
|
||||||
|
<div className="toolbar"><h3>{t('int_sources')}</h3></div>
|
||||||
|
<ListView getRowKey={(p) => p} rows={['patients', 'employees']} onRowClick={(p) => open('source', null, { purpose: p })}
|
||||||
|
columns={[
|
||||||
|
{ key: 'purpose', label: t('int_source'), sortable: false, render: (p) => t(`int_purpose_${p}`) },
|
||||||
|
{ key: 'table', label: t('int_table'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? `${s.connection_name} · ${s.source_key}/${s.table_name}` : <Badge color="muted">{t('int_not_configured')}</Badge>; } },
|
||||||
|
{ key: 'mode', label: t('int_mode'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? t(`int_mode_${s.mode}`) : '—'; } },
|
||||||
|
{ key: 'sync', label: t('int_last_sync'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? syncSummary(t, s.last_result) : '—'; } }
|
||||||
|
]} />
|
||||||
|
<p className="text-muted ext-hint">{t('int_schedule_hint')}</p>
|
||||||
|
|
||||||
|
<DetailPopup open={!!popup} title={popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : popup?.item ? popup.item.name : t('int_new_connection')} onClose={close} width={popup?.kind === 'source' ? 980 : 760} height={popup?.kind === 'source' ? 720 : 460}>
|
||||||
|
{popup && <div className="ext-form">
|
||||||
|
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{editing && <DraftNotice draft={draft} />}
|
||||||
|
{!editing ? <>
|
||||||
|
{popup.kind === 'connection' && <>
|
||||||
|
<div className="ext-detail">
|
||||||
|
<p><strong>{t('common_name')}:</strong> {popup.item.name}</p>
|
||||||
|
<p><strong>{t('int_url')}:</strong> {popup.item.base_url}</p>
|
||||||
|
<p><strong>{t('common_status')}:</strong> {t(popup.item.is_active ? 'common_active' : 'common_inactive')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-left ext-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={PlugZap} disabled={busy} onClick={() => void test(popup.item.id)}>{t('int_test')}</Button></div>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'source' && <>
|
||||||
|
{!src ? <InfoBar tone="warning">{t('int_not_configured')}</InfoBar> : <div className="ext-detail">
|
||||||
|
<p><strong>{t('int_connection')}:</strong> {src.connection_name}</p>
|
||||||
|
<p><strong>{t('int_table')}:</strong> {src.source_key}/{src.table_name}</p>
|
||||||
|
<p><strong>{t('int_mode')}:</strong> {t(`int_mode_${src.mode}`)}</p>
|
||||||
|
{src.field_map && <p><strong>{t('int_mapping')}:</strong> {Object.entries(src.field_map).map(([k, f]) => `${t(`int_target_${k}`)} ← ${f}`).join(' · ')}</p>}
|
||||||
|
{src.filter && Object.keys(src.filter).length > 0 && <p><strong>{t('int_filter')}:</strong> {Object.entries(src.filter).map(([k, x]) => `${k} = ${x}`).join(', ')}</p>}
|
||||||
|
{src.changed_field && <p><strong>{t('int_changed_field')}:</strong> {src.changed_field}</p>}
|
||||||
|
<p><strong>{t('int_last_sync')}:</strong> {syncSummary(t, src.last_result)}</p>
|
||||||
|
</div>}
|
||||||
|
<div className="toolbar-left ext-actions">
|
||||||
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{src ? t('common_edit') : t('int_configure')}</Button>
|
||||||
|
{src && popup.purpose === 'employees' && <Button icon={RefreshCw} disabled={busy} onClick={() => void sync('employees')}>{t('emp_update')}</Button>}
|
||||||
|
{src && popup.purpose === 'patients' && src.mode === 'import' && <>
|
||||||
|
<Button icon={RefreshCw} disabled={busy} onClick={() => void sync('changes')}>{t('int_sync_now')}</Button>
|
||||||
|
<Button icon={DatabaseZap} disabled={busy} onClick={() => void sync('full')}>{t('int_sync_full')}</Button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</> : <>
|
||||||
|
{popup.kind === 'connection' && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}><input className="input" maxLength={120} value={v.name} onChange={(e) => draft.setField('name', e.target.value)} /></Field>
|
||||||
|
<Field label={t('int_url')} dirty={draft.isFieldDirty('baseUrl')}><input className="input" maxLength={255} placeholder="http://192.168.0.253:8455" value={v.baseUrl} onChange={(e) => draft.setField('baseUrl', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_token')} hint={popup.item ? t('int_token_keep') : undefined}><PasswordInput value={v.token} onChange={(e) => draft.setField('token', e.target.value)} /></Field>
|
||||||
|
<Field label={t('common_status')}><label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('common_active')}</label></Field>
|
||||||
|
</FieldRow>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'source' && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_connection')} dirty={draft.isFieldDirty('connectionId')}><ComboBox value={v.connectionId} onChange={(x) => draft.setValues((o) => ({ ...o, connectionId: x, sourceKey: '', tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...connections.filter((c) => c.is_active).map((c) => ({ value: c.id, label: c.name }))]} /></Field>
|
||||||
|
{popup.purpose === 'patients'
|
||||||
|
? <Field label={t('int_mode')} dirty={draft.isFieldDirty('mode')}><ComboBox value={v.mode} onChange={(x) => draft.setField('mode', x)} options={['import', 'live'].map((x) => ({ value: x, label: t(`int_mode_${x}`) }))} /></Field>
|
||||||
|
: <div className="field" />}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_source')} dirty={draft.isFieldDirty('sourceKey')}><ComboBox value={v.sourceKey} onChange={(x) => draft.setValues((o) => ({ ...o, sourceKey: x, tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...remote.sources.map((s) => ({ value: s.id, label: s.name }))]} /></Field>
|
||||||
|
<Field label={t('int_table')} dirty={draft.isFieldDirty('tableName')}><ComboBox value={v.tableName} onChange={(x) => draft.setField('tableName', x)} options={[{ value: '', label: t('common_select') }, ...remote.tables.filter((x) => !x.error).map((x) => ({ value: x.name, label: `${x.name} (${x.records})` }))]} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{v.tableName && !remote.schema && <p className="text-muted">{t('common_loading')}</p>}
|
||||||
|
{remote.schema && <>
|
||||||
|
<Field label={t('int_preview')}><div className="ext-preview"><table className="list-table"><thead><tr>{fields.map((f) => <th key={f}>{f}</th>)}</tr></thead><tbody>{remote.sample.map((r) => <tr key={r._recno}>{fields.map((f) => <td key={f}>{r[f] === null || r[f] === undefined ? '' : String(r[f])}</td>)}</tr>)}</tbody></table></div></Field>
|
||||||
|
<h4 className="ext-subtitle">{t('int_mapping')}</h4>
|
||||||
|
<div className="ext-map">
|
||||||
|
{TARGETS[popup.purpose].map((k) => <Field key={k} label={`${t(`int_target_${k}`)}${REQUIRED[popup.purpose].includes(k) ? ` (${t('int_required')})` : ''}`}>
|
||||||
|
<ComboBox value={v.fieldMap?.[k] || ''} onChange={(x) => draft.setValues((o) => ({ ...o, fieldMap: { ...o.fieldMap, [k]: x || undefined } }))} options={fieldOptions} />
|
||||||
|
</Field>)}
|
||||||
|
</div>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={`${t('int_filter')} · ${t('int_filter_field')}`}><ComboBox value={v.filterField} onChange={(x) => draft.setField('filterField', x)} options={fieldOptions} /></Field>
|
||||||
|
<Field label={t('int_filter_value')}><input className="input" maxLength={60} value={v.filterValue} onChange={(e) => draft.setField('filterValue', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{popup.purpose === 'patients' && <FieldRow>
|
||||||
|
<Field label={t('int_changed_field')}><ComboBox value={v.changedField} onChange={(x) => draft.setField('changedField', x)} options={[{ value: '', label: '—' }, ...(remote.schema.fields || []).filter((f) => f.type === 'T').map((f) => ({ value: f.name, label: f.name }))]} /></Field>
|
||||||
|
<div className="field" />
|
||||||
|
</FieldRow>}
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
<div className="toolbar-left ext-actions">
|
||||||
|
<Button variant="accent" disabled={busy || !canSave || (!!(popup.item || src) && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
|
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>}
|
||||||
|
</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
import { listEmployees, listSources, syncEmployees } from '../../services/integrations.js';
|
||||||
|
import { syncSummary } from '../Configuracion/ExternalConnectionsPanel.jsx';
|
||||||
|
|
||||||
|
// Empleados importados de la nómina (Conexiones externas). Solo lectura aquí; "Actualizar
|
||||||
|
// empleados" los vuelve a traer a pedido (admin y superadmin).
|
||||||
|
export default function EmployeesPanel() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const [rows, setRows] = useState(null), [source, setSource] = useState(undefined), [search, setSearch] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const [e, s] = await Promise.all([listEmployees(), listSources()]);
|
||||||
|
setRows(e); setSource(s.find((x) => x.purpose === 'employees') || null); setError('');
|
||||||
|
} catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void reload(); }, []);
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await syncEmployees();
|
||||||
|
await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows) return error ? <InfoBar tone="error">{error}</InfoBar> : <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
return <div>
|
||||||
|
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{source === null && <InfoBar tone="warning">{t('emp_no_source')}</InfoBar>}
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-left"><SearchInput value={search} onChange={setSearch} /><span className="text-muted" style={{ fontSize: '.78rem' }}>{t('int_last_sync')}: {syncSummary(t, source?.last_result)}</span></div>
|
||||||
|
<Button variant="accent" icon={RefreshCw} disabled={busy || !source} onClick={() => void update()}>{t('emp_update')}</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: '.78rem', margin: '0 0 8px' }}>{t('emp_protected_hint')}</p>
|
||||||
|
<ListView getRowKey={(e) => e.id} rows={rows} searchValue={search} getSearchHaystack={(e) => `${e.last_names} ${e.first_names} ${e.identification} ${e.position || ''} ${e.department_name || ''} ${t(`emp_origin_${e.origin}`)}`}
|
||||||
|
initialSort={{ key: 'last_names', dir: 1 }}
|
||||||
|
columns={[
|
||||||
|
{ key: 'last_names', label: t('int_target_lastNames') },
|
||||||
|
{ key: 'first_names', label: t('int_target_firstNames') },
|
||||||
|
{ key: 'identification', label: t('int_target_identification') },
|
||||||
|
{ key: 'position', label: t('int_target_position'), render: (e) => e.position || '—' },
|
||||||
|
{ key: 'department_name', label: t('int_target_department'), render: (e) => e.department_name || '—' },
|
||||||
|
{ key: 'origin', label: t('emp_origin'), render: (e) => t(`emp_origin_${e.origin}`) },
|
||||||
|
{ key: 'is_active', label: t('common_status'), render: (e) => <Badge color={e.is_active ? 'success' : 'muted'}>{t(e.is_active ? 'common_active' : 'common_inactive')}</Badge> },
|
||||||
|
{ key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' }
|
||||||
|
]} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import { Tabs } from '../../components/Tabs.jsx';
|
|||||||
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
||||||
import UsersPanel from './UsersPanel.jsx';
|
import UsersPanel from './UsersPanel.jsx';
|
||||||
|
import EmployeesPanel from './EmployeesPanel.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
|
||||||
export default function OrganizacionPage() {
|
export default function OrganizacionPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
|
const { user } = useAuth();
|
||||||
|
const canManage = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||||
|
const asked = searchParams.get('tab');
|
||||||
|
const tab = asked === 'users' || (asked === 'employees' && canManage) ? asked : 'departments';
|
||||||
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||||
const drafts = useUserDrafts();
|
const drafts = useUserDrafts();
|
||||||
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
||||||
@@ -21,11 +26,12 @@ export default function OrganizacionPage() {
|
|||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
||||||
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
||||||
|
...(canManage ? [{ key: 'employees', label: t('org_employees') }] : []),
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
{tab === 'departments' ? <DepartmentsPanel /> : <UsersPanel />}
|
{tab === 'departments' ? <DepartmentsPanel /> : tab === 'users' ? <UsersPanel /> : <EmployeesPanel />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx';
|
|||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
|
import { listEmployees } from '../../services/integrations.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
@@ -27,7 +28,10 @@ function flatten(nodes, acc = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
|
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
|
||||||
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '', employeeId: '' };
|
||||||
|
// Nómina viene en MAYÚSCULAS: "RIERA CAJAMARCA" → "Riera Cajamarca".
|
||||||
|
const titleCase = (s) => String(s || '').toLowerCase().replace(/(^|\s)\p{L}/gu, (m) => m.toUpperCase()).trim();
|
||||||
|
const fold = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
|
||||||
|
|
||||||
export default function UsersPanel() {
|
export default function UsersPanel() {
|
||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
@@ -47,6 +51,7 @@ export default function UsersPanel() {
|
|||||||
const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]);
|
const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [employees, setEmployees] = useState([]);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [approving, setApproving] = useState(null);
|
const [approving, setApproving] = useState(null);
|
||||||
@@ -79,6 +84,14 @@ export default function UsersPanel() {
|
|||||||
function startNew() {
|
function startNew() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
// Empleados de nómina que aún no tienen usuario (si hay fuente de nómina configurada).
|
||||||
|
listEmployees(true).then(setEmployees).catch(() => setEmployees([]));
|
||||||
|
}
|
||||||
|
function pickEmployee(id) {
|
||||||
|
const e = employees.find((x) => x.id === Number(id));
|
||||||
|
if (!e) return setForm((f) => ({ ...f, employeeId: '' }));
|
||||||
|
const dept = deptOptions.find((d) => fold(d.label) === fold(e.department_name));
|
||||||
|
setForm((f) => ({ ...f, employeeId: e.id, name: titleCase(`${e.first_names} ${e.last_names}`), email: e.email || f.email, position: e.position ? titleCase(e.position) : f.position, departmentId: dept ? dept.value : f.departmentId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(user) {
|
function startEdit(user) {
|
||||||
@@ -94,7 +107,7 @@ export default function UsersPanel() {
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
|
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
|
||||||
} else {
|
} else {
|
||||||
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
|
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password, employeeId: form.employeeId || undefined });
|
||||||
}
|
}
|
||||||
draft.clear();
|
draft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
@@ -208,6 +221,11 @@ export default function UsersPanel() {
|
|||||||
|
|
||||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||||
<DraftNotice draft={draft} />
|
<DraftNotice draft={draft} />
|
||||||
|
{!selected && employees.length > 0 && (
|
||||||
|
<Field label={t('user_from_payroll')} dirty={draft.isFieldDirty('employeeId')}>
|
||||||
|
<ComboBox options={[{ value: '', label: '—' }, ...employees.map((e) => ({ value: e.id, label: `${e.last_names} ${e.first_names} · ${e.identification}${e.position ? ` · ${e.position}` : ''}` }))]} value={form.employeeId} onChange={pickEmployee} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ export const clinicSchedules=(id)=>api.get(`/clinic/config/schedules/${id}`);
|
|||||||
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
||||||
export const clinicTypes=()=>api.get('/clinic/config/types');
|
export const clinicTypes=()=>api.get('/clinic/config/types');
|
||||||
export const clinicSaveType=(data,id)=>id?api.put(`/clinic/config/types/${id}`,data):api.post('/clinic/config/types',data);
|
export const clinicSaveType=(data,id)=>id?api.put(`/clinic/config/types/${id}`,data):api.post('/clinic/config/types',data);
|
||||||
|
export const clinicPatientSource=()=>api.get('/clinic/patients/source');
|
||||||
|
export const clinicExternalPatients=(search)=>api.get(`/clinic/patients/external${query({search})}`);
|
||||||
|
export const clinicImportExternalPatient=(externalId)=>api.post(`/clinic/patients/external/${encodeURIComponent(externalId)}/import`);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
const qs = (data) => `?${new URLSearchParams(Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== ''))}`;
|
||||||
|
export const listConnections = () => api.get('/integrations/connections');
|
||||||
|
export const saveConnection = (data, id) => (id ? api.put(`/integrations/connections/${id}`, data) : api.post('/integrations/connections', data));
|
||||||
|
export const testConnection = (id) => api.post(`/integrations/connections/${id}/test`);
|
||||||
|
export const browseConnection = (id, source, table) => api.get(`/integrations/connections/${id}/browse${qs({ source, table })}`);
|
||||||
|
export const listSources = () => api.get('/integrations/sources');
|
||||||
|
export const saveSource = (purpose, data) => api.put(`/integrations/sources/${purpose}`, data);
|
||||||
|
export const syncPatients = (full) => api.post('/integrations/sources/patients/sync', { full });
|
||||||
|
export const listEmployees = (available) => api.get(`/integrations/employees${available ? '?available=1' : ''}`);
|
||||||
|
export const syncEmployees = () => api.post('/integrations/employees/sync');
|
||||||
@@ -27,7 +27,9 @@ const DRAFT_FIELDS = {
|
|||||||
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
|
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
|
||||||
'task-new': [], 'task-edit': ['editVersion'],
|
'task-new': [], 'task-edit': ['editVersion'],
|
||||||
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
|
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
|
||||||
'user-new': ['roleId', 'departmentId'],
|
'user-new': ['roleId', 'departmentId', 'employeeId'],
|
||||||
|
'ext-connection': ['editVersion', 'name', 'baseUrl', 'isActive'],
|
||||||
|
'ext-source': ['editVersion', 'connectionId', 'sourceKey', 'tableName', 'mode', 'fieldMap', 'filterField', 'filterValue', 'changedField'],
|
||||||
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||||
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
||||||
'account-password': [],
|
'account-password': [],
|
||||||
|
|||||||
Reference in New Issue
Block a user