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)
|
||||
- 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`
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
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=`
|
||||
(modo en vivo) y `POST /api/clinic/patients/external/:codigo/import`. `POST /api/users` acepta
|
||||
`employeeId` (Desde nómina).
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 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
|
||||
### Added
|
||||
- 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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
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 +
|
||||
autorregistro/aprobacion/SMTP.
|
||||
|
||||
@@ -14,6 +14,8 @@ autorregistro/aprobacion/SMTP.
|
||||
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).
|
||||
- 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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
) 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;
|
||||
-- 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;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "project-linkedcorp-backend",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
|
||||
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;
|
||||
|
||||
@@ -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 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'] }
|
||||
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';
|
||||
@@ -163,7 +164,7 @@ export async function saveSource(purpose, raw, user) {
|
||||
|
||||
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');
|
||||
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);
|
||||
if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada');
|
||||
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');
|
||||
}
|
||||
|
||||
// --- 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) ---
|
||||
|
||||
const patientKey = (src) => `src${src.id}`;
|
||||
|
||||
@@ -10,14 +10,16 @@ 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); }
|
||||
// Usa la fuente "Proveedores" de Conexiones externas (mismos campos que la pantalla Empleados →
|
||||
// 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 valid = (v) => v && !/^9+$/.test(v);
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
|
||||
|
||||
## 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
|
||||
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.
|
||||
@@ -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
|
||||
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`).
|
||||
- **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
|
||||
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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.4.16",
|
||||
"highlight.js": "^11.12.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "project-linkedcorp-frontend",
|
||||
"private": true,
|
||||
"version": "4.12.0",
|
||||
"version": "4.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -674,6 +674,12 @@ export const messages = {
|
||||
int_sources: 'Fuentes de datos',
|
||||
int_purpose_patients: 'Pacientes (Agenda médica)',
|
||||
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_configure: 'Configurar',
|
||||
int_connection: 'Conexión',
|
||||
@@ -1431,6 +1437,12 @@ export const messages = {
|
||||
int_sources: 'Data sources',
|
||||
int_purpose_patients: 'Patients (Medical schedule)',
|
||||
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_configure: 'Configure',
|
||||
int_connection: 'Connection',
|
||||
|
||||
@@ -20,9 +20,10 @@ import './ExternalConnectionsPanel.css';
|
||||
|
||||
const TARGETS = {
|
||||
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 sourceForm = (purpose, s) => {
|
||||
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); } }]} />
|
||||
|
||||
<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={[
|
||||
{ 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>; } },
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from '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 { 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 { listEmployees, listSources, syncEmployees, searchProviders, importProvider } 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 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);
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
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); }
|
||||
}
|
||||
useEffect(() => { void reload(); }, []);
|
||||
@@ -32,14 +50,41 @@ export default function EmployeesPanel() {
|
||||
} 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>;
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</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 }}
|
||||
@@ -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: '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>;
|
||||
}
|
||||
|
||||
@@ -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 listEmployees = (available) => api.get(`/integrations/employees${available ? '?available=1' : ''}`);
|
||||
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-message { font-weight: 600; }
|
||||
.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'],
|
||||
'department-new': ['parentId'], 'department-edit': ['editVersion', 'parentId'],
|
||||
'user-new': ['roleId', 'departmentId', 'employeeId'],
|
||||
'employee-from-provider': ['lastNames', 'firstNames', 'position'],
|
||||
'ext-connection': ['editVersion', 'name', 'baseUrl', 'isActive'],
|
||||
'ext-source': ['editVersion', 'connectionId', 'sourceKey', 'tableName', 'mode', 'fieldMap', 'filterField', 'filterValue', 'changedField'],
|
||||
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||
|
||||
Reference in New Issue
Block a user