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