feat: version 4.13.0 -- Agregar medicos desde proveedores de SAGA (solo por API)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 17:15:12 -05:00
parent 0ee9ed65bf
commit d85a7196f0
21 changed files with 206 additions and 23 deletions
+2 -1
View File
@@ -84,7 +84,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- Configuración (catálogos: estados, prioridades, tipos de proyecto) - Configuración (catálogos: estados, prioridades, tipos de proyecto)
- Conexiones externas: datos de otros sistemas vía conectores de solo lectura en el servidor origen - 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 — (`data/conectores/`, FoxPro instalado en Exilaser), pacientes de SAGA y empleados de nómina —
ver `docs/CONEXIONES-EXTERNAS.md` ver `docs/CONEXIONES-EXTERNAS.md`. Regla: datos de SAGA **solo por la API del conector** (nunca
leer DBF por SSH); SSH al Windows solo para administrar el conector, con permiso.
## Restricciones tecnicas ## Restricciones tecnicas
+2 -1
View File
@@ -39,7 +39,8 @@ Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
`POST /connections/:id/test`, `GET /connections/:id/browse?source=&table=` (fuentes, tablas, `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 + 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 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. solo ve estado), `GET /employees[?available=1]`, `POST /employees/sync`, `GET /providers?search=` (busca en la fuente
Proveedores, marca `employee_id`), `POST /providers/:codigo/import {lastNames, firstNames, position}`. El token nunca se devuelve.
En la Agenda (recepción): `GET /api/clinic/patients/source`, `GET /api/clinic/patients/external?search=` 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 (modo en vivo) y `POST /api/clinic/patients/external/:codigo/import`. `POST /api/users` acepta
`employeeId` (Desde nómina). `employeeId` (Desde nómina).
+9
View File
@@ -1,5 +1,14 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [4.13.0] - 2026-10-29
### Added
- Fuente "Proveedores" en Conexiones externas y Organización → Empleados → "Agregar desde
proveedores": buscar un médico nuevo por nombre, apellido o cédula en SAGA (por la API del
conector), confirmar apellidos/nombres/cargo y guardarlo como empleado. Marca "Ya es empleado".
### Changed
- Regla de seguridad: todo dato de SAGA se consulta solo por la API del conector; el script de carga
masiva usa la fuente "Proveedores". Migración 038 agrega el destino `providers` (idempotente).
## [4.12.0] - 2026-10-29 ## [4.12.0] - 2026-10-29
### Added ### Added
- Conexiones externas en el sistema (migración 038, `modules/integrations`): Configuración → - Conexiones externas en el sistema (migración 038, `modules/integrations`): Configuración →
+1
View File
@@ -18,3 +18,4 @@
| 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 | | 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 | | 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 | | 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 |
| 13 | 2026-10-29 | **Todo dato de SAGA se consulta por la API del conector** (sistema, scripts y búsquedas a pedido); SSH al Windows solo para administrar el conector | Seguridad primero (pedido del usuario) | Leer DBF por SSH/SMB | vigente |
+3 -1
View File
@@ -1,6 +1,6 @@
# ESTADO — project.linkedcorp.net # ESTADO — project.linkedcorp.net
Ultima actualizacion: 2026-10-29 (v4.12.0 commit + push, sin desplegar) Ultima actualizacion: 2026-10-29 (v4.13.0 commit + push, sin desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP. autorregistro/aprobacion/SMTP.
@@ -14,6 +14,8 @@ autorregistro/aprobacion/SMTP.
empleados y 31 220 pacientes importados (modo importar). Probado por API y navegador (usuario empleados y 31 220 pacientes importados (modo importar). Probado por API y navegador (usuario
superadmin temporal, ya borrado), sin errores de consola. superadmin temporal, ya borrado), sin errores de consola.
- Base de dev **sin datos de prueba de la Agenda** (borrados 2026-10-29). - Base de dev **sin datos de prueba de la Agenda** (borrados 2026-10-29).
- v4.13.0: fuente "Proveedores" y Empleados → "Agregar desde proveedores"
(buscar médico nuevo por nombre/apellido/cédula y guardarlo como empleado). Probado en navegador.
- Revisión `medium` hecha y corregida. Médicos cargados en dev como empleados (9 proveedores + 2 de - 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. 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 - **Producción está en 2.19.0 (migraciones hasta 027)**: para cargar allá los médicos hay que
+1 -1
View File
@@ -1 +1 @@
4.12.0 4.13.0
@@ -60,6 +60,9 @@ CREATE TABLE IF NOT EXISTS employees (
CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Tercera fuente: proveedores (médicos que facturan), para buscarlos y agregarlos como empleados.
ALTER TABLE external_sources MODIFY purpose ENUM('patients','employees','providers') NOT NULL;
-- Origen del empleado: nómina (se sincroniza) o proveedor de SAGA cargado a mano (médicos que facturan; -- 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. -- 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 origin ENUM('payroll','provider') NOT NULL DEFAULT 'payroll' AFTER is_active;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.12.0", "version": "4.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.12.0", "version": "4.13.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.12.0", "version": "4.13.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
@@ -19,4 +19,6 @@ router.put('/sources/:purpose', h((req) => svc.saveSource(req.params.purpose, re
router.post('/sources/patients/sync', h((req) => svc.syncPatients({ full: !!req.body?.full, user: 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.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser))); router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser)));
router.get('/providers', h((req) => svc.searchProviders(req.query.search, req.currentUser)));
router.post('/providers/:code/import', h((req) => svc.importProvider(req.params.code, req.body || {}, req.currentUser), 201));
export default router; export default router;
+56 -2
View File
@@ -13,7 +13,8 @@ const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const TARGETS = { const TARGETS = {
employees: { required: ['identification'], fields: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] }, 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'] } patients: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] },
providers: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] }
}; };
const isSuperadmin = (user) => user.roleCode === 'superadmin'; const isSuperadmin = (user) => user.roleCode === 'superadmin';
@@ -163,7 +164,7 @@ export async function saveSource(purpose, raw, user) {
async function sourceClient(purpose) { async function sourceClient(purpose) {
const src = await sourceRow(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'); if (!src) throw new HttpError(409, { employees: 'No hay fuente de nómina configurada', patients: 'No hay fuente de pacientes configurada', providers: 'No hay fuente de proveedores configurada' }[purpose]);
const conn = await loadConnection(src.connection_id); const conn = await loadConnection(src.connection_id);
if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada'); if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada');
return { src, conn: clientFor(conn) }; return { src, conn: clientFor(conn) };
@@ -246,6 +247,59 @@ export async function linkEmployeeToUser(employeeId, userId, connection = pool)
if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe'); if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe');
} }
// --- Proveedores → empleados (médicos que facturan; admin y superadmin, a pedido) ---
const providerValues = (src, r) => {
const m = src.field_map;
const raw = clip(m.identification && r[m.identification], 40);
const email = clip(m.email && r[m.email], 160);
const phone = clip(m.phone && r[m.phone], 40);
return {
code: clip(r[m.externalId], 40),
// RUC de persona natural = cédula + "001".
identification: raw && /^\d{13}$/.test(raw) && raw.endsWith('001') ? raw.slice(0, 10) : raw,
fullName: clip(r[m.fullName], 120)?.replace(/^DRA?\.?\s+/i, '') || null,
phone: phone && !/^9+$/.test(phone) ? phone : null,
email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
};
};
export async function searchProviders(text, user) {
requireAdmin(user);
const { src, conn } = await sourceClient('providers');
const term = String(text || '').trim();
if (term.length < 3) return [];
const m = src.field_map;
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].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 30, ...eqQuery(src.filter)
});
const list = page.rows.map((r) => providerValues(src, r)).filter((p) => p.code && p.fullName);
const idents = list.map((p) => p.identification).filter(Boolean);
const employees = idents.length ? new Map((await exec(pool, 'SELECT id, identification FROM employees WHERE identification IN (?)', [idents])).map((e) => [e.identification, e.id])) : new Map();
return list.map((p) => ({ ...p, employee_id: employees.get(p.identification) || null }));
}
export async function importProvider(code, raw, user) {
requireAdmin(user);
const { src, conn } = await sourceClient('providers');
const c = clip(code, 40);
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
[`eq.${src.field_map.externalId}`]: c, fields: fieldsQuery(src), limit: 1
});
if (!page.rows.length) throw new HttpError(404, 'El proveedor no existe en el sistema origen');
const p = providerValues(src, page.rows[0]);
if (!p.identification) throw new HttpError(400, 'El proveedor no tiene cédula/RUC');
// El orden de nombres en el origen no es fijo: apellidos y nombres los confirma quien agrega.
const last = clip(raw.lastNames, 120), first = clip(raw.firstNames, 120), position = clip(raw.position, 120);
if (!last || !first) throw new HttpError(400, 'Apellidos y nombres son obligatorios');
const [exists] = await exec(pool, 'SELECT id FROM employees WHERE identification=?', [p.identification]);
if (exists) throw new HttpError(409, 'Ya existe un empleado con esa cédula');
const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
VALUES (?,?,?,?,?,?,1,'provider',0,?)`, [p.identification, last, first, p.phone, p.email, position, guayaquilNow()]);
await logAudit({ userId: user.id, action: 'import', entityType: 'employee', entityId: result.insertId, detail: { providerCode: c } });
return (await exec(pool, 'SELECT * FROM employees WHERE id=?', [result.insertId]))[0];
}
// --- Pacientes (clientes del origen) --- // --- Pacientes (clientes del origen) ---
const patientKey = (src) => `src${src.id}`; const patientKey = (src) => `src${src.id}`;
@@ -10,14 +10,16 @@ import { pool } from '../config/db.js';
import { decrypt } from '../utils/crypto.js'; import { decrypt } from '../utils/crypto.js';
import { connectorGet } from '../modules/integrations/connector.js'; import { connectorGet } from '../modules/integrations/connector.js';
const SOURCE = 'clientes', TABLE = 'PROVEDOR';
const args = process.argv.slice(2); const args = process.argv.slice(2);
const position = (args.find((a) => a.startsWith('--position=')) || '--position=MEDICO').split('=')[1]; const position = (args.find((a) => a.startsWith('--position=')) || '--position=MEDICO').split('=')[1];
const people = args.filter((a) => !a.startsWith('--')).map((a) => a.split('+')); 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); } 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]; // Usa la fuente "Proveedores" de Conexiones externas (mismos campos que la pantalla Empleados →
if (!conn) { console.error('No hay conexión activa (Configuración → Conexiones externas)'); process.exit(1); } // Agregar desde proveedores); con PROVCOD/PROVNOM/PROVRUC/PROVTEL/PROVEMAIL como en SAGA.
const [conn] = (await pool.query("SELECT c.base_url, c.token_encrypted, s.source_key, s.table_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='providers' AND c.is_active=1"))[0];
if (!conn) { console.error('No hay fuente de proveedores activa (Configuración → Conexiones externas)'); process.exit(1); }
const SOURCE = conn.source_key, TABLE = conn.table_name;
const client = { baseUrl: conn.base_url, token: decrypt(conn.token_encrypted) }; const client = { baseUrl: conn.base_url, token: decrypt(conn.token_encrypted) };
const valid = (v) => v && !/^9+$/.test(v); const valid = (v) => v && !/^9+$/.test(v);
+12
View File
@@ -58,6 +58,12 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
## Seguridad (resumen) ## Seguridad (resumen)
- **Regla (usuario, 2026-10-29): todo lo que se busque o traiga de SAGA es por la API del conector**,
también las búsquedas que haga Claude a pedido. El SSH al Windows es solo para administrar el
conector (instalar, actualizar, decidir qué tablas/campos expone), con permiso del usuario. Una
tabla nueva primero se agrega a `config.json` del conector con los campos mínimos; después se
consulta por la API.
- En el conector: solo `GET`, token (se guarda solo el SHA-256), IPs permitidas en el conector y en - En el conector: solo `GET`, token (se guarda solo el SHA-256), IPs permitidas en el conector y en
el firewall de Windows, tablas/campos permitidos y denegados (sueldos, claves), sin rutas ni SQL el firewall de Windows, tablas/campos permitidos y denegados (sueldos, claves), sin rutas ni SQL
libres, límites de peticiones/concurrencia/página/tiempo, log sin tokens ni valores buscados. libres, límites de peticiones/concurrencia/página/tiempo, log sin tokens ni valores buscados.
@@ -114,6 +120,12 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
del conector por código de proveedor; sin datos personales en Git). En dev: 9 proveedores + 2 que ya 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: 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`). `137+031 142+033 809 234 040 794 817 565 540 405 676` (`--position=MEDICO`).
- **Médico nuevo** (desde 2026-10-29): **Organización → Empleados → "Agregar desde proveedores"**
(admin/superadmin): buscar por nombre, apellido o cédula en la fuente "Proveedores"
(Configuración → Conexiones externas, en dev: `clientes/PROVEDOR`), elegir, confirmar apellidos,
nombres y cargo (por defecto MEDICO) y guardar como empleado (`origin=provider`). Marca "Ya es
empleado" a los existentes. Luego, si entra al sistema: Nuevo usuario → Desde nómina, y Personal de
la Agenda. Los 11 médicos ya cargados se mantienen. El script queda para cargas masivas.
- **Protección**: un empleado con usuario en el sistema (p. ej. médico) **no se actualiza ni se - **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. 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 - **Agenda**: en modo importar se busca entre los importados (también por nombre de origen); en modo
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.12.0", "version": "4.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.12.0", "version": "4.13.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.12.0", "version": "4.13.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+12
View File
@@ -674,6 +674,12 @@ export const messages = {
int_sources: 'Fuentes de datos', int_sources: 'Fuentes de datos',
int_purpose_patients: 'Pacientes (Agenda médica)', int_purpose_patients: 'Pacientes (Agenda médica)',
int_purpose_employees: 'Empleados (nómina)', int_purpose_employees: 'Empleados (nómina)',
int_purpose_providers: 'Proveedores (médicos que facturan)',
emp_from_provider: 'Agregar desde proveedores',
emp_provider_search: 'Buscar por nombre, apellido o cédula (mínimo 3 letras)',
emp_provider_pick: 'Revisa apellidos y nombres: en el origen el orden no siempre es el mismo.',
emp_already: 'Ya es empleado',
emp_no_provider_source: 'No hay fuente de proveedores configurada (Configuración → Conexiones externas).',
int_not_configured: 'Sin configurar', int_not_configured: 'Sin configurar',
int_configure: 'Configurar', int_configure: 'Configurar',
int_connection: 'Conexión', int_connection: 'Conexión',
@@ -1431,6 +1437,12 @@ export const messages = {
int_sources: 'Data sources', int_sources: 'Data sources',
int_purpose_patients: 'Patients (Medical schedule)', int_purpose_patients: 'Patients (Medical schedule)',
int_purpose_employees: 'Employees (payroll)', int_purpose_employees: 'Employees (payroll)',
int_purpose_providers: 'Suppliers (billing doctors)',
emp_from_provider: 'Add from suppliers',
emp_provider_search: 'Search by name, last name or ID (at least 3 letters)',
emp_provider_pick: 'Check last and first names: the source does not always use the same order.',
emp_already: 'Already an employee',
emp_no_provider_source: 'No supplier source configured (Settings → External connections).',
int_not_configured: 'Not configured', int_not_configured: 'Not configured',
int_configure: 'Configure', int_configure: 'Configure',
int_connection: 'Connection', int_connection: 'Connection',
@@ -20,9 +20,10 @@ import './ExternalConnectionsPanel.css';
const TARGETS = { const TARGETS = {
patients: ['externalId', 'identification', 'fullName', 'phone', 'email'], patients: ['externalId', 'identification', 'fullName', 'phone', 'email'],
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'],
providers: ['externalId', 'identification', 'fullName', 'phone', 'email']
}; };
const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'] }; const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'], providers: ['externalId', 'fullName'] };
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 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 sourceForm = (purpose, s) => {
const [filterField = '', filterValue = ''] = Object.entries(s?.filter || {})[0] || []; const [filterField = '', filterValue = ''] = Object.entries(s?.filter || {})[0] || [];
@@ -139,7 +140,7 @@ export default function ExternalConnectionsPanel() {
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); } }]} /> 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> <div className="toolbar"><h3>{t('int_sources')}</h3></div>
<ListView getRowKey={(p) => p} rows={['patients', 'employees']} onRowClick={(p) => open('source', null, { purpose: p })} <ListView getRowKey={(p) => p} rows={['patients', 'employees', 'providers']} onRowClick={(p) => open('source', null, { purpose: p })}
columns={[ columns={[
{ key: 'purpose', label: t('int_source'), sortable: false, render: (p) => t(`int_purpose_${p}`) }, { 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: '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>; } },
@@ -1,24 +1,42 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, UserPlus } from 'lucide-react';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { InfoBar } from '../../components/InfoBar.jsx'; import { InfoBar } from '../../components/InfoBar.jsx';
import { ListView } from '../../components/ListView.jsx'; import { ListView } from '../../components/ListView.jsx';
import { SearchInput } from '../../components/SearchInput.jsx'; import { SearchInput } from '../../components/SearchInput.jsx';
import { Badge } from '../../components/Badge.jsx'; import { Badge } from '../../components/Badge.jsx';
import { listEmployees, listSources, syncEmployees } from '../../services/integrations.js'; import { listEmployees, listSources, syncEmployees, searchProviders, importProvider } from '../../services/integrations.js';
import { syncSummary } from '../Configuracion/ExternalConnectionsPanel.jsx'; import { syncSummary } from '../Configuracion/ExternalConnectionsPanel.jsx';
// Empleados importados de la nómina (Conexiones externas). Solo lectura aquí; "Actualizar // Empleados importados de la nómina (Conexiones externas). Solo lectura aquí; "Actualizar
// empleados" los vuelve a traer a pedido (admin y superadmin). // empleados" los vuelve a traer a pedido (admin y superadmin).
export default function EmployeesPanel() { export default function EmployeesPanel() {
const { t, pushToast } = useUi(); const { t, pushToast } = useUi();
const guardedClose = useGuardedClose();
// "Agregar desde proveedores": buscar en el origen, elegir y confirmar apellidos/nombres/cargo.
const [picking, setPicking] = useState(false), [provSearch, setProvSearch] = useState(''), [found, setFound] = useState([]), [picked, setPicked] = useState(null), [pickError, setPickError] = useState('');
const [providerSource, setProviderSource] = useState(undefined);
const searchSeq = useRef(0);
const words = picked ? picked.fullName.split(/\s+/) : [];
const draft = useDraftForm({
formId: 'employee-from-provider', entityId: picked?.code ?? null,
initial: picked ? { lastNames: words.slice(0, 2).join(' '), firstNames: words.slice(2).join(' '), position: 'MEDICO' } : {},
label: t('emp_from_provider'), enabled: !!picked,
onDiscard: () => setPicked(null)
});
const pv = draft.values;
const [rows, setRows] = useState(null), [source, setSource] = useState(undefined), [search, setSearch] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false); const [rows, setRows] = useState(null), [source, setSource] = useState(undefined), [search, setSearch] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false);
async function reload() { async function reload() {
try { try {
const [e, s] = await Promise.all([listEmployees(), listSources()]); const [e, s] = await Promise.all([listEmployees(), listSources()]);
setRows(e); setSource(s.find((x) => x.purpose === 'employees') || null); setError(''); setRows(e); setSource(s.find((x) => x.purpose === 'employees') || null); setProviderSource(s.find((x) => x.purpose === 'providers') || null); setError('');
} catch (err) { setError(err.message); } } catch (err) { setError(err.message); }
} }
useEffect(() => { void reload(); }, []); useEffect(() => { void reload(); }, []);
@@ -32,14 +50,41 @@ export default function EmployeesPanel() {
} catch (err) { setError(err.message); } finally { setBusy(false); } } catch (err) { setError(err.message); } finally { setBusy(false); }
} }
function searchProv(text) {
setProvSearch(text);
const seq = ++searchSeq.current;
if (text.trim().length < 3) { setFound([]); return; }
setTimeout(() => {
if (seq !== searchSeq.current) return;
searchProviders(text).then((r) => seq === searchSeq.current && setFound(r)).catch((e) => setPickError(e.message));
}, 400);
}
function openPicker() {
setPicking(true); setPicked(null); setProvSearch(''); setFound([]); setPickError('');
}
async function saveProvider() {
try {
await importProvider(picked.code, pv);
draft.clear();
setPicked(null);
setPicking(false);
await reload();
pushToast({ tone: 'success', message: t('common_saved') });
} catch (e) { setPickError(e.message); }
}
const closePicker = () => void guardedClose(picked ? draft : null, () => { setPicked(null); setPicking(false); });
if (!rows) return error ? <InfoBar tone="error">{error}</InfoBar> : <div className="text-muted">{t('common_loading')}</div>; if (!rows) return error ? <InfoBar tone="error">{error}</InfoBar> : <div className="text-muted">{t('common_loading')}</div>;
return <div> return <div>
{error && <InfoBar tone="error">{error}</InfoBar>} {error && <InfoBar tone="error">{error}</InfoBar>}
{source === null && <InfoBar tone="warning">{t('emp_no_source')}</InfoBar>} {source === null && <InfoBar tone="warning">{t('emp_no_source')}</InfoBar>}
<div className="toolbar"> <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> <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>
<div className="toolbar-left">
<Button icon={UserPlus} disabled={!providerSource} title={providerSource ? undefined : t('emp_no_provider_source')} onClick={openPicker}>{t('emp_from_provider')}</Button>
<Button variant="accent" icon={RefreshCw} disabled={busy || !source} onClick={() => void update()}>{t('emp_update')}</Button> <Button variant="accent" icon={RefreshCw} disabled={busy || !source} onClick={() => void update()}>{t('emp_update')}</Button>
</div> </div>
</div>
<p className="text-muted" style={{ fontSize: '.78rem', margin: '0 0 8px' }}>{t('emp_protected_hint')}</p> <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}`)}`} <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 }} initialSort={{ key: 'last_names', dir: 1 }}
@@ -53,5 +98,37 @@ export default function EmployeesPanel() {
{ 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: '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 || '—' } { key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' }
]} /> ]} />
<DetailPopup open={picking} title={t('emp_from_provider')} onClose={closePicker} width={900} height={620}>
<div className="emp-pick">
{pickError && <InfoBar tone="error">{pickError}</InfoBar>}
{!picked ? <>
<Field label={t('emp_provider_search')}><input className="input" autoFocus value={provSearch} onChange={(e) => searchProv(e.target.value)} /></Field>
{found.length > 0 && <ListView getRowKey={(p) => p.code} rows={found} onRowClick={(p) => { if (!p.employee_id) { setPicked(p); setPickError(''); } }}
columns={[
{ key: 'fullName', label: t('common_name') },
{ key: 'identification', label: t('int_target_identification'), render: (p) => p.identification || '—' },
{ key: 'phone', label: t('int_target_phone'), render: (p) => p.phone || '—' },
{ key: 'email', label: t('int_target_email'), render: (p) => p.email || '—' },
{ key: 'employee_id', label: t('common_status'), sortable: false, render: (p) => (p.employee_id ? <Badge color="muted">{t('emp_already')}</Badge> : '') }
]} />}
</> : <>
<DraftNotice draft={draft} />
<InfoBar tone="info">{picked.fullName} · {picked.identification} — {t('emp_provider_pick')}</InfoBar>
<FieldRow>
<Field label={t('int_target_lastNames')} dirty={draft.isFieldDirty('lastNames')}><input className="input" maxLength={120} value={pv.lastNames || ''} onChange={(e) => draft.setField('lastNames', e.target.value)} /></Field>
<Field label={t('int_target_firstNames')} dirty={draft.isFieldDirty('firstNames')}><input className="input" maxLength={120} value={pv.firstNames || ''} onChange={(e) => draft.setField('firstNames', e.target.value)} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('int_target_position')} dirty={draft.isFieldDirty('position')}><input className="input" maxLength={120} value={pv.position || ''} onChange={(e) => draft.setField('position', e.target.value)} /></Field>
<Field label={t('int_target_identification')}><input className="input" readOnly value={picked.identification || ''} /></Field>
</FieldRow>
<div className="toolbar-left" style={{ marginTop: 18 }}>
<Button variant="accent" disabled={!pv.lastNames?.trim() || !pv.firstNames?.trim()} onClick={() => void saveProvider()}>{t('common_save')}</Button>
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
</div>
</>}
</div>
</DetailPopup>
</div>; </div>;
} }
+2
View File
@@ -10,3 +10,5 @@ export const saveSource = (purpose, data) => api.put(`/integrations/sources/${pu
export const syncPatients = (full) => api.post('/integrations/sources/patients/sync', { full }); export const syncPatients = (full) => api.post('/integrations/sources/patients/sync', { full });
export const listEmployees = (available) => api.get(`/integrations/employees${available ? '?available=1' : ''}`); export const listEmployees = (available) => api.get(`/integrations/employees${available ? '?available=1' : ''}`);
export const syncEmployees = () => api.post('/integrations/employees/sync'); export const syncEmployees = () => api.post('/integrations/employees/sync');
export const searchProviders = (search) => api.get(`/integrations/providers${qs({ search })}`);
export const importProvider = (code, data) => api.post(`/integrations/providers/${encodeURIComponent(code)}/import`, data);
+3
View File
@@ -592,3 +592,6 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
.busy-spinner { width: 28px; height: 28px; flex: none; border-radius: 50%; border: 3px solid rgb(var(--accent) / .25); border-top-color: rgb(var(--accent)); animation: update-spin .9s linear infinite; } .busy-spinner { width: 28px; height: 28px; flex: none; border-radius: 50%; border: 3px solid rgb(var(--accent) / .25); border-top-color: rgb(var(--accent)); animation: update-spin .9s linear infinite; }
.busy-message { font-weight: 600; } .busy-message { font-weight: 600; }
.busy-hint { font-size: .78rem; margin-top: 2px; } .busy-hint { font-size: .78rem; margin-top: 2px; }
/* Organización → Empleados → Agregar desde proveedores */
.emp-pick { display: grid; gap: 12px; align-content: start; }
+1
View File
@@ -28,6 +28,7 @@ const DRAFT_FIELDS = {
'task-new': [], 'task-edit': ['editVersion'], 'task-new': [], 'task-edit': ['editVersion'],
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'], 'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
'user-new': ['roleId', 'departmentId', 'employeeId'], 'user-new': ['roleId', 'departmentId', 'employeeId'],
'employee-from-provider': ['lastNames', 'firstNames', 'position'],
'ext-connection': ['editVersion', 'name', 'baseUrl', 'isActive'], 'ext-connection': ['editVersion', 'name', 'baseUrl', 'isActive'],
'ext-source': ['editVersion', 'connectionId', 'sourceKey', 'tableName', 'mode', 'fieldMap', 'filterField', 'filterValue', 'changedField'], 'ext-source': ['editVersion', 'connectionId', 'sourceKey', 'tableName', 'mode', 'fieldMap', 'filterField', 'filterValue', 'changedField'],
'user-edit': ['editVersion', 'roleId', 'departmentId'], 'user-edit': ['editVersion', 'roleId', 'departmentId'],