diff --git a/AGENTS.md b/AGENTS.md index 6ddd8ae..95bf380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/API.md b/API.md index e4e8607..0d07e7f 100644 --- a/API.md +++ b/API.md @@ -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). diff --git a/CHANGELOG.md b/CHANGELOG.md index 54dfb8b..ff370e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 → diff --git a/DECISIONES.md b/DECISIONES.md index 5bee183..641d107 100644 --- a/DECISIONES.md +++ b/DECISIONES.md @@ -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 | diff --git a/ESTADO.md b/ESTADO.md index c3ee779..ff0463c 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -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 diff --git a/VERSION b/VERSION index 815588e..813b83b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.12.0 +4.13.0 diff --git a/backend/database/migrations/038_integrations.sql b/backend/database/migrations/038_integrations.sql index b19dadf..21f53f7 100644 --- a/backend/database/migrations/038_integrations.sql +++ b/backend/database/migrations/038_integrations.sql @@ -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; diff --git a/backend/package-lock.json b/backend/package-lock.json index 68a65d2..2473e26 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 4072f56..6c3e741 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.12.0", + "version": "4.13.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/integrations/routes.js b/backend/src/modules/integrations/routes.js index c555caa..f9fa52c 100644 --- a/backend/src/modules/integrations/routes.js +++ b/backend/src/modules/integrations/routes.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; diff --git a/backend/src/modules/integrations/service.js b/backend/src/modules/integrations/service.js index 2be9ef6..6115ace 100644 --- a/backend/src/modules/integrations/service.js +++ b/backend/src/modules/integrations/service.js @@ -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}`; diff --git a/backend/src/scripts/import-provider-employees.js b/backend/src/scripts/import-provider-employees.js index 16412db..2fda374 100644 --- a/backend/src/scripts/import-provider-employees.js +++ b/backend/src/scripts/import-provider-employees.js @@ -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); diff --git a/docs/CONEXIONES-EXTERNAS.md b/docs/CONEXIONES-EXTERNAS.md index 2258669..a3034de 100644 --- a/docs/CONEXIONES-EXTERNAS.md +++ b/docs/CONEXIONES-EXTERNAS.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eb9a5cd..08540b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ec12a4e..f47f84f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.12.0", + "version": "4.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 5bb0718..f98d37f 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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', diff --git a/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx b/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx index a8f8ac7..362395f 100644 --- a/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx +++ b/frontend/src/pages/Configuracion/ExternalConnectionsPanel.jsx @@ -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); } }]} />

{t('int_sources')}

- p} rows={['patients', 'employees']} onRowClick={(p) => open('source', null, { purpose: 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}` : {t('int_not_configured')}; } }, diff --git a/frontend/src/pages/Organizacion/EmployeesPanel.jsx b/frontend/src/pages/Organizacion/EmployeesPanel.jsx index 391c7e1..19d1465 100644 --- a/frontend/src/pages/Organizacion/EmployeesPanel.jsx +++ b/frontend/src/pages/Organizacion/EmployeesPanel.jsx @@ -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,13 +50,40 @@ 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 ? {error} :
{t('common_loading')}
; return
{error && {error}} {source === null && {t('emp_no_source')}}
{t('int_last_sync')}: {syncSummary(t, source?.last_result)}
- +
+ + +

{t('emp_protected_hint')}

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}`)}`} @@ -53,5 +98,37 @@ export default function EmployeesPanel() { { key: 'is_active', label: t('common_status'), render: (e) => {t(e.is_active ? 'common_active' : 'common_inactive')} }, { key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' } ]} /> + + +
+ {pickError && {pickError}} + {!picked ? <> + searchProv(e.target.value)} /> + {found.length > 0 && 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 ? {t('emp_already')} : '') } + ]} />} + : <> + + {picked.fullName} · {picked.identification} — {t('emp_provider_pick')} + + draft.setField('lastNames', e.target.value)} /> + draft.setField('firstNames', e.target.value)} /> + + + draft.setField('position', e.target.value)} /> + + +
+ + +
+ } +
+
; } diff --git a/frontend/src/services/integrations.js b/frontend/src/services/integrations.js index d69df20..85715e6 100644 --- a/frontend/src/services/integrations.js +++ b/frontend/src/services/integrations.js @@ -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); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index ee6b46b..249b5e3 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; } diff --git a/frontend/src/utils/drafts.js b/frontend/src/utils/drafts.js index 17441ae..7d022ab 100644 --- a/frontend/src/utils/drafts.js +++ b/frontend/src/utils/drafts.js @@ -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'],