feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos

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