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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 →
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user