10 Commits

Author SHA1 Message Date
Milton Gonzalez 053f51afe7 feat: version 4.16.1 -- Equipos: filas sin subniveles alineadas con los grupos
Incluye el registro del despliegue de 4.16.0 en ESTADO y PRODUCCION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:43:03 -05:00
Milton Gonzalez 420a794c9e feat: version 4.16.0 -- Equipos: tipo de licencia y software agrupado por fabricante
Clasificador por reglas sin IA, correccion manual (migracion 040) y filas desplegables en ListView.
Flyout por encima de popups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 12:36:02 -05:00
Milton Gonzalez f5721f95a9 fix: version 4.15.1 -- alinear version de package.json (actualizador quedaba en 95%)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:54:31 -05:00
Milton Gonzalez 34183d15ce feat: version 4.15.0 -- Equipos: pestaña Licenciamiento (Windows y Office)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:47:57 -05:00
Milton Gonzalez b2c25c4fe5 feat: version 4.14.0 -- Equipos: año del procesador con boton Obtener fecha (IA)
La IA consulta solo el nombre del procesador y devuelve el año (el mas antiguo si hay dos).
Migracion 039.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 19:07:31 -05:00
Milton Gonzalez 0968e61ced feat: version 4.13.1 -- Equipos: columna Asignado a en el listado
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 18:50:05 -05:00
Milton Gonzalez d85a7196f0 feat: version 4.13.0 -- Agregar medicos desde proveedores de SAGA (solo por API)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:15:12 -05:00
Milton Gonzalez 0ee9ed65bf feat: version 4.12.0 -- Conexiones externas: pacientes de SAGA, empleados de nomina y medicos
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:06:10 -05:00
Milton Gonzalez fabe5b530d feat: version 4.11.0 -- Agenda medica refactorizada, aviso de citas futuras y correcciones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:30:21 -05:00
Milton Gonzalez d0f90542e9 feat: version 4.10.0 -- Conectores de datos externos y conector FoxPro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:32:08 -05:00
69 changed files with 3858 additions and 648 deletions
+33 -1
View File
@@ -82,6 +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: datos de otros sistemas vía conectores de solo lectura en el servidor origen
(`data/conectores/`, FoxPro instalado en Exilaser), pacientes de SAGA y empleados de nómina —
ver `docs/CONEXIONES-EXTERNAS.md`. 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
@@ -143,6 +147,33 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
guardar solo si hubo cambios reales. guardar solo si hubo cambios reales.
- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos) - Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos)
van en pestañas reales, nunca apiladas ni navegando a otra pantalla. van en pestañas reales, nunca apiladas ni navegando a otra pantalla.
- **Formularios en popup = modelo "Nueva reunión" de Calendario** (`pages/Calendario/CalendarioPage.jsx`):
rejilla de 2 columnas (campos cortos en pares, texto largo a lo ancho), fecha + hora con
`DateTimeEditor`, botones al pie. Cualquier formulario nuevo o existente que no siga esto se
ajusta a ese modelo.
- Un popup "Nuevo …" abre limpio con el contexto del clic (día, hora, médico): nada de la apertura
anterior (búsquedas, selecciones) se arrastra. Solo vuelve lo que el usuario dejó como borrador
("Dejar pendiente"), con el aviso de borrador recuperado.
## Delegar a Codex (u otro agente) en este proyecto
Además de la regla del orquestador: Claude escribe en el encargo las reglas que aplican (no "lee los
md") y nombra **solo** los archivos/secciones necesarios de esta lista, según la tarea:
| Si la tarea toca… | Nombrar |
|---|---|
| Pantallas/formularios | `docs/UI-UX-WINDOWS.md` (sección del componente) + pantalla modelo (formularios: `pages/Calendario/CalendarioPage.jsx`, listados: `pages/Organizacion/UsersPanel.jsx`) |
| Formularios con edición | `docs/SESION-Y-BORRADORES.md` § Comportamiento (`useDraftForm`) |
| Backend / permisos | `docs/SEGURIDAD-Y-CALIDAD.md` (sección aplicable), `API.md` § del módulo |
| Base de datos | `BASE_DE_DATOS.md` § del módulo + última migración |
Reglas fijas que siempre van escritas en el encargo: rejilla de 2 columnas como "Nueva reunión";
listado primero y popup en lectura → Editar; nada se arrastra entre aperturas de un popup; textos en
`i18n/messages.js` (es y en); reutilizar componentes de `src/components` sin crear variantes.
Si necesita herramientas, se le dicen en el encargo (build: `npx vite build` en `frontend/`; navegador:
Playwright global del orquestador; usuarios de prueba de dev: ver `ESTADO.md`).
Codex desarrolla y **Claude revisa y prueba siempre** lo que entregue (diff contra estas reglas, build,
navegador si cambia pantalla) antes de darlo por bueno.
## Flujos criticos ## Flujos criticos
@@ -169,7 +200,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción) - `PRODUCCION.md`, `DEPLOY_WEB.md` (topología y flujo de deploy — arquetipo B, en producción)
- `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md` - `docs/UI-UX-WINDOWS.md`, `docs/SEGURIDAD-Y-CALIDAD.md`, `docs/ARQUITECTURA-INVENTARIO.md`
- `docs/SESION-Y-BORRADORES.md` (vencimiento de sesión, retorno al contexto, borradores y avisos al salir; usar `useDraftForm` en todo formulario nuevo) - `docs/SESION-Y-BORRADORES.md` (vencimiento de sesión, retorno al contexto, borradores y avisos al salir; usar `useDraftForm` en todo formulario nuevo)
- `ESTADO.md` (handoff — leer siempre al retomar el proyecto) - `docs/CONEXIONES-EXTERNAS.md` (requisitos y diseño de datos externos) y `data/conectores/README.md` (contrato v1 de conectores)
- `ESTADO.md` (handoff — leer siempre al retomar el proyecto) y `DECISIONES.md` (decisiones acordadas)
## Pendientes para quedar listo ## Pendientes para quedar listo
+17 -1
View File
@@ -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
+14 -1
View File
@@ -15,9 +15,19 @@ 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` está creada y **no se ha aplicado** en ninguna BD. `backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
(2026-10-28/29), **pendientes en producción** (las aplica el actualizador).
Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por
médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`. médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
@@ -73,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) |
@@ -263,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.
+106
View File
@@ -1,5 +1,111 @@
# 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
### Added
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
agendadas/confirmadas, de cualquier fecha y médico.
- Agenda médica: "Nueva cita" desde la fila de un paciente; "Nuevo paciente" desde la cita precarga
la cédula buscada; historial de citas del paciente en listado.
### Changed
- Agenda médica refactorizada con el modelo de "Nueva reunión" de Calendario: formularios de cita,
paciente y bloqueo en pares de campos con `DateTimeEditor`; Pacientes y Bloqueos como `ListView`;
Configuración (personal, tipos, horario) como listados con popup en lectura → Editar (se quitó el
campo "ID de usuario"). Código dividido en `ClinicForms.jsx`, `ClinicConfigTab.jsx`, `clinicUtils.js`.
### Fixed
- Agenda médica: el paciente/cédula de una cita ya no se arrastra a la siguiente (borrador huérfano
al cancelar o cerrar "Nuevo paciente"); las notas de la cita se conservan al volver del paciente.
- Agenda médica: "Descartar cambios" en una cita, paciente o bloqueo nuevo ya no tumba la página.
- Agenda médica: búsquedas y huecos libres ya no se pisan con respuestas viejas; el paciente elegido
no desaparece al buscar; el tipo desactivado de una cita se conserva al editarla.
- Diálogo "Cambios sin guardar": los botones ya no se salen por la izquierda (pasan a otra línea).
Sin migración.
## [4.10.0] - 2026-10-29
### Added
- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura
que se instala en el servidor origen; el sistema no guarda credenciales del origen.
- Conector FoxPro 1.0.0 (`data/conectores/foxpro/`): lee DBF/FPT desde una copia temporal (no
bloquea a SAGA), token SHA-256, IPs permitidas, tablas/campos permitidos y denegados, límites de
carga, scripts de instalación como tarea de Windows (`C:\api_project\foxpro`). 16 pruebas locales.
- Documentación: `docs/CONEXIONES-EXTERNAS.md` (requisitos y diseño) y `DECISIONES.md`.
Sin cambios en la aplicación ni migración.
## [4.9.1] - 2026-10-29 ## [4.9.1] - 2026-10-29
### Fixed ### Fixed
- Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas - Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas
+21
View File
@@ -0,0 +1,21 @@
# DECISIONES - project.linkedcorp.net
> Registro de decisiones acordadas (qué, por qué, alternativas descartadas). Se agrega una entrada cada vez
> que se acuerda algo que cambia cómo se construye el proyecto. No se borran: si cambia, se agrega una
> nueva que la reemplaza. Se versiona en Git.
| # | Fecha | Decisión | Por qué | Descartado | Estado |
|---|---|---|---|---|---|
| 1 | 2026-09-03 | Identidad UI Windows/Fluent (`docs/UI-UX-WINDOWS.md`) en vez de la identidad web LinkedCorp | Decisión explícita del usuario al arrancar el proyecto | `UI-UX-LINKEDCORP.md` | vigente |
| 2 | 2026-10-29 | Datos de sistemas externos vía **conectores** (API HTTP de solo lectura instalada en el servidor origen, contrato v1 en `data/conectores/README.md`); el primero es FoxPro | Más seguro (el sistema no guarda credenciales del origen; el origen decide qué tablas/campos salen) y escalable (otra tecnología = otro conector, mismo contrato) | Conexión directa desde el backend por SMB/SFTP a los DBF o con usuario de BD; ODBC de FoxPro | vigente |
| 3 | 2026-10-29 | Conector FoxPro en Node con `dbffile`, `node.exe` portable en `C:\api_project\foxpro`, tarea programada al arranque; lee una copia temporal de DBF+FPT | Mismo stack del proyecto, sin instalar nada en el server, no choca con los bloqueos de SAGA | Servicio .NET/Go; abrir los DBF originales; usar índices CDX | vigente |
| 4 | 2026-10-29 | Por fuente, el superadmin elige **importar** o **consulta en vivo**; empleados de nómina siempre se importan con botón "Actualizar empleados" (admin y superadmin) | Pedido del usuario (ver `docs/CONEXIONES-EXTERNAS.md`) | — | 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 |
| 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 |
+27 -1
View File
@@ -1,11 +1,37 @@
# ESTADO — project.linkedcorp.net # ESTADO — project.linkedcorp.net
Ultima actualizacion: 2026-10-28 (v4.7.0 en archivos, sin commit ni 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 (v4.12.0, en dev, sin desplegar)
- v4.16.0 (2026-10-30): Software con tipo de licencia, grupos por fabricante y corrección manual;
migración `040` aplicada en dev. Backend probado contra la BD de dev; pantalla probada con API
simulada (sin login real) y **falta probarla con un usuario real**. Desplegada en LinkedCorp `.3`
el 2026-10-30 (ver `PRODUCCION.md`). Exilaser se actualiza por su panel.
- Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea
`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): 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`).
- (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
+12
View File
@@ -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.
+16 -1
View File
@@ -102,9 +102,18 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
y tipos de cita desde la pestaña Configuración de agenda. y tipos de cita desde la pestaña Configuración de agenda.
- Vistas (v4.8.0; desde v4.8.1 abre en Mes con la barra del Calendario; la elegida se recuerda): **Día** con una columna por médico; **Semana** como - Vistas (v4.8.0; desde v4.8.1 abre en Mes con la barra del Calendario; la elegida se recuerda): **Día** con una columna por médico; **Semana** como
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes** calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
como calendario mensual (hasta 4 entradas por día + "N más"; clic en un día abre su vista Día); como calendario mensual (hasta 5 entradas por día + "N más" que lleva al día; desde v4.9.0 clic en un día u hora libre abre la cita nueva con esa fecha);
**Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes. **Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes.
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto. Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
- Formularios (desde la refactorización posterior a v4.10.0) con el modelo de "Nueva reunión" de
Calendario: campos en pares, fecha + hora con `DateTimeEditor`, notas a lo ancho. Pacientes y
Bloqueos son listados (`ListView`); Configuración (personal, tipos, horario) son listados con popup
en lectura → Editar. El detalle de un paciente muestra su historial de citas.
- "Nuevo paciente" desde la cita abre el formulario del paciente encima (precarga la cédula si lo
buscado es numérico); guardar, cancelar o cerrar vuelve a la cita. Cada cita nueva abre limpia.
- **Aviso, no bloqueo:** al elegir el paciente de una cita, si tiene citas futuras agendadas o
confirmadas (cualquier fecha y médico, sin contar la que se edita) se listan como advertencia y
se puede agendar igual. Sin citas futuras no se muestra nada.
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza - Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
@@ -115,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.
+1 -1
View File
@@ -1 +1 @@
4.9.1 4.16.1
@@ -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;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.9.1", "version": "4.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.9.1", "version": "4.13.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.9.1", "version": "4.16.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+2
View File
@@ -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).
+19
View File
@@ -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);
}
+6
View File
@@ -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));
+7 -4
View File
@@ -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)));
+1 -1
View File
@@ -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 },
+38 -3
View File
@@ -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 };
} }
+21 -2
View File
@@ -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
+8 -2
View File
@@ -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;
+61 -1
View File
@@ -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;
+444
View File
@@ -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;
}
+12 -1
View File
@@ -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
View File
@@ -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();
+55
View File
@@ -0,0 +1,55 @@
# Conectores de datos externos — contrato v1
El sistema **no se conecta directo** a las bases de datos de los clientes. En cada servidor origen
se instala un **conector**: una API HTTP de solo lectura que expone tablas elegidas. El sistema
habla siempre el mismo contrato, sin importar la tecnología de origen (FoxPro, SQL Server, Excel…).
| Conector | Carpeta | Estado |
|---|---|---|
| FoxPro (DBF/FPT/CDX) | `foxpro/` | 1.1.0 |
## Reglas que todo conector debe cumplir
1. **Solo lectura.** Únicamente `GET`; cualquier otro método → `405`. Nunca escribe en el origen.
2. **Token Bearer** (`Authorization: Bearer <token>`). El conector guarda solo el SHA-256; el
sistema guarda el token cifrado (AES-256-GCM). Se admiten varios hashes para rotar sin cortar.
3. **Lista de IPs permitidas** (backend del sistema), en el conector y en el firewall del servidor.
4. **El administrador del origen decide qué sale:** tablas permitidas, campos permitidos y campos
denegados (sueldos, claves…). Lo no permitido no existe para el sistema (`404`/`400`).
5. **Sin SQL ni rutas libres:** tablas y campos se validan contra la lista real del origen.
6. **Protege al origen:** límite de peticiones por minuto, lecturas simultáneas, tamaño de página
y tiempo máximo por petición. Responde `429`/`503` en vez de cargar el servidor.
7. **Log de acceso** sin tokens ni valores buscados (pueden ser datos personales).
## Endpoints
| Método y ruta | Respuesta |
|---|---|
| `GET /v1/health` | `{ ok, connector, contract: "v1", version, time }` |
| `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/:table/schema` | `{ table, records, version, modifiedAt, fields: [{ name, type, size, decimals }] }` |
| `GET /v1/sources/:source/tables/:table/rows` | `{ table, version, modifiedAt, maxRecno, total, offset, limit, fields, rows }` |
Parámetros de `rows`:
- `fields=A,B` columnas a devolver (por defecto todas las permitidas).
- `limit` (1…máximo del conector, def. 100) y `offset` para paginar.
- `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.
- `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:
el sistema la compara para saber si hace falta volver a sincronizar.
Tipos de dato en JSON: texto recortado (vacío → `null`), números, booleanos, fecha `YYYY-MM-DD`,
fecha-hora ISO 8601.
Errores: `{ error: "mensaje" }` con `400` parámetro inválido, `401` token, `403` IP, `404`
fuente/tabla, `405` método, `423` tabla bloqueada en el origen, `429` exceso de peticiones,
`503` conector ocupado, `500` error interno (detalle solo en el log del conector).
+4
View File
@@ -0,0 +1,4 @@
config.json
logs/
node_modules/
node/
+46
View File
@@ -0,0 +1,46 @@
# Conector FoxPro 1.1.0
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`.
- No abre los originales para leer registros: copia `DBF`+`FPT` a una carpeta temporal y lee la
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
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.
## 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
server). Sin `config.json` ni `logs` de otro equipo.
2. Node: usa el Node instalado en el servidor (Exilaser tiene Node 24); si no hay, copiar `node.exe`
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
**tokenSha256** va en `config.json`.
4. `config.json` (a partir de `config.example.json`):
- `sources`: una entrada por carpeta (`nomina`, `clientes`…). Usar `path` con doble `\\`.
- `tables`: `"*"` (todas) o solo las tablas que se quieren exponer, con `fields` si se quiere
limitar columnas. `denyFields` oculta columnas sensibles (sueldo, claves).
- `encoding`: `cp1252` normalmente; si salen mal las tildes/eñes, probar `cp850`.
- `allowIps`: IP del backend de producción (`192.168.222.3`) y la de desarrollo si aplica.
5. Probar a mano: `node\node.exe src\server.js` y, desde el backend,
`curl -H "Authorization: Bearer <TOKEN>" http://192.168.0.253:8455/v1/health`.
6. Dejarlo como servicio (PowerShell como administrador):
`powershell -ExecutionPolicy Bypass -File .\scripts\instalar-servicio.ps1`
- Crea la tarea `LinkedCorp-Conector-FoxPro` (arranque con Windows, reinicio si cae) y la regla
de firewall del puerto 8455 solo para las IPs permitidas.
- Recomendado: `-Usuario 'DOMINIO\svc_conector' -Clave '...'` con una cuenta que tenga **solo
lectura** sobre las carpetas de datos (por defecto corre como SYSTEM).
7. Desinstalar: `scripts\desinstalar-servicio.ps1`.
TLS opcional: en `config.json` → `tls.pfx` con la ruta del `.pfx` y la clave en la variable de
entorno indicada en `tls.passphraseEnv`. Por la VPN WireGuard el tráfico ya va cifrado.
Logs: `logs\acceso-AAAA-MM-DD.log` (sin tokens ni valores buscados).
## Desarrollo
- `npm test`: crea una tabla DBF temporal, levanta el conector y valida el contrato (19 pruebas).
- `npm run token`: genera token + hash.
@@ -0,0 +1,86 @@
{
"host": "0.0.0.0",
"port": 8455,
"tls": {
"pfx": "",
"passphraseEnv": "CONECTOR_TLS_PASS"
},
"tokenSha256": "PEGAR_AQUI_EL_SHA256_DE_npm_run_token",
"allowIps": [
"192.168.222.3",
"192.168.222.40"
],
"sources": [
{
"id": "nomina",
"name": "Nómina",
"path": "C:\\Sistema\\Nomina\\Datos\\Exilaser",
"encoding": "cp1252",
"tables": {
"EMPLEADO": {
"fields": [
"EMPCED",
"EMPAPE",
"EMPNOM",
"EMPTEL",
"EMPTEL1",
"EMPFECNAC",
"EMPCARGO",
"EMPDEPART",
"EMPINGRESO",
"EMPSALIDA",
"EMPACTIVO"
]
}
}
},
{
"id": "clientes",
"name": "SAGA (clientes y proveedores)",
"path": "C:\\Sistema\\Datos\\exilaser",
"encoding": "cp850",
"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": {
"maxPageSize": 1000,
"requestsPerMinute": 120,
"maxConcurrentReads": 2,
"maxQueuedReads": 10,
"cacheMaxRecordsPerTable": 100000,
"cacheMaxTables": 10,
"requestTimeoutMs": 30000,
"minReloadSeconds": 60
},
"logDir": "logs"
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "linkedcorp-conector-foxpro",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "linkedcorp-conector-foxpro",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"dbffile": "1.14.1"
}
},
"node_modules/dbffile": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dbffile/-/dbffile-1.14.1.tgz",
"integrity": "sha512-X5+w+H5J3hVsKPu1/WAWhe9JMoqe/9RojCRGIV1+4tyGkXv1p2rQ7L+vw8jtQWppWqGc7XLaz5O8sCSAeAfjaQ==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.7.3"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "linkedcorp-conector-foxpro",
"version": "1.1.0",
"description": "Conector de solo lectura para tablas FoxPro (DBF/FPT) - contrato v1",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"token": "node scripts/generar-token.js",
"test": "node scripts/prueba-local.js"
},
"license": "UNLICENSED",
"dependencies": {
"dbffile": "1.14.1"
},
"private": true
}
@@ -0,0 +1,6 @@
# Detiene y quita la tarea y la regla de firewall del conector. No borra archivos.
$Tarea = 'LinkedCorp-Conector-FoxPro'
Stop-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $Tarea -Confirm:$false -ErrorAction SilentlyContinue
Get-NetFirewallRule -DisplayName 'LinkedCorp-Conector-FoxPro' -ErrorAction SilentlyContinue | Remove-NetFirewallRule
Write-Host 'Conector desinstalado.'
@@ -0,0 +1,9 @@
'use strict';
// Genera un token nuevo. El TOKEN se carga en el sistema (Configuración → Conexiones externas);
// en config.json del conector va SOLO el SHA-256.
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('base64url');
const hash = crypto.createHash('sha256').update(token).digest('hex');
console.log(`TOKEN (para el sistema, no se guarda aquí): ${token}`);
console.log(`tokenSha256 (para config.json): ${hash}`);
@@ -0,0 +1,42 @@
# Instala el conector FoxPro como tarea programada que arranca con Windows y se reinicia si cae.
# Ejecutar en PowerShell como administrador, desde C:\api_project\foxpro:
# powershell -ExecutionPolicy Bypass -File .\scripts\instalar-servicio.ps1
# Opcional: -Usuario 'DOMINIO\svc_conector' (cuenta con SOLO lectura sobre las carpetas de datos).
param(
[string]$Usuario = 'SYSTEM',
[string]$Clave = '',
[int]$Puerto = 8455,
[string[]]$IpsPermitidas = @('192.168.222.3', '192.168.222.40')
)
$ErrorActionPreference = 'Stop'
$App = Split-Path -Parent $PSScriptRoot
$Tarea = 'LinkedCorp-Conector-FoxPro'
# node.exe portable dentro de la carpeta (no requiere instalar Node en el servidor); si no, el del sistema.
$Node = Join-Path $App 'node\node.exe'
if (-not (Test-Path $Node)) { $Node = (Get-Command node.exe -ErrorAction Stop).Source }
if (-not (Test-Path (Join-Path $App 'config.json'))) { throw 'Falta config.json (copie config.example.json y complételo).' }
$accion = New-ScheduledTaskAction -Execute $Node -Argument "`"$App\src\server.js`"" -WorkingDirectory $App
$disparador = New-ScheduledTaskTrigger -AtStartup
$ajustes = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
if (Get-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue) {
Stop-ScheduledTask -TaskName $Tarea -ErrorAction SilentlyContinue
Unregister-ScheduledTask -TaskName $Tarea -Confirm:$false
}
if ($Usuario -eq 'SYSTEM') {
Register-ScheduledTask -TaskName $Tarea -Action $accion -Trigger $disparador -Settings $ajustes -User 'SYSTEM' -RunLevel Highest | Out-Null
} else {
Register-ScheduledTask -TaskName $Tarea -Action $accion -Trigger $disparador -Settings $ajustes -User $Usuario -Password $Clave | Out-Null
}
# Firewall: el puerto solo acepta a las IPs del backend (además del filtro allowIps del propio conector).
$Regla = 'LinkedCorp-Conector-FoxPro'
Get-NetFirewallRule -DisplayName $Regla -ErrorAction SilentlyContinue | Remove-NetFirewallRule
New-NetFirewallRule -DisplayName $Regla -Direction Inbound -Protocol TCP -LocalPort $Puerto `
-RemoteAddress $IpsPermitidas -Action Allow | Out-Null
Start-ScheduledTask -TaskName $Tarea
Write-Host "Conector instalado. Tarea: $Tarea Puerto: $Puerto IPs: $($IpsPermitidas -join ', ')"
@@ -0,0 +1,135 @@
'use strict';
// Prueba de humo: crea una tabla DBF temporal, levanta el conector y valida el contrato v1.
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const { DBFFile } = require('dbffile');
const PORT = 18455;
const TOKEN = 'token-de-prueba';
async function main() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prueba-fox-'));
const data = path.join(dir, 'nomina');
fs.mkdirSync(data);
const dbf = await DBFFile.create(path.join(data, 'EMPLEADO.DBF'), [
{ name: 'CEDULA', type: 'C', size: 13 },
{ name: 'NOMBRE', type: 'C', size: 60 },
{ name: 'CARGO', type: 'C', size: 40 },
{ name: 'SUELDO', type: 'N', size: 10, decimalPlaces: 2 },
{ name: 'INGRESO', type: 'D', size: 8 },
{ name: 'ACTIVO', type: 'L', size: 1 },
], { encoding: 'cp1252' });
await dbf.appendRecords([
{ CEDULA: '0102030405', NOMBRE: 'MUÑOZ PEÑA JOSÉ', CARGO: 'Contador', SUELDO: 1200.5, INGRESO: new Date('2020-03-15'), ACTIVO: true },
{ CEDULA: '0911122233', NOMBRE: 'ANDRADE LUISA', CARGO: 'Recepción', SUELDO: 600, INGRESO: new Date('2023-01-02'), ACTIVO: true },
{ CEDULA: '1700000001', NOMBRE: 'BORRADO TEST', CARGO: 'X', SUELDO: 1, INGRESO: null, ACTIVO: false },
]);
// Marcar el 3.er registro como borrado (byte '*' al inicio del registro), como hace FoxPro con DELETE.
const buf = fs.readFileSync(path.join(data, 'EMPLEADO.DBF'));
const headerLen = buf.readUInt16LE(8);
const recLen = buf.readUInt16LE(10);
buf[headerLen + recLen * 2] = 0x2a;
fs.writeFileSync(path.join(data, 'EMPLEADO.DBF'), buf);
fs.writeFileSync(path.join(data, 'OCULTA.DBF'), buf);
const cfgPath = path.join(dir, 'config.json');
fs.writeFileSync(cfgPath, JSON.stringify({
host: '127.0.0.1',
port: PORT,
tokenSha256: crypto.createHash('sha256').update(TOKEN).digest('hex'),
allowIps: ['127.0.0.1'],
sources: [{ id: 'nomina', path: data, encoding: 'cp1252', tables: { EMPLEADO: {} }, denyFields: ['SUELDO'] }],
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'] });
await new Promise((resolve, reject) => {
srv.stdout.once('data', resolve);
srv.once('exit', (c) => reject(new Error(`el servidor salió con ${c}`)));
});
const get = async (p, token = TOKEN, method = 'GET') => {
const r = await fetch(`http://127.0.0.1:${PORT}${p}`, { method, headers: token ? { Authorization: `Bearer ${token}` } : {} });
return { status: r.status, body: await r.json() };
};
let ok = 0;
const check = async (name, fn) => {
await fn();
ok += 1;
console.log(`OK ${name}`);
};
try {
await check('sin token → 401', async () => assert.strictEqual((await get('/v1/health', null)).status, 401));
await check('token errado → 401', async () => assert.strictEqual((await get('/v1/health', 'otro')).status, 401));
await check('POST → 405', async () => assert.strictEqual((await get('/v1/health', TOKEN, 'POST')).status, 405));
await check('health', async () => assert.strictEqual((await get('/v1/health')).body.contract, 'v1'));
await check('sources', async () => assert.deepStrictEqual((await get('/v1/sources')).body.sources.map((s) => s.id), ['nomina']));
await check('tables solo permitidas', async () => {
const t = (await get('/v1/sources/nomina/tables')).body.tables;
assert.deepStrictEqual(t.map((x) => x.name), ['EMPLEADO']);
});
await check('tabla no permitida → 404', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/OCULTA/schema')).status, 404));
await check('traversal → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/..%2F..%2Fx/rows')).status, 400));
await check('schema sin campo denegado', async () => {
const s = (await get('/v1/sources/nomina/tables/empleado/schema')).body;
assert.deepStrictEqual(s.fields.map((f) => f.name), ['CEDULA', 'NOMBRE', 'CARGO', 'INGRESO', 'ACTIVO']);
});
await check('rows: tildes, fecha, sin borrados', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows')).body;
assert.strictEqual(r.total, 2);
assert.strictEqual(r.rows[0].NOMBRE, 'MUÑOZ PEÑA JOSÉ');
assert.strictEqual(r.rows[0].INGRESO, '2020-03-15');
assert.strictEqual(r.rows[0].SUELDO, undefined);
});
await check('rows includeDeleted', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?includeDeleted=1')).body.total, 3));
await check('búsqueda sin tildes', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?q=pena%20jose&qFields=NOMBRE')).body;
assert.strictEqual(r.total, 1);
});
await check('eq + fields', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?eq.CEDULA=0911122233&fields=CEDULA,CARGO')).body;
assert.deepStrictEqual(r.rows, [{ _recno: 2, CEDULA: '0911122233', CARGO: 'Recepción' }]);
});
await check('pedir campo denegado → 400', async () => assert.strictEqual((await get('/v1/sources/nomina/tables/EMPLEADO/rows?fields=SUELDO')).status, 400));
await check('paginación', async () => {
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows?limit=1&offset=1')).body;
assert.strictEqual(r.rows.length, 1);
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 () => {
const v1 = (await get('/v1/sources/nomina/tables/EMPLEADO/schema')).body.version;
const f = await DBFFile.open(path.join(data, 'EMPLEADO.DBF'), { encoding: 'cp1252' });
await f.appendRecords([{ CEDULA: '0000000001', NOMBRE: 'NUEVO', CARGO: 'Y', SUELDO: 2, INGRESO: null, ACTIVO: true }]);
const r = (await get('/v1/sources/nomina/tables/EMPLEADO/rows')).body;
assert.notStrictEqual(r.version, v1);
assert.strictEqual(r.total, 3);
});
console.log(`\n${ok} pruebas OK`);
} finally {
srv.kill();
fs.rmSync(dir, { recursive: true, force: true });
}
}
main().catch((err) => {
console.error('FALLÓ:', err);
process.exit(1);
});
+82
View File
@@ -0,0 +1,82 @@
'use strict';
// Carga y valida config.json. Ruta: variable CONECTOR_CONFIG o <raíz>/config.json.
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
function fail(msg) {
throw new Error(`config.json: ${msg}`);
}
function upperList(list, where) {
if (list === undefined) return [];
if (!Array.isArray(list)) fail(`${where} debe ser una lista`);
return list.map((f) => String(f).trim().toUpperCase());
}
function normalizeTables(tables, where) {
// "*" = todas las .dbf de la carpeta; objeto = solo esas tablas (y opcionalmente solo esos campos).
if (tables === undefined || tables === '*') return '*';
if (typeof tables !== 'object' || Array.isArray(tables)) fail(`${where}.tables debe ser "*" o un objeto`);
const out = {};
for (const [name, rule] of Object.entries(tables)) {
if (!ID_RE.test(name)) fail(`${where}.tables: nombre inválido "${name}"`);
out[name.toUpperCase()] = { fields: rule && rule.fields ? upperList(rule.fields, `${where}.tables.${name}.fields`) : null };
}
return out;
}
function load() {
const file = process.env.CONECTOR_CONFIG || path.join(ROOT, 'config.json');
if (!fs.existsSync(file)) fail(`no existe ${file} (copie config.example.json)`);
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
const tokens = Array.isArray(raw.tokenSha256) ? raw.tokenSha256 : [raw.tokenSha256];
if (!tokens.length || tokens.some((t) => !/^[a-f0-9]{64}$/i.test(String(t || '')))) {
fail('tokenSha256 debe ser el SHA-256 (hex) del token; genérelo con "npm run token"');
}
if (!Array.isArray(raw.allowIps) || !raw.allowIps.length) fail('allowIps es obligatorio (IPs o CIDR del backend)');
if (!Array.isArray(raw.sources) || !raw.sources.length) fail('sources debe tener al menos una carpeta');
const ids = new Set();
const sources = raw.sources.map((s, i) => {
const where = `sources[${i}]`;
if (!ID_RE.test(String(s.id || ''))) fail(`${where}.id inválido`);
if (ids.has(s.id)) fail(`${where}.id repetido: ${s.id}`);
ids.add(s.id);
if (!s.path || !fs.existsSync(s.path)) fail(`${where}.path no existe: ${s.path}`);
return {
id: s.id,
name: s.name || s.id,
path: path.resolve(s.path),
encoding: s.encoding || 'cp1252',
tables: normalizeTables(s.tables, where),
denyFields: upperList(s.denyFields, `${where}.denyFields`),
};
});
const limits = raw.limits || {};
return {
host: raw.host || '0.0.0.0',
port: Number(raw.port || 8455),
tls: raw.tls && raw.tls.pfx ? { pfx: raw.tls.pfx, passphraseEnv: raw.tls.passphraseEnv || 'CONECTOR_TLS_PASS' } : null,
tokenHashes: tokens.map((t) => String(t).toLowerCase()),
allowIps: raw.allowIps.map(String),
sources,
limits: {
maxPageSize: Number(limits.maxPageSize || 1000),
requestsPerMinute: Number(limits.requestsPerMinute || 120),
maxConcurrentReads: Number(limits.maxConcurrentReads || 2),
maxQueuedReads: Number(limits.maxQueuedReads || 10),
cacheMaxRecordsPerTable: Number(limits.cacheMaxRecordsPerTable || 100000),
cacheMaxTables: Number(limits.cacheMaxTables || 10),
requestTimeoutMs: Number(limits.requestTimeoutMs || 30000),
minReloadSeconds: Number(limits.minReloadSeconds ?? 60),
},
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
};
}
module.exports = { load, ROOT, ID_RE };
+239
View File
@@ -0,0 +1,239 @@
'use strict';
// Lectura de tablas FoxPro en SOLO LECTURA. Nunca se abre el original para leer registros:
// se copia DBF+FPT a una carpeta temporal y se lee la copia (no choca con los bloqueos de FoxPro).
const fs = require('fs');
const fsp = require('fs/promises');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const { DBFFile, DELETED } = require('dbffile');
const { ID_RE } = require('./config');
class HttpError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// --- Resolución segura de archivos (el nombre llega del cliente; solo se acepta si existe en la carpeta) ---
async function dirIndex(source) {
const names = await fsp.readdir(source.path);
const map = new Map(); // MAYÚSCULAS -> nombre real
for (const n of names) map.set(n.toUpperCase(), n);
return map;
}
function tableAllowed(source, table) {
return source.tables === '*' || Object.prototype.hasOwnProperty.call(source.tables, table);
}
async function resolveTable(source, tableName) {
if (!ID_RE.test(tableName)) throw new HttpError(400, 'Nombre de tabla inválido');
const table = tableName.toUpperCase();
if (!tableAllowed(source, table)) throw new HttpError(404, 'Tabla no encontrada');
const index = await dirIndex(source);
const dbfName = index.get(`${table}.DBF`);
if (!dbfName) throw new HttpError(404, 'Tabla no encontrada');
const memoName = index.get(`${table}.FPT`) || index.get(`${table}.DBT`) || null;
return {
table,
dbfPath: path.join(source.path, dbfName),
memoPath: memoName ? path.join(source.path, memoName) : null,
};
}
function visibleFields(source, table, fields) {
const rule = source.tables === '*' ? null : source.tables[table];
const allow = rule && rule.fields ? new Set(rule.fields) : null;
const deny = new Set(source.denyFields);
return fields.filter((f) => {
const n = f.name.toUpperCase();
return !deny.has(n) && (!allow || allow.has(n));
});
}
// Firma del archivo: cambia cuando FoxPro escribe (sirve al sistema para saber si hay que resincronizar).
async function signature(ref) {
const a = await fsp.stat(ref.dbfPath);
const b = ref.memoPath ? await fsp.stat(ref.memoPath) : null;
const raw = `${a.size}:${a.mtimeMs}:${b ? `${b.size}:${b.mtimeMs}` : '-'}`;
return { version: crypto.createHash('sha1').update(raw).digest('hex').slice(0, 16), modifiedAt: new Date(Math.max(a.mtimeMs, b ? b.mtimeMs : 0)) };
}
function lockError(err) {
if (['EBUSY', 'EPERM', 'EACCES'].includes(err.code)) {
return new HttpError(423, 'La tabla está bloqueada por FoxPro (uso exclusivo); intente más tarde');
}
return err;
}
async function withSnapshot(ref, fn) {
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'conector-fox-'));
try {
const dbfCopy = path.join(dir, 't.dbf');
try {
await fsp.copyFile(ref.dbfPath, dbfCopy);
if (ref.memoPath) await fsp.copyFile(ref.memoPath, path.join(dir, ref.memoPath.toLowerCase().endsWith('.dbt') ? 't.dbt' : 't.fpt'));
} catch (err) {
throw lockError(err);
}
// VFP con autoincrement/varchar (0x31/0x32) usa el mismo formato de memo que 0x30; la librería solo
// busca el .fpt para 0x30/0xF5, así que se normaliza el byte de versión EN LA COPIA.
const fd = await fsp.open(dbfCopy, 'r+');
try {
const b = Buffer.alloc(1);
await fd.read(b, 0, 1, 0);
if (b[0] === 0x31 || b[0] === 0x32) await fd.write(Buffer.from([0x30]), 0, 1, 0);
} finally {
await fd.close();
}
return await fn(dbfCopy);
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
}
function toPlain(value, type) {
if (value === null || value === undefined) return null;
if (value instanceof Date) {
// 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();
}
if (typeof value === 'string') {
const s = value.replace(/\0+$/, '').trim();
return s === '' ? null : s;
}
return value;
}
// --- Caché por tabla (se invalida sola cuando cambia la firma) ---
const cache = new Map(); // `${source}/${table}` -> { version, fields, records }
function cachePut(key, entry, limits) {
cache.delete(key);
cache.set(key, entry);
while (cache.size > limits.cacheMaxTables) cache.delete(cache.keys().next().value);
}
async function loadTable(source, ref, limits) {
const key = `${source.id}/${ref.table}`;
const sig = await signature(ref);
const hit = cache.get(key);
// 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.set(key, hit); // LRU
return hit;
}
const entry = await withSnapshot(ref, async (copy) => {
const dbf = await DBFFile.open(copy, { readMode: 'loose', encoding: source.encoding, includeDeletedRecords: true });
const fields = visibleFields(source, ref.table, dbf.fields);
const records = [];
let recno = 0;
for await (const r of dbf) {
recno += 1;
const row = { _recno: recno, _deleted: Boolean(r[DELETED]) };
for (const f of fields) row[f.name] = toPlain(r[f.name], f.type);
records.push(row);
}
return { version: sig.version, modifiedAt: sig.modifiedAt, loadedAt: Date.now(), fields, records };
});
if (entry.records.length <= limits.cacheMaxRecordsPerTable) cachePut(key, entry, limits);
return entry;
}
// --- Operaciones públicas ---
async function listTables(source) {
const index = await dirIndex(source);
const out = [];
for (const [upper] of index) {
if (!upper.endsWith('.DBF')) continue;
const table = upper.slice(0, -4);
if (!ID_RE.test(table) || !tableAllowed(source, table)) continue;
try {
const ref = await resolveTable(source, table);
const dbf = await DBFFile.open(ref.dbfPath, { readMode: 'loose', encoding: source.encoding }); // solo lee la cabecera
const sig = await signature(ref);
out.push({ name: table, records: dbf.recordCount, fields: visibleFields(source, table, dbf.fields).length, hasMemo: Boolean(ref.memoPath), version: sig.version, modifiedAt: sig.modifiedAt });
} catch (err) {
out.push({ name: table, error: lockError(err).status === 423 ? 'bloqueada' : 'no legible' });
}
}
return out.sort((a, b) => a.name.localeCompare(b.name));
}
async function getSchema(source, tableName) {
const ref = await resolveTable(source, tableName);
let dbf;
try {
dbf = await DBFFile.open(ref.dbfPath, { readMode: 'loose', encoding: source.encoding });
} catch (err) {
throw lockError(err);
}
const sig = await signature(ref);
return {
table: ref.table,
records: dbf.recordCount,
version: sig.version,
modifiedAt: sig.modifiedAt,
fields: visibleFields(source, ref.table, dbf.fields).map((f) => ({ name: f.name, type: f.type, size: f.size, decimals: f.decimalPlaces || 0 })),
};
}
function fold(s) {
return String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toUpperCase();
}
async function readRows(source, tableName, q, limits) {
const ref = await resolveTable(source, tableName);
const data = await loadTable(source, ref, limits);
const known = new Set(data.fields.map((f) => f.name.toUpperCase()));
const pick = (list, what) => {
const names = String(list).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean);
for (const n of names) if (!known.has(n)) throw new HttpError(400, `Campo desconocido en ${what}: ${n}`);
return names;
};
const fields = q.fields ? pick(q.fields, 'fields') : data.fields.map((f) => f.name);
const limit = Math.min(Math.max(parseInt(q.limit, 10) || 100, 1), limits.maxPageSize);
const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
const includeDeleted = q.includeDeleted === '1' || q.includeDeleted === 'true';
const eq = Object.entries(q)
.filter(([k]) => k.startsWith('eq.'))
.map(([k, v]) => [pick(k.slice(3), 'eq')[0], fold(v).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 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;
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;
return true;
});
const rows = matches.slice(offset, offset + limit).map((r) => {
const o = { _recno: r._recno };
if (includeDeleted) o._deleted = r._deleted;
for (const f of fields) o[f] = r[f];
return o;
});
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 };
+157
View File
@@ -0,0 +1,157 @@
'use strict';
// Conector FoxPro — API de solo lectura (contrato v1, ver ../README.md).
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const config = require('./config');
const dbf = require('./dbf');
const pkg = require('../package.json');
const cfg = config.load();
const { HttpError } = dbf;
// --- Log de acceso (sin tokens ni valores de búsqueda: pueden ser datos personales) ---
fs.mkdirSync(cfg.logDir, { recursive: true });
function log(line) {
const day = new Date().toISOString().slice(0, 10);
fs.appendFile(path.join(cfg.logDir, `acceso-${day}.log`), `${new Date().toISOString()} ${line}\n`, () => {});
}
// --- IPs permitidas (IPv4 exacta o CIDR) ---
function ipToInt(ip) {
const p = ip.split('.').map(Number);
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
}
const allow = cfg.allowIps.map((rule) => {
const [base, bits = '32'] = rule.split('/');
const b = ipToInt(base);
const n = Number(bits);
if (b === null || !(n >= 0 && n <= 32)) throw new Error(`config.json: allowIps inválida: ${rule}`);
const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
return { net: (b & mask) >>> 0, mask };
});
function ipAllowed(ip) {
const v = ipToInt(ip);
return v !== null && allow.some((r) => ((v & r.mask) >>> 0) === r.net);
}
// --- Token Bearer (se guarda solo su SHA-256) ---
const hashes = cfg.tokenHashes.map((h) => Buffer.from(h, 'hex'));
function tokenOk(req) {
const m = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || '');
if (!m) return false;
const h = crypto.createHash('sha256').update(m[1]).digest();
return hashes.some((x) => crypto.timingSafeEqual(x, h));
}
// --- Límite de peticiones por IP y de lecturas simultáneas (no cargar el servidor de SAGA) ---
const hits = new Map();
function rateOk(ip) {
const now = Date.now();
const w = hits.get(ip) || { start: now, n: 0 };
if (now - w.start >= 60000) {
w.start = now;
w.n = 0;
}
w.n += 1;
hits.set(ip, w);
return w.n <= cfg.limits.requestsPerMinute;
}
let active = 0;
const queue = [];
async function withReadSlot(fn) {
if (active >= cfg.limits.maxConcurrentReads) {
if (queue.length >= cfg.limits.maxQueuedReads) throw new HttpError(503, 'Conector ocupado; reintente');
await new Promise((resolve) => queue.push(resolve));
}
active += 1;
try {
return await fn();
} finally {
active -= 1;
const next = queue.shift();
if (next) next();
}
}
// --- Rutas ---
function findSource(id) {
const s = cfg.sources.find((x) => x.id === id);
if (!s) throw new HttpError(404, 'Fuente no encontrada');
return s;
}
async function route(parts, query) {
// parts: ['v1', ...]
if (parts[0] !== 'v1') throw new HttpError(404, 'No encontrado');
const [, a, sourceId, b, table, c] = parts;
if (a === 'health' && parts.length === 2) {
return { ok: true, connector: 'foxpro', contract: 'v1', version: pkg.version, time: new Date().toISOString() };
}
if (a !== 'sources') throw new HttpError(404, 'No encontrado');
if (parts.length === 2) {
return { sources: cfg.sources.map((s) => ({ id: s.id, name: s.name })) };
}
const source = findSource(sourceId);
if (parts.length === 4 && b === 'tables') return { source: source.id, tables: await withReadSlot(() => dbf.listTables(source)) };
if (parts.length === 6 && b === 'tables' && c === 'schema') return { source: source.id, ...(await withReadSlot(() => dbf.getSchema(source, table))) };
if (parts.length === 6 && b === 'tables' && c === 'rows') return { source: source.id, ...(await withReadSlot(() => dbf.readRows(source, table, query, cfg.limits))) };
throw new HttpError(404, 'No encontrado');
}
function send(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(data);
}
async function handler(req, res) {
const t0 = Date.now();
const ip = String(req.socket.remoteAddress || '').replace(/^::ffff:/, '');
let status = 500;
let pathname = '-';
try {
if (!ipAllowed(ip)) throw new HttpError(403, 'Origen no permitido');
if (!rateOk(ip)) throw new HttpError(429, 'Demasiadas peticiones');
if (!tokenOk(req)) throw new HttpError(401, 'Token inválido');
if (req.method !== 'GET') throw new HttpError(405, 'Solo lectura (GET)');
const url = new URL(req.url, 'http://x');
pathname = url.pathname;
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const body = await route(parts, Object.fromEntries(url.searchParams));
status = 200;
send(res, status, body);
} catch (err) {
status = err instanceof HttpError ? err.status : 500;
if (status === 500) log(`ERROR ${ip} ${pathname} ${err.stack || err}`);
send(res, status, { error: status === 500 ? 'Error interno del conector' : err.message });
} finally {
log(`${ip} ${req.method} ${pathname} ${status} ${Date.now() - t0}ms`);
}
}
const server = cfg.tls
? https.createServer({ pfx: fs.readFileSync(cfg.tls.pfx), passphrase: process.env[cfg.tls.passphraseEnv] }, handler)
: http.createServer(handler);
server.requestTimeout = cfg.limits.requestTimeoutMs;
server.headersTimeout = 10000;
server.listen(cfg.port, cfg.host, () => {
const msg = `conector foxpro ${pkg.version} escuchando en ${cfg.tls ? 'https' : 'http'}://${cfg.host}:${cfg.port}`;
log(msg);
console.log(msg);
});
+9
View File
@@ -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).
+138
View File
@@ -0,0 +1,138 @@
# Conexiones externas (fuentes de datos de otros sistemas)
> Leer cuando: se trabaje en traer/consultar datos de sistemas externos (pacientes, empleados de
> 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).
Estado (2026-10-29): **conector FoxPro 1.1.0 instalado y funcionando en Exilaser** (ver abajo).
**Módulo del sistema hecho en dev** (migración 038, `backend/src/modules/integrations`, job
`integrationSync`), sin commit ni producción.
## Arquitectura
El sistema **no se conecta directo** a las bases de origen. En cada servidor origen se instala un
**conector** (API HTTP de solo lectura) y el sistema habla con él por un contrato único (v1).
Para otra tecnología (SQL Server, Excel, etc.) se desarrolla otro conector con el mismo contrato;
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
la VPN WireGuard. Conector en `C:\api_project\foxpro`, puerto `8455`.
- Fuentes reales (revisadas por SSH, solo estructura, 2026-10-29):
| 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
1. **Dos modos por fuente** (lo elige el superadmin): **importar** (copiar a nuestra BD) o
**consulta en vivo** (leer siempre del origen). En consulta en vivo, al agendar se guarda una
copia mínima del paciente porque las citas necesitan el registro local.
2. **Formas de conexión que no comprometan al origen** (superadmin): todo pasa por el conector de
solo lectura; el sistema no guarda credenciales del origen, solo el token del conector (cifrado
AES-256-GCM, nunca vuelve al navegador).
3. **Mapeo en dos pasos** (superadmin): 1) conexión al servidor (URL del conector + token, botón
Probar); 2) qué fuente/tabla usar y a qué destino (Pacientes, Empleados).
4. **Importación al configurar o solo consultas**: al terminar la configuración se ofrece
"Importar ahora" o dejarla solo para consultas.
5. **Diseño por drivers/conectores** con la misma interfaz (aprobado).
6. **Empleados de nómina**: se importan a una tabla local de empleados (clave: cédula; los que
desaparecen del origen quedan inactivos, no se borran). Botón **"Actualizar empleados"** a
pedido, visible para **admin y superadmin**. Debe funcionar con cualquier tipo de origen.
7. **Vista previa**: el superadmin ve las columnas y filas reales de la tabla y elige qué
columnas usar en el sistema.
8. **Crear usuario desde nómina**: en Organización → Usuarios → Nuevo, opción de elegir un empleado
de la lista importada; precarga nombre, correo, cargo y departamento si están mapeados, y deja
el usuario vinculado. No se ofrecen empleados que ya tienen usuario.
## Seguridad (resumen)
- **Regla (usuario, 2026-10-29): todo lo que se busque o traiga de SAGA es por la API del conector**,
también las búsquedas que haga Claude a pedido. El SSH al Windows es solo para administrar el
conector (instalar, actualizar, decidir qué tablas/campos expone), con permiso del usuario. Una
tabla nueva primero se agrega a `config.json` del conector con los campos mínimos; después se
consulta por la API.
- En el conector: solo `GET`, token (se guarda solo el SHA-256), IPs permitidas en el conector y en
el firewall de Windows, tablas/campos permitidos y denegados (sueldos, claves), sin rutas ni SQL
libres, límites de peticiones/concurrencia/página/tiempo, log sin tokens ni valores buscados.
- FoxPro: se lee una **copia temporal** de DBF+FPT, nunca el original (no bloquea a SAGA).
- En el sistema: configuración solo superadmin; "Actualizar empleados" admin/superadmin; auditoría
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
- [x] Revisar en el server las carpetas de nómina y clientes (estructura, codificación).
- [x] Instalar el conector en `C:\api_project\foxpro` y probar desde dev.
- [x] Módulo del sistema (ver "Cómo quedó en el sistema").
- [ ] Commit, revisión y producción (migraciones 036–038, conexión y fuentes a configurar allá).
## Cómo quedó en el sistema (2026-10-29)
- **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.
+5
View File
@@ -42,6 +42,11 @@ abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=
pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y
Empresa además se consulta el servidor justo antes de guardar. Empresa además se consulta el servidor justo antes de guardar.
- **Formulario abierto desde otro** (ej. "Nuevo paciente" desde una cita): guardar, cancelar o
cerrar vuelve al formulario de origen con su borrador; nunca se cierra el de origen dejando su
borrador huérfano (eso hacía que la siguiente cita nueva apareciera con el paciente anterior).
Descartar un registro **nuevo** cierra el popup; uno existente vuelve a lectura.
## Formularios sin recuperación (a propósito) ## Formularios sin recuperación (a propósito)
| Formulario | Motivo | | Formulario | Motivo |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.9.1", "version": "4.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.9.1", "version": "4.13.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.9.1", "version": "4.16.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+78 -38
View File
@@ -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>}
+214 -2
View File
@@ -53,7 +53,11 @@ export const messages = {
clinic_kind_other: 'Otro', clinic_kind_other: 'Otro',
clinic_title: 'Título', clinic_title: 'Título',
clinic_staff: 'Personal', clinic_staff: 'Personal',
clinic_user_id: 'ID de usuario', clinic_user: 'Usuario',
clinic_scope: 'Rol en la agenda',
clinic_add_staff: 'Agregar personal',
clinic_add_segment: 'Agregar tramo',
clinic_patient_upcoming: 'Este paciente ya tiene citas pendientes (puedes agendar igual):',
clinic_scope_all: 'Recepción (todos)', clinic_scope_all: 'Recepción (todos)',
clinic_scope_own: 'Médico (propia)', clinic_scope_own: 'Médico (propia)',
clinic_is_doctor: 'Es médico', clinic_is_doctor: 'Es médico',
@@ -290,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',
@@ -462,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',
@@ -489,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',
@@ -659,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',
@@ -743,7 +849,11 @@ export const messages = {
clinic_kind_other: 'Other', clinic_kind_other: 'Other',
clinic_title: 'Title', clinic_title: 'Title',
clinic_staff: 'Staff', clinic_staff: 'Staff',
clinic_user_id: 'User ID', clinic_user: 'User',
clinic_scope: 'Agenda role',
clinic_add_staff: 'Add staff',
clinic_add_segment: 'Add time slot',
clinic_patient_upcoming: 'This patient already has upcoming appointments (you can still book):',
clinic_scope_all: 'Reception (all)', clinic_scope_all: 'Reception (all)',
clinic_scope_own: 'Doctor (own)', clinic_scope_own: 'Doctor (own)',
clinic_is_doctor: 'Is doctor', clinic_is_doctor: 'Is doctor',
@@ -980,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',
@@ -1152,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',
@@ -1179,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',
@@ -1349,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',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2 } 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 * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import { WEEKDAYS } from './clinicUtils.js';
// Configuración de la agenda (admin/superadmin): Personal, Tipos de cita y Horario de atención.
// Cada sección es un listado; "Agregar"/clic en fila abre un popup en lectura y "Editar" habilita.
const staffForm = (s) => s ? { userId: s.user_id, scope: s.scope, isDoctor: !!s.is_doctor, specialty: s.specialty || '', color: s.color || '#0078d4', isActive: !!s.is_active, editVersion: s.edit_version } : { userId: '', scope: 'own', isDoctor: true, specialty: '', color: '#0078d4', isActive: true };
const typeForm = (x) => x ? { name: x.name, durationMinutes: x.duration_minutes, color: x.color, isActive: !!x.is_active, editVersion: x.edit_version } : { name: '', durationMinutes: 30, color: '#0078d4', isActive: true };
const scheduleForm = (rows) => ({ rows: rows.map((s) => ({ weekday: s.weekday, startsTime: String(s.starts_time).slice(0, 5), endsTime: String(s.ends_time).slice(0, 5) })) });
const swatch = (color) => <span className="calendar-swatch" style={{ background: color || 'rgb(var(--accent))' }} />;
export function ClinicConfigTab({ onChanged }) {
const { t, pushToast } = useUi();
const guardedClose = useGuardedClose();
const [staff, setStaff] = useState([]), [scheduleDoctors, setScheduleDoctors] = useState([]), [users, setUsers] = useState([]), [types, setTypes] = useState([]);
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [loadError, setLoadError] = useState('');
async function reload() {
try {
const [s, u, ty] = await Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]);
// Horario por médico (todos los médicos del personal, aunque quien configura no sea personal).
const docs = s.filter((x) => x.is_doctor);
const segs = await Promise.all(docs.map((d) => api.clinicSchedules(d.user_id)));
setStaff(s); setUsers(u); setTypes(ty);
setScheduleDoctors(docs.map((d, i) => ({ id: d.user_id, name: d.name, color: d.color, schedules: segs[i] })));
setLoadError('');
} catch (e) { setLoadError(e.message); }
}
useEffect(() => { void reload(); }, []);
const initial = !popup ? {} : popup.kind === 'staff' ? staffForm(popup.item) : popup.kind === 'type' ? typeForm(popup.item) : scheduleForm(popup.rows || []);
const draft = useDraftForm({
formId: popup?.kind === 'staff' ? 'clinic-staff' : popup?.kind === 'type' ? 'clinic-type' : 'clinic-schedule',
entityId: popup?.kind === 'staff' ? popup.item?.user_id : popup?.kind === 'type' ? popup.item?.id : popup?.doctor?.id,
initial,
label: t(popup?.kind === 'staff' ? 'clinic_staff' : popup?.kind === 'type' ? 'clinic_types' : 'clinic_schedules'),
enabled: !!popup && editing,
onDiscard: () => { if (popup?.item || popup?.kind === 'schedule') setEditing(false); else setPopup(null); }
});
const v = draft.values;
async function open(kind, item = null, extra = {}) {
setError('');
if (kind === 'schedule') extra.rows = extra.doctor.schedules;
setPopup({ kind, item, ...extra });
setEditing(!item && kind !== 'schedule');
}
function close() { void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); }); }
const cancelEdit = () => draft.discard();
async function save() {
try {
if (popup.kind === 'staff') await api.clinicSaveStaff(v.userId, v);
if (popup.kind === 'type') await api.clinicSaveType(v, popup.item?.id);
if (popup.kind === 'schedule') await api.clinicSaveSchedules(popup.doctor.id, { segments: rows });
} catch (e) { setError(e.message); return; }
draft.clear();
setPopup(null);
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
// Guardado ya hecho: si falla el refresco, se avisa fuera del popup (que ya está cerrado).
try { await reload(); await onChanged(); } catch (e) { setLoadError(e.message); }
}
const staffIds = new Set(staff.map((s) => s.user_id));
const scheduleText = (d) => (d.schedules || []).map((s) => `${t(`calendar_${WEEKDAYS[s.weekday]}`)} ${String(s.starts_time).slice(0, 5)}–${String(s.ends_time).slice(0, 5)}`).join(' · ') || '—';
const rows = (v?.rows || []).map((r) => ({ weekday: Number(r.weekday), startsTime: r.startsTime ?? String(r.starts_time || '').slice(0, 5), endsTime: r.endsTime ?? String(r.ends_time || '').slice(0, 5) }));
const setRows = (next) => draft.setField('rows', next);
const canSave = popup?.kind === 'staff' ? !!v.userId : popup?.kind === 'type' ? !!v.name?.trim() && Number(v.durationMinutes) > 0 : true;
const detail = !popup?.item ? [] : popup.kind === 'staff' ? [
[t('clinic_user'), popup.item.name], [t('clinic_scope'), t(`clinic_scope_${popup.item.scope}`)], [t('clinic_is_doctor'), t(popup.item.is_doctor ? 'common_yes' : 'common_no')],
[t('clinic_specialty'), popup.item.specialty || '—'], [t('clinic_color'), popup.item.color ? <>{swatch(popup.item.color)} {popup.item.color}</> : '—'], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : popup.kind === 'type' ? [
[t('clinic_name'), popup.item.name], [t('clinic_duration'), popup.item.duration_minutes], [t('clinic_color'), <>{swatch(popup.item.color)} {popup.item.color}</>], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : [];
return <div className="clinic-config">
{loadError && <InfoBar tone="error">{loadError}</InfoBar>}
<div className="toolbar"><h3 className="mt-0">{t('clinic_staff')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('staff')}>{t('clinic_add_staff')}</Button></div>
<ListView getRowKey={(s) => s.user_id} rows={staff} onRowClick={(s) => void open('staff', s)}
columns={[
{ key: 'name', label: t('clinic_user'), render: (s) => <span className="clinic-name">{s.is_doctor ? swatch(s.color) : null}{s.name}</span> },
{ key: 'scope', label: t('clinic_scope'), render: (s) => t(`clinic_scope_${s.scope}`) },
{ key: 'specialty', label: t('clinic_specialty'), render: (s) => s.specialty || '—' },
{ key: 'is_active', label: t('common_status'), render: (s) => <Badge color={s.is_active ? 'success' : 'muted'}>{t(s.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(s) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('staff', s).then(() => setEditing(true)); } }]} />
<div className="toolbar"><h3>{t('clinic_types')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('type')}>{t('clinic_new_type')}</Button></div>
<ListView getRowKey={(x) => x.id} rows={types} onRowClick={(x) => void open('type', x)}
columns={[
{ key: 'name', label: t('clinic_name'), render: (x) => <span className="clinic-name">{swatch(x.color)}{x.name}</span> },
{ key: 'duration_minutes', label: t('clinic_duration') },
{ key: 'is_active', label: t('common_status'), render: (x) => <Badge color={x.is_active ? 'success' : 'muted'}>{t(x.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(x) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('type', x).then(() => setEditing(true)); } }]} />
<h3>{t('clinic_schedules')}</h3>
<ListView getRowKey={(d) => d.id} rows={scheduleDoctors} onRowClick={(d) => void open('schedule', null, { doctor: d })}
columns={[
{ key: 'name', label: t('clinic_doctor'), render: (d) => <span className="clinic-name">{swatch(d.color)}{d.name}</span> },
{ key: 'schedules', label: t('clinic_schedules'), sortable: false, render: scheduleText }
]} />
<DetailPopup open={!!popup} title={popup?.kind === 'schedule' ? `${t('clinic_schedules')}: ${popup.doctor.name}` : popup?.kind === 'staff' ? (popup.item ? popup.item.name : t('clinic_add_staff')) : (popup?.item ? popup.item.name : t('clinic_new_type'))} onClose={close} width={760} height={popup?.kind === 'schedule' ? 680 : 480}>
{popup && <div className="clinic-form">
{error && <InfoBar tone="error">{error}</InfoBar>}
{editing && <DraftNotice draft={draft} />}
{!editing ? <>
{popup.kind === 'schedule'
? <div className="clinic-detail">{(popup.rows || []).length ? popup.rows.map((s) => <p key={s.id}><strong>{t(`calendar_${WEEKDAYS[s.weekday]}`)}:</strong> {String(s.starts_time).slice(0, 5)}–{String(s.ends_time).slice(0, 5)}</p>) : <p>—</p>}</div>
: <div className="clinic-detail">{detail.map(([label, val]) => <p key={label}><strong>{label}:</strong> {val}</p>)}</div>}
<div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
</> : <>
{popup.kind === 'staff' && <>
<FieldRow>
<Field label={t('clinic_user')} dirty={draft.isFieldDirty('userId')}>{popup.item ? <input className="input" readOnly value={popup.item.name} /> : <ComboBox value={v.userId} onChange={(x) => draft.setField('userId', x)} options={[{ value: '', label: t('common_select') }, ...users.filter((u) => u.is_active && !staffIds.has(u.id)).map((u) => ({ value: u.id, label: u.name }))]} />}</Field>
<Field label={t('clinic_scope')} dirty={draft.isFieldDirty('scope')}><ComboBox value={v.scope} onChange={(x) => draft.setValues((old) => ({ ...old, scope: x, isDoctor: x === 'own' ? true : old.isDoctor }))} options={['all', 'own'].map((x) => ({ value: x, label: t(`clinic_scope_${x}`) }))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_specialty')} dirty={draft.isFieldDirty('specialty')}><input className="input" maxLength={120} value={v.specialty} disabled={!v.isDoctor} onChange={(e) => draft.setField('specialty', e.target.value)} /></Field>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} disabled={!v.isDoctor} onChange={(e) => draft.setField('color', e.target.value)} /></Field>
</FieldRow>
<div className="clinic-checks">
<label className="calendar-filter"><input type="checkbox" checked={v.isDoctor} disabled={v.scope === 'own'} onChange={(e) => draft.setField('isDoctor', e.target.checked)} /> {t('clinic_is_doctor')}</label>
<label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('clinic_active')}</label>
</div>
</>}
{popup.kind === 'type' && <>
<FieldRow>
<Field label={t('clinic_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('clinic_duration')} dirty={draft.isFieldDirty('durationMinutes')}><input className="input" type="number" min={5} max={480} step={5} value={v.durationMinutes} onChange={(e) => draft.setField('durationMinutes', Number(e.target.value))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} onChange={(e) => draft.setField('color', 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('clinic_active')}</label></Field>
</FieldRow>
</>}
{popup.kind === 'schedule' && <>
{rows.map((s, i) => <div className="clinic-schedule-row" key={i}>
<Field label={t('calendar_day')}><ComboBox value={s.weekday} onChange={(x) => setRows(rows.map((r, j) => j === i ? { ...r, weekday: Number(x) } : r))} options={WEEKDAYS.map((x, n) => ({ value: n, label: t(`calendar_${x}`) }))} /></Field>
<Field label={t('calendar_start')}><input className="input" type="time" value={s.startsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, startsTime: e.target.value } : r))} /></Field>
<Field label={t('calendar_end')}><input className="input" type="time" value={s.endsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, endsTime: e.target.value } : r))} /></Field>
<Button icon={Trash2} variant="danger" aria-label={t('common_delete')} title={t('common_delete')} onClick={() => setRows(rows.filter((_, j) => j !== i))} />
</div>)}
<div><Button icon={Plus} onClick={() => setRows([...rows, { weekday: 0, startsTime: '09:00', endsTime: '17:00' }])}>{t('clinic_add_segment')}</Button></div>
</>}
<div className="toolbar-left clinic-actions"><Button variant="accent" disabled={!canSave || (!!popup.item || popup.kind === 'schedule') && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={cancelEdit}>{t('common_cancel')}</Button></div>
</>}
</div>}
</DetailPopup>
</div>;
}
@@ -0,0 +1,80 @@
import { UserPlus } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
import { DatePicker } from '../../components/DatePicker.jsx';
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
import { plus } from './clinicUtils.js';
// 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.
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, externalPatients = [], onPatientChange, search, onSearch, onNewPatient, slots, upcoming = [] }) {
const { t } = useUi();
const v = draft.values;
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
return <>
<FieldRow>
<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')}>
<div className="clinic-inline">
<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')} />
</div>
</Field>
</FieldRow>
{upcoming.length > 0 && <InfoBar tone="warning">{t('clinic_patient_upcoming')}<ul className="clinic-upcoming">{upcoming.slice(0, 5).map((a) => <li key={a.id}>{a.starts_at.slice(0, 16)} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</li>)}</ul>{upcoming.length > 5 && `+${upcoming.length - 5}`}</InfoBar>}
<FieldRow>
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}><ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} /></Field>
<Field label={t('clinic_type')} dirty={draft.isFieldDirty('typeId')}><ComboBox value={v.typeId} onChange={(x) => draft.setValues((old) => ({ ...old, typeId: x, endsAt: plus(old.startsAt, duration(x)) }))} options={[{ value: '', label: t('common_select') }, ...types.map((z) => ({ value: z.id, label: `${z.name} (${z.duration_minutes} min)` }))]} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setValues((old) => ({ ...old, startsAt: x, endsAt: plus(x, duration(old.typeId)) }))} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
{slots.length > 0 && <Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map((s) => <Button key={s.startsAt} size="sm" variant={s.startsAt === v.startsAt ? 'accent' : undefined} onClick={() => draft.setValues((old) => ({ ...old, startsAt: s.startsAt, endsAt: s.endsAt }))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field>}
<FieldRow>
<Field label={t('clinic_channel')} dirty={draft.isFieldDirty('channel')}><ComboBox value={v.channel} onChange={(x) => draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map((x) => ({ value: x, label: t(`clinic_channel_${x}`) }))} /></Field>
<div className="field" />
</FieldRow>
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
</>;
}
export function PatientForm({ draft, unsplit = false }) {
const { t } = useUi();
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>;
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('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
<FieldRow>
{input('email', 'clinic_email', { maxLength: 160, type: 'email' })}
<Field label={t('clinic_birth_date')} dirty={draft.isFieldDirty('birthDate')}><DatePicker value={v.birthDate || ''} onChange={(x) => draft.setField('birthDate', x || '')} label={t('clinic_birth_date')} /></Field>
</FieldRow>
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes || ''} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
</>;
}
export function BlockForm({ draft, doctors, fixedDoctor }) {
const { t } = useUi();
const v = draft.values;
return <>
<FieldRow>
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}>{fixedDoctor ? <input className="input" readOnly value={doctors.find((d) => d.id === v.doctorId)?.name || ''} /> : <ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} />}</Field>
<Field label={t('clinic_kind')} dirty={draft.isFieldDirty('kind')}><ComboBox value={v.kind} onChange={(x) => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map((x) => ({ value: x, label: t(`clinic_kind_${x}`) }))} /></Field>
</FieldRow>
<Field label={t('clinic_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title', e.target.value)} /></Field>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt', x)} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={(x) => draft.setField('recurrence', x)} />
</>;
}
+23 -1
View File
@@ -1,4 +1,26 @@
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}} .clinic-page{padding:16px;min-width:0}
.clinic-page .page-title{display:flex;align-items:center;gap:8px}
/* Formularios: misma estructura que "Nueva reunión" (FieldRow en pares, acciones al pie). */
.clinic-form{display:grid;gap:12px;align-content:start}
.clinic-form .field{min-width:0}
.clinic-inline{display:flex;gap:8px;align-items:center}
.clinic-inline>:first-child{flex:1;min-width:0}
.clinic-actions{margin-top:18px;flex-wrap:wrap}
.clinic-checks{display:flex;gap:24px;flex-wrap:wrap}
.clinic-color{padding:2px;height:36px;max-width:120px}
.clinic-upcoming{margin:6px 0 0;padding-left:18px}
.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}
.clinic-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}
.clinic-name{display:inline-flex;align-items:center;gap:8px}
.clinic-name .calendar-swatch,.clinic-detail .calendar-swatch{display:inline-block;width:10px;height:10px;border-radius:3px;flex:none}
.clinic-pager{margin-top:12px;align-items:center}
.clinic-config{display:grid;gap:8px}
.clinic-config h3:not(.mt-0){margin:20px 0 4px}
.clinic-config h3.mt-0{margin-bottom:4px}
.clinic-schedule-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(110px,1fr) minmax(110px,1fr) auto;gap:8px;align-items:end}
.clinic-schedule-row>.btn{margin-bottom:2px}
@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}.clinic-schedule-row{grid-template-columns:1fr 1fr}}
/* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */ /* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */
.meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px} .meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px}
.meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)} .meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)}
@@ -0,0 +1,70 @@
// Utilidades de la Agenda médica (fechas "YYYY-MM-DD HH:MM:SS" en hora de Ecuador, sin zona).
export const at = (day, hour = '09:00') => `${day} ${hour}:00`;
export const plus = (s, n) => {
const d = new Date(`${s.replace(' ', 'T')}Z`);
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
};
export const readStored = (key, fallback) => {
try {
return JSON.parse(localStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};
export const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
export const initialAppointment = (day, doctorId, slot, patientId) => ({
doctorId: doctorId || '',
patientId: patientId || '',
typeId: '',
startsAt: slot || at(day),
endsAt: plus(slot || at(day), 30),
channel: 'phone',
notes: ''
});
export const initialPatient = (identification = '') => ({
firstNames: '',
lastNames: '',
identification,
phone: '',
email: '',
birthDate: '',
notes: ''
});
export const initialBlock = (day, doctorId) => ({
doctorId: doctorId || '',
kind: 'surgery',
title: '',
startsAt: at(day),
endsAt: plus(at(day), 60),
recurrence: null
});
export const formAppointment = (a) => ({
doctorId: a.doctor_user_id,
patientId: a.patient_id,
typeId: a.type_id,
startsAt: a.starts_at,
endsAt: a.ends_at,
channel: a.channel,
notes: a.notes || '',
editVersion: a.edit_version
});
export const formPatient = (p) => ({
firstNames: p.first_names,
lastNames: p.last_names,
identification: p.identification || '',
phone: p.phone,
email: p.email || '',
birthDate: p.birth_date || '',
notes: p.notes || '',
editVersion: p.edit_version
});
export const formBlock = (b) => ({
doctorId: b.doctor_user_id,
kind: b.kind,
title: b.title,
startsAt: b.starts_at,
endsAt: b.ends_at,
recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
editVersion: b.edit_version
});
@@ -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>;
}
+90 -22
View File
@@ -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>
); );
} }
+20 -2
View File
@@ -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 />
+3
View File
@@ -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`);
+2
View File
@@ -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`);
+14
View File
@@ -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);
+9 -4
View File
@@ -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; }
@@ -303,14 +305,14 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px; display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px;
} }
.dialog-surface { .dialog-surface {
width: 420px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg); width: 460px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4); overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4);
} }
.dialog-stripe { height: 4px; } .dialog-stripe { height: 4px; }
.dialog-body { padding: 20px; } .dialog-body { padding: 20px; }
.dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; } .dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
.dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; } .dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); } .dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); }
/* --- InfoBar --- */ /* --- InfoBar --- */
.infobar { .infobar {
@@ -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; }
+5 -2
View File
@@ -14,7 +14,7 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i; const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = { const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'], 'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'clinic-staff': ['editVersion','scope','isDoctor','specialty','color','isActive'], 'clinic-staff': ['editVersion','userId','scope','isDoctor','specialty','color','isActive'],
'clinic-type': ['editVersion','name','durationMinutes','color','isActive'], 'clinic-type': ['editVersion','name','durationMinutes','color','isActive'],
'clinic-schedule': ['rows'], 'clinic-schedule': ['rows'],
'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'], 'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'],
@@ -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': [],