From 0ee9ed65bf74e44736c40b72925abb00f61777bb Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 29 Oct 2026 17:06:10 -0500 Subject: [PATCH] feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- API.md | 11 + BASE_DE_DATOS.md | 9 + CHANGELOG.md | 20 + DECISIONES.md | 5 + ESTADO.md | 26 +- SPEC.md | 6 + VERSION | 2 +- .../database/migrations/038_integrations.sql | 77 ++++ backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/app.js | 2 + backend/src/jobs/integrationSync.js | 19 + backend/src/modules/clinic/routes.js | 6 + backend/src/modules/clinic/service.js | 11 +- backend/src/modules/integrations/connector.js | 53 +++ backend/src/modules/integrations/routes.js | 22 + backend/src/modules/integrations/service.js | 390 ++++++++++++++++++ backend/src/modules/users/service.js | 13 +- .../src/scripts/import-provider-employees.js | 42 ++ backend/src/server.js | 2 + data/conectores/README.md | 9 +- data/conectores/foxpro/README.md | 10 +- data/conectores/foxpro/config.example.json | 72 +++- data/conectores/foxpro/package.json | 2 +- .../conectores/foxpro/scripts/prueba-local.js | 11 + data/conectores/foxpro/src/config.js | 1 + data/conectores/foxpro/src/dbf.js | 20 +- docs/CONEXIONES-EXTERNAS.md | 87 +++- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/i18n/messages.js | 126 ++++++ .../pages/AgendaMedica/AgendaMedicaPage.jsx | 30 +- .../src/pages/AgendaMedica/ClinicForms.jsx | 9 +- .../pages/Configuracion/ConfiguracionPage.jsx | 7 +- .../ExternalConnectionsPanel.css | 14 + .../ExternalConnectionsPanel.jsx | 232 +++++++++++ .../src/pages/Organizacion/EmployeesPanel.jsx | 57 +++ .../pages/Organizacion/OrganizacionPage.jsx | 10 +- .../src/pages/Organizacion/UsersPanel.jsx | 22 +- frontend/src/services/clinic.js | 3 + frontend/src/services/integrations.js | 12 + frontend/src/utils/drafts.js | 4 +- 43 files changed, 1402 insertions(+), 69 deletions(-) create mode 100644 backend/database/migrations/038_integrations.sql create mode 100644 backend/src/jobs/integrationSync.js create mode 100644 backend/src/modules/integrations/connector.js create mode 100644 backend/src/modules/integrations/routes.js create mode 100644 backend/src/modules/integrations/service.js create mode 100644 backend/src/scripts/import-provider-employees.js create mode 100644 frontend/src/pages/Configuracion/ExternalConnectionsPanel.css create mode 100644 frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx create mode 100644 frontend/src/pages/Organizacion/EmployeesPanel.jsx create mode 100644 frontend/src/services/integrations.js diff --git a/AGENTS.md b/AGENTS.md index dacba56..6ddd8ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,9 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - Notificaciones in-app - Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`) - 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 - servidor origen (`data/conectores/`, primero FoxPro) — ver `docs/CONEXIONES-EXTERNAS.md` +- Conexiones externas: datos de otros sistemas vía conectores de solo lectura en el servidor origen + (`data/conectores/`, FoxPro instalado en Exilaser), pacientes de SAGA y empleados de nómina — + ver `docs/CONEXIONES-EXTERNAS.md` ## Restricciones tecnicas diff --git a/API.md b/API.md index a359c67..e4e8607 100644 --- a/API.md +++ b/API.md @@ -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`. 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) Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`. diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 4a5a222..5aeb096 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -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`. 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) `backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev** diff --git a/CHANGELOG.md b/CHANGELOG.md index a6be9b4..54dfb8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # 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 ### Added - Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras diff --git a/DECISIONES.md b/DECISIONES.md index 95c8fed..5bee183 100644 --- a/DECISIONES.md +++ b/DECISIONES.md @@ -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 | | 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 | +| 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 | diff --git a/ESTADO.md b/ESTADO.md index 581d9d9..c3ee779 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,23 +1,31 @@ # 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 + 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`. -- Hecho: conector FoxPro 1.0.0 en `data/conectores/foxpro/` (API solo lectura, contrato v1 en - `data/conectores/README.md`). `npm test` 16/16 OK con DBF temporal. Sin probar con tablas reales - de SAGA ni los `.ps1` en Windows. -- Siguiente: con permiso del usuario, revisar por SSH `192.168.0.253` las carpetas de nómina y - clientes; instalar en `C:\api_project\foxpro`; luego el módulo del sistema. +- Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea + `LinkedCorp-Conector-FoxPro`). Token en `~/.config/linkedcorp/conector-foxpro-exilaser.token`. +- **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job + `integrationSync`, Configuración → Conexiones externas, Organización → Empleados, Nuevo usuario → + Desde nómina, pacientes de SAGA en la Agenda. Dev quedó con la conexión "SAGA Exilaser", 31 + 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) - 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: 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 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 diff --git a/SPEC.md b/SPEC.md index 95ba811..db4d6aa 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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`. - 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 - Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa desde el gráfico en el MVP. diff --git a/VERSION b/VERSION index a162ea7..815588e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.11.0 +4.12.0 diff --git a/backend/database/migrations/038_integrations.sql b/backend/database/migrations/038_integrations.sql new file mode 100644 index 0000000..b19dadf --- /dev/null +++ b/backend/database/migrations/038_integrations.sql @@ -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); diff --git a/backend/package-lock.json b/backend/package-lock.json index a05a426..68a65d2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "4.11.0", + "version": "4.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "4.11.0", + "version": "4.12.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 9077498..4072f56 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.11.0", + "version": "4.12.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 75f2cbf..f828e88 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -30,6 +30,7 @@ import inventoryIngestRoutes from './modules/devices/ingest.js'; import aiRoutes from './modules/ai/routes.js'; import meetingsRoutes from './modules/meetings/routes.js'; import clinicRoutes from './modules/clinic/routes.js'; +import integrationRoutes from './modules/integrations/routes.js'; export const app = express(); @@ -76,6 +77,7 @@ app.use('/api/devices', devicesRoutes); app.use('/api/ai', aiRoutes); app.use('/api/meetings', meetingsRoutes); 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 // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/jobs/integrationSync.js b/backend/src/jobs/integrationSync.js new file mode 100644 index 0000000..8632d13 --- /dev/null +++ b/backend/src/jobs/integrationSync.js @@ -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); +} diff --git a/backend/src/modules/clinic/routes.js b/backend/src/modules/clinic/routes.js index c8bb9dc..0cbd048 100644 --- a/backend/src/modules/clinic/routes.js +++ b/backend/src/modules/clinic/routes.js @@ -3,6 +3,7 @@ import { requireAuth } from '../../middleware/auth.js'; import { requireModule } from '../../middleware/modules.js'; import { asyncHandler, ok } from '../../utils/http.js'; import * as clinic from './service.js'; +import * as integrations from '../integrations/service.js'; const router = Router(); router.use(requireAuth, requireModule('clinic')); // 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)); 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)); +// 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)))); 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)); diff --git a/backend/src/modules/clinic/service.js b/backend/src/modules/clinic/service.js index d7b6fa7..26ec47c 100644 --- a/backend/src/modules/clinic/service.js +++ b/backend/src/modules/clinic/service.js @@ -37,6 +37,9 @@ const audit = async (user, action, entityType, entityId, connection = pool) => l connection }); 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) { 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); 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 where = 'is_active=1 AND (first_names LIKE ? OR last_names 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 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 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, like]); + 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 { items, total: count.n, @@ -122,7 +125,7 @@ export async function savePatient(raw, user, staff, patientId = null) { const data = patientData(raw); let result; 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) { if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada'); throw e; diff --git a/backend/src/modules/integrations/connector.js b/backend/src/modules/integrations/connector.js new file mode 100644 index 0000000..127ce98 --- /dev/null +++ b/backend/src/modules/integrations/connector.js @@ -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á'); +} diff --git a/backend/src/modules/integrations/routes.js b/backend/src/modules/integrations/routes.js new file mode 100644 index 0000000..c555caa --- /dev/null +++ b/backend/src/modules/integrations/routes.js @@ -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; diff --git a/backend/src/modules/integrations/service.js b/backend/src/modules/integrations/service.js new file mode 100644 index 0000000..2be9ef6 --- /dev/null +++ b/backend/src/modules/integrations/service.js @@ -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; +} diff --git a/backend/src/modules/users/service.js b/backend/src/modules/users/service.js index 0aee0b9..b692825 100644 --- a/backend/src/modules/users/service.js +++ b/backend/src/modules/users/service.js @@ -1,5 +1,6 @@ import crypto from 'node:crypto'; import bcrypt from 'bcryptjs'; +import { linkEmployeeToUser, requireFreeEmployee } from '../integrations/service.js'; import * as repo from './repository.js'; import { HttpError } from '../../utils/http.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); if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email'); 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 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; } diff --git a/backend/src/scripts/import-provider-employees.js b/backend/src/scripts/import-provider-employees.js new file mode 100644 index 0000000..16412db --- /dev/null +++ b/backend/src/scripts/import-provider-employees.js @@ -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(); diff --git a/backend/src/server.js b/backend/src/server.js index 12d5b91..03aebb2 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -2,6 +2,7 @@ import { app } from './app.js'; import { env } from './config/env.js'; import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js'; import { startMeetingReminderJob } from './jobs/meetingReminders.js'; +import { startIntegrationSyncJob } from './jobs/integrationSync.js'; app.listen(env.port, () => { console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`); @@ -9,3 +10,4 @@ app.listen(env.port, () => { startRecurringTaskReminderJob(); startMeetingReminderJob(); +startIntegrationSyncJob(); diff --git a/data/conectores/README.md b/data/conectores/README.md index d3a7677..e6a4af8 100644 --- a/data/conectores/README.md +++ b/data/conectores/README.md @@ -6,7 +6,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro, | 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 @@ -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/: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/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`: @@ -38,6 +38,11 @@ Parámetros de `rows`: - `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. - `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: el sistema la compara para saber si hace falta volver a sincronizar. diff --git a/data/conectores/foxpro/README.md b/data/conectores/foxpro/README.md index c71acae..7ed8130 100644 --- a/data/conectores/foxpro/README.md +++ b/data/conectores/foxpro/README.md @@ -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 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`. - 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. +- 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. ## 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 server). Sin `config.json` ni `logs` de otro equipo. -2. Node: copiar `node.exe` portable (Node 20 LTS o superior, Windows x64) a - `C:\api_project\foxpro\node\node.exe`. Así no hay que instalar Node en el servidor. +2. Node: usa el Node instalado en el servidor (Exilaser tiene Node 24); si no hay, copiar `node.exe` + 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 **tokenSha256** va en `config.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 -- `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. diff --git a/data/conectores/foxpro/config.example.json b/data/conectores/foxpro/config.example.json index 31b21a3..5ef22e4 100644 --- a/data/conectores/foxpro/config.example.json +++ b/data/conectores/foxpro/config.example.json @@ -1,24 +1,75 @@ { "host": "0.0.0.0", "port": 8455, - "tls": { "pfx": "", "passphraseEnv": "CONECTOR_TLS_PASS" }, + "tls": { + "pfx": "", + "passphraseEnv": "CONECTOR_TLS_PASS" + }, "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": [ { "id": "nomina", "name": "Nómina", - "path": "C:\\Sistemas\\nomina", + "path": "C:\\Sistema\\Nomina\\Datos\\Exilaser", "encoding": "cp1252", - "tables": "*", - "denyFields": ["SUELDO", "SALARIO", "CLAVE", "PASSWORD"] + "tables": { + "EMPLEADO": { + "fields": [ + "EMPCED", + "EMPAPE", + "EMPNOM", + "EMPTEL", + "EMPTEL1", + "EMPFECNAC", + "EMPCARGO", + "EMPDEPART", + "EMPINGRESO", + "EMPSALIDA", + "EMPACTIVO" + ] + } + } }, { "id": "clientes", - "name": "Clientes SAGA", - "path": "C:\\Sistemas\\saga", - "encoding": "cp1252", - "tables": { "CLIENTES": { "fields": ["CODIGO", "CEDULA", "NOMBRE", "TELEFONO", "EMAIL", "FECNAC"] } } + "name": "SAGA (clientes y proveedores)", + "path": "C:\\Sistema\\Datos\\exilaser", + "encoding": "cp850", + "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": { @@ -28,7 +79,8 @@ "maxQueuedReads": 10, "cacheMaxRecordsPerTable": 100000, "cacheMaxTables": 10, - "requestTimeoutMs": 30000 + "requestTimeoutMs": 30000, + "minReloadSeconds": 60 }, "logDir": "logs" } diff --git a/data/conectores/foxpro/package.json b/data/conectores/foxpro/package.json index 34d3256..d2ca976 100644 --- a/data/conectores/foxpro/package.json +++ b/data/conectores/foxpro/package.json @@ -1,6 +1,6 @@ { "name": "linkedcorp-conector-foxpro", - "version": "1.0.0", + "version": "1.1.0", "description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1", "main": "src/server.js", "scripts": { diff --git a/data/conectores/foxpro/scripts/prueba-local.js b/data/conectores/foxpro/scripts/prueba-local.js index bddaf91..3f8f4e1 100644 --- a/data/conectores/foxpro/scripts/prueba-local.js +++ b/data/conectores/foxpro/scripts/prueba-local.js @@ -45,6 +45,7 @@ async function main() { allowIps: ['127.0.0.1'], sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }], 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'] }); @@ -103,6 +104,16 @@ async function main() { assert.strictEqual(r.rows.length, 1); 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 () => { const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version; const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' }); diff --git a/data/conectores/foxpro/src/config.js b/data/conectores/foxpro/src/config.js index 0931946..c1c9988 100644 --- a/data/conectores/foxpro/src/config.js +++ b/data/conectores/foxpro/src/config.js @@ -73,6 +73,7 @@ function load() { cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000), cacheMaxTables: Number(limits.cacheMaxTables || 10), requestTimeoutMs: Number(limits.requestTimeoutMs || 30000), + minReloadSeconds: Number(limits.minReloadSeconds ?? 60), }, logDir: path.resolve(ROOT, raw.logDir || 'logs'), }; diff --git a/data/conectores/foxpro/src/dbf.js b/data/conectores/foxpro/src/dbf.js index 1019c76..f9f13df 100644 --- a/data/conectores/foxpro/src/dbf.js +++ b/data/conectores/foxpro/src/dbf.js @@ -98,7 +98,8 @@ async function withSnapshot(ref, fn) { function toPlain(value, type) { if (value === null || value === undefined) return null; 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(); } if (typeof value === 'string') { @@ -122,7 +123,10 @@ async function loadTable(source, ref, limits) { const key = `${source.id}/${ref.table}`; const sig = await signature(ref); 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.set(key, hit); // LRU 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); 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); return entry; @@ -206,9 +210,17 @@ async function readRows(source, tableName, q, limits) { .filter(([k]) => k.startsWith('eq.')) .map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).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 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; 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; @@ -221,7 +233,7 @@ async function readRows(source, tableName, q, limits) { for (const f of fields) o[f] = r[f]; 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 }; diff --git a/docs/CONEXIONES-EXTERNAS.md b/docs/CONEXIONES-EXTERNAS.md index 8eff221..2258669 100644 --- a/docs/CONEXIONES-EXTERNAS.md +++ b/docs/CONEXIONES-EXTERNAS.md @@ -4,8 +4,9 @@ > 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). -Estado (2026-10-29): **conector FoxPro 1.0.0 hecho** (`data/conectores/foxpro/`, 16 pruebas locales -OK, sin probar con tablas reales ni en Windows). **Módulo del sistema: pendiente** (solo diseño). +Estado (2026-10-29): **conector FoxPro 1.1.0 instalado y funcionando en Exilaser** (ver abajo). +**Módulo del sistema hecho en dev** (migración 038, `backend/src/modules/integrations`, job +`integrationSync`), sin commit ni producción. ## 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 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 - Agenda médica). Nombres reales de tablas y campos: **por confirmar** en el servidor. +- Fuentes reales (revisadas por SSH, solo estructura, 2026-10-29): + +| 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 @@ -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 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 -- [ ] Revisar en el server (SSH, solo lectura, con permiso del usuario) las carpetas de nómina y - clientes: tablas, campos, versión de los DBF y codificación (cp1252/cp850). -- [ ] Instalar el conector en `C:\api_project\foxpro` (node.exe portable + `config.json` + token + - `instalar-servicio.ps1`) y probar desde el backend. -- [ ] 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 - usuario desde nómina, búsqueda de pacientes externa en la Agenda. +- [x] Revisar en el server las carpetas de nómina y clientes (estructura, codificación). +- [x] Instalar el conector en `C:\api_project\foxpro` y probar desde dev. +- [x] Módulo del sistema (ver "Cómo quedó en el sistema"). +- [ ] Commit, revisión y producción (migraciones 036–038, conexión y fuentes a configurar allá). + +## Cómo quedó en el sistema (2026-10-29) + +- **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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 29f6aaf..eb9a5cd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "4.11.0", + "version": "4.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "4.11.0", + "version": "4.12.0", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", diff --git a/frontend/package.json b/frontend/package.json index 06f26a1..ec12a4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.11.0", + "version": "4.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index c111a3b..5bb0718 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -663,6 +663,69 @@ export const messages = { org_departments: 'Departamentos', 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_parent: 'Departamento padre', user_new: 'Nuevo usuario', @@ -1357,6 +1420,69 @@ export const messages = { org_departments: 'Departments', 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_parent: 'Parent department', user_new: 'New user', diff --git a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx index 5545818..335185b 100644 --- a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx +++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx @@ -54,7 +54,10 @@ export default function AgendaMedicaPage() { [patientTotal, setPatientTotal] = useState(0); // Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita. 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), [editing, setEditing] = useState(false), [conflict, setConflict] = useState(''), @@ -98,6 +101,7 @@ export default function AgendaMedicaPage() { api.clinicMe().then(async staff => { setMe(staff); await loadDoctors(); + if (staff.scope === 'all') api.clinicPatientSource().then(setPatientSource).catch(() => setPatientSource(null)); }).catch(e => { if (isAdmin) setTab('config'); else setError(e.message); @@ -169,6 +173,7 @@ export default function AgendaMedicaPage() { if (kind === 'appointment' && isReception) { // Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes. setApptSearch(''); + setExternalPatients([]); const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : []; setApptPatients(current); 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) { setApptSearch(text); 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 => { if (seq !== searchSeq.current) return; setApptPatients(prev => { @@ -188,6 +196,18 @@ export default function AgendaMedicaPage() { }); }).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, // cancelar o cerrar. Si lo buscado parece una cédula, se precarga. function newPatientFromAppointment() { @@ -403,7 +423,9 @@ export default function AgendaMedicaPage() { ] : popup.kind === 'patient' ? [ [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_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_title'), it.title], [t('clinic_created_by'), it.created_by_name], @@ -520,8 +542,8 @@ export default function AgendaMedicaPage() { } : <> - {popup.kind === 'appointment' && } - {popup.kind === 'patient' && } + {popup.kind === 'appointment' && void pickApptPatient(x)} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />} + {popup.kind === 'patient' && } {popup.kind === 'block' && }
diff --git a/frontend/src/pages/AgendaMedica/ClinicForms.jsx b/frontend/src/pages/AgendaMedica/ClinicForms.jsx index 808ccc0..698ec5c 100644 --- a/frontend/src/pages/AgendaMedica/ClinicForms.jsx +++ b/frontend/src/pages/AgendaMedica/ClinicForms.jsx @@ -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: // 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 v = draft.values; 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 onSearch(e.target.value)} />
- draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(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')}` }))]} />
@@ -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 v = draft.values; const input = (k, label, extra = {}) => draft.setField(k, e.target.value)} {...extra} />; return <> + {unsplit && {t('clinic_name_unsplit')}} {input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })} {input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })} diff --git a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx index aae1e33..0a7a31b 100644 --- a/frontend/src/pages/Configuracion/ConfiguracionPage.jsx +++ b/frontend/src/pages/Configuracion/ConfiguracionPage.jsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; 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 { useAuth } from '../../context/auth-context.jsx'; import { Expander } from '../../components/Expander.jsx'; @@ -13,6 +13,7 @@ import AiUsagePanel from './AiUsagePanel.jsx'; import CompanySettingsPanel from './CompanySettingsPanel.jsx'; import UpdaterPanel from './UpdaterPanel.jsx'; import ErrorLogsPanel from './ErrorLogsPanel.jsx'; +import ExternalConnectionsPanel from './ExternalConnectionsPanel.jsx'; export default function ConfiguracionPage() { const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi(); @@ -117,6 +118,10 @@ export default function ConfiguracionPage() { /> + + + + diff --git a/frontend/src/pages/Configuracion/ExternalConnectionsPanel.css b/frontend/src/pages/Configuracion/ExternalConnectionsPanel.css new file mode 100644 index 0000000..c095bb4 --- /dev/null +++ b/frontend/src/pages/Configuracion/ExternalConnectionsPanel.css @@ -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}} diff --git a/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx b/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx new file mode 100644 index 0000000..a8f8ac7 --- /dev/null +++ b/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx @@ -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
+ {error && !popup && {error}} +

{t('int_connections')}

+ 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) => {t(c.is_active ? 'common_active' : 'common_inactive')} } + ]} + 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); } }]} /> + +

{t('int_sources')}

+ 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}` : {t('int_not_configured')}; } }, + { 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) : '—'; } } + ]} /> +

{t('int_schedule_hint')}

+ + + {popup &&
+ {error && {error}} + {editing && } + {!editing ? <> + {popup.kind === 'connection' && <> +
+

{t('common_name')}: {popup.item.name}

+

{t('int_url')}: {popup.item.base_url}

+

{t('common_status')}: {t(popup.item.is_active ? 'common_active' : 'common_inactive')}

+
+
+ } + {popup.kind === 'source' && <> + {!src ? {t('int_not_configured')} :
+

{t('int_connection')}: {src.connection_name}

+

{t('int_table')}: {src.source_key}/{src.table_name}

+

{t('int_mode')}: {t(`int_mode_${src.mode}`)}

+ {src.field_map &&

{t('int_mapping')}: {Object.entries(src.field_map).map(([k, f]) => `${t(`int_target_${k}`)} ← ${f}`).join(' · ')}

} + {src.filter && Object.keys(src.filter).length > 0 &&

{t('int_filter')}: {Object.entries(src.filter).map(([k, x]) => `${k} = ${x}`).join(', ')}

} + {src.changed_field &&

{t('int_changed_field')}: {src.changed_field}

} +

{t('int_last_sync')}: {syncSummary(t, src.last_result)}

+
} +
+ + {src && popup.purpose === 'employees' && } + {src && popup.purpose === 'patients' && src.mode === 'import' && <> + + + } +
+ } + : <> + {popup.kind === 'connection' && <> + + draft.setField('name', e.target.value)} /> + draft.setField('baseUrl', e.target.value)} /> + + + draft.setField('token', e.target.value)} /> + + + } + {popup.kind === 'source' && <> + + 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 }))]} /> + {popup.purpose === 'patients' + ? draft.setField('mode', x)} options={['import', 'live'].map((x) => ({ value: x, label: t(`int_mode_${x}`) }))} /> + :
} + + + draft.setValues((o) => ({ ...o, sourceKey: x, tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...remote.sources.map((s) => ({ value: s.id, label: s.name }))]} /> + 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})` }))]} /> + + {v.tableName && !remote.schema &&

{t('common_loading')}

} + {remote.schema && <> +
{fields.map((f) => )}{remote.sample.map((r) => {fields.map((f) => )})}
{f}
{r[f] === null || r[f] === undefined ? '' : String(r[f])}
+

{t('int_mapping')}

+
+ {TARGETS[popup.purpose].map((k) => + draft.setValues((o) => ({ ...o, fieldMap: { ...o.fieldMap, [k]: x || undefined } }))} options={fieldOptions} /> + )} +
+ + draft.setField('filterField', x)} options={fieldOptions} /> + draft.setField('filterValue', e.target.value)} /> + + {popup.purpose === 'patients' && + draft.setField('changedField', x)} options={[{ value: '', label: '—' }, ...(remote.schema.fields || []).filter((f) => f.type === 'T').map((f) => ({ value: f.name, label: f.name }))]} /> +
+ } + } + } +
+ + +
+ } +
} + +
; +} diff --git a/frontend/src/pages/Organizacion/EmployeesPanel.jsx b/frontend/src/pages/Organizacion/EmployeesPanel.jsx new file mode 100644 index 0000000..391c7e1 --- /dev/null +++ b/frontend/src/pages/Organizacion/EmployeesPanel.jsx @@ -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 ? {error} :
{t('common_loading')}
; + return
+ {error && {error}} + {source === null && {t('emp_no_source')}} +
+
{t('int_last_sync')}: {syncSummary(t, source?.last_result)}
+ +
+

{t('emp_protected_hint')}

+ 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) => {t(e.is_active ? 'common_active' : 'common_inactive')} }, + { key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' } + ]} /> +
; +} diff --git a/frontend/src/pages/Organizacion/OrganizacionPage.jsx b/frontend/src/pages/Organizacion/OrganizacionPage.jsx index c9afd43..cb3384d 100644 --- a/frontend/src/pages/Organizacion/OrganizacionPage.jsx +++ b/frontend/src/pages/Organizacion/OrganizacionPage.jsx @@ -4,12 +4,17 @@ import { Tabs } from '../../components/Tabs.jsx'; import { useUserDrafts } from '../../hooks/useDraftForm.js'; import DepartmentsPanel from './DepartmentsPanel.jsx'; import UsersPanel from './UsersPanel.jsx'; +import EmployeesPanel from './EmployeesPanel.jsx'; +import { useAuth } from '../../context/auth-context.jsx'; export default function OrganizacionPage() { const { t } = useUi(); // Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes. 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 drafts = useUserDrafts(); const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix)); @@ -21,11 +26,12 @@ export default function OrganizacionPage() { tabs={[ { key: 'departments', label: t('org_departments'), dirty: dirty('department-') }, { key: 'users', label: t('org_users'), dirty: dirty('user-') }, + ...(canManage ? [{ key: 'employees', label: t('org_employees') }] : []), ]} active={tab} onChange={setTab} /> - {tab === 'departments' ? : } + {tab === 'departments' ? : tab === 'users' ? : }
); } diff --git a/frontend/src/pages/Organizacion/UsersPanel.jsx b/frontend/src/pages/Organizacion/UsersPanel.jsx index 01f8f08..158fb33 100644 --- a/frontend/src/pages/Organizacion/UsersPanel.jsx +++ b/frontend/src/pages/Organizacion/UsersPanel.jsx @@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js'; import { listDepartments } from '../../services/departments.js'; +import { listEmployees } from '../../services/integrations.js'; import { ListView } from '../../components/ListView.jsx'; import { SearchInput } from '../../components/SearchInput.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 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() { const { t, pushToast, openDialog } = useUi(); @@ -47,6 +51,7 @@ export default function UsersPanel() { const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]); const [search, setSearch] = useState(''); + const [employees, setEmployees] = useState([]); const [editing, setEditing] = useState(false); const [selected, setSelected] = useState(null); const [approving, setApproving] = useState(null); @@ -79,6 +84,14 @@ export default function UsersPanel() { function startNew() { setSelected(null); 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) { @@ -94,7 +107,7 @@ export default function UsersPanel() { if (selected) { await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion }); } 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(); await reload(); @@ -208,6 +221,11 @@ export default function UsersPanel() { guardedClose(draft, () => setEditing(false))} width={560} height={500}> + {!selected && employees.length > 0 && ( + + ({ value: e.id, label: `${e.last_names} ${e.first_names} · ${e.identification}${e.position ? ` · ${e.position}` : ''}` }))]} value={form.employeeId} onChange={pickEmployee} /> + + )} setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> diff --git a/frontend/src/services/clinic.js b/frontend/src/services/clinic.js index 4a0aef1..0d26f0b 100644 --- a/frontend/src/services/clinic.js +++ b/frontend/src/services/clinic.js @@ -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 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 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`); diff --git a/frontend/src/services/integrations.js b/frontend/src/services/integrations.js new file mode 100644 index 0000000..d69df20 --- /dev/null +++ b/frontend/src/services/integrations.js @@ -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'); diff --git a/frontend/src/utils/drafts.js b/frontend/src/utils/drafts.js index e968b4d..17441ae 100644 --- a/frontend/src/utils/drafts.js +++ b/frontend/src/utils/drafts.js @@ -27,7 +27,9 @@ const DRAFT_FIELDS = { 'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'], 'task-new': [], 'task-edit': ['editVersion'], '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-approve': ['editVersion', 'roleId', 'departmentId'], 'account-password': [],