Compare commits
8 Commits
fabe5b530d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 053f51afe7 | |||
| 420a794c9e | |||
| f5721f95a9 | |||
| 34183d15ce | |||
| b2c25c4fe5 | |||
| 0968e61ced | |||
| d85a7196f0 | |||
| 0ee9ed65bf |
@@ -82,8 +82,10 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- Notificaciones in-app
|
- Notificaciones in-app
|
||||||
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
|
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
|
||||||
- Configuración (catálogos: estados, prioridades, tipos de proyecto)
|
- Configuración (catálogos: estados, prioridades, tipos de proyecto)
|
||||||
- Conexiones externas (en curso): datos de otros sistemas vía conectores de solo lectura en el
|
- Conexiones externas: datos de otros sistemas vía conectores de solo lectura en el servidor origen
|
||||||
servidor origen (`data/conectores/`, primero FoxPro) — ver `docs/CONEXIONES-EXTERNAS.md`
|
(`data/conectores/`, FoxPro instalado en Exilaser), pacientes de SAGA y empleados de nómina —
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ campo en multipart. La API responde `428` si falta o es inválido y `409` si otr
|
|||||||
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
||||||
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
|
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
|
||||||
|
|
||||||
|
## Conexiones externas
|
||||||
|
|
||||||
|
`/api/integrations` (JWT). Superadmin: `GET/POST /connections`, `PUT /connections/:id`,
|
||||||
|
`POST /connections/:id/test`, `GET /connections/:id/browse?source=&table=` (fuentes, tablas,
|
||||||
|
estructura + 20 filas), `PUT /sources/:purpose` (`patients`/`employees`, valida el mapeo contra la
|
||||||
|
estructura real), `POST /sources/patients/sync {full}`. Admin y superadmin: `GET /sources` (el admin
|
||||||
|
solo ve estado), `GET /employees[?available=1]`, `POST /employees/sync`, `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).
|
||||||
|
|
||||||
## Agenda médica (v4.7.0)
|
## Agenda médica (v4.7.0)
|
||||||
|
|
||||||
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
|
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
|
||||||
@@ -218,10 +230,14 @@ se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar
|
|||||||
- `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware,
|
- `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware,
|
||||||
red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes, editVersion }`.
|
red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes, editVersion }`.
|
||||||
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
- `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` ·
|
||||||
`GET /api/devices/software/devices?name=` — software transversal.
|
`GET /api/devices/software/devices?name=` — software transversal. Cada programa trae `license_type`
|
||||||
|
(`free`/`paid`/`personal_free`/`component`/`null`), `license_manual`, `vendor` y `group`.
|
||||||
|
- `PUT /api/devices/software/classification` — `{ name, licenseType }`: corrige el tipo de un programa
|
||||||
|
en todos los equipos; `licenseType` vacío vuelve a las reglas. Roles que editan equipos; audita `classify_software`.
|
||||||
- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de
|
- `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de
|
||||||
dashboard/listado/detalle/software) también para usuarios con acceso compartido.
|
dashboard/listado/detalle/software) también para usuarios con acceso compartido.
|
||||||
- `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin).
|
- `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin).
|
||||||
|
- `POST /api/devices/:id/cpu-year` → equipo con `cpu_year`: la IA consulta solo el nombre del procesador y devuelve el año de lanzamiento (el más antiguo si hay dos). Roles que editan equipos; `400` sin procesador o IA sin configurar, `422` si no se pudo determinar; 30/hora.
|
||||||
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
|
- `GET /api/devices/:id/raw` — último inventario crudo del agente.
|
||||||
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
|
- `GET /api/devices/tokens/:id/secret` — token en claro (descifrado) para armar el instalador; audita `reveal`.
|
||||||
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
|
- `GET/POST /api/devices/tokens` · `PATCH/DELETE /api/devices/tokens/:id` — tokens de ingesta
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de
|
|||||||
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
|
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
|
||||||
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
|
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
|
||||||
|
|
||||||
|
## Conexiones externas (migración 038)
|
||||||
|
|
||||||
|
`038_integrations.sql` (aplicada en dev 2026-10-29): `external_connections` (URL, token cifrado
|
||||||
|
AES-256-GCM), `external_sources` (una por destino `patients`/`employees`: tabla, modo, mapeo,
|
||||||
|
filtro, campo de cambios y estado de sincronización: `last_max_recno`, `last_changed_value`),
|
||||||
|
`employees` (importados de nómina; `user_id` único al crear el usuario desde nómina) y columnas en
|
||||||
|
`clinic_patients`: `external_source`, `external_id` (único con la fuente), `source_name`,
|
||||||
|
`name_split`, `synced_at`. Ver `docs/CONEXIONES-EXTERNAS.md`.
|
||||||
|
|
||||||
## Agenda médica (migración 036, v4.7.0)
|
## Agenda médica (migración 036, v4.7.0)
|
||||||
|
|
||||||
`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
|
`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
|
||||||
@@ -74,6 +83,7 @@ desde la UI. No hay migración aplicada ni conexión a BD en este cambio.
|
|||||||
| `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` |
|
| `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` |
|
||||||
| `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte |
|
| `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte |
|
||||||
| `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) |
|
| `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) |
|
||||||
|
| `software_classifications` | tipo de licencia corregido a mano por programa (`name`, `license_type`, `updated_by`); gana sobre las reglas (migración `040`) |
|
||||||
| `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) |
|
| `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) |
|
||||||
| `system_settings` (IA) | `ai_api_key_enc` (AES-256-GCM), `ai_model`, `ai_edit_version` (migración 031) |
|
| `system_settings` (IA) | `ai_api_key_enc` (AES-256-GCM), `ai_model`, `ai_edit_version` (migración 031) |
|
||||||
| `ai_usage` | consumo por llamada a la IA (`user_id`, `action`, `model`, `input_tokens`, `output_tokens`, `cost_usd` estimado, `created_at`) (migración 032) |
|
| `ai_usage` | consumo por llamada a la IA (`user_id`, `action`, `model`, `input_tokens`, `output_tokens`, `cost_usd` estimado, `created_at`) (migración 032) |
|
||||||
@@ -264,3 +274,5 @@ en producción.
|
|||||||
## Cambio de correo (migración 035, no aplicada)
|
## Cambio de correo (migración 035, no aplicada)
|
||||||
|
|
||||||
`email_change_requests` guarda usuario, correo anterior/nuevo, estado, decisión, motivo y fechas. Los tokens de aprobación y confirmación se guardan solo como hash SHA-256. Índices únicos sobre columnas generadas impiden más de una solicitud abierta por usuario o correo nuevo; el cotejamiento `utf8mb4_unicode_ci` evita duplicados por mayúsculas. `notifications.entity_type` agrega `email_change_request` y conserva `project`, `activity` y `meeting`. El migrador reejecuta todos los archivos; la tabla se crea con `IF NOT EXISTS`.
|
`email_change_requests` guarda usuario, correo anterior/nuevo, estado, decisión, motivo y fechas. Los tokens de aprobación y confirmación se guardan solo como hash SHA-256. Índices únicos sobre columnas generadas impiden más de una solicitud abierta por usuario o correo nuevo; el cotejamiento `utf8mb4_unicode_ci` evita duplicados por mayúsculas. `notifications.entity_type` agrega `email_change_request` y conserva `project`, `activity` y `meeting`. El migrador reejecuta todos los archivos; la tabla se crea con `IF NOT EXISTS`.
|
||||||
|
|
||||||
|
- `devices.cpu_year` / `cpu_year_for` (migración `039`): año del procesador consultado a la IA (solo por el nombre del procesador); se ignora si el procesador reportado cambia.
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [4.16.1] - 2026-10-30
|
||||||
|
### Changed
|
||||||
|
- Equipos → Software: los programas sin subniveles llevan un punto y quedan alineados con los
|
||||||
|
grupos por fabricante; los programas desplegados van más adentro.
|
||||||
|
|
||||||
|
## [4.16.0] - 2026-10-30
|
||||||
|
### Added
|
||||||
|
- Equipos → Software (detalle del equipo y pestaña general): columna "Tipo" y filtro con Gratuito,
|
||||||
|
Pago, Gratis solo personal, Componente o incluido y Sin clasificar. Clasificador por reglas de
|
||||||
|
nombre y fabricante, sin IA ni consultas externas (`softwareClassifier.js`). Es orientativo y no
|
||||||
|
detecta software sin licencia. Quien edita equipos puede corregir el tipo de un programa desde
|
||||||
|
el menú de la fila; vale para todos los equipos (migración `040`).
|
||||||
|
- Software agrupado por fabricante desde 2 programas, con fila desplegable (`ListView` admite
|
||||||
|
`getChildren`). Windows, Office y otros productos Microsoft con licencia quedan fuera del grupo.
|
||||||
|
El grupo cerrado muestra el tipo de mayor riesgo y la búsqueda abre los que coinciden.
|
||||||
|
### Changed
|
||||||
|
- Las apps de la tienda de Microsoft y los paquetes que vienen con Windows ahora cuentan como
|
||||||
|
componentes: se ocultan con el selector apagado.
|
||||||
|
### Fixed
|
||||||
|
- Los menús `Flyout` quedaban detrás de los popups y diálogos.
|
||||||
|
|
||||||
|
## [4.15.1] - 2026-10-30
|
||||||
|
### Fixed
|
||||||
|
- La 4.15.0 se publicó con `VERSION` en 4.15.0 pero los `package.json` en 4.14.0: el frontend se
|
||||||
|
compilaba como 4.14.0 y el actualizador se quedaba en "Comprobando ambas versiones…" (95 %).
|
||||||
|
Versiones alineadas con `scripts/set-version.sh`. Sin cambios de código.
|
||||||
|
|
||||||
|
## [4.15.0] - 2026-10-30
|
||||||
|
### Added
|
||||||
|
- Equipos → detalle: pestaña "Licenciamiento" (antes de Historial) con Windows (edición, Product ID,
|
||||||
|
clave parcial, canal OEM o Retail/Volumen) y Office (licencias que reporta el agente con canal,
|
||||||
|
Product ID y clave parcial; aviso si es MSDN) más el Office instalado. La clave se guarda solo con
|
||||||
|
los últimos 5 caracteres. Los equipos ya inventariados se completan desde su último reporte crudo
|
||||||
|
al abrir el detalle. No indica si la licencia está activada.
|
||||||
|
|
||||||
|
## [4.14.0] - 2026-10-29
|
||||||
|
### Added
|
||||||
|
- Equipos → detalle → Hardware: fila "Año del procesador" con botón "Obtener fecha": la IA consulta
|
||||||
|
**solo el nombre del procesador** (nada del equipo ni del usuario) y devuelve el año de
|
||||||
|
lanzamiento; si hay dos fechas, el más antiguo. Se valida el rango antes de guardar, queda en
|
||||||
|
auditoría y en el consumo de IA, y el año se descarta si el equipo cambia de procesador. Solo
|
||||||
|
roles que editan equipos; 30 consultas/hora. Migración `039` (`devices.cpu_year`, `cpu_year_for`).
|
||||||
|
|
||||||
|
## [4.13.1] - 2026-10-29
|
||||||
|
### Changed
|
||||||
|
- Equipos → listado: nueva columna "Asignado a" después de Nombre (usuario asignado a mano; "—" si no hay).
|
||||||
|
|
||||||
|
## [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 →
|
||||||
|
Conexiones externas (superadmin) con conexiones a conectores (token cifrado, Probar), fuentes de
|
||||||
|
Pacientes y Empleados (vista previa de 20 filas, mapeo de campos, filtro, modo importar/en vivo).
|
||||||
|
- Pacientes desde SAGA: sincronización completa lunes a viernes 05:00 y solo cambios cada 15 min
|
||||||
|
(06:00–21:00); vínculo por cédula (manda SAGA); nombre de SAGA guardado entero hasta que recepción
|
||||||
|
lo separe. En modo en vivo la Agenda busca en SAGA y trae solo el paciente elegido.
|
||||||
|
- Organización → Empleados (admin/superadmin) con "Actualizar empleados" (a pedido); Nuevo usuario →
|
||||||
|
Desde nómina (precarga nombre, cargo, departamento, correo y vincula). Empleados con usuario no se
|
||||||
|
actualizan ni inactivan automáticamente.
|
||||||
|
- Médicos que facturan como proveedores de SAGA cargados como empleados (`origin=provider`) con
|
||||||
|
`backend/src/scripts/import-provider-employees.js` (lee del conector por código).
|
||||||
|
- Conector FoxPro 1.1.0: sincronización incremental (`afterRecno`, `changedField/changedSince`),
|
||||||
|
recarga mínima de 60 s, fechas vacías como `null`, tabla de proveedores (sin datos bancarios).
|
||||||
|
### Fixed
|
||||||
|
- Revisión de código: lotes con códigos repetidos, lectura paginada reiniciada si el origen cambia,
|
||||||
|
usuario no queda huérfano si falla el vínculo con el empleado, auditoría al traer un paciente,
|
||||||
|
fuente con conexión desactivada, campo de cambios solo fecha-hora.
|
||||||
|
|
||||||
## [4.11.0] - 2026-10-29
|
## [4.11.0] - 2026-10-29
|
||||||
### Added
|
### Added
|
||||||
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
|
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
|
||||||
|
|||||||
@@ -13,3 +13,9 @@
|
|||||||
| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
|
| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
|
||||||
| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
|
| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
|
||||||
| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
|
| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
|
||||||
|
| 8 | 2026-10-29 | Pacientes: con conexión activa se pueden crear a mano; al vincular por cédula **manda SAGA** | Pedido del usuario: el paciente puede llegar antes de estar en SAGA, y SAGA es la fuente oficial | Solo crear si no está en SAGA; mandan los datos locales | vigente |
|
||||||
|
| 9 | 2026-10-29 | Sincronización **incremental** (agregados por `_recno` + modificados por `FECHAACT`) y recarga mínima de 60 s en el conector; completa periódica para borrados | `clientes.DBF` ~20 MB y SAGA lo escribe todo el día | Copiar y enviar toda la tabla en cada consulta | vigente |
|
||||||
|
| 10 | 2026-10-29 | Nombre de SAGA (`CLINOM`) **no se separa automáticamente**: se guarda entero y recepción lo separa al editar; desde ahí la sincronización no lo pisa | SAGA no respeta orden apellidos/nombres | Separar por las 2 primeras palabras | vigente |
|
||||||
|
| 11 | 2026-10-29 | Sincronización completa de pacientes **lunes a viernes 05:00**; cambios cada 15 min de 06:00 a 21:00; empleados solo a pedido | Pedido del usuario: carga principal + solo cambios; recarga total temprano | Recarga total frecuente | vigente |
|
||||||
|
| 12 | 2026-10-29 | Médicos: se toman de proveedores de SAGA (y de nómina) como empleados `origin=provider`; los empleados con usuario no se actualizan ni inactivan automáticamente | Casi todos los médicos facturan, no están en nómina; lo que tiene usuario lo administra la clínica | Crear médicos sin vínculo con SAGA | vigente |
|
||||||
|
| 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,23 +1,37 @@
|
|||||||
# ESTADO — project.linkedcorp.net
|
# ESTADO — project.linkedcorp.net
|
||||||
|
|
||||||
Ultima actualizacion: 2026-10-29 (v4.11.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.
|
||||||
|
|
||||||
## Conexiones externas (2026-10-29, en curso, sin commit)
|
## Conexiones externas (v4.12.0, en dev, sin desplegar)
|
||||||
|
|
||||||
- Diseño y requisitos acordados en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 en `DECISIONES.md`.
|
- v4.16.0 (2026-10-30): Software con tipo de licencia, grupos por fabricante y corrección manual;
|
||||||
- Hecho: conector FoxPro 1.0.0 en `data/conectores/foxpro/` (API solo lectura, contrato v1 en
|
migración `040` aplicada en dev. Backend probado contra la BD de dev; pantalla probada con API
|
||||||
`data/conectores/README.md`). `npm test` 16/16 OK con DBF temporal. Sin probar con tablas reales
|
simulada (sin login real) y **falta probarla con un usuario real**. Desplegada en LinkedCorp `.3`
|
||||||
de SAGA ni los `.ps1` en Windows.
|
el 2026-10-30 (ver `PRODUCCION.md`). Exilaser se actualiza por su panel.
|
||||||
- Siguiente: con permiso del usuario, revisar por SSH `192.168.0.253` las carpetas de nómina y
|
- Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea
|
||||||
clientes; instalar en `C:\api_project\foxpro`; luego el módulo del sistema.
|
`LinkedCorp-Conector-FoxPro`). Token en `~/.config/linkedcorp/conector-foxpro-exilaser.token`.
|
||||||
|
- **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job
|
||||||
|
`integrationSync`, Configuración → Conexiones externas, Organización → Empleados, Nuevo usuario →
|
||||||
|
Desde nómina, pacientes de SAGA en la Agenda. Dev quedó con la conexión "SAGA Exilaser", 31
|
||||||
|
empleados y 31 220 pacientes importados (modo importar). Probado por API y navegador (usuario
|
||||||
|
superadmin temporal, ya borrado), sin errores de consola.
|
||||||
|
- Base de dev **sin datos de prueba de la Agenda** (borrados 2026-10-29).
|
||||||
|
- 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
|
||||||
|
desplegar 4.12.0 (migraciones 028–038), crear la conexión con el mismo token, las fuentes, y correr
|
||||||
|
el script con los mismos códigos. El firewall del Windows ya permite a `192.168.222.3`.
|
||||||
|
- Detalle en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 y #8–#11 en `DECISIONES.md`.
|
||||||
|
|
||||||
## Agenda médica (v4.7.0, sin desplegar)
|
## Agenda médica (v4.7.0, sin desplegar)
|
||||||
|
|
||||||
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
|
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
|
||||||
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
|
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
|
||||||
- 2026-10-29: BD de dev con datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
|
- (borrados el 2026-10-29) BD de dev tuvo datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
|
||||||
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
|
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
|
||||||
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
|
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
|
||||||
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
|
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
|
||||||
|
|||||||
@@ -624,3 +624,15 @@ sudo journalctl -u project-linkedcorp-frontend -n 100 --no-pager
|
|||||||
- No dejar secretos en el repo (`.env` de produccion vive solo en `backend/.env` del servidor).
|
- No dejar secretos en el repo (`.env` de produccion vive solo en `backend/.env` del servidor).
|
||||||
- No reemplazar `storage/` ni `logs/` durante un deploy.
|
- No reemplazar `storage/` ni `logs/` durante un deploy.
|
||||||
- Documentar cambios de version y estado real en este archivo y en `CHANGELOG.md`.
|
- Documentar cambios de version y estado real en este archivo y en `CHANGELOG.md`.
|
||||||
|
|
||||||
|
## Actualizacion v4.16.0 (2026-10-30) — salto desde v2.19.0
|
||||||
|
|
||||||
|
- Servidor `.3`, salto de 2.19.0 a 4.16.0 (commit `420a794`). `migrate-db.js` aplico las 40
|
||||||
|
migraciones; la BD paso de 36 a 55 tablas. Conteos iguales antes y despues: 3 usuarios,
|
||||||
|
12 proyectos, 38 actividades, 186 tareas.
|
||||||
|
- Antes de migrar se confirmo el marcador `seed_it_baseline_2026_09`: sin el, `006` borraria proyectos.
|
||||||
|
- Respaldos: `backups/pre_v4.16.0_20261030_173843.sql` y `backups/dist_2.19.0_20261030_173843/`.
|
||||||
|
- Reversion: restaurar ese SQL, `git reset --hard 8ed1d63` en `source/`, reinstalar dependencias,
|
||||||
|
recompilar frontend y reiniciar los dos servicios.
|
||||||
|
- PID cambiados y `/health` OK. Publico: `version.json` 4.16.0, login sin errores de consola.
|
||||||
|
- Las sesiones anteriores quedan invalidas (JWT sin `sid`): todos inician sesion una vez.
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
en el detalle. `DATETIME` representa hora civil `America/Guayaquil`.
|
en el detalle. `DATETIME` representa hora civil `America/Guayaquil`.
|
||||||
- El calendario personal muestra al médico sus citas activas y bloqueos en la capa Citas médicas.
|
- El calendario personal muestra al médico sus citas activas y bloqueos en la capa Citas médicas.
|
||||||
|
|
||||||
|
### 7 ter. Conexiones externas
|
||||||
|
- Datos de otros sistemas por conectores de solo lectura (contrato en `data/conectores/README.md`).
|
||||||
|
Requisitos, decisiones y funcionamiento en `docs/CONEXIONES-EXTERNAS.md`: pacientes desde SAGA
|
||||||
|
(importar o en vivo; completa L-V 05:00 + cambios cada 15 min) y empleados de nómina a pedido, con
|
||||||
|
alta de usuario desde nómina.
|
||||||
|
|
||||||
### 8. Gantt
|
### 8. Gantt
|
||||||
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
|
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
|
||||||
desde el gráfico en el MVP.
|
desde el gráfico en el MVP.
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Conexiones externas (conectores de solo lectura, contrato v1 en data/conectores/README.md),
|
||||||
|
-- empleados importados de nómina y vínculo de pacientes con el sistema origen. Idempotente.
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_connections (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
base_url VARCHAR(255) NOT NULL,
|
||||||
|
token_encrypted TEXT NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ext_conn_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Una fuente por destino: qué tabla del conector alimenta Pacientes o Empleados, cómo (importar o
|
||||||
|
-- consultar en vivo), el mapeo de campos y el estado de la última sincronización.
|
||||||
|
CREATE TABLE IF NOT EXISTS external_sources (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
purpose ENUM('patients','employees') NOT NULL UNIQUE,
|
||||||
|
connection_id INT NOT NULL,
|
||||||
|
source_key VARCHAR(64) NOT NULL,
|
||||||
|
table_name VARCHAR(64) NOT NULL,
|
||||||
|
mode ENUM('import','live') NOT NULL DEFAULT 'import',
|
||||||
|
field_map_json TEXT NOT NULL,
|
||||||
|
filter_json TEXT NULL,
|
||||||
|
changed_field VARCHAR(64) NULL,
|
||||||
|
last_full_sync_at DATETIME NULL,
|
||||||
|
last_sync_at DATETIME NULL,
|
||||||
|
last_max_recno INT UNSIGNED NULL,
|
||||||
|
last_changed_value VARCHAR(40) NULL,
|
||||||
|
last_result_json TEXT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ext_source_conn FOREIGN KEY (connection_id) REFERENCES external_connections(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
identification VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
last_names VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
first_names VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(40) NULL,
|
||||||
|
phone2 VARCHAR(40) NULL,
|
||||||
|
birth_date DATE NULL,
|
||||||
|
position VARCHAR(120) NULL,
|
||||||
|
department_name VARCHAR(120) NULL,
|
||||||
|
hire_date DATE NULL,
|
||||||
|
leave_date DATE NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
in_source TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
user_id INT NULL UNIQUE,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_employee_name (last_names, first_names),
|
||||||
|
CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 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;
|
||||||
|
ALTER TABLE employees ADD COLUMN IF NOT EXISTS email VARCHAR(160) NULL AFTER phone2;
|
||||||
|
|
||||||
|
-- Pacientes vinculados al origen (SAGA): source_name guarda el nombre tal como viene (SAGA no
|
||||||
|
-- separa nombres y apellidos ni respeta un orden); mientras recepción no lo separe a mano, el
|
||||||
|
-- nombre visible es ese. Manda el origen en identificación/teléfono/correo/nombre de origen.
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_source VARCHAR(20) NULL AFTER notes;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_id VARCHAR(40) NULL AFTER external_source;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS source_name VARCHAR(160) NULL AFTER external_id;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS name_split TINYINT(1) NOT NULL DEFAULT 1 AFTER source_name;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS synced_at DATETIME NULL AFTER name_split;
|
||||||
|
ALTER TABLE clinic_patients MODIFY phone VARCHAR(40) NOT NULL DEFAULT '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_clinic_patient_external ON clinic_patients (external_source, external_id);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Año de lanzamiento del procesador (el agente GLPI no lo reporta): lo consulta la IA solo con el
|
||||||
|
-- nombre del procesador. cpu_year_for guarda el cpu_summary usado, para descartar el año si el
|
||||||
|
-- procesador del equipo cambia. Idempotente.
|
||||||
|
ALTER TABLE devices
|
||||||
|
ADD COLUMN IF NOT EXISTS cpu_year SMALLINT UNSIGNED NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS cpu_year_for VARCHAR(255) NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Tipo de licencia de un programa corregido a mano (gratuito, pago, gratis solo personal o
|
||||||
|
-- componente). Vale para todos los equipos que lo tengan y gana sobre el clasificador por reglas
|
||||||
|
-- (softwareClassifier.js), que no se guarda: se calcula al leer. Idempotente.
|
||||||
|
CREATE TABLE IF NOT EXISTS software_classifications (
|
||||||
|
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
license_type VARCHAR(20) NOT NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_software_classifications_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.11.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.11.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.11.0",
|
"version": "4.16.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import inventoryIngestRoutes from './modules/devices/ingest.js';
|
|||||||
import aiRoutes from './modules/ai/routes.js';
|
import aiRoutes from './modules/ai/routes.js';
|
||||||
import meetingsRoutes from './modules/meetings/routes.js';
|
import meetingsRoutes from './modules/meetings/routes.js';
|
||||||
import clinicRoutes from './modules/clinic/routes.js';
|
import clinicRoutes from './modules/clinic/routes.js';
|
||||||
|
import integrationRoutes from './modules/integrations/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ app.use('/api/devices', devicesRoutes);
|
|||||||
app.use('/api/ai', aiRoutes);
|
app.use('/api/ai', aiRoutes);
|
||||||
app.use('/api/meetings', meetingsRoutes);
|
app.use('/api/meetings', meetingsRoutes);
|
||||||
app.use('/api/clinic', clinicRoutes);
|
app.use('/api/clinic', clinicRoutes);
|
||||||
|
app.use('/api/integrations', integrationRoutes);
|
||||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { scheduledPatientSync } from '../modules/integrations/service.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Pacientes desde el sistema origen (modo importar): completa lunes a viernes a las 05:00 y
|
||||||
|
// incremental cada 15 min de 06:00 a 21:00 (ver integrations/service.scheduledPatientSync).
|
||||||
|
// Mismo patrón que los demás jobs: dentro del proceso del backend, sin cron del SO.
|
||||||
|
export function startIntegrationSyncJob() {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const result = await scheduledPatientSync();
|
||||||
|
if (result) console.log(`[integrations] pacientes ${result.full ? 'completa' : 'incremental'}: ${result.received} recibidos, ${result.created} nuevos, ${result.updated} actualizados, ${result.deactivated} inactivados`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 409) console.error('[integrations] error sincronizando pacientes:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(run, 30000);
|
||||||
|
setInterval(run, CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { requireAuth } from '../../middleware/auth.js';
|
|||||||
import { requireModule } from '../../middleware/modules.js';
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { asyncHandler, ok } from '../../utils/http.js';
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
import * as clinic from './service.js';
|
import * as clinic from './service.js';
|
||||||
|
import * as integrations from '../integrations/service.js';
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth, requireModule('clinic'));
|
router.use(requireAuth, requireModule('clinic'));
|
||||||
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
|
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
|
||||||
@@ -46,6 +47,11 @@ write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, sta
|
|||||||
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
|
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
|
||||||
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
|
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
|
||||||
get('/patients', (q, s) => clinic.patients(q, s));
|
get('/patients', (q, s) => clinic.patients(q, s));
|
||||||
|
// Pacientes del sistema origen (Conexiones externas): estado de la fuente, búsqueda en vivo e
|
||||||
|
// importación del elegido. Solo recepción (gestiona pacientes).
|
||||||
|
get('/patients/source', async (q, s) => { clinic.requireReception(s); return integrations.patientSourceInfo(); });
|
||||||
|
get('/patients/external', async (q, s) => { clinic.requireReception(s); return integrations.searchExternalPatients(q.search); });
|
||||||
|
write('post', '/patients/external/:id/import', async (body, user, staff, n) => { clinic.requireReception(staff); return integrations.importExternalPatient(n, user); });
|
||||||
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
|
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
|
||||||
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
|
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
|
||||||
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
|
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const audit = async (user, action, entityType, entityId, connection = pool) => l
|
|||||||
connection
|
connection
|
||||||
});
|
});
|
||||||
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
|
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
|
||||||
|
export function requireReception(staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
|
||||||
|
}
|
||||||
export function requireConfig(user) {
|
export function requireConfig(user) {
|
||||||
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
|
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
|
||||||
}
|
}
|
||||||
@@ -88,9 +91,9 @@ export async function patients(q, staff) {
|
|||||||
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
|
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
|
||||||
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
|
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
|
||||||
const like = `%${search}%`;
|
const like = `%${search}%`;
|
||||||
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR identification LIKE ? OR phone LIKE ?)';
|
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR source_name LIKE ? OR identification LIKE ? OR phone LIKE ?)';
|
||||||
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like]);
|
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like, like]);
|
||||||
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, pageSize, (page - 1) * pageSize]);
|
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, like, pageSize, (page - 1) * pageSize]);
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
total: count.n,
|
total: count.n,
|
||||||
@@ -122,7 +125,7 @@ export async function savePatient(raw, user, staff, patientId = null) {
|
|||||||
const data = patientData(raw);
|
const data = patientData(raw);
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
|
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,name_split=1,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
|
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const putDevice = asyncHandler(async (req, res) => ok(res, await service.
|
|||||||
export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard()));
|
export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard()));
|
||||||
export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all)));
|
export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all)));
|
||||||
export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || ''))));
|
export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || ''))));
|
||||||
|
export const putSoftwareClassification = asyncHandler(async (req, res) => ok(res, await service.setSoftwareClassification(req.body.name, req.body.licenseType, req.currentUser)));
|
||||||
|
|
||||||
export const getRaw = asyncHandler(async (req, res) => {
|
export const getRaw = asyncHandler(async (req, res) => {
|
||||||
const { full, file } = await service.getRaw(Number(req.params.id));
|
const { full, file } = await service.getRaw(Number(req.params.id));
|
||||||
@@ -23,3 +24,5 @@ export const getAccess = asyncHandler(async (req, res) => ok(res, await service.
|
|||||||
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
|
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
|
||||||
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
|
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
|
||||||
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
|
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
|
||||||
|
|
||||||
|
export const postCpuYear = asyncHandler(async (req, res) => ok(res, await service.fetchCpuYear(Number(req.params.id), req.currentUser)));
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function saveRaw(deviceid, text, format) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInWorker(text, format) {
|
export function parseInWorker(text, format) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
||||||
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
import { isWindowsBundled } from './softwareClassifier.js';
|
||||||
|
|
||||||
// Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo
|
// Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo
|
||||||
// `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que
|
// `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que
|
||||||
@@ -7,7 +8,7 @@ import { XMLParser } from 'fast-xml-parser';
|
|||||||
|
|
||||||
const XML_ARRAY_SECTIONS = new Set([
|
const XML_ARRAY_SECTIONS = new Set([
|
||||||
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
|
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
|
||||||
'users', 'local_users', 'local_groups', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
|
'users', 'local_users', 'local_groups', 'licenseinfos', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
|
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
|
||||||
@@ -74,6 +75,38 @@ function isComponent(name, category) {
|
|||||||
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Licenciamiento de Windows y Office. La clave de producto NUNCA se guarda completa en la BD: solo
|
||||||
|
// el ultimo bloque (la completa sigue solo en el reporte crudo, de descarga restringida).
|
||||||
|
const maskKey = (v) => {
|
||||||
|
const key = str(v, 64);
|
||||||
|
if (!key) return null;
|
||||||
|
return key.replace(/[A-Z0-9]/gi, '*').slice(0, -5) + key.slice(-5);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractLicenses(c) {
|
||||||
|
const hw = c.hardware || {};
|
||||||
|
const os = c.operatingsystem || {};
|
||||||
|
const winPid = str(hw.winprodid, 64);
|
||||||
|
const isWindows = /windows/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`);
|
||||||
|
const windows = isWindows ? {
|
||||||
|
edition: str(os.full_name ?? hw.osname),
|
||||||
|
version: str(os.version ?? hw.osversion, 120),
|
||||||
|
product_id: winPid,
|
||||||
|
key: maskKey(hw.winprodkey),
|
||||||
|
oem: winPid ? /OEM/i.test(winPid) : null,
|
||||||
|
owner: str(hw.winowner, 190),
|
||||||
|
company: str(hw.wincompany, 190),
|
||||||
|
} : null;
|
||||||
|
const office = asArray(c.licenseinfos)
|
||||||
|
.filter((x) => /office/i.test(`${x.name || ''} ${x.fullname || ''}`))
|
||||||
|
.map((x) => ({
|
||||||
|
name: str(x.name), full_name: str(x.fullname), product_id: str(x.productid, 64), key: maskKey(x.key),
|
||||||
|
oem: x.oem == null || x.oem === '' ? null : String(x.oem) === '1',
|
||||||
|
msdn: /msdn/i.test(`${x.name || ''} ${x.fullname || ''}`),
|
||||||
|
}));
|
||||||
|
return { windows, office };
|
||||||
|
}
|
||||||
|
|
||||||
const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i;
|
const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i;
|
||||||
const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i;
|
const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i;
|
||||||
|
|
||||||
@@ -137,9 +170,10 @@ export function normalizeInventory(parsed) {
|
|||||||
.map((x) => {
|
.map((x) => {
|
||||||
const name = str(x.name);
|
const name = str(x.name);
|
||||||
const category = str(x.system_category, 40);
|
const category = str(x.system_category, 40);
|
||||||
|
const publisher = str(x.publisher, 190);
|
||||||
return {
|
return {
|
||||||
name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40),
|
name, version: str(x.version, 120), publisher, install_date: str(x.installdate ?? x.install_date, 40),
|
||||||
category, is_component: isComponent(name, category) ? 1 : 0,
|
category, is_component: isComponent(name, category) || isWindowsBundled(name, publisher, category) ? 1 : 0,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((s) => s.name);
|
.filter((s) => s.name);
|
||||||
@@ -197,6 +231,7 @@ export function normalizeInventory(parsed) {
|
|||||||
for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) {
|
for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) {
|
||||||
if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key];
|
if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key];
|
||||||
}
|
}
|
||||||
|
extra.licenses = extractLicenses(c);
|
||||||
|
|
||||||
return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra };
|
return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ export async function findById(id) {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
detail.changes = changes;
|
detail.changes = changes;
|
||||||
|
// El año del procesador solo vale mientras el equipo siga reportando el mismo procesador.
|
||||||
|
if (detail.cpu_year_for !== detail.cpu_summary) detail.cpu_year = null;
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,10 @@ export async function updateManual(id, { status, assignedUserId, departmentId, n
|
|||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const saveCpuYear = (id, year, cpuSummary) => pool.query('UPDATE devices SET cpu_year = ?, cpu_year_for = ? WHERE id = ?', [year, cpuSummary, id]);
|
||||||
|
|
||||||
|
export const saveExtra = (id, extra) => pool.query('UPDATE devices SET extra_json = ? WHERE id = ?', [JSON.stringify(extra), id]);
|
||||||
|
|
||||||
export async function findRawFile(id) {
|
export async function findRawFile(id) {
|
||||||
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
|
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
|
||||||
return row?.raw_file || null;
|
return row?.raw_file || null;
|
||||||
@@ -238,8 +244,8 @@ export async function listSoftware({ search, all }) {
|
|||||||
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
|
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
|
||||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count,
|
`SELECT s.name, MAX(s.publisher) AS publisher, MAX(s.category) AS category, MAX(s.is_component) AS is_component,
|
||||||
COUNT(DISTINCT s.version) AS version_count
|
COUNT(DISTINCT s.device_id) AS device_count, COUNT(DISTINCT s.version) AS version_count
|
||||||
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
|
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
|
||||||
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
|
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
|
||||||
params
|
params
|
||||||
@@ -247,6 +253,19 @@ export async function listSoftware({ search, all }) {
|
|||||||
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
|
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function softwareOverrides() {
|
||||||
|
const [rows] = await pool.query('SELECT name, license_type FROM software_classifications');
|
||||||
|
return new Map(rows.map((r) => [r.name.toLowerCase(), r.license_type]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveSoftwareClassification = (name, type, userId) => pool.query(
|
||||||
|
`INSERT INTO software_classifications (name, license_type, updated_by) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE license_type = VALUES(license_type), updated_by = VALUES(updated_by)`,
|
||||||
|
[name, type, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const removeSoftwareClassification = (name) => pool.query('DELETE FROM software_classifications WHERE name = ?', [name]);
|
||||||
|
|
||||||
export async function listDevicesWithSoftware(name) {
|
export async function listDevicesWithSoftware(name) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
|
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
import { requireModule } from '../../middleware/modules.js';
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { fail } from '../../utils/http.js';
|
import { fail } from '../../utils/http.js';
|
||||||
import { getAccess as getAccessFor } from './service.js';
|
import { getAccess as getAccessFor } from './service.js';
|
||||||
import {
|
import {
|
||||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, putSoftwareClassification, getRaw,
|
||||||
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer,
|
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Cada consulta a la IA cuesta: tope por IP para no gastar saldo por error o abuso.
|
||||||
|
const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
|
||||||
|
|
||||||
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
||||||
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
||||||
|
|
||||||
@@ -27,6 +31,7 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para
|
|||||||
router.get('/dashboard', canView, getDashboard);
|
router.get('/dashboard', canView, getDashboard);
|
||||||
router.get('/software', canView, getSoftware);
|
router.get('/software', canView, getSoftware);
|
||||||
router.get('/software/devices', canView, getSoftwareDevices);
|
router.get('/software/devices', canView, getSoftwareDevices);
|
||||||
|
router.put('/software/classification', requireRole(...CAN_EDIT_DEVICES), putSoftwareClassification);
|
||||||
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
||||||
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
||||||
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
||||||
@@ -38,6 +43,7 @@ router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken);
|
|||||||
router.get('/', canView, getDevices);
|
router.get('/', canView, getDevices);
|
||||||
router.get('/:id', canView, getDevice);
|
router.get('/:id', canView, getDevice);
|
||||||
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
|
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
|
||||||
|
router.post('/:id/cpu-year', requireRole(...CAN_EDIT_DEVICES), aiLimiter, postCpuYear);
|
||||||
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
|
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { HttpError } from '../../utils/http.js';
|
|||||||
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
|
import { askStructured } from '../../utils/ai.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import { parseInWorker } from './ingest.js';
|
||||||
|
import { classifySoftware, LICENSE_TYPES } from './softwareClassifier.js';
|
||||||
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
|
|
||||||
const STATUSES = ['active', 'retired'];
|
const STATUSES = ['active', 'retired'];
|
||||||
@@ -23,9 +26,26 @@ export const listDevices = (filters) => repo.findAll({
|
|||||||
export async function getDevice(id) {
|
export async function getDevice(id) {
|
||||||
const device = await repo.findById(id);
|
const device = await repo.findById(id);
|
||||||
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
||||||
|
if (device.extra?.licenses === undefined && device.raw_file) await backfillLicenses(device);
|
||||||
|
const overrides = await repo.softwareOverrides();
|
||||||
|
device.software = device.software.map((s) => ({ ...s, ...classifySoftware(s, overrides) }));
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Equipos inventariados antes de la pestaña Licenciamiento: se sacan del ultimo reporte crudo una
|
||||||
|
// sola vez (si falla queda vacio y se completa con el proximo reporte del agente).
|
||||||
|
async function backfillLicenses(device) {
|
||||||
|
let licenses = { windows: null, office: [] };
|
||||||
|
try {
|
||||||
|
const full = path.join(env.storageRoot, 'inventories', path.basename(device.raw_file));
|
||||||
|
const text = await fs.promises.readFile(full, 'utf8');
|
||||||
|
const parsed = await parseInWorker(text, path.extname(full) === '.json' ? 'json' : 'xml');
|
||||||
|
licenses = parsed.normalized?.extra?.licenses || licenses;
|
||||||
|
} catch { /* sin crudo legible: queda vacio */ }
|
||||||
|
device.extra = { ...(device.extra || {}), licenses };
|
||||||
|
await repo.saveExtra(device.id, device.extra);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateDevice(id, data, actor) {
|
export async function updateDevice(id, data, actor) {
|
||||||
const current = await getDevice(id);
|
const current = await getDevice(id);
|
||||||
const status = data.status ?? current.status;
|
const status = data.status ?? current.status;
|
||||||
@@ -42,6 +62,30 @@ export async function updateDevice(id, data, actor) {
|
|||||||
return getDevice(id);
|
return getDevice(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Año de lanzamiento del procesador, consultado a la IA SOLO con el nombre del procesador (nada
|
||||||
|
// del equipo, usuario ni red). Si la IA da más de una fecha se pide la más antigua. La IA solo
|
||||||
|
// sugiere: se valida el rango antes de guardar.
|
||||||
|
export async function fetchCpuYear(id, actor) {
|
||||||
|
const device = await getDevice(id);
|
||||||
|
const cpuName = device.cpus.find((c) => c.name)?.name;
|
||||||
|
if (!cpuName) throw new HttpError(400, 'Este equipo no reporta procesador');
|
||||||
|
const result = await askStructured({
|
||||||
|
system: 'Eres un experto en hardware de computadores. Te dan SOLO el nombre de un procesador. Indica el AÑO en que se lanzó. Si hay más de una fecha asociada (por ejemplo anuncio y disponibilidad), devuelve el año MÁS ANTIGUO. Si no puedes determinarlo con seguridad, devuelve null: no inventes.',
|
||||||
|
prompt: `Procesador: ${String(cpuName).trim().slice(0, 200)}`,
|
||||||
|
toolName: 'cpu_release_year',
|
||||||
|
schema: { type: 'object', properties: { year: { type: ['integer', 'null'], description: 'Año de lanzamiento (4 dígitos) o null' } }, required: ['year'] },
|
||||||
|
maxTokens: 200,
|
||||||
|
usage: { userId: actor.id, action: 'device_cpu_year' },
|
||||||
|
});
|
||||||
|
const year = Number(result?.year);
|
||||||
|
if (!Number.isInteger(year) || year < 1990 || year > new Date().getFullYear() + 1) {
|
||||||
|
throw new HttpError(422, 'No se pudo determinar el año de este procesador');
|
||||||
|
}
|
||||||
|
await repo.saveCpuYear(id, year, device.cpu_summary);
|
||||||
|
await logAudit({ userId: actor.id, action: 'cpu_year', entityType: 'device', entityId: id, detail: { cpu: cpuName, year } });
|
||||||
|
return getDevice(id);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRaw(id) {
|
export async function getRaw(id) {
|
||||||
const file = await repo.findRawFile(id);
|
const file = await repo.findRawFile(id);
|
||||||
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
|
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
|
||||||
@@ -50,7 +94,23 @@ export async function getRaw(id) {
|
|||||||
return { full, file };
|
return { full, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all });
|
export async function listSoftware(q, all) {
|
||||||
|
const [rows, overrides] = await Promise.all([repo.listSoftware({ search: q?.trim() || null, all: !!all }), repo.softwareOverrides()]);
|
||||||
|
const classified = rows.map((r) => ({ ...r, ...classifySoftware(r, overrides) }));
|
||||||
|
return all ? classified : classified.filter((r) => !r.is_component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correccion manual del tipo de licencia de un programa; `licenseType` vacio vuelve a las reglas.
|
||||||
|
export async function setSoftwareClassification(name, licenseType, actor) {
|
||||||
|
const program = String(name || '').trim();
|
||||||
|
if (!program || program.length > 255) throw new HttpError(400, 'Programa invalido');
|
||||||
|
if (licenseType && !LICENSE_TYPES.includes(licenseType)) throw new HttpError(400, 'Tipo de licencia invalido');
|
||||||
|
if (licenseType) await repo.saveSoftwareClassification(program, licenseType, actor.id);
|
||||||
|
else await repo.removeSoftwareClassification(program);
|
||||||
|
await logAudit({ userId: actor.id, action: 'classify_software', entityType: 'software', entityId: null, detail: { name: program, licenseType: licenseType || null } });
|
||||||
|
return { name: program, licenseType: licenseType || null };
|
||||||
|
}
|
||||||
|
|
||||||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Clasificador de software por reglas, sin IA ni consultas externas. Solo mira nombre, fabricante y
|
||||||
|
// categoria que reporta el agente. Es orientativo: lo que no esta en las listas queda sin clasificar
|
||||||
|
// (null) y un editor lo corrige a mano (software_classifications), nunca se adivina.
|
||||||
|
|
||||||
|
export const LICENSE_TYPES = ['free', 'paid', 'personal_free', 'component'];
|
||||||
|
|
||||||
|
// Fabricante normalizado: variantes de publisher y programas que solo lo nombran en el titulo.
|
||||||
|
const VENDORS = [
|
||||||
|
['Epson', /\bepson\b/i], ['Microsoft', /\bmicrosoft\b/i], ['Adobe', /\badobe\b/i], ['Google', /\bgoogle\b/i],
|
||||||
|
['HP', /\b(hp|hewlett[- ]packard)\b/i], ['Canon', /\bcanon\b/i], ['Brother', /\bbrother\b/i],
|
||||||
|
['Intel', /\bintel\b/i], ['Realtek', /\brealtek\b/i], ['NVIDIA', /\bnvidia\b/i], ['AMD', /\b(amd|advanced micro devices)\b/i],
|
||||||
|
['Lenovo', /\blenovo\b/i], ['ASUS', /\basus(tek)?\b/i], ['Samsung', /\bsamsung\b/i], ['Conexant', /\bconexant\b/i],
|
||||||
|
['OpenVPN', /\bopenvpn\b/i], ['Notepad++', /\bnotepad\+\+/i], ['Dell', /\bdell\b/i], ['Logitech', /\blogitech\b/i], ['Autodesk', /\bautodesk\b/i],
|
||||||
|
['Mozilla', /\bmozilla\b/i], ['Oracle', /\boracle\b/i],
|
||||||
|
];
|
||||||
|
// Traen sus programas con el hardware: lo que no sea un producto de licencia va como incluido.
|
||||||
|
const HARDWARE_VENDORS = new Set(['Epson', 'HP', 'Canon', 'Brother', 'Intel', 'Realtek', 'NVIDIA', 'AMD', 'Lenovo', 'Dell', 'Logitech', 'ASUS', 'Samsung', 'Conexant']);
|
||||||
|
|
||||||
|
const LEGAL_SUFFIX = /[,.\s]+(corporation|corp|incorporated|inc|ltd|limited|llc|gmbh|s\.?a\.?|co)\.?$/i;
|
||||||
|
|
||||||
|
export function vendorOf(name, publisher) {
|
||||||
|
for (const [vendor, pattern] of VENDORS) if (pattern.test(publisher || '')) return vendor;
|
||||||
|
for (const [vendor, pattern] of VENDORS) if (pattern.test(name || '')) return vendor;
|
||||||
|
// Los paquetes de la tienda reportan el firmante ("CN=..."): no sirve como nombre visible.
|
||||||
|
if (!publisher || /^cn=/i.test(publisher)) return null;
|
||||||
|
return publisher.replace(LEGAL_SUFFIX, '').replace(LEGAL_SUFFIX, '').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Productos Microsoft con licencia propia: nunca se esconden dentro del grupo.
|
||||||
|
const MICROSOFT_LICENSED = new RegExp([
|
||||||
|
'^(microsoft )?windows (10|11|server)\\b(?!.*(asistente|assistant))',
|
||||||
|
'^microsoft 365\\b(?!.*local ai)', '^microsoft office (profesional|professional|hogar|home|est[aá]ndar|standard|ltsc|\\d{4})',
|
||||||
|
'^microsoft (visio|project)\\b', '^(microsoft )?visual studio (community|professional|enterprise|\\d{4})',
|
||||||
|
'^microsoft sql server \\d{4}', '^microsoft dynamics', '^microsoft exchange server',
|
||||||
|
].join('|'), 'i');
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
// Antes que las demas: nombres que tambien entrarian como pago/gratuito.
|
||||||
|
['personal_free', /teamviewer|anydesk|winrar|\bwinzip\b|ccleaner|malwarebytes|avast free|avg (anti|free)|macrium reflect free|docker desktop|^java( \d+)?( update| se)|^java\(tm\)|sketchup free/i],
|
||||||
|
['free', /acrobat reader|adobe reader|sql server.*express|visual studio code|^microsoft edge\b/i],
|
||||||
|
['paid', new RegExp([
|
||||||
|
'acrobat', 'photoshop', 'illustrator', 'indesign', 'premiere pro', 'after effects', 'lightroom',
|
||||||
|
'autocad', 'revit', 'sketchup', 'coreldraw', 'solidworks', 'matlab', 'abbyy finereader', 'nitro pro',
|
||||||
|
'foxit (pdf )?editor', 'realvnc server', 'kaspersky', '\\beset\\b', 'bitdefender', 'norton', 'mcafee',
|
||||||
|
].join('|'), 'i')],
|
||||||
|
['free', new RegExp([
|
||||||
|
'^7-zip', '^vlc\\b', '^google chrome', 'mozilla (firefox|thunderbird)', '^notepad\\+\\+', 'libreoffice', 'openoffice',
|
||||||
|
'^putty\\b', '^winscp', '^filezilla', '^git\\b', '^node\\.js', '^python \\d', '^gimp', '^inkscape', '^paint\\.net',
|
||||||
|
'^pdfcreator', '^audacity', '^keepass', 'openvpn', '^tap-windows', '^xampp', '^mysql (workbench|connector)', '^firmaec', '^nextcloud',
|
||||||
|
'^rustdesk', '^freefilesync', '^glpi agent', '^android studio', '^composer\\b', '^python launcher', '^nmap\\b',
|
||||||
|
].join('|'), 'i')],
|
||||||
|
];
|
||||||
|
|
||||||
|
function ruleType(name) {
|
||||||
|
for (const [type, pattern] of RULES) if (pattern.test(name)) return type;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps de la tienda y paquetes que vienen con Windows: mismo trato que actualizaciones y drivers.
|
||||||
|
const WINDOWS_BUNDLED = /desktop runtime|winappruntime|ink\.handwriting|windows web experience|windows package manager|experiencia local|language experience pack|widgets platform/i;
|
||||||
|
const REAL_STORE_APP = /whiteboard|teams|to do|onenote|outlook|powerpoint|\bexcel\b|\bword\b|clipchamp|power bi|company portal|remote desktop|visio|project/i;
|
||||||
|
|
||||||
|
export function isWindowsBundled(name, publisher, category) {
|
||||||
|
if (WINDOWS_BUNDLED.test(name || '')) return true;
|
||||||
|
return String(category || '').toLowerCase() === 'store' && vendorOf(name, publisher) === 'Microsoft' && !REAL_STORE_APP.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve { is_component, license_type, vendor, group }: `group` es el fabricante bajo el que se
|
||||||
|
// colapsa la fila, null si debe verse suelta. `overrides` es un Map(nombre en minusculas -> tipo).
|
||||||
|
export function classifySoftware(row, overrides) {
|
||||||
|
const name = row.name || '';
|
||||||
|
const vendor = vendorOf(name, row.publisher);
|
||||||
|
const isComponent = !!row.is_component || isWindowsBundled(name, row.publisher, row.category);
|
||||||
|
const licensed = vendor === 'Microsoft' && MICROSOFT_LICENSED.test(name);
|
||||||
|
let type = isComponent ? 'component' : licensed ? 'paid' : ruleType(name);
|
||||||
|
if (!type && (vendor === 'Microsoft' || HARDWARE_VENDORS.has(vendor))) type = 'component';
|
||||||
|
const manual = overrides?.get(name.toLowerCase());
|
||||||
|
return {
|
||||||
|
is_component: isComponent ? 1 : 0,
|
||||||
|
license_type: manual || type,
|
||||||
|
license_manual: !!manual,
|
||||||
|
vendor,
|
||||||
|
group: licensed ? null : vendor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
|
// Cliente del contrato v1 de conectores (data/conectores/README.md). Solo GET, con token y tiempo
|
||||||
|
// máximo; los errores del conector se traducen a mensajes para el administrador.
|
||||||
|
const TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export function normalizeBaseUrl(raw) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(raw || '').trim());
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'URL del conector inválida');
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new HttpError(400, 'URL del conector inválida');
|
||||||
|
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectorGet(conn, path, query = {}) {
|
||||||
|
const url = new URL(`${conn.baseUrl}${path}`);
|
||||||
|
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { Authorization: `Bearer ${conn.token}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, err.name === 'TimeoutError' ? 'El conector no respondió a tiempo' : 'No se pudo conectar con el conector');
|
||||||
|
}
|
||||||
|
let body = null;
|
||||||
|
try { body = await res.json(); } catch { /* respuesta no JSON */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const reason = res.status === 401 ? 'token inválido' : res.status === 403 ? 'IP no permitida' : body?.error || `HTTP ${res.status}`;
|
||||||
|
throw new HttpError(502, `Conector: ${reason}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorre todas las páginas de una consulta de filas (máximo 1000 por página en el conector).
|
||||||
|
// Si el origen cambia entre páginas (otra versión del archivo), se reinicia la lectura para no
|
||||||
|
// duplicar ni saltar filas.
|
||||||
|
export async function connectorAllRows(conn, source, table, query = {}) {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const rows = [];
|
||||||
|
let offset = 0, page, version = null, stable = true;
|
||||||
|
do {
|
||||||
|
page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(source)}/tables/${encodeURIComponent(table)}/rows`, { ...query, limit: 1000, offset });
|
||||||
|
if (version !== null && page.version !== version) { stable = false; break; }
|
||||||
|
version = page.version;
|
||||||
|
rows.push(...page.rows);
|
||||||
|
offset += page.rows.length;
|
||||||
|
} while (page.rows.length && offset < page.total);
|
||||||
|
if (stable) return { rows, maxRecno: page.maxRecno, version };
|
||||||
|
}
|
||||||
|
throw new HttpError(503, 'El origen cambió durante la lectura; se reintentará');
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
|
import * as svc from './service.js';
|
||||||
|
|
||||||
|
// Conexiones externas. Permisos en el servicio: configuración solo superadmin; empleados
|
||||||
|
// (listar y "Actualizar empleados") admin y superadmin.
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
const h = (fn, status = 200) => asyncHandler(async (req, res) => ok(res, await fn(req), status));
|
||||||
|
|
||||||
|
router.get('/connections', h((req) => svc.listConnections(req.currentUser)));
|
||||||
|
router.post('/connections', h((req) => svc.saveConnection(req.body, req.currentUser), 201));
|
||||||
|
router.put('/connections/:id', h((req) => svc.saveConnection(req.body, req.currentUser, req.params.id)));
|
||||||
|
router.post('/connections/:id/test', h((req) => svc.testConnection(req.params.id, req.currentUser)));
|
||||||
|
router.get('/connections/:id/browse', h((req) => svc.browse(req.params.id, req.query, req.currentUser)));
|
||||||
|
router.get('/sources', h((req) => svc.listSources(req.currentUser)));
|
||||||
|
router.put('/sources/:purpose', h((req) => svc.saveSource(req.params.purpose, req.body, req.currentUser)));
|
||||||
|
router.post('/sources/patients/sync', h((req) => svc.syncPatients({ full: !!req.body?.full, user: req.currentUser })));
|
||||||
|
router.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
|
||||||
|
router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser)));
|
||||||
|
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;
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { requireEditVersion } from '../../utils/editVersion.js';
|
||||||
|
import { guayaquilNow } from '../meetings/recurrence.js';
|
||||||
|
import { connectorGet, connectorAllRows, normalizeBaseUrl } from './connector.js';
|
||||||
|
|
||||||
|
// Conexiones externas (ver docs/CONEXIONES-EXTERNAS.md). El sistema nunca escribe en el origen:
|
||||||
|
// solo lee por el conector y copia/consulta lo que el superadmin mapeó.
|
||||||
|
|
||||||
|
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
|
||||||
|
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
const TARGETS = {
|
||||||
|
employees: { required: ['identification'], fields: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] },
|
||||||
|
patients: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] },
|
||||||
|
providers: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuperadmin = (user) => user.roleCode === 'superadmin';
|
||||||
|
const isAdmin = (user) => ['admin', 'superadmin'].includes(user.roleCode);
|
||||||
|
export function requireSuperadmin(user) {
|
||||||
|
if (!isSuperadmin(user)) throw new HttpError(403, 'Solo el superadmin configura las conexiones externas');
|
||||||
|
}
|
||||||
|
export function requireAdmin(user) {
|
||||||
|
if (!isAdmin(user)) throw new HttpError(403, 'No tienes permisos para esta acción');
|
||||||
|
}
|
||||||
|
const num = (v) => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const purposeOf = (p) => {
|
||||||
|
if (!TARGETS[p]) throw new HttpError(400, 'Destino inválido');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const clip = (v, max) => {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
const s = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||||
|
return s ? s.slice(0, max) : null;
|
||||||
|
};
|
||||||
|
const dateOnly = (v) => (typeof v === 'string' && /^\d{4}-\d\d-\d\d/.test(v) ? v.slice(0, 10) : null);
|
||||||
|
const truthy = (v) => v === true || v === 1 || /^(1|s|si|sí|true|t|y|activo)$/i.test(String(v ?? '').trim());
|
||||||
|
|
||||||
|
// --- Conexiones ---
|
||||||
|
|
||||||
|
async function loadConnection(id) {
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM external_connections WHERE id=?', [num(id)]);
|
||||||
|
if (!row) throw new HttpError(404, 'Conexión no encontrada');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const clientFor = (row) => ({ baseUrl: row.base_url, token: decrypt(row.token_encrypted) });
|
||||||
|
const publicConnection = (r) => ({ id: r.id, name: r.name, base_url: r.base_url, is_active: !!r.is_active, edit_version: r.edit_version, created_at: r.created_at, updated_at: r.updated_at });
|
||||||
|
|
||||||
|
export async function listConnections(user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
return (await exec(pool, 'SELECT * FROM external_connections ORDER BY name')).map(publicConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConnection(raw, user, connId = null) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const name = clip(raw.name, 120);
|
||||||
|
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||||
|
const baseUrl = normalizeBaseUrl(raw.baseUrl);
|
||||||
|
const token = raw.token ? String(raw.token).trim() : '';
|
||||||
|
if (connId) {
|
||||||
|
const before = await loadConnection(connId);
|
||||||
|
const version = requireEditVersion(Number(raw.editVersion));
|
||||||
|
const result = await exec(pool, `UPDATE external_connections SET name=?,base_url=?,is_active=?${token ? ',token_encrypted=?' : ''},edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
|
||||||
|
[name, baseUrl, raw.isActive === false ? 0 : 1, ...(token ? [encrypt(token)] : []), before.id, version]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Conexión modificada por otra persona');
|
||||||
|
await logAudit({ userId: user.id, action: 'update', entityType: 'external_connection', entityId: before.id, detail: { name, baseUrl, tokenChanged: !!token } });
|
||||||
|
return publicConnection(await loadConnection(before.id));
|
||||||
|
}
|
||||||
|
if (!token) throw new HttpError(400, 'El token es obligatorio');
|
||||||
|
const result = await exec(pool, 'INSERT INTO external_connections (name,base_url,token_encrypted,created_by) VALUES (?,?,?,?)', [name, baseUrl, encrypt(token), user.id]);
|
||||||
|
await logAudit({ userId: user.id, action: 'create', entityType: 'external_connection', entityId: result.insertId, detail: { name, baseUrl } });
|
||||||
|
return publicConnection(await loadConnection(result.insertId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConnection(connId, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const row = await loadConnection(connId);
|
||||||
|
const health = await connectorGet(clientFor(row), '/v1/health');
|
||||||
|
await logAudit({ userId: user.id, action: 'test', entityType: 'external_connection', entityId: row.id });
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exploración para el superadmin: fuentes, tablas, estructura y una muestra de filas.
|
||||||
|
export async function browse(connId, q, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const conn = clientFor(await loadConnection(connId));
|
||||||
|
if (!q.source) return connectorGet(conn, '/v1/sources');
|
||||||
|
if (!ID_RE.test(q.source)) throw new HttpError(400, 'Fuente inválida');
|
||||||
|
const base = `/v1/sources/${encodeURIComponent(q.source)}/tables`;
|
||||||
|
if (!q.table) return connectorGet(conn, base);
|
||||||
|
if (!ID_RE.test(q.table)) throw new HttpError(400, 'Tabla inválida');
|
||||||
|
const [schema, sample] = await Promise.all([
|
||||||
|
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/schema`),
|
||||||
|
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/rows`, { limit: 20 })
|
||||||
|
]);
|
||||||
|
await logAudit({ userId: user.id, action: 'preview', entityType: 'external_connection', entityId: Number(connId), detail: { source: q.source, table: q.table } });
|
||||||
|
return { schema, sample: sample.rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fuentes (qué alimenta Pacientes / Empleados) ---
|
||||||
|
|
||||||
|
const parseSource = (r) => r && ({ ...r, field_map: JSON.parse(r.field_map_json), filter: r.filter_json ? JSON.parse(r.filter_json) : {}, last_result: r.last_result_json ? JSON.parse(r.last_result_json) : null });
|
||||||
|
async function sourceRow(purpose) {
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM external_sources WHERE purpose=?', [purposeOf(purpose)]);
|
||||||
|
return parseSource(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSources(user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const rows = (await exec(pool, 'SELECT s.*, c.name AS connection_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id')).map(parseSource);
|
||||||
|
// El admin solo ve el estado (para "Actualizar empleados"); el mapeo es del superadmin.
|
||||||
|
return isSuperadmin(user) ? rows : rows.map((r) => ({ purpose: r.purpose, mode: r.mode, connection_name: r.connection_name, last_sync_at: r.last_sync_at, last_full_sync_at: r.last_full_sync_at, last_result: r.last_result }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSource(purpose, raw, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const p = purposeOf(purpose);
|
||||||
|
const conn = await loadConnection(raw.connectionId);
|
||||||
|
const sourceKey = String(raw.sourceKey || ''), tableName = String(raw.tableName || '');
|
||||||
|
if (!ID_RE.test(sourceKey) || !ID_RE.test(tableName)) throw new HttpError(400, 'Fuente o tabla inválida');
|
||||||
|
const mode = p === 'employees' ? 'import' : raw.mode === 'live' ? 'live' : 'import';
|
||||||
|
// El mapeo se valida contra la estructura real que publica el conector.
|
||||||
|
const schema = await connectorGet(clientFor(conn), `/v1/sources/${encodeURIComponent(sourceKey)}/tables/${encodeURIComponent(tableName)}/schema`);
|
||||||
|
const known = new Set(schema.fields.map((f) => f.name));
|
||||||
|
const map = {};
|
||||||
|
for (const target of TARGETS[p].fields) {
|
||||||
|
const field = raw.fieldMap?.[target];
|
||||||
|
if (!field) continue;
|
||||||
|
if (!known.has(field)) throw new HttpError(400, `Campo inexistente en la tabla: ${field}`);
|
||||||
|
map[target] = field;
|
||||||
|
}
|
||||||
|
for (const r of TARGETS[p].required) if (!map[r]) throw new HttpError(400, `Falta mapear el campo obligatorio: ${r}`);
|
||||||
|
const filter = {};
|
||||||
|
for (const [field, value] of Object.entries(raw.filter || {})) {
|
||||||
|
if (!known.has(field)) throw new HttpError(400, `Campo de filtro inexistente: ${field}`);
|
||||||
|
if (String(value).trim()) filter[field] = String(value).trim().slice(0, 60);
|
||||||
|
}
|
||||||
|
const changedField = raw.changedField ? String(raw.changedField) : null;
|
||||||
|
if (changedField && schema.fields.find((f) => f.name === changedField)?.type !== 'T') throw new HttpError(400, 'El campo de cambios debe ser de fecha y hora');
|
||||||
|
const before = await sourceRow(p);
|
||||||
|
if (p === 'patients' && before && before.field_map.externalId !== map.externalId) {
|
||||||
|
const [{ n }] = await exec(pool, 'SELECT COUNT(*) AS n FROM clinic_patients WHERE external_source=?', [patientKey(before)]);
|
||||||
|
if (n) throw new HttpError(409, 'No se puede cambiar el campo "código en el origen" con pacientes ya importados');
|
||||||
|
}
|
||||||
|
const sameTable = before && before.connection_id === conn.id && before.source_key === sourceKey && before.table_name === tableName;
|
||||||
|
if (before) {
|
||||||
|
const result = await exec(pool, `UPDATE external_sources SET connection_id=?,source_key=?,table_name=?,mode=?,field_map_json=?,filter_json=?,changed_field=?,
|
||||||
|
${sameTable ? '' : 'last_full_sync_at=NULL,last_sync_at=NULL,last_max_recno=NULL,last_changed_value=NULL,'}edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
|
||||||
|
[conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField, before.id, requireEditVersion(Number(raw.editVersion))]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Fuente modificada por otra persona');
|
||||||
|
} else {
|
||||||
|
await exec(pool, 'INSERT INTO external_sources (purpose,connection_id,source_key,table_name,mode,field_map_json,filter_json,changed_field) VALUES (?,?,?,?,?,?,?,?)',
|
||||||
|
[p, conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField]);
|
||||||
|
}
|
||||||
|
await logAudit({ userId: user.id, action: before ? 'update' : 'create', entityType: 'external_source', entityId: null, detail: { purpose: p, sourceKey, tableName, mode, map, filter } });
|
||||||
|
return sourceRow(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceClient(purpose) {
|
||||||
|
const src = await sourceRow(purpose);
|
||||||
|
if (!src) throw new HttpError(409, { 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) };
|
||||||
|
}
|
||||||
|
const eqQuery = (filter) => Object.fromEntries(Object.entries(filter || {}).map(([k, v]) => [`eq.${k}`, v]));
|
||||||
|
const fieldsQuery = (src) => [...new Set([...Object.values(src.field_map), ...(src.changed_field ? [src.changed_field] : [])])].join(',');
|
||||||
|
|
||||||
|
async function finishSync(src, values, result) {
|
||||||
|
await exec(pool, `UPDATE external_sources SET last_sync_at=?,${values.full ? 'last_full_sync_at=?,' : ''}last_max_recno=?,last_changed_value=?,last_result_json=? WHERE id=?`,
|
||||||
|
[guayaquilNow(), ...(values.full ? [guayaquilNow()] : []), values.maxRecno ?? src.last_max_recno, values.changedValue ?? src.last_changed_value, JSON.stringify(result), src.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empleados (nómina): siempre importados, a pedido (admin/superadmin) ---
|
||||||
|
|
||||||
|
const running = new Set();
|
||||||
|
async function exclusive(key, fn) {
|
||||||
|
if (running.has(key)) throw new HttpError(409, 'Ya hay una sincronización en curso');
|
||||||
|
running.add(key);
|
||||||
|
try { return await fn(); } finally { running.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncEmployees(user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
return exclusive('employees', async () => {
|
||||||
|
const { src, conn } = await sourceClient('employees');
|
||||||
|
const m = src.field_map;
|
||||||
|
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, { fields: fieldsQuery(src), ...eqQuery(src.filter) });
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const seen = [];
|
||||||
|
let created = 0, updated = 0, protectedRows = 0;
|
||||||
|
// Empleados con usuario en el sistema (p. ej. médicos) no se actualizan ni se inactivan solos.
|
||||||
|
const known = await exec(pool, 'SELECT identification, user_id FROM employees');
|
||||||
|
const withUser = new Set(known.filter((e) => e.user_id).map((e) => e.identification));
|
||||||
|
const existed = new Set(known.map((e) => e.identification));
|
||||||
|
for (const r of rows) {
|
||||||
|
const identification = clip(r[m.identification], 40);
|
||||||
|
if (!identification) continue;
|
||||||
|
seen.push(identification);
|
||||||
|
if (withUser.has(identification)) { protectedRows++; continue; }
|
||||||
|
const leave = m.leaveDate ? dateOnly(r[m.leaveDate]) : null;
|
||||||
|
const active = m.active ? truthy(r[m.active]) : !leave;
|
||||||
|
const values = [clip(m.lastNames && r[m.lastNames], 120) || '', clip(m.firstNames && r[m.firstNames], 120) || '', clip(m.phone && r[m.phone], 40), clip(m.phone2 && r[m.phone2], 40),
|
||||||
|
m.birthDate ? dateOnly(r[m.birthDate]) : null, clip(m.position && r[m.position], 120), clip(m.department && r[m.department], 120), m.hireDate ? dateOnly(r[m.hireDate]) : null, leave, active ? 1 : 0];
|
||||||
|
const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,phone2,birth_date,position,department_name,hire_date,leave_date,is_active,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?) ON DUPLICATE KEY UPDATE last_names=VALUES(last_names),first_names=VALUES(first_names),phone=VALUES(phone),phone2=VALUES(phone2),birth_date=VALUES(birth_date),
|
||||||
|
position=VALUES(position),department_name=VALUES(department_name),hire_date=VALUES(hire_date),leave_date=VALUES(leave_date),is_active=VALUES(is_active),in_source=1,origin='payroll'`,
|
||||||
|
[identification, ...values, now]);
|
||||||
|
// MySQL informa 2 filas afectadas solo cuando el registro existente cambió de verdad.
|
||||||
|
if (!existed.has(identification)) created++;
|
||||||
|
else if (result.affectedRows === 2) updated++;
|
||||||
|
}
|
||||||
|
const synced = seen.filter((x) => !withUser.has(x));
|
||||||
|
if (synced.length) await exec(pool, 'UPDATE employees SET synced_at=? WHERE identification IN (?)', [now, synced]);
|
||||||
|
// Los que ya no están en la nómina quedan inactivos (no se borran: pueden tener usuario).
|
||||||
|
const gone = seen.length
|
||||||
|
? await exec(pool, "UPDATE employees SET in_source=0,is_active=0 WHERE in_source=1 AND origin='payroll' AND user_id IS NULL AND identification NOT IN (?)", [seen])
|
||||||
|
: { affectedRows: 0 };
|
||||||
|
const result = { at: now, full: true, total: rows.length, created, updated, removed: gone.affectedRows, protected: protectedRows };
|
||||||
|
await finishSync(src, { full: true, maxRecno }, result);
|
||||||
|
await logAudit({ userId: user.id, action: 'sync', entityType: 'employees', entityId: null, detail: result });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEmployees(user, q = {}) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const onlyFree = q.available === '1';
|
||||||
|
return exec(pool, `SELECT e.*, u.name AS user_name FROM employees e LEFT JOIN users u ON u.id=e.user_id
|
||||||
|
WHERE 1=1 ${onlyFree ? 'AND e.is_active=1 AND e.user_id IS NULL' : ''} ORDER BY e.last_names, e.first_names`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireFreeEmployee(employeeId) {
|
||||||
|
const [e] = await exec(pool, 'SELECT id FROM employees WHERE id=? AND is_active=1 AND user_id IS NULL', [num(employeeId)]);
|
||||||
|
if (!e) throw new HttpError(409, 'Ese empleado ya tiene usuario, está inactivo o no existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vincula un usuario recién creado con su empleado (desde "Nuevo usuario" → Desde nómina).
|
||||||
|
export async function linkEmployeeToUser(employeeId, userId, connection = pool) {
|
||||||
|
const result = await exec(connection, 'UPDATE employees SET user_id=? WHERE id=? AND user_id IS NULL', [userId, num(employeeId)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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}`;
|
||||||
|
function patientValues(src, r) {
|
||||||
|
const m = src.field_map;
|
||||||
|
const email = clip(m.email && r[m.email], 160);
|
||||||
|
return {
|
||||||
|
externalId: clip(r[m.externalId], 40),
|
||||||
|
identification: clip(m.identification && r[m.identification], 40),
|
||||||
|
fullName: clip(r[m.fullName], 120),
|
||||||
|
phone: clip(m.phone && r[m.phone], 40) || '',
|
||||||
|
email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert de un lote: manda el origen (cédula, teléfono, correo, nombre de origen). El nombre visible
|
||||||
|
// solo se reemplaza mientras recepción no lo haya separado a mano (name_split=0).
|
||||||
|
async function upsertPatients(src, rows, now) {
|
||||||
|
const key = patientKey(src);
|
||||||
|
const stats = { created: 0, updated: 0, linked: 0, unchanged: 0, skipped: 0, identConflicts: 0 };
|
||||||
|
const seen = new Set();
|
||||||
|
if (!rows.length) return { stats, seen };
|
||||||
|
// Lotes chicos (incremental, importar uno): solo se precarga lo del lote; la completa trae todo.
|
||||||
|
const small = rows.length <= 2000;
|
||||||
|
const batchIds = rows.map((r) => clip(r[src.field_map.externalId], 40)).filter(Boolean);
|
||||||
|
const batchIdents = src.field_map.identification ? rows.map((r) => clip(r[src.field_map.identification], 40)).filter(Boolean) : [];
|
||||||
|
const existing = new Map((small && !batchIds.length ? [] : await exec(pool, `SELECT id,external_id,identification,source_name,phone,email,is_active,name_split FROM clinic_patients WHERE external_source=?${small ? ' AND external_id IN (?)' : ''}`, small ? [key, batchIds] : [key])).map((p) => [p.external_id, p]));
|
||||||
|
const byIdent = new Map((small && !batchIdents.length ? [] : await exec(pool, `SELECT id,identification,external_id FROM clinic_patients WHERE identification IS NOT NULL${small ? ' AND identification IN (?)' : ''}`, small ? [batchIdents] : [])).map((p) => [p.identification, p]));
|
||||||
|
for (const r of rows) {
|
||||||
|
const v = patientValues(src, r);
|
||||||
|
if (v.externalId) seen.add(v.externalId);
|
||||||
|
if (!v.externalId || !v.fullName) { stats.skipped++; continue; }
|
||||||
|
// Código repetido en el lote (duplicado en el origen): se procesa una sola vez.
|
||||||
|
if (existing.get(v.externalId)?.batch) { stats.skipped++; continue; }
|
||||||
|
const owner = v.identification ? byIdent.get(v.identification) : null;
|
||||||
|
const cur = existing.get(v.externalId);
|
||||||
|
// La cédula es única: si otro paciente ya la tiene (duplicado en el origen), no se repite.
|
||||||
|
const identFor = (selfId) => (v.identification && owner && owner.id !== selfId ? (stats.identConflicts++, null) : v.identification);
|
||||||
|
if (cur) {
|
||||||
|
const ident = identFor(cur.id);
|
||||||
|
if (cur.source_name === v.fullName && (cur.identification || null) === ident && cur.phone === v.phone && (cur.email || null) === v.email && cur.is_active) { cur.batch = true; stats.unchanged++; continue; }
|
||||||
|
await exec(pool, 'UPDATE clinic_patients SET source_name=?,first_names=IF(name_split=0,?,first_names),identification=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
|
||||||
|
[v.fullName, v.fullName, ident, v.phone, v.email, now, cur.id]);
|
||||||
|
if (ident) byIdent.set(ident, { id: cur.id, identification: ident });
|
||||||
|
cur.batch = true;
|
||||||
|
stats.updated++;
|
||||||
|
} else if (owner && !owner.external_id) {
|
||||||
|
// Paciente creado a mano con la misma cédula: se vincula y manda el origen (sin tocar su nombre separado).
|
||||||
|
await exec(pool, 'UPDATE clinic_patients SET external_source=?,external_id=?,source_name=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
|
||||||
|
[key, v.externalId, v.fullName, v.phone, v.email, now, owner.id]);
|
||||||
|
owner.external_id = v.externalId;
|
||||||
|
existing.set(v.externalId, { id: owner.id, batch: true });
|
||||||
|
stats.linked++;
|
||||||
|
} else {
|
||||||
|
const result = await exec(pool, `INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,external_source,external_id,source_name,name_split,synced_at)
|
||||||
|
VALUES (?,'',?,?,?,?,?,?,0,?)`, [v.fullName, identFor(null), v.phone, v.email, key, v.externalId, v.fullName, now]);
|
||||||
|
if (v.identification && !owner) byIdent.set(v.identification, { id: result.insertId, identification: v.identification, external_id: v.externalId });
|
||||||
|
existing.set(v.externalId, { id: result.insertId, batch: true });
|
||||||
|
stats.created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { stats, seen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPatients({ full = false, user = null } = {}) {
|
||||||
|
if (user) requireSuperadmin(user);
|
||||||
|
return exclusive('patients', async () => {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
if (src.mode !== 'import') throw new HttpError(409, 'La fuente de pacientes está en modo consulta en vivo');
|
||||||
|
const doFull = full || !src.last_full_sync_at;
|
||||||
|
const query = { fields: fieldsQuery(src), ...eqQuery(src.filter) };
|
||||||
|
if (!doFull) {
|
||||||
|
query.afterRecno = src.last_max_recno ?? 0;
|
||||||
|
if (src.changed_field && src.last_changed_value) Object.assign(query, { changedField: src.changed_field, changedSince: src.last_changed_value });
|
||||||
|
}
|
||||||
|
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, query);
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const { stats, seen } = await upsertPatients(src, rows, now);
|
||||||
|
let deactivated = 0;
|
||||||
|
if (doFull && seen.size) {
|
||||||
|
// Los que ya no están en el origen (borrados) quedan inactivos; su historial se conserva.
|
||||||
|
deactivated = (await exec(pool, 'UPDATE clinic_patients SET is_active=0 WHERE external_source=? AND is_active=1 AND external_id NOT IN (?)', [patientKey(src), [...seen]])).affectedRows;
|
||||||
|
}
|
||||||
|
let changedValue = src.last_changed_value;
|
||||||
|
if (src.changed_field) for (const r of rows) { const c = r[src.changed_field]; if (c && (!changedValue || String(c) > changedValue)) changedValue = String(c).slice(0, 40); }
|
||||||
|
const result = { at: now, full: doFull, received: rows.length, ...stats, deactivated };
|
||||||
|
await finishSync(src, { full: doFull, maxRecno, changedValue }, result);
|
||||||
|
if (user) await logAudit({ userId: user.id, action: 'sync', entityType: 'clinic_patients', entityId: null, detail: result });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo consulta en vivo: buscar en el origen y traer al sistema solo el paciente elegido.
|
||||||
|
export async function patientSourceInfo() {
|
||||||
|
const [src] = await exec(pool, "SELECT s.mode, s.last_sync_at FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1");
|
||||||
|
return src ? { configured: true, mode: src.mode, last_sync_at: src.last_sync_at } : { configured: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchExternalPatients(text) {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
const m = src.field_map;
|
||||||
|
const term = String(text || '').trim();
|
||||||
|
if (term.length < 3) return [];
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
q: term.slice(0, 60), qFields: [m.fullName, m.identification, m.phone].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 20, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
const ids = page.rows.map((r) => clip(r[m.externalId], 40)).filter(Boolean);
|
||||||
|
const local = ids.length ? new Map((await exec(pool, 'SELECT id,external_id FROM clinic_patients WHERE external_source=? AND external_id IN (?)', [patientKey(src), ids])).map((p) => [p.external_id, p.id])) : new Map();
|
||||||
|
return page.rows.map((r) => {
|
||||||
|
const v = patientValues(src, r);
|
||||||
|
return { external_id: v.externalId, identification: v.identification, full_name: v.fullName, phone: v.phone, email: v.email, local_id: local.get(v.externalId) || null };
|
||||||
|
}).filter((p) => p.external_id && p.full_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importExternalPatient(externalId, user) {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
const ext = clip(externalId, 40);
|
||||||
|
if (!ext) throw new HttpError(400, 'Paciente externo inválido');
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
[`eq.${src.field_map.externalId}`]: ext, fields: fieldsQuery(src), limit: 1, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
if (!page.rows.length) throw new HttpError(404, 'El paciente ya no existe en el sistema origen');
|
||||||
|
await upsertPatients(src, page.rows, guayaquilNow());
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_patients WHERE external_source=? AND external_id=?', [patientKey(src), ext]);
|
||||||
|
if (!row) throw new HttpError(409, 'No se pudo traer el paciente');
|
||||||
|
await logAudit({ userId: user.id, action: 'import', entityType: 'clinic_patient', entityId: row.id, detail: { externalId: ext } });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programación (job): completa lunes a viernes a las 05:00; incremental cada 15 min de 06:00 a 21:00.
|
||||||
|
export async function scheduledPatientSync() {
|
||||||
|
const src = await sourceRow('patients');
|
||||||
|
if (!src || src.mode !== 'import') return null;
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const weekday = new Date(`${now.slice(0, 10)}T12:00:00Z`).getUTCDay();
|
||||||
|
const hour = Number(now.slice(11, 13));
|
||||||
|
const fullToday = src.last_full_sync_at && String(src.last_full_sync_at).slice(0, 10) === now.slice(0, 10);
|
||||||
|
if (!src.last_full_sync_at || (weekday >= 1 && weekday <= 5 && hour === 5 && !fullToday)) return syncPatients({ full: true });
|
||||||
|
const minutesSince = src.last_sync_at ? (Date.parse(`${now.replace(' ', 'T')}Z`) - Date.parse(`${String(src.last_sync_at).replace(' ', 'T')}Z`)) / 60000 : Infinity;
|
||||||
|
if (hour >= 6 && hour < 21 && minutesSince >= 15) return syncPatients({ full: false });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { linkEmployeeToUser, requireFreeEmployee } from '../integrations/service.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
@@ -57,10 +58,20 @@ export async function createUser(payload, actor) {
|
|||||||
const existing = await repo.findByEmail(payload.email);
|
const existing = await repo.findByEmail(payload.email);
|
||||||
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
||||||
await checkRole(payload.roleId, actor);
|
await checkRole(payload.roleId, actor);
|
||||||
|
// "Desde nómina": el empleado debe existir, estar activo y no tener usuario (se valida antes de crear).
|
||||||
|
if (payload.employeeId) await requireFreeEmployee(payload.employeeId);
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(payload.password, 12);
|
const passwordHash = await bcrypt.hash(payload.password, 12);
|
||||||
const user = await repo.create({ ...payload, passwordHash });
|
const user = await repo.create({ ...payload, passwordHash });
|
||||||
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
|
if (payload.employeeId) {
|
||||||
|
try {
|
||||||
|
await linkEmployeeToUser(payload.employeeId, user.id);
|
||||||
|
} catch (err) {
|
||||||
|
await repo.remove(user.id);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id, detail: payload.employeeId ? { employeeId: Number(payload.employeeId) } : undefined });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Carga como empleados (origin=provider) a proveedores de SAGA que trabajan en la clínica pero no
|
||||||
|
// están en nómina (médicos que facturan). Lee del conector configurado en Conexiones externas, así
|
||||||
|
// no se guardan datos personales en el repositorio.
|
||||||
|
// node src/scripts/import-provider-employees.js --position=MEDICO 137+031 142+033 809 ...
|
||||||
|
// Cada argumento es una persona; "+" une códigos duplicados de la misma persona (el primero da el
|
||||||
|
// nombre; teléfono y correo se toman del primero que los tenga). Si la cédula ya existe como
|
||||||
|
// empleado (p. ej. de nómina) solo completa teléfono y correo vacíos.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { decrypt } from '../utils/crypto.js';
|
||||||
|
import { connectorGet } from '../modules/integrations/connector.js';
|
||||||
|
|
||||||
|
const 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); }
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
for (const codes of people) {
|
||||||
|
const rows = [];
|
||||||
|
for (const code of codes) {
|
||||||
|
const page = await connectorGet(client, `/v1/sources/${SOURCE}/tables/${TABLE}/rows`, { [`eq.PROVCOD`]: code, limit: 1 });
|
||||||
|
if (page.rows[0]) rows.push(page.rows[0]);
|
||||||
|
}
|
||||||
|
if (!rows.length) { console.log(`${codes.join('+')}: no existe en proveedores`); continue; }
|
||||||
|
const words = String(rows[0].PROVNOM).replace(/^DRA?\.?\s+/i, '').trim().split(/\s+/);
|
||||||
|
const identification = String(rows[0].PROVRUC || '').slice(0, 10);
|
||||||
|
if (!/^\d{10}$/.test(identification)) { console.log(`${codes.join('+')}: cédula inválida`); continue; }
|
||||||
|
const phone = rows.map((r) => r.PROVTEL).find(valid) || null;
|
||||||
|
const email = rows.map((r) => r.PROVEMAIL).find((e) => e && /@/.test(e)) || null;
|
||||||
|
const last = words.slice(0, 2).join(' '), first = words.slice(2).join(' ');
|
||||||
|
const [res] = await pool.query(`INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,1,'provider',0,NOW()) ON DUPLICATE KEY UPDATE phone=COALESCE(phone,VALUES(phone)),email=COALESCE(email,VALUES(email))`,
|
||||||
|
[identification, last, first, phone, email, position]);
|
||||||
|
console.log(`${codes.join('+')}: ${last} ${first} → ${res.affectedRows === 1 ? 'creado' : res.affectedRows === 2 ? 'ya existía (completado)' : 'ya existía'}`);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
@@ -2,6 +2,7 @@ import { app } from './app.js';
|
|||||||
import { env } from './config/env.js';
|
import { env } from './config/env.js';
|
||||||
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
|
import { startRecurringTaskReminderJob } from './jobs/recurringTaskReminders.js';
|
||||||
import { startMeetingReminderJob } from './jobs/meetingReminders.js';
|
import { startMeetingReminderJob } from './jobs/meetingReminders.js';
|
||||||
|
import { startIntegrationSyncJob } from './jobs/integrationSync.js';
|
||||||
|
|
||||||
app.listen(env.port, () => {
|
app.listen(env.port, () => {
|
||||||
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
|
console.log(`[server] project.linkedcorp.net backend escuchando en :${env.port} (${env.nodeEnv})`);
|
||||||
@@ -9,3 +10,4 @@ app.listen(env.port, () => {
|
|||||||
|
|
||||||
startRecurringTaskReminderJob();
|
startRecurringTaskReminderJob();
|
||||||
startMeetingReminderJob();
|
startMeetingReminderJob();
|
||||||
|
startIntegrationSyncJob();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro,
|
|||||||
|
|
||||||
| Conector | Carpeta | Estado |
|
| Conector | Carpeta | Estado |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.0.0 |
|
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.1.0 |
|
||||||
|
|
||||||
## Reglas que todo conector debe cumplir
|
## Reglas que todo conector debe cumplir
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro,
|
|||||||
| `GET /v1/sources` | `{ sources: [{ id, name }] }` — carpetas/bases configuradas |
|
| `GET /v1/sources` | `{ sources: [{ id, name }] }` — carpetas/bases configuradas |
|
||||||
| `GET /v1/sources/:source/tables` | `{ tables: [{ name, records, fields, version, modifiedAt }] }` |
|
| `GET /v1/sources/:source/tables` | `{ tables: [{ name, records, fields, version, modifiedAt }] }` |
|
||||||
| `GET /v1/sources/:source/tables/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` |
|
| `GET /v1/sources/:source/tables/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` |
|
||||||
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, total, offset, limit, fields, rows }` |
|
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, maxRecno, total, offset, limit, fields, rows }` |
|
||||||
|
|
||||||
Parámetros de `rows`:
|
Parámetros de `rows`:
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ Parámetros de `rows`:
|
|||||||
- `q=texto` y `qFields=A,B`: contiene, sin distinguir mayúsculas ni tildes.
|
- `q=texto` y `qFields=A,B`: contiene, sin distinguir mayúsculas ni tildes.
|
||||||
- `eq.CAMPO=valor`: igualdad exacta (sin distinguir mayúsculas). Se pueden combinar varios.
|
- `eq.CAMPO=valor`: igualdad exacta (sin distinguir mayúsculas). Se pueden combinar varios.
|
||||||
- `includeDeleted=1`: incluye los registros marcados como borrados (`_deleted`).
|
- `includeDeleted=1`: incluye los registros marcados como borrados (`_deleted`).
|
||||||
|
- Sincronización incremental (v1.1): `afterRecno=N` devuelve solo lo agregado después del registro N;
|
||||||
|
`changedField=CAMPO&changedSince=valor` solo lo modificado después de ese valor (campo de fecha/hora
|
||||||
|
del origen, comparado tal como lo devuelve el conector). Con ambos, basta cumplir uno. La respuesta
|
||||||
|
trae `maxRecno` para guardar como próximo `afterRecno`. Una sincronización completa periódica
|
||||||
|
recoge los borrados.
|
||||||
|
|
||||||
Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica:
|
Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica:
|
||||||
el sistema la compara para saber si hace falta volver a sincronizar.
|
el sistema la compara para saber si hace falta volver a sincronizar.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Conector FoxPro 1.0.0
|
# Conector FoxPro 1.1.0
|
||||||
|
|
||||||
API de solo lectura para tablas FoxPro/Visual FoxPro (`.dbf` con memo `.fpt`), según el contrato
|
API de solo lectura para tablas FoxPro/Visual FoxPro (`.dbf` con memo `.fpt`), según el contrato
|
||||||
v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única dependencia: `dbffile`.
|
v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única dependencia: `dbffile`.
|
||||||
@@ -7,14 +7,16 @@ v1 de [`../README.md`](../README.md). Node.js sin servidor web adicional; única
|
|||||||
copia, así no choca con los bloqueos de SAGA. Si FoxPro tiene la tabla en uso exclusivo → `423`.
|
copia, así no choca con los bloqueos de SAGA. Si FoxPro tiene la tabla en uso exclusivo → `423`.
|
||||||
- El índice `.cdx` no se usa (se recorre la tabla). Las tablas se guardan en caché y la caché se
|
- El índice `.cdx` no se usa (se recorre la tabla). Las tablas se guardan en caché y la caché se
|
||||||
renueva sola cuando cambia el archivo.
|
renueva sola cuando cambia el archivo.
|
||||||
|
- Tablas que el origen escribe todo el día: no se recopian más de una vez cada `limits.minReloadSeconds`
|
||||||
|
(60 por defecto).
|
||||||
- Tipos soportados: C, N, F, Y, I, L, D, T, B, M. Otros (V, Q, W…) se omiten.
|
- Tipos soportados: C, N, F, Y, I, L, D, T, B, M. Otros (V, Q, W…) se omiten.
|
||||||
|
|
||||||
## Instalación en el Windows (`C:\api_project\foxpro`)
|
## Instalación en el Windows (`C:\api_project\foxpro`)
|
||||||
|
|
||||||
1. Copiar esta carpeta a `C:\api_project\foxpro` **con** `node_modules` (no requiere internet en el
|
1. Copiar esta carpeta a `C:\api_project\foxpro` **con** `node_modules` (no requiere internet en el
|
||||||
server). Sin `config.json` ni `logs` de otro equipo.
|
server). Sin `config.json` ni `logs` de otro equipo.
|
||||||
2. Node: copiar `node.exe` portable (Node 20 LTS o superior, Windows x64) a
|
2. Node: usa el Node instalado en el servidor (Exilaser tiene Node 24); si no hay, copiar `node.exe`
|
||||||
`C:\api_project\foxpro\node\node.exe`. Así no hay que instalar Node en el servidor.
|
portable (Node 20+ x64) a `C:\api_project\foxpro\node\node.exe`.
|
||||||
3. Token: `node\node.exe scripts\generar-token.js`. El **TOKEN** se carga en el sistema; el
|
3. Token: `node\node.exe scripts\generar-token.js`. El **TOKEN** se carga en el sistema; el
|
||||||
**tokenSha256** va en `config.json`.
|
**tokenSha256** va en `config.json`.
|
||||||
4. `config.json` (a partir de `config.example.json`):
|
4. `config.json` (a partir de `config.example.json`):
|
||||||
@@ -40,5 +42,5 @@ Logs: `logs\acceso-AAAA-MM-DD.log` (sin tokens ni valores buscados).
|
|||||||
|
|
||||||
## Desarrollo
|
## Desarrollo
|
||||||
|
|
||||||
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (16 pruebas).
|
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (19 pruebas).
|
||||||
- `npm run token`: genera token + hash.
|
- `npm run token`: genera token + hash.
|
||||||
|
|||||||
@@ -1,24 +1,75 @@
|
|||||||
{
|
{
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 8455,
|
"port": 8455,
|
||||||
"tls": { "pfx": "", "passphraseEnv": "CONECTOR_TLS_PASS" },
|
"tls": {
|
||||||
|
"pfx": "",
|
||||||
|
"passphraseEnv": "CONECTOR_TLS_PASS"
|
||||||
|
},
|
||||||
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
|
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
|
||||||
"allowIps": ["192.168.222.3", "192.168.222.40"],
|
"allowIps": [
|
||||||
|
"192.168.222.3",
|
||||||
|
"192.168.222.40"
|
||||||
|
],
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"id": "nomina",
|
"id": "nomina",
|
||||||
"name": "Nómina",
|
"name": "Nómina",
|
||||||
"path": "C:\\Sistemas\\nomina",
|
"path": "C:\\Sistema\\Nomina\\Datos\\Exilaser",
|
||||||
"encoding": "cp1252",
|
"encoding": "cp1252",
|
||||||
"tables": "*",
|
"tables": {
|
||||||
"denyFields": ["SUELDO", "SALARIO", "CLAVE", "PASSWORD"]
|
"EMPLEADO": {
|
||||||
|
"fields": [
|
||||||
|
"EMPCED",
|
||||||
|
"EMPAPE",
|
||||||
|
"EMPNOM",
|
||||||
|
"EMPTEL",
|
||||||
|
"EMPTEL1",
|
||||||
|
"EMPFECNAC",
|
||||||
|
"EMPCARGO",
|
||||||
|
"EMPDEPART",
|
||||||
|
"EMPINGRESO",
|
||||||
|
"EMPSALIDA",
|
||||||
|
"EMPACTIVO"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "clientes",
|
"id": "clientes",
|
||||||
"name": "Clientes SAGA",
|
"name": "SAGA (clientes y proveedores)",
|
||||||
"path": "C:\\Sistemas\\saga",
|
"path": "C:\\Sistema\\Datos\\exilaser",
|
||||||
"encoding": "cp1252",
|
"encoding": "cp850",
|
||||||
"tables": { "CLIENTES": { "fields": ["CODIGO", "CEDULA", "NOMBRE", "TELEFONO", "EMAIL", "FECNAC"] } }
|
"tables": {
|
||||||
|
"CLIENTES": {
|
||||||
|
"fields": [
|
||||||
|
"CLICOD",
|
||||||
|
"CLIRUC",
|
||||||
|
"CLINOM",
|
||||||
|
"CLITEL",
|
||||||
|
"CLIEMAIL",
|
||||||
|
"CLIDIR",
|
||||||
|
"CLICIU",
|
||||||
|
"CLIPERSONA",
|
||||||
|
"CLITIPRUC",
|
||||||
|
"CLISEXO",
|
||||||
|
"INACTIVO",
|
||||||
|
"CLIESTADO",
|
||||||
|
"FECHAACT"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"PROVEDOR": {
|
||||||
|
"fields": [
|
||||||
|
"PROVCOD",
|
||||||
|
"PROVNOM",
|
||||||
|
"PROVRUC",
|
||||||
|
"PROVTEL",
|
||||||
|
"PROVEMAIL",
|
||||||
|
"PROVCIU",
|
||||||
|
"PROVTIPO",
|
||||||
|
"INACTIVO"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"limits": {
|
"limits": {
|
||||||
@@ -28,7 +79,8 @@
|
|||||||
"maxQueuedReads": 10,
|
"maxQueuedReads": 10,
|
||||||
"cacheMaxRecordsPerTable": 100000,
|
"cacheMaxRecordsPerTable": 100000,
|
||||||
"cacheMaxTables": 10,
|
"cacheMaxTables": 10,
|
||||||
"requestTimeoutMs": 30000
|
"requestTimeoutMs": 30000,
|
||||||
|
"minReloadSeconds": 60
|
||||||
},
|
},
|
||||||
"logDir": "logs"
|
"logDir": "logs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkedcorp-conector-foxpro",
|
"name": "linkedcorp-conector-foxpro",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
|
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async function main() {
|
|||||||
allowIps: ['127.0.0.1'],
|
allowIps: ['127.0.0.1'],
|
||||||
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
|
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
|
||||||
logDir: path.join(dir, 'logs'),
|
logDir: path.join(dir, 'logs'),
|
||||||
|
limits: { minReloadSeconds: 0 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
|
const srv = spawn(process.execPath, [path.join(__dirname, '..', 'src', 'server.js')], { env: { ...process.env, CONECTOR_CONFIG: cfgPath }, stdio: ['ignore', 'pipe', 'inherit'] });
|
||||||
@@ -103,6 +104,16 @@ async function main() {
|
|||||||
assert.strictEqual(r.rows.length, 1);
|
assert.strictEqual(r.rows.length, 1);
|
||||||
assert.strictEqual(r.rows[0]._recno, 2);
|
assert.strictEqual(r.rows[0]._recno, 2);
|
||||||
});
|
});
|
||||||
|
await check('incremental: afterRecno', async () => {
|
||||||
|
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?afterRecno=1')).body;
|
||||||
|
assert.deepStrictEqual(r.rows.map((x) => x._recno), [2]);
|
||||||
|
assert.strictEqual(r.maxRecno, 3);
|
||||||
|
});
|
||||||
|
await check('incremental: changedSince', async () => {
|
||||||
|
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?changedField=INGRESO&changedSince=2021-01-01')).body;
|
||||||
|
assert.deepStrictEqual(r.rows.map((x) => x.CEDULA), ['0911122233']);
|
||||||
|
});
|
||||||
|
await check('incremental: changedField sin changedSince → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?changedField=INGRESO')).status, 400));
|
||||||
await check('versión cambia al modificar la tabla', async () => {
|
await check('versión cambia al modificar la tabla', async () => {
|
||||||
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
|
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
|
||||||
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
|
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ function load() {
|
|||||||
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
|
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
|
||||||
cacheMaxTables: Number(limits.cacheMaxTables || 10),
|
cacheMaxTables: Number(limits.cacheMaxTables || 10),
|
||||||
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
|
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
|
||||||
|
minReloadSeconds: Number(limits.minReloadSeconds ?? 60),
|
||||||
},
|
},
|
||||||
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
|
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ async function withSnapshot(ref, fn) {
|
|||||||
function toPlain(value, type) {
|
function toPlain(value, type) {
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
if (Number.isNaN(value.getTime())) return null;
|
// Fecha vacía en FoxPro: la librería la convierte en el día juliano 0 (año -4713).
|
||||||
|
if (Number.isNaN(value.getTime()) || value.getUTCFullYear() < 1000) return null;
|
||||||
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
|
return type === 'D' ? value.toISOString().slice(0, 10) : value.toISOString();
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
@@ -122,7 +123,10 @@ async function loadTable(source, ref, limits) {
|
|||||||
const key = `${source.id}/${ref.table}`;
|
const key = `${source.id}/${ref.table}`;
|
||||||
const sig = await signature(ref);
|
const sig = await signature(ref);
|
||||||
const hit = cache.get(key);
|
const hit = cache.get(key);
|
||||||
if (hit && hit.version === sig.version) {
|
// Tablas que el sistema origen escribe todo el día: no se vuelve a copiar más de una vez cada
|
||||||
|
// minReloadSeconds aunque la firma haya cambiado.
|
||||||
|
const fresh = hit && Date.now() - hit.loadedAt < limits.minReloadSeconds * 1000;
|
||||||
|
if (hit && (hit.version === sig.version || fresh)) {
|
||||||
cache.delete(key);
|
cache.delete(key);
|
||||||
cache.set(key, hit); // LRU
|
cache.set(key, hit); // LRU
|
||||||
return hit;
|
return hit;
|
||||||
@@ -138,7 +142,7 @@ async function loadTable(source, ref, limits) {
|
|||||||
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
|
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
|
||||||
records.push(row);
|
records.push(row);
|
||||||
}
|
}
|
||||||
return { version: sig.version, modifiedAt: sig.modifiedAt, fields, records };
|
return { version: sig.version, modifiedAt: sig.modifiedAt, loadedAt: Date.now(), fields, records };
|
||||||
});
|
});
|
||||||
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
|
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -206,9 +210,17 @@ async function readRows(source, tableName, q, limits) {
|
|||||||
.filter(([k]) => k.startsWith('eq.'))
|
.filter(([k]) => k.startsWith('eq.'))
|
||||||
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
|
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).trim()]);
|
||||||
const text = q.q ? fold(q.q).trim() : '';
|
const text = q.q ? fold(q.q).trim() : '';
|
||||||
|
// Sincronización incremental: solo lo agregado (afterRecno) y/o lo modificado desde una fecha en
|
||||||
|
// un campo de fecha/hora del origen (changedField + changedSince). Si se dan ambos, cualquiera basta.
|
||||||
|
const afterRecno = q.afterRecno !== undefined ? Math.max(parseInt(q.afterRecno, 10) || 0, 0) : null;
|
||||||
|
const changedField = q.changedField ? pick(q.changedField, 'changedField')[0] : null;
|
||||||
|
const changedSince = q.changedSince ? String(q.changedSince) : null;
|
||||||
|
if (!!changedField !== !!changedSince) throw new HttpError(400, 'changedField y changedSince van juntos');
|
||||||
|
const incremental = afterRecno !== null || changedField;
|
||||||
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
|
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
|
||||||
|
|
||||||
const matches = data.records.filter((r) => {
|
const matches = data.records.filter((r) => {
|
||||||
|
if (incremental && !((afterRecno !== null && r._recno > afterRecno) || (changedField && r[changedField] !== null && String(r[changedField]) > changedSince))) return false;
|
||||||
if (!includeDeleted && r._deleted) return false;
|
if (!includeDeleted && r._deleted) return false;
|
||||||
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
|
for (const [f, v] of eq) if (r[f] === null || fold(r[f]).trim() !== v) return false;
|
||||||
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
|
if (text && !qFields.some((f) => r[f] !== null && fold(r[f]).includes(text))) return false;
|
||||||
@@ -221,7 +233,7 @@ async function readRows(source, tableName, q, limits) {
|
|||||||
for (const f of fields) o[f] = r[f];
|
for (const f of fields) o[f] = r[f];
|
||||||
return o;
|
return o;
|
||||||
});
|
});
|
||||||
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, total: matches.length, offset, limit, fields, rows };
|
return { table: ref.table, version: data.version, modifiedAt: data.modifiedAt, maxRecno: data.records.length, total: matches.length, offset, limit, fields, rows };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { listTables, getSchema, readRows, HttpError };
|
module.exports = { listTables, getSchema, readRows, HttpError };
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ ausente queda `null` (el formato varía por versión del agente y SO). Tipo de e
|
|||||||
nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video,
|
nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video,
|
||||||
batería) se guarda en `devices.extra_json`.
|
batería) se guarda en `devices.extra_json`.
|
||||||
|
|
||||||
|
## Clasificación de software
|
||||||
|
|
||||||
|
`softwareClassifier.js` asigna el tipo y el fabricante **al leer**, con reglas de nombre y fabricante
|
||||||
|
(sin IA ni consultas externas): por eso una regla nueva aplica sin esperar otro reporte. El agente no
|
||||||
|
informa si un programa es gratis o de pago ni si tiene licencia; el resultado es orientativo. Las
|
||||||
|
correcciones manuales viven en `software_classifications`. Lo que no está en las listas queda sin
|
||||||
|
clasificar: no se adivina. La pantalla agrupa por fabricante; los productos Microsoft con licencia
|
||||||
|
quedan fuera del grupo.
|
||||||
|
|
||||||
## Instalar el agente en un equipo
|
## Instalar el agente en un equipo
|
||||||
|
|
||||||
1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez).
|
1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez).
|
||||||
|
|||||||
+88
-11
@@ -4,8 +4,9 @@
|
|||||||
> nómina, FoxPro u otra tecnología). No leer si: la tarea no toca datos externos.
|
> nómina, FoxPro u otra tecnología). No leer si: la tarea no toca datos externos.
|
||||||
> Contrato técnico de los conectores: [`data/conectores/README.md`](../data/conectores/README.md).
|
> Contrato técnico de los conectores: [`data/conectores/README.md`](../data/conectores/README.md).
|
||||||
|
|
||||||
Estado (2026-10-29): **conector FoxPro 1.0.0 hecho** (`data/conectores/foxpro/`, 16 pruebas locales
|
Estado (2026-10-29): **conector FoxPro 1.1.0 instalado y funcionando en Exilaser** (ver abajo).
|
||||||
OK, sin probar con tablas reales ni en Windows). **Módulo del sistema: pendiente** (solo diseño).
|
**Módulo del sistema hecho en dev** (migración 038, `backend/src/modules/integrations`, job
|
||||||
|
`integrationSync`), sin commit ni producción.
|
||||||
|
|
||||||
## Arquitectura
|
## Arquitectura
|
||||||
|
|
||||||
@@ -16,8 +17,22 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
|
|||||||
|
|
||||||
- Primer caso: Exilaser, Windows Server `192.168.0.253` (SAGA/SistViso en FoxPro), alcanzado por
|
- Primer caso: Exilaser, Windows Server `192.168.0.253` (SAGA/SistViso en FoxPro), alcanzado por
|
||||||
la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`.
|
la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`.
|
||||||
- Fuentes previstas: `Sistemas\nomina` (empleados) y la tabla de clientes de SAGA (pacientes de la
|
- Fuentes reales (revisadas por SSH, solo estructura, 2026-10-29):
|
||||||
Agenda médica). Nombres reales de tablas y campos: **por confirmar** en el servidor.
|
|
||||||
|
| Fuente | Ruta | Tabla | Registros | Codificación | Campos útiles |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Nómina | `C:\Sistema\Nomina\Datos\Exilaser` | `empleado.DBF` (VFP 0x30, sin memo) | 31 | cp1252 | `EMPCED`, `EMPAPE`, `EMPNOM`, `EMPTEL`, `EMPTEL1`, `EMPFECNAC`, `EMPCARGO`, `EMPDEPART`, `EMPINGRESO`, `EMPSALIDA`, `EMPACTIVO` |
|
||||||
|
| SAGA clientes | `C:\Sistema\Datos\exilaser` | `clientes.DBF` (VFP 0x30, sin memo, ~20 MB) | 32 985 | **cp850** | `CLICOD`, `CLIRUC`, `CLINOM`, `CLITEL`, `CLIEMAIL`, `CLIDIR`, `CLICIU`, `CLIPERSONA`, `CLITIPRUC`, `CLISEXO`, `INACTIVO`, `CLIESTADO`, `FECHAACT` |
|
||||||
|
|
||||||
|
- **No exponer** de nómina: `EMPSUELDO`, `EMPBANCO`, `EMPCUENT`, `EMPTIPOCUE`, `EMPNUMIESS`,
|
||||||
|
`EMPLET*`, `FIJO1-5`, `EMPCARGAS`, `EMPESTADO` (es estado civil) (van en `denyFields` o se usa lista de campos permitidos).
|
||||||
|
- Nómina **no tiene correo**: al crear un usuario desde nómina el correo se escribe a mano.
|
||||||
|
- Clientes: el nombre viene en **un solo campo** (`CLINOM`), sin nacimiento; incluye empresas
|
||||||
|
(RUC) — para pacientes filtrar personas naturales (`CLIPERSONA`/`CLITIPRUC`). El orden
|
||||||
|
apellidos/nombres de `CLINOM` se confirma con una muestra al instalar.
|
||||||
|
- `exilaser14`…`exilaser25` son cierres de años anteriores: **no** se exponen.
|
||||||
|
- `clientes.DBF` cambia todo el día (SAGA escribe): conviene modo **importar** o caché con
|
||||||
|
recarga mínima, para no copiar 20 MB en cada consulta.
|
||||||
|
|
||||||
## Requisitos acordados con el usuario
|
## Requisitos acordados con el usuario
|
||||||
|
|
||||||
@@ -43,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.
|
||||||
@@ -50,12 +71,68 @@ el sistema no cambia. Ver decisión #2 en `DECISIONES.md`.
|
|||||||
- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría
|
- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría
|
||||||
de pruebas de conexión, importaciones y consultas.
|
de pruebas de conexión, importaciones y consultas.
|
||||||
|
|
||||||
|
## Instalación en Exilaser (2026-10-29)
|
||||||
|
|
||||||
|
- `C:\api_project\foxpro`, Node 24 del servidor, tarea programada `LinkedCorp-Conector-FoxPro`
|
||||||
|
(al arranque, usuario SYSTEM, reinicio si cae), regla de firewall del mismo nombre: TCP 8455 solo
|
||||||
|
desde `192.168.222.3` (prod) y `192.168.222.40` (dev).
|
||||||
|
- URL desde LinkedCorp: `http://192.168.0.253:8455` (por la VPN). Token: archivo
|
||||||
|
`~/.config/linkedcorp/conector-foxpro-exilaser.token` (600) en el equipo de desarrollo; en el
|
||||||
|
Windows solo está su SHA-256.
|
||||||
|
- Probado desde dev: sin token → 401; nómina 31 empleados (11 campos permitidos); clientes 32 988
|
||||||
|
(13 campos); primera carga de clientes ~8 s, luego caché (recarga como mínimo cada 60 s);
|
||||||
|
incremental `changedField=FECHAACT` OK; tildes/ñ correctas (cp850).
|
||||||
|
- `CLINOM` viene como **APELLIDOS NOMBRES** (ej. "GONZALEZ JIMENEZ RAUL VICENTE"): al importar se
|
||||||
|
separa en las 2 primeras palabras = apellidos, resto = nombres (los apellidos compuestos se
|
||||||
|
corrigen a mano). Pacientes = `CLIPERSONA = Natural` (en una muestra de 1000: 988 naturales).
|
||||||
|
- Mejora a futuro: correr la tarea con una cuenta de dominio de solo lectura en vez de SYSTEM.
|
||||||
|
- Actualizar el conector: copiar `src/` a `C:\api_project\foxpro\src`, detener la tarea, matar el
|
||||||
|
`node` del conector y volver a iniciar la tarea.
|
||||||
|
|
||||||
|
## Decisiones del usuario sobre pacientes (2026-10-29)
|
||||||
|
|
||||||
|
- Con conexión activa **se permite crear pacientes a mano** (quedan como locales).
|
||||||
|
- Al vincular por cédula, si los datos difieren **manda SAGA** (el cambio queda en auditoría).
|
||||||
|
|
||||||
## Pendientes
|
## Pendientes
|
||||||
|
|
||||||
- [ ] Revisar en el server (SSH, solo lectura, con permiso del usuario) las carpetas de nómina y
|
- [x] Revisar en el server las carpetas de nómina y clientes (estructura, codificación).
|
||||||
clientes: tablas, campos, versión de los DBF y codificación (cp1252/cp850).
|
- [x] Instalar el conector en `C:\api_project\foxpro` y probar desde dev.
|
||||||
- [ ] Instalar el conector en `C:\api_project\foxpro` (node.exe portable + `config.json` + token +
|
- [x] Módulo del sistema (ver "Cómo quedó en el sistema").
|
||||||
`instalar-servicio.ps1`) y probar desde el backend.
|
- [ ] Commit, revisión y producción (migraciones 036–038, conexión y fuentes a configurar allá).
|
||||||
- [ ] Módulo del sistema: migración (conexiones, fuentes/mapeos, empleados, vínculo usuario↔empleado),
|
|
||||||
backend `modules/integrations`, pantalla en Configuración, botón en Organización, alta de
|
## Cómo quedó en el sistema (2026-10-29)
|
||||||
usuario desde nómina, búsqueda de pacientes externa en la Agenda.
|
|
||||||
|
- **Configuración → Conexiones externas** (superadmin): conexiones (URL + token cifrado, Probar) y
|
||||||
|
fuentes Pacientes/Empleados: conexión → fuente → tabla, vista previa de 20 filas, mapeo de campos,
|
||||||
|
filtro (ej. `CLIPERSONA = Natural`), campo de cambios (`FECHAACT`) y modo (pacientes: importar o en
|
||||||
|
vivo). Botones "Traer cambios" y "Sincronización completa".
|
||||||
|
- **Programación** (job en el backend, cada 5 min revisa): pacientes en modo importar → completa
|
||||||
|
**lunes a viernes a las 05:00** (recoge borrados: quedan inactivos) e incremental cada 15 min de
|
||||||
|
06:00 a 21:00 (agregados por `_recno` + modificados por `FECHAACT`). La primera vez hace la completa.
|
||||||
|
El conector solo copia el DBF cuando el sistema le pide datos (como mínimo cada 60 s).
|
||||||
|
- **Organización → Empleados** (admin/superadmin): listado y **"Actualizar empleados"** (a pedido).
|
||||||
|
Los que salen de la nómina quedan inactivos. **Nuevo usuario → Desde nómina** precarga nombre,
|
||||||
|
cargo y departamento (si coincide por nombre) y vincula el usuario con el empleado.
|
||||||
|
- **Médicos (2026-10-29)**: casi todos facturan como **proveedores** de SAGA (`provedor.DBF`, expuesto
|
||||||
|
en el conector solo con código, nombre, RUC, teléfono, correo, ciudad, tipo e inactivo). Se cargan
|
||||||
|
como empleados con `origin=provider` con `backend/src/scripts/import-provider-employees.js` (lee
|
||||||
|
del conector por código de proveedor; sin datos personales en Git). En dev: 9 proveedores + 2 que ya
|
||||||
|
eran de nómina (Torres Palacios Nataly, Rodríguez Nava Christian). Códigos usados:
|
||||||
|
`137+031 142+033 809 234 040 794 817 565 540 405 676` (`--position=MEDICO`).
|
||||||
|
- **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
|
||||||
|
en vivo el selector agrega los del origen ("Del sistema origen") y al elegir uno se trae solo ese.
|
||||||
|
El detalle del paciente muestra su origen.
|
||||||
|
- **Nombres de SAGA**: `CLINOM` no tiene orden fijo (a veces nombre primero), así que no se separa:
|
||||||
|
se guarda entero como nombre visible (`name_split=0`). Al editar el paciente, recepción separa
|
||||||
|
apellidos y nombres; desde ahí la sincronización ya no pisa su nombre (sí cédula/teléfono/correo).
|
||||||
|
- Prueba en dev con datos reales: 31 empleados (24 activos), 31 220 pacientes en 24 s; repetir la
|
||||||
|
completa sin cambios 1,7 s; 14 cédulas repetidas en SAGA quedan sin cédula en el segundo registro.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "4.11.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.11.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.11.0",
|
"version": "4.16.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { Fragment, useMemo, useState } from 'react';
|
||||||
import { MoreVertical, GripVertical } from 'lucide-react';
|
import { MoreVertical, GripVertical, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { useDragReorder } from '../hooks/useDragReorder.js';
|
import { useDragReorder } from '../hooks/useDragReorder.js';
|
||||||
import { Flyout, FlyoutItem } from './Flyout.jsx';
|
import { Flyout, FlyoutItem } from './Flyout.jsx';
|
||||||
@@ -9,15 +9,25 @@ import { Flyout, FlyoutItem } from './Flyout.jsx';
|
|||||||
// `onReorder(idsEnNuevoOrden)` es opcional: si se pasa, habilita arrastrar filas con el mouse para
|
// `onReorder(idsEnNuevoOrden)` es opcional: si se pasa, habilita arrastrar filas con el mouse para
|
||||||
// definir un orden manual (columna con icono de agarre a la izquierda) — solo mientras no haya un
|
// definir un orden manual (columna con icono de agarre a la izquierda) — solo mientras no haya un
|
||||||
// ordenamiento por columna activo, para no mezclar los dos mecanismos de orden a la vez.
|
// ordenamiento por columna activo, para no mezclar los dos mecanismos de orden a la vez.
|
||||||
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder }) {
|
// `getChildren(fila)` es opcional: si devuelve filas, la fila se pliega/despliega con su boton o con
|
||||||
|
// un clic y las hijas se muestran debajo. Al buscar, solo quedan las hijas que coinciden y se abren.
|
||||||
|
export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder, getChildren }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
|
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
|
||||||
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
|
const haystack = (r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase();
|
||||||
|
const toggleRow = (key) => setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (!next.delete(key)) next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!searchValue) return rows;
|
if (!searchValue) return rows;
|
||||||
const q = searchValue.toLowerCase();
|
const q = searchValue.toLowerCase();
|
||||||
return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q));
|
return rows.filter((r) => haystack(r).includes(q) || getChildren?.(r)?.some((c) => haystack(c).includes(q)));
|
||||||
}, [rows, searchValue, getSearchHaystack]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rows, searchValue, getSearchHaystack, getChildren]);
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (!sort.key) return filtered;
|
if (!sort.key) return filtered;
|
||||||
@@ -38,6 +48,55 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
|
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRow(row, { isParent, open, onToggle, child } = {}) {
|
||||||
|
const actions = rowActions?.(row) || [];
|
||||||
|
const activate = isParent ? onToggle : onRowClick ? () => onRowClick(row) : null;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={getRowKey(row)}
|
||||||
|
onClick={() => activate?.()}
|
||||||
|
onContextMenu={actions.length ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined}
|
||||||
|
tabIndex={activate ? 0 : undefined}
|
||||||
|
onKeyDown={(event) => { if (activate && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); activate(); } }}
|
||||||
|
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
||||||
|
{...(dragEnabled ? getRowProps(row) : {})}
|
||||||
|
>
|
||||||
|
{dragEnabled && (
|
||||||
|
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
|
||||||
|
<GripVertical size={15} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map((col, index) => (
|
||||||
|
<td key={col.key} style={index === 0 && child ? { paddingLeft: 54 } : undefined}>
|
||||||
|
{index === 0 && getChildren && !child ? (
|
||||||
|
<span className="list-tree-cell">
|
||||||
|
{isParent ? <button type="button" className="tree-toggle" aria-expanded={open} aria-label={t(open ? 'common_collapse' : 'common_expand')} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
|
||||||
|
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
</button> : <span className="tree-toggle list-tree-dot" aria-hidden="true" />}
|
||||||
|
{col.render ? col.render(row) : row[col.key]}
|
||||||
|
</span>
|
||||||
|
) : (col.render ? col.render(row) : row[col.key])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{rowActions && (
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<Flyout trigger={({ onClick }) => (
|
||||||
|
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
|
||||||
|
)}>
|
||||||
|
{actions.map((action, i) => (
|
||||||
|
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</FlyoutItem>
|
||||||
|
))}
|
||||||
|
</Flyout>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list-card">
|
<div className="list-card">
|
||||||
<table className="list-table">
|
<table className="list-table">
|
||||||
@@ -53,39 +112,20 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{displayRows.map((row) => (
|
{displayRows.map((row) => {
|
||||||
<tr
|
const key = getRowKey(row);
|
||||||
key={getRowKey(row)}
|
const allChildren = getChildren?.(row);
|
||||||
onClick={() => onRowClick?.(row)}
|
const isParent = !!allChildren?.length;
|
||||||
onContextMenu={rowActions ? (event) => { event.preventDefault(); event.currentTarget.querySelector('.row-menu-btn')?.click(); } : undefined}
|
const q = searchValue?.toLowerCase();
|
||||||
tabIndex={onRowClick ? 0 : undefined}
|
const children = isParent && q ? allChildren.filter((c) => haystack(c).includes(q)) : allChildren;
|
||||||
onKeyDown={(event) => { if (onRowClick && event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onRowClick(row); } }}
|
const open = isParent && (expanded.has(key) || (!!q && children.length > 0));
|
||||||
className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''}
|
return (
|
||||||
{...(dragEnabled ? getRowProps(row) : {})}
|
<Fragment key={key}>
|
||||||
>
|
{renderRow(row, { isParent, open, onToggle: () => toggleRow(key) })}
|
||||||
{dragEnabled && (
|
{open && children.map((child) => renderRow(child, { child: true }))}
|
||||||
<td className="drag-handle-cell" onClick={(e) => e.stopPropagation()} {...getHandleProps(row)}>
|
</Fragment>
|
||||||
<GripVertical size={15} />
|
);
|
||||||
</td>
|
})}
|
||||||
)}
|
|
||||||
{columns.map((col) => (
|
|
||||||
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
|
|
||||||
))}
|
|
||||||
{rowActions && (
|
|
||||||
<td onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Flyout trigger={({ onClick }) => (
|
|
||||||
<button className="icon-btn row-menu-btn" aria-label={t('common_actions')} onClick={onClick}><MoreVertical /></button>
|
|
||||||
)}>
|
|
||||||
{rowActions(row).map((action, i) => (
|
|
||||||
<FlyoutItem key={i} icon={action.icon} danger={action.danger} onClick={action.onClick}>
|
|
||||||
{action.label}
|
|
||||||
</FlyoutItem>
|
|
||||||
))}
|
|
||||||
</Flyout>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ export const messages = {
|
|||||||
common_search: 'Buscar...',
|
common_search: 'Buscar...',
|
||||||
common_select: 'Seleccionar',
|
common_select: 'Seleccionar',
|
||||||
common_actions: 'Acciones',
|
common_actions: 'Acciones',
|
||||||
|
common_expand: 'Desplegar',
|
||||||
|
common_collapse: 'Plegar',
|
||||||
common_search_no_results: 'Sin resultados',
|
common_search_no_results: 'Sin resultados',
|
||||||
common_actions: 'Acciones',
|
common_actions: 'Acciones',
|
||||||
common_view: 'Ver',
|
common_view: 'Ver',
|
||||||
@@ -466,6 +468,10 @@ export const messages = {
|
|||||||
eq_assigned_to: 'Asignado a',
|
eq_assigned_to: 'Asignado a',
|
||||||
eq_notes: 'Notas',
|
eq_notes: 'Notas',
|
||||||
eq_cpu: 'Procesador',
|
eq_cpu: 'Procesador',
|
||||||
|
eq_cpu_year: 'Año del procesador',
|
||||||
|
eq_cpu_year_get: 'Obtener fecha',
|
||||||
|
eq_cpu_year_loading: 'Consultando...',
|
||||||
|
eq_cpu_year_hint: 'La IA consulta solo el nombre del procesador y devuelve el año de lanzamiento (el más antiguo si hay dos).',
|
||||||
eq_ram: 'Memoria RAM',
|
eq_ram: 'Memoria RAM',
|
||||||
eq_disk_total: 'Almacenamiento',
|
eq_disk_total: 'Almacenamiento',
|
||||||
eq_disk_max_used: 'volumen más lleno',
|
eq_disk_max_used: 'volumen más lleno',
|
||||||
@@ -493,6 +499,33 @@ export const messages = {
|
|||||||
eq_before: 'Antes',
|
eq_before: 'Antes',
|
||||||
eq_after: 'Después',
|
eq_after: 'Después',
|
||||||
eq_history_empty: 'Sin cambios registrados entre reportes.',
|
eq_history_empty: 'Sin cambios registrados entre reportes.',
|
||||||
|
eq_tab_licenses: 'Licenciamiento',
|
||||||
|
eq_sw_type: 'Tipo',
|
||||||
|
eq_sw_type_all: 'Todos los tipos',
|
||||||
|
eq_sw_type_free: 'Gratuito',
|
||||||
|
eq_sw_type_paid: 'Pago',
|
||||||
|
eq_sw_type_personal_free: 'Gratis solo personal',
|
||||||
|
eq_sw_type_component: 'Componente o incluido',
|
||||||
|
eq_sw_type_unclassified: 'Sin clasificar',
|
||||||
|
eq_sw_programs: 'programas',
|
||||||
|
eq_sw_classify_as: 'Marcar como',
|
||||||
|
eq_sw_classify_auto: 'Volver a automático',
|
||||||
|
eq_sw_manual: 'Tipo corregido a mano',
|
||||||
|
eq_sw_hint: 'Clasificación orientativa por nombre y fabricante: no es un dictamen legal ni detecta software sin licencia.',
|
||||||
|
eq_lic_windows: 'Windows',
|
||||||
|
eq_lic_office: 'Office',
|
||||||
|
eq_lic_office_installed: 'Office instalado',
|
||||||
|
eq_lic_edition: 'Edición',
|
||||||
|
eq_lic_product_id: 'Product ID',
|
||||||
|
eq_lic_key: 'Clave (parcial)',
|
||||||
|
eq_lic_channel: 'Canal',
|
||||||
|
eq_lic_owner: 'Registrado a',
|
||||||
|
eq_lic_oem: 'OEM',
|
||||||
|
eq_lic_not_oem: 'Retail / Volumen',
|
||||||
|
eq_lic_msdn: 'MSDN: solo desarrollo y pruebas',
|
||||||
|
eq_lic_no_windows: 'El agente no reportó licencia de Windows (equipo sin Windows o reporte anterior).',
|
||||||
|
eq_lic_no_office: 'El agente no reportó licencias de Office.',
|
||||||
|
eq_lic_hint: 'Datos que reporta el agente desde el registro: no indican si la licencia está activada. Microsoft 365 por suscripción se licencia por usuario y no aparece aquí. La clave se muestra parcial.',
|
||||||
eq_change_field: 'Dato modificado',
|
eq_change_field: 'Dato modificado',
|
||||||
eq_change_disk_added: 'Disco agregado',
|
eq_change_disk_added: 'Disco agregado',
|
||||||
eq_change_disk_removed: 'Disco retirado',
|
eq_change_disk_removed: 'Disco retirado',
|
||||||
@@ -663,6 +696,75 @@ export const messages = {
|
|||||||
|
|
||||||
org_departments: 'Departamentos',
|
org_departments: 'Departamentos',
|
||||||
org_users: 'Usuarios',
|
org_users: 'Usuarios',
|
||||||
|
config_integrations: 'Conexiones externas',
|
||||||
|
int_connections: 'Conexiones',
|
||||||
|
int_new_connection: 'Nueva conexión',
|
||||||
|
int_url: 'URL del conector',
|
||||||
|
int_token: 'Token del conector',
|
||||||
|
int_token_keep: 'Déjalo vacío para conservar el token guardado.',
|
||||||
|
int_test: 'Probar conexión',
|
||||||
|
int_test_ok: 'Conexión correcta',
|
||||||
|
int_sources: 'Fuentes de datos',
|
||||||
|
int_purpose_patients: 'Pacientes (Agenda médica)',
|
||||||
|
int_purpose_employees: 'Empleados (nómina)',
|
||||||
|
int_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',
|
||||||
|
int_source: 'Fuente',
|
||||||
|
int_table: 'Tabla',
|
||||||
|
int_mode: 'Modo',
|
||||||
|
int_mode_import: 'Importar (copia en el sistema)',
|
||||||
|
int_mode_live: 'Consulta en vivo',
|
||||||
|
int_preview: 'Vista previa (20 filas)',
|
||||||
|
int_mapping: 'Campos a usar',
|
||||||
|
int_filter: 'Filtro (solo registros con este valor)',
|
||||||
|
int_filter_field: 'Campo',
|
||||||
|
int_filter_value: 'Valor',
|
||||||
|
int_changed_field: 'Campo de última modificación (para traer solo cambios)',
|
||||||
|
int_last_sync: 'Última sincronización',
|
||||||
|
int_sync_now: 'Traer cambios',
|
||||||
|
int_sync_full: 'Sincronización completa',
|
||||||
|
int_schedule_hint: 'Automático: completa de lunes a viernes a las 05:00 y solo cambios cada 15 min de 06:00 a 21:00.',
|
||||||
|
int_received: 'recibidos',
|
||||||
|
int_created: 'nuevos',
|
||||||
|
int_updated: 'actualizados',
|
||||||
|
int_deactivated: 'inactivados',
|
||||||
|
int_target_identification: 'Cédula / identificación',
|
||||||
|
int_target_lastNames: 'Apellidos',
|
||||||
|
int_target_firstNames: 'Nombres',
|
||||||
|
int_target_phone: 'Teléfono',
|
||||||
|
int_target_phone2: 'Teléfono 2',
|
||||||
|
int_target_birthDate: 'Nacimiento',
|
||||||
|
int_target_position: 'Cargo',
|
||||||
|
int_target_department: 'Departamento',
|
||||||
|
int_target_hireDate: 'Ingreso',
|
||||||
|
int_target_leaveDate: 'Salida',
|
||||||
|
int_target_active: 'Activo',
|
||||||
|
int_target_externalId: 'Código en el origen',
|
||||||
|
int_target_fullName: 'Nombre completo',
|
||||||
|
int_target_email: 'Correo',
|
||||||
|
int_required: 'obligatorio',
|
||||||
|
org_employees: 'Empleados',
|
||||||
|
emp_origin: 'Origen',
|
||||||
|
emp_origin_payroll: 'Nómina',
|
||||||
|
emp_origin_provider: 'Proveedor (SAGA)',
|
||||||
|
emp_protected_hint: 'Los empleados con usuario en el sistema no se actualizan ni se inactivan al actualizar.',
|
||||||
|
emp_update: 'Actualizar empleados',
|
||||||
|
emp_never: 'Nunca actualizado',
|
||||||
|
emp_user: 'Usuario del sistema',
|
||||||
|
emp_no_source: 'No hay fuente de nómina configurada (Configuración → Conexiones externas).',
|
||||||
|
user_from_payroll: 'Desde nómina',
|
||||||
|
clinic_origin: 'Origen',
|
||||||
|
clinic_origin_local: 'Creado aquí',
|
||||||
|
clinic_source_name: 'Nombre en el origen',
|
||||||
|
clinic_name_unsplit: 'Nombre traído del origen sin separar: al editar, separa apellidos y nombres.',
|
||||||
|
clinic_external_results: 'Del sistema origen',
|
||||||
department_new: 'Nuevo departamento',
|
department_new: 'Nuevo departamento',
|
||||||
department_parent: 'Departamento padre',
|
department_parent: 'Departamento padre',
|
||||||
user_new: 'Nuevo usuario',
|
user_new: 'Nuevo usuario',
|
||||||
@@ -988,6 +1090,8 @@ export const messages = {
|
|||||||
common_search: 'Search...',
|
common_search: 'Search...',
|
||||||
common_select: 'Select',
|
common_select: 'Select',
|
||||||
common_actions: 'Actions',
|
common_actions: 'Actions',
|
||||||
|
common_expand: 'Expand',
|
||||||
|
common_collapse: 'Collapse',
|
||||||
common_search_no_results: 'No results',
|
common_search_no_results: 'No results',
|
||||||
common_actions: 'Actions',
|
common_actions: 'Actions',
|
||||||
common_view: 'View',
|
common_view: 'View',
|
||||||
@@ -1160,6 +1264,10 @@ export const messages = {
|
|||||||
eq_assigned_to: 'Assigned to',
|
eq_assigned_to: 'Assigned to',
|
||||||
eq_notes: 'Notes',
|
eq_notes: 'Notes',
|
||||||
eq_cpu: 'Processor',
|
eq_cpu: 'Processor',
|
||||||
|
eq_cpu_year: 'Processor year',
|
||||||
|
eq_cpu_year_get: 'Get date',
|
||||||
|
eq_cpu_year_loading: 'Looking up...',
|
||||||
|
eq_cpu_year_hint: 'The AI looks up only the processor name and returns its release year (the oldest if there are two).',
|
||||||
eq_ram: 'RAM',
|
eq_ram: 'RAM',
|
||||||
eq_disk_total: 'Storage',
|
eq_disk_total: 'Storage',
|
||||||
eq_disk_max_used: 'fullest volume',
|
eq_disk_max_used: 'fullest volume',
|
||||||
@@ -1187,6 +1295,33 @@ export const messages = {
|
|||||||
eq_before: 'Before',
|
eq_before: 'Before',
|
||||||
eq_after: 'After',
|
eq_after: 'After',
|
||||||
eq_history_empty: 'No changes recorded between reports.',
|
eq_history_empty: 'No changes recorded between reports.',
|
||||||
|
eq_tab_licenses: 'Licensing',
|
||||||
|
eq_sw_type: 'Type',
|
||||||
|
eq_sw_type_all: 'All types',
|
||||||
|
eq_sw_type_free: 'Free',
|
||||||
|
eq_sw_type_paid: 'Paid',
|
||||||
|
eq_sw_type_personal_free: 'Free for personal use only',
|
||||||
|
eq_sw_type_component: 'Component or bundled',
|
||||||
|
eq_sw_type_unclassified: 'Unclassified',
|
||||||
|
eq_sw_programs: 'programs',
|
||||||
|
eq_sw_classify_as: 'Mark as',
|
||||||
|
eq_sw_classify_auto: 'Back to automatic',
|
||||||
|
eq_sw_manual: 'Type set manually',
|
||||||
|
eq_sw_hint: 'Indicative classification by name and publisher: it is not a legal opinion and does not detect unlicensed software.',
|
||||||
|
eq_lic_windows: 'Windows',
|
||||||
|
eq_lic_office: 'Office',
|
||||||
|
eq_lic_office_installed: 'Installed Office',
|
||||||
|
eq_lic_edition: 'Edition',
|
||||||
|
eq_lic_product_id: 'Product ID',
|
||||||
|
eq_lic_key: 'Key (partial)',
|
||||||
|
eq_lic_channel: 'Channel',
|
||||||
|
eq_lic_owner: 'Registered to',
|
||||||
|
eq_lic_oem: 'OEM',
|
||||||
|
eq_lic_not_oem: 'Retail / Volume',
|
||||||
|
eq_lic_msdn: 'MSDN: development and testing only',
|
||||||
|
eq_lic_no_windows: 'The agent did not report a Windows license (non-Windows device or older report).',
|
||||||
|
eq_lic_no_office: 'The agent did not report Office licenses.',
|
||||||
|
eq_lic_hint: 'Data reported by the agent from the registry: it does not tell whether the license is activated. Microsoft 365 subscriptions are licensed per user and do not show here. The key is shown partially.',
|
||||||
eq_change_field: 'Field changed',
|
eq_change_field: 'Field changed',
|
||||||
eq_change_disk_added: 'Disk added',
|
eq_change_disk_added: 'Disk added',
|
||||||
eq_change_disk_removed: 'Disk removed',
|
eq_change_disk_removed: 'Disk removed',
|
||||||
@@ -1357,6 +1492,75 @@ export const messages = {
|
|||||||
|
|
||||||
org_departments: 'Departments',
|
org_departments: 'Departments',
|
||||||
org_users: 'Users',
|
org_users: 'Users',
|
||||||
|
config_integrations: 'External connections',
|
||||||
|
int_connections: 'Connections',
|
||||||
|
int_new_connection: 'New connection',
|
||||||
|
int_url: 'Connector URL',
|
||||||
|
int_token: 'Connector token',
|
||||||
|
int_token_keep: 'Leave empty to keep the saved token.',
|
||||||
|
int_test: 'Test connection',
|
||||||
|
int_test_ok: 'Connection OK',
|
||||||
|
int_sources: 'Data sources',
|
||||||
|
int_purpose_patients: 'Patients (Medical schedule)',
|
||||||
|
int_purpose_employees: 'Employees (payroll)',
|
||||||
|
int_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',
|
||||||
|
int_source: 'Source',
|
||||||
|
int_table: 'Table',
|
||||||
|
int_mode: 'Mode',
|
||||||
|
int_mode_import: 'Import (copy into the system)',
|
||||||
|
int_mode_live: 'Live lookup',
|
||||||
|
int_preview: 'Preview (20 rows)',
|
||||||
|
int_mapping: 'Fields to use',
|
||||||
|
int_filter: 'Filter (only records with this value)',
|
||||||
|
int_filter_field: 'Field',
|
||||||
|
int_filter_value: 'Value',
|
||||||
|
int_changed_field: 'Last-modified field (to fetch only changes)',
|
||||||
|
int_last_sync: 'Last sync',
|
||||||
|
int_sync_now: 'Fetch changes',
|
||||||
|
int_sync_full: 'Full sync',
|
||||||
|
int_schedule_hint: 'Automatic: full sync Monday to Friday at 05:00 and changes only every 15 min from 06:00 to 21:00.',
|
||||||
|
int_received: 'received',
|
||||||
|
int_created: 'new',
|
||||||
|
int_updated: 'updated',
|
||||||
|
int_deactivated: 'deactivated',
|
||||||
|
int_target_identification: 'ID number',
|
||||||
|
int_target_lastNames: 'Last names',
|
||||||
|
int_target_firstNames: 'First names',
|
||||||
|
int_target_phone: 'Phone',
|
||||||
|
int_target_phone2: 'Phone 2',
|
||||||
|
int_target_birthDate: 'Birth date',
|
||||||
|
int_target_position: 'Position',
|
||||||
|
int_target_department: 'Department',
|
||||||
|
int_target_hireDate: 'Hire date',
|
||||||
|
int_target_leaveDate: 'Leave date',
|
||||||
|
int_target_active: 'Active',
|
||||||
|
int_target_externalId: 'Source code',
|
||||||
|
int_target_fullName: 'Full name',
|
||||||
|
int_target_email: 'Email',
|
||||||
|
int_required: 'required',
|
||||||
|
org_employees: 'Employees',
|
||||||
|
emp_origin: 'Source',
|
||||||
|
emp_origin_payroll: 'Payroll',
|
||||||
|
emp_origin_provider: 'Supplier (SAGA)',
|
||||||
|
emp_protected_hint: 'Employees with a system user are not updated or deactivated by the update.',
|
||||||
|
emp_update: 'Update employees',
|
||||||
|
emp_never: 'Never updated',
|
||||||
|
emp_user: 'System user',
|
||||||
|
emp_no_source: 'No payroll source configured (Settings → External connections).',
|
||||||
|
user_from_payroll: 'From payroll',
|
||||||
|
clinic_origin: 'Source',
|
||||||
|
clinic_origin_local: 'Created here',
|
||||||
|
clinic_source_name: 'Name in source',
|
||||||
|
clinic_name_unsplit: 'Name imported without split: when editing, separate last and first names.',
|
||||||
|
clinic_external_results: 'From source system',
|
||||||
department_new: 'New department',
|
department_new: 'New department',
|
||||||
department_parent: 'Parent department',
|
department_parent: 'Parent department',
|
||||||
user_new: 'New user',
|
user_new: 'New user',
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export default function AgendaMedicaPage() {
|
|||||||
[patientTotal, setPatientTotal] = useState(0);
|
[patientTotal, setPatientTotal] = useState(0);
|
||||||
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
|
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
|
||||||
const [apptPatients, setApptPatients] = useState([]),
|
const [apptPatients, setApptPatients] = useState([]),
|
||||||
[apptSearch, setApptSearch] = useState('');
|
[apptSearch, setApptSearch] = useState(''),
|
||||||
|
// Pacientes del sistema origen (Conexiones externas, modo consulta en vivo): aún no importados.
|
||||||
|
[externalPatients, setExternalPatients] = useState([]),
|
||||||
|
[patientSource, setPatientSource] = useState(null);
|
||||||
const [popup, setPopup] = useState(null),
|
const [popup, setPopup] = useState(null),
|
||||||
[editing, setEditing] = useState(false),
|
[editing, setEditing] = useState(false),
|
||||||
[conflict, setConflict] = useState(''),
|
[conflict, setConflict] = useState(''),
|
||||||
@@ -98,6 +101,7 @@ export default function AgendaMedicaPage() {
|
|||||||
api.clinicMe().then(async staff => {
|
api.clinicMe().then(async staff => {
|
||||||
setMe(staff);
|
setMe(staff);
|
||||||
await loadDoctors();
|
await loadDoctors();
|
||||||
|
if (staff.scope === 'all') api.clinicPatientSource().then(setPatientSource).catch(() => setPatientSource(null));
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
if (isAdmin) setTab('config');
|
if (isAdmin) setTab('config');
|
||||||
else setError(e.message);
|
else setError(e.message);
|
||||||
@@ -169,6 +173,7 @@ export default function AgendaMedicaPage() {
|
|||||||
if (kind === 'appointment' && isReception) {
|
if (kind === 'appointment' && isReception) {
|
||||||
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
||||||
setApptSearch('');
|
setApptSearch('');
|
||||||
|
setExternalPatients([]);
|
||||||
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
|
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
|
||||||
setApptPatients(current);
|
setApptPatients(current);
|
||||||
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
|
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
|
||||||
@@ -180,6 +185,9 @@ export default function AgendaMedicaPage() {
|
|||||||
function searchApptPatients(text) {
|
function searchApptPatients(text) {
|
||||||
setApptSearch(text);
|
setApptSearch(text);
|
||||||
const seq = ++searchSeq.current;
|
const seq = ++searchSeq.current;
|
||||||
|
if (patientSource?.mode === 'live' && text.trim().length >= 3) {
|
||||||
|
api.clinicExternalPatients(text).then(rows => { if (seq === searchSeq.current) setExternalPatients(rows.filter(r => !r.local_id)); }).catch(() => setExternalPatients([]));
|
||||||
|
} else setExternalPatients([]);
|
||||||
api.clinicPatients(text, 1).then(x => {
|
api.clinicPatients(text, 1).then(x => {
|
||||||
if (seq !== searchSeq.current) return;
|
if (seq !== searchSeq.current) return;
|
||||||
setApptPatients(prev => {
|
setApptPatients(prev => {
|
||||||
@@ -188,6 +196,18 @@ export default function AgendaMedicaPage() {
|
|||||||
});
|
});
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
// Elegir paciente; si es del sistema origen (ext:código) se trae primero al sistema.
|
||||||
|
async function pickApptPatient(value) {
|
||||||
|
if (!String(value).startsWith('ext:')) return draft.setField('patientId', value);
|
||||||
|
try {
|
||||||
|
const p = await api.clinicImportExternalPatient(String(value).slice(4));
|
||||||
|
setApptPatients(list => [p, ...list.filter(x => x.id !== p.id)]);
|
||||||
|
setExternalPatients(list => list.filter(x => `ext:${x.external_id}` !== value));
|
||||||
|
draft.setField('patientId', p.id);
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
|
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
|
||||||
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
|
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
|
||||||
function newPatientFromAppointment() {
|
function newPatientFromAppointment() {
|
||||||
@@ -403,7 +423,9 @@ export default function AgendaMedicaPage() {
|
|||||||
] : popup.kind === 'patient' ? [
|
] : popup.kind === 'patient' ? [
|
||||||
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
|
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
|
||||||
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
|
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
|
||||||
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes]
|
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes],
|
||||||
|
[t('clinic_origin'), it.external_source ? `${t('clinic_external_results')} (${it.external_id})` : t('clinic_origin_local')],
|
||||||
|
[t('clinic_source_name'), it.external_source && it.name_split ? it.source_name : null]
|
||||||
] : [
|
] : [
|
||||||
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
|
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
|
||||||
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
|
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
|
||||||
@@ -520,8 +542,8 @@ export default function AgendaMedicaPage() {
|
|||||||
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
||||||
</div>}
|
</div>}
|
||||||
</> : <>
|
</> : <>
|
||||||
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} externalPatients={externalPatients} onPatientChange={x => void pickApptPatient(x)} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
||||||
{popup.kind === 'patient' && <PatientForm draft={draft} />}
|
{popup.kind === 'patient' && <PatientForm draft={draft} unsplit={!!it && !it.name_split} />}
|
||||||
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
||||||
<div className="toolbar-left clinic-actions">
|
<div className="toolbar-left clinic-actions">
|
||||||
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { plus } from './clinicUtils.js';
|
|||||||
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
|
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
|
||||||
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
|
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
|
||||||
|
|
||||||
const patientLabel = (p) => `${p.last_names} ${p.first_names}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
const patientLabel = (p) => `${`${p.last_names} ${p.first_names}`.trim()}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
|
||||||
|
|
||||||
export function AppointmentForm({ draft, doctors, types, patients, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
export function AppointmentForm({ draft, doctors, types, patients, externalPatients = [], onPatientChange, search, onSearch, onNewPatient, slots, upcoming = [] }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const v = draft.values;
|
const v = draft.values;
|
||||||
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
|
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
|
||||||
@@ -23,7 +23,7 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
|
|||||||
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
|
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
|
||||||
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
|
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
|
||||||
<div className="clinic-inline">
|
<div className="clinic-inline">
|
||||||
<ComboBox value={v.patientId} onChange={(x) => draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) }))]} />
|
<ComboBox value={v.patientId} onChange={onPatientChange} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) })), ...externalPatients.map((p) => ({ value: `ext:${p.external_id}`, label: `${p.full_name}${p.identification ? ` · ${p.identification}` : ''} — ${t('clinic_external_results')}` }))]} />
|
||||||
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
|
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -46,11 +46,12 @@ export function AppointmentForm({ draft, doctors, types, patients, search, onSea
|
|||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PatientForm({ draft }) {
|
export function PatientForm({ draft, unsplit = false }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const v = draft.values;
|
const v = draft.values;
|
||||||
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
|
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
|
||||||
return <>
|
return <>
|
||||||
|
{unsplit && <InfoBar tone="warning">{t('clinic_name_unsplit')}</InfoBar>}
|
||||||
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
|
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
|
||||||
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
|
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles } from 'lucide-react';
|
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles, Plug } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { Expander } from '../../components/Expander.jsx';
|
import { Expander } from '../../components/Expander.jsx';
|
||||||
@@ -13,6 +13,7 @@ import AiUsagePanel from './AiUsagePanel.jsx';
|
|||||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||||
import UpdaterPanel from './UpdaterPanel.jsx';
|
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||||
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
||||||
|
import ExternalConnectionsPanel from './ExternalConnectionsPanel.jsx';
|
||||||
|
|
||||||
export default function ConfiguracionPage() {
|
export default function ConfiguracionPage() {
|
||||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||||
@@ -117,6 +118,10 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
<Expander icon={Plug} title={t('config_integrations')} draftForms={['ext-connection', 'ext-source']}>
|
||||||
|
<ExternalConnectionsPanel />
|
||||||
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
||||||
<SmtpSettingsPanel />
|
<SmtpSettingsPanel />
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* Conexiones externas: mismo esquema que los formularios de la Agenda (pares de campos, acciones al pie). */
|
||||||
|
.ext-panel h3:not(.mt-0){margin:20px 0 4px}
|
||||||
|
.ext-panel h3.mt-0{margin-bottom:4px}
|
||||||
|
.ext-hint{font-size:.78rem;margin:8px 0 0}
|
||||||
|
.ext-form{display:grid;gap:12px;align-content:start}
|
||||||
|
.ext-form .field{min-width:0}
|
||||||
|
.ext-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
|
||||||
|
.ext-detail p{margin:5px 0;overflow-wrap:anywhere}
|
||||||
|
.ext-actions{margin-top:18px;flex-wrap:wrap}
|
||||||
|
.ext-preview{overflow:auto;max-height:240px;border:1px solid rgb(var(--line));border-radius:var(--r-sm)}
|
||||||
|
.ext-preview .list-table{font-size:.75rem;white-space:nowrap}
|
||||||
|
.ext-subtitle{margin:4px 0 0;font-size:.9rem}
|
||||||
|
.ext-map{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px 12px}
|
||||||
|
@media(max-width:640px){.ext-detail{grid-template-columns:1fr}}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Pencil, PlugZap, RefreshCw, DatabaseZap } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
import * as api from '../../services/integrations.js';
|
||||||
|
import './ExternalConnectionsPanel.css';
|
||||||
|
|
||||||
|
// Conexiones externas (superadmin): conectores de solo lectura y qué tabla alimenta Pacientes y
|
||||||
|
// Empleados. Ver docs/CONEXIONES-EXTERNAS.md. Listado → popup en lectura → Editar.
|
||||||
|
|
||||||
|
const TARGETS = {
|
||||||
|
patients: ['externalId', 'identification', 'fullName', 'phone', 'email'],
|
||||||
|
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'],
|
||||||
|
providers: ['externalId', 'identification', 'fullName', 'phone', 'email']
|
||||||
|
};
|
||||||
|
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] || [];
|
||||||
|
return s
|
||||||
|
? { connectionId: s.connection_id, sourceKey: s.source_key, tableName: s.table_name, mode: s.mode, fieldMap: s.field_map, filterField, filterValue, changedField: s.changed_field || '', editVersion: s.edit_version }
|
||||||
|
: { connectionId: '', sourceKey: '', tableName: '', mode: 'import', fieldMap: {}, filterField: '', filterValue: '', changedField: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function syncSummary(t, r) {
|
||||||
|
if (!r) return t('emp_never');
|
||||||
|
return `${String(r.at).slice(0, 16)} · ${r.received ?? r.total} ${t('int_received')} · ${r.created} ${t('int_created')} · ${r.updated} ${t('int_updated')}${r.deactivated || r.removed ? ` · ${r.deactivated ?? r.removed} ${t('int_deactivated')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalConnectionsPanel() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const [connections, setConnections] = useState([]), [sources, setSources] = useState([]);
|
||||||
|
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||||
|
const [remote, setRemote] = useState({ sources: [], tables: [], schema: null, sample: [] });
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const [c, s] = await Promise.all([api.listConnections(), api.listSources()]);
|
||||||
|
setConnections(c); setSources(s); setError('');
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void reload(); }, []);
|
||||||
|
|
||||||
|
const sourceOf = (purpose) => sources.find((s) => s.purpose === purpose);
|
||||||
|
const initial = !popup ? {} : popup.kind === 'connection' ? connForm(popup.item) : sourceForm(popup.purpose, sourceOf(popup.purpose));
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: popup?.kind === 'source' ? 'ext-source' : 'ext-connection',
|
||||||
|
entityId: popup?.kind === 'source' ? popup.purpose : popup?.item?.id,
|
||||||
|
initial,
|
||||||
|
label: popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : t('int_connections'),
|
||||||
|
enabled: !!popup && editing,
|
||||||
|
exclude: ['token'],
|
||||||
|
onDiscard: () => { if (popup?.item || popup?.kind === 'source') setEditing(false); else setPopup(null); }
|
||||||
|
});
|
||||||
|
const v = draft.values;
|
||||||
|
|
||||||
|
// Exploración del conector mientras se edita una fuente: fuentes → tablas → estructura + muestra.
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId) return undefined;
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId).then((x) => active && setRemote((r) => ({ ...r, sources: x.sources }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey) return undefined;
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId, v.sourceKey).then((x) => active && setRemote((r) => ({ ...r, tables: x.tables }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId, v.sourceKey]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey || !v.tableName) { setRemote((r) => ({ ...r, schema: null, sample: [] })); return undefined; }
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId, v.sourceKey, v.tableName).then((x) => active && setRemote((r) => ({ ...r, schema: x.schema, sample: x.sample }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId, v.sourceKey, v.tableName]);
|
||||||
|
|
||||||
|
function open(kind, item = null, extra = {}) {
|
||||||
|
setError('');
|
||||||
|
setRemote({ sources: [], tables: [], schema: null, sample: [] });
|
||||||
|
setPopup({ kind, item, ...extra });
|
||||||
|
setEditing(kind === 'connection' && !item);
|
||||||
|
}
|
||||||
|
const close = () => void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); });
|
||||||
|
|
||||||
|
async function run(fn, okMessage = t('common_saved')) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
pushToast({ tone: 'success', message: okMessage });
|
||||||
|
return result;
|
||||||
|
} catch (e) { setError(e.message); return null; } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const saved = await run(async () => {
|
||||||
|
if (popup.kind === 'connection') return api.saveConnection({ ...v, token: v.token || undefined }, popup.item?.id);
|
||||||
|
return api.saveSource(popup.purpose, {
|
||||||
|
connectionId: Number(v.connectionId), sourceKey: v.sourceKey, tableName: v.tableName, mode: v.mode, fieldMap: v.fieldMap,
|
||||||
|
filter: v.filterField && v.filterValue ? { [v.filterField]: v.filterValue } : {}, changedField: v.changedField || null, editVersion: v.editVersion
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!saved) return;
|
||||||
|
draft.clear();
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
const test = (id) => run(() => api.testConnection(id), t('int_test_ok'));
|
||||||
|
async function sync(kind) {
|
||||||
|
const r = await run(() => (kind === 'employees' ? api.syncEmployees() : api.syncPatients(kind === 'full')), t('common_saved'));
|
||||||
|
if (r) { await reload(); setPopup((p) => p && { ...p }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = remote.schema?.fields?.map((f) => f.name) || [];
|
||||||
|
const fieldOptions = [{ value: '', label: '—' }, ...fields.map((f) => ({ value: f, label: f }))];
|
||||||
|
const canSave = popup?.kind === 'connection'
|
||||||
|
? !!(v.name?.trim() && v.baseUrl?.trim() && (popup.item || v.token))
|
||||||
|
: !!(v.connectionId && v.sourceKey && v.tableName && REQUIRED[popup?.purpose || 'patients'].every((k) => v.fieldMap?.[k]));
|
||||||
|
const src = popup?.kind === 'source' ? sourceOf(popup.purpose) : null;
|
||||||
|
|
||||||
|
return <div className="ext-panel">
|
||||||
|
{error && !popup && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
<div className="toolbar"><h3 className="mt-0">{t('int_connections')}</h3><Button variant="accent" icon={Plus} onClick={() => open('connection')}>{t('int_new_connection')}</Button></div>
|
||||||
|
<ListView getRowKey={(c) => c.id} rows={connections} onRowClick={(c) => open('connection', c)}
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', label: t('common_name') },
|
||||||
|
{ key: 'base_url', label: t('int_url') },
|
||||||
|
{ key: 'is_active', label: t('common_status'), render: (c) => <Badge color={c.is_active ? 'success' : 'muted'}>{t(c.is_active ? 'common_active' : 'common_inactive')}</Badge> }
|
||||||
|
]}
|
||||||
|
rowActions={(c) => [{ label: t('int_test'), icon: PlugZap, onClick: () => void test(c.id) }, { label: t('common_edit'), icon: Pencil, onClick: () => { open('connection', c); setEditing(true); } }]} />
|
||||||
|
|
||||||
|
<div className="toolbar"><h3>{t('int_sources')}</h3></div>
|
||||||
|
<ListView getRowKey={(p) => p} rows={['patients', 'employees', '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>; } },
|
||||||
|
{ key: 'mode', label: t('int_mode'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? t(`int_mode_${s.mode}`) : '—'; } },
|
||||||
|
{ key: 'sync', label: t('int_last_sync'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? syncSummary(t, s.last_result) : '—'; } }
|
||||||
|
]} />
|
||||||
|
<p className="text-muted ext-hint">{t('int_schedule_hint')}</p>
|
||||||
|
|
||||||
|
<DetailPopup open={!!popup} title={popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : popup?.item ? popup.item.name : t('int_new_connection')} onClose={close} width={popup?.kind === 'source' ? 980 : 760} height={popup?.kind === 'source' ? 720 : 460}>
|
||||||
|
{popup && <div className="ext-form">
|
||||||
|
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{editing && <DraftNotice draft={draft} />}
|
||||||
|
{!editing ? <>
|
||||||
|
{popup.kind === 'connection' && <>
|
||||||
|
<div className="ext-detail">
|
||||||
|
<p><strong>{t('common_name')}:</strong> {popup.item.name}</p>
|
||||||
|
<p><strong>{t('int_url')}:</strong> {popup.item.base_url}</p>
|
||||||
|
<p><strong>{t('common_status')}:</strong> {t(popup.item.is_active ? 'common_active' : 'common_inactive')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-left ext-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={PlugZap} disabled={busy} onClick={() => void test(popup.item.id)}>{t('int_test')}</Button></div>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'source' && <>
|
||||||
|
{!src ? <InfoBar tone="warning">{t('int_not_configured')}</InfoBar> : <div className="ext-detail">
|
||||||
|
<p><strong>{t('int_connection')}:</strong> {src.connection_name}</p>
|
||||||
|
<p><strong>{t('int_table')}:</strong> {src.source_key}/{src.table_name}</p>
|
||||||
|
<p><strong>{t('int_mode')}:</strong> {t(`int_mode_${src.mode}`)}</p>
|
||||||
|
{src.field_map && <p><strong>{t('int_mapping')}:</strong> {Object.entries(src.field_map).map(([k, f]) => `${t(`int_target_${k}`)} ← ${f}`).join(' · ')}</p>}
|
||||||
|
{src.filter && Object.keys(src.filter).length > 0 && <p><strong>{t('int_filter')}:</strong> {Object.entries(src.filter).map(([k, x]) => `${k} = ${x}`).join(', ')}</p>}
|
||||||
|
{src.changed_field && <p><strong>{t('int_changed_field')}:</strong> {src.changed_field}</p>}
|
||||||
|
<p><strong>{t('int_last_sync')}:</strong> {syncSummary(t, src.last_result)}</p>
|
||||||
|
</div>}
|
||||||
|
<div className="toolbar-left ext-actions">
|
||||||
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{src ? t('common_edit') : t('int_configure')}</Button>
|
||||||
|
{src && popup.purpose === 'employees' && <Button icon={RefreshCw} disabled={busy} onClick={() => void sync('employees')}>{t('emp_update')}</Button>}
|
||||||
|
{src && popup.purpose === 'patients' && src.mode === 'import' && <>
|
||||||
|
<Button icon={RefreshCw} disabled={busy} onClick={() => void sync('changes')}>{t('int_sync_now')}</Button>
|
||||||
|
<Button icon={DatabaseZap} disabled={busy} onClick={() => void sync('full')}>{t('int_sync_full')}</Button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</> : <>
|
||||||
|
{popup.kind === 'connection' && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}><input className="input" maxLength={120} value={v.name} onChange={(e) => draft.setField('name', e.target.value)} /></Field>
|
||||||
|
<Field label={t('int_url')} dirty={draft.isFieldDirty('baseUrl')}><input className="input" maxLength={255} placeholder="http://192.168.0.253:8455" value={v.baseUrl} onChange={(e) => draft.setField('baseUrl', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_token')} hint={popup.item ? t('int_token_keep') : undefined}><PasswordInput value={v.token} onChange={(e) => draft.setField('token', e.target.value)} /></Field>
|
||||||
|
<Field label={t('common_status')}><label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('common_active')}</label></Field>
|
||||||
|
</FieldRow>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'source' && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_connection')} dirty={draft.isFieldDirty('connectionId')}><ComboBox value={v.connectionId} onChange={(x) => draft.setValues((o) => ({ ...o, connectionId: x, sourceKey: '', tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...connections.filter((c) => c.is_active).map((c) => ({ value: c.id, label: c.name }))]} /></Field>
|
||||||
|
{popup.purpose === 'patients'
|
||||||
|
? <Field label={t('int_mode')} dirty={draft.isFieldDirty('mode')}><ComboBox value={v.mode} onChange={(x) => draft.setField('mode', x)} options={['import', 'live'].map((x) => ({ value: x, label: t(`int_mode_${x}`) }))} /></Field>
|
||||||
|
: <div className="field" />}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_source')} dirty={draft.isFieldDirty('sourceKey')}><ComboBox value={v.sourceKey} onChange={(x) => draft.setValues((o) => ({ ...o, sourceKey: x, tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...remote.sources.map((s) => ({ value: s.id, label: s.name }))]} /></Field>
|
||||||
|
<Field label={t('int_table')} dirty={draft.isFieldDirty('tableName')}><ComboBox value={v.tableName} onChange={(x) => draft.setField('tableName', x)} options={[{ value: '', label: t('common_select') }, ...remote.tables.filter((x) => !x.error).map((x) => ({ value: x.name, label: `${x.name} (${x.records})` }))]} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{v.tableName && !remote.schema && <p className="text-muted">{t('common_loading')}</p>}
|
||||||
|
{remote.schema && <>
|
||||||
|
<Field label={t('int_preview')}><div className="ext-preview"><table className="list-table"><thead><tr>{fields.map((f) => <th key={f}>{f}</th>)}</tr></thead><tbody>{remote.sample.map((r) => <tr key={r._recno}>{fields.map((f) => <td key={f}>{r[f] === null || r[f] === undefined ? '' : String(r[f])}</td>)}</tr>)}</tbody></table></div></Field>
|
||||||
|
<h4 className="ext-subtitle">{t('int_mapping')}</h4>
|
||||||
|
<div className="ext-map">
|
||||||
|
{TARGETS[popup.purpose].map((k) => <Field key={k} label={`${t(`int_target_${k}`)}${REQUIRED[popup.purpose].includes(k) ? ` (${t('int_required')})` : ''}`}>
|
||||||
|
<ComboBox value={v.fieldMap?.[k] || ''} onChange={(x) => draft.setValues((o) => ({ ...o, fieldMap: { ...o.fieldMap, [k]: x || undefined } }))} options={fieldOptions} />
|
||||||
|
</Field>)}
|
||||||
|
</div>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={`${t('int_filter')} · ${t('int_filter_field')}`}><ComboBox value={v.filterField} onChange={(x) => draft.setField('filterField', x)} options={fieldOptions} /></Field>
|
||||||
|
<Field label={t('int_filter_value')}><input className="input" maxLength={60} value={v.filterValue} onChange={(e) => draft.setField('filterValue', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{popup.purpose === 'patients' && <FieldRow>
|
||||||
|
<Field label={t('int_changed_field')}><ComboBox value={v.changedField} onChange={(x) => draft.setField('changedField', x)} options={[{ value: '', label: '—' }, ...(remote.schema.fields || []).filter((f) => f.type === 'T').map((f) => ({ value: f.name, label: f.name }))]} /></Field>
|
||||||
|
<div className="field" />
|
||||||
|
</FieldRow>}
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
<div className="toolbar-left ext-actions">
|
||||||
|
<Button variant="accent" disabled={busy || !canSave || (!!(popup.item || src) && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
|
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>}
|
||||||
|
</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
|
import { Pencil, Save, Plus, Trash2, Power, Copy, Download, Sparkles } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import {
|
import {
|
||||||
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
listDevices, getDevice, updateDevice, fetchDeviceCpuYear, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
||||||
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
|
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
|
||||||
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
||||||
} from '../../services/devices.js';
|
} from '../../services/devices.js';
|
||||||
@@ -23,6 +23,7 @@ import { ListView } from '../../components/ListView.jsx';
|
|||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||||
|
import { SoftwareList, SoftwareTypeFilter, matchesSoftwareType } from './SoftwareList.jsx';
|
||||||
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
||||||
|
|
||||||
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
||||||
@@ -69,6 +70,8 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
||||||
const [searches, setSearches] = useState({});
|
const [searches, setSearches] = useState({});
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
const [showComponents, setShowComponents] = useState(false);
|
||||||
|
const [softwareType, setSoftwareType] = useState('');
|
||||||
|
const [fetchingYear, setFetchingYear] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
||||||
const draft = useDraftForm({
|
const draft = useDraftForm({
|
||||||
@@ -84,6 +87,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
||||||
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
||||||
|
|
||||||
|
// Año de lanzamiento del procesador: la IA lo consulta solo con el nombre del procesador.
|
||||||
|
function getCpuYear() {
|
||||||
|
setFetchingYear(true);
|
||||||
|
runAction(async () => {
|
||||||
|
await fetchDeviceCpuYear(id);
|
||||||
|
await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
}).catch(() => {}).finally(() => setFetchingYear(false));
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
@@ -114,6 +127,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
{ key: 'hardware', label: t('eq_tab_hardware') },
|
{ key: 'hardware', label: t('eq_tab_hardware') },
|
||||||
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
||||||
{ key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length },
|
{ key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length },
|
||||||
|
{ key: 'licenses', label: t('eq_tab_licenses'), count: (device.extra?.licenses?.windows ? 1 : 0) + (device.extra?.licenses?.office?.length || 0) },
|
||||||
{ key: 'history', label: t('eq_tab_history'), count: device.changes.length },
|
{ key: 'history', label: t('eq_tab_history'), count: device.changes.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -147,6 +161,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
|
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
|
||||||
const hwRows = [
|
const hwRows = [
|
||||||
[t('eq_cpu'), val(cpuText), cpuText],
|
[t('eq_cpu'), val(cpuText), cpuText],
|
||||||
|
[t('eq_cpu_year'), (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{val(device.cpu_year)}
|
||||||
|
{canEdit && cpuText && (
|
||||||
|
<Button size="sm" variant="standard" icon={Sparkles} onClick={getCpuYear} disabled={fetchingYear} title={t('eq_cpu_year_hint')}>
|
||||||
|
{fetchingYear ? t('eq_cpu_year_loading') : t('eq_cpu_year_get')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
), String(device.cpu_year ?? '')],
|
||||||
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
|
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
|
||||||
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
|
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
|
||||||
];
|
];
|
||||||
@@ -171,7 +195,18 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
||||||
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
|
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
|
||||||
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
|
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
|
||||||
const software = only(device.software.filter((r) => showComponents || !r.is_component), (r) => [r.name, r.version, r.publisher]);
|
const software = device.software.filter((r) => (showComponents || !r.is_component) && matchesSoftwareType(r, softwareType));
|
||||||
|
const win = device.extra?.licenses?.windows;
|
||||||
|
const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem'));
|
||||||
|
const winRows = win ? [
|
||||||
|
[t('eq_lic_edition'), val([win.edition, win.version].filter(Boolean).join(' ')), `${win.edition || ''} ${win.version || ''}`],
|
||||||
|
[t('eq_lic_product_id'), val(win.product_id), win.product_id],
|
||||||
|
[t('eq_lic_key'), <code>{val(win.key)}</code>, win.key],
|
||||||
|
[t('eq_lic_channel'), val(channel(win.oem)), channel(win.oem)],
|
||||||
|
[t('eq_lic_owner'), val([win.owner, win.company].filter(Boolean).join(' · ')), `${win.owner || ''} ${win.company || ''}`],
|
||||||
|
] : [];
|
||||||
|
const officeLicenses = only(device.extra?.licenses?.office || [], (r) => [r.name, r.full_name, r.product_id, r.key]);
|
||||||
|
const officeInstalled = only(device.software.filter((r) => !r.is_component && /^microsoft (office|365)\b/i.test(r.name)), (r) => [r.name, r.version]);
|
||||||
const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
|
const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,10 +215,13 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<div className="toolbar" style={{ marginBottom: 10 }}>
|
<div className="toolbar" style={{ marginBottom: 10 }}>
|
||||||
{tab === 'software' ? (
|
{tab === 'software' ? (
|
||||||
<label className="checkbox-row">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
<label className="checkbox-row">
|
||||||
{t('eq_show_components')}
|
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||||
</label>
|
{t('eq_show_components')}
|
||||||
|
</label>
|
||||||
|
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||||
|
</div>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -271,9 +309,35 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'software' && (
|
{tab === 'software' && (
|
||||||
<SimpleTable empty={emptyText} rows={software} columns={[
|
<SoftwareList
|
||||||
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
|
rows={software} searchValue={searches.software} canEdit={canEdit} onChanged={reload} initialSort={{ key: 'name', dir: 1 }}
|
||||||
]} />
|
columns={[
|
||||||
|
{ key: 'name', label: t('common_name') },
|
||||||
|
{ key: 'version', label: t('eq_version'), render: (r) => dash(r.version), groupRender: () => '—' },
|
||||||
|
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'licenses' && (
|
||||||
|
<>
|
||||||
|
<h4>{t('eq_lic_windows')}</h4>
|
||||||
|
{win ? showRows(winRows) : <div className="text-muted">{t('eq_lic_no_windows')}</div>}
|
||||||
|
{!q && officeLicenses.length === 0 ? (
|
||||||
|
<><h4>{t('eq_lic_office')}</h4><div className="text-muted">{t('eq_lic_no_office')}</div></>
|
||||||
|
) : hwSection(t('eq_lic_office'), officeLicenses, [
|
||||||
|
{ key: 'name', label: t('common_name'), render: (r) => (
|
||||||
|
<>{r.name} {r.msdn && <Badge color="warning">{t('eq_lic_msdn')}</Badge>}</>
|
||||||
|
) },
|
||||||
|
{ key: 'full_name', label: t('eq_lic_channel') },
|
||||||
|
{ key: 'product_id', label: t('eq_lic_product_id') },
|
||||||
|
{ key: 'key', label: t('eq_lic_key'), render: (r) => <code>{dash(r.key)}</code> },
|
||||||
|
])}
|
||||||
|
{hwSection(t('eq_lic_office_installed'), officeInstalled, [
|
||||||
|
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') },
|
||||||
|
])}
|
||||||
|
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_lic_hint')}</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'history' && (
|
{tab === 'history' && (
|
||||||
@@ -353,6 +417,7 @@ function DevicesTab({ canEdit }) {
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'hostname', label: t('eq_hostname'), render: (r) => <strong>{dash(r.hostname)}</strong> },
|
{ key: 'hostname', label: t('eq_hostname'), render: (r) => <strong>{dash(r.hostname)}</strong> },
|
||||||
|
{ key: 'assigned_user_name', label: t('eq_assigned_to'), render: (r) => dash(r.assigned_user_name) },
|
||||||
{ key: 'kind', label: t('eq_kind'), render: (r) => t(`eq_kind_${r.kind}`) },
|
{ key: 'kind', label: t('eq_kind'), render: (r) => t(`eq_kind_${r.kind}`) },
|
||||||
{ key: 'model', label: t('eq_manufacturer_model'), sortValue: (r) => r.model || '', render: (r) => dash([r.manufacturer, r.model].filter(Boolean).join(' ')) },
|
{ key: 'model', label: t('eq_manufacturer_model'), sortValue: (r) => r.model || '', render: (r) => dash([r.manufacturer, r.model].filter(Boolean).join(' ')) },
|
||||||
{ key: 'os_name', label: t('eq_os'), render: (r) => dash(r.os_name) },
|
{ key: 'os_name', label: t('eq_os'), render: (r) => dash(r.os_name) },
|
||||||
@@ -391,34 +456,37 @@ function DevicesTab({ canEdit }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SoftwareTab() {
|
function SoftwareTab({ canEdit }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
const [showComponents, setShowComponents] = useState(false);
|
||||||
const { data: rows } = useAsync(() => listSoftware(showComponents), [showComponents]);
|
const [softwareType, setSoftwareType] = useState('');
|
||||||
|
const { data: rows, reload } = useAsync(() => listSoftware(showComponents), [showComponents]);
|
||||||
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
|
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
|
||||||
|
|
||||||
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
||||||
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
||||||
{ key: 'version_count', label: t('eq_versions') },
|
{ key: 'version_count', label: t('eq_versions'), groupRender: () => '—' },
|
||||||
{ key: 'device_count', label: t('eq_installed_on') },
|
{ key: 'device_count', label: t('eq_installed_on') },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<label className="checkbox-row">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
<label className="checkbox-row">
|
||||||
{t('eq_show_components')}
|
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||||
</label>
|
{t('eq_show_components')}
|
||||||
|
</label>
|
||||||
|
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||||
|
</div>
|
||||||
<SearchInput value={search} onChange={setSearch} />
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<SoftwareList
|
||||||
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
|
rows={rows.filter((r) => matchesSoftwareType(r, softwareType))} columns={columns} searchValue={search} canEdit={canEdit}
|
||||||
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
|
onChanged={reload} onRowClick={(r) => setSelected(r.name)} initialSort={{ key: 'device_count', dir: -1 }}
|
||||||
initialSort={{ key: 'device_count', dir: -1 }}
|
|
||||||
/>
|
/>
|
||||||
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
||||||
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
||||||
@@ -619,7 +687,7 @@ export default function EquiposPage() {
|
|||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{tab === 'summary' && <SummaryTab />}
|
{tab === 'summary' && <SummaryTab />}
|
||||||
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
||||||
{tab === 'software' && <SoftwareTab />}
|
{tab === 'software' && <SoftwareTab canEdit={access.canEdit} />}
|
||||||
{tab === 'agents' && access.canManage && <AgentsTab />}
|
{tab === 'agents' && access.canManage && <AgentsTab />}
|
||||||
{tab === 'share' && access.canManage && <ShareTab />}
|
{tab === 'share' && access.canManage && <ShareTab />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Tag, RotateCcw } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { setSoftwareClassification } from '../../services/devices.js';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
|
||||||
|
const TYPES = ['free', 'paid', 'personal_free', 'component'];
|
||||||
|
const TYPE_COLOR = { free: 'success', paid: 'accent', personal_free: 'warning', component: 'muted' };
|
||||||
|
// El grupo cerrado muestra el tipo de mayor riesgo que tenga dentro: nada queda escondido.
|
||||||
|
const RISK = ['personal_free', 'paid', null, 'free', 'component'];
|
||||||
|
const typeKey = (type) => type || 'unclassified';
|
||||||
|
const getChildren = (row) => row.children;
|
||||||
|
const getRowKey = (row) => row.groupKey || row.id || row.name;
|
||||||
|
|
||||||
|
export function matchesSoftwareType(row, filter) {
|
||||||
|
return !filter || typeKey(row.license_type) === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrupa por fabricante desde 2 programas; lo que no tiene grupo sigue como fila normal.
|
||||||
|
function groupRows(rows) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const row of rows) if (row.group) groups.set(row.group, [...(groups.get(row.group) || []), row]);
|
||||||
|
const seen = new Set();
|
||||||
|
const result = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const children = row.group ? groups.get(row.group) : null;
|
||||||
|
if (!children || children.length < 2) { result.push(row); continue; }
|
||||||
|
if (seen.has(row.group)) continue;
|
||||||
|
seen.add(row.group);
|
||||||
|
result.push({
|
||||||
|
groupKey: `group:${row.group}`, name: row.group, children,
|
||||||
|
publisher: children.find((c) => c.publisher)?.publisher || null,
|
||||||
|
license_type: RISK.find((type) => children.some((c) => (c.license_type || null) === type)) ?? null,
|
||||||
|
device_count: Math.max(...children.map((c) => c.device_count || 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoftwareTypeFilter({ value, onChange }) {
|
||||||
|
const { t } = useUi();
|
||||||
|
const options = [{ value: '', label: t('eq_sw_type_all') }, ...[...TYPES, 'unclassified'].map((k) => ({ value: k, label: t(`eq_sw_type_${k}`) }))];
|
||||||
|
return <div style={{ width: 210 }}><ComboBox options={options} value={value} onChange={onChange} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoftwareList({ rows, columns, searchValue, canEdit, onChanged, onRowClick, initialSort }) {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const runAction = useAction();
|
||||||
|
const grouped = useMemo(() => groupRows(rows), [rows]);
|
||||||
|
|
||||||
|
function classify(row, licenseType) {
|
||||||
|
runAction(async () => {
|
||||||
|
await setSoftwareClassification(row.name, licenseType);
|
||||||
|
await onChanged();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
...columns.map((col, index) => (index === 0 ? {
|
||||||
|
...col,
|
||||||
|
render: (r) => (r.children ? <span><strong>{r.name}</strong> <span className="text-muted">· {r.children.length} {t('eq_sw_programs')}</span></span> : col.render ? col.render(r) : r[col.key]),
|
||||||
|
} : { ...col, render: (r) => (r.children && col.groupRender ? col.groupRender(r) : col.render ? col.render(r) : r[col.key]) })),
|
||||||
|
{
|
||||||
|
key: 'license_type', label: t('eq_sw_type'), sortValue: (r) => RISK.indexOf(r.license_type || null),
|
||||||
|
render: (r) => (
|
||||||
|
<span title={r.license_manual ? t('eq_sw_manual') : undefined}>
|
||||||
|
{r.license_type
|
||||||
|
? <Badge color={TYPE_COLOR[r.license_type]}>{t(`eq_sw_type_${r.license_type}`)}{r.license_manual ? ' *' : ''}</Badge>
|
||||||
|
: <Badge color="danger">{t('eq_sw_type_unclassified')}</Badge>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rowActions = canEdit ? (r) => (r.children ? [] : [
|
||||||
|
...TYPES.filter((type) => type !== r.license_type).map((type) => ({
|
||||||
|
icon: Tag, label: `${t('eq_sw_classify_as')}: ${t(`eq_sw_type_${type}`)}`, onClick: () => classify(r, type),
|
||||||
|
})),
|
||||||
|
...(r.license_manual ? [{ icon: RotateCcw, label: t('eq_sw_classify_auto'), onClick: () => classify(r, null) }] : []),
|
||||||
|
]) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListView
|
||||||
|
columns={allColumns} rows={grouped} searchValue={searchValue} getRowKey={getRowKey} getChildren={getChildren}
|
||||||
|
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''} ${r.version || ''}`}
|
||||||
|
onRowClick={onRowClick} rowActions={rowActions} initialSort={initialSort}
|
||||||
|
/>
|
||||||
|
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_sw_hint')}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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, 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); setProviderSource(s.find((x) => x.purpose === 'providers') || null); setError('');
|
||||||
|
} catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void reload(); }, []);
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await syncEmployees();
|
||||||
|
await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
columns={[
|
||||||
|
{ key: 'last_names', label: t('int_target_lastNames') },
|
||||||
|
{ key: 'first_names', label: t('int_target_firstNames') },
|
||||||
|
{ key: 'identification', label: t('int_target_identification') },
|
||||||
|
{ key: 'position', label: t('int_target_position'), render: (e) => e.position || '—' },
|
||||||
|
{ key: 'department_name', label: t('int_target_department'), render: (e) => e.department_name || '—' },
|
||||||
|
{ key: 'origin', label: t('emp_origin'), render: (e) => t(`emp_origin_${e.origin}`) },
|
||||||
|
{ key: 'is_active', label: t('common_status'), render: (e) => <Badge color={e.is_active ? 'success' : 'muted'}>{t(e.is_active ? 'common_active' : 'common_inactive')}</Badge> },
|
||||||
|
{ key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' }
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<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>;
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import { Tabs } from '../../components/Tabs.jsx';
|
|||||||
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
||||||
import UsersPanel from './UsersPanel.jsx';
|
import UsersPanel from './UsersPanel.jsx';
|
||||||
|
import EmployeesPanel from './EmployeesPanel.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
|
||||||
export default function OrganizacionPage() {
|
export default function OrganizacionPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
|
const { user } = useAuth();
|
||||||
|
const canManage = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||||
|
const asked = searchParams.get('tab');
|
||||||
|
const tab = asked === 'users' || (asked === 'employees' && canManage) ? asked : 'departments';
|
||||||
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||||
const drafts = useUserDrafts();
|
const drafts = useUserDrafts();
|
||||||
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
||||||
@@ -21,11 +26,12 @@ export default function OrganizacionPage() {
|
|||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
||||||
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
||||||
|
...(canManage ? [{ key: 'employees', label: t('org_employees') }] : []),
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
{tab === 'departments' ? <DepartmentsPanel /> : <UsersPanel />}
|
{tab === 'departments' ? <DepartmentsPanel /> : tab === 'users' ? <UsersPanel /> : <EmployeesPanel />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx';
|
|||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
|
import { listEmployees } from '../../services/integrations.js';
|
||||||
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 { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
@@ -27,7 +28,10 @@ function flatten(nodes, acc = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
|
const STATUS_COLOR = { pending_verification: 'muted', pending_approval: 'warning', active: 'success', invited: 'muted' };
|
||||||
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '' };
|
const EMPTY_FORM = { name: '', email: '', roleId: '', departmentId: '', position: '', password: '', employeeId: '' };
|
||||||
|
// Nómina viene en MAYÚSCULAS: "RIERA CAJAMARCA" → "Riera Cajamarca".
|
||||||
|
const titleCase = (s) => String(s || '').toLowerCase().replace(/(^|\s)\p{L}/gu, (m) => m.toUpperCase()).trim();
|
||||||
|
const fold = (s) => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toUpperCase();
|
||||||
|
|
||||||
export default function UsersPanel() {
|
export default function UsersPanel() {
|
||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
@@ -47,6 +51,7 @@ export default function UsersPanel() {
|
|||||||
const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]);
|
const revokeAllSessions = useCallback(() => revokeAllUserSessions(sessionsUser.id), [sessionsUser]);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [employees, setEmployees] = useState([]);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [approving, setApproving] = useState(null);
|
const [approving, setApproving] = useState(null);
|
||||||
@@ -79,6 +84,14 @@ export default function UsersPanel() {
|
|||||||
function startNew() {
|
function startNew() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
// Empleados de nómina que aún no tienen usuario (si hay fuente de nómina configurada).
|
||||||
|
listEmployees(true).then(setEmployees).catch(() => setEmployees([]));
|
||||||
|
}
|
||||||
|
function pickEmployee(id) {
|
||||||
|
const e = employees.find((x) => x.id === Number(id));
|
||||||
|
if (!e) return setForm((f) => ({ ...f, employeeId: '' }));
|
||||||
|
const dept = deptOptions.find((d) => fold(d.label) === fold(e.department_name));
|
||||||
|
setForm((f) => ({ ...f, employeeId: e.id, name: titleCase(`${e.first_names} ${e.last_names}`), email: e.email || f.email, position: e.position ? titleCase(e.position) : f.position, departmentId: dept ? dept.value : f.departmentId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEdit(user) {
|
function startEdit(user) {
|
||||||
@@ -94,7 +107,7 @@ export default function UsersPanel() {
|
|||||||
if (selected) {
|
if (selected) {
|
||||||
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
|
await updateUser(selected.id, { name: form.name.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, editVersion: form.editVersion });
|
||||||
} else {
|
} else {
|
||||||
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password });
|
await createUser({ name: form.name.trim(), email: form.email.trim(), roleId: form.roleId, departmentId: form.departmentId || null, position: form.position || null, password: form.password, employeeId: form.employeeId || undefined });
|
||||||
}
|
}
|
||||||
draft.clear();
|
draft.clear();
|
||||||
await reload();
|
await reload();
|
||||||
@@ -208,6 +221,11 @@ export default function UsersPanel() {
|
|||||||
|
|
||||||
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
<DetailPopup open={editing} title={selected ? t('common_edit') : t('user_new')} onClose={() => guardedClose(draft, () => setEditing(false))} width={560} height={500}>
|
||||||
<DraftNotice draft={draft} />
|
<DraftNotice draft={draft} />
|
||||||
|
{!selected && employees.length > 0 && (
|
||||||
|
<Field label={t('user_from_payroll')} dirty={draft.isFieldDirty('employeeId')}>
|
||||||
|
<ComboBox options={[{ value: '', label: '—' }, ...employees.map((e) => ({ value: e.id, label: `${e.last_names} ${e.first_names} · ${e.identification}${e.position ? ` · ${e.position}` : ''}` }))]} value={form.employeeId} onChange={pickEmployee} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
<FieldRow>
|
<FieldRow>
|
||||||
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ export const clinicSchedules=(id)=>api.get(`/clinic/config/schedules/${id}`);
|
|||||||
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
||||||
export const clinicTypes=()=>api.get('/clinic/config/types');
|
export const clinicTypes=()=>api.get('/clinic/config/types');
|
||||||
export const clinicSaveType=(data,id)=>id?api.put(`/clinic/config/types/${id}`,data):api.post('/clinic/config/types',data);
|
export const clinicSaveType=(data,id)=>id?api.put(`/clinic/config/types/${id}`,data):api.post('/clinic/config/types',data);
|
||||||
|
export const clinicPatientSource=()=>api.get('/clinic/patients/source');
|
||||||
|
export const clinicExternalPatients=(search)=>api.get(`/clinic/patients/external${query({search})}`);
|
||||||
|
export const clinicImportExternalPatient=(externalId)=>api.post(`/clinic/patients/external/${encodeURIComponent(externalId)}/import`);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const getDevice = (id) => api.get(`/devices/${id}`);
|
|||||||
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
|
export const updateDevice = (id, data) => api.put(`/devices/${id}`, data);
|
||||||
export const getDevicesDashboard = () => api.get('/devices/dashboard');
|
export const getDevicesDashboard = () => api.get('/devices/dashboard');
|
||||||
export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`);
|
export const listSoftware = (all) => api.get(`/devices/software${all ? '?all=1' : ''}`);
|
||||||
|
export const setSoftwareClassification = (name, licenseType) => api.put('/devices/software/classification', { name, licenseType });
|
||||||
export const listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`);
|
export const listSoftwareDevices = (name) => api.get(`/devices/software/devices?name=${encodeURIComponent(name)}`);
|
||||||
|
|
||||||
export const listInventoryTokens = () => api.get('/devices/tokens');
|
export const listInventoryTokens = () => api.get('/devices/tokens');
|
||||||
@@ -20,3 +21,4 @@ export const getDevicesAccess = () => api.get('/devices/access');
|
|||||||
export const listDeviceViewers = () => api.get('/devices/viewers');
|
export const listDeviceViewers = () => api.get('/devices/viewers');
|
||||||
export const addDeviceViewer = (userId) => api.post('/devices/viewers', { userId });
|
export const addDeviceViewer = (userId) => api.post('/devices/viewers', { userId });
|
||||||
export const removeDeviceViewer = (userId) => api.delete(`/devices/viewers/${userId}`);
|
export const removeDeviceViewer = (userId) => api.delete(`/devices/viewers/${userId}`);
|
||||||
|
export const fetchDeviceCpuYear = (id) => api.post(`/devices/${id}/cpu-year`);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { api } from './api.js';
|
||||||
|
|
||||||
|
const qs = (data) => `?${new URLSearchParams(Object.entries(data).filter(([, v]) => v !== undefined && v !== null && v !== ''))}`;
|
||||||
|
export const listConnections = () => api.get('/integrations/connections');
|
||||||
|
export const saveConnection = (data, id) => (id ? api.put(`/integrations/connections/${id}`, data) : api.post('/integrations/connections', data));
|
||||||
|
export const testConnection = (id) => api.post(`/integrations/connections/${id}/test`);
|
||||||
|
export const browseConnection = (id, source, table) => api.get(`/integrations/connections/${id}/browse${qs({ source, table })}`);
|
||||||
|
export const listSources = () => api.get('/integrations/sources');
|
||||||
|
export const saveSource = (purpose, data) => api.put(`/integrations/sources/${purpose}`, data);
|
||||||
|
export const syncPatients = (full) => api.post('/integrations/sources/patients/sync', { full });
|
||||||
|
export const listEmployees = (available) => api.get(`/integrations/employees${available ? '?available=1' : ''}`);
|
||||||
|
export const syncEmployees = () => api.post('/integrations/employees/sync');
|
||||||
|
export const searchProviders = (search) => api.get(`/integrations/providers${qs({ search })}`);
|
||||||
|
export const importProvider = (code, data) => api.post(`/integrations/providers/${encodeURIComponent(code)}/import`, data);
|
||||||
@@ -230,6 +230,8 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
.list-table tbody tr { cursor: pointer; }
|
.list-table tbody tr { cursor: pointer; }
|
||||||
.list-table tbody tr:hover { background: rgb(var(--panel)); }
|
.list-table tbody tr:hover { background: rgb(var(--panel)); }
|
||||||
.list-table tbody tr:last-child td { border-bottom: none; }
|
.list-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.list-tree-cell { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.list-tree-dot::before { content: ''; width: 4px; height: 4px; border-radius: 50%; background: rgb(var(--muted)); }
|
||||||
.list-empty { padding: 40px 16px; text-align: center; color: rgb(var(--muted)); }
|
.list-empty { padding: 40px 16px; text-align: center; color: rgb(var(--muted)); }
|
||||||
.row-menu-btn { opacity: .6; }
|
.row-menu-btn { opacity: .6; }
|
||||||
.row-menu-btn:hover { opacity: 1; }
|
.row-menu-btn:hover { opacity: 1; }
|
||||||
@@ -336,9 +338,9 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
.toast-error { border-left-color: rgb(var(--danger)); }
|
.toast-error { border-left-color: rgb(var(--danger)); }
|
||||||
|
|
||||||
/* --- Flyout / menú contextual --- */
|
/* --- Flyout / menú contextual --- */
|
||||||
.flyout-backdrop { position: fixed; inset: 0; z-index: 90; }
|
.flyout-backdrop { position: fixed; inset: 0; z-index: 260; }
|
||||||
.flyout-panel {
|
.flyout-panel {
|
||||||
position: fixed; z-index: 100; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
position: fixed; z-index: 270; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
||||||
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
|
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
|
||||||
}
|
}
|
||||||
.flyout-item {
|
.flyout-item {
|
||||||
@@ -592,3 +594,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; }
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ const DRAFT_FIELDS = {
|
|||||||
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
|
'activity-info': ['editVersion', 'responsibleUserId', 'priorityId', 'statusId', 'startDate', 'dueDate', 'isMilestone', 'estimatedHours', 'progressPct'],
|
||||||
'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'],
|
'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'],
|
'user-edit': ['editVersion', 'roleId', 'departmentId'],
|
||||||
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
'user-approve': ['editVersion', 'roleId', 'departmentId'],
|
||||||
'account-password': [],
|
'account-password': [],
|
||||||
|
|||||||
Reference in New Issue
Block a user