Compare commits
18 Commits
a6cc41666e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 053f51afe7 | |||
| 420a794c9e | |||
| f5721f95a9 | |||
| 34183d15ce | |||
| b2c25c4fe5 | |||
| 0968e61ced | |||
| d85a7196f0 | |||
| 0ee9ed65bf | |||
| fabe5b530d | |||
| d0f90542e9 | |||
| 28444b1485 | |||
| 366447dffc | |||
| 00aaf1ebef | |||
| 91e15e8318 | |||
| 1e25022630 | |||
| bdc62403f4 | |||
| d562351242 | |||
| 6066f27ba0 |
@@ -76,11 +76,16 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- Proyectos (con miembros)
|
- Proyectos (con miembros)
|
||||||
- Actividades + Tareas + dependencias simples
|
- Actividades + Tareas + dependencias simples
|
||||||
- Dashboard (KPIs)
|
- Dashboard (KPIs)
|
||||||
|
- Agenda médica (citas, pacientes, bloqueos, horarios y tipos; v4.7.0)
|
||||||
- Calendario y Gantt de solo lectura
|
- Calendario y Gantt de solo lectura
|
||||||
- Comentarios y adjuntos
|
- Comentarios y adjuntos
|
||||||
- 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
|
||||||
|
|
||||||
@@ -107,6 +112,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
## Seguridad (proyecto con auth/usuarios)
|
## Seguridad (proyecto con auth/usuarios)
|
||||||
|
|
||||||
- Permisos validados SIEMPRE en el backend, nunca solo en el frontend.
|
- Permisos validados SIEMPRE en el backend, nunca solo en el frontend.
|
||||||
|
- Cambio de correo con aprobación (v4.6.0): reautenticación, aprobador según rol y confirmación en el correo nuevo; sesiones revocadas solo al aplicar (migración 035).
|
||||||
- Autorregistro con doble candado (decisión posterior al MVP inicial, ver `SPEC.md`): el usuario
|
- Autorregistro con doble candado (decisión posterior al MVP inicial, ver `SPEC.md`): el usuario
|
||||||
se crea a sí mismo pero queda `pending_verification` → confirma su correo (token de un solo uso,
|
se crea a sí mismo pero queda `pending_verification` → confirma su correo (token de un solo uso,
|
||||||
24h) → `pending_approval` → un `admin`/`superadmin` aprueba (elige rol/departamento) o rechaza
|
24h) → `pending_approval` → un `admin`/`superadmin` aprueba (elige rol/departamento) o rechaza
|
||||||
@@ -132,6 +138,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se
|
- Sin SMTP configurado, el sistema degrada a modo dev: los correos de verificación/recuperación se
|
||||||
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
|
loguean en la consola del backend en vez de enviarse (nunca rompe el flujo).
|
||||||
|
|
||||||
|
|
||||||
## Regla de UX para pantallas
|
## Regla de UX para pantallas
|
||||||
|
|
||||||
- No mezclar listado y formulario de creación en la vista inicial de un módulo.
|
- No mezclar listado y formulario de creación en la vista inicial de un módulo.
|
||||||
@@ -140,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
|
||||||
|
|
||||||
@@ -166,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
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,85 @@ catálogos, notas, tareas recurrentes, equipos y SMTP exigen `editVersion` (ente
|
|||||||
obtenido de la lectura correspondiente como `edit_version`. El `PUT /api/company` exige el mismo
|
obtenido de la lectura correspondiente como `edit_version`. El `PUT /api/company` exige el mismo
|
||||||
campo en multipart. La API responde `428` si falta o es inválido y `409` si otro guardado cambió
|
campo en multipart. La API responde `428` si falta o es inválido y `409` si otro guardado cambió
|
||||||
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`).
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
|
||||||
|
Sin la fila se responde 403. `scope=own` solo accede a su médico; `scope=all` gestiona todos.
|
||||||
|
Configuración exige además rol admin/superadmin. Fechas `YYYY-MM-DD HH:mm:ss` en hora civil
|
||||||
|
`America/Guayaquil`; rangos de listado de máximo 93 días.
|
||||||
|
|
||||||
|
| Método y ruta | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `GET /staff/me` | alcance y condición de médico |
|
||||||
|
| `GET /doctors` | médicos visibles con horario y tipos activos |
|
||||||
|
| `GET /availability?doctorId=&date=&typeId=` | huecos de 15 minutos con duración del tipo |
|
||||||
|
| `GET /appointments?from=&to=[&doctorId=][&patientId=]` | citas del rango, incluidas canceladas y eliminadas (`deleted_at`) para trazabilidad; `patientId` solo recepción |
|
||||||
|
| `POST /appointments`, `PUT /appointments/:id` | crear o reprogramar/editar; `PUT` exige `editVersion` |
|
||||||
|
| `POST /appointments/:id/delete` | `{reason,editVersion}` borrado lógico (solo recepción): libera el horario, conserva la fila con quién/cuándo/motivo |
|
||||||
|
| `POST /appointments/:id/status` | `{status,editVersion}`; médico propio solo `attended`/`no_show` |
|
||||||
|
| `POST /appointments/:id/cancel` | `{reason,editVersion}`, solo recepción |
|
||||||
|
| `GET /blocks?from=&to=[&doctorId=]` | ocurrencias expandidas del rango |
|
||||||
|
| `POST /blocks`, `PUT /blocks/:id` | bloqueo; `{confirmAffected:true}` para guardar si afecta citas |
|
||||||
|
| `DELETE /blocks/:id?editVersion=` | eliminar bloqueo con versión |
|
||||||
|
| `GET /patients?search=&page=&pageSize=` | búsqueda paginada, solo recepción |
|
||||||
|
| `GET /patients/:id/appointments` | últimas 100 citas, solo recepción |
|
||||||
|
| `POST /patients`, `PUT /patients/:id` | alta y edición, solo recepción; `PUT` exige `editVersion` |
|
||||||
|
| `GET /config/staff`, `PUT /config/staff/:id` | personal y permiso interno |
|
||||||
|
| `GET/PUT /config/schedules/:id` | tramos; `PUT` recibe `{segments:[{weekday,startsTime,endsTime}]}` |
|
||||||
|
| `GET/POST /config/types`, `PUT /config/types/:id` | tipos de cita |
|
||||||
|
|
||||||
|
Una cita fuera del horario o en choque devuelve 409 con motivo concreto. Un bloqueo afectado
|
||||||
|
responde `{requiresConfirmation:true,affected:[...]}` y no se guarda sin confirmación. Las
|
||||||
|
ediciones con versión obsoleta devuelven 409. Las altas, ediciones, cambios de estado y
|
||||||
|
cancelaciones se auditan sin copiar datos de pacientes al detalle de auditoría.
|
||||||
|
|
||||||
|
## Calendario de reuniones (v4.4.0)
|
||||||
|
|
||||||
|
Todas las rutas requieren JWT y módulo `calendar`. Las fechas son `YYYY-MM-DD HH:mm:ss` en hora
|
||||||
|
civil `America/Guayaquil` (UTC-5). `GET /api/meetings?from=&to=&page=&pageSize=[&projectId=]` (sin `projectId`: solo reuniones que el
|
||||||
|
usuario organiza o a las que fue invitado, cualquier rol; con `projectId`: todas las del proyecto si
|
||||||
|
puede leerlo, si no 404) devuelve
|
||||||
|
`{items,total,page,pageSize}` con ocurrencias expandidas (`occurrence_start`, `occurrence_end`);
|
||||||
|
rango máximo 93 días y hasta 1500 ocurrencias por página (predeterminado: 1500).
|
||||||
|
`GET /api/meetings/activities?from=&to=` devuelve hasta 1000 vencimientos de actividades donde el
|
||||||
|
usuario es responsable (con `is_closed` y `overdue`).
|
||||||
|
`GET /api/meetings/tasks?from=&to=`: tareas donde el usuario es responsable (`activity_id`, `project_id`,
|
||||||
|
`is_closed`, `overdue`). `GET /api/meetings/projects?from=&to=`: proyectos donde es dueño o miembro,
|
||||||
|
una fila por fecha en rango (`milestone`: `start` | `end`). Ambas requieren módulo `projects`.
|
||||||
|
`GET /api/meetings/recurring?from=&to=` (requiere también el módulo `recurring_tasks`) devuelve los
|
||||||
|
próximos vencimientos de las tareas recurrentes activas que el usuario creó o de las que es responsable, calculados al vuelo
|
||||||
|
desde `next_due_date` con la misma regla que `advanceCycle` y cortados en `end_date`. Cada fila trae
|
||||||
|
`due_date`, `is_next` (el ciclo pendiente real), `overdue` y `can_open` (si puede abrirla en Tareas recurrentes).
|
||||||
|
|
||||||
|
Todas las filas de calendario traen `shared` (lo creó/organiza otra persona y me incluye).
|
||||||
|
|
||||||
|
- `GET /api/meetings/:id`: detalle, participantes con RSVP y excepciones.
|
||||||
|
- `POST /api/meetings`: `{title,description,startsAt,endsAt,allDay,location,videoUrl,projectId,reminderMinutes,participantUserIds,externalEmails,recurrence}`.
|
||||||
|
- `PUT /api/meetings/:id`: mismo cuerpo con `editVersion` (aplica a toda la serie).
|
||||||
|
- `DELETE /api/meetings/:id`: **cancelación lógica** de toda la serie, con `METHOD:CANCEL`.
|
||||||
|
- `POST /api/meetings/:id/occurrences/:date/cancel`: excluye una ocurrencia; `date` URL encoded.
|
||||||
|
- `PUT /api/meetings/:id/rsvp`: `{status:"accepted"|"declined"|"tentative"}`; solo participante propio.
|
||||||
|
|
||||||
|
`recurrence` es `null` o `{frequency:"daily"|"weekly"|"monthly",interval,weekdays,until,count}`;
|
||||||
|
`weekdays` usa 0=lunes a 6=domingo. Se exige exactamente uno de `until` o `count` y máximo
|
||||||
|
365 ocurrencias. Cuando hay choque de participantes, POST/PUT devuelve
|
||||||
|
`{requiresConfirmation:true,warnings:[nombres]}` sin guardar; reenviar con
|
||||||
|
`confirmConflicts:true` confirma. Correo y avisos se despachan en segundo plano; sin SMTP el correo
|
||||||
|
se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar la migración 034.
|
||||||
|
|
||||||
## Endpoints principales
|
## Endpoints principales
|
||||||
|
|
||||||
@@ -66,7 +145,7 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
|||||||
- `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`.
|
- `POST /api/users/:id/approve` — `{ roleId?, departmentId?, editVersion }`, solo sobre usuarios `pending_approval`; también rechaza cambios concurrentes con `409`.
|
||||||
- `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes)
|
- `POST /api/users/:id/reject` — borra al usuario `pending_approval` (se audita el email antes)
|
||||||
- `GET /api/users/:id/modules` · `PUT /api/users/:id/modules` `{ modules: [...] }` — solo Superadministrador.
|
- `GET /api/users/:id/modules` · `PUT /api/users/:id/modules` `{ modules: [...] }` — solo Superadministrador.
|
||||||
Módulos: `projects`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a
|
Módulos: `projects`, `calendar`, `recurring_tasks`, `notes`, `vault`, `devices`, `organization`. No aplica a
|
||||||
un superadmin (`400`). `GET /api/auth/me` y el login devuelven `modules` (efectivos del usuario).
|
un superadmin (`400`). `GET /api/auth/me` y el login devuelven `modules` (efectivos del usuario).
|
||||||
Las rutas de un módulo deshabilitado responden `403`; la búsqueda global devuelve vacío.
|
Las rutas de un módulo deshabilitado responden `403`; la búsqueda global devuelve vacío.
|
||||||
|
|
||||||
@@ -151,10 +230,14 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
|||||||
- `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
|
||||||
@@ -244,3 +327,16 @@ el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
|||||||
- `403` sin permisos para la acción (según rol)
|
- `403` sin permisos para la acción (según rol)
|
||||||
- `404` no encontrado (incluye cuando el recurso existe pero no pertenece al usuario/alcance)
|
- `404` no encontrado (incluye cuando el recurso existe pero no pertenece al usuario/alcance)
|
||||||
- `500` error interno (sanitizado, sin detalle de stack en producción)
|
- `500` error interno (sanitizado, sin detalle de stack en producción)
|
||||||
|
|
||||||
|
## Cambio de correo (v4.6.0)
|
||||||
|
|
||||||
|
- `GET /api/account/email-change`: última solicitud propia (requiere JWT).
|
||||||
|
- `POST /api/account/email-change`: `{ newEmail, currentPassword }`; crea una solicitud tras validar la clave, con límite de 20 solicitudes por 15 minutos.
|
||||||
|
- `DELETE /api/account/email-change`: cancela la solicitud propia pendiente o aprobada.
|
||||||
|
- `GET /api/users/email-change-requests`: pendientes que el admin/superadmin puede decidir.
|
||||||
|
- `POST /api/users/email-change-requests/:id/approve`: aprueba y envía el enlace de confirmación al correo nuevo.
|
||||||
|
- `POST /api/users/email-change-requests/:id/reject`: `{ reason }` obligatorio; avisa al correo actual.
|
||||||
|
- `POST /api/auth/email-change/self-approve`: `{ token }`; enlace público de aprobación exclusiva del superadmin, un solo uso.
|
||||||
|
- `POST /api/auth/email-change/confirm`: `{ token }`; enlace público de confirmación del correo nuevo. Revoca todas las sesiones al aplicar.
|
||||||
|
|
||||||
|
Los endpoints públicos tienen límite de 20 solicitudes por 15 minutos. Los enlaces duran 24 horas; las solicitudes abiertas vencen a los 7 días. Los tokens se almacenan solo como SHA-256.
|
||||||
|
|||||||
@@ -10,6 +10,40 @@
|
|||||||
- Charset: `utf8mb4`
|
- Charset: `utf8mb4`
|
||||||
- Collation: `utf8mb4_unicode_ci`
|
- Collation: `utf8mb4_unicode_ci`
|
||||||
|
|
||||||
|
Migración `034_meetings.sql` (v4.4.0): aplicada en desarrollo, pendiente en
|
||||||
|
producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de notificaciones.
|
||||||
|
`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.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
`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
|
||||||
|
médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
|
||||||
|
|
||||||
|
| Tabla | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `clinic_staff` | permiso `all`/`own`, condición de médico, especialidad, color, activo, versión |
|
||||||
|
| `clinic_patients` | identificación única opcional, contacto mínimo, nacimiento, nota corta, versión |
|
||||||
|
| `clinic_schedules` | tramos por médico y día de semana, lunes=0 |
|
||||||
|
| `clinic_appointment_types` | nombre, duración, color y estado |
|
||||||
|
| `clinic_appointments` | cita, médico, paciente, tipo, estado, canal, cancelación y versión; autor fijo (`created_by`, `created_by_kind` user/ai_agent, `created_by_name`) y borrado lógico (`deleted_at/by/by_name/reason`) — migración 037 |
|
||||||
|
| `clinic_blocks` | bloqueo de médico, clase y recurrencia con las reglas de reuniones; `created_by_name` y borrado lógico (`deleted_at/by/by_name`) |
|
||||||
|
|
||||||
|
La reserva bloquea la fila `clinic_staff` del médico y la del paciente antes de verificar choques;
|
||||||
|
los cambios de horario y bloqueos se serializan por la misma fila del médico. La migración crea una fila inicial `all` para el superadmin más antiguo; el resto se habilita
|
||||||
|
desde la UI. No hay migración aplicada ni conexión a BD en este cambio.
|
||||||
|
|
||||||
## Fuente de verdad
|
## Fuente de verdad
|
||||||
|
|
||||||
- La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`).
|
- La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`).
|
||||||
@@ -34,6 +68,10 @@
|
|||||||
| `activity_dependencies` | dependencias simples entre actividades ("depende de") |
|
| `activity_dependencies` | dependencias simples entre actividades ("depende de") |
|
||||||
| `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) |
|
| `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) |
|
||||||
| `recurring_tasks` | recordatorios periódicos (ej. renovación de licencias), menú propio "Tareas recurrentes" |
|
| `recurring_tasks` | recordatorios periódicos (ej. renovación de licencias), menú propio "Tareas recurrentes" |
|
||||||
|
| `meetings` | serie de reunión en hora civil America/Guayaquil; UID iCalendar, secuencia, versión y cancelación lógica |
|
||||||
|
| `meeting_participants` / `meeting_external_participants` | usuarios con RSVP y correos externos sin respuesta |
|
||||||
|
| `meeting_exceptions` | ocurrencias canceladas, sin materializar el resto de la serie |
|
||||||
|
| `meeting_reminders` | clave `(meeting_id, occurrence_start)` para deduplicar avisos |
|
||||||
| `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) |
|
| `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) |
|
||||||
| `attachments` | archivos adjuntos a proyecto o actividad |
|
| `attachments` | archivos adjuntos a proyecto o actividad |
|
||||||
| `notifications` | notificaciones in-app por usuario |
|
| `notifications` | notificaciones in-app por usuario |
|
||||||
@@ -45,6 +83,7 @@
|
|||||||
| `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) |
|
||||||
@@ -231,3 +270,9 @@ en producción.
|
|||||||
sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de
|
sistema (según `system_category` del agente + nombre, ver `normalizer.js#isComponent`); las vistas de
|
||||||
software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado
|
software lo ocultan salvo que se marque la casilla. `inventory_tokens` guarda el hash (`token_hash`, valida la ingesta) y el token cifrado
|
||||||
(`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador.
|
(`token_enc`, migración `025`, AES-256-GCM con `ENCRYPTION_KEY`) para regenerar el instalador.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
|||||||
+180
@@ -1,5 +1,185 @@
|
|||||||
# 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
|
||||||
|
### Fixed
|
||||||
|
- Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas
|
||||||
|
Mes/Semana/Día/Agenda, filtros con etiqueta y leyenda). Se ve completo aunque aún no haya médicos;
|
||||||
|
antes podía quedar en blanco si el navegador tenía guardada la vista Día. Siempre abre en Mes.
|
||||||
|
- Semana/Día de la agenda: las horas fuera del horario de los médicos (o bloqueadas, viendo un solo
|
||||||
|
médico) se muestran deshabilitadas; clic en una hora libre abre la cita nueva a esa hora.
|
||||||
|
### Changed
|
||||||
|
- Componente compartido `CalendarBoard` (reemplaza a `MonthGrid`); Calendario y Agenda médica lo
|
||||||
|
usan, sin código de cuadrícula duplicado. Sin migración.
|
||||||
|
|
||||||
|
## [4.9.0] - 2026-10-29
|
||||||
|
### Changed
|
||||||
|
- Agenda médica: clic en un día del mes (o en una hora libre de Semana/Día) abre directamente el
|
||||||
|
formulario de cita nueva con esa fecha; clic en una cita la abre en modo lectura con **Editar**.
|
||||||
|
Antes el clic en el día saltaba a la vista Día. "+N más" sí lleva al día.
|
||||||
|
### Added
|
||||||
|
- Agenda médica: cada cita guarda quién la agendó (usuario, o "Agente IA" para el futuro canal de
|
||||||
|
WhatsApp) y no cambia al editarla; se muestra en el detalle y en el historial del paciente.
|
||||||
|
- Agenda médica: **Eliminar cita** con motivo, como borrado lógico (libera el horario pero la cita
|
||||||
|
queda registrada con quién, cuándo y por qué). Los bloqueos también se eliminan de forma lógica.
|
||||||
|
El filtro Estado permite ver Canceladas y Eliminadas. Migración 037.
|
||||||
|
### Fixed
|
||||||
|
- Agenda médica: clic en una cita dentro de Semana/Día ya no abre además una cita nueva encima.
|
||||||
|
|
||||||
|
## [4.8.1] - 2026-10-28
|
||||||
|
### Fixed
|
||||||
|
- Agenda médica: abre en vista **Mes** con la misma barra que el Calendario (Hoy, anterior/siguiente,
|
||||||
|
mes y año; vista Mes/Semana/Día/Agenda a la derecha). Antes abría en Día y, sin médicos
|
||||||
|
configurados, la pantalla quedaba vacía. Aviso cuando aún no hay médicos registrados.
|
||||||
|
|
||||||
|
## [4.8.0] - 2026-10-28
|
||||||
|
### Added
|
||||||
|
- Agenda médica: vista **Mes** tipo calendario (hasta 4 citas/bloqueos por día, clic abre el día) y
|
||||||
|
leyenda de colores por médico. Componente reutilizable `MonthGrid`.
|
||||||
|
### Changed
|
||||||
|
- Agenda médica: la vista **Semana** muestra las citas de todos los médicos filtrados (antes uno solo),
|
||||||
|
coloreadas por médico y en formato compacto. La vista elegida se recuerda.
|
||||||
|
|
||||||
|
## [4.7.0] - 2026-10-28
|
||||||
|
### Added
|
||||||
|
- Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad.
|
||||||
|
- Validación bloqueante de horario y choques de médico, paciente y bloqueos dentro de transacciones con bloqueo de filas.
|
||||||
|
- Capa de citas y bloqueos propios del médico en el calendario personal. Migración 036 creada, no aplicada.
|
||||||
|
|
||||||
|
## [4.6.0] - 2026-10-28
|
||||||
|
### Added
|
||||||
|
- Cambio de correo con aprobación por rol, confirmación de correo nuevo, auditoría y revocación de todas las sesiones al aplicar. Migración 035 pendiente de aplicar.
|
||||||
|
|
||||||
|
## [4.5.0] - 2026-10-28
|
||||||
|
### Added
|
||||||
|
- Calendario: capa de tareas recurrentes con sus próximos vencimientos (el pendiente real y las
|
||||||
|
repeticiones futuras proyectadas), también en la pestaña Calendario del proyecto.
|
||||||
|
- Leyenda de colores con interruptor por capa (reuniones, vencimientos de actividades, tareas
|
||||||
|
recurrentes), persistida; vencidos en rojo y actividades cerradas tachadas.
|
||||||
|
- Tareas recurrentes acepta `?task=id` para abrir una tarea desde el calendario.
|
||||||
|
- Capas Proyectos (inicio/fin estimado) y Mis tareas.
|
||||||
|
- Filtros persistidos: **Ver** (Todo / Solo lo mío / Compartido conmigo) y **Mostrar** (lista con
|
||||||
|
casillas por tipo con opción Todo). Lo compartido conmigo se marca con una franja.
|
||||||
|
### Changed
|
||||||
|
- El calendario es personal para todos los roles: reuniones que organizo o me invitaron, y
|
||||||
|
actividades/tareas/recurrentes donde soy responsable (antes un admin veía las de todos).
|
||||||
|
Las tareas recurrentes compartidas (soy responsable) se ven en solo lectura.
|
||||||
|
- La pestaña Calendario del proyecto muestra todas las reuniones vinculadas al proyecto.
|
||||||
|
|
||||||
|
## [4.4.0] - 2026-10-27
|
||||||
|
### Added
|
||||||
|
- Menú Calendario con vistas Mes, Semana, Día y Agenda; reuniones con participantes internos/externos,
|
||||||
|
RSVP interno (avisa al organizador), vínculo opcional a proyecto y vencimientos de actividades diferenciados.
|
||||||
|
- Invitaciones iCalendar por correo, notificaciones in-app, recurrencia con excepciones, recordatorios
|
||||||
|
por minuto y advertencia de choques. Requiere migración `034_meetings.sql`.
|
||||||
|
- Acceso al módulo `calendar` configurable por usuario y edición con versión optimista.
|
||||||
|
|
||||||
|
|
||||||
## [4.3.0] - 2026-10-26
|
## [4.3.0] - 2026-10-26
|
||||||
### Added
|
### Added
|
||||||
- Sesiones activas: cada inicio de sesión queda registrado (`user_sessions`, migración 033) con
|
- Sesiones activas: cada inicio de sesión queda registrado (`user_sessions`, migración 033) con
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -1,9 +1,88 @@
|
|||||||
# ESTADO — project.linkedcorp.net
|
# ESTADO — project.linkedcorp.net
|
||||||
|
|
||||||
Ultima actualizacion: 2026-10-23 (v3.0.1 en desarrollo, sin desplegar)
|
Ultima actualizacion: 2026-10-29 (v4.13.0 commit + push, sin desplegar)
|
||||||
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
|
||||||
autorregistro/aprobacion/SMTP.
|
autorregistro/aprobacion/SMTP.
|
||||||
|
|
||||||
|
## Conexiones externas (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)
|
||||||
|
|
||||||
|
- 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
|
||||||
|
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
|
||||||
|
lectura (Editar, Confirmar, Cancelar, Eliminar). Autor fijo de la cita (`created_by_kind`
|
||||||
|
user/ai_agent → "Agente IA" para el futuro WhatsApp). Borrado lógico de citas (con motivo) y
|
||||||
|
bloqueos; filtro Estado incluye Canceladas/Eliminadas. Migración 037 aplicada en dev (2 veces OK).
|
||||||
|
Probado en dev: 41/41 pruebas de API + navegador, datos temporales borrados.
|
||||||
|
- v4.8.1: abre en Mes con barra igual al Calendario; aviso si no hay médicos (en prod se veía vacío
|
||||||
|
porque la 4.8.0 abría en Día y aún no hay médicos configurados).
|
||||||
|
- v4.8.0: vistas Mes (calendario) y Semana con todos los médicos filtrados, color por médico. Sin
|
||||||
|
migración. Revisado en dev con datos temporales (ya borrados), sin errores de consola.
|
||||||
|
|
||||||
|
- Módulo `clinic` con permisos por `clinic_staff` (`all` recepción, `own` médico), pacientes, horarios, tipos, citas y bloqueos recurrentes.
|
||||||
|
- Migración `036_clinic.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica el actualizador).
|
||||||
|
- Probado en dev con usuarios/pacientes temporales (ya borrados), 29/29: configuración por admin que no es
|
||||||
|
personal, 403 sin módulo o sin staff, horario de atención, choque médico, choque paciente con otro médico,
|
||||||
|
cita contigua permitida, quirófano recurrente (también la semana siguiente), bloqueo sobre cita pide
|
||||||
|
confirmación, disponibilidad, concurrencia (6 intentos simultáneos de 2 recepcionistas al mismo hueco →
|
||||||
|
1 creada, 5 × 409), médico solo ve/gestiona lo suyo, cancelar exige motivo, auditoría. UI Día revisada.
|
||||||
|
- La migración crea el primer staff `all` para el superadmin más antiguo. Configurar el resto del personal, tipos y horarios desde la pestaña de configuración antes de agendar. `clinic` está desactivado por defecto para otros usuarios y el superadmin lo habilita en Organización.
|
||||||
|
- Sin commit, push ni despliegue.
|
||||||
|
|
||||||
|
## Cambio de correo con aprobación (v4.6.0, sin desplegar)
|
||||||
|
|
||||||
|
- Implementado en Mi cuenta y Organización → Usuarios, con enlaces públicos de aprobación propia del superadmin y confirmación del correo nuevo.
|
||||||
|
- Migración `035_email_change_requests.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica
|
||||||
|
el actualizador). Probado en dev con usuarios temporales (ya borrados): colaborador→admin aprueba,
|
||||||
|
admin→solo superadmin, superadmin→se autoaprueba por enlace al correo actual; sesiones siguen vivas
|
||||||
|
hasta confirmar desde el correo nuevo y ahí se cierran todas; login viejo falla y nuevo funciona;
|
||||||
|
tokens de un solo uso y nunca en auditoría. Pendiente: envío real con SMTP (dev → log).
|
||||||
|
- No se hizo commit ni push.
|
||||||
|
|
||||||
|
## Calendario de reuniones (v4.4.0, sin desplegar)
|
||||||
|
|
||||||
|
- v4.5.0: calendario personal para todos los roles (ver SPEC §7), capas Proyectos/Actividades/
|
||||||
|
Tareas/Recurrentes/Reuniones con colores, filtros Ver (mío/compartido) y Mostrar (tipos); recurrentes compartidas en solo
|
||||||
|
lectura. Probado con un usuario temporal (ya borrado): lo compartido aparece a ambos y lo ajeno
|
||||||
|
no. Sin migración nueva. Probado en dev con tareas de prueba (fin de mes 31→30, corte por
|
||||||
|
`end_date`, vencida en rojo), ya borradas.
|
||||||
|
|
||||||
|
- Menú y API de reuniones, recurrencia, RSVP, vencimientos de actividades, correo iCalendar y
|
||||||
|
recordatorios in-app implementados en código. `DELETE` marca cancelada toda la serie.
|
||||||
|
- Migración `034_meetings.sql` **aplicada en dev** (2026-10-27), **no en producción**. Aplicarla
|
||||||
|
antes de arrancar el backend 4.4.0 en prod. El backend de dev en :4019 sigue con el código
|
||||||
|
anterior hasta reiniciarlo.
|
||||||
|
- Probado en dev con instancia temporal (4099/5197, datos de prueba ya borrados): alta, choque con
|
||||||
|
confirmación, validaciones, expansión semanal, rango máx. 93 días, edición con versión (409),
|
||||||
|
cancelar ocurrencia (EXDATE/RECURRENCE-ID), cancelar serie (METHOD:CANCEL), RSVP solo
|
||||||
|
participante (aviso in-app al organizador), recordatorio in-app a tiempo, UI escritorio/móvil sin
|
||||||
|
errores de consola. Pendiente: probar envío real con SMTP (dev no tiene SMTP → log).
|
||||||
|
|
||||||
## Auditoría de seguridad y calidad (v3.0.0, sin desplegar)
|
## Auditoría de seguridad y calidad (v3.0.0, sin desplegar)
|
||||||
|
|
||||||
- Se corrigieron controles de roles, alcance de búsqueda y tareas recurrentes, bootstrap
|
- Se corrigieron controles de roles, alcance de búsqueda y tareas recurrentes, bootstrap
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
## Modulos (MVP)
|
## Modulos (MVP)
|
||||||
|
|
||||||
### 1. Seguridad
|
### 1. Seguridad
|
||||||
|
|
||||||
|
- Cambio de correo (v4.6.0): desde Mi cuenta, correo nuevo y clave actual. Admin/superadmin aprueban solicitudes de usuarios comunes; solo superadmin aprueba admins; un superadmin se autoaprueba mediante enlace enviado a su correo actual. El nuevo correo se confirma por enlace de un solo uso. Solo entonces se actualiza `users.email` y se cierran todas las sesiones; solicitudes abiertas vencen a los 7 días y enlaces a las 24 horas. El usuario puede cancelar hasta aplicar.
|
||||||
|
|
||||||
- Login/logout con JWT (`sub`, `sv`, `sid`) y fila activa por sesión en `user_sessions`.
|
- Login/logout con JWT (`sub`, `sv`, `sid`) y fila activa por sesión en `user_sessions`.
|
||||||
Mi cuenta muestra perfil, preferencias, seguridad y sesiones activas. Cada usuario puede cerrar
|
Mi cuenta muestra perfil, preferencias, seguridad y sesiones activas. Cada usuario puede cerrar
|
||||||
una sesión propia o todas las demás; admin/superadmin pueden cerrar sesiones de usuarios desde
|
una sesión propia o todas las demás; admin/superadmin pueden cerrar sesiones de usuarios desde
|
||||||
@@ -66,7 +69,66 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
promedio. Lista de actividades vencidas y de hoy del usuario.
|
promedio. Lista de actividades vencidas y de hoy del usuario.
|
||||||
|
|
||||||
### 7. Calendario
|
### 7. Calendario
|
||||||
- Vista mensual de actividades por fecha límite (incluye hitos marcados).
|
- Menú propio de reuniones (v4.4.0): Mes/Semana/Día/Agenda, creación desde hueco o botón,
|
||||||
|
detalle de lectura y edición de toda la serie. Título, descripción, inicio/fin, todo el día,
|
||||||
|
ubicación, enlace de videollamada, proyecto opcional, recordatorio y recurrencia.
|
||||||
|
- Participantes internos (RSVP aceptar/rechazar/quizás) y hasta 50 correos externos sin RSVP.
|
||||||
|
Solo organizador, admin y superadmin editan/cancelan. Lectura: organizador, participante o
|
||||||
|
acceso al proyecto asociado. El módulo `calendar` se puede deshabilitar por usuario.
|
||||||
|
- Recurrencia diaria, semanal por días o mensual por día del mes; hasta 365 ocurrencias y fin
|
||||||
|
obligatorio por fecha o cantidad. Cancelación puntual con EXDATE; DELETE cancela lógicamente
|
||||||
|
toda la serie. Invitaciones/cancelaciones iCalendar y recordatorios in-app.
|
||||||
|
- **Calendario personal (v4.5.0), igual para todos los roles** (un admin no ve lo de los demás):
|
||||||
|
reuniones que organizo o a las que me invitaron; proyectos donde soy dueño o miembro (inicio y
|
||||||
|
fin estimado); actividades y tareas donde soy responsable; tareas recurrentes que creé o de las
|
||||||
|
que soy responsable. Lo compartido aparece en el calendario de ambos: participantes de una
|
||||||
|
reunión y responsable de una tarea recurrente (este la ve en solo lectura, porque el menú Tareas
|
||||||
|
recurrentes solo la muestra al creador/admin).
|
||||||
|
- Filtros persistidos: **Ver** (Todo / Solo lo mío / Compartido conmigo) y **Mostrar** (lista con
|
||||||
|
casillas: Todo, Reuniones, Proyectos, Mis actividades, Mis tareas, Tareas recurrentes). Compartido
|
||||||
|
conmigo = lo creó u organiza otra persona y me incluye (reunión: organizador; proyecto: dueño;
|
||||||
|
actividad y recurrente: creador; tarea: creador de su actividad, porque tasks no guarda creador).
|
||||||
|
- Cada tipo con color propio y leyenda; vencido en rojo, cerrado tachado, compartido con franja
|
||||||
|
a la izquierda.
|
||||||
|
- La pestaña Calendario del proyecto muestra todas sus actividades y todas las reuniones
|
||||||
|
vinculadas al proyecto (requiere acceso de lectura al proyecto).
|
||||||
|
|
||||||
|
### 7 bis. Agenda médica (v4.7.0)
|
||||||
|
- `clinic` se habilita explícitamente por usuario (desactivado por defecto; superadmin siempre tiene el módulo). Además exige una fila activa en `clinic_staff`: `all` permite
|
||||||
|
a recepción ver/agendar con todos los médicos, gestionar pacientes y bloqueos; `own` permite
|
||||||
|
a un médico ver su agenda, gestionar sus bloqueos y marcar atendida/no asistió. No crea citas.
|
||||||
|
Los médicos conservan el resto de sus módulos y no se crea un rol del sistema.
|
||||||
|
- Admin/superadmin con acceso a `clinic` y fila de staff configuran personal, horarios por tramos
|
||||||
|
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
|
||||||
|
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
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
|
||||||
|
en una transacción para evitar reservas simultáneas del mismo hueco.
|
||||||
|
- Crear/editar un bloqueo que cruce citas devuelve las citas afectadas; solo se guarda tras
|
||||||
|
`confirmAffected=true`. No cancela las citas: recepción debe reprogramarlas.
|
||||||
|
- Datos de paciente mínimos, sin historia clínica; cambios auditados sin incluir datos sensibles
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meetings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
uid CHAR(36) NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
all_day TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
location VARCHAR(255) NULL,
|
||||||
|
video_url VARCHAR(2048) NULL,
|
||||||
|
project_id INT NULL,
|
||||||
|
organizer_id INT NOT NULL,
|
||||||
|
reminder_minutes SMALLINT UNSIGNED NULL,
|
||||||
|
recurrence_json TEXT NULL,
|
||||||
|
sequence_number INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
cancelled_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_meetings_range (starts_at, ends_at, cancelled_at),
|
||||||
|
INDEX idx_meetings_project (project_id, starts_at),
|
||||||
|
INDEX idx_meetings_organizer (organizer_id, starts_at),
|
||||||
|
CONSTRAINT fk_meetings_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_meetings_organizer FOREIGN KEY (organizer_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meeting_participants (
|
||||||
|
meeting_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
rsvp ENUM('pending','accepted','declined','tentative') NOT NULL DEFAULT 'pending',
|
||||||
|
responded_at DATETIME NULL,
|
||||||
|
PRIMARY KEY (meeting_id, user_id),
|
||||||
|
INDEX idx_meeting_participants_user (user_id, meeting_id),
|
||||||
|
CONSTRAINT fk_mp_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_mp_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meeting_external_participants (
|
||||||
|
meeting_id INT NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL,
|
||||||
|
PRIMARY KEY (meeting_id, email),
|
||||||
|
CONSTRAINT fk_mep_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meeting_exceptions (
|
||||||
|
meeting_id INT NOT NULL,
|
||||||
|
occurrence_start DATETIME NOT NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (meeting_id, occurrence_start),
|
||||||
|
CONSTRAINT fk_mex_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_mex_user FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS meeting_reminders (
|
||||||
|
meeting_id INT NOT NULL,
|
||||||
|
occurrence_start DATETIME NOT NULL,
|
||||||
|
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (meeting_id, occurrence_start),
|
||||||
|
CONSTRAINT fk_mr_meeting FOREIGN KEY (meeting_id) REFERENCES meetings(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE notifications MODIFY COLUMN entity_type ENUM('project','activity','meeting') NULL;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS email_change_requests (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
old_email VARCHAR(160) NOT NULL,
|
||||||
|
new_email VARCHAR(160) NOT NULL,
|
||||||
|
status ENUM('pending','approved','rejected','cancelled','expired','applied','failed') NOT NULL DEFAULT 'pending',
|
||||||
|
requires_self_approval TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
approve_token_hash CHAR(64) NULL,
|
||||||
|
approve_token_expires DATETIME NULL,
|
||||||
|
confirm_token_hash CHAR(64) NULL,
|
||||||
|
confirm_token_expires DATETIME NULL,
|
||||||
|
decided_by INT NULL,
|
||||||
|
decided_at DATETIME NULL,
|
||||||
|
reject_reason VARCHAR(1000) NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
applied_at DATETIME NULL,
|
||||||
|
open_user_id INT AS (CASE WHEN status IN ('pending','approved') THEN user_id ELSE NULL END) PERSISTENT,
|
||||||
|
open_new_email VARCHAR(160) AS (CASE WHEN status IN ('pending','approved') THEN new_email ELSE NULL END) PERSISTENT,
|
||||||
|
UNIQUE KEY uq_email_change_open_user (open_user_id),
|
||||||
|
UNIQUE KEY uq_email_change_open_email (open_new_email),
|
||||||
|
INDEX idx_email_change_user_status (user_id, status),
|
||||||
|
INDEX idx_email_change_approve_hash (approve_token_hash),
|
||||||
|
INDEX idx_email_change_confirm_hash (confirm_token_hash),
|
||||||
|
CONSTRAINT fk_email_change_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_email_change_decider FOREIGN KEY (decided_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE notifications MODIFY COLUMN entity_type ENUM('project','activity','meeting','email_change_request') NULL;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_staff (
|
||||||
|
user_id INT PRIMARY KEY,
|
||||||
|
scope ENUM('all','own') NOT NULL DEFAULT 'own',
|
||||||
|
is_doctor TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
specialty VARCHAR(120) NULL,
|
||||||
|
color VARCHAR(7) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
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_clinic_staff_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_patients (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
first_names VARCHAR(120) NOT NULL,
|
||||||
|
last_names VARCHAR(120) NOT NULL,
|
||||||
|
identification VARCHAR(40) NULL UNIQUE,
|
||||||
|
phone VARCHAR(40) NOT NULL,
|
||||||
|
email VARCHAR(160) NULL,
|
||||||
|
birth_date DATE NULL,
|
||||||
|
notes VARCHAR(500) 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,
|
||||||
|
INDEX idx_clinic_patient_name (last_names, first_names),
|
||||||
|
INDEX idx_clinic_patient_phone (phone),
|
||||||
|
CONSTRAINT fk_clinic_patient_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_schedules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
doctor_user_id INT NOT NULL,
|
||||||
|
weekday TINYINT UNSIGNED NOT NULL,
|
||||||
|
starts_time TIME NOT NULL,
|
||||||
|
ends_time TIME NOT NULL,
|
||||||
|
INDEX idx_clinic_schedule_doctor_day (doctor_user_id, weekday),
|
||||||
|
CONSTRAINT fk_clinic_schedule_doctor FOREIGN KEY (doctor_user_id) REFERENCES clinic_staff(user_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_appointment_types (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
duration_minutes SMALLINT UNSIGNED NOT NULL,
|
||||||
|
color VARCHAR(7) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
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
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_appointments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
doctor_user_id INT NOT NULL,
|
||||||
|
patient_id INT NOT NULL,
|
||||||
|
type_id INT NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
status ENUM('scheduled','confirmed','attended','no_show','cancelled') NOT NULL DEFAULT 'scheduled',
|
||||||
|
channel ENUM('phone','whatsapp','in_person','other') NOT NULL DEFAULT 'phone',
|
||||||
|
notes VARCHAR(500) NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
cancelled_at DATETIME NULL,
|
||||||
|
cancelled_by INT NULL,
|
||||||
|
cancelled_reason VARCHAR(500) 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,
|
||||||
|
INDEX idx_clinic_appointment_doctor_range (doctor_user_id, starts_at, ends_at, status),
|
||||||
|
INDEX idx_clinic_appointment_patient_range (patient_id, starts_at, ends_at, status),
|
||||||
|
CONSTRAINT fk_clinic_appointment_doctor FOREIGN KEY (doctor_user_id) REFERENCES clinic_staff(user_id),
|
||||||
|
CONSTRAINT fk_clinic_appointment_patient FOREIGN KEY (patient_id) REFERENCES clinic_patients(id),
|
||||||
|
CONSTRAINT fk_clinic_appointment_type FOREIGN KEY (type_id) REFERENCES clinic_appointment_types(id),
|
||||||
|
CONSTRAINT fk_clinic_appointment_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_clinic_appointment_canceller FOREIGN KEY (cancelled_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_blocks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
doctor_user_id INT NOT NULL,
|
||||||
|
kind ENUM('surgery','vacation','other') NOT NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
recurrence_json TEXT NULL,
|
||||||
|
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,
|
||||||
|
INDEX idx_clinic_block_doctor_range (doctor_user_id, starts_at, ends_at),
|
||||||
|
CONSTRAINT fk_clinic_block_doctor FOREIGN KEY (doctor_user_id) REFERENCES clinic_staff(user_id),
|
||||||
|
CONSTRAINT fk_clinic_block_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Bootstrap del único configurador inicial. El resto del personal se habilita desde la UI.
|
||||||
|
INSERT IGNORE INTO clinic_staff (user_id, scope, is_doctor, is_active)
|
||||||
|
SELECT u.id, 'all', 0, 1 FROM users u JOIN roles r ON r.id=u.role_id
|
||||||
|
WHERE r.code='superadmin' AND u.is_active=1 ORDER BY u.id LIMIT 1;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- Trazabilidad de Agenda médica: quién agendó (usuario o agente IA, fijo desde la creación) y
|
||||||
|
-- borrado lógico de citas y bloqueos (nunca se borran filas).
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS created_by_kind ENUM('user','ai_agent') NOT NULL DEFAULT 'user' AFTER created_by;
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS created_by_name VARCHAR(160) NULL AFTER created_by_kind;
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER cancelled_reason;
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_by INT NULL AFTER deleted_at;
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_by_name VARCHAR(160) NULL AFTER deleted_by;
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_reason VARCHAR(500) NULL AFTER deleted_by_name;
|
||||||
|
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS cancelled_by_name VARCHAR(160) NULL AFTER cancelled_by;
|
||||||
|
UPDATE clinic_appointments a JOIN users u ON u.id = a.created_by SET a.created_by_name = u.name WHERE a.created_by_name IS NULL;
|
||||||
|
UPDATE clinic_appointments a JOIN users u ON u.id = a.cancelled_by SET a.cancelled_by_name = u.name WHERE a.cancelled_by_name IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS created_by_name VARCHAR(160) NULL AFTER created_by;
|
||||||
|
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER edit_version;
|
||||||
|
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS deleted_by INT NULL AFTER deleted_at;
|
||||||
|
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS deleted_by_name VARCHAR(160) NULL AFTER deleted_by;
|
||||||
|
UPDATE clinic_blocks b JOIN users u ON u.id = b.created_by SET b.created_by_name = u.name WHERE b.created_by_name IS NULL;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- Conexiones externas (conectores de solo lectura, contrato v1 en data/conectores/README.md),
|
||||||
|
-- empleados importados de nómina y vínculo de pacientes con el sistema origen. Idempotente.
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_connections (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
base_url VARCHAR(255) NOT NULL,
|
||||||
|
token_encrypted TEXT NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ext_conn_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Una fuente por destino: qué tabla del conector alimenta Pacientes o Empleados, cómo (importar o
|
||||||
|
-- consultar en vivo), el mapeo de campos y el estado de la última sincronización.
|
||||||
|
CREATE TABLE IF NOT EXISTS external_sources (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
purpose ENUM('patients','employees') NOT NULL UNIQUE,
|
||||||
|
connection_id INT NOT NULL,
|
||||||
|
source_key VARCHAR(64) NOT NULL,
|
||||||
|
table_name VARCHAR(64) NOT NULL,
|
||||||
|
mode ENUM('import','live') NOT NULL DEFAULT 'import',
|
||||||
|
field_map_json TEXT NOT NULL,
|
||||||
|
filter_json TEXT NULL,
|
||||||
|
changed_field VARCHAR(64) NULL,
|
||||||
|
last_full_sync_at DATETIME NULL,
|
||||||
|
last_sync_at DATETIME NULL,
|
||||||
|
last_max_recno INT UNSIGNED NULL,
|
||||||
|
last_changed_value VARCHAR(40) NULL,
|
||||||
|
last_result_json TEXT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_ext_source_conn FOREIGN KEY (connection_id) REFERENCES external_connections(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
identification VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
last_names VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
first_names VARCHAR(120) NOT NULL DEFAULT '',
|
||||||
|
phone VARCHAR(40) NULL,
|
||||||
|
phone2 VARCHAR(40) NULL,
|
||||||
|
birth_date DATE NULL,
|
||||||
|
position VARCHAR(120) NULL,
|
||||||
|
department_name VARCHAR(120) NULL,
|
||||||
|
hire_date DATE NULL,
|
||||||
|
leave_date DATE NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
in_source TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
user_id INT NULL UNIQUE,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_employee_name (last_names, first_names),
|
||||||
|
CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Tercera fuente: proveedores (médicos que facturan), para buscarlos y agregarlos como empleados.
|
||||||
|
ALTER TABLE external_sources MODIFY purpose ENUM('patients','employees','providers') NOT NULL;
|
||||||
|
|
||||||
|
-- Origen del empleado: nómina (se sincroniza) o proveedor de SAGA cargado a mano (médicos que facturan;
|
||||||
|
-- la sincronización de nómina no los inactiva). Correo: los proveedores lo tienen, la nómina no.
|
||||||
|
ALTER TABLE employees ADD COLUMN IF NOT EXISTS origin ENUM('payroll','provider') NOT NULL DEFAULT 'payroll' AFTER is_active;
|
||||||
|
ALTER TABLE employees ADD COLUMN IF NOT EXISTS email VARCHAR(160) NULL AFTER phone2;
|
||||||
|
|
||||||
|
-- Pacientes vinculados al origen (SAGA): source_name guarda el nombre tal como viene (SAGA no
|
||||||
|
-- separa nombres y apellidos ni respeta un orden); mientras recepción no lo separe a mano, el
|
||||||
|
-- nombre visible es ese. Manda el origen en identificación/teléfono/correo/nombre de origen.
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_source VARCHAR(20) NULL AFTER notes;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS external_id VARCHAR(40) NULL AFTER external_source;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS source_name VARCHAR(160) NULL AFTER external_id;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS name_split TINYINT(1) NOT NULL DEFAULT 1 AFTER source_name;
|
||||||
|
ALTER TABLE clinic_patients ADD COLUMN IF NOT EXISTS synced_at DATETIME NULL AFTER name_split;
|
||||||
|
ALTER TABLE clinic_patients MODIFY phone VARCHAR(40) NOT NULL DEFAULT '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_clinic_patient_external ON clinic_patients (external_source, external_id);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Año de lanzamiento del procesador (el agente GLPI no lo reporta): lo consulta la IA solo con el
|
||||||
|
-- nombre del procesador. cpu_year_for guarda el cpu_summary usado, para descartar el año si el
|
||||||
|
-- procesador del equipo cambia. Idempotente.
|
||||||
|
ALTER TABLE devices
|
||||||
|
ADD COLUMN IF NOT EXISTS cpu_year SMALLINT UNSIGNED NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS cpu_year_for VARCHAR(255) NULL;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Tipo de licencia de un programa corregido a mano (gratuito, pago, gratis solo personal o
|
||||||
|
-- componente). Vale para todos los equipos que lo tengan y gana sobre el clasificador por reglas
|
||||||
|
-- (softwareClassifier.js), que no se guarda: se calcula al leer. Idempotente.
|
||||||
|
CREATE TABLE IF NOT EXISTS software_classifications (
|
||||||
|
name VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
license_type VARCHAR(20) NOT NULL,
|
||||||
|
updated_by INT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_software_classifications_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "3.0.1",
|
"version": "4.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "3.0.1",
|
"version": "4.13.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "4.3.0",
|
"version": "4.16.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { env } from './config/env.js';
|
|||||||
import { notFoundHandler, errorHandler } from './middleware/errorHandler.js';
|
import { notFoundHandler, errorHandler } from './middleware/errorHandler.js';
|
||||||
|
|
||||||
import authRoutes from './modules/auth/routes.js';
|
import authRoutes from './modules/auth/routes.js';
|
||||||
|
import { accountEmailRoutes, usersEmailRoutes, publicEmailRoutes } from './modules/email-change/routes.js';
|
||||||
import usersRoutes from './modules/users/routes.js';
|
import usersRoutes from './modules/users/routes.js';
|
||||||
import departmentsRoutes from './modules/departments/routes.js';
|
import departmentsRoutes from './modules/departments/routes.js';
|
||||||
import catalogsRoutes from './modules/catalogs/routes.js';
|
import catalogsRoutes from './modules/catalogs/routes.js';
|
||||||
@@ -27,6 +28,9 @@ import vaultRoutes from './modules/vault/routes.js';
|
|||||||
import devicesRoutes from './modules/devices/routes.js';
|
import devicesRoutes from './modules/devices/routes.js';
|
||||||
import inventoryIngestRoutes from './modules/devices/ingest.js';
|
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 clinicRoutes from './modules/clinic/routes.js';
|
||||||
|
import integrationRoutes from './modules/integrations/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -47,6 +51,9 @@ app.use(express.json({ limit: '2mb' }));
|
|||||||
|
|
||||||
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
|
app.get('/health', (req, res) => res.json({ success: true, data: { status: 'ok' }, error: null }));
|
||||||
|
|
||||||
|
app.use('/api/auth/email-change', publicEmailRoutes);
|
||||||
|
app.use('/api/account/email-change', accountEmailRoutes);
|
||||||
|
app.use('/api/users/email-change-requests', usersEmailRoutes);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/users', usersRoutes);
|
app.use('/api/users', usersRoutes);
|
||||||
app.use('/api/departments', departmentsRoutes);
|
app.use('/api/departments', departmentsRoutes);
|
||||||
@@ -68,6 +75,9 @@ app.use('/api/notes', notesRoutes);
|
|||||||
app.use('/api/vault', vaultRoutes);
|
app.use('/api/vault', vaultRoutes);
|
||||||
app.use('/api/devices', devicesRoutes);
|
app.use('/api/devices', devicesRoutes);
|
||||||
app.use('/api/ai', aiRoutes);
|
app.use('/api/ai', aiRoutes);
|
||||||
|
app.use('/api/meetings', meetingsRoutes);
|
||||||
|
app.use('/api/clinic', clinicRoutes);
|
||||||
|
app.use('/api/integrations', integrationRoutes);
|
||||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { scheduledPatientSync } from '../modules/integrations/service.js';
|
||||||
|
|
||||||
|
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Pacientes desde el sistema origen (modo importar): completa lunes a viernes a las 05:00 y
|
||||||
|
// incremental cada 15 min de 06:00 a 21:00 (ver integrations/service.scheduledPatientSync).
|
||||||
|
// Mismo patrón que los demás jobs: dentro del proceso del backend, sin cron del SO.
|
||||||
|
export function startIntegrationSyncJob() {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
const result = await scheduledPatientSync();
|
||||||
|
if (result) console.log(`[integrations] pacientes ${result.full ? 'completa' : 'incremental'}: ${result.received} recibidos, ${result.created} nuevos, ${result.updated} actualizados, ${result.deactivated} inactivados`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status !== 409) console.error('[integrations] error sincronizando pacientes:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(run, 30000);
|
||||||
|
setInterval(run, CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { sendDueReminders } from '../modules/meetings/service.js';
|
||||||
|
export function startMeetingReminderJob() {
|
||||||
|
let running = false;
|
||||||
|
const run = async () => {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
try { await sendDueReminders(); } catch (err) { console.error('[meetings] recordatorios:', err.message); }
|
||||||
|
finally { running = false; }
|
||||||
|
};
|
||||||
|
setInterval(run, 60_000);
|
||||||
|
void run();
|
||||||
|
}
|
||||||
@@ -3,14 +3,15 @@ import { fail } from '../utils/http.js';
|
|||||||
|
|
||||||
// Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no
|
// Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no
|
||||||
// estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin.
|
// estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin.
|
||||||
export const MODULES = ['projects', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
|
export const MODULES = ['projects', 'calendar', 'clinic', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
|
||||||
|
|
||||||
// Sin fila en user_modules = habilitado. El superadmin siempre tiene todo.
|
// Sin fila en user_modules = habilitado. El superadmin siempre tiene todo.
|
||||||
export async function loadModules(userId, roleCode) {
|
export async function loadModules(userId, roleCode) {
|
||||||
if (roleCode === 'superadmin') return [...MODULES];
|
if (roleCode === 'superadmin') return [...MODULES];
|
||||||
const [rows] = await pool.query('SELECT module FROM user_modules WHERE user_id = ? AND enabled = 0', [userId]);
|
const [rows] = await pool.query('SELECT module, enabled FROM user_modules WHERE user_id = ?', [userId]);
|
||||||
const disabled = new Set(rows.map((r) => r.module));
|
const enabled = new Map(rows.map((r) => [r.module, !!r.enabled]));
|
||||||
return MODULES.filter((m) => !disabled.has(m));
|
// Clinic exige activación explícita; los módulos previos conservan su valor por defecto.
|
||||||
|
return MODULES.filter((m) => m === 'clinic' ? enabled.get(m) === true : enabled.get(m) !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasModule(user, module) {
|
export function hasModule(user, module) {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
|
import * as clinic from './service.js';
|
||||||
|
import * as integrations from '../integrations/service.js';
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth, requireModule('clinic'));
|
||||||
|
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
|
||||||
|
// del middleware que resuelve clinicStaff.
|
||||||
|
router.get('/config/staff', asyncHandler(async (req, res) => ok(res, await clinic.configStaff(req.currentUser))));
|
||||||
|
router.put('/config/staff/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveStaff(req.body, req.currentUser, req.params.id))));
|
||||||
|
router.get('/config/schedules/:id', asyncHandler(async (req, res) => {
|
||||||
|
clinic.requireConfig(req.currentUser);
|
||||||
|
ok(res, await clinic.schedules(req.params.id));
|
||||||
|
}));
|
||||||
|
router.put('/config/schedules/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveSchedules(req.params.id, req.body, req.currentUser))));
|
||||||
|
router.get('/config/types', asyncHandler(async (req, res) => {
|
||||||
|
clinic.requireConfig(req.currentUser);
|
||||||
|
ok(res, await clinic.types());
|
||||||
|
}));
|
||||||
|
router.post('/config/types', asyncHandler(async (req, res) => ok(res, await clinic.saveType(req.body, req.currentUser), 201)));
|
||||||
|
router.put('/config/types/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveType(req.body, req.currentUser, req.params.id))));
|
||||||
|
router.use(asyncHandler(async (req, res, next) => {
|
||||||
|
req.clinicStaff = await clinic.staffFor(req.currentUser);
|
||||||
|
next();
|
||||||
|
}));
|
||||||
|
const get = (path, fn) => router.get(path, asyncHandler(async (req, res) => ok(res, await fn(req.query, req.clinicStaff, req.currentUser))));
|
||||||
|
const write = (method, path, fn) => router[method](path, asyncHandler(async (req, res) => ok(res, await fn(req.body, req.currentUser, req.clinicStaff, req.params.id), method === 'post' && !req.params.id ? 201 : 200)));
|
||||||
|
get('/staff/me', async (q, s) => s);
|
||||||
|
get('/doctors', async (q, s) => Promise.all([clinic.doctors(s), clinic.types()]).then(async ([doctors, types]) => ({
|
||||||
|
doctors: await Promise.all(doctors.map(async d => ({
|
||||||
|
...d,
|
||||||
|
schedules: await clinic.schedules(d.id)
|
||||||
|
}))),
|
||||||
|
types: types.filter(t => t.is_active)
|
||||||
|
})));
|
||||||
|
get('/availability', (q, s) => clinic.availability(q, s));
|
||||||
|
get('/appointments', (q, s) => clinic.appointments(q, s));
|
||||||
|
write('post', '/appointments', (body, user, staff) => clinic.saveAppointment(body, user, staff));
|
||||||
|
write('put', '/appointments/:id', (body, user, staff, n) => clinic.saveAppointment(body, user, staff, n));
|
||||||
|
write('post', '/appointments/:id/cancel', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff, true));
|
||||||
|
write('post', '/appointments/:id/delete', (body, user, staff, n) => clinic.deleteAppointment(n, body, user, staff));
|
||||||
|
write('post', '/appointments/:id/status', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff));
|
||||||
|
get('/blocks', (q, s) => clinic.blocks(q, s));
|
||||||
|
write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, staff));
|
||||||
|
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))));
|
||||||
|
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))));
|
||||||
|
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));
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion } from '../../utils/editVersion.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { addMinutes, durationMinutes, expand, validateRecurrence, guayaquilNow } from '../meetings/recurrence.js';
|
||||||
|
const dateRe = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/;
|
||||||
|
const id = value => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const date = value => {
|
||||||
|
if (typeof value !== 'string' || !dateRe.test(value)) throw new HttpError(400, 'Fecha inválida');
|
||||||
|
const s = `${value.slice(0, 16)}:00`;
|
||||||
|
const d = new Date(`${s.replace(' ', 'T')}Z`);
|
||||||
|
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 19).replace('T', ' ') !== s) throw new HttpError(400, 'Fecha inválida');
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const text = (v, max, required = false) => {
|
||||||
|
const s = String(v ?? '').trim();
|
||||||
|
if (required && !s || s.length > max || /[\u0000-\u001f\u007f]/.test(s)) throw new HttpError(400, 'Texto inválido');
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const version = v => requireEditVersion(Number(v));
|
||||||
|
const range = q => {
|
||||||
|
const from = date(q.from),
|
||||||
|
to = date(q.to);
|
||||||
|
if (from >= to || durationMinutes(from, to) > 93 * 1440) throw new HttpError(400, 'Rango inválido (máximo 93 días)');
|
||||||
|
return [from, to];
|
||||||
|
};
|
||||||
|
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
|
||||||
|
const audit = async (user, action, entityType, entityId, connection = pool) => logAudit({
|
||||||
|
userId: user.id,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
connection
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
|
||||||
|
}
|
||||||
|
export async function staffFor(user) {
|
||||||
|
const rows = await exec(pool, 'SELECT s.*, u.name FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.user_id=? AND s.is_active=1 AND u.is_active=1', [user.id]);
|
||||||
|
if (!rows.length) throw new HttpError(403, 'No estás habilitado como personal de Agenda médica');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
function doctorAllowed(staff, doctorId) {
|
||||||
|
if (staff.scope === 'own' && (!staff.is_doctor || doctorId !== staff.user_id)) throw new HttpError(403, 'Solo puedes acceder a tu propia agenda');
|
||||||
|
}
|
||||||
|
export async function doctors(staff) {
|
||||||
|
return exec(pool, `SELECT s.user_id AS id, u.name, s.specialty, s.color, s.scope, s.is_doctor FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.is_doctor=1 AND s.is_active=1 AND u.is_active=1 ${staff.scope === 'own' ? 'AND s.user_id=?' : ''} ORDER BY u.name`, staff.scope === 'own' ? [staff.user_id] : []);
|
||||||
|
}
|
||||||
|
export async function schedules(doctorId) {
|
||||||
|
return exec(pool, 'SELECT id,doctor_user_id,weekday,starts_time,ends_time FROM clinic_schedules WHERE doctor_user_id=? ORDER BY weekday,starts_time', [id(doctorId)]);
|
||||||
|
}
|
||||||
|
export async function types() {
|
||||||
|
return exec(pool, 'SELECT * FROM clinic_appointment_types ORDER BY name');
|
||||||
|
}
|
||||||
|
// Por defecto solo citas vigentes (las que ocupan horario); con all=true tambien canceladas y
|
||||||
|
// eliminadas, para mostrarlas con su trazabilidad.
|
||||||
|
function selectAppointments(db, from, to, doctorId, patientId, excludeId, all = false) {
|
||||||
|
return exec(db, `SELECT a.*, p.first_names,p.last_names,t.name AS type_name,t.color AS type_color,u.name AS doctor_name FROM clinic_appointments a JOIN clinic_patients p ON p.id=a.patient_id JOIN clinic_appointment_types t ON t.id=a.type_id JOIN users u ON u.id=a.doctor_user_id WHERE ${all ? '1=1' : "a.status<>'cancelled' AND a.deleted_at IS NULL"} AND a.starts_at<? AND a.ends_at>? ${doctorId ? 'AND a.doctor_user_id=?' : ''} ${patientId ? 'AND a.patient_id=?' : ''} ${excludeId ? 'AND a.id<>?' : ''} ORDER BY a.starts_at`, [to, from, ...(doctorId ? [doctorId] : []), ...(patientId ? [patientId] : []), ...(excludeId ? [excludeId] : [])]);
|
||||||
|
}
|
||||||
|
async function blocksIn(db, doctorId, from, to) {
|
||||||
|
const rows = await exec(db, 'SELECT * FROM clinic_blocks WHERE doctor_user_id=? AND starts_at<? AND deleted_at IS NULL', [doctorId, to]);
|
||||||
|
return rows.flatMap(r => expand(r, from, to));
|
||||||
|
}
|
||||||
|
export async function appointments(q, staff) {
|
||||||
|
const [from, to] = range(q);
|
||||||
|
const doctorId = q.doctorId ? id(q.doctorId) : null;
|
||||||
|
if (doctorId) doctorAllowed(staff, doctorId);
|
||||||
|
const patientId = q.patientId ? id(q.patientId) : null;
|
||||||
|
if (patientId && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial');
|
||||||
|
return selectAppointments(pool, from, to, staff.scope === 'own' ? staff.user_id : doctorId, patientId, null, true);
|
||||||
|
}
|
||||||
|
export async function blocks(q, staff) {
|
||||||
|
const [from, to] = range(q);
|
||||||
|
const doctorId = q.doctorId ? id(q.doctorId) : null;
|
||||||
|
if (doctorId) doctorAllowed(staff, doctorId);
|
||||||
|
const ds = doctorId ? [doctorId] : (await doctors(staff)).map(d => d.id);
|
||||||
|
return (await Promise.all(ds.map(d => blocksIn(pool, d, from, to)))).flat();
|
||||||
|
}
|
||||||
|
export async function patients(q, staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
|
||||||
|
const search = text(q.search, 120);
|
||||||
|
const page = q.page === undefined ? 1 : Number(q.page),
|
||||||
|
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');
|
||||||
|
const like = `%${search}%`;
|
||||||
|
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, like]);
|
||||||
|
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 {
|
||||||
|
items,
|
||||||
|
total: count.n,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function patientData(raw) {
|
||||||
|
const first = text(raw.firstNames, 120, true),
|
||||||
|
last = text(raw.lastNames, 120, true),
|
||||||
|
identification = text(raw.identification, 40) || null,
|
||||||
|
phone = text(raw.phone, 40, true),
|
||||||
|
email = text(raw.email, 160) || null,
|
||||||
|
notes = text(raw.notes, 500) || null,
|
||||||
|
birth = raw.birthDate ? String(raw.birthDate) : null;
|
||||||
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new HttpError(400, 'Correo inválido');
|
||||||
|
if (birth && (!/^\d{4}-\d\d-\d\d$/.test(birth) || date(`${birth} 00:00:00`).slice(0, 10) !== birth)) throw new HttpError(400, 'Nacimiento inválido');
|
||||||
|
return [first, last, identification, phone, email, birth, notes];
|
||||||
|
}
|
||||||
|
export async function patientHistory(patientId, staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial');
|
||||||
|
const n = id(patientId);
|
||||||
|
const [patient] = await exec(pool, 'SELECT id FROM clinic_patients WHERE id=?', [n]);
|
||||||
|
if (!patient) throw new HttpError(404, 'Paciente no encontrado');
|
||||||
|
return exec(pool, 'SELECT a.id,a.starts_at,a.ends_at,a.status,a.channel,a.created_by_kind,a.created_by_name,a.deleted_at,a.deleted_by_name,a.deleted_reason,u.name AS doctor_name,t.name AS type_name FROM clinic_appointments a JOIN users u ON u.id=a.doctor_user_id JOIN clinic_appointment_types t ON t.id=a.type_id WHERE a.patient_id=? ORDER BY a.starts_at DESC LIMIT 100', [n]);
|
||||||
|
}
|
||||||
|
export async function savePatient(raw, user, staff, patientId = null) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
|
||||||
|
const data = patientData(raw);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (patientId && !result.affectedRows) throw new HttpError(409, 'Paciente modificado por otra persona');
|
||||||
|
const newId = patientId || result.insertId;
|
||||||
|
await audit(user, patientId ? 'update' : 'create', 'clinic_patient', newId);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_patients WHERE id=?', [newId]))[0];
|
||||||
|
}
|
||||||
|
function appointmentData(raw, staff) {
|
||||||
|
const doctorId = id(raw.doctorId),
|
||||||
|
patientId = id(raw.patientId),
|
||||||
|
typeId = id(raw.typeId),
|
||||||
|
start = date(raw.startsAt),
|
||||||
|
end = date(raw.endsAt);
|
||||||
|
doctorAllowed(staff, doctorId);
|
||||||
|
if (end <= start || durationMinutes(start, end) > 24 * 60) throw new HttpError(400, 'Horario inválido');
|
||||||
|
const channel = raw.channel || 'phone';
|
||||||
|
if (!['phone', 'whatsapp', 'in_person', 'other'].includes(channel)) throw new HttpError(400, 'Canal inválido');
|
||||||
|
return {
|
||||||
|
doctorId,
|
||||||
|
patientId,
|
||||||
|
typeId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
channel,
|
||||||
|
notes: text(raw.notes, 500) || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function validateSlot(db, d, excludeId) {
|
||||||
|
const [doctor] = await exec(db, 'SELECT s.user_id FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.user_id=? AND s.is_doctor=1 AND s.is_active=1 AND u.is_active=1', [d.doctorId]);
|
||||||
|
if (!doctor) throw new HttpError(400, 'Médico no disponible');
|
||||||
|
const [patient] = await exec(db, 'SELECT id FROM clinic_patients WHERE id=? AND is_active=1', [d.patientId]);
|
||||||
|
if (!patient) throw new HttpError(400, 'Paciente no disponible');
|
||||||
|
const [type] = await exec(db, 'SELECT id FROM clinic_appointment_types WHERE id=? AND is_active=1', [d.typeId]);
|
||||||
|
if (!type) throw new HttpError(400, 'Tipo de cita no disponible');
|
||||||
|
const weekday = (new Date(`${d.start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const day = d.start.slice(0, 10);
|
||||||
|
const available = await exec(db, `SELECT id FROM clinic_schedules WHERE doctor_user_id=? AND weekday=? AND CONCAT(?, ' ', starts_time)<=? AND CONCAT(?, ' ', ends_time)>=?`, [d.doctorId, weekday, day, d.start, day, d.end]);
|
||||||
|
if (!available.length || d.end.slice(0, 10) !== day) throw new HttpError(409, 'Fuera del horario de atención del médico');
|
||||||
|
const doctorClash = await selectAppointments(db, d.start, d.end, d.doctorId, null, excludeId);
|
||||||
|
const hhmm = (s) => String(s).slice(11, 16);
|
||||||
|
if (doctorClash.length) { const c = doctorClash[0]; throw new HttpError(409, `El médico ya tiene una cita de ${hhmm(c.starts_at)} a ${hhmm(c.ends_at)} (${c.first_names} ${c.last_names})`); }
|
||||||
|
const block = (await blocksIn(db, d.doctorId, d.start, d.end))[0];
|
||||||
|
if (block) throw new HttpError(409, `El médico no está disponible de ${hhmm(block.occurrence_start)} a ${hhmm(block.occurrence_end)}: ${block.title}`);
|
||||||
|
const patientClash = await selectAppointments(db, d.start, d.end, null, d.patientId, excludeId);
|
||||||
|
if (patientClash.length) { const c = patientClash[0]; throw new HttpError(409, `El paciente ya tiene una cita de ${hhmm(c.starts_at)} a ${hhmm(c.ends_at)} con ${c.doctor_name}`); }
|
||||||
|
}
|
||||||
|
export async function saveAppointment(raw, user, staff, appointmentId = null) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción agenda citas');
|
||||||
|
const d = appointmentData(raw, staff);
|
||||||
|
const db = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await db.beginTransaction();
|
||||||
|
let before = null;
|
||||||
|
if (appointmentId) {
|
||||||
|
[before] = await exec(db, 'SELECT * FROM clinic_appointments WHERE id=?', [id(appointmentId)]);
|
||||||
|
if (!before) throw new HttpError(404, 'Cita no encontrada');
|
||||||
|
if (before.deleted_at) throw new HttpError(409, 'Cita eliminada');
|
||||||
|
if (before.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
|
||||||
|
if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
} // Se bloquean filas de médicos en orden para mover entre dos agendas.
|
||||||
|
for (const doctorId of [...new Set([d.doctorId, ...(before ? [before.doctor_user_id] : [])])].sort((a, b) => a - b)) await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? FOR UPDATE', [doctorId]);
|
||||||
|
for (const patientId of [...new Set([d.patientId, ...(before ? [before.patient_id] : [])])].sort((a, b) => a - b)) await exec(db, 'SELECT id FROM clinic_patients WHERE id=? FOR UPDATE', [patientId]);
|
||||||
|
if (before) {
|
||||||
|
const [fresh] = await exec(db, 'SELECT edit_version FROM clinic_appointments WHERE id=?', [before.id]);
|
||||||
|
if (Number(fresh.edit_version) !== Number(before.edit_version)) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
}
|
||||||
|
await validateSlot(db, d, before?.id);
|
||||||
|
let result;
|
||||||
|
if (before) result = await exec(db, 'UPDATE clinic_appointments SET doctor_user_id=?,patient_id=?,type_id=?,starts_at=?,ends_at=?,channel=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, before.id, version(raw.editVersion)]);else result = await exec(db, "INSERT INTO clinic_appointments (doctor_user_id,patient_id,type_id,starts_at,ends_at,channel,notes,created_by,created_by_kind,created_by_name) VALUES (?,?,?,?,?,?,?,?,'user',?)", [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, user.id, user.name]);
|
||||||
|
if (before && !result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
const newId = before?.id || result.insertId;
|
||||||
|
await audit(user, before ? 'update' : 'create', 'clinic_appointment', newId, db);
|
||||||
|
await db.commit();
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [newId]))[0];
|
||||||
|
} catch (e) {
|
||||||
|
await db.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function changeStatus(appointmentId, raw, user, staff, cancel = false) {
|
||||||
|
const n = id(appointmentId);
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]);
|
||||||
|
if (!row) throw new HttpError(404, 'Cita no encontrada');
|
||||||
|
doctorAllowed(staff, row.doctor_user_id);
|
||||||
|
const status = cancel ? 'cancelled' : raw.status;
|
||||||
|
if (cancel && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción cancela citas');
|
||||||
|
if (!cancel && !(staff.scope === 'all' ? ['scheduled', 'confirmed', 'attended', 'no_show'].includes(status) : ['attended', 'no_show'].includes(status))) throw new HttpError(403, 'Estado no permitido');
|
||||||
|
if (row.deleted_at) throw new HttpError(409, 'Cita eliminada');
|
||||||
|
if (row.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
|
||||||
|
const editVersion = version(raw.editVersion);
|
||||||
|
if (!cancel && row.status === status && Number(row.edit_version) === editVersion) return row;
|
||||||
|
const reason = cancel ? text(raw.reason, 500, true) : null;
|
||||||
|
const result = await exec(pool, 'UPDATE clinic_appointments SET status=?,cancelled_at=?,cancelled_by=?,cancelled_by_name=?,cancelled_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [status, cancel ? guayaquilNow() : null, cancel ? user.id : null, cancel ? user.name : null, reason, n, editVersion]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
await logAudit({ userId: user.id, action: cancel ? 'cancel' : 'status', entityType: 'clinic_appointment', entityId: n, detail: cancel ? { reason } : { from: row.status, to: status } });
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0];
|
||||||
|
}
|
||||||
|
// Borrado logico: la cita deja de ocupar horario y de verse por defecto, pero queda con quien la
|
||||||
|
// elimino, cuando y por que (y su autor original intacto).
|
||||||
|
export async function deleteAppointment(appointmentId, raw, user, staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción elimina citas');
|
||||||
|
const n = id(appointmentId);
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]);
|
||||||
|
if (!row) throw new HttpError(404, 'Cita no encontrada');
|
||||||
|
if (row.deleted_at) throw new HttpError(409, 'Cita ya eliminada');
|
||||||
|
const reason = text(raw.reason, 500, true);
|
||||||
|
const result = await exec(pool, 'UPDATE clinic_appointments SET deleted_at=?,deleted_by=?,deleted_by_name=?,deleted_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=? AND deleted_at IS NULL', [guayaquilNow(), user.id, user.name, reason, n, version(raw.editVersion)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
await logAudit({ userId: user.id, action: 'delete', entityType: 'clinic_appointment', entityId: n, detail: { reason } });
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0];
|
||||||
|
}
|
||||||
|
export async function availability(q, staff) {
|
||||||
|
const doctorId = id(q.doctorId),
|
||||||
|
day = String(q.date || '');
|
||||||
|
doctorAllowed(staff, doctorId);
|
||||||
|
const [doctor] = await exec(pool, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 AND is_active=1', [doctorId]);
|
||||||
|
if (!doctor) throw new HttpError(404, 'Médico no encontrado');
|
||||||
|
if (!/^\d{4}-\d\d-\d\d$/.test(day)) throw new HttpError(400, 'Fecha inválida');
|
||||||
|
date(`${day} 00:00:00`);
|
||||||
|
const [type] = await exec(pool, 'SELECT duration_minutes FROM clinic_appointment_types WHERE id=? AND is_active=1', [id(q.typeId)]);
|
||||||
|
if (!type) throw new HttpError(400, 'Tipo inválido');
|
||||||
|
const weekday = (new Date(`${day}T00:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const segments = await exec(pool, 'SELECT starts_time,ends_time FROM clinic_schedules WHERE doctor_user_id=? AND weekday=?', [doctorId, weekday]);
|
||||||
|
const from = `${day} 00:00:00`,
|
||||||
|
to = addMinutes(from, 1440);
|
||||||
|
const busy = await selectAppointments(pool, from, to, doctorId, null, null),
|
||||||
|
blocked = await blocksIn(pool, doctorId, from, to);
|
||||||
|
const slots = [];
|
||||||
|
for (const s of segments) {
|
||||||
|
for (let start = `${day} ${s.starts_time}`; addMinutes(start, type.duration_minutes) <= `${day} ${s.ends_time}`; start = addMinutes(start, 15)) {
|
||||||
|
const end = addMinutes(start, type.duration_minutes);
|
||||||
|
if (!busy.some(x => x.starts_at < end && x.ends_at > start) && !blocked.some(x => x.occurrence_start < end && x.occurrence_end > start)) slots.push({
|
||||||
|
startsAt: start,
|
||||||
|
endsAt: end
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
export async function saveBlock(raw, user, staff, blockId = null) {
|
||||||
|
const doctorId = id(raw.doctorId);
|
||||||
|
doctorAllowed(staff, doctorId);
|
||||||
|
const kind = raw.kind;
|
||||||
|
if (!['surgery', 'vacation', 'other'].includes(kind)) throw new HttpError(400, 'Tipo de bloqueo inválido');
|
||||||
|
const title = text(raw.title, 160, true),
|
||||||
|
start = date(raw.startsAt),
|
||||||
|
end = date(raw.endsAt);
|
||||||
|
if (end <= start || durationMinutes(start, end) > 31 * 1440) throw new HttpError(400, 'Horario inválido');
|
||||||
|
const recurrence = validateRecurrence(raw.recurrence, start);
|
||||||
|
const proposed = expand({
|
||||||
|
starts_at: start,
|
||||||
|
ends_at: end,
|
||||||
|
recurrence_json: recurrence ? JSON.stringify(recurrence) : null
|
||||||
|
}, start, '9999-12-31 23:59:59');
|
||||||
|
const db = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await db.beginTransaction();
|
||||||
|
const [doctor] = await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 AND is_active=1 FOR UPDATE', [doctorId]);
|
||||||
|
if (!doctor) throw new HttpError(400, 'Médico no disponible');
|
||||||
|
let before = null;
|
||||||
|
if (blockId) {
|
||||||
|
[before] = await exec(db, 'SELECT * FROM clinic_blocks WHERE id=? AND deleted_at IS NULL', [id(blockId)]);
|
||||||
|
if (!before || before.doctor_user_id !== doctorId) throw new HttpError(404, 'Bloqueo no encontrado');
|
||||||
|
if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Bloqueo modificado por otra persona');
|
||||||
|
}
|
||||||
|
const affected = [];
|
||||||
|
const candidates = await selectAppointments(db, proposed[0].occurrence_start, proposed.at(-1).occurrence_end, doctorId, null, null);
|
||||||
|
for (const c of candidates) if (proposed.some(p => p.occurrence_start < c.ends_at && p.occurrence_end > c.starts_at)) affected.push({
|
||||||
|
id: c.id,
|
||||||
|
starts_at: c.starts_at,
|
||||||
|
patient_name: `${c.first_names} ${c.last_names}`
|
||||||
|
});
|
||||||
|
if (affected.length && raw.confirmAffected !== true) {
|
||||||
|
await db.rollback();
|
||||||
|
return {
|
||||||
|
requiresConfirmation: true,
|
||||||
|
affected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
if (before) result = await exec(db, 'UPDATE clinic_blocks SET kind=?,title=?,starts_at=?,ends_at=?,recurrence_json=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, before.id, before.edit_version]);else result = await exec(db, 'INSERT INTO clinic_blocks (doctor_user_id,kind,title,starts_at,ends_at,recurrence_json,created_by,created_by_name) VALUES (?,?,?,?,?,?,?,?)', [doctorId, kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, user.id, user.name]);
|
||||||
|
if (before && !result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
|
||||||
|
const newId = before?.id || result.insertId;
|
||||||
|
await audit(user, before ? 'update' : 'create', 'clinic_block', newId, db);
|
||||||
|
await db.commit();
|
||||||
|
return {
|
||||||
|
block: (await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [newId]))[0],
|
||||||
|
affected
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
await db.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function deleteBlock(blockId, raw, user, staff) {
|
||||||
|
const n = id(blockId);
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=? AND deleted_at IS NULL', [n]);
|
||||||
|
if (!row) throw new HttpError(404, 'Bloqueo no encontrado');
|
||||||
|
doctorAllowed(staff, row.doctor_user_id);
|
||||||
|
// Borrado logico: deja de bloquear horario pero la fila queda para trazabilidad.
|
||||||
|
const result = await exec(pool, 'UPDATE clinic_blocks SET deleted_at=?,deleted_by=?,deleted_by_name=?,edit_version=edit_version+1 WHERE id=? AND edit_version=? AND deleted_at IS NULL', [guayaquilNow(), user.id, user.name, n, version(raw.editVersion)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
|
||||||
|
await audit(user, 'delete', 'clinic_block', n);
|
||||||
|
return {
|
||||||
|
id: n
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function configStaff(user) {
|
||||||
|
requireConfig(user);
|
||||||
|
return exec(pool, 'SELECT s.*,u.name,u.email FROM clinic_staff s JOIN users u ON u.id=s.user_id ORDER BY u.name');
|
||||||
|
}
|
||||||
|
export async function saveStaff(raw, user, staffId) {
|
||||||
|
requireConfig(user);
|
||||||
|
const n = id(staffId),
|
||||||
|
scope = raw.scope;
|
||||||
|
if (!['all', 'own'].includes(scope)) throw new HttpError(400, 'Alcance inválido');
|
||||||
|
const doctor = !!raw.isDoctor;
|
||||||
|
if (scope === 'own' && !doctor) throw new HttpError(400, 'El alcance propio exige médico');
|
||||||
|
const specialty = text(raw.specialty, 120) || null,
|
||||||
|
color = raw.color || null;
|
||||||
|
if (color && !/^#[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(400, 'Color inválido');
|
||||||
|
const [target] = await exec(pool, 'SELECT id FROM users WHERE id=? AND is_active=1', [n]);
|
||||||
|
if (!target) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
|
if (raw.editVersion) {
|
||||||
|
const result = await exec(pool, 'UPDATE clinic_staff SET scope=?,is_doctor=?,specialty=?,color=?,is_active=?,edit_version=edit_version+1 WHERE user_id=? AND edit_version=?', [scope, doctor, specialty, color, raw.isActive === false ? 0 : 1, n, version(raw.editVersion)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Personal modificado por otra persona');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await exec(pool, 'INSERT INTO clinic_staff (user_id,scope,is_doctor,specialty,color,is_active) VALUES (?,?,?,?,?,?)', [n, scope, doctor, specialty, color, raw.isActive === false ? 0 : 1]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Personal creado por otra persona');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await audit(user, 'update', 'clinic_staff', n);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_staff WHERE user_id=?', [n]))[0];
|
||||||
|
}
|
||||||
|
export async function saveSchedules(doctorId, raw, user) {
|
||||||
|
requireConfig(user);
|
||||||
|
const n = id(doctorId),
|
||||||
|
rows = raw.segments;
|
||||||
|
if (!Array.isArray(rows) || rows.length > 30) throw new HttpError(400, 'Horario inválido');
|
||||||
|
const parsed = rows.map(s => {
|
||||||
|
const weekday = Number(s.weekday),
|
||||||
|
start = String(s.startsTime || ''),
|
||||||
|
end = String(s.endsTime || '');
|
||||||
|
if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6 || !/^\d\d:\d\d(?::\d\d)?$/.test(start) || !/^\d\d:\d\d(?::\d\d)?$/.test(end) || start >= end || start.slice(0, 5) > '23:59' || end.slice(0, 5) > '23:59') throw new HttpError(400, 'Tramo inválido');
|
||||||
|
return [weekday, start, end];
|
||||||
|
});
|
||||||
|
for (let i = 0; i < parsed.length; i++) for (let j = i + 1; j < parsed.length; j++) if (parsed[i][0] === parsed[j][0] && parsed[i][1] < parsed[j][2] && parsed[i][2] > parsed[j][1]) throw new HttpError(400, 'Tramos superpuestos');
|
||||||
|
const db = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await db.beginTransaction();
|
||||||
|
const [doctor] = await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 FOR UPDATE', [n]);
|
||||||
|
if (!doctor) throw new HttpError(404, 'Médico no encontrado');
|
||||||
|
await exec(db, 'DELETE FROM clinic_schedules WHERE doctor_user_id=?', [n]);
|
||||||
|
for (const r of parsed) await exec(db, 'INSERT INTO clinic_schedules (doctor_user_id,weekday,starts_time,ends_time) VALUES (?,?,?,?)', [n, ...r]);
|
||||||
|
await audit(user, 'update', 'clinic_schedule', n, db);
|
||||||
|
await db.commit();
|
||||||
|
return schedules(n);
|
||||||
|
} catch (e) {
|
||||||
|
await db.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function saveType(raw, user, typeId = null) {
|
||||||
|
requireConfig(user);
|
||||||
|
const name = text(raw.name, 120, true),
|
||||||
|
duration = Number(raw.durationMinutes),
|
||||||
|
color = raw.color;
|
||||||
|
if (!Number.isInteger(duration) || duration < 5 || duration > 480 || !/^#[0-9a-fA-F]{6}$/.test(color || '')) throw new HttpError(400, 'Tipo inválido');
|
||||||
|
let result;
|
||||||
|
if (typeId) result = await exec(pool, 'UPDATE clinic_appointment_types SET name=?,duration_minutes=?,color=?,is_active=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [name, duration, color, raw.isActive === false ? 0 : 1, id(typeId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_appointment_types (name,duration_minutes,color,is_active) VALUES (?,?,?,?)', [name, duration, color, raw.isActive === false ? 0 : 1]);
|
||||||
|
if (typeId && !result.affectedRows) throw new HttpError(409, 'Tipo modificado por otra persona');
|
||||||
|
const n = typeId || result.insertId;
|
||||||
|
await audit(user, typeId ? 'update' : 'create', 'clinic_appointment_type', n);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointment_types WHERE id=?', [n]))[0];
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const putDevice = asyncHandler(async (req, res) => ok(res, await service.
|
|||||||
export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard()));
|
export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard()));
|
||||||
export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all)));
|
export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all)));
|
||||||
export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || ''))));
|
export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || ''))));
|
||||||
|
export const putSoftwareClassification = asyncHandler(async (req, res) => ok(res, await service.setSoftwareClassification(req.body.name, req.body.licenseType, req.currentUser)));
|
||||||
|
|
||||||
export const getRaw = asyncHandler(async (req, res) => {
|
export const getRaw = asyncHandler(async (req, res) => {
|
||||||
const { full, file } = await service.getRaw(Number(req.params.id));
|
const { full, file } = await service.getRaw(Number(req.params.id));
|
||||||
@@ -23,3 +24,5 @@ export const getAccess = asyncHandler(async (req, res) => ok(res, await service.
|
|||||||
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
|
export const getViewers = asyncHandler(async (req, res) => ok(res, await service.listViewers()));
|
||||||
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
|
export const postViewer = asyncHandler(async (req, res) => ok(res, await service.addViewer(Number(req.body.userId), req.currentUser), 201));
|
||||||
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
|
export const deleteViewer = asyncHandler(async (req, res) => ok(res, await service.removeViewer(Number(req.params.userId), req.currentUser)));
|
||||||
|
|
||||||
|
export const postCpuYear = asyncHandler(async (req, res) => ok(res, await service.fetchCpuYear(Number(req.params.id), req.currentUser)));
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function saveRaw(deviceid, text, format) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseInWorker(text, format) {
|
export function parseInWorker(text, format) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
const worker = new Worker(new URL('./parseWorker.js', import.meta.url), {
|
||||||
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
workerData: { text, format }, resourceLimits: { maxOldGenerationSizeMb: 256 },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
|
import { isWindowsBundled } from './softwareClassifier.js';
|
||||||
|
|
||||||
// Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo
|
// Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo
|
||||||
// `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que
|
// `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que
|
||||||
@@ -7,7 +8,7 @@ import { XMLParser } from 'fast-xml-parser';
|
|||||||
|
|
||||||
const XML_ARRAY_SECTIONS = new Set([
|
const XML_ARRAY_SECTIONS = new Set([
|
||||||
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
|
'cpus', 'memories', 'storages', 'drives', 'networks', 'monitors', 'softwares', 'printers',
|
||||||
'users', 'local_users', 'local_groups', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
|
'users', 'local_users', 'local_groups', 'licenseinfos', 'videos', 'sounds', 'batteries', 'controllers', 'ports', 'slots', 'antivirus',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
|
const xmlParser = new XMLParser({ ignoreAttributes: true, parseTagValue: false, trimValues: true });
|
||||||
@@ -74,6 +75,38 @@ function isComponent(name, category) {
|
|||||||
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
return COMPONENT_CATEGORIES.has((category || '').toLowerCase()) || COMPONENT_NAME.test(name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Licenciamiento de Windows y Office. La clave de producto NUNCA se guarda completa en la BD: solo
|
||||||
|
// el ultimo bloque (la completa sigue solo en el reporte crudo, de descarga restringida).
|
||||||
|
const maskKey = (v) => {
|
||||||
|
const key = str(v, 64);
|
||||||
|
if (!key) return null;
|
||||||
|
return key.replace(/[A-Z0-9]/gi, '*').slice(0, -5) + key.slice(-5);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractLicenses(c) {
|
||||||
|
const hw = c.hardware || {};
|
||||||
|
const os = c.operatingsystem || {};
|
||||||
|
const winPid = str(hw.winprodid, 64);
|
||||||
|
const isWindows = /windows/i.test(`${os.name || ''} ${os.full_name || ''} ${hw.osname || ''}`);
|
||||||
|
const windows = isWindows ? {
|
||||||
|
edition: str(os.full_name ?? hw.osname),
|
||||||
|
version: str(os.version ?? hw.osversion, 120),
|
||||||
|
product_id: winPid,
|
||||||
|
key: maskKey(hw.winprodkey),
|
||||||
|
oem: winPid ? /OEM/i.test(winPid) : null,
|
||||||
|
owner: str(hw.winowner, 190),
|
||||||
|
company: str(hw.wincompany, 190),
|
||||||
|
} : null;
|
||||||
|
const office = asArray(c.licenseinfos)
|
||||||
|
.filter((x) => /office/i.test(`${x.name || ''} ${x.fullname || ''}`))
|
||||||
|
.map((x) => ({
|
||||||
|
name: str(x.name), full_name: str(x.fullname), product_id: str(x.productid, 64), key: maskKey(x.key),
|
||||||
|
oem: x.oem == null || x.oem === '' ? null : String(x.oem) === '1',
|
||||||
|
msdn: /msdn/i.test(`${x.name || ''} ${x.fullname || ''}`),
|
||||||
|
}));
|
||||||
|
return { windows, office };
|
||||||
|
}
|
||||||
|
|
||||||
const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i;
|
const LAPTOP_CHASSIS = /laptop|notebook|portable|hand held|sub notebook|convertible|tablet|detachable|netbook/i;
|
||||||
const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i;
|
const SERVER_CHASSIS = /server|rack|blade|main server|multi-system/i;
|
||||||
|
|
||||||
@@ -137,9 +170,10 @@ export function normalizeInventory(parsed) {
|
|||||||
.map((x) => {
|
.map((x) => {
|
||||||
const name = str(x.name);
|
const name = str(x.name);
|
||||||
const category = str(x.system_category, 40);
|
const category = str(x.system_category, 40);
|
||||||
|
const publisher = str(x.publisher, 190);
|
||||||
return {
|
return {
|
||||||
name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40),
|
name, version: str(x.version, 120), publisher, install_date: str(x.installdate ?? x.install_date, 40),
|
||||||
category, is_component: isComponent(name, category) ? 1 : 0,
|
category, is_component: isComponent(name, category) || isWindowsBundled(name, publisher, category) ? 1 : 0,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((s) => s.name);
|
.filter((s) => s.name);
|
||||||
@@ -197,6 +231,7 @@ export function normalizeInventory(parsed) {
|
|||||||
for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) {
|
for (const key of ['printers', 'network_device', 'antivirus', 'videos', 'batteries', 'sounds']) {
|
||||||
if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key];
|
if (c[key] && (!Array.isArray(c[key]) || c[key].length)) extra[key] = c[key];
|
||||||
}
|
}
|
||||||
|
extra.licenses = extractLicenses(c);
|
||||||
|
|
||||||
return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra };
|
return { device, cpus, memories, disks, volumes, networks, monitors, software, accounts, extra };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ export async function findById(id) {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
detail.changes = changes;
|
detail.changes = changes;
|
||||||
|
// El año del procesador solo vale mientras el equipo siga reportando el mismo procesador.
|
||||||
|
if (detail.cpu_year_for !== detail.cpu_summary) detail.cpu_year = null;
|
||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +227,10 @@ export async function updateManual(id, { status, assignedUserId, departmentId, n
|
|||||||
return result.affectedRows > 0;
|
return result.affectedRows > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const saveCpuYear = (id, year, cpuSummary) => pool.query('UPDATE devices SET cpu_year = ?, cpu_year_for = ? WHERE id = ?', [year, cpuSummary, id]);
|
||||||
|
|
||||||
|
export const saveExtra = (id, extra) => pool.query('UPDATE devices SET extra_json = ? WHERE id = ?', [JSON.stringify(extra), id]);
|
||||||
|
|
||||||
export async function findRawFile(id) {
|
export async function findRawFile(id) {
|
||||||
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
|
const [[row]] = await pool.query('SELECT raw_file FROM devices WHERE id = ?', [id]);
|
||||||
return row?.raw_file || null;
|
return row?.raw_file || null;
|
||||||
@@ -238,8 +244,8 @@ export async function listSoftware({ search, all }) {
|
|||||||
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
|
if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); }
|
||||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count,
|
`SELECT s.name, MAX(s.publisher) AS publisher, MAX(s.category) AS category, MAX(s.is_component) AS is_component,
|
||||||
COUNT(DISTINCT s.version) AS version_count
|
COUNT(DISTINCT s.device_id) AS device_count, COUNT(DISTINCT s.version) AS version_count
|
||||||
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
|
FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active'
|
||||||
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
|
${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`,
|
||||||
params
|
params
|
||||||
@@ -247,6 +253,19 @@ export async function listSoftware({ search, all }) {
|
|||||||
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
|
return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function softwareOverrides() {
|
||||||
|
const [rows] = await pool.query('SELECT name, license_type FROM software_classifications');
|
||||||
|
return new Map(rows.map((r) => [r.name.toLowerCase(), r.license_type]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveSoftwareClassification = (name, type, userId) => pool.query(
|
||||||
|
`INSERT INTO software_classifications (name, license_type, updated_by) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE license_type = VALUES(license_type), updated_by = VALUES(updated_by)`,
|
||||||
|
[name, type, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const removeSoftwareClassification = (name) => pool.query('DELETE FROM software_classifications WHERE name = ?', [name]);
|
||||||
|
|
||||||
export async function listDevicesWithSoftware(name) {
|
export async function listDevicesWithSoftware(name) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
|
`SELECT d.id, d.hostname, d.kind, d.last_user, s.version
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
import { requireAuth, requireRole, CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
import { requireModule } from '../../middleware/modules.js';
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
import { fail } from '../../utils/http.js';
|
import { fail } from '../../utils/http.js';
|
||||||
import { getAccess as getAccessFor } from './service.js';
|
import { getAccess as getAccessFor } from './service.js';
|
||||||
import {
|
import {
|
||||||
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw,
|
getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, putSoftwareClassification, getRaw,
|
||||||
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer,
|
getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// Cada consulta a la IA cuesta: tope por IP para no gastar saldo por error o abuso.
|
||||||
|
const aiLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 30, standardHeaders: true, legacyHeaders: false });
|
||||||
|
|
||||||
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
// Inventario: lo ve y lo edita quien gestiona TI. Los tokens del agente y el compartir, solo
|
||||||
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
// admin/superadmin. Otros usuarios pueden tener acceso de SOLO LECTURA compartido (device_viewers).
|
||||||
|
|
||||||
@@ -27,6 +31,7 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para
|
|||||||
router.get('/dashboard', canView, getDashboard);
|
router.get('/dashboard', canView, getDashboard);
|
||||||
router.get('/software', canView, getSoftware);
|
router.get('/software', canView, getSoftware);
|
||||||
router.get('/software/devices', canView, getSoftwareDevices);
|
router.get('/software/devices', canView, getSoftwareDevices);
|
||||||
|
router.put('/software/classification', requireRole(...CAN_EDIT_DEVICES), putSoftwareClassification);
|
||||||
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers);
|
||||||
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer);
|
||||||
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer);
|
||||||
@@ -38,6 +43,7 @@ router.delete('/tokens/:id', requireRole(...CAN_MANAGE_DEVICES), deleteToken);
|
|||||||
router.get('/', canView, getDevices);
|
router.get('/', canView, getDevices);
|
||||||
router.get('/:id', canView, getDevice);
|
router.get('/:id', canView, getDevice);
|
||||||
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
|
router.get('/:id/raw', requireRole(...CAN_EDIT_DEVICES), getRaw);
|
||||||
|
router.post('/:id/cpu-year', requireRole(...CAN_EDIT_DEVICES), aiLimiter, postCpuYear);
|
||||||
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
|
router.put('/:id', requireRole(...CAN_EDIT_DEVICES), putDevice);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { HttpError } from '../../utils/http.js';
|
|||||||
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
import { encrypt, decrypt } from '../../utils/crypto.js';
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
|
import { askStructured } from '../../utils/ai.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import { parseInWorker } from './ingest.js';
|
||||||
|
import { classifySoftware, LICENSE_TYPES } from './softwareClassifier.js';
|
||||||
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js';
|
||||||
|
|
||||||
const STATUSES = ['active', 'retired'];
|
const STATUSES = ['active', 'retired'];
|
||||||
@@ -23,9 +26,26 @@ export const listDevices = (filters) => repo.findAll({
|
|||||||
export async function getDevice(id) {
|
export async function getDevice(id) {
|
||||||
const device = await repo.findById(id);
|
const device = await repo.findById(id);
|
||||||
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
if (!device) throw new HttpError(404, 'Equipo no encontrado');
|
||||||
|
if (device.extra?.licenses === undefined && device.raw_file) await backfillLicenses(device);
|
||||||
|
const overrides = await repo.softwareOverrides();
|
||||||
|
device.software = device.software.map((s) => ({ ...s, ...classifySoftware(s, overrides) }));
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Equipos inventariados antes de la pestaña Licenciamiento: se sacan del ultimo reporte crudo una
|
||||||
|
// sola vez (si falla queda vacio y se completa con el proximo reporte del agente).
|
||||||
|
async function backfillLicenses(device) {
|
||||||
|
let licenses = { windows: null, office: [] };
|
||||||
|
try {
|
||||||
|
const full = path.join(env.storageRoot, 'inventories', path.basename(device.raw_file));
|
||||||
|
const text = await fs.promises.readFile(full, 'utf8');
|
||||||
|
const parsed = await parseInWorker(text, path.extname(full) === '.json' ? 'json' : 'xml');
|
||||||
|
licenses = parsed.normalized?.extra?.licenses || licenses;
|
||||||
|
} catch { /* sin crudo legible: queda vacio */ }
|
||||||
|
device.extra = { ...(device.extra || {}), licenses };
|
||||||
|
await repo.saveExtra(device.id, device.extra);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateDevice(id, data, actor) {
|
export async function updateDevice(id, data, actor) {
|
||||||
const current = await getDevice(id);
|
const current = await getDevice(id);
|
||||||
const status = data.status ?? current.status;
|
const status = data.status ?? current.status;
|
||||||
@@ -42,6 +62,30 @@ export async function updateDevice(id, data, actor) {
|
|||||||
return getDevice(id);
|
return getDevice(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Año de lanzamiento del procesador, consultado a la IA SOLO con el nombre del procesador (nada
|
||||||
|
// del equipo, usuario ni red). Si la IA da más de una fecha se pide la más antigua. La IA solo
|
||||||
|
// sugiere: se valida el rango antes de guardar.
|
||||||
|
export async function fetchCpuYear(id, actor) {
|
||||||
|
const device = await getDevice(id);
|
||||||
|
const cpuName = device.cpus.find((c) => c.name)?.name;
|
||||||
|
if (!cpuName) throw new HttpError(400, 'Este equipo no reporta procesador');
|
||||||
|
const result = await askStructured({
|
||||||
|
system: 'Eres un experto en hardware de computadores. Te dan SOLO el nombre de un procesador. Indica el AÑO en que se lanzó. Si hay más de una fecha asociada (por ejemplo anuncio y disponibilidad), devuelve el año MÁS ANTIGUO. Si no puedes determinarlo con seguridad, devuelve null: no inventes.',
|
||||||
|
prompt: `Procesador: ${String(cpuName).trim().slice(0, 200)}`,
|
||||||
|
toolName: 'cpu_release_year',
|
||||||
|
schema: { type: 'object', properties: { year: { type: ['integer', 'null'], description: 'Año de lanzamiento (4 dígitos) o null' } }, required: ['year'] },
|
||||||
|
maxTokens: 200,
|
||||||
|
usage: { userId: actor.id, action: 'device_cpu_year' },
|
||||||
|
});
|
||||||
|
const year = Number(result?.year);
|
||||||
|
if (!Number.isInteger(year) || year < 1990 || year > new Date().getFullYear() + 1) {
|
||||||
|
throw new HttpError(422, 'No se pudo determinar el año de este procesador');
|
||||||
|
}
|
||||||
|
await repo.saveCpuYear(id, year, device.cpu_summary);
|
||||||
|
await logAudit({ userId: actor.id, action: 'cpu_year', entityType: 'device', entityId: id, detail: { cpu: cpuName, year } });
|
||||||
|
return getDevice(id);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getRaw(id) {
|
export async function getRaw(id) {
|
||||||
const file = await repo.findRawFile(id);
|
const file = await repo.findRawFile(id);
|
||||||
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
|
if (!file) throw new HttpError(404, 'Sin inventario crudo para este equipo');
|
||||||
@@ -50,7 +94,23 @@ export async function getRaw(id) {
|
|||||||
return { full, file };
|
return { full, file };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all });
|
export async function listSoftware(q, all) {
|
||||||
|
const [rows, overrides] = await Promise.all([repo.listSoftware({ search: q?.trim() || null, all: !!all }), repo.softwareOverrides()]);
|
||||||
|
const classified = rows.map((r) => ({ ...r, ...classifySoftware(r, overrides) }));
|
||||||
|
return all ? classified : classified.filter((r) => !r.is_component);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Correccion manual del tipo de licencia de un programa; `licenseType` vacio vuelve a las reglas.
|
||||||
|
export async function setSoftwareClassification(name, licenseType, actor) {
|
||||||
|
const program = String(name || '').trim();
|
||||||
|
if (!program || program.length > 255) throw new HttpError(400, 'Programa invalido');
|
||||||
|
if (licenseType && !LICENSE_TYPES.includes(licenseType)) throw new HttpError(400, 'Tipo de licencia invalido');
|
||||||
|
if (licenseType) await repo.saveSoftwareClassification(program, licenseType, actor.id);
|
||||||
|
else await repo.removeSoftwareClassification(program);
|
||||||
|
await logAudit({ userId: actor.id, action: 'classify_software', entityType: 'software', entityId: null, detail: { name: program, licenseType: licenseType || null } });
|
||||||
|
return { name: program, licenseType: licenseType || null };
|
||||||
|
}
|
||||||
|
|
||||||
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name);
|
||||||
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
export const getDashboard = () => repo.dashboard(STALE_DAYS);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Clasificador de software por reglas, sin IA ni consultas externas. Solo mira nombre, fabricante y
|
||||||
|
// categoria que reporta el agente. Es orientativo: lo que no esta en las listas queda sin clasificar
|
||||||
|
// (null) y un editor lo corrige a mano (software_classifications), nunca se adivina.
|
||||||
|
|
||||||
|
export const LICENSE_TYPES = ['free', 'paid', 'personal_free', 'component'];
|
||||||
|
|
||||||
|
// Fabricante normalizado: variantes de publisher y programas que solo lo nombran en el titulo.
|
||||||
|
const VENDORS = [
|
||||||
|
['Epson', /\bepson\b/i], ['Microsoft', /\bmicrosoft\b/i], ['Adobe', /\badobe\b/i], ['Google', /\bgoogle\b/i],
|
||||||
|
['HP', /\b(hp|hewlett[- ]packard)\b/i], ['Canon', /\bcanon\b/i], ['Brother', /\bbrother\b/i],
|
||||||
|
['Intel', /\bintel\b/i], ['Realtek', /\brealtek\b/i], ['NVIDIA', /\bnvidia\b/i], ['AMD', /\b(amd|advanced micro devices)\b/i],
|
||||||
|
['Lenovo', /\blenovo\b/i], ['ASUS', /\basus(tek)?\b/i], ['Samsung', /\bsamsung\b/i], ['Conexant', /\bconexant\b/i],
|
||||||
|
['OpenVPN', /\bopenvpn\b/i], ['Notepad++', /\bnotepad\+\+/i], ['Dell', /\bdell\b/i], ['Logitech', /\blogitech\b/i], ['Autodesk', /\bautodesk\b/i],
|
||||||
|
['Mozilla', /\bmozilla\b/i], ['Oracle', /\boracle\b/i],
|
||||||
|
];
|
||||||
|
// Traen sus programas con el hardware: lo que no sea un producto de licencia va como incluido.
|
||||||
|
const HARDWARE_VENDORS = new Set(['Epson', 'HP', 'Canon', 'Brother', 'Intel', 'Realtek', 'NVIDIA', 'AMD', 'Lenovo', 'Dell', 'Logitech', 'ASUS', 'Samsung', 'Conexant']);
|
||||||
|
|
||||||
|
const LEGAL_SUFFIX = /[,.\s]+(corporation|corp|incorporated|inc|ltd|limited|llc|gmbh|s\.?a\.?|co)\.?$/i;
|
||||||
|
|
||||||
|
export function vendorOf(name, publisher) {
|
||||||
|
for (const [vendor, pattern] of VENDORS) if (pattern.test(publisher || '')) return vendor;
|
||||||
|
for (const [vendor, pattern] of VENDORS) if (pattern.test(name || '')) return vendor;
|
||||||
|
// Los paquetes de la tienda reportan el firmante ("CN=..."): no sirve como nombre visible.
|
||||||
|
if (!publisher || /^cn=/i.test(publisher)) return null;
|
||||||
|
return publisher.replace(LEGAL_SUFFIX, '').replace(LEGAL_SUFFIX, '').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Productos Microsoft con licencia propia: nunca se esconden dentro del grupo.
|
||||||
|
const MICROSOFT_LICENSED = new RegExp([
|
||||||
|
'^(microsoft )?windows (10|11|server)\\b(?!.*(asistente|assistant))',
|
||||||
|
'^microsoft 365\\b(?!.*local ai)', '^microsoft office (profesional|professional|hogar|home|est[aá]ndar|standard|ltsc|\\d{4})',
|
||||||
|
'^microsoft (visio|project)\\b', '^(microsoft )?visual studio (community|professional|enterprise|\\d{4})',
|
||||||
|
'^microsoft sql server \\d{4}', '^microsoft dynamics', '^microsoft exchange server',
|
||||||
|
].join('|'), 'i');
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
// Antes que las demas: nombres que tambien entrarian como pago/gratuito.
|
||||||
|
['personal_free', /teamviewer|anydesk|winrar|\bwinzip\b|ccleaner|malwarebytes|avast free|avg (anti|free)|macrium reflect free|docker desktop|^java( \d+)?( update| se)|^java\(tm\)|sketchup free/i],
|
||||||
|
['free', /acrobat reader|adobe reader|sql server.*express|visual studio code|^microsoft edge\b/i],
|
||||||
|
['paid', new RegExp([
|
||||||
|
'acrobat', 'photoshop', 'illustrator', 'indesign', 'premiere pro', 'after effects', 'lightroom',
|
||||||
|
'autocad', 'revit', 'sketchup', 'coreldraw', 'solidworks', 'matlab', 'abbyy finereader', 'nitro pro',
|
||||||
|
'foxit (pdf )?editor', 'realvnc server', 'kaspersky', '\\beset\\b', 'bitdefender', 'norton', 'mcafee',
|
||||||
|
].join('|'), 'i')],
|
||||||
|
['free', new RegExp([
|
||||||
|
'^7-zip', '^vlc\\b', '^google chrome', 'mozilla (firefox|thunderbird)', '^notepad\\+\\+', 'libreoffice', 'openoffice',
|
||||||
|
'^putty\\b', '^winscp', '^filezilla', '^git\\b', '^node\\.js', '^python \\d', '^gimp', '^inkscape', '^paint\\.net',
|
||||||
|
'^pdfcreator', '^audacity', '^keepass', 'openvpn', '^tap-windows', '^xampp', '^mysql (workbench|connector)', '^firmaec', '^nextcloud',
|
||||||
|
'^rustdesk', '^freefilesync', '^glpi agent', '^android studio', '^composer\\b', '^python launcher', '^nmap\\b',
|
||||||
|
].join('|'), 'i')],
|
||||||
|
];
|
||||||
|
|
||||||
|
function ruleType(name) {
|
||||||
|
for (const [type, pattern] of RULES) if (pattern.test(name)) return type;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps de la tienda y paquetes que vienen con Windows: mismo trato que actualizaciones y drivers.
|
||||||
|
const WINDOWS_BUNDLED = /desktop runtime|winappruntime|ink\.handwriting|windows web experience|windows package manager|experiencia local|language experience pack|widgets platform/i;
|
||||||
|
const REAL_STORE_APP = /whiteboard|teams|to do|onenote|outlook|powerpoint|\bexcel\b|\bword\b|clipchamp|power bi|company portal|remote desktop|visio|project/i;
|
||||||
|
|
||||||
|
export function isWindowsBundled(name, publisher, category) {
|
||||||
|
if (WINDOWS_BUNDLED.test(name || '')) return true;
|
||||||
|
return String(category || '').toLowerCase() === 'store' && vendorOf(name, publisher) === 'Microsoft' && !REAL_STORE_APP.test(name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve { is_component, license_type, vendor, group }: `group` es el fabricante bajo el que se
|
||||||
|
// colapsa la fila, null si debe verse suelta. `overrides` es un Map(nombre en minusculas -> tipo).
|
||||||
|
export function classifySoftware(row, overrides) {
|
||||||
|
const name = row.name || '';
|
||||||
|
const vendor = vendorOf(name, row.publisher);
|
||||||
|
const isComponent = !!row.is_component || isWindowsBundled(name, row.publisher, row.category);
|
||||||
|
const licensed = vendor === 'Microsoft' && MICROSOFT_LICENSED.test(name);
|
||||||
|
let type = isComponent ? 'component' : licensed ? 'paid' : ruleType(name);
|
||||||
|
if (!type && (vendor === 'Microsoft' || HARDWARE_VENDORS.has(vendor))) type = 'component';
|
||||||
|
const manual = overrides?.get(name.toLowerCase());
|
||||||
|
return {
|
||||||
|
is_component: isComponent ? 1 : 0,
|
||||||
|
license_type: manual || type,
|
||||||
|
license_manual: !!manual,
|
||||||
|
vendor,
|
||||||
|
group: licensed ? null : vendor,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
|
import { ok, asyncHandler } from '../../utils/http.js';
|
||||||
|
import * as service from './service.js';
|
||||||
|
|
||||||
|
export const accountEmailRoutes = Router();
|
||||||
|
export const usersEmailRoutes = Router();
|
||||||
|
export const publicEmailRoutes = Router();
|
||||||
|
const limiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
|
||||||
|
accountEmailRoutes.use(requireAuth);
|
||||||
|
accountEmailRoutes.get('/', asyncHandler(async (req, res) => ok(res, await service.mine(req.currentUser.id))));
|
||||||
|
accountEmailRoutes.post('/', limiter, asyncHandler(async (req, res) => ok(res, await service.request(req.currentUser.id, req.body), 201)));
|
||||||
|
accountEmailRoutes.delete('/', asyncHandler(async (req, res) => ok(res, await service.cancel(req.currentUser.id))));
|
||||||
|
usersEmailRoutes.use(requireAuth, requireModule('organization'));
|
||||||
|
usersEmailRoutes.get('/', asyncHandler(async (req, res) => ok(res, await service.list(req.currentUser))));
|
||||||
|
usersEmailRoutes.post('/:id/approve', asyncHandler(async (req, res) => ok(res, await service.decide(Number(req.params.id), req.currentUser, 'approved'))));
|
||||||
|
usersEmailRoutes.post('/:id/reject', asyncHandler(async (req, res) => ok(res, await service.decide(Number(req.params.id), req.currentUser, 'rejected', req.body?.reason))));
|
||||||
|
publicEmailRoutes.post('/self-approve', limiter, asyncHandler(async (req, res) => ok(res, await service.selfApprove(req.body?.token))));
|
||||||
|
publicEmailRoutes.post('/confirm', limiter, asyncHandler(async (req, res) => ok(res, await service.confirm(req.body?.token))));
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import { env } from '../../config/env.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
|
import { emailChangeLinkEmail, emailChangeNoticeEmail } from '../../utils/emailHtml.js';
|
||||||
|
|
||||||
|
const hash = (token) => crypto.createHash('sha256').update(token).digest('hex');
|
||||||
|
const token = () => crypto.randomBytes(32).toString('hex');
|
||||||
|
const detail = (r, extra = {}) => ({ request_id: r.id, old_email: r.old_email, new_email: r.new_email, ...extra });
|
||||||
|
const publicRow = (r) => r && ({ id: r.id, userId: r.user_id, name: r.name, oldEmail: r.old_email, newEmail: r.new_email, status: r.status, rejectReason: r.reject_reason, createdAt: r.created_at });
|
||||||
|
const validToken = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
||||||
|
|
||||||
|
async function audit(connection, actor, action, r, extra) {
|
||||||
|
await connection.query('INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[actor, action, 'email_change_request', r.id, JSON.stringify(detail(r, extra))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expire() {
|
||||||
|
const [rows] = await pool.query(`SELECT * FROM email_change_requests WHERE (status = 'pending' AND
|
||||||
|
(created_at < DATE_SUB(NOW(), INTERVAL 7 DAY) OR (requires_self_approval = 1 AND approve_token_expires <= NOW())))
|
||||||
|
OR (status = 'approved' AND (created_at < DATE_SUB(NOW(), INTERVAL 7 DAY) OR confirm_token_expires <= NOW()))`);
|
||||||
|
for (const r of rows) {
|
||||||
|
const [result] = await pool.query(`UPDATE email_change_requests SET status = 'expired' WHERE id = ? AND status = ?`, [r.id, r.status]);
|
||||||
|
if (result.affectedRows) await logAudit({ userId: r.user_id, action: 'email_change_expired', entityType: 'email_change_request', entityId: r.id, detail: detail(r) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se llama despues del commit: un fallo aqui (correo o auditoria) nunca debe tumbar la respuesta.
|
||||||
|
async function mail(r, to, kind, url, reason = '') {
|
||||||
|
try {
|
||||||
|
const html = url ? emailChangeLinkEmail(r.name, url, kind) : emailChangeNoticeEmail(r.name, r.new_email, kind, reason);
|
||||||
|
const result = await sendMail({ to, subject: kind === 'rejected' ? 'Solicitud de cambio de correo rechazada' : 'Cambio de correo — project.linkedcorp.net', html });
|
||||||
|
await logAudit({ userId: r.user_id, action: 'email_change_mail_sent', entityType: 'email_change_request', entityId: r.id,
|
||||||
|
detail: detail(r, { destination: to === r.old_email ? 'actual' : 'nuevo', purpose: kind, delivered: result.sent, dev: result.dev }) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[email-change] correo:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request(userId, payload) {
|
||||||
|
await expire();
|
||||||
|
const newEmail = String(payload?.newEmail || '').trim().toLowerCase();
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail) || newEmail.length > 160) throw new HttpError(400, 'Correo invalido');
|
||||||
|
if (!payload?.currentPassword) throw new HttpError(400, 'Clave actual obligatoria');
|
||||||
|
const [users] = await pool.query(`SELECT u.id, u.name, u.email, u.password_hash, r.code role_code FROM users u
|
||||||
|
JOIN roles r ON r.id = u.role_id WHERE u.id = ?`, [userId]);
|
||||||
|
const user = users[0];
|
||||||
|
if (!user || !(await bcrypt.compare(payload.currentPassword, user.password_hash))) throw new HttpError(400, 'Clave actual incorrecta');
|
||||||
|
if (user.email.toLowerCase() === newEmail) throw new HttpError(400, 'El correo nuevo debe ser diferente');
|
||||||
|
const [busy] = await pool.query(`SELECT 1 FROM users WHERE LOWER(email) = ? UNION ALL
|
||||||
|
SELECT 1 FROM email_change_requests WHERE open_new_email = ? LIMIT 1`, [newEmail, newEmail]);
|
||||||
|
if (busy.length) throw new HttpError(400, 'Ese correo no esta disponible');
|
||||||
|
const self = user.role_code === 'superadmin';
|
||||||
|
const approvalToken = self ? token() : null;
|
||||||
|
let id;
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(`INSERT INTO email_change_requests
|
||||||
|
(user_id, old_email, new_email, requires_self_approval, approve_token_hash, approve_token_expires)
|
||||||
|
VALUES (?, ?, ?, ?, ?, IF(?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NULL))`,
|
||||||
|
[userId, user.email, newEmail, self ? 1 : 0, approvalToken ? hash(approvalToken) : null, self ? 1 : 0]);
|
||||||
|
id = result.insertId;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY') throw new HttpError(400, 'Ya existe una solicitud abierta o ese correo no esta disponible');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const r = { id, user_id: userId, name: user.name, old_email: user.email, new_email: newEmail };
|
||||||
|
await logAudit({ userId, action: 'email_change_request', entityType: 'email_change_request', entityId: id, detail: detail(r) });
|
||||||
|
if (self) await mail(r, user.email, 'approve', `${env.corsOrigin}/aprobar-cambio-correo?token=${approvalToken}`);
|
||||||
|
else {
|
||||||
|
const roles = user.role_code === 'admin' ? ['superadmin'] : ['admin', 'superadmin'];
|
||||||
|
const [approvers] = await pool.query(`SELECT u.id FROM users u JOIN roles role ON role.id = u.role_id
|
||||||
|
WHERE role.code IN (?) AND u.is_active = 1 AND u.id <> ?`, [roles, userId]);
|
||||||
|
for (const approver of approvers) await pool.query(`INSERT INTO notifications (user_id, type, message, entity_type, entity_id)
|
||||||
|
VALUES (?, 'email_change_pending', ?, 'email_change_request', ?)`, [approver.id, `Cambio de correo pendiente: ${user.name}`, id]);
|
||||||
|
}
|
||||||
|
return { id, status: 'pending' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mine(userId) {
|
||||||
|
await expire();
|
||||||
|
const [rows] = await pool.query(`SELECT * FROM email_change_requests WHERE user_id = ? ORDER BY id DESC LIMIT 1`, [userId]);
|
||||||
|
return publicRow(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancel(userId) {
|
||||||
|
await expire();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [rows] = await connection.query(`SELECT * FROM email_change_requests WHERE user_id = ? AND status IN ('pending','approved') FOR UPDATE`, [userId]);
|
||||||
|
const r = rows[0];
|
||||||
|
if (!r) throw new HttpError(400, 'No hay solicitud abierta');
|
||||||
|
await connection.query(`UPDATE email_change_requests SET status = 'cancelled', approve_token_hash = NULL, confirm_token_hash = NULL WHERE id = ?`, [r.id]);
|
||||||
|
await audit(connection, userId, 'email_change_cancel', r);
|
||||||
|
await connection.commit();
|
||||||
|
return { cancelled: true };
|
||||||
|
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function canDecide(r, actor) {
|
||||||
|
if (r.requires_self_approval || actor.id === r.user_id || r.role_code === 'superadmin' ||
|
||||||
|
(r.role_code === 'admin' && actor.roleCode !== 'superadmin') || !['admin','superadmin'].includes(actor.roleCode))
|
||||||
|
throw new HttpError(403, 'No puedes decidir esta solicitud');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(actor) {
|
||||||
|
await expire();
|
||||||
|
if (!['admin','superadmin'].includes(actor.roleCode)) throw new HttpError(403, 'Acceso denegado');
|
||||||
|
const [rows] = await pool.query(`SELECT e.*, u.name, role.code role_code FROM email_change_requests e
|
||||||
|
JOIN users u ON u.id = e.user_id JOIN roles role ON role.id = u.role_id
|
||||||
|
WHERE e.status = 'pending' AND e.requires_self_approval = 0 AND e.user_id <> ?
|
||||||
|
AND role.code <> 'superadmin' AND (role.code <> 'admin' OR ? = 'superadmin') ORDER BY e.created_at`, [actor.id, actor.roleCode]);
|
||||||
|
return rows.map(publicRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decide(id, actor, decision, reason) {
|
||||||
|
await expire();
|
||||||
|
if (!Number.isSafeInteger(id) || id < 1) throw new HttpError(404, 'Solicitud no encontrada');
|
||||||
|
if (decision === 'rejected' && (!String(reason || '').trim() || String(reason).length > 1000)) throw new HttpError(400, 'Motivo obligatorio (maximo 1000 caracteres)');
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let r, confirmToken;
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [rows] = await connection.query(`SELECT e.*, u.name, role.code role_code FROM email_change_requests e JOIN users u ON u.id=e.user_id
|
||||||
|
JOIN roles role ON role.id=u.role_id WHERE e.id=? FOR UPDATE`, [id]);
|
||||||
|
r = rows[0];
|
||||||
|
if (!r) throw new HttpError(404, 'Solicitud no encontrada');
|
||||||
|
canDecide(r, actor);
|
||||||
|
if (r.status !== 'pending') throw new HttpError(400, 'Solicitud no pendiente');
|
||||||
|
confirmToken = decision === 'approved' ? token() : null;
|
||||||
|
await connection.query(`UPDATE email_change_requests SET status=?, decided_by=?, decided_at=NOW(), reject_reason=?,
|
||||||
|
confirm_token_hash=?, confirm_token_expires=IF(?, DATE_ADD(NOW(), INTERVAL 24 HOUR), NULL), approve_token_hash=NULL WHERE id=?`,
|
||||||
|
[decision, actor.id, decision === 'rejected' ? reason.trim() : null, confirmToken ? hash(confirmToken) : null, confirmToken ? 1 : 0, id]);
|
||||||
|
await audit(connection, actor.id, decision === 'approved' ? 'email_change_approve' : 'email_change_reject', r, decision === 'rejected' ? { reason: reason.trim() } : {});
|
||||||
|
await connection.query(`INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, 'email_change_request', ?)`,
|
||||||
|
[r.user_id, `email_change_${decision}`, decision === 'approved' ? 'Tu cambio de correo fue aprobado; revisa el correo nuevo' : 'Tu cambio de correo fue rechazado', id]);
|
||||||
|
await connection.commit();
|
||||||
|
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
|
||||||
|
if (confirmToken) await mail(r, r.new_email, 'confirm', `${env.corsOrigin}/confirmar-cambio-correo?token=${confirmToken}`);
|
||||||
|
await mail(r, r.old_email, decision === 'approved' ? 'approved' : 'rejected', null, reason);
|
||||||
|
return { status: decision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selfApprove(value) {
|
||||||
|
if (!validToken(value)) throw new HttpError(400, 'Enlace invalido o vencido');
|
||||||
|
await expire();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let r, confirmToken;
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [rows] = await connection.query(`SELECT e.*, (e.approve_token_expires <= NOW()) AS token_expired, u.name, role.code role_code FROM email_change_requests e JOIN users u ON u.id=e.user_id
|
||||||
|
JOIN roles role ON role.id=u.role_id WHERE e.approve_token_hash=? FOR UPDATE`, [hash(value)]);
|
||||||
|
r = rows[0];
|
||||||
|
if (r?.status === 'expired') throw new HttpError(400, 'El enlace vencio; la solicitud quedo vencida');
|
||||||
|
if (!r || r.status !== 'pending' || !r.requires_self_approval || r.role_code !== 'superadmin' || r.token_expired) throw new HttpError(400, 'Enlace invalido o vencido');
|
||||||
|
confirmToken = token();
|
||||||
|
await connection.query(`UPDATE email_change_requests SET status='approved', decided_by=?, decided_at=NOW(), approve_token_hash=NULL,
|
||||||
|
confirm_token_hash=?, confirm_token_expires=DATE_ADD(NOW(), INTERVAL 24 HOUR) WHERE id=?`, [r.user_id, hash(confirmToken), r.id]);
|
||||||
|
await audit(connection, r.user_id, 'email_change_self_approve', r);
|
||||||
|
await connection.commit();
|
||||||
|
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
|
||||||
|
await mail(r, r.new_email, 'confirm', `${env.corsOrigin}/confirmar-cambio-correo?token=${confirmToken}`);
|
||||||
|
await mail(r, r.old_email, 'approved');
|
||||||
|
return { status: 'approved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirm(value) {
|
||||||
|
if (!validToken(value)) throw new HttpError(400, 'Enlace invalido o vencido');
|
||||||
|
await expire();
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let r, failure;
|
||||||
|
try {
|
||||||
|
await connection.beginTransaction();
|
||||||
|
const [rows] = await connection.query(`SELECT *, (confirm_token_expires <= NOW()) AS token_expired, (created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)) AS request_expired FROM email_change_requests WHERE confirm_token_hash=? FOR UPDATE`, [hash(value)]);
|
||||||
|
r = rows[0];
|
||||||
|
if (!r) throw new HttpError(400, 'Enlace invalido');
|
||||||
|
if (r.status === 'expired') throw new HttpError(400, 'El enlace vencio; la solicitud quedo vencida');
|
||||||
|
if (r.status !== 'approved') throw new HttpError(400, 'Enlace invalido o ya utilizado');
|
||||||
|
const [occupied] = await connection.query(`SELECT id FROM users WHERE LOWER(email)=LOWER(?) AND id<>? LIMIT 1`, [r.new_email, r.user_id]);
|
||||||
|
failure = r.token_expired || r.request_expired ? 'expired' : occupied.length ? 'failed' : null;
|
||||||
|
if (failure) {
|
||||||
|
await connection.query(`UPDATE email_change_requests SET status=?, confirm_token_hash=NULL WHERE id=?`, [failure, r.id]);
|
||||||
|
await audit(connection, r.user_id, `email_change_${failure}`, r);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await connection.query(`UPDATE users SET email=?, verification_token=NULL, verification_token_expires=NULL,
|
||||||
|
reset_token=NULL, reset_token_expires=NULL, session_version=session_version+1, edit_version=edit_version+1 WHERE id=?`, [r.new_email, r.user_id]);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'ER_DUP_ENTRY') throw err;
|
||||||
|
failure = 'failed';
|
||||||
|
await connection.query(`UPDATE email_change_requests SET status='failed', confirm_token_hash=NULL WHERE id=?`, [r.id]);
|
||||||
|
await audit(connection, r.user_id, 'email_change_failed', r);
|
||||||
|
}
|
||||||
|
if (!failure) {
|
||||||
|
await audit(connection, r.user_id, 'email_change_confirm', r);
|
||||||
|
const [revoked] = await connection.query(`UPDATE user_sessions SET revoked_at=NOW(), revoked_by=?, revoke_reason='email_change'
|
||||||
|
WHERE user_id=? AND revoked_at IS NULL`, [r.user_id, r.user_id]);
|
||||||
|
await audit(connection, r.user_id, 'email_change_sessions_revoked', r, { count: revoked.affectedRows });
|
||||||
|
await connection.query(`UPDATE email_change_requests SET status='applied', applied_at=NOW(), confirm_token_hash=NULL WHERE id=?`, [r.id]);
|
||||||
|
await audit(connection, r.user_id, 'email_change_applied', r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await connection.commit();
|
||||||
|
} catch (err) { await connection.rollback(); throw err; } finally { connection.release(); }
|
||||||
|
if (failure) throw new HttpError(400, failure === 'expired' ? 'El enlace vencio; la solicitud quedo vencida' : 'El correo ya fue tomado; la solicitud quedo fallida');
|
||||||
|
return { email: r.new_email, sessionsClosed: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
|
// Cliente del contrato v1 de conectores (data/conectores/README.md). Solo GET, con token y tiempo
|
||||||
|
// máximo; los errores del conector se traducen a mensajes para el administrador.
|
||||||
|
const TIMEOUT_MS = 60000;
|
||||||
|
|
||||||
|
export function normalizeBaseUrl(raw) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(raw || '').trim());
|
||||||
|
} catch {
|
||||||
|
throw new HttpError(400, 'URL del conector inválida');
|
||||||
|
}
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new HttpError(400, 'URL del conector inválida');
|
||||||
|
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectorGet(conn, path, query = {}) {
|
||||||
|
const url = new URL(`${conn.baseUrl}${path}`);
|
||||||
|
for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { Authorization: `Bearer ${conn.token}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
||||||
|
} catch (err) {
|
||||||
|
throw new HttpError(502, err.name === 'TimeoutError' ? 'El conector no respondió a tiempo' : 'No se pudo conectar con el conector');
|
||||||
|
}
|
||||||
|
let body = null;
|
||||||
|
try { body = await res.json(); } catch { /* respuesta no JSON */ }
|
||||||
|
if (!res.ok) {
|
||||||
|
const reason = res.status === 401 ? 'token inválido' : res.status === 403 ? 'IP no permitida' : body?.error || `HTTP ${res.status}`;
|
||||||
|
throw new HttpError(502, `Conector: ${reason}`);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recorre todas las páginas de una consulta de filas (máximo 1000 por página en el conector).
|
||||||
|
// Si el origen cambia entre páginas (otra versión del archivo), se reinicia la lectura para no
|
||||||
|
// duplicar ni saltar filas.
|
||||||
|
export async function connectorAllRows(conn, source, table, query = {}) {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const rows = [];
|
||||||
|
let offset = 0, page, version = null, stable = true;
|
||||||
|
do {
|
||||||
|
page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(source)}/tables/${encodeURIComponent(table)}/rows`, { ...query, limit: 1000, offset });
|
||||||
|
if (version !== null && page.version !== version) { stable = false; break; }
|
||||||
|
version = page.version;
|
||||||
|
rows.push(...page.rows);
|
||||||
|
offset += page.rows.length;
|
||||||
|
} while (page.rows.length && offset < page.total);
|
||||||
|
if (stable) return { rows, maxRecno: page.maxRecno, version };
|
||||||
|
}
|
||||||
|
throw new HttpError(503, 'El origen cambió durante la lectura; se reintentará');
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
|
import * as svc from './service.js';
|
||||||
|
|
||||||
|
// Conexiones externas. Permisos en el servicio: configuración solo superadmin; empleados
|
||||||
|
// (listar y "Actualizar empleados") admin y superadmin.
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
const h = (fn, status = 200) => asyncHandler(async (req, res) => ok(res, await fn(req), status));
|
||||||
|
|
||||||
|
router.get('/connections', h((req) => svc.listConnections(req.currentUser)));
|
||||||
|
router.post('/connections', h((req) => svc.saveConnection(req.body, req.currentUser), 201));
|
||||||
|
router.put('/connections/:id', h((req) => svc.saveConnection(req.body, req.currentUser, req.params.id)));
|
||||||
|
router.post('/connections/:id/test', h((req) => svc.testConnection(req.params.id, req.currentUser)));
|
||||||
|
router.get('/connections/:id/browse', h((req) => svc.browse(req.params.id, req.query, req.currentUser)));
|
||||||
|
router.get('/sources', h((req) => svc.listSources(req.currentUser)));
|
||||||
|
router.put('/sources/:purpose', h((req) => svc.saveSource(req.params.purpose, req.body, req.currentUser)));
|
||||||
|
router.post('/sources/patients/sync', h((req) => svc.syncPatients({ full: !!req.body?.full, user: req.currentUser })));
|
||||||
|
router.get('/employees', h((req) => svc.listEmployees(req.currentUser, req.query)));
|
||||||
|
router.post('/employees/sync', h((req) => svc.syncEmployees(req.currentUser)));
|
||||||
|
router.get('/providers', h((req) => svc.searchProviders(req.query.search, req.currentUser)));
|
||||||
|
router.post('/providers/:code/import', h((req) => svc.importProvider(req.params.code, req.body || {}, req.currentUser), 201));
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { encrypt, decrypt } from '../../utils/crypto.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { requireEditVersion } from '../../utils/editVersion.js';
|
||||||
|
import { guayaquilNow } from '../meetings/recurrence.js';
|
||||||
|
import { connectorGet, connectorAllRows, normalizeBaseUrl } from './connector.js';
|
||||||
|
|
||||||
|
// Conexiones externas (ver docs/CONEXIONES-EXTERNAS.md). El sistema nunca escribe en el origen:
|
||||||
|
// solo lee por el conector y copia/consulta lo que el superadmin mapeó.
|
||||||
|
|
||||||
|
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
|
||||||
|
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
const TARGETS = {
|
||||||
|
employees: { required: ['identification'], fields: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'] },
|
||||||
|
patients: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] },
|
||||||
|
providers: { required: ['externalId', 'fullName'], fields: ['externalId', 'identification', 'fullName', 'phone', 'email'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuperadmin = (user) => user.roleCode === 'superadmin';
|
||||||
|
const isAdmin = (user) => ['admin', 'superadmin'].includes(user.roleCode);
|
||||||
|
export function requireSuperadmin(user) {
|
||||||
|
if (!isSuperadmin(user)) throw new HttpError(403, 'Solo el superadmin configura las conexiones externas');
|
||||||
|
}
|
||||||
|
export function requireAdmin(user) {
|
||||||
|
if (!isAdmin(user)) throw new HttpError(403, 'No tienes permisos para esta acción');
|
||||||
|
}
|
||||||
|
const num = (v) => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const purposeOf = (p) => {
|
||||||
|
if (!TARGETS[p]) throw new HttpError(400, 'Destino inválido');
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const clip = (v, max) => {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
const s = String(v).replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
||||||
|
return s ? s.slice(0, max) : null;
|
||||||
|
};
|
||||||
|
const dateOnly = (v) => (typeof v === 'string' && /^\d{4}-\d\d-\d\d/.test(v) ? v.slice(0, 10) : null);
|
||||||
|
const truthy = (v) => v === true || v === 1 || /^(1|s|si|sí|true|t|y|activo)$/i.test(String(v ?? '').trim());
|
||||||
|
|
||||||
|
// --- Conexiones ---
|
||||||
|
|
||||||
|
async function loadConnection(id) {
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM external_connections WHERE id=?', [num(id)]);
|
||||||
|
if (!row) throw new HttpError(404, 'Conexión no encontrada');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
const clientFor = (row) => ({ baseUrl: row.base_url, token: decrypt(row.token_encrypted) });
|
||||||
|
const publicConnection = (r) => ({ id: r.id, name: r.name, base_url: r.base_url, is_active: !!r.is_active, edit_version: r.edit_version, created_at: r.created_at, updated_at: r.updated_at });
|
||||||
|
|
||||||
|
export async function listConnections(user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
return (await exec(pool, 'SELECT * FROM external_connections ORDER BY name')).map(publicConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConnection(raw, user, connId = null) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const name = clip(raw.name, 120);
|
||||||
|
if (!name) throw new HttpError(400, 'El nombre es obligatorio');
|
||||||
|
const baseUrl = normalizeBaseUrl(raw.baseUrl);
|
||||||
|
const token = raw.token ? String(raw.token).trim() : '';
|
||||||
|
if (connId) {
|
||||||
|
const before = await loadConnection(connId);
|
||||||
|
const version = requireEditVersion(Number(raw.editVersion));
|
||||||
|
const result = await exec(pool, `UPDATE external_connections SET name=?,base_url=?,is_active=?${token ? ',token_encrypted=?' : ''},edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
|
||||||
|
[name, baseUrl, raw.isActive === false ? 0 : 1, ...(token ? [encrypt(token)] : []), before.id, version]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Conexión modificada por otra persona');
|
||||||
|
await logAudit({ userId: user.id, action: 'update', entityType: 'external_connection', entityId: before.id, detail: { name, baseUrl, tokenChanged: !!token } });
|
||||||
|
return publicConnection(await loadConnection(before.id));
|
||||||
|
}
|
||||||
|
if (!token) throw new HttpError(400, 'El token es obligatorio');
|
||||||
|
const result = await exec(pool, 'INSERT INTO external_connections (name,base_url,token_encrypted,created_by) VALUES (?,?,?,?)', [name, baseUrl, encrypt(token), user.id]);
|
||||||
|
await logAudit({ userId: user.id, action: 'create', entityType: 'external_connection', entityId: result.insertId, detail: { name, baseUrl } });
|
||||||
|
return publicConnection(await loadConnection(result.insertId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testConnection(connId, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const row = await loadConnection(connId);
|
||||||
|
const health = await connectorGet(clientFor(row), '/v1/health');
|
||||||
|
await logAudit({ userId: user.id, action: 'test', entityType: 'external_connection', entityId: row.id });
|
||||||
|
return health;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exploración para el superadmin: fuentes, tablas, estructura y una muestra de filas.
|
||||||
|
export async function browse(connId, q, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const conn = clientFor(await loadConnection(connId));
|
||||||
|
if (!q.source) return connectorGet(conn, '/v1/sources');
|
||||||
|
if (!ID_RE.test(q.source)) throw new HttpError(400, 'Fuente inválida');
|
||||||
|
const base = `/v1/sources/${encodeURIComponent(q.source)}/tables`;
|
||||||
|
if (!q.table) return connectorGet(conn, base);
|
||||||
|
if (!ID_RE.test(q.table)) throw new HttpError(400, 'Tabla inválida');
|
||||||
|
const [schema, sample] = await Promise.all([
|
||||||
|
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/schema`),
|
||||||
|
connectorGet(conn, `${base}/${encodeURIComponent(q.table)}/rows`, { limit: 20 })
|
||||||
|
]);
|
||||||
|
await logAudit({ userId: user.id, action: 'preview', entityType: 'external_connection', entityId: Number(connId), detail: { source: q.source, table: q.table } });
|
||||||
|
return { schema, sample: sample.rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Fuentes (qué alimenta Pacientes / Empleados) ---
|
||||||
|
|
||||||
|
const parseSource = (r) => r && ({ ...r, field_map: JSON.parse(r.field_map_json), filter: r.filter_json ? JSON.parse(r.filter_json) : {}, last_result: r.last_result_json ? JSON.parse(r.last_result_json) : null });
|
||||||
|
async function sourceRow(purpose) {
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM external_sources WHERE purpose=?', [purposeOf(purpose)]);
|
||||||
|
return parseSource(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSources(user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const rows = (await exec(pool, 'SELECT s.*, c.name AS connection_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id')).map(parseSource);
|
||||||
|
// El admin solo ve el estado (para "Actualizar empleados"); el mapeo es del superadmin.
|
||||||
|
return isSuperadmin(user) ? rows : rows.map((r) => ({ purpose: r.purpose, mode: r.mode, connection_name: r.connection_name, last_sync_at: r.last_sync_at, last_full_sync_at: r.last_full_sync_at, last_result: r.last_result }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSource(purpose, raw, user) {
|
||||||
|
requireSuperadmin(user);
|
||||||
|
const p = purposeOf(purpose);
|
||||||
|
const conn = await loadConnection(raw.connectionId);
|
||||||
|
const sourceKey = String(raw.sourceKey || ''), tableName = String(raw.tableName || '');
|
||||||
|
if (!ID_RE.test(sourceKey) || !ID_RE.test(tableName)) throw new HttpError(400, 'Fuente o tabla inválida');
|
||||||
|
const mode = p === 'employees' ? 'import' : raw.mode === 'live' ? 'live' : 'import';
|
||||||
|
// El mapeo se valida contra la estructura real que publica el conector.
|
||||||
|
const schema = await connectorGet(clientFor(conn), `/v1/sources/${encodeURIComponent(sourceKey)}/tables/${encodeURIComponent(tableName)}/schema`);
|
||||||
|
const known = new Set(schema.fields.map((f) => f.name));
|
||||||
|
const map = {};
|
||||||
|
for (const target of TARGETS[p].fields) {
|
||||||
|
const field = raw.fieldMap?.[target];
|
||||||
|
if (!field) continue;
|
||||||
|
if (!known.has(field)) throw new HttpError(400, `Campo inexistente en la tabla: ${field}`);
|
||||||
|
map[target] = field;
|
||||||
|
}
|
||||||
|
for (const r of TARGETS[p].required) if (!map[r]) throw new HttpError(400, `Falta mapear el campo obligatorio: ${r}`);
|
||||||
|
const filter = {};
|
||||||
|
for (const [field, value] of Object.entries(raw.filter || {})) {
|
||||||
|
if (!known.has(field)) throw new HttpError(400, `Campo de filtro inexistente: ${field}`);
|
||||||
|
if (String(value).trim()) filter[field] = String(value).trim().slice(0, 60);
|
||||||
|
}
|
||||||
|
const changedField = raw.changedField ? String(raw.changedField) : null;
|
||||||
|
if (changedField && schema.fields.find((f) => f.name === changedField)?.type !== 'T') throw new HttpError(400, 'El campo de cambios debe ser de fecha y hora');
|
||||||
|
const before = await sourceRow(p);
|
||||||
|
if (p === 'patients' && before && before.field_map.externalId !== map.externalId) {
|
||||||
|
const [{ n }] = await exec(pool, 'SELECT COUNT(*) AS n FROM clinic_patients WHERE external_source=?', [patientKey(before)]);
|
||||||
|
if (n) throw new HttpError(409, 'No se puede cambiar el campo "código en el origen" con pacientes ya importados');
|
||||||
|
}
|
||||||
|
const sameTable = before && before.connection_id === conn.id && before.source_key === sourceKey && before.table_name === tableName;
|
||||||
|
if (before) {
|
||||||
|
const result = await exec(pool, `UPDATE external_sources SET connection_id=?,source_key=?,table_name=?,mode=?,field_map_json=?,filter_json=?,changed_field=?,
|
||||||
|
${sameTable ? '' : 'last_full_sync_at=NULL,last_sync_at=NULL,last_max_recno=NULL,last_changed_value=NULL,'}edit_version=edit_version+1 WHERE id=? AND edit_version=?`,
|
||||||
|
[conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField, before.id, requireEditVersion(Number(raw.editVersion))]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Fuente modificada por otra persona');
|
||||||
|
} else {
|
||||||
|
await exec(pool, 'INSERT INTO external_sources (purpose,connection_id,source_key,table_name,mode,field_map_json,filter_json,changed_field) VALUES (?,?,?,?,?,?,?,?)',
|
||||||
|
[p, conn.id, sourceKey, tableName, mode, JSON.stringify(map), JSON.stringify(filter), changedField]);
|
||||||
|
}
|
||||||
|
await logAudit({ userId: user.id, action: before ? 'update' : 'create', entityType: 'external_source', entityId: null, detail: { purpose: p, sourceKey, tableName, mode, map, filter } });
|
||||||
|
return sourceRow(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sourceClient(purpose) {
|
||||||
|
const src = await sourceRow(purpose);
|
||||||
|
if (!src) throw new HttpError(409, { employees: 'No hay fuente de nómina configurada', patients: 'No hay fuente de pacientes configurada', providers: 'No hay fuente de proveedores configurada' }[purpose]);
|
||||||
|
const conn = await loadConnection(src.connection_id);
|
||||||
|
if (!conn.is_active) throw new HttpError(409, 'La conexión está desactivada');
|
||||||
|
return { src, conn: clientFor(conn) };
|
||||||
|
}
|
||||||
|
const eqQuery = (filter) => Object.fromEntries(Object.entries(filter || {}).map(([k, v]) => [`eq.${k}`, v]));
|
||||||
|
const fieldsQuery = (src) => [...new Set([...Object.values(src.field_map), ...(src.changed_field ? [src.changed_field] : [])])].join(',');
|
||||||
|
|
||||||
|
async function finishSync(src, values, result) {
|
||||||
|
await exec(pool, `UPDATE external_sources SET last_sync_at=?,${values.full ? 'last_full_sync_at=?,' : ''}last_max_recno=?,last_changed_value=?,last_result_json=? WHERE id=?`,
|
||||||
|
[guayaquilNow(), ...(values.full ? [guayaquilNow()] : []), values.maxRecno ?? src.last_max_recno, values.changedValue ?? src.last_changed_value, JSON.stringify(result), src.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Empleados (nómina): siempre importados, a pedido (admin/superadmin) ---
|
||||||
|
|
||||||
|
const running = new Set();
|
||||||
|
async function exclusive(key, fn) {
|
||||||
|
if (running.has(key)) throw new HttpError(409, 'Ya hay una sincronización en curso');
|
||||||
|
running.add(key);
|
||||||
|
try { return await fn(); } finally { running.delete(key); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncEmployees(user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
return exclusive('employees', async () => {
|
||||||
|
const { src, conn } = await sourceClient('employees');
|
||||||
|
const m = src.field_map;
|
||||||
|
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, { fields: fieldsQuery(src), ...eqQuery(src.filter) });
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const seen = [];
|
||||||
|
let created = 0, updated = 0, protectedRows = 0;
|
||||||
|
// Empleados con usuario en el sistema (p. ej. médicos) no se actualizan ni se inactivan solos.
|
||||||
|
const known = await exec(pool, 'SELECT identification, user_id FROM employees');
|
||||||
|
const withUser = new Set(known.filter((e) => e.user_id).map((e) => e.identification));
|
||||||
|
const existed = new Set(known.map((e) => e.identification));
|
||||||
|
for (const r of rows) {
|
||||||
|
const identification = clip(r[m.identification], 40);
|
||||||
|
if (!identification) continue;
|
||||||
|
seen.push(identification);
|
||||||
|
if (withUser.has(identification)) { protectedRows++; continue; }
|
||||||
|
const leave = m.leaveDate ? dateOnly(r[m.leaveDate]) : null;
|
||||||
|
const active = m.active ? truthy(r[m.active]) : !leave;
|
||||||
|
const values = [clip(m.lastNames && r[m.lastNames], 120) || '', clip(m.firstNames && r[m.firstNames], 120) || '', clip(m.phone && r[m.phone], 40), clip(m.phone2 && r[m.phone2], 40),
|
||||||
|
m.birthDate ? dateOnly(r[m.birthDate]) : null, clip(m.position && r[m.position], 120), clip(m.department && r[m.department], 120), m.hireDate ? dateOnly(r[m.hireDate]) : null, leave, active ? 1 : 0];
|
||||||
|
const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,phone2,birth_date,position,department_name,hire_date,leave_date,is_active,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?) ON DUPLICATE KEY UPDATE last_names=VALUES(last_names),first_names=VALUES(first_names),phone=VALUES(phone),phone2=VALUES(phone2),birth_date=VALUES(birth_date),
|
||||||
|
position=VALUES(position),department_name=VALUES(department_name),hire_date=VALUES(hire_date),leave_date=VALUES(leave_date),is_active=VALUES(is_active),in_source=1,origin='payroll'`,
|
||||||
|
[identification, ...values, now]);
|
||||||
|
// MySQL informa 2 filas afectadas solo cuando el registro existente cambió de verdad.
|
||||||
|
if (!existed.has(identification)) created++;
|
||||||
|
else if (result.affectedRows === 2) updated++;
|
||||||
|
}
|
||||||
|
const synced = seen.filter((x) => !withUser.has(x));
|
||||||
|
if (synced.length) await exec(pool, 'UPDATE employees SET synced_at=? WHERE identification IN (?)', [now, synced]);
|
||||||
|
// Los que ya no están en la nómina quedan inactivos (no se borran: pueden tener usuario).
|
||||||
|
const gone = seen.length
|
||||||
|
? await exec(pool, "UPDATE employees SET in_source=0,is_active=0 WHERE in_source=1 AND origin='payroll' AND user_id IS NULL AND identification NOT IN (?)", [seen])
|
||||||
|
: { affectedRows: 0 };
|
||||||
|
const result = { at: now, full: true, total: rows.length, created, updated, removed: gone.affectedRows, protected: protectedRows };
|
||||||
|
await finishSync(src, { full: true, maxRecno }, result);
|
||||||
|
await logAudit({ userId: user.id, action: 'sync', entityType: 'employees', entityId: null, detail: result });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEmployees(user, q = {}) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const onlyFree = q.available === '1';
|
||||||
|
return exec(pool, `SELECT e.*, u.name AS user_name FROM employees e LEFT JOIN users u ON u.id=e.user_id
|
||||||
|
WHERE 1=1 ${onlyFree ? 'AND e.is_active=1 AND e.user_id IS NULL' : ''} ORDER BY e.last_names, e.first_names`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireFreeEmployee(employeeId) {
|
||||||
|
const [e] = await exec(pool, 'SELECT id FROM employees WHERE id=? AND is_active=1 AND user_id IS NULL', [num(employeeId)]);
|
||||||
|
if (!e) throw new HttpError(409, 'Ese empleado ya tiene usuario, está inactivo o no existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vincula un usuario recién creado con su empleado (desde "Nuevo usuario" → Desde nómina).
|
||||||
|
export async function linkEmployeeToUser(employeeId, userId, connection = pool) {
|
||||||
|
const result = await exec(connection, 'UPDATE employees SET user_id=? WHERE id=? AND user_id IS NULL', [userId, num(employeeId)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Ese empleado ya tiene usuario o no existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Proveedores → empleados (médicos que facturan; admin y superadmin, a pedido) ---
|
||||||
|
|
||||||
|
const providerValues = (src, r) => {
|
||||||
|
const m = src.field_map;
|
||||||
|
const raw = clip(m.identification && r[m.identification], 40);
|
||||||
|
const email = clip(m.email && r[m.email], 160);
|
||||||
|
const phone = clip(m.phone && r[m.phone], 40);
|
||||||
|
return {
|
||||||
|
code: clip(r[m.externalId], 40),
|
||||||
|
// RUC de persona natural = cédula + "001".
|
||||||
|
identification: raw && /^\d{13}$/.test(raw) && raw.endsWith('001') ? raw.slice(0, 10) : raw,
|
||||||
|
fullName: clip(r[m.fullName], 120)?.replace(/^DRA?\.?\s+/i, '') || null,
|
||||||
|
phone: phone && !/^9+$/.test(phone) ? phone : null,
|
||||||
|
email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function searchProviders(text, user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const { src, conn } = await sourceClient('providers');
|
||||||
|
const term = String(text || '').trim();
|
||||||
|
if (term.length < 3) return [];
|
||||||
|
const m = src.field_map;
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
q: term.slice(0, 60), qFields: [m.fullName, m.identification].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 30, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
const list = page.rows.map((r) => providerValues(src, r)).filter((p) => p.code && p.fullName);
|
||||||
|
const idents = list.map((p) => p.identification).filter(Boolean);
|
||||||
|
const employees = idents.length ? new Map((await exec(pool, 'SELECT id, identification FROM employees WHERE identification IN (?)', [idents])).map((e) => [e.identification, e.id])) : new Map();
|
||||||
|
return list.map((p) => ({ ...p, employee_id: employees.get(p.identification) || null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importProvider(code, raw, user) {
|
||||||
|
requireAdmin(user);
|
||||||
|
const { src, conn } = await sourceClient('providers');
|
||||||
|
const c = clip(code, 40);
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
[`eq.${src.field_map.externalId}`]: c, fields: fieldsQuery(src), limit: 1
|
||||||
|
});
|
||||||
|
if (!page.rows.length) throw new HttpError(404, 'El proveedor no existe en el sistema origen');
|
||||||
|
const p = providerValues(src, page.rows[0]);
|
||||||
|
if (!p.identification) throw new HttpError(400, 'El proveedor no tiene cédula/RUC');
|
||||||
|
// El orden de nombres en el origen no es fijo: apellidos y nombres los confirma quien agrega.
|
||||||
|
const last = clip(raw.lastNames, 120), first = clip(raw.firstNames, 120), position = clip(raw.position, 120);
|
||||||
|
if (!last || !first) throw new HttpError(400, 'Apellidos y nombres son obligatorios');
|
||||||
|
const [exists] = await exec(pool, 'SELECT id FROM employees WHERE identification=?', [p.identification]);
|
||||||
|
if (exists) throw new HttpError(409, 'Ya existe un empleado con esa cédula');
|
||||||
|
const result = await exec(pool, `INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,1,'provider',0,?)`, [p.identification, last, first, p.phone, p.email, position, guayaquilNow()]);
|
||||||
|
await logAudit({ userId: user.id, action: 'import', entityType: 'employee', entityId: result.insertId, detail: { providerCode: c } });
|
||||||
|
return (await exec(pool, 'SELECT * FROM employees WHERE id=?', [result.insertId]))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pacientes (clientes del origen) ---
|
||||||
|
|
||||||
|
const patientKey = (src) => `src${src.id}`;
|
||||||
|
function patientValues(src, r) {
|
||||||
|
const m = src.field_map;
|
||||||
|
const email = clip(m.email && r[m.email], 160);
|
||||||
|
return {
|
||||||
|
externalId: clip(r[m.externalId], 40),
|
||||||
|
identification: clip(m.identification && r[m.identification], 40),
|
||||||
|
fullName: clip(r[m.fullName], 120),
|
||||||
|
phone: clip(m.phone && r[m.phone], 40) || '',
|
||||||
|
email: email && /^[^\s@,;]+@[^\s@,;]+\.[^\s@,;]+$/.test(email) ? email : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert de un lote: manda el origen (cédula, teléfono, correo, nombre de origen). El nombre visible
|
||||||
|
// solo se reemplaza mientras recepción no lo haya separado a mano (name_split=0).
|
||||||
|
async function upsertPatients(src, rows, now) {
|
||||||
|
const key = patientKey(src);
|
||||||
|
const stats = { created: 0, updated: 0, linked: 0, unchanged: 0, skipped: 0, identConflicts: 0 };
|
||||||
|
const seen = new Set();
|
||||||
|
if (!rows.length) return { stats, seen };
|
||||||
|
// Lotes chicos (incremental, importar uno): solo se precarga lo del lote; la completa trae todo.
|
||||||
|
const small = rows.length <= 2000;
|
||||||
|
const batchIds = rows.map((r) => clip(r[src.field_map.externalId], 40)).filter(Boolean);
|
||||||
|
const batchIdents = src.field_map.identification ? rows.map((r) => clip(r[src.field_map.identification], 40)).filter(Boolean) : [];
|
||||||
|
const existing = new Map((small && !batchIds.length ? [] : await exec(pool, `SELECT id,external_id,identification,source_name,phone,email,is_active,name_split FROM clinic_patients WHERE external_source=?${small ? ' AND external_id IN (?)' : ''}`, small ? [key, batchIds] : [key])).map((p) => [p.external_id, p]));
|
||||||
|
const byIdent = new Map((small && !batchIdents.length ? [] : await exec(pool, `SELECT id,identification,external_id FROM clinic_patients WHERE identification IS NOT NULL${small ? ' AND identification IN (?)' : ''}`, small ? [batchIdents] : [])).map((p) => [p.identification, p]));
|
||||||
|
for (const r of rows) {
|
||||||
|
const v = patientValues(src, r);
|
||||||
|
if (v.externalId) seen.add(v.externalId);
|
||||||
|
if (!v.externalId || !v.fullName) { stats.skipped++; continue; }
|
||||||
|
// Código repetido en el lote (duplicado en el origen): se procesa una sola vez.
|
||||||
|
if (existing.get(v.externalId)?.batch) { stats.skipped++; continue; }
|
||||||
|
const owner = v.identification ? byIdent.get(v.identification) : null;
|
||||||
|
const cur = existing.get(v.externalId);
|
||||||
|
// La cédula es única: si otro paciente ya la tiene (duplicado en el origen), no se repite.
|
||||||
|
const identFor = (selfId) => (v.identification && owner && owner.id !== selfId ? (stats.identConflicts++, null) : v.identification);
|
||||||
|
if (cur) {
|
||||||
|
const ident = identFor(cur.id);
|
||||||
|
if (cur.source_name === v.fullName && (cur.identification || null) === ident && cur.phone === v.phone && (cur.email || null) === v.email && cur.is_active) { cur.batch = true; stats.unchanged++; continue; }
|
||||||
|
await exec(pool, 'UPDATE clinic_patients SET source_name=?,first_names=IF(name_split=0,?,first_names),identification=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
|
||||||
|
[v.fullName, v.fullName, ident, v.phone, v.email, now, cur.id]);
|
||||||
|
if (ident) byIdent.set(ident, { id: cur.id, identification: ident });
|
||||||
|
cur.batch = true;
|
||||||
|
stats.updated++;
|
||||||
|
} else if (owner && !owner.external_id) {
|
||||||
|
// Paciente creado a mano con la misma cédula: se vincula y manda el origen (sin tocar su nombre separado).
|
||||||
|
await exec(pool, 'UPDATE clinic_patients SET external_source=?,external_id=?,source_name=?,phone=?,email=?,is_active=1,synced_at=? WHERE id=?',
|
||||||
|
[key, v.externalId, v.fullName, v.phone, v.email, now, owner.id]);
|
||||||
|
owner.external_id = v.externalId;
|
||||||
|
existing.set(v.externalId, { id: owner.id, batch: true });
|
||||||
|
stats.linked++;
|
||||||
|
} else {
|
||||||
|
const result = await exec(pool, `INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,external_source,external_id,source_name,name_split,synced_at)
|
||||||
|
VALUES (?,'',?,?,?,?,?,?,0,?)`, [v.fullName, identFor(null), v.phone, v.email, key, v.externalId, v.fullName, now]);
|
||||||
|
if (v.identification && !owner) byIdent.set(v.identification, { id: result.insertId, identification: v.identification, external_id: v.externalId });
|
||||||
|
existing.set(v.externalId, { id: result.insertId, batch: true });
|
||||||
|
stats.created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { stats, seen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncPatients({ full = false, user = null } = {}) {
|
||||||
|
if (user) requireSuperadmin(user);
|
||||||
|
return exclusive('patients', async () => {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
if (src.mode !== 'import') throw new HttpError(409, 'La fuente de pacientes está en modo consulta en vivo');
|
||||||
|
const doFull = full || !src.last_full_sync_at;
|
||||||
|
const query = { fields: fieldsQuery(src), ...eqQuery(src.filter) };
|
||||||
|
if (!doFull) {
|
||||||
|
query.afterRecno = src.last_max_recno ?? 0;
|
||||||
|
if (src.changed_field && src.last_changed_value) Object.assign(query, { changedField: src.changed_field, changedSince: src.last_changed_value });
|
||||||
|
}
|
||||||
|
const { rows, maxRecno } = await connectorAllRows(conn, src.source_key, src.table_name, query);
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const { stats, seen } = await upsertPatients(src, rows, now);
|
||||||
|
let deactivated = 0;
|
||||||
|
if (doFull && seen.size) {
|
||||||
|
// Los que ya no están en el origen (borrados) quedan inactivos; su historial se conserva.
|
||||||
|
deactivated = (await exec(pool, 'UPDATE clinic_patients SET is_active=0 WHERE external_source=? AND is_active=1 AND external_id NOT IN (?)', [patientKey(src), [...seen]])).affectedRows;
|
||||||
|
}
|
||||||
|
let changedValue = src.last_changed_value;
|
||||||
|
if (src.changed_field) for (const r of rows) { const c = r[src.changed_field]; if (c && (!changedValue || String(c) > changedValue)) changedValue = String(c).slice(0, 40); }
|
||||||
|
const result = { at: now, full: doFull, received: rows.length, ...stats, deactivated };
|
||||||
|
await finishSync(src, { full: doFull, maxRecno, changedValue }, result);
|
||||||
|
if (user) await logAudit({ userId: user.id, action: 'sync', entityType: 'clinic_patients', entityId: null, detail: result });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo consulta en vivo: buscar en el origen y traer al sistema solo el paciente elegido.
|
||||||
|
export async function patientSourceInfo() {
|
||||||
|
const [src] = await exec(pool, "SELECT s.mode, s.last_sync_at FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='patients' AND c.is_active=1");
|
||||||
|
return src ? { configured: true, mode: src.mode, last_sync_at: src.last_sync_at } : { configured: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchExternalPatients(text) {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
const m = src.field_map;
|
||||||
|
const term = String(text || '').trim();
|
||||||
|
if (term.length < 3) return [];
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
q: term.slice(0, 60), qFields: [m.fullName, m.identification, m.phone].filter(Boolean).join(','), fields: fieldsQuery(src), limit: 20, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
const ids = page.rows.map((r) => clip(r[m.externalId], 40)).filter(Boolean);
|
||||||
|
const local = ids.length ? new Map((await exec(pool, 'SELECT id,external_id FROM clinic_patients WHERE external_source=? AND external_id IN (?)', [patientKey(src), ids])).map((p) => [p.external_id, p.id])) : new Map();
|
||||||
|
return page.rows.map((r) => {
|
||||||
|
const v = patientValues(src, r);
|
||||||
|
return { external_id: v.externalId, identification: v.identification, full_name: v.fullName, phone: v.phone, email: v.email, local_id: local.get(v.externalId) || null };
|
||||||
|
}).filter((p) => p.external_id && p.full_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importExternalPatient(externalId, user) {
|
||||||
|
const { src, conn } = await sourceClient('patients');
|
||||||
|
const ext = clip(externalId, 40);
|
||||||
|
if (!ext) throw new HttpError(400, 'Paciente externo inválido');
|
||||||
|
const page = await connectorGet(conn, `/v1/sources/${encodeURIComponent(src.source_key)}/tables/${encodeURIComponent(src.table_name)}/rows`, {
|
||||||
|
[`eq.${src.field_map.externalId}`]: ext, fields: fieldsQuery(src), limit: 1, ...eqQuery(src.filter)
|
||||||
|
});
|
||||||
|
if (!page.rows.length) throw new HttpError(404, 'El paciente ya no existe en el sistema origen');
|
||||||
|
await upsertPatients(src, page.rows, guayaquilNow());
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_patients WHERE external_source=? AND external_id=?', [patientKey(src), ext]);
|
||||||
|
if (!row) throw new HttpError(409, 'No se pudo traer el paciente');
|
||||||
|
await logAudit({ userId: user.id, action: 'import', entityType: 'clinic_patient', entityId: row.id, detail: { externalId: ext } });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programación (job): completa lunes a viernes a las 05:00; incremental cada 15 min de 06:00 a 21:00.
|
||||||
|
export async function scheduledPatientSync() {
|
||||||
|
const src = await sourceRow('patients');
|
||||||
|
if (!src || src.mode !== 'import') return null;
|
||||||
|
const now = guayaquilNow();
|
||||||
|
const weekday = new Date(`${now.slice(0, 10)}T12:00:00Z`).getUTCDay();
|
||||||
|
const hour = Number(now.slice(11, 13));
|
||||||
|
const fullToday = src.last_full_sync_at && String(src.last_full_sync_at).slice(0, 10) === now.slice(0, 10);
|
||||||
|
if (!src.last_full_sync_at || (weekday >= 1 && weekday <= 5 && hour === 5 && !fullToday)) return syncPatients({ full: true });
|
||||||
|
const minutesSince = src.last_sync_at ? (Date.parse(`${now.replace(' ', 'T')}Z`) - Date.parse(`${String(src.last_sync_at).replace(' ', 'T')}Z`)) / 60000 : Infinity;
|
||||||
|
if (hour >= 6 && hour < 21 && minutesSince >= 15) return syncPatients({ full: false });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
|
import * as service from './service.js';
|
||||||
|
export const getMeetings = asyncHandler(async (req,res) => ok(res, await service.list(req.query, req.currentUser)));
|
||||||
|
export const getActivities = asyncHandler(async (req,res) => ok(res, await service.activities(req.query, req.currentUser)));
|
||||||
|
export const getTasks = asyncHandler(async (req,res) => ok(res, await service.tasks(req.query, req.currentUser)));
|
||||||
|
export const getProjects = asyncHandler(async (req,res) => ok(res, await service.projects(req.query, req.currentUser)));
|
||||||
|
export const getRecurring = asyncHandler(async (req,res) => ok(res, await service.recurring(req.query, req.currentUser)));
|
||||||
|
export const getMeeting = asyncHandler(async (req,res) => ok(res, await service.detail(Number(req.params.id), req.currentUser)));
|
||||||
|
export const postMeeting = asyncHandler(async (req,res) => { const result = await service.create(req.body || {}, req.currentUser); ok(res, result, result.requiresConfirmation ? 200 : 201); });
|
||||||
|
export const putMeeting = asyncHandler(async (req,res) => ok(res, await service.update(Number(req.params.id), req.body || {}, req.currentUser)));
|
||||||
|
export const deleteMeeting = asyncHandler(async (req,res) => ok(res, await service.cancel(Number(req.params.id), req.currentUser)));
|
||||||
|
export const postCancelOccurrence = asyncHandler(async (req,res) => ok(res, await service.cancelOccurrence(Number(req.params.id), req.params.date, req.currentUser)));
|
||||||
|
export const putRsvp = asyncHandler(async (req,res) => ok(res, await service.rsvp(Number(req.params.id), req.body?.status, req.currentUser)));
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const escapeText = (value = '') => String(value).replace(/\\/g, '\\\\').replace(/\r\n|\r|\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
|
||||||
|
const safeEmail = (value) => String(value || '').replace(/[^A-Za-z0-9._%+@-]/g, '');
|
||||||
|
const stamp = (s) => String(s).replace(/[-:]/g, '').replace(' ', 'T') + 'Z';
|
||||||
|
// Hora civil de Guayaquil -> UTC para iCalendar. Ecuador continental no usa DST.
|
||||||
|
const utc = (s) => stamp(new Date(`${s.replace(' ', 'T')}-05:00`).toISOString().slice(0, 19).replace('T', ' '));
|
||||||
|
function fold(line) {
|
||||||
|
const chunks = []; let part = '';
|
||||||
|
for (const char of line) {
|
||||||
|
if (Buffer.byteLength(part + char, 'utf8') > 75) { chunks.push(part); part = ' ' + char; } else part += char;
|
||||||
|
}
|
||||||
|
chunks.push(part);
|
||||||
|
return chunks.join('\r\n');
|
||||||
|
}
|
||||||
|
export function buildIcs(meeting, method = 'REQUEST', occurrenceStart = null) {
|
||||||
|
const duration = new Date(`${meeting.ends_at.replace(' ', 'T')}-05:00`) - new Date(`${meeting.starts_at.replace(' ', 'T')}-05:00`);
|
||||||
|
const start = occurrenceStart || meeting.starts_at;
|
||||||
|
const end = occurrenceStart ? new Date(new Date(`${start.replace(' ', 'T')}-05:00`).getTime() + duration - 5 * 3600000).toISOString().slice(0, 19).replace('T', ' ') : meeting.ends_at;
|
||||||
|
const allDay = !!meeting.all_day;
|
||||||
|
const dateValue = (s) => s.slice(0, 10).replace(/-/g, '');
|
||||||
|
const exclusiveEnd = (s) => new Date(new Date(`${s.slice(0,10)}T00:00:00Z`).getTime() + 86400000).toISOString().slice(0,10).replace(/-/g,'');
|
||||||
|
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//LinkedCorp//Project Calendar//ES', 'CALSCALE:GREGORIAN', 'X-WR-TIMEZONE:America/Guayaquil', `METHOD:${method}`, 'BEGIN:VEVENT', `UID:${meeting.uid}@project.linkedcorp.net`, `SEQUENCE:${meeting.sequence_number}`, `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}`,
|
||||||
|
allDay ? `DTSTART;VALUE=DATE:${dateValue(start)}` : `DTSTART:${utc(start)}`,
|
||||||
|
allDay ? `DTEND;VALUE=DATE:${exclusiveEnd(end)}` : `DTEND:${utc(end)}`,
|
||||||
|
`SUMMARY:${escapeText(meeting.title)}`, `DESCRIPTION:${escapeText(meeting.description)}`, `LOCATION:${escapeText(meeting.location)}`, `ORGANIZER:mailto:${safeEmail(meeting.organizer_email)}`, `STATUS:${method === 'CANCEL' ? 'CANCELLED' : 'CONFIRMED'}`];
|
||||||
|
for (const participant of meeting.participants || []) lines.push(`ATTENDEE;RSVP=TRUE;PARTSTAT=${({accepted:'ACCEPTED',declined:'DECLINED',tentative:'TENTATIVE'})[participant.rsvp] || 'NEEDS-ACTION'}:mailto:${safeEmail(participant.email)}`);
|
||||||
|
for (const email of meeting.external_emails || []) lines.push(`ATTENDEE;RSVP=FALSE:mailto:${safeEmail(email)}`);
|
||||||
|
if (occurrenceStart) lines.push(allDay ? `RECURRENCE-ID;VALUE=DATE:${dateValue(start)}` : `RECURRENCE-ID:${utc(start)}`);
|
||||||
|
if (meeting.video_url) lines.push(`URL:${meeting.video_url}`);
|
||||||
|
const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null;
|
||||||
|
if (rule && !occurrenceStart) {
|
||||||
|
const parts = [`FREQ=${rule.frequency.toUpperCase()}`, `INTERVAL=${rule.interval}`];
|
||||||
|
if (rule.frequency === 'weekly') parts.push(`BYDAY=${rule.weekdays.map((i) => ['MO','TU','WE','TH','FR','SA','SU'][i]).join(',')}`);
|
||||||
|
if (rule.count) parts.push(`COUNT=${rule.count}`);
|
||||||
|
if (rule.until) parts.push(`UNTIL=${allDay ? dateValue(rule.until) : utc(`${rule.until} 23:59:59`)}`);
|
||||||
|
lines.push(`RRULE:${parts.join(';')}`);
|
||||||
|
}
|
||||||
|
if (meeting.exceptions?.length && !occurrenceStart) lines.push(allDay ? `EXDATE;VALUE=DATE:${meeting.exceptions.map(dateValue).join(',')}` : `EXDATE:${meeting.exceptions.map(utc).join(',')}`);
|
||||||
|
lines.push('END:VEVENT', 'END:VCALENDAR');
|
||||||
|
return lines.map(fold).join('\r\n') + '\r\n';
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
|
||||||
|
// DATETIME representa hora civil de America/Guayaquil (UTC-5 sin DST). Date en UTC se usa
|
||||||
|
// solo para aritmética de calendario; no convierte estos valores a la zona del servidor.
|
||||||
|
const parse = (s) => new Date(`${s.replace(' ', 'T')}Z`);
|
||||||
|
const format = (d) => d.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
export const addMinutes = (s, n) => format(new Date(parse(s).getTime() + n * 60000));
|
||||||
|
export const durationMinutes = (a, b) => (parse(b) - parse(a)) / 60000;
|
||||||
|
export const guayaquilNow = () => format(new Date(Date.now() - 5 * 3600000));
|
||||||
|
|
||||||
|
function* occurrenceStarts(start, rule, max = 365) {
|
||||||
|
const first = parse(start);
|
||||||
|
if (!rule) { yield start; return; }
|
||||||
|
let emitted = 0;
|
||||||
|
const accept = (d) => d >= first && d.getUTCFullYear() <= 9999 && (!rule.until || format(d).slice(0, 10) <= rule.until);
|
||||||
|
if (rule.frequency === 'daily') {
|
||||||
|
for (let n = 0; emitted < max && (!rule.count || emitted < rule.count); n++) {
|
||||||
|
const d = new Date(first.getTime() + n * rule.interval * 86400000);
|
||||||
|
if (!accept(d)) break;
|
||||||
|
emitted++; yield format(d);
|
||||||
|
}
|
||||||
|
} else if (rule.frequency === 'weekly') {
|
||||||
|
const monday = new Date(first.getTime() - ((first.getUTCDay() + 6) % 7) * 86400000);
|
||||||
|
for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) {
|
||||||
|
const week = new Date(monday.getTime() + block * rule.interval * 7 * 86400000);
|
||||||
|
if (week.getUTCFullYear() > 9999 || (rule.until && format(week).slice(0, 10) > rule.until)) break;
|
||||||
|
for (const weekday of rule.weekdays) {
|
||||||
|
const d = new Date(week.getTime() + weekday * 86400000);
|
||||||
|
if (d < first) continue;
|
||||||
|
if (!accept(d)) return;
|
||||||
|
emitted++; yield format(d);
|
||||||
|
if (emitted >= max || (rule.count && emitted >= rule.count)) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (rule.frequency === 'monthly') {
|
||||||
|
const time = [first.getUTCHours(), first.getUTCMinutes(), first.getUTCSeconds()];
|
||||||
|
for (let block = 0; emitted < max && (!rule.count || emitted < rule.count); block++) {
|
||||||
|
const totalMonths = first.getUTCFullYear() * 12 + first.getUTCMonth() + block * rule.interval;
|
||||||
|
const year = Math.floor(totalMonths / 12), month = totalMonths % 12;
|
||||||
|
if (year > 9999 || (rule.until && `${year}-${String(month+1).padStart(2,'0')}-01` > rule.until)) break;
|
||||||
|
const d = new Date(Date.UTC(year, month, first.getUTCDate(), ...time));
|
||||||
|
if (d.getUTCMonth() !== month) continue; // p. ej. no hay 31 de febrero.
|
||||||
|
if (!accept(d)) break;
|
||||||
|
emitted++; yield format(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRecurrence(raw, start) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const { frequency, interval = 1, weekdays, until, count } = raw;
|
||||||
|
if (!['daily', 'weekly', 'monthly'].includes(frequency) || !Number.isInteger(interval) || interval < 1 || interval > 365) throw new HttpError(400, 'Recurrencia invalida');
|
||||||
|
if ((!!until) === (!!count)) throw new HttpError(400, 'Indica fecha final o numero de ocurrencias');
|
||||||
|
if (count && (!Number.isInteger(count) || count < 1 || count > 365)) throw new HttpError(400, 'Maximo 365 ocurrencias');
|
||||||
|
if (until && (typeof until !== 'string' || !/^\d{4}-\d\d-\d\d$/.test(until) || Number.isNaN(Date.parse(`${until}T00:00:00Z`)) || new Date(`${until}T00:00:00Z`).toISOString().slice(0,10) !== until || until < start.slice(0, 10))) throw new HttpError(400, 'Fecha final de recurrencia invalida');
|
||||||
|
if (frequency === 'weekly' && (!Array.isArray(weekdays) || !weekdays.length || weekdays.some((v) => !Number.isInteger(v) || v < 0 || v > 6))) throw new HttpError(400, 'Dias semanales invalidos');
|
||||||
|
if (frequency === 'weekly' && !weekdays.includes((parse(start).getUTCDay() + 6) % 7)) throw new HttpError(400, 'El primer dia debe estar incluido');
|
||||||
|
const rule = { frequency, interval, weekdays: frequency === 'weekly' ? [...new Set(weekdays)].sort() : [], until: until || null, count: count || null };
|
||||||
|
const occurrences = [...occurrenceStarts(start, rule, 366)];
|
||||||
|
if (occurrences.length > 365) throw new HttpError(400, 'Maximo 365 ocurrencias');
|
||||||
|
if (count && occurrences.length < count) throw new HttpError(400, 'La serie supera el rango de fechas admitido');
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expand(meeting, from, to) {
|
||||||
|
if (meeting.cancelled_at) return [];
|
||||||
|
const rule = meeting.recurrence_json ? JSON.parse(meeting.recurrence_json) : null;
|
||||||
|
const duration = durationMinutes(meeting.starts_at, meeting.ends_at);
|
||||||
|
const exclusions = new Set(meeting.exceptions || []);
|
||||||
|
const out = [];
|
||||||
|
for (const start of occurrenceStarts(meeting.starts_at, rule)) {
|
||||||
|
if (start >= to) break;
|
||||||
|
const end = addMinutes(start, duration);
|
||||||
|
if (end > from && !exclusions.has(start)) out.push({ ...meeting, occurrence_start: start, occurrence_end: end });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import * as notificationsRepo from '../notifications/repository.js';
|
||||||
|
|
||||||
|
const base = `SELECT m.*, u.name AS organizer_name, u.email AS organizer_email, p.name AS project_name FROM meetings m
|
||||||
|
JOIN users u ON u.id=m.organizer_id LEFT JOIN projects p ON p.id=m.project_id`;
|
||||||
|
|
||||||
|
export async function findById(id) {
|
||||||
|
const [[row]] = await pool.query(`${base} WHERE m.id=?`, [id]);
|
||||||
|
if (!row) return null;
|
||||||
|
const [users] = await pool.query(`SELECT mp.user_id, mp.rsvp, u.name, u.email FROM meeting_participants mp JOIN users u ON u.id=mp.user_id WHERE mp.meeting_id=?`, [id]);
|
||||||
|
const [externals] = await pool.query('SELECT email FROM meeting_external_participants WHERE meeting_id=?', [id]);
|
||||||
|
const [exceptions] = await pool.query('SELECT occurrence_start FROM meeting_exceptions WHERE meeting_id=?', [id]);
|
||||||
|
return { ...row, participants: users, external_emails: externals.map((r) => r.email), exceptions: exceptions.map((r) => r.occurrence_start) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findCandidates(from, to) {
|
||||||
|
const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ORDER BY m.starts_at`, [to, from]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findParticipantConflicts(from, to, ids, excludeId = null) {
|
||||||
|
const [rows] = await pool.query(`SELECT m.*, mp.user_id AS conflict_user_id, u.name AS conflict_name
|
||||||
|
FROM meeting_participants mp JOIN meetings m ON m.id=mp.meeting_id JOIN users u ON u.id=mp.user_id
|
||||||
|
WHERE mp.user_id IN (${ids.map(() => '?').join(',')}) AND m.cancelled_at IS NULL AND m.starts_at<?
|
||||||
|
AND (m.recurrence_json IS NOT NULL OR m.ends_at>?) AND (? IS NULL OR m.id<>?)
|
||||||
|
ORDER BY m.starts_at`, [...ids, to, from, excludeId, excludeId]);
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!grouped.has(row.id)) grouped.set(row.id, { ...row, conflict_names: [] });
|
||||||
|
grouped.get(row.id).conflict_names.push(row.conflict_name);
|
||||||
|
}
|
||||||
|
return [...grouped.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendario personal: solo reuniones que el usuario organiza o a las que fue invitado, sea cual
|
||||||
|
// sea su rol. Con projectId (pestana Calendario del proyecto, acceso ya validado en el service)
|
||||||
|
// devuelve todas las reuniones vinculadas a ese proyecto.
|
||||||
|
export async function findVisibleCandidates(from, to, user, projectId = null) {
|
||||||
|
const scope = projectId ? 'AND m.project_id=?' : 'AND (m.organizer_id=? OR EXISTS (SELECT 1 FROM meeting_participants mp WHERE mp.meeting_id=m.id AND mp.user_id=?))';
|
||||||
|
const [rows] = await pool.query(`${base} WHERE m.cancelled_at IS NULL AND m.starts_at < ? AND (m.recurrence_json IS NOT NULL OR m.ends_at > ?) ${scope} ORDER BY m.starts_at`, [to, from, ...(projectId ? [projectId] : [user.id, user.id])]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exceptionsFor(ids) {
|
||||||
|
if (!ids.length) return new Map();
|
||||||
|
const [rows] = await pool.query(`SELECT meeting_id, occurrence_start FROM meeting_exceptions WHERE meeting_id IN (${ids.map(() => '?').join(',')})`, ids);
|
||||||
|
const map = new Map();
|
||||||
|
for (const row of rows) { if (!map.has(row.meeting_id)) map.set(row.meeting_id, []); map.get(row.meeting_id).push(row.occurrence_start); }
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activeUsers(ids) {
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const [rows] = await pool.query(`SELECT id,name,email FROM users WHERE id IN (${ids.map(() => '?').join(',')}) AND is_active=1 AND status='active'`, ids);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceParticipants(conn, id, data) {
|
||||||
|
if (data.participantUserIds.length) await conn.query(`DELETE FROM meeting_participants WHERE meeting_id=? AND user_id NOT IN (${data.participantUserIds.map(() => '?').join(',')})`, [id, ...data.participantUserIds]);
|
||||||
|
else await conn.query('DELETE FROM meeting_participants WHERE meeting_id=?', [id]);
|
||||||
|
await conn.query('DELETE FROM meeting_external_participants WHERE meeting_id=?', [id]);
|
||||||
|
for (const userId of data.participantUserIds) await conn.query('INSERT IGNORE INTO meeting_participants (meeting_id,user_id) VALUES (?,?)', [id, userId]);
|
||||||
|
for (const email of data.externalEmails) await conn.query('INSERT INTO meeting_external_participants (meeting_id,email) VALUES (?,?)', [id, email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(data, organizerId, uid) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [result] = await conn.query(`INSERT INTO meetings (uid,title,description,starts_at,ends_at,all_day,location,video_url,project_id,organizer_id,reminder_minutes,recurrence_json)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [uid, data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, organizerId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null]);
|
||||||
|
await replaceParticipants(conn, result.insertId, data);
|
||||||
|
await conn.commit();
|
||||||
|
return findById(result.insertId);
|
||||||
|
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(id, data, version, resetExceptions) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [result] = await conn.query(`UPDATE meetings SET title=?,description=?,starts_at=?,ends_at=?,all_day=?,location=?,video_url=?,project_id=?,reminder_minutes=?,recurrence_json=?,sequence_number=sequence_number+1,edit_version=edit_version+1
|
||||||
|
WHERE id=? AND edit_version=? AND cancelled_at IS NULL`, [data.title, data.description, data.startsAt, data.endsAt, data.allDay, data.location, data.videoUrl, data.projectId, data.reminderMinutes, data.recurrence ? JSON.stringify(data.recurrence) : null, id, version]);
|
||||||
|
if (!result.affectedRows) { await conn.rollback(); return null; }
|
||||||
|
await replaceParticipants(conn, id, data);
|
||||||
|
if (resetExceptions) await conn.query('DELETE FROM meeting_exceptions WHERE meeting_id=?', [id]);
|
||||||
|
await conn.commit();
|
||||||
|
return findById(id);
|
||||||
|
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cancel(id) {
|
||||||
|
const [result] = await pool.query('UPDATE meetings SET cancelled_at=NOW(),sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=? AND cancelled_at IS NULL', [id]);
|
||||||
|
return result.affectedRows ? findById(id) : null;
|
||||||
|
}
|
||||||
|
export async function cancelOccurrence(id, date, userId) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [[row]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]);
|
||||||
|
if (!row || row.cancelled_at) { await conn.rollback(); return null; }
|
||||||
|
const [insert] = await conn.query('INSERT IGNORE INTO meeting_exceptions (meeting_id,occurrence_start,created_by) VALUES (?,?,?)', [id, date, userId]);
|
||||||
|
if (!insert.affectedRows) { await conn.rollback(); return null; }
|
||||||
|
await conn.query('UPDATE meetings SET sequence_number=sequence_number+1,edit_version=edit_version+1 WHERE id=?', [id]);
|
||||||
|
await conn.commit();
|
||||||
|
return findById(id);
|
||||||
|
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
|
||||||
|
}
|
||||||
|
export async function rsvp(id, userId, status) {
|
||||||
|
const [result] = await pool.query('UPDATE meeting_participants SET rsvp=?,responded_at=NOW() WHERE meeting_id=? AND user_id=?', [status, id, userId]);
|
||||||
|
return !!result.affectedRows;
|
||||||
|
}
|
||||||
|
// Capas personales del calendario: lo que tiene al usuario como responsable (actividades, tareas)
|
||||||
|
// y los proyectos donde es dueno o miembro.
|
||||||
|
export async function activityDue(from, to, user) {
|
||||||
|
const [rows] = await pool.query(`SELECT a.id,a.name,a.due_date,a.project_id,p.name AS project_name,st.is_closed,a.created_by FROM activities a JOIN projects p ON p.id=a.project_id JOIN activity_statuses st ON st.id=a.status_id
|
||||||
|
WHERE a.due_date>=? AND a.due_date<? AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND a.responsible_user_id=?
|
||||||
|
ORDER BY a.due_date,a.id LIMIT 1000`, [from, to, user.id]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
export async function taskDue(from, to, user) {
|
||||||
|
const [rows] = await pool.query(`SELECT t.id,t.name,t.due_date,t.is_done,a.created_by,a.id AS activity_id,a.name AS activity_name,p.id AS project_id,p.name AS project_name
|
||||||
|
FROM tasks t JOIN activities a ON a.id=t.activity_id JOIN projects p ON p.id=a.project_id
|
||||||
|
WHERE t.due_date>=? AND t.due_date<? AND t.deleted_at IS NULL AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND t.responsible_user_id=?
|
||||||
|
ORDER BY t.due_date,t.id LIMIT 1000`, [from, to, user.id]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
export async function projectDates(from, to, user) {
|
||||||
|
const [rows] = await pool.query(`SELECT p.id,p.code,p.name,p.start_date,p.estimated_end_date,st.is_closed,p.owner_user_id FROM projects p JOIN project_statuses st ON st.id=p.status_id
|
||||||
|
WHERE p.deleted_at IS NULL AND (p.owner_user_id=? OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id=p.id AND pm.user_id=?))
|
||||||
|
AND ((p.start_date>=? AND p.start_date<?) OR (p.estimated_end_date>=? AND p.estimated_end_date<?))
|
||||||
|
ORDER BY p.id LIMIT 500`, [user.id, user.id, from, to, from, to]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
export async function sendReminderOnce(id, start, recipientIds, title) {
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [[meeting]] = await conn.query('SELECT cancelled_at FROM meetings WHERE id=? FOR UPDATE', [id]);
|
||||||
|
const [[exception]] = await conn.query('SELECT 1 AS excluded FROM meeting_exceptions WHERE meeting_id=? AND occurrence_start=?', [id, start]);
|
||||||
|
if (!meeting || meeting.cancelled_at || exception) { await conn.rollback(); return false; }
|
||||||
|
const [r] = await conn.query('INSERT IGNORE INTO meeting_reminders (meeting_id,occurrence_start) VALUES (?,?)', [id, start]);
|
||||||
|
if (!r.affectedRows) { await conn.rollback(); return false; }
|
||||||
|
for (const userId of recipientIds) await notificationsRepo.create({ userId, type: 'meeting_reminder', message: title.slice(0,255), entityType: 'meeting', entityId: id }, conn);
|
||||||
|
await conn.commit();
|
||||||
|
return true;
|
||||||
|
} catch (err) { await conn.rollback(); throw err; } finally { conn.release(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
|
import { getMeetings, getActivities, getRecurring, getTasks, getProjects, getMeeting, postMeeting, putMeeting, deleteMeeting, postCancelOccurrence, putRsvp } from './controller.js';
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth, requireModule('calendar'));
|
||||||
|
router.get('/', getMeetings);
|
||||||
|
router.get('/activities', getActivities);
|
||||||
|
router.get('/recurring', getRecurring);
|
||||||
|
router.get('/tasks', getTasks);
|
||||||
|
router.get('/projects', getProjects);
|
||||||
|
router.post('/', postMeeting);
|
||||||
|
router.get('/:id', getMeeting);
|
||||||
|
router.put('/:id', putMeeting);
|
||||||
|
router.delete('/:id', deleteMeeting);
|
||||||
|
router.post('/:id/occurrences/:date/cancel', postCancelOccurrence);
|
||||||
|
router.put('/:id/rsvp', putRsvp);
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as repo from './repository.js';
|
||||||
|
import * as projectsRepo from '../projects/repository.js';
|
||||||
|
import { canReadProject } from '../projects/service.js';
|
||||||
|
import * as recurringRepo from '../recurring-tasks/repository.js';
|
||||||
|
import { createNotification } from '../notifications/service.js';
|
||||||
|
import { sendMail } from '../../utils/mailer.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
|
import { expand, validateRecurrence, durationMinutes, guayaquilNow, addMinutes } from './recurrence.js';
|
||||||
|
import { buildIcs } from './ical.js';
|
||||||
|
|
||||||
|
const DATE = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/;
|
||||||
|
const EMAIL = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i;
|
||||||
|
const ROLES = ['admin', 'superadmin'];
|
||||||
|
const htmlEscape = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[c]);
|
||||||
|
const normalizeDate = (s) => {
|
||||||
|
if (typeof s !== 'string' || !DATE.test(s) || Number(s.slice(0, 4)) < 1000) return null;
|
||||||
|
const short = `${s.slice(0, 16)}:00`;
|
||||||
|
const date = new Date(`${short.replace(' ', 'T')}Z`);
|
||||||
|
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 19).replace('T', ' ') === short ? short : null;
|
||||||
|
};
|
||||||
|
function range(from, to) {
|
||||||
|
const a = normalizeDate(from), b = normalizeDate(to);
|
||||||
|
if (!a || !b || a >= b || durationMinutes(a, b) > 93 * 1440) throw new HttpError(400, 'Rango invalido (maximo 93 dias)');
|
||||||
|
return [a, b];
|
||||||
|
}
|
||||||
|
async function normalize(raw, actor) {
|
||||||
|
const title = String(raw.title || '').trim();
|
||||||
|
const description = String(raw.description || '').trim();
|
||||||
|
const location = String(raw.location || '').trim();
|
||||||
|
let videoUrl = String(raw.videoUrl || '').trim();
|
||||||
|
const startsAt = normalizeDate(raw.startsAt), endsAt = normalizeDate(raw.endsAt);
|
||||||
|
if (!title || title.length > 160 || /[\r\n\u0000]/.test(title) || description.length > 5000 || location.length > 255 || videoUrl.length > 2048) throw new HttpError(400, 'Titulo o texto invalido');
|
||||||
|
if (!startsAt || !endsAt || endsAt <= startsAt || durationMinutes(startsAt, endsAt) > 30 * 1440) throw new HttpError(400, 'Horario invalido');
|
||||||
|
if (videoUrl && (!/^https?:\/\//i.test(videoUrl) || /[\u0000-\u001f\u007f]/.test(videoUrl))) throw new HttpError(400, 'Enlace http/https invalido');
|
||||||
|
try { if (videoUrl) videoUrl = new URL(videoUrl).href; } catch { throw new HttpError(400, 'Enlace invalido'); }
|
||||||
|
const reminderMinutes = raw.reminderMinutes === null || raw.reminderMinutes === '' || raw.reminderMinutes === undefined ? null : Number(raw.reminderMinutes);
|
||||||
|
if (reminderMinutes !== null && ![5,10,15,30,60,1440].includes(reminderMinutes)) throw new HttpError(400, 'Recordatorio invalido');
|
||||||
|
const projectId = raw.projectId ? Number(raw.projectId) : null;
|
||||||
|
if (projectId !== null && (!Number.isSafeInteger(projectId) || projectId <= 0)) throw new HttpError(400, 'Proyecto invalido');
|
||||||
|
if (projectId) {
|
||||||
|
if (!actor.modules.includes('projects')) throw new HttpError(403, 'Sin acceso al modulo Proyectos');
|
||||||
|
const project = await projectsRepo.findById(projectId);
|
||||||
|
if (!project || !(await canReadProject(actor, project))) throw new HttpError(404, 'Proyecto no encontrado');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(raw.participantUserIds || []) || !Array.isArray(raw.externalEmails || [])) throw new HttpError(400, 'Participantes invalidos');
|
||||||
|
const ids = [...new Set((raw.participantUserIds || []).map(Number))];
|
||||||
|
const emails = [...new Set((raw.externalEmails || []).map((e) => String(e).trim().toLowerCase()))];
|
||||||
|
if (ids.length + emails.length > 50 || ids.some((id) => !Number.isSafeInteger(id) || id <= 0) || emails.some((e) => e.length > 160 || !EMAIL.test(e))) throw new HttpError(400, 'Participantes invalidos (maximo 50)');
|
||||||
|
const users = await repo.activeUsers(ids);
|
||||||
|
if (users.length !== ids.length) throw new HttpError(400, 'Selecciona solo usuarios activos');
|
||||||
|
return { title, description, location, videoUrl: videoUrl || null, startsAt, endsAt, allDay: !!raw.allDay, reminderMinutes, projectId, participantUserIds: ids.filter((id) => id !== actor.id), externalEmails: emails, recurrence: validateRecurrence(raw.recurrence, startsAt) };
|
||||||
|
}
|
||||||
|
async function visible(meeting, user) {
|
||||||
|
if (meeting.organizer_id === user.id || meeting.participants.some((p) => p.user_id === user.id)) return true;
|
||||||
|
if (!meeting.project_id || !user.modules.includes('projects')) return false;
|
||||||
|
const project = await projectsRepo.findById(meeting.project_id);
|
||||||
|
return !!project && canReadProject(user, project);
|
||||||
|
}
|
||||||
|
export async function detail(id, user) {
|
||||||
|
if (!Number.isSafeInteger(id) || id <= 0) throw new HttpError(404, 'Reunion no encontrada');
|
||||||
|
const row = await repo.findById(id);
|
||||||
|
if (!row || !(await visible(row, user))) throw new HttpError(404, 'Reunion no encontrada');
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function canManage(row, user) { return row.organizer_id === user.id || ROLES.includes(user.roleCode); }
|
||||||
|
async function notify(row, action, extraIds = []) {
|
||||||
|
const recipients = [...new Set([...row.participants.map((p) => p.user_id), ...extraIds])];
|
||||||
|
await Promise.allSettled(recipients.map((userId) => createNotification({ userId, type: `meeting_${action}`, message: row.title.slice(0, 255), entityType: 'meeting', entityId: row.id })));
|
||||||
|
}
|
||||||
|
async function mail(row, method, recipientsOverride = null, occurrenceStart = null) {
|
||||||
|
const recipients = recipientsOverride || [...new Set([row.organizer_email, ...row.participants.map((p) => p.email), ...row.external_emails].filter(Boolean))];
|
||||||
|
const html = `<p><strong>${htmlEscape(row.title)}</strong></p><p>${htmlEscape(row.description).replace(/\n/g, '<br>')}</p><p>${htmlEscape(occurrenceStart || row.starts_at)} – ${htmlEscape(row.ends_at)} (America/Guayaquil)</p><p>${htmlEscape(row.location)}</p>${row.video_url ? `<p><a href="${htmlEscape(row.video_url)}">${htmlEscape(row.video_url)}</a></p>` : ''}`;
|
||||||
|
await Promise.allSettled(recipients.map((to) => sendMail({ to, subject: `${method === 'CANCEL' ? 'Reunión cancelada / Meeting cancelled' : 'Invitación / Invitation'}: ${row.title}`, html, icalEvent: { method, content: buildIcs(row, method, occurrenceStart), filename: 'reunion.ics' } })));
|
||||||
|
}
|
||||||
|
function dispatch(row, action, method = 'REQUEST', extraIds = []) {
|
||||||
|
setImmediate(() => { void Promise.allSettled([notify(row, action, extraIds), mail(row, method)]); });
|
||||||
|
}
|
||||||
|
function deferMail(row, method, recipients = null, occurrenceStart = null) {
|
||||||
|
setImmediate(() => { void mail(row, method, recipients, occurrenceStart).catch((err) => console.error('[meetings] correo:', err.message)); });
|
||||||
|
}
|
||||||
|
function overlaps(a, b) {
|
||||||
|
let i = 0, j = 0;
|
||||||
|
while (i < a.length && j < b.length) {
|
||||||
|
if (a[i].occurrence_start < b[j].occurrence_end && a[i].occurrence_end > b[j].occurrence_start) return true;
|
||||||
|
if (a[i].occurrence_end <= b[j].occurrence_end) i++; else j++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async function conflictWarnings(data, excludeId) {
|
||||||
|
const ids = data.participantUserIds;
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const proposed = expand({ starts_at: data.startsAt, ends_at: data.endsAt, recurrence_json: data.recurrence ? JSON.stringify(data.recurrence) : null, exceptions: [] }, data.startsAt, '9999-12-31 23:59:59');
|
||||||
|
const end = proposed.at(-1)?.occurrence_end || data.endsAt;
|
||||||
|
const rows = await repo.findParticipantConflicts(data.startsAt, end, ids, excludeId || null);
|
||||||
|
const exceptionMap = await repo.exceptionsFor(rows.map((r) => r.id));
|
||||||
|
const conflicts = new Set();
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = expand({ ...row, exceptions: exceptionMap.get(row.id) || [] }, data.startsAt, end);
|
||||||
|
if (overlaps(proposed, existing)) row.conflict_names.forEach((name) => conflicts.add(name));
|
||||||
|
}
|
||||||
|
return [...conflicts];
|
||||||
|
}
|
||||||
|
export async function list(query, user) {
|
||||||
|
const [from, to] = range(query.from, query.to);
|
||||||
|
const page = Number(query.page || 1), pageSize = Number(query.pageSize || 1500);
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 1500 || !Number.isSafeInteger((page-1)*pageSize)) throw new HttpError(400, 'Paginacion invalida');
|
||||||
|
let projectId = null;
|
||||||
|
if (query.projectId) {
|
||||||
|
projectId = Number(query.projectId);
|
||||||
|
const project = Number.isSafeInteger(projectId) && projectId > 0 && user.modules.includes('projects') ? await projectsRepo.findById(projectId) : null;
|
||||||
|
if (!project || !(await canReadProject(user, project))) throw new HttpError(404, 'Proyecto no encontrado');
|
||||||
|
}
|
||||||
|
const rows = await repo.findVisibleCandidates(from, to, user, projectId);
|
||||||
|
const exceptions = await repo.exceptionsFor(rows.map((r) => r.id));
|
||||||
|
const all = rows.flatMap((r) => expand({ ...r, exceptions: exceptions.get(r.id) || [] }, from, to))
|
||||||
|
.sort((a, b) => a.occurrence_start.localeCompare(b.occurrence_start) || a.id - b.id);
|
||||||
|
// shared = me la compartieron (organiza otra persona); el calendario permite filtrar por origen.
|
||||||
|
const items = all.slice((page-1)*pageSize, page*pageSize).map((m) => ({ ...m, shared: m.organizer_id !== user.id }));
|
||||||
|
return { items, total: all.length, page, pageSize };
|
||||||
|
}
|
||||||
|
export async function activities(query, user) {
|
||||||
|
const [from, to] = range(query.from, query.to);
|
||||||
|
if (!user.modules.includes('projects')) return [];
|
||||||
|
const today = guayaquilNow().slice(0, 10);
|
||||||
|
return (await repo.activityDue(from.slice(0, 10), to.slice(0, 10), user))
|
||||||
|
.map((a) => ({ ...a, is_closed: !!a.is_closed, overdue: !a.is_closed && String(a.due_date).slice(0, 10) < today, shared: a.created_by !== user.id }));
|
||||||
|
}
|
||||||
|
export async function tasks(query, user) {
|
||||||
|
const [from, to] = range(query.from, query.to);
|
||||||
|
if (!user.modules.includes('projects')) return [];
|
||||||
|
const today = guayaquilNow().slice(0, 10);
|
||||||
|
return (await repo.taskDue(from.slice(0, 10), to.slice(0, 10), user))
|
||||||
|
.map((t) => ({ ...t, is_closed: !!t.is_done, overdue: !t.is_done && String(t.due_date).slice(0, 10) < today, shared: t.created_by !== user.id })); // tasks no guarda creador: se usa el de la actividad
|
||||||
|
}
|
||||||
|
// Un evento por fecha del proyecto (inicio y fin estimado) que caiga en el rango.
|
||||||
|
export async function projects(query, user) {
|
||||||
|
const [from, to] = range(query.from, query.to);
|
||||||
|
if (!user.modules.includes('projects')) return [];
|
||||||
|
const a = from.slice(0, 10), b = to.slice(0, 10), today = guayaquilNow().slice(0, 10);
|
||||||
|
const out = [];
|
||||||
|
for (const p of await repo.projectDates(a, b, user)) {
|
||||||
|
const start = p.start_date && String(p.start_date).slice(0, 10), end = p.estimated_end_date && String(p.estimated_end_date).slice(0, 10);
|
||||||
|
if (start && start >= a && start < b) out.push({ id: p.id, code: p.code, name: p.name, due_date: start, milestone: 'start', is_closed: !!p.is_closed, overdue: false, shared: p.owner_user_id !== user.id });
|
||||||
|
if (end && end >= a && end < b) out.push({ id: p.id, code: p.code, name: p.name, due_date: end, milestone: 'end', is_closed: !!p.is_closed, overdue: !p.is_closed && end < today, shared: p.owner_user_id !== user.id });
|
||||||
|
}
|
||||||
|
return out.sort((x, y) => x.due_date.localeCompare(y.due_date) || x.id - y.id);
|
||||||
|
}
|
||||||
|
// Proximos vencimientos de tareas recurrentes en el rango. Replica advanceCycle (DATE_ADD
|
||||||
|
// sucesivo de MariaDB: 31-ene + 1 mes = 28-feb y de ahi 28-mar), para que el calendario muestre
|
||||||
|
// las mismas fechas que tendra la tarea al ir completando ciclos. El primero es el pendiente real.
|
||||||
|
function addInterval(day, unit, value) {
|
||||||
|
const [y, m, d] = day.split('-').map(Number);
|
||||||
|
if (unit === 'days') return new Date(Date.UTC(y, m - 1, d + value)).toISOString().slice(0, 10);
|
||||||
|
const months = (m - 1) + (unit === 'years' ? value * 12 : value);
|
||||||
|
const ty = y + Math.floor(months / 12), tm = months % 12;
|
||||||
|
const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate();
|
||||||
|
return new Date(Date.UTC(ty, tm, Math.min(d, last))).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
export async function recurring(query, user) {
|
||||||
|
const [from, to] = range(query.from, query.to);
|
||||||
|
if (!user.modules.includes('recurring_tasks')) return [];
|
||||||
|
const first = from.slice(0, 10), last = to.slice(0, 10), today = guayaquilNow().slice(0, 10);
|
||||||
|
const out = [];
|
||||||
|
// Personal: las que el usuario creo o de las que es responsable (se las compartieron), cualquier rol.
|
||||||
|
// can_open indica si puede abrirla en Tareas recurrentes (alli solo entra el creador o admin).
|
||||||
|
const mine = (await recurringRepo.findAll()).filter((t) => t.created_by === user.id || t.responsible_user_id === user.id);
|
||||||
|
for (const task of mine) {
|
||||||
|
if (!task.is_active || !task.next_due_date) continue;
|
||||||
|
const canOpen = task.created_by === user.id || ['superadmin', 'admin', 'it_manager'].includes(user.roleCode);
|
||||||
|
let day = String(task.next_due_date).slice(0, 10);
|
||||||
|
for (let n = 0; n < 400 && day < last && (!task.end_date || day <= String(task.end_date).slice(0, 10)); n++) {
|
||||||
|
if (day >= first) out.push({ id: task.id, name: task.name, project_id: task.project_id, project_name: task.project_name, responsible_name: task.responsible_name, can_open: canOpen, shared: task.created_by !== user.id, due_date: day, is_next: n === 0, overdue: n === 0 && day < today });
|
||||||
|
day = addInterval(day, task.interval_unit, task.interval_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.due_date.localeCompare(b.due_date) || a.id - b.id);
|
||||||
|
}
|
||||||
|
export async function create(raw, user) {
|
||||||
|
const data = await normalize(raw, user);
|
||||||
|
const warnings = await conflictWarnings(data);
|
||||||
|
if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings };
|
||||||
|
const row = await repo.create(data, user.id, randomUUID());
|
||||||
|
await logAudit({ userId: user.id, action: 'create', entityType: 'meeting', entityId: row.id });
|
||||||
|
dispatch(row, 'created');
|
||||||
|
return { meeting: row, warnings };
|
||||||
|
}
|
||||||
|
export async function update(id, raw, user) {
|
||||||
|
const before = await detail(id, user);
|
||||||
|
if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para editar');
|
||||||
|
if (before.cancelled_at) throw new HttpError(409, 'Reunion cancelada');
|
||||||
|
requireEditVersion(raw.editVersion);
|
||||||
|
const data = await normalize(raw, user);
|
||||||
|
const warnings = await conflictWarnings(data, id);
|
||||||
|
if (warnings.length && raw.confirmConflicts !== true) return { requiresConfirmation: true, warnings };
|
||||||
|
const resetExceptions = before.starts_at !== data.startsAt || before.ends_at !== data.endsAt || before.recurrence_json !== (data.recurrence ? JSON.stringify(data.recurrence) : null);
|
||||||
|
const row = requireUpdated(await repo.update(id, data, raw.editVersion, resetExceptions));
|
||||||
|
await logAudit({ userId: user.id, action: 'update', entityType: 'meeting', entityId: id });
|
||||||
|
const currentEmails = new Set([...row.participants.map((p) => p.email), ...row.external_emails]);
|
||||||
|
const removedEmails = [...new Set([...before.participants.map((p) => p.email), ...before.external_emails])].filter((email) => !currentEmails.has(email));
|
||||||
|
const currentIds = new Set(row.participants.map((p) => p.user_id));
|
||||||
|
const removedIds = before.participants.filter((p) => !currentIds.has(p.user_id)).map((p) => p.user_id);
|
||||||
|
if (removedEmails.length) deferMail(row, 'CANCEL', removedEmails);
|
||||||
|
if (before.starts_at !== row.starts_at || before.ends_at !== row.ends_at || before.location !== row.location || before.video_url !== row.video_url || before.recurrence_json !== row.recurrence_json || removedIds.length || before.participants.length !== row.participants.length) dispatch(row, 'updated', 'REQUEST', removedIds);
|
||||||
|
else deferMail(row, 'REQUEST');
|
||||||
|
return { meeting: row, warnings };
|
||||||
|
}
|
||||||
|
export async function cancel(id, user) {
|
||||||
|
const before = await detail(id, user);
|
||||||
|
if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar');
|
||||||
|
if (before.cancelled_at) throw new HttpError(409, 'Reunion ya cancelada');
|
||||||
|
const row = requireUpdated(await repo.cancel(id));
|
||||||
|
await logAudit({ userId: user.id, action: 'cancel', entityType: 'meeting', entityId: id });
|
||||||
|
dispatch(row, 'cancelled', 'CANCEL');
|
||||||
|
return { cancelled: true };
|
||||||
|
}
|
||||||
|
export async function cancelOccurrence(id, date, user) {
|
||||||
|
const before = await detail(id, user);
|
||||||
|
if (!canManage(before, user)) throw new HttpError(403, 'Sin permiso para cancelar');
|
||||||
|
if (!before.recurrence_json) throw new HttpError(400, 'Esta reunion no es recurrente');
|
||||||
|
const start = normalizeDate(date);
|
||||||
|
if (!start || !expand(before, start, addMinutes(start, 1)).some((r) => r.occurrence_start === start)) throw new HttpError(400, 'Ocurrencia invalida');
|
||||||
|
const row = requireUpdated(await repo.cancelOccurrence(id, start, user.id));
|
||||||
|
await logAudit({ userId: user.id, action: 'cancel_occurrence', entityType: 'meeting', entityId: id, detail: { start } });
|
||||||
|
dispatch(row, 'occurrence_cancelled');
|
||||||
|
deferMail(row, 'CANCEL', null, start);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
export async function rsvp(id, status, user) {
|
||||||
|
if (!['accepted','declined','tentative'].includes(status)) throw new HttpError(400, 'Respuesta invalida');
|
||||||
|
const row = await detail(id, user);
|
||||||
|
if (row.cancelled_at) throw new HttpError(409, 'Reunion cancelada');
|
||||||
|
if (!(await repo.rsvp(id, user.id, status))) throw new HttpError(403, 'Solo el participante puede responder');
|
||||||
|
await logAudit({ userId: user.id, action: 'rsvp', entityType: 'meeting', entityId: id, detail: { status } });
|
||||||
|
if (row.organizer_id !== user.id) {
|
||||||
|
setImmediate(() => { void createNotification({ userId: row.organizer_id, type: `meeting_rsvp_${status}`, message: `${user.name || ''} · ${row.title}`.slice(0, 255), entityType: 'meeting', entityId: id }).catch(() => {}); });
|
||||||
|
}
|
||||||
|
return repo.findById(id);
|
||||||
|
}
|
||||||
|
export async function sendDueReminders() {
|
||||||
|
const now = guayaquilNow(), end = addMinutes(now, 1441);
|
||||||
|
const rows = await repo.findCandidates(now, end);
|
||||||
|
const map = await repo.exceptionsFor(rows.map((r) => r.id));
|
||||||
|
let count = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.reminder_minutes) continue;
|
||||||
|
const occurrences = expand({ ...row, exceptions: map.get(row.id) || [] }, now, end);
|
||||||
|
for (const occurrence of occurrences) {
|
||||||
|
const due = addMinutes(occurrence.occurrence_start, -row.reminder_minutes);
|
||||||
|
if (due > now || due < addMinutes(now, -2)) continue;
|
||||||
|
const full = await repo.findById(row.id);
|
||||||
|
const recipientIds = [...new Set([row.organizer_id, ...full.participants.map((p) => p.user_id)])];
|
||||||
|
if (await repo.sendReminderOnce(row.id, occurrence.occurrence_start, recipientIds, row.title)) count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ export async function countUnread(userId) {
|
|||||||
return rows[0].c;
|
return rows[0].c;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create({ userId, type, message, entityType, entityId }) {
|
export async function create({ userId, type, message, entityType, entityId }, connection = pool) {
|
||||||
const [result] = await pool.query(
|
const [result] = await connection.query(
|
||||||
'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO notifications (user_id, type, message, entity_type, entity_id) VALUES (?, ?, ?, ?, ?)',
|
||||||
[userId, type, message, entityType || null, entityId || null]
|
[userId, type, message, entityType || null, entityId || null]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { linkEmployeeToUser, requireFreeEmployee } from '../integrations/service.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
import { requireEditVersion, requireUpdated } from '../../utils/editVersion.js';
|
||||||
@@ -57,10 +58,20 @@ export async function createUser(payload, actor) {
|
|||||||
const existing = await repo.findByEmail(payload.email);
|
const existing = await repo.findByEmail(payload.email);
|
||||||
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
if (existing) throw new HttpError(400, 'Ya existe un usuario con ese email');
|
||||||
await checkRole(payload.roleId, actor);
|
await checkRole(payload.roleId, actor);
|
||||||
|
// "Desde nómina": el empleado debe existir, estar activo y no tener usuario (se valida antes de crear).
|
||||||
|
if (payload.employeeId) await requireFreeEmployee(payload.employeeId);
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(payload.password, 12);
|
const passwordHash = await bcrypt.hash(payload.password, 12);
|
||||||
const user = await repo.create({ ...payload, passwordHash });
|
const user = await repo.create({ ...payload, passwordHash });
|
||||||
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id });
|
if (payload.employeeId) {
|
||||||
|
try {
|
||||||
|
await linkEmployeeToUser(payload.employeeId, user.id);
|
||||||
|
} catch (err) {
|
||||||
|
await repo.remove(user.id);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await logAudit({ userId: actor.id, action: 'create', entityType: 'user', entityId: user.id, detail: payload.employeeId ? { employeeId: Number(payload.employeeId) } : undefined });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Carga como empleados (origin=provider) a proveedores de SAGA que trabajan en la clínica pero no
|
||||||
|
// están en nómina (médicos que facturan). Lee del conector configurado en Conexiones externas, así
|
||||||
|
// no se guardan datos personales en el repositorio.
|
||||||
|
// node src/scripts/import-provider-employees.js --position=MEDICO 137+031 142+033 809 ...
|
||||||
|
// Cada argumento es una persona; "+" une códigos duplicados de la misma persona (el primero da el
|
||||||
|
// nombre; teléfono y correo se toman del primero que los tenga). Si la cédula ya existe como
|
||||||
|
// empleado (p. ej. de nómina) solo completa teléfono y correo vacíos.
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { pool } from '../config/db.js';
|
||||||
|
import { decrypt } from '../utils/crypto.js';
|
||||||
|
import { connectorGet } from '../modules/integrations/connector.js';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const position = (args.find((a) => a.startsWith('--position=')) || '--position=MEDICO').split('=')[1];
|
||||||
|
const people = args.filter((a) => !a.startsWith('--')).map((a) => a.split('+'));
|
||||||
|
if (!people.length) { console.error('Uso: import-provider-employees.js [--position=X] COD[+COD] ...'); process.exit(1); }
|
||||||
|
|
||||||
|
// Usa la fuente "Proveedores" de Conexiones externas (mismos campos que la pantalla Empleados →
|
||||||
|
// Agregar desde proveedores); con PROVCOD/PROVNOM/PROVRUC/PROVTEL/PROVEMAIL como en SAGA.
|
||||||
|
const [conn] = (await pool.query("SELECT c.base_url, c.token_encrypted, s.source_key, s.table_name FROM external_sources s JOIN external_connections c ON c.id=s.connection_id WHERE s.purpose='providers' AND c.is_active=1"))[0];
|
||||||
|
if (!conn) { console.error('No hay fuente de proveedores activa (Configuración → Conexiones externas)'); process.exit(1); }
|
||||||
|
const SOURCE = conn.source_key, TABLE = conn.table_name;
|
||||||
|
const client = { baseUrl: conn.base_url, token: decrypt(conn.token_encrypted) };
|
||||||
|
const valid = (v) => v && !/^9+$/.test(v);
|
||||||
|
|
||||||
|
for (const codes of people) {
|
||||||
|
const rows = [];
|
||||||
|
for (const code of codes) {
|
||||||
|
const page = await connectorGet(client, `/v1/sources/${SOURCE}/tables/${TABLE}/rows`, { [`eq.PROVCOD`]: code, limit: 1 });
|
||||||
|
if (page.rows[0]) rows.push(page.rows[0]);
|
||||||
|
}
|
||||||
|
if (!rows.length) { console.log(`${codes.join('+')}: no existe en proveedores`); continue; }
|
||||||
|
const words = String(rows[0].PROVNOM).replace(/^DRA?\.?\s+/i, '').trim().split(/\s+/);
|
||||||
|
const identification = String(rows[0].PROVRUC || '').slice(0, 10);
|
||||||
|
if (!/^\d{10}$/.test(identification)) { console.log(`${codes.join('+')}: cédula inválida`); continue; }
|
||||||
|
const phone = rows.map((r) => r.PROVTEL).find(valid) || null;
|
||||||
|
const email = rows.map((r) => r.PROVEMAIL).find((e) => e && /@/.test(e)) || null;
|
||||||
|
const last = words.slice(0, 2).join(' '), first = words.slice(2).join(' ');
|
||||||
|
const [res] = await pool.query(`INSERT INTO employees (identification,last_names,first_names,phone,email,position,is_active,origin,in_source,synced_at)
|
||||||
|
VALUES (?,?,?,?,?,?,1,'provider',0,NOW()) ON DUPLICATE KEY UPDATE phone=COALESCE(phone,VALUES(phone)),email=COALESCE(email,VALUES(email))`,
|
||||||
|
[identification, last, first, phone, email, position]);
|
||||||
|
console.log(`${codes.join('+')}: ${last} ${first} → ${res.affectedRows === 1 ? 'creado' : res.affectedRows === 2 ? 'ya existía (completado)' : 'ya existía'}`);
|
||||||
|
}
|
||||||
|
await pool.end();
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { app } from './app.js';
|
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 { 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})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
startRecurringTaskReminderJob();
|
startRecurringTaskReminderJob();
|
||||||
|
startMeetingReminderJob();
|
||||||
|
startIntegrationSyncJob();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pool } from '../config/db.js';
|
import { pool } from '../config/db.js';
|
||||||
|
|
||||||
export async function logAudit({ userId, action, entityType, entityId, detail }) {
|
export async function logAudit({ userId, action, entityType, entityId, detail, connection = pool }) {
|
||||||
await pool.query(
|
await connection.query(
|
||||||
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`,
|
||||||
[userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null]
|
[userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,3 +18,13 @@ export function recoveryEmail(name, url) {
|
|||||||
const safeUrl = escapeHtml(url);
|
const safeUrl = escapeHtml(url);
|
||||||
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
|
return `<p>Hola ${escapeHtml(name)},</p><p>Para elegir una nueva clave entra a este enlace (vence en 1 hora):</p><p><a href="${safeUrl}">${safeUrl}</a></p><p>Si no pediste esto, ignora el correo.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function emailChangeLinkEmail(name, url, purpose) {
|
||||||
|
const action = purpose === 'approve' ? 'aprobar la solicitud desde tu correo actual' : 'confirmar tu correo nuevo';
|
||||||
|
return `<p>Hola ${escapeHtml(name)},</p><p>Para ${action}, abre este enlace:</p><p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p><p>Vence en 24 horas. Si no solicitaste el cambio, contacta al administrador.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emailChangeNoticeEmail(name, newEmail, kind, reason = '') {
|
||||||
|
const message = kind === 'rejected' ? `se rechazó tu solicitud de cambio de correo. Motivo: ${escapeHtml(reason)}` : `se aprobó el cambio de tu correo a ${escapeHtml(newEmail)}`;
|
||||||
|
return `<p>Hola ${escapeHtml(name)},</p><p>${message}.</p><p>Si no fuiste tú, contacta al administrador.</p>`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { getSmtpConfigInternal } from '../modules/settings/service.js';
|
|||||||
|
|
||||||
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
|
// Degradacion a modo dev cuando el SMTP no esta configurado (regla del orquestador): en vez de
|
||||||
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
|
// fallar, se loguea el contenido/enlace y el flujo que llamo a esto sigue funcionando.
|
||||||
export async function sendMail({ to, subject, html }) {
|
export async function sendMail({ to, subject, html, icalEvent }) {
|
||||||
try {
|
try {
|
||||||
const config = await getSmtpConfigInternal();
|
const config = await getSmtpConfigInternal();
|
||||||
|
|
||||||
if (!config) {
|
if (!config) {
|
||||||
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}`);
|
console.log(`[mail:dev] to=${to} subject="${subject}"\n${html}${icalEvent?.content ? `\n[icalEvent]\n${icalEvent.content}` : ''}`);
|
||||||
return { sent: false, dev: true };
|
return { sent: false, dev: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export async function sendMail({ to, subject, html }) {
|
|||||||
|
|
||||||
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
|
// Nunca dejar que un fallo de envio (SMTP mal configurado, host caido) tumbe al que llama —
|
||||||
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
|
// critico para forgot-password, que siempre debe responder generico sin importar el resultado.
|
||||||
await transporter.sendMail({ from: config.from, to, subject, html });
|
await transporter.sendMail({ from: config.from, to, subject, html, ...(icalEvent ? { icalEvent } : {}) });
|
||||||
return { sent: true, dev: false };
|
return { sent: true, dev: false };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[mail:error]', err.message);
|
console.error('[mail:error]', err.message);
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
config.json
|
||||||
|
logs/
|
||||||
|
node_modules/
|
||||||
|
node/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Generated
+47
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -42,12 +42,18 @@ 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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Contraseñas (todo el módulo, incluido PIN y grupos) | Los datos están protegidos por PIN y cifrados; guardarlos en el navegador saltaría esa protección. |
|
| Contraseñas (todo el módulo, incluido PIN y grupos) | Los datos están protegidos por PIN y cifrados; guardarlos en el navegador saltaría esa protección. |
|
||||||
| Cambiar contraseña (menú del usuario) | Solo contiene secretos. |
|
| Cambiar contraseña (menú del usuario) | Solo contiene secretos. |
|
||||||
|
| Cambio de correo (Mi cuenta) | Solo se guarda el correo nuevo; la clave actual nunca se persiste. |
|
||||||
| Login, registro, olvidé/restablecer clave, aceptar invitación, restablecer PIN | Pantallas públicas: no hay usuario autenticado para aislar el borrador y contienen claves/tokens. |
|
| Login, registro, olvidé/restablecer clave, aceptar invitación, restablecer PIN | Pantallas públicas: no hay usuario autenticado para aislar el borrador y contienen claves/tokens. |
|
||||||
| Documentos (subir archivo), logo y nombre de Empresa | Archivos y texto libre no se guardan en el navegador. |
|
| Documentos (subir archivo), logo y nombre de Empresa | Archivos y texto libre no se guardan en el navegador. |
|
||||||
| Equipos → Agentes (token de instalación) | Token secreto; generar uno es una acción de un paso. |
|
| Equipos → Agentes (token de instalación) | Token secreto; generar uno es una acción de un paso. |
|
||||||
|
|||||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "3.0.1",
|
"version": "4.13.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "3.0.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",
|
||||||
@@ -2748,7 +2748,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "4.3.0",
|
"version": "4.16.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AppShell from './AppShell.jsx';
|
|||||||
import LoginPage from '../pages/Login/LoginPage.jsx';
|
import LoginPage from '../pages/Login/LoginPage.jsx';
|
||||||
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
import RegisterPage from '../pages/Login/RegisterPage.jsx';
|
||||||
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
import VerifyEmailPage from '../pages/Login/VerifyEmailPage.jsx';
|
||||||
|
import EmailChangeLinkPage from '../pages/Login/EmailChangeLinkPage.jsx';
|
||||||
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
import ForgotPasswordPage from '../pages/Login/ForgotPasswordPage.jsx';
|
||||||
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
import ResetPasswordPage from '../pages/Login/ResetPasswordPage.jsx';
|
||||||
import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx';
|
import AcceptInvitePage from '../pages/Login/AcceptInvitePage.jsx';
|
||||||
@@ -24,6 +25,8 @@ import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx';
|
|||||||
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
|
import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
|
||||||
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
||||||
import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx';
|
import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx';
|
||||||
|
import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx';
|
||||||
|
import AgendaMedicaPage from '../pages/AgendaMedica/AgendaMedicaPage.jsx';
|
||||||
|
|
||||||
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
|
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
|
||||||
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
|
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
|
||||||
@@ -32,6 +35,8 @@ const router = createBrowserRouter(createRoutesFromElements(
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/registro" element={<RegisterPage />} />
|
<Route path="/registro" element={<RegisterPage />} />
|
||||||
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
<Route path="/verificar-correo" element={<VerifyEmailPage />} />
|
||||||
|
<Route path="/aprobar-cambio-correo" element={<EmailChangeLinkPage kind="approve" />} />
|
||||||
|
<Route path="/confirmar-cambio-correo" element={<EmailChangeLinkPage kind="confirm" />} />
|
||||||
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
<Route path="/olvide-clave" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
<Route path="/restablecer-clave" element={<ResetPasswordPage />} />
|
||||||
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
|
<Route path="/restablecer-pin" element={<ResetVaultPinPage />} />
|
||||||
@@ -40,6 +45,8 @@ const router = createBrowserRouter(createRoutesFromElements(
|
|||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="mi-cuenta" element={<MiCuentaPage />} />
|
<Route path="mi-cuenta" element={<MiCuentaPage />} />
|
||||||
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
||||||
|
<Route path="calendario" element={<ModuleRoute module="calendar"><CalendarioPage /></ModuleRoute>} />
|
||||||
|
<Route path="agenda-medica" element={<ModuleRoute module="clinic"><AgendaMedicaPage /></ModuleRoute>} />
|
||||||
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
||||||
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
||||||
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
|
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export default function AppShell() {
|
|||||||
if (!notification.is_read) await markNotificationRead(notification.id);
|
if (!notification.is_read) await markNotificationRead(notification.id);
|
||||||
await reloadNotifications();
|
await reloadNotifications();
|
||||||
if (notification.entity_type === 'project') navigate(`/proyectos/${notification.entity_id}`);
|
if (notification.entity_type === 'project') navigate(`/proyectos/${notification.entity_id}`);
|
||||||
|
else if (notification.entity_type === 'meeting') navigate(`/calendario?meeting=${notification.entity_id}`);
|
||||||
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
|
else if (notification.entity_type === 'activity') navigate(`/proyectos`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ export default function AppShell() {
|
|||||||
)}
|
)}
|
||||||
{notifData?.items?.slice(0, 8).map((n) => (
|
{notifData?.items?.slice(0, 8).map((n) => (
|
||||||
<FlyoutItem key={n.id} onClick={() => handleNotificationClick(n)}>
|
<FlyoutItem key={n.id} onClick={() => handleNotificationClick(n)}>
|
||||||
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.message}</span>
|
<span style={{ fontWeight: n.is_read ? 400 : 700 }}>{n.type?.startsWith('meeting_') ? `${t(`notification_${n.type}`)}: ${n.message}` : n.message}</span>
|
||||||
</FlyoutItem>
|
</FlyoutItem>
|
||||||
))}
|
))}
|
||||||
{notifData?.items?.length > 0 && (
|
{notifData?.items?.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
.meeting-calendar .page-title { display:flex; align-items:center; gap:8px; }
|
||||||
|
.meeting-calendar .calendar-filter { display:flex; align-items:center; gap:7px; margin:10px 0 16px; font-size:.82rem; }
|
||||||
|
.meeting-datetime { display:flex; align-items:center; gap:4px; }
|
||||||
|
.meeting-datetime .datepicker-trigger { flex:1; min-width:112px; }
|
||||||
|
.meeting-datetime .combobox { width:55px; flex:none; }
|
||||||
|
.meeting-datetime .combobox .input { padding:6px 4px; }
|
||||||
|
.meeting-calendar .calendar-grid { width:100%; }
|
||||||
|
.meeting-calendar .calendar-grid.calendar-day { grid-template-columns:1fr; }
|
||||||
|
.meeting-calendar .calendar-cell { min-height:110px; min-width:0; cursor:pointer; }
|
||||||
|
.meeting-calendar .calendar-day .calendar-cell { min-height:360px; }
|
||||||
|
.meeting-calendar .calendar-hours { display:flex; flex-direction:column; margin-top:6px; }
|
||||||
|
.meeting-calendar .calendar-hours button { border:0; border-top:1px solid rgb(var(--line)); background:transparent; color:rgb(var(--muted)); text-align:left; min-height:32px; display:flex; align-items:center; justify-content:space-between; cursor:pointer; font:inherit; }
|
||||||
|
.meeting-calendar .calendar-hours button:hover { background:rgb(var(--accent) / .10); color:rgb(var(--accent)); }
|
||||||
|
.meeting-calendar .calendar-entry { display:block; width:100%; border:0; border-radius:4px; padding:4px 6px; text-align:left; font:inherit; font-size:.72rem; cursor:pointer; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:rgb(var(--accent)); background:rgb(var(--accent) / .16); }
|
||||||
|
.meeting-calendar .calendar-entry.activity { color:rgb(var(--warning)); background:rgb(var(--warning) / .14); }
|
||||||
|
.meeting-calendar .calendar-entry:hover, .meeting-calendar .calendar-entry:focus-visible { outline:2px solid rgb(var(--accent)); }
|
||||||
|
.meeting-calendar .calendar-entry-more { float:right; padding:0 3px; }
|
||||||
|
.meeting-calendar .calendar-agenda-row { display:flex; gap:12px; align-items:center; padding:8px 4px; border-bottom:1px solid rgb(var(--line)); }
|
||||||
|
.meeting-calendar .calendar-agenda-row strong { min-width:105px; font-size:.78rem; }
|
||||||
|
.meeting-calendar .calendar-agenda-row .calendar-entry { flex:1; }
|
||||||
|
.calendar-context-layer { position:fixed; inset:0; z-index:101; }
|
||||||
|
.calendar-context { position:fixed; visibility:visible; min-width:190px; }
|
||||||
|
.calendar-tags { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||||
|
.calendar-tags button { background:rgb(var(--accent) / .16); color:rgb(var(--body)); border:1px solid rgb(var(--line)); border-radius:4px; padding:3px 7px; cursor:pointer; }
|
||||||
|
.calendar-weekdays { display:flex; flex-wrap:wrap; gap:12px; margin:8px 0; font-size:.8rem; }
|
||||||
|
.calendar-detail p { line-height:1.5; }
|
||||||
|
@media (max-width:700px) { .meeting-calendar .calendar-grid { gap:2px; } .meeting-calendar .calendar-cell { min-height:80px; padding:3px; } .meeting-calendar .calendar-entry { font-size:.6rem; padding:2px; } .meeting-calendar .calendar-dow { font-size:.58rem; } }
|
||||||
|
.meeting-calendar .calendar-entry.project { color:rgb(var(--cal-project)); background:rgb(var(--cal-project) / .14); }
|
||||||
|
.meeting-calendar .calendar-entry.task { color:rgb(var(--cal-task)); background:rgb(var(--cal-task) / .14); }
|
||||||
|
.meeting-calendar .calendar-legend-item.project .calendar-swatch { background:rgb(var(--cal-project)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.task .calendar-swatch { background:rgb(var(--cal-task)); }
|
||||||
|
/* Colores por tipo de evento (leyenda = mismos colores que las entradas). */
|
||||||
|
.meeting-calendar .calendar-entry.recurring { color:rgb(var(--success)); background:rgb(var(--success) / .14); }
|
||||||
|
.meeting-calendar .calendar-entry.projected { opacity:.72; border:1px dashed rgb(var(--success) / .55); }
|
||||||
|
.meeting-calendar .calendar-entry.done { opacity:.55; text-decoration:line-through; }
|
||||||
|
.meeting-calendar .calendar-entry.overdue { color:rgb(var(--danger)); background:rgb(var(--danger) / .14); border:1px solid rgb(var(--danger) / .5); }
|
||||||
|
.meeting-calendar .calendar-legend { display:flex; flex-wrap:wrap; align-items:center; gap:8px 18px; margin:10px 0 16px; font-size:.82rem; }
|
||||||
|
.meeting-calendar .calendar-legend-item { display:inline-flex; align-items:center; gap:6px; cursor:pointer; }
|
||||||
|
.meeting-calendar .calendar-legend-item.overdue { cursor:default; color:rgb(var(--muted)); }
|
||||||
|
.meeting-calendar .calendar-swatch { width:12px; height:12px; border-radius:3px; background:rgb(var(--accent)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.activity .calendar-swatch { background:rgb(var(--warning)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.recurring .calendar-swatch { background:rgb(var(--success)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.overdue .calendar-swatch { background:rgb(var(--danger)); }
|
||||||
|
.meeting-calendar .calendar-filters { display:flex; flex-wrap:wrap; align-items:flex-end; gap:10px 18px; margin:10px 0 6px; }
|
||||||
|
.meeting-calendar .calendar-filter-label { display:flex; flex-direction:column; gap:4px; font-size:.72rem; color:rgb(var(--muted)); min-width:200px; }
|
||||||
|
/* Compartido conmigo: franja a la izquierda del color del tipo. */
|
||||||
|
.meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; }
|
||||||
|
.meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); }
|
||||||
|
|
||||||
|
.meeting-calendar .calendar-entry.clinic { color:rgb(var(--cal-task)); background:rgb(var(--cal-task)/.2); border-left:3px solid rgb(var(--cal-task)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.clinic .calendar-swatch { background:rgb(var(--cal-task)); }
|
||||||
|
/* "+N mas" (Mes) y horas no disponibles (Agenda medica: fuera de horario o bloqueadas). */
|
||||||
|
.meeting-calendar .calendar-more { border:0; background:none; padding:2px 0; color:rgb(var(--accent)); font:inherit; font-size:.68rem; text-align:left; cursor:pointer; }
|
||||||
|
.meeting-calendar .calendar-hours button:disabled { cursor:default; color:rgb(var(--muted) / .6); background:rgb(var(--muted) / .08); }
|
||||||
|
.meeting-calendar .calendar-hours button.blocked { background:repeating-linear-gradient(135deg, rgb(var(--warning) / .12) 0 8px, transparent 8px 16px); }
|
||||||
|
.meeting-calendar .calendar-hours button:disabled:hover { background:rgb(var(--muted) / .08); color:rgb(var(--muted) / .6); }
|
||||||
|
.meeting-calendar .calendar-hours button.blocked:hover { background:repeating-linear-gradient(135deg, rgb(var(--warning) / .12) 0 8px, transparent 8px 16px); }
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { Button } from './Button.jsx';
|
||||||
|
import { ComboBox } from './ComboBox.jsx';
|
||||||
|
import './CalendarBoard.css';
|
||||||
|
|
||||||
|
// Calendario reutilizable (Calendario y Agenda medica): barra Hoy / anterior / siguiente / periodo,
|
||||||
|
// vistas Mes, Semana, Dia y Agenda, y la cuadricula. Que entradas hay y que pasa al hacer clic lo
|
||||||
|
// decide quien lo usa. Fechas en hora civil de Ecuador (America/Guayaquil).
|
||||||
|
export const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda'];
|
||||||
|
export const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
export const ecuadorNow = () => {
|
||||||
|
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Guayaquil', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date()).map((p) => [p.type, p.value]));
|
||||||
|
return new Date(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute));
|
||||||
|
};
|
||||||
|
export const shift = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + days);
|
||||||
|
export const fromKey = (key) => { const [y, m, d] = key.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||||
|
export function calendarBounds(cursor, view) {
|
||||||
|
let first, last;
|
||||||
|
if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); }
|
||||||
|
else if (view === 'week') { first = shift(cursor, -((cursor.getDay() + 6) % 7)); last = shift(first, 7); }
|
||||||
|
else if (view === 'day') { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first, 1); }
|
||||||
|
else { first = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate()); last = shift(first, 30); }
|
||||||
|
// En Mes se piden tambien los dias de relleno de la primera y ultima semana visibles.
|
||||||
|
const gridFirst = view === 'month' ? shift(first, -((first.getDay() + 6) % 7)) : first;
|
||||||
|
const gridLast = view === 'month' ? shift(last, (7 - ((last.getDay() + 6) % 7)) % 7) : last;
|
||||||
|
return { from: `${dateKey(gridFirst)} 00:00:00`, to: `${dateKey(gridLast)} 00:00:00`, first, last };
|
||||||
|
}
|
||||||
|
const DEFAULT_HOURS = Array.from({ length: 11 }, (_, i) => i + 8);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - items: entradas con `day` (YYYY-MM-DD), ya ordenadas; renderItem(item) dibuja cada una (boton
|
||||||
|
* `calendar-entry` que debe cortar la propagacion del clic).
|
||||||
|
* - onSlot(day, hour|null): clic en un dia (Mes) o en una hora (Semana/Dia).
|
||||||
|
* - hourState(day, hour): 'closed' | 'blocked' | undefined; la hora se muestra deshabilitada.
|
||||||
|
* - actions/filters/legend: nodos de la barra (derecha), fila de filtros y leyenda.
|
||||||
|
*/
|
||||||
|
export function CalendarBoard({ title, icon: Icon, view, onViewChange, cursor, onCursorChange, actions, filters, legend, notice, items, renderItem, onSlot, hours = DEFAULT_HOURS, hourState, maxPerDay }) {
|
||||||
|
const { t, lang } = useUi();
|
||||||
|
const { first, last } = calendarBounds(cursor, view);
|
||||||
|
const todayKey = dateKey(ecuadorNow());
|
||||||
|
function navigate(delta) {
|
||||||
|
if (view === 'month') onCursorChange(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
|
||||||
|
else onCursorChange(shift(cursor, delta * (view === 'week' ? 7 : view === 'agenda' ? 30 : 1)));
|
||||||
|
}
|
||||||
|
const byDay = items.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {});
|
||||||
|
const days = view === 'month'
|
||||||
|
? Array.from({ length: Math.ceil((((first.getDay() + 6) % 7) + Math.round((last - first) / 86400000)) / 7) * 7 }, (_, i) => shift(first, i - ((first.getDay() + 6) % 7)))
|
||||||
|
: Array.from({ length: Math.round((last - first) / 86400000) }, (_, i) => shift(first, i));
|
||||||
|
const weekLabels = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((d) => t(`calendar_${d}`));
|
||||||
|
return <div className="meeting-calendar">
|
||||||
|
<div className="toolbar"><div className="toolbar-left">{title && <h2 className="page-title mt-0">{Icon && <Icon size={22} />} {title}</h2>}
|
||||||
|
<Button onClick={() => onCursorChange(ecuadorNow())}>{t('calendar_today')}</Button><Button icon={ChevronLeft} onClick={() => navigate(-1)} aria-label={t('calendar_previous')} /><Button icon={ChevronRight} onClick={() => navigate(1)} aria-label={t('calendar_next')} />
|
||||||
|
<strong>{first.toLocaleDateString(lang, { month: 'long', year: 'numeric' })}</strong></div>
|
||||||
|
<div className="toolbar-left"><ComboBox options={CALENDAR_VIEWS.map((x) => ({ value: x, label: t(`calendar_${x}`) }))} value={view} onChange={onViewChange} />{actions}</div></div>
|
||||||
|
{filters && <div className="calendar-filters">{filters}</div>}
|
||||||
|
{legend && <div className="calendar-legend" aria-label={t('calendar_legend')}>{legend}</div>}
|
||||||
|
{notice}
|
||||||
|
{view === 'agenda' ? <div className="calendar-agenda">{items.length ? items.map((item, i) => <div key={i} className="calendar-agenda-row"><strong>{item.day}</strong>{renderItem(item)}</div>) : <p className="text-muted">{t('calendar_empty')}</p>}</div> : <div className={`calendar-grid calendar-${view}`}>
|
||||||
|
{view !== 'day' && weekLabels.map((label) => <div key={label} className="calendar-dow">{label}</div>)}
|
||||||
|
{days.map((day) => {
|
||||||
|
const key = dateKey(day);
|
||||||
|
const muted = view === 'month' && day.getMonth() !== cursor.getMonth();
|
||||||
|
const dayItems = byDay[key] || [];
|
||||||
|
const limit = view === 'month' && maxPerDay && dayItems.length > maxPerDay ? maxPerDay - 1 : dayItems.length;
|
||||||
|
return <div key={key} className={`calendar-cell ${muted ? 'muted' : ''} ${key === todayKey ? 'today' : ''}`} onClick={() => onSlot?.(key, null)}>
|
||||||
|
<div className="calendar-day-num">{view === 'day' ? day.toLocaleDateString(lang, { weekday: 'long', day: 'numeric', month: 'long' }) : day.getDate()}</div>
|
||||||
|
{dayItems.slice(0, limit).map(renderItem)}
|
||||||
|
{limit < dayItems.length && <button type="button" className="calendar-more" onClick={(e) => { e.stopPropagation(); onCursorChange(day); onViewChange('day'); }}>+{dayItems.length - limit} {t('calendar_more')}</button>}
|
||||||
|
{view !== 'month' && <div className="calendar-hours">{hours.map((hour) => { const state = hourState?.(key, hour); return <button key={hour} className={state || undefined} disabled={!!state} onClick={(e) => { e.stopPropagation(); onSlot?.(key, hour); }}>{String(hour).padStart(2, '0')}:00 {!state && <Plus size={12} />}</button>; })}</div>}
|
||||||
|
</div>;
|
||||||
|
})}</div>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
|
||||||
|
const key = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
const fromKey = (s) => { const [y,m,d] = String(s || '').split('-').map(Number); return Number.isFinite(y) && Number.isFinite(m) && Number.isFinite(d) ? new Date(y,m-1,d) : new Date(); };
|
||||||
|
|
||||||
|
export function DatePicker({ id, value, onChange, label }) {
|
||||||
|
const { lang, t } = useUi();
|
||||||
|
const triggerRef = useRef(null), panelRef = useRef(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [cursor, setCursor] = useState(() => fromKey(value));
|
||||||
|
const [pos, setPos] = useState(null);
|
||||||
|
useEffect(() => { if (value) setCursor(fromKey(value)); }, [value]);
|
||||||
|
function openPanel() {
|
||||||
|
const r = triggerRef.current.getBoundingClientRect();
|
||||||
|
setCursor(fromKey(value));
|
||||||
|
setPos({ top:r.bottom+4, left:r.left, anchorTop:r.top, anchorBottom:r.bottom, ready:false });
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open || !pos || pos.ready || !panelRef.current) return;
|
||||||
|
const r = panelRef.current.getBoundingClientRect(), margin = 8;
|
||||||
|
let top = pos.top;
|
||||||
|
if (top+r.height > window.innerHeight-margin) top = pos.anchorTop-r.height-4 >= margin ? pos.anchorTop-r.height-4 : Math.max(margin,window.innerHeight-r.height-margin);
|
||||||
|
const left = Math.max(margin, Math.min(pos.left,window.innerWidth-r.width-margin));
|
||||||
|
setPos((p) => ({...p,top,left,ready:true}));
|
||||||
|
}, [open,pos]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const outside = (e) => { if (!triggerRef.current?.contains(e.target) && !panelRef.current?.contains(e.target)) setOpen(false); };
|
||||||
|
const keydown = (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setOpen(false); triggerRef.current?.focus(); } else if (e.key === 'Tab' && panelRef.current?.contains(document.activeElement)) e.stopPropagation(); };
|
||||||
|
const scroll = (e) => { if (!panelRef.current?.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener('mousedown',outside);
|
||||||
|
document.addEventListener('keydown',keydown,true);
|
||||||
|
window.addEventListener('scroll',scroll,true);
|
||||||
|
window.addEventListener('resize',scroll);
|
||||||
|
return () => { document.removeEventListener('mousedown',outside); document.removeEventListener('keydown',keydown,true); window.removeEventListener('scroll',scroll,true); window.removeEventListener('resize',scroll); };
|
||||||
|
}, [open]);
|
||||||
|
useEffect(() => { if (open && pos?.ready) panelRef.current?.querySelector('button.selected')?.focus(); }, [open,pos?.ready]);
|
||||||
|
const first = new Date(cursor.getFullYear(),cursor.getMonth(),1);
|
||||||
|
const offset = (first.getDay()+6)%7;
|
||||||
|
const count = new Date(cursor.getFullYear(),cursor.getMonth()+1,0).getDate();
|
||||||
|
const days = Array.from({length:Math.ceil((offset+count)/7)*7},(_,i) => i-offset+1);
|
||||||
|
const weekday = Array.from({length:7},(_,i) => new Date(2024,0,1+i).toLocaleDateString(lang,{weekday:'short'}));
|
||||||
|
const today = new Date(Date.now()-5*3600000).toISOString().slice(0,10);
|
||||||
|
return <>
|
||||||
|
<button type="button" ref={triggerRef} id={id} className="input datepicker-trigger" aria-label={label || t('calendar_date')} aria-haspopup="dialog" aria-expanded={open} onClick={() => open ? setOpen(false) : openPanel()}><span>{value || '—'}</span><CalendarDays size={16} /></button>
|
||||||
|
{open && pos && createPortal(<div ref={panelRef} className="datepicker-panel" role="dialog" aria-label={t('calendar_date')} style={{top:pos.top,left:pos.left,visibility:pos.ready?'visible':'hidden'}}>
|
||||||
|
<div className="datepicker-head"><button type="button" aria-label={t('calendar_previous')} onClick={() => setCursor(new Date(cursor.getFullYear(),cursor.getMonth()-1,1))}><ChevronLeft size={16} /></button><strong>{cursor.toLocaleDateString(lang,{month:'long',year:'numeric'})}</strong><button type="button" aria-label={t('calendar_next')} onClick={() => setCursor(new Date(cursor.getFullYear(),cursor.getMonth()+1,1))}><ChevronRight size={16} /></button></div>
|
||||||
|
<div className="datepicker-grid">{weekday.map((day,i)=><span key={i} className="datepicker-weekday">{day}</span>)}{days.map((day,i) => day<1||day>count ? <span key={i} /> : <button type="button" key={i} className={`${key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===value?'selected':''} ${key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===today?'today':''}`} aria-pressed={key(new Date(cursor.getFullYear(),cursor.getMonth(),day))===value} onClick={() => { onChange(key(new Date(cursor.getFullYear(),cursor.getMonth(),day))); setOpen(false); triggerRef.current?.focus(); }}>{day}</button>)}</div>
|
||||||
|
</div>,document.body)}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { DatePicker } from './DatePicker.jsx';
|
||||||
|
import { ComboBox } from './ComboBox.jsx';
|
||||||
|
const hours = Array.from({length:24},(_,i) => ({value:String(i).padStart(2,'0'),label:String(i).padStart(2,'0')}));
|
||||||
|
const minutes = Array.from({length:12},(_,i) => ({value:String(i*5).padStart(2,'0'),label:String(i*5).padStart(2,'0')}));
|
||||||
|
export function DateTimeEditor({ value, onChange, allDay, label, isEnd = false }) {
|
||||||
|
const date = value.slice(0,10), hour = value.slice(11,13), minute = value.slice(14,16);
|
||||||
|
const setDate = (next) => onChange(`${next} ${allDay ? (isEnd ? '23:59' : '00:00') : `${hour}:${minute}`}:00`);
|
||||||
|
const setTime = (h,m) => onChange(`${date} ${h}:${m}:00`);
|
||||||
|
return <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
|
||||||
|
}
|
||||||
@@ -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>}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useUserDrafts } from '../hooks/useDraftForm.js';
|
import { useUserDrafts } from '../hooks/useDraftForm.js';
|
||||||
import { DraftMark } from './NavigationGuard.jsx';
|
import { DraftMark } from './NavigationGuard.jsx';
|
||||||
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor } from 'lucide-react';
|
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor, CalendarDays, Stethoscope } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from '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';
|
||||||
@@ -14,6 +14,8 @@ import { hasModule } from '../utils/modules.js';
|
|||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
||||||
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
|
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||||
|
{ to: '/calendario', module: 'calendar', icon: CalendarDays, labelKey: 'nav_calendar' },
|
||||||
|
{ to: '/agenda-medica', module: 'clinic', icon: Stethoscope, labelKey: 'nav_clinic' },
|
||||||
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||||
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
|
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
|
||||||
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
|
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { Field, FieldRow } from './Field.jsx';
|
||||||
|
import { ComboBox } from './ComboBox.jsx';
|
||||||
|
import { DatePicker } from './DatePicker.jsx';
|
||||||
|
|
||||||
|
// Reuniones y bloqueos clínicos comparten las mismas reglas de recurrencia del backend.
|
||||||
|
export function RecurrenceEditor({
|
||||||
|
value,
|
||||||
|
start,
|
||||||
|
onChange
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
t
|
||||||
|
} = useUi();
|
||||||
|
const weekday = (new Date(`${start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const labels = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
const patch = fields => onChange({
|
||||||
|
...value,
|
||||||
|
...fields
|
||||||
|
});
|
||||||
|
return <>
|
||||||
|
<Field label={t('calendar_recurrence')}>
|
||||||
|
<ComboBox options={['none', 'daily', 'weekly', 'monthly'].map(x => ({
|
||||||
|
value: x,
|
||||||
|
label: t(`calendar_${x}`)
|
||||||
|
}))} value={value?.frequency || 'none'} onChange={x => onChange(x === 'none' ? null : {
|
||||||
|
frequency: x,
|
||||||
|
interval: 1,
|
||||||
|
weekdays: x === 'weekly' ? [weekday] : [],
|
||||||
|
count: 10
|
||||||
|
})} />
|
||||||
|
</Field>
|
||||||
|
{value && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={value.interval} onChange={e => patch({
|
||||||
|
interval: Number(e.target.value)
|
||||||
|
})} /></Field>
|
||||||
|
<Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={value.count || ''} onChange={e => patch({
|
||||||
|
count: e.target.value ? Number(e.target.value) : null,
|
||||||
|
until: null
|
||||||
|
})} /></Field>
|
||||||
|
<Field label={t('calendar_until')}><DatePicker value={value.until || ''} onChange={x => patch({
|
||||||
|
until: x || null,
|
||||||
|
count: null
|
||||||
|
})} label={t('calendar_until')} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{value.frequency === 'weekly' && <div className="calendar-weekdays">{labels.map((name, index) => <label key={name}>
|
||||||
|
<input type="checkbox" checked={value.weekdays.includes(index)} onChange={() => patch({
|
||||||
|
weekdays: value.weekdays.includes(index) ? value.weekdays.filter(x => x !== index) : [...value.weekdays, index]
|
||||||
|
})} />
|
||||||
|
{' '}{t(`calendar_${name}`)}
|
||||||
|
</label>)}</div>}
|
||||||
|
</>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields,
|
sanitizeDraftValues, fingerprint, setActiveEditor, draftAllowedFields,
|
||||||
} from '../utils/drafts.js';
|
} from '../utils/drafts.js';
|
||||||
|
|
||||||
const same = (a, b) => String(a ?? '') === String(b ?? '');
|
const same = (a, b) => (a && typeof a === 'object') || (b && typeof b === 'object')
|
||||||
|
? JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
|
||||||
|
: String(a ?? '') === String(b ?? '');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estado de un formulario con borrador persistente (ver utils/drafts.js).
|
* Estado de un formulario con borrador persistente (ver utils/drafts.js).
|
||||||
|
|||||||
@@ -1,5 +1,88 @@
|
|||||||
export const messages = {
|
export const messages = {
|
||||||
'es-EC': {
|
'es-EC': {
|
||||||
|
email_change_title: 'Cambiar correo', email_change_new: 'Correo nuevo', email_change_request: 'Solicitar cambio', email_change_requested: 'Solicitud creada', email_change_cancel: 'Cancelar solicitud', email_change_cancelled: 'Solicitud cancelada', email_change_requests: 'Solicitudes de cambio de correo', email_change_none: 'No hay solicitudes pendientes', email_change_approve: 'Aprobar', email_change_reject: 'Rechazar', email_change_reason: 'Motivo del rechazo', email_change_approved: 'Cambio aprobado', email_change_rejected: 'Cambio rechazado', email_change_approve_title: 'Aprobar mi cambio de correo', email_change_confirm_title: 'Confirmar correo nuevo', email_change_accept: 'Aceptar', email_change_self_approved: 'Aprobado. Revisa el correo nuevo para confirmarlo.', email_change_applied: 'Correo actualizado. Todas tus sesiones se cerraron. Inicia sesión con:', email_change_status_pending: 'Pendiente de aprobación', email_change_status_approved: 'Aprobada: revisa tu correo nuevo', email_change_status_rejected: 'Rechazada', email_change_status_cancelled: 'Cancelada', email_change_status_expired: 'Vencida', email_change_status_failed: 'Fallida', email_change_status_applied: 'Aplicada',
|
||||||
|
clinic_status_cancelled: 'Cancelada',
|
||||||
|
clinic_active: 'Activo',
|
||||||
|
clinic_reprogram: 'Reprogramar',
|
||||||
|
clinic_history: 'Historial de citas',
|
||||||
|
clinic_cancel_confirm: 'Se cancelará la cita y quedará el motivo registrado.',
|
||||||
|
clinic_delete_block: 'Eliminar bloqueo',
|
||||||
|
clinic_delete_block_confirm: 'Se eliminará el bloqueo.',
|
||||||
|
nav_clinic: 'Agenda médica',
|
||||||
|
clinic_agenda: 'Agenda',
|
||||||
|
clinic_patients: 'Pacientes',
|
||||||
|
clinic_blocks: 'Bloqueos',
|
||||||
|
clinic_config: 'Configuración de agenda',
|
||||||
|
clinic_new_appointment: 'Nueva cita',
|
||||||
|
clinic_new_patient: 'Nuevo paciente',
|
||||||
|
clinic_new_block: 'Nuevo bloqueo',
|
||||||
|
clinic_appointment: 'Cita',
|
||||||
|
clinic_patient: 'Paciente',
|
||||||
|
clinic_block: 'Bloqueo',
|
||||||
|
clinic_no_doctors: 'Aún no hay médicos configurados en la agenda. Pide a un administrador que los registre.', clinic_no_doctors_admin: 'Aún no hay médicos configurados. Regístralos con su horario en la pestaña Configuración de agenda.', calendar_more: 'más', clinic_all_doctors: 'Todos los médicos', clinic_go_to_date: 'Ir a fecha', clinic_status_deleted: 'Eliminada', clinic_delete: 'Eliminar cita', clinic_delete_reason: 'Motivo de eliminación', clinic_delete_confirm: 'La cita se marcará como eliminada: deja de ocupar el horario, pero queda registrada con el motivo para la trazabilidad.', clinic_deleted_notice: 'Cita eliminada: se conserva solo para consulta.', clinic_created_by: 'Agendado por', clinic_cancelled_by: 'Cancelada por', clinic_deleted_by: 'Eliminada por', clinic_ai_agent: 'Agente IA',
|
||||||
|
clinic_doctors: 'Médicos',
|
||||||
|
clinic_doctor: 'Médico',
|
||||||
|
clinic_status: 'Estado',
|
||||||
|
clinic_status_scheduled: 'Programada',
|
||||||
|
clinic_status_confirmed: 'Confirmada',
|
||||||
|
clinic_status_attended: 'Atendida',
|
||||||
|
clinic_status_no_show: 'No asistió',
|
||||||
|
clinic_search_patient: 'Buscar por nombre, cédula o teléfono',
|
||||||
|
clinic_type: 'Tipo de cita',
|
||||||
|
clinic_channel: 'Canal',
|
||||||
|
clinic_channel_phone: 'Teléfono',
|
||||||
|
clinic_channel_whatsapp: 'WhatsApp',
|
||||||
|
clinic_channel_in_person: 'Presencial',
|
||||||
|
clinic_channel_other: 'Otro',
|
||||||
|
clinic_notes: 'Notas',
|
||||||
|
clinic_available: 'Huecos disponibles',
|
||||||
|
clinic_confirm: 'Confirmar',
|
||||||
|
clinic_cancel: 'Cancelar cita',
|
||||||
|
clinic_cancel_reason: 'Motivo de cancelación',
|
||||||
|
clinic_affected: 'Citas afectadas; hay que reprogramarlas',
|
||||||
|
clinic_confirm_affected: 'Confirmar bloqueo',
|
||||||
|
clinic_first_names: 'Nombres',
|
||||||
|
clinic_last_names: 'Apellidos',
|
||||||
|
clinic_identification: 'Cédula / identificación',
|
||||||
|
clinic_phone: 'Teléfono',
|
||||||
|
clinic_email: 'Correo',
|
||||||
|
clinic_birth_date: 'Nacimiento',
|
||||||
|
clinic_kind: 'Clase',
|
||||||
|
clinic_kind_surgery: 'Quirófano',
|
||||||
|
clinic_kind_vacation: 'Vacaciones',
|
||||||
|
clinic_kind_other: 'Otro',
|
||||||
|
clinic_title: 'Título',
|
||||||
|
clinic_staff: 'Personal',
|
||||||
|
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_own: 'Médico (propia)',
|
||||||
|
clinic_is_doctor: 'Es médico',
|
||||||
|
clinic_specialty: 'Especialidad',
|
||||||
|
clinic_color: 'Color',
|
||||||
|
clinic_types: 'Tipos de cita',
|
||||||
|
clinic_new_type: 'Nuevo tipo',
|
||||||
|
clinic_name: 'Nombre',
|
||||||
|
clinic_duration: 'Duración (minutos)',
|
||||||
|
clinic_schedules: 'Horario de atención',
|
||||||
|
calendar_layer_clinic: 'Citas médicas',
|
||||||
|
nav_calendar: 'Calendario',
|
||||||
|
calendar_date: 'Fecha',
|
||||||
|
notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás',
|
||||||
|
calendar_today: 'Hoy', calendar_previous: 'Anterior', calendar_next: 'Siguiente',
|
||||||
|
calendar_month: 'Mes', calendar_week: 'Semana', calendar_day: 'Día', calendar_agenda: 'Agenda',
|
||||||
|
calendar_new: 'Nueva reunión', calendar_open: 'Abrir', calendar_show_due: 'Mostrar vencimientos de actividades', calendar_legend: 'Capas del calendario', calendar_layer_meetings: 'Reuniones', calendar_layer_activities: 'Mis actividades', calendar_layer_recurring: 'Tareas recurrentes', calendar_overdue: 'Vencido', calendar_layer_projects: 'Proyectos', calendar_layer_tasks: 'Mis tareas', calendar_only: 'Solo', calendar_show_all: 'Ver todo', calendar_filter_origin: 'Ver', calendar_filter_types: 'Mostrar', calendar_all_types: 'Todo', calendar_no_types: 'Nada seleccionado', calendar_origin_all: 'Todo (mío y compartido)', calendar_origin_mine: 'Solo lo mío', calendar_origin_shared: 'Compartido conmigo', calendar_due: 'Vence', calendar_empty: 'No hay eventos en este período',
|
||||||
|
calendar_title: 'Título', calendar_description: 'Descripción', calendar_start: 'Inicio', calendar_end: 'Fin', calendar_all_day: 'Todo el día',
|
||||||
|
calendar_location: 'Ubicación', calendar_video_url: 'Enlace de videollamada', calendar_project: 'Proyecto', calendar_organizer: 'Organizador', calendar_reminder: 'Recordatorio', calendar_none: 'Ninguno', calendar_one_day: 'Un día antes', calendar_minutes: 'minutos',
|
||||||
|
calendar_participants: 'Participantes internos', calendar_external: 'Correos externos', calendar_add: 'Agregar', calendar_invalid_email: 'Correo inválido o límite de 50 participantes',
|
||||||
|
calendar_recurrence: 'Repetición', calendar_daily: 'Diaria', calendar_weekly: 'Semanal', calendar_monthly: 'Mensual', calendar_interval: 'Cada', calendar_count: 'Número de ocurrencias', calendar_until: 'Hasta la fecha',
|
||||||
|
calendar_mon: 'Lun', calendar_tue: 'Mar', calendar_wed: 'Mié', calendar_thu: 'Jue', calendar_fri: 'Vie', calendar_sat: 'Sáb', calendar_sun: 'Dom',
|
||||||
|
calendar_rsvp_pending: 'Pendiente', calendar_rsvp_accepted: 'Aceptar', calendar_rsvp_declined: 'Rechazar', calendar_rsvp_tentative: 'Quizás',
|
||||||
|
calendar_conflicts: 'Hay participantes con otra reunión a la misma hora', calendar_save_anyway: 'Confirmar y guardar',
|
||||||
|
calendar_cancel: 'Cancelar reunión', calendar_cancel_occurrence: 'Cancelar esta ocurrencia', calendar_cancel_confirm: 'Se cancelará la serie y se avisará a los participantes.', calendar_cancel_occurrence_confirm: 'Se cancelará esta ocurrencia y se avisará a los participantes.', calendar_cancelled: 'Reunión cancelada',
|
||||||
appName: 'LinkedCorp Proyectos',
|
appName: 'LinkedCorp Proyectos',
|
||||||
nav_dashboard: 'Panel',
|
nav_dashboard: 'Panel',
|
||||||
user_modules: 'Módulos',
|
user_modules: 'Módulos',
|
||||||
@@ -211,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',
|
||||||
@@ -383,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',
|
||||||
@@ -410,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',
|
||||||
@@ -580,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',
|
||||||
@@ -611,6 +796,89 @@ export const messages = {
|
|||||||
notifications_mark_all_read: 'Marcar todas como leidas',
|
notifications_mark_all_read: 'Marcar todas como leidas',
|
||||||
},
|
},
|
||||||
'en-US': {
|
'en-US': {
|
||||||
|
email_change_title: 'Change email', email_change_new: 'New email', email_change_request: 'Request change', email_change_requested: 'Request created', email_change_cancel: 'Cancel request', email_change_cancelled: 'Request cancelled', email_change_requests: 'Email change requests', email_change_none: 'No pending requests', email_change_approve: 'Approve', email_change_reject: 'Reject', email_change_reason: 'Rejection reason', email_change_approved: 'Change approved', email_change_rejected: 'Change rejected', email_change_approve_title: 'Approve my email change', email_change_confirm_title: 'Confirm new email', email_change_accept: 'Accept', email_change_self_approved: 'Approved. Check your new email to confirm it.', email_change_applied: 'Email updated. All sessions were closed. Sign in with:', email_change_status_pending: 'Pending approval', email_change_status_approved: 'Approved: check your new email', email_change_status_rejected: 'Rejected', email_change_status_cancelled: 'Cancelled', email_change_status_expired: 'Expired', email_change_status_failed: 'Failed', email_change_status_applied: 'Applied',
|
||||||
|
clinic_status_cancelled: 'Cancelled',
|
||||||
|
clinic_active: 'Active',
|
||||||
|
clinic_reprogram: 'Reschedule',
|
||||||
|
clinic_history: 'Appointment history',
|
||||||
|
clinic_cancel_confirm: 'The appointment will be cancelled and the reason recorded.',
|
||||||
|
clinic_delete_block: 'Delete block',
|
||||||
|
clinic_delete_block_confirm: 'The block will be deleted.',
|
||||||
|
nav_clinic: 'Medical schedule',
|
||||||
|
clinic_agenda: 'Schedule',
|
||||||
|
clinic_patients: 'Patients',
|
||||||
|
clinic_blocks: 'Blocks',
|
||||||
|
clinic_config: 'Schedule settings',
|
||||||
|
clinic_new_appointment: 'New appointment',
|
||||||
|
clinic_new_patient: 'New patient',
|
||||||
|
clinic_new_block: 'New block',
|
||||||
|
clinic_appointment: 'Appointment',
|
||||||
|
clinic_patient: 'Patient',
|
||||||
|
clinic_block: 'Block',
|
||||||
|
clinic_no_doctors: 'No doctors are set up in the schedule yet. Ask an administrator to add them.', clinic_no_doctors_admin: 'No doctors are set up yet. Add them with their hours in the Schedule settings tab.', calendar_more: 'more', clinic_all_doctors: 'All doctors', clinic_go_to_date: 'Go to date', clinic_status_deleted: 'Deleted', clinic_delete: 'Delete appointment', clinic_delete_reason: 'Deletion reason', clinic_delete_confirm: 'The appointment will be marked as deleted: it frees the time slot but stays recorded with the reason for traceability.', clinic_deleted_notice: 'Deleted appointment: kept for reference only.', clinic_created_by: 'Booked by', clinic_cancelled_by: 'Cancelled by', clinic_deleted_by: 'Deleted by', clinic_ai_agent: 'AI agent',
|
||||||
|
clinic_doctors: 'Doctors',
|
||||||
|
clinic_doctor: 'Doctor',
|
||||||
|
clinic_status: 'Status',
|
||||||
|
clinic_status_scheduled: 'Scheduled',
|
||||||
|
clinic_status_confirmed: 'Confirmed',
|
||||||
|
clinic_status_attended: 'Attended',
|
||||||
|
clinic_status_no_show: 'No show',
|
||||||
|
clinic_search_patient: 'Search name, ID or phone',
|
||||||
|
clinic_type: 'Appointment type',
|
||||||
|
clinic_channel: 'Channel',
|
||||||
|
clinic_channel_phone: 'Phone',
|
||||||
|
clinic_channel_whatsapp: 'WhatsApp',
|
||||||
|
clinic_channel_in_person: 'In person',
|
||||||
|
clinic_channel_other: 'Other',
|
||||||
|
clinic_notes: 'Notes',
|
||||||
|
clinic_available: 'Available slots',
|
||||||
|
clinic_confirm: 'Confirm',
|
||||||
|
clinic_cancel: 'Cancel appointment',
|
||||||
|
clinic_cancel_reason: 'Cancellation reason',
|
||||||
|
clinic_affected: 'Affected appointments; rescheduling needed',
|
||||||
|
clinic_confirm_affected: 'Confirm block',
|
||||||
|
clinic_first_names: 'First names',
|
||||||
|
clinic_last_names: 'Last names',
|
||||||
|
clinic_identification: 'ID',
|
||||||
|
clinic_phone: 'Phone',
|
||||||
|
clinic_email: 'Email',
|
||||||
|
clinic_birth_date: 'Date of birth',
|
||||||
|
clinic_kind: 'Kind',
|
||||||
|
clinic_kind_surgery: 'Surgery',
|
||||||
|
clinic_kind_vacation: 'Vacation',
|
||||||
|
clinic_kind_other: 'Other',
|
||||||
|
clinic_title: 'Title',
|
||||||
|
clinic_staff: 'Staff',
|
||||||
|
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_own: 'Doctor (own)',
|
||||||
|
clinic_is_doctor: 'Is doctor',
|
||||||
|
clinic_specialty: 'Specialty',
|
||||||
|
clinic_color: 'Color',
|
||||||
|
clinic_types: 'Appointment types',
|
||||||
|
clinic_new_type: 'New type',
|
||||||
|
clinic_name: 'Name',
|
||||||
|
clinic_duration: 'Duration (minutes)',
|
||||||
|
clinic_schedules: 'Working hours',
|
||||||
|
calendar_layer_clinic: 'Medical appointments',
|
||||||
|
nav_calendar: 'Calendar',
|
||||||
|
calendar_date: 'Date',
|
||||||
|
notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe',
|
||||||
|
calendar_today: 'Today', calendar_previous: 'Previous', calendar_next: 'Next',
|
||||||
|
calendar_month: 'Month', calendar_week: 'Week', calendar_day: 'Day', calendar_agenda: 'Agenda',
|
||||||
|
calendar_new: 'New meeting', calendar_open: 'Open', calendar_show_due: 'Show activity due dates', calendar_legend: 'Calendar layers', calendar_layer_meetings: 'Meetings', calendar_layer_activities: 'My activities', calendar_layer_recurring: 'Recurring tasks', calendar_overdue: 'Overdue', calendar_layer_projects: 'Projects', calendar_layer_tasks: 'My tasks', calendar_only: 'Only', calendar_show_all: 'Show all', calendar_filter_origin: 'View', calendar_filter_types: 'Show', calendar_all_types: 'All', calendar_no_types: 'Nothing selected', calendar_origin_all: 'All (mine and shared)', calendar_origin_mine: 'Only mine', calendar_origin_shared: 'Shared with me', calendar_due: 'Due', calendar_empty: 'No events in this period',
|
||||||
|
calendar_title: 'Title', calendar_description: 'Description', calendar_start: 'Start', calendar_end: 'End', calendar_all_day: 'All day',
|
||||||
|
calendar_location: 'Location', calendar_video_url: 'Video call URL', calendar_project: 'Project', calendar_organizer: 'Organizer', calendar_reminder: 'Reminder', calendar_none: 'None', calendar_one_day: 'One day before', calendar_minutes: 'minutes',
|
||||||
|
calendar_participants: 'Internal participants', calendar_external: 'External emails', calendar_add: 'Add', calendar_invalid_email: 'Invalid email or 50 participant limit',
|
||||||
|
calendar_recurrence: 'Recurrence', calendar_daily: 'Daily', calendar_weekly: 'Weekly', calendar_monthly: 'Monthly', calendar_interval: 'Every', calendar_count: 'Occurrences', calendar_until: 'Until date',
|
||||||
|
calendar_mon: 'Mon', calendar_tue: 'Tue', calendar_wed: 'Wed', calendar_thu: 'Thu', calendar_fri: 'Fri', calendar_sat: 'Sat', calendar_sun: 'Sun',
|
||||||
|
calendar_rsvp_pending: 'Pending', calendar_rsvp_accepted: 'Accept', calendar_rsvp_declined: 'Decline', calendar_rsvp_tentative: 'Maybe',
|
||||||
|
calendar_conflicts: 'Some participants have another meeting at this time', calendar_save_anyway: 'Confirm and save',
|
||||||
|
calendar_cancel: 'Cancel meeting', calendar_cancel_occurrence: 'Cancel this occurrence', calendar_cancel_confirm: 'The series will be cancelled and participants notified.', calendar_cancel_occurrence_confirm: 'This occurrence will be cancelled and participants notified.', calendar_cancelled: 'Meeting cancelled',
|
||||||
appName: 'LinkedCorp Projects',
|
appName: 'LinkedCorp Projects',
|
||||||
nav_dashboard: 'Dashboard',
|
nav_dashboard: 'Dashboard',
|
||||||
user_modules: 'Modules',
|
user_modules: 'Modules',
|
||||||
@@ -822,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',
|
||||||
@@ -994,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',
|
||||||
@@ -1021,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',
|
||||||
@@ -1191,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',
|
||||||
|
|||||||
@@ -0,0 +1,555 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Stethoscope, Plus, Pencil, Check, XCircle, Trash2, CalendarClock, UserCheck, UserX } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-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 } from '../../components/Field.jsx';
|
||||||
|
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { DatePicker } from '../../components/DatePicker.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
import { CalendarBoard, CALENDAR_VIEWS, calendarBounds, dateKey, ecuadorNow, fromKey } from '../../components/CalendarBoard.jsx';
|
||||||
|
import * as api from '../../services/clinic.js';
|
||||||
|
import { draftKey, removeDraft } from '../../utils/drafts.js';
|
||||||
|
import { AppointmentForm, PatientForm, BlockForm } from './ClinicForms.jsx';
|
||||||
|
import { ClinicConfigTab } from './ClinicConfigTab.jsx';
|
||||||
|
import { at, plus, readStored, initialAppointment, initialPatient, initialBlock, formAppointment, formPatient, formBlock } from './clinicUtils.js';
|
||||||
|
import './clinic.css';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export default function AgendaMedicaPage() {
|
||||||
|
const { t, openDialog, pushToast } = useUi();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const [me, setMe] = useState(null),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[doctors, setDoctors] = useState([]),
|
||||||
|
[types, setTypes] = useState([]);
|
||||||
|
const [tab, setTab] = useState('agenda'),
|
||||||
|
// Clave nueva: la 4.8.x podia dejar guardada la vista Dia; ahora siempre arranca en Mes.
|
||||||
|
[view, setView] = useState(() => { const v = readStored('plc_clinic_view_v3', 'month'); return CALENDAR_VIEWS.includes(v) ? v : 'month'; }),
|
||||||
|
[cursor, setCursor] = useState(ecuadorNow);
|
||||||
|
const day = dateKey(cursor);
|
||||||
|
const [doctorFilter, setDoctorFilter] = useState(() => {
|
||||||
|
const value = readStored('plc_clinic_doctors', []);
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
});
|
||||||
|
const [statusFilter, setStatusFilter] = useState(() => {
|
||||||
|
const value = readStored('plc_clinic_status', ['scheduled', 'confirmed', 'attended', 'no_show']);
|
||||||
|
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
|
||||||
|
});
|
||||||
|
const [appointments, setAppointments] = useState([]),
|
||||||
|
[blocks, setBlocks] = useState([]);
|
||||||
|
// Pestaña Pacientes (listado paginado en el servidor).
|
||||||
|
const [patients, setPatients] = useState([]),
|
||||||
|
[search, setSearch] = useState(''),
|
||||||
|
[page, setPage] = useState(1),
|
||||||
|
[patientTotal, setPatientTotal] = useState(0);
|
||||||
|
// Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
|
||||||
|
const [apptPatients, setApptPatients] = useState([]),
|
||||||
|
[apptSearch, setApptSearch] = useState(''),
|
||||||
|
// Pacientes del sistema origen (Conexiones externas, modo consulta en vivo): aún no importados.
|
||||||
|
[externalPatients, setExternalPatients] = useState([]),
|
||||||
|
[patientSource, setPatientSource] = useState(null);
|
||||||
|
const [popup, setPopup] = useState(null),
|
||||||
|
[editing, setEditing] = useState(false),
|
||||||
|
[conflict, setConflict] = useState(''),
|
||||||
|
[affected, setAffected] = useState([]),
|
||||||
|
[slots, setSlots] = useState([]),
|
||||||
|
[actionReason, setActionReason] = useState(''),
|
||||||
|
[actionMode, setActionMode] = useState(null),
|
||||||
|
[patientHistory, setPatientHistory] = useState([]),
|
||||||
|
[quickPatientId, setQuickPatientId] = useState(null),
|
||||||
|
[reprogram, setReprogram] = useState([]);
|
||||||
|
const isReception = me?.scope === 'all',
|
||||||
|
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||||
|
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
|
||||||
|
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
|
||||||
|
const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!me) return;
|
||||||
|
try {
|
||||||
|
const [a, b] = await Promise.all([api.clinicAppointments(period.from, period.to), api.clinicBlocks(period.from, period.to)]);
|
||||||
|
setAppointments(a);
|
||||||
|
setBlocks(b);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadDoctors() {
|
||||||
|
const data = await api.clinicDoctors();
|
||||||
|
setDoctors(data.doctors);
|
||||||
|
setTypes(data.types);
|
||||||
|
}
|
||||||
|
async function loadPatients() {
|
||||||
|
const x = await api.clinicPatients(search, page);
|
||||||
|
setPatients(x.items);
|
||||||
|
setPatientTotal(x.total);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.modules?.includes('clinic')) return;
|
||||||
|
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
|
||||||
|
api.clinicMe().then(async staff => {
|
||||||
|
setMe(staff);
|
||||||
|
await loadDoctors();
|
||||||
|
if (staff.scope === 'all') api.clinicPatientSource().then(setPatientSource).catch(() => setPatientSource(null));
|
||||||
|
}).catch(e => {
|
||||||
|
if (isAdmin) setTab('config');
|
||||||
|
else setError(e.message);
|
||||||
|
});
|
||||||
|
}, [user?.id]);
|
||||||
|
useEffect(() => { void load(); }, [me?.user_id, period.from, period.to]);
|
||||||
|
useEffect(() => { try { localStorage.setItem('plc_clinic_view_v3', JSON.stringify(view)); } catch { /* almacenamiento privado */ } }, [view]);
|
||||||
|
useEffect(() => { try { localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter)); } catch { /* almacenamiento privado */ } }, [doctorFilter]);
|
||||||
|
useEffect(() => { try { localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter)); } catch { /* almacenamiento privado */ } }, [statusFilter]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === 'patients' && isReception) loadPatients().catch(e => setError(e.message));
|
||||||
|
}, [tab, search, page, isReception]);
|
||||||
|
|
||||||
|
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
|
||||||
|
const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot, popup.patient?.id)
|
||||||
|
: popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient(popup.prefill)
|
||||||
|
: popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId,
|
||||||
|
entityId: popup?.item?.id,
|
||||||
|
initial,
|
||||||
|
label: t(`clinic_${popup?.kind || 'appointment'}`),
|
||||||
|
enabled: !!popup && editing,
|
||||||
|
// Descartar (botón Cancelar, aviso de borrador o diálogo al salir): un registro existente vuelve
|
||||||
|
// a lectura; uno nuevo se cierra; el paciente creado desde una cita vuelve a la cita.
|
||||||
|
onDiscard: () => {
|
||||||
|
if (popup?.returnTo) backToAppointment();
|
||||||
|
else if (popup?.item) setEditing(false);
|
||||||
|
else setPopup(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const v = draft.values;
|
||||||
|
// Al volver de "Nuevo paciente": se reponen los valores que tenía la cita (en memoria, no en el
|
||||||
|
// borrador) y se elige el paciente recién creado.
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'appointment' || !editing || (!popup.restore && !quickPatientId)) return;
|
||||||
|
draft.setValues(old => ({ ...old, ...(popup.restore || {}), ...(quickPatientId ? { patientId: quickPatientId } : {}) }));
|
||||||
|
setQuickPatientId(null);
|
||||||
|
setPopup(p => ({ ...p, restore: null }));
|
||||||
|
}, [quickPatientId, popup?.kind, popup?.restore, editing]);
|
||||||
|
// Aviso (no bloquea): citas futuras pendientes del paciente elegido, sin contar la que se edita.
|
||||||
|
const [upcoming, setUpcoming] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'appointment' || !editing || !v.patientId) { setUpcoming([]); return; }
|
||||||
|
let active = true;
|
||||||
|
const n = ecuadorNow();
|
||||||
|
const now = `${dateKey(n)} ${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}:00`;
|
||||||
|
api.clinicPatientHistory(v.patientId).then(rows => {
|
||||||
|
if (active) setUpcoming(rows.filter(a => !a.deleted_at && ['scheduled', 'confirmed'].includes(a.status) && a.starts_at > now && a.id !== popup.item?.id).sort((x, y) => x.starts_at.localeCompare(y.starts_at)));
|
||||||
|
}).catch(() => active && setUpcoming([]));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, popup?.item?.id, editing, v.patientId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) { setSlots([]); return; }
|
||||||
|
let active = true;
|
||||||
|
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(x => active && setSlots(x)).catch(() => active && setSlots([]));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
|
||||||
|
|
||||||
|
function open(kind, item = null, extra = {}) {
|
||||||
|
setPopup({ kind, item, ...extra });
|
||||||
|
setEditing(!item);
|
||||||
|
setConflict('');
|
||||||
|
setAffected([]);
|
||||||
|
setActionMode(null);
|
||||||
|
setActionReason('');
|
||||||
|
setPatientHistory([]);
|
||||||
|
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
|
||||||
|
if (kind === 'appointment' && isReception) {
|
||||||
|
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
||||||
|
setApptSearch('');
|
||||||
|
setExternalPatients([]);
|
||||||
|
const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
|
||||||
|
setApptPatients(current);
|
||||||
|
api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Búsqueda del selector: el paciente ya elegido se conserva en la lista y una respuesta vieja no
|
||||||
|
// pisa a la más reciente.
|
||||||
|
const searchSeq = useRef(0);
|
||||||
|
function searchApptPatients(text) {
|
||||||
|
setApptSearch(text);
|
||||||
|
const seq = ++searchSeq.current;
|
||||||
|
if (patientSource?.mode === 'live' && text.trim().length >= 3) {
|
||||||
|
api.clinicExternalPatients(text).then(rows => { if (seq === searchSeq.current) setExternalPatients(rows.filter(r => !r.local_id)); }).catch(() => setExternalPatients([]));
|
||||||
|
} else setExternalPatients([]);
|
||||||
|
api.clinicPatients(text, 1).then(x => {
|
||||||
|
if (seq !== searchSeq.current) return;
|
||||||
|
setApptPatients(prev => {
|
||||||
|
const selected = prev.find(p => p.id === Number(draft.values.patientId));
|
||||||
|
return selected && !x.items.some(p => p.id === selected.id) ? [selected, ...x.items] : x.items;
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
// Elegir paciente; si es del sistema origen (ext:código) se trae primero al sistema.
|
||||||
|
async function pickApptPatient(value) {
|
||||||
|
if (!String(value).startsWith('ext:')) return draft.setField('patientId', value);
|
||||||
|
try {
|
||||||
|
const p = await api.clinicImportExternalPatient(String(value).slice(4));
|
||||||
|
setApptPatients(list => [p, ...list.filter(x => x.id !== p.id)]);
|
||||||
|
setExternalPatients(list => list.filter(x => `ext:${x.external_id}` !== value));
|
||||||
|
draft.setField('patientId', p.id);
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
|
||||||
|
// cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
|
||||||
|
function newPatientFromAppointment() {
|
||||||
|
const prefill = /^\d{5,}$/.test(apptSearch.trim()) ? apptSearch.trim() : '';
|
||||||
|
// La cita se guarda en memoria y su borrador se quita: mientras el paciente está abierto no
|
||||||
|
// debe quedar un borrador de cita huérfano (si se sale a otro menú, la siguiente cita abre limpia).
|
||||||
|
if (user) removeDraft(draftKey(user.id, 'clinic-appointment', popup.item?.id));
|
||||||
|
setPopup({ kind: 'patient', item: null, prefill, returnTo: { ...popup, restore: draft.values } });
|
||||||
|
setEditing(true);
|
||||||
|
setConflict('');
|
||||||
|
}
|
||||||
|
function backToAppointment() {
|
||||||
|
setPopup(popup.returnTo);
|
||||||
|
setEditing(true);
|
||||||
|
setConflict('');
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
void guardedClose(editing ? draft : null, () => {
|
||||||
|
if (popup?.returnTo) return backToAppointment();
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
setConflict('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cancelEdit = () => draft.discard();
|
||||||
|
async function save(confirmAffected = false) {
|
||||||
|
if (popup.item && !draft.isDirty) return;
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
|
||||||
|
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
|
||||||
|
if (popup.kind === 'block') result = await api.clinicSaveBlock({ ...v, confirmAffected }, popup.item?.id);
|
||||||
|
if (result?.requiresConfirmation) {
|
||||||
|
setAffected(result.affected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.clear();
|
||||||
|
if (popup.returnTo) {
|
||||||
|
setApptPatients(list => [result, ...list.filter(p => p.id !== result.id)]);
|
||||||
|
setQuickPatientId(result.id);
|
||||||
|
backToAppointment();
|
||||||
|
} else {
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
setConflict('');
|
||||||
|
setAffected([]);
|
||||||
|
if (result?.affected?.length) setReprogram(result.affected);
|
||||||
|
await load();
|
||||||
|
if (tab === 'patients') await loadPatients();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setStatus(item, status) {
|
||||||
|
try {
|
||||||
|
await api.clinicAppointmentStatus(item.id, { status, editVersion: item.edit_version });
|
||||||
|
if (popup?.item?.id === item.id) setPopup(null);
|
||||||
|
await load();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
} catch (e) {
|
||||||
|
if (popup) setConflict(e.message); else setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados.
|
||||||
|
function reasonAction() {
|
||||||
|
if (!actionReason.trim()) return;
|
||||||
|
const remove = actionMode === 'delete';
|
||||||
|
openDialog({
|
||||||
|
tone: 'warning',
|
||||||
|
title: t(remove ? 'clinic_delete' : 'clinic_cancel'),
|
||||||
|
message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'),
|
||||||
|
actions: [{ label: t('common_cancel') }, {
|
||||||
|
label: t(remove ? 'clinic_delete' : 'clinic_cancel'),
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, { reason: actionReason, editVersion: popup.item.edit_version });
|
||||||
|
setPopup(null);
|
||||||
|
setActionMode(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function deleteBlock() {
|
||||||
|
openDialog({
|
||||||
|
tone: 'warning',
|
||||||
|
title: t('clinic_delete_block'),
|
||||||
|
message: t('clinic_delete_block_confirm'),
|
||||||
|
actions: [{ label: t('common_cancel') }, {
|
||||||
|
label: t('common_delete'),
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.clinicDeleteBlock(popup.item.id, popup.item.edit_version);
|
||||||
|
setPopup(null);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function appointmentMenu(item) {
|
||||||
|
const actions = [{ label: t('common_close') }];
|
||||||
|
if (item.deleted_at) return open('appointment', item);
|
||||||
|
const withReason = mode => () => { open('appointment', item); setActionMode(mode); };
|
||||||
|
if (isReception && item.status === 'cancelled') actions.push({ label: t('clinic_delete'), variant: 'danger', onClick: withReason('delete') });
|
||||||
|
if (item.status === 'cancelled') return openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
|
||||||
|
if (isReception) {
|
||||||
|
actions.push({ label: t('clinic_confirm'), onClick: () => void setStatus(item, 'confirmed') });
|
||||||
|
actions.push({ label: t('clinic_reprogram'), onClick: () => { open('appointment', item); setEditing(true); } });
|
||||||
|
actions.push({ label: t('clinic_cancel'), variant: 'danger', onClick: withReason('cancel') });
|
||||||
|
actions.push({ label: t('clinic_delete'), variant: 'danger', onClick: withReason('delete') });
|
||||||
|
}
|
||||||
|
for (const status of ['attended', 'no_show']) actions.push({ label: t(`clinic_status_${status}`), onClick: () => void setStatus(item, status) });
|
||||||
|
openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctorName = id => doctors.find(d => d.id === id)?.name || '';
|
||||||
|
const doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
|
||||||
|
const doctorShort = id => doctorName(id).split(' ').slice(0, 2).join(' ');
|
||||||
|
const statusOf = a => a.deleted_at ? 'deleted' : a.status;
|
||||||
|
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(statusOf(a)));
|
||||||
|
// Quien agendo: fijo desde la creacion; si fue el agente de WhatsApp dice "Agente IA".
|
||||||
|
const author = a => a.created_by_kind === 'ai_agent' ? t('clinic_ai_agent') : a.created_by_name || '—';
|
||||||
|
// Clic en un dia/hora libre: cita nueva a la primera hora de atencion de ese dia (medico preelegido
|
||||||
|
// si se mira uno solo). Sin permiso de agendar, el clic solo abre ese dia.
|
||||||
|
function newAt(d, slot, patient) {
|
||||||
|
if (!isReception || !doctors.length) {
|
||||||
|
if (!slot) {
|
||||||
|
setCursor(fromKey(d));
|
||||||
|
setView('day');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const first = selectedDoctors.flatMap(doc => (doc.schedules || []).filter(seg => seg.weekday === weekday).map(seg => String(seg.starts_time).slice(0, 5))).sort()[0];
|
||||||
|
open('appointment', null, {
|
||||||
|
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
|
||||||
|
slot: slot || at(d, first || '09:00'),
|
||||||
|
patient
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const openItem = (e, kind, item) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
open(kind, item);
|
||||||
|
};
|
||||||
|
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id));
|
||||||
|
// Entradas del calendario: color del medico; canceladas/eliminadas tachadas. En Mes/Semana van
|
||||||
|
// compactas; en Dia/Agenda con medico, tipo y estado.
|
||||||
|
const compact = view === 'month' || view === 'week';
|
||||||
|
const apptLong = x => `${x.starts_at.slice(11, 16)}–${x.ends_at.slice(11, 16)} ${x.first_names} ${x.last_names} · ${x.doctor_name} · ${x.type_name} · ${t(`clinic_status_${statusOf(x)}`)}`;
|
||||||
|
const blockLong = x => `${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctorName(x.doctor_user_id)} · ${t(`clinic_kind_${x.kind}`) === x.title ? x.title : `${t(`clinic_kind_${x.kind}`)} · ${x.title}`}`;
|
||||||
|
const renderItem = ({ kind, data: x }) => kind === 'appointment'
|
||||||
|
? <button key={`a-${x.id}`} className={`calendar-entry clinic-appt${['cancelled', 'deleted'].includes(statusOf(x)) ? ' done' : ''}${x.deleted_at ? ' deleted' : ''}`} style={{ '--doctor': doctorColor(x.doctor_user_id) }}
|
||||||
|
onClick={e => openItem(e, 'appointment', x)} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); appointmentMenu(x); }}
|
||||||
|
title={apptLong(x)}>{compact ? `${x.starts_at.slice(11, 16)} ${x.first_names} ${x.last_names.slice(0, 1)}.` : apptLong(x)}</button>
|
||||||
|
: <button key={`b-${x.id}-${x.occurrence_start}`} className="calendar-entry clinic-block" style={{ '--doctor': doctorColor(x.doctor_user_id) }}
|
||||||
|
onClick={e => openItem(e, 'block', x)} title={blockLong(x)}>{compact ? `${doctorShort(x.doctor_user_id)} · ${x.title}` : blockLong(x)}</button>;
|
||||||
|
// Un bloqueo de varios dias (vacaciones) aparece en cada dia que cubre.
|
||||||
|
const items = [...visible.map(a => ({ kind: 'appointment', day: a.starts_at.slice(0, 10), start: a.starts_at, data: a })), ...visibleBlocks.flatMap(b => {
|
||||||
|
const out = [];
|
||||||
|
for (let d = fromKey(b.occurrence_start.slice(0, 10)); dateKey(d) <= b.occurrence_end.slice(0, 10) && out.length < 62; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1)) out.push({ kind: 'block', day: dateKey(d), start: dateKey(d) === b.occurrence_start.slice(0, 10) ? b.occurrence_start : `${dateKey(d)} 00:00:00`, data: b });
|
||||||
|
return out;
|
||||||
|
})].sort((x, y) => x.start.localeCompare(y.start));
|
||||||
|
// Hora de Semana/Dia: cerrada si ningun medico mostrado atiende; bloqueada si se mira un solo
|
||||||
|
// medico y un bloqueo la cubre entera.
|
||||||
|
const hourState = (d, h) => {
|
||||||
|
if (!selectedDoctors.length) return undefined;
|
||||||
|
const start = at(d, `${String(h).padStart(2, '0')}:00`),
|
||||||
|
end = plus(start, 60),
|
||||||
|
weekday = (fromKey(d).getDay() + 6) % 7;
|
||||||
|
if (!selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` < end && `${d} ${seg.ends_time}` > start))) return 'closed';
|
||||||
|
if (selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start <= start && b.occurrence_end >= end)) return 'blocked';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
|
||||||
|
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
|
||||||
|
const hourRange = (() => {
|
||||||
|
let lo = 24, hi = 0;
|
||||||
|
for (const d of selectedDoctors) for (const seg of d.schedules || []) {
|
||||||
|
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2)));
|
||||||
|
hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
|
||||||
|
}
|
||||||
|
for (const x of [...visible.map(a => [a.starts_at, a.ends_at]), ...visibleBlocks.map(b => [b.occurrence_start, b.occurrence_end])]) {
|
||||||
|
lo = Math.min(lo, Number(String(x[0]).slice(11, 13)));
|
||||||
|
hi = Math.max(hi, Math.min(24, Math.ceil(Number(String(x[1]).slice(11, 13)) + Number(String(x[1]).slice(14, 16)) / 60)));
|
||||||
|
}
|
||||||
|
if (lo >= hi) [lo, hi] = [7, 19];
|
||||||
|
return Array.from({ length: hi - lo }, (_, i) => lo + i);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const it = popup?.item;
|
||||||
|
// Tipos activos + el tipo actual de la cita que se edita aunque se haya desactivado.
|
||||||
|
const apptTypes = it && popup.kind === 'appointment' && !types.some(z => z.id === it.type_id)
|
||||||
|
? [...types, { id: it.type_id, name: it.type_name, duration_minutes: Math.round((new Date(it.ends_at.replace(' ', 'T')) - new Date(it.starts_at.replace(' ', 'T'))) / 60000) }]
|
||||||
|
: types;
|
||||||
|
const detail = !it ? [] : popup.kind === 'appointment' ? [
|
||||||
|
[t('clinic_patient'), `${it.first_names} ${it.last_names}`], [t('clinic_doctor'), it.doctor_name],
|
||||||
|
[t('clinic_type'), it.type_name], [t('clinic_status'), t(`clinic_status_${statusOf(it)}`)],
|
||||||
|
[t('calendar_start'), it.starts_at.slice(0, 16)], [t('calendar_end'), it.ends_at.slice(0, 16)],
|
||||||
|
[t('clinic_channel'), t(`clinic_channel_${it.channel}`)], [t('clinic_created_by'), `${author(it)} · ${String(it.created_at || '').slice(0, 16)}`],
|
||||||
|
[t('clinic_notes'), it.notes],
|
||||||
|
[t('clinic_cancelled_by'), it.cancelled_at && `${it.cancelled_by_name || '—'} · ${it.cancelled_at.slice(0, 16)} · ${it.cancelled_reason || ''}`],
|
||||||
|
[t('clinic_deleted_by'), it.deleted_at && `${it.deleted_by_name || '—'} · ${it.deleted_at.slice(0, 16)} · ${it.deleted_reason || ''}`]
|
||||||
|
] : popup.kind === 'patient' ? [
|
||||||
|
[t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names],
|
||||||
|
[t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone],
|
||||||
|
[t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes],
|
||||||
|
[t('clinic_origin'), it.external_source ? `${t('clinic_external_results')} (${it.external_id})` : t('clinic_origin_local')],
|
||||||
|
[t('clinic_source_name'), it.external_source && it.name_split ? it.source_name : null]
|
||||||
|
] : [
|
||||||
|
[t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)],
|
||||||
|
[t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name],
|
||||||
|
[t('calendar_start'), it.occurrence_start.slice(0, 16)], [t('calendar_end'), it.occurrence_end.slice(0, 16)]
|
||||||
|
];
|
||||||
|
const popupTitle = !popup ? '' : it
|
||||||
|
? popup.kind === 'appointment' ? `${t('clinic_appointment')}: ${it.first_names} ${it.last_names}` : popup.kind === 'patient' ? `${it.first_names} ${it.last_names}` : `${t('clinic_block')}: ${it.title}`
|
||||||
|
: t(popup.kind === 'appointment' ? 'clinic_new_appointment' : popup.kind === 'patient' ? 'clinic_new_patient' : 'clinic_new_block');
|
||||||
|
const canSave = popup?.kind === 'appointment' ? !!(v.patientId && v.doctorId && v.typeId)
|
||||||
|
: popup?.kind === 'patient' ? !!(v.firstNames?.trim() && v.lastNames?.trim() && v.phone?.trim())
|
||||||
|
: popup?.kind === 'block' ? !!(v.doctorId && v.title?.trim()) : false;
|
||||||
|
|
||||||
|
if (error && !me && !isAdmin) return <InfoBar tone="error">{error}</InfoBar>;
|
||||||
|
|
||||||
|
const tabs = [...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])];
|
||||||
|
return <div className="clinic-page">
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div>
|
||||||
|
<div className="toolbar-left">
|
||||||
|
{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}
|
||||||
|
{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, { doctorId: singleDoctor })}>{t('clinic_new_block')}</Button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && me && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>}
|
||||||
|
<Tabs tabs={tabs.map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => void guardedClose(popup && editing ? draft : null, () => setTab(key))} />
|
||||||
|
|
||||||
|
{tab === 'agenda' && <CalendarBoard view={view} onViewChange={setView} cursor={cursor} onCursorChange={setCursor} items={items} renderItem={renderItem} hours={hourRange} hourState={hourState} maxPerDay={5}
|
||||||
|
onSlot={(d, h) => newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))}
|
||||||
|
actions={isReception && <Button variant="accent" icon={Plus} onClick={() => newAt(day)}>{t('clinic_new_appointment')}</Button>}
|
||||||
|
filters={<>
|
||||||
|
<label className="calendar-filter-label">{t('clinic_doctors')}
|
||||||
|
<CheckListDropdown label={!doctorFilter.length || doctorFilter.length === doctors.length ? t('clinic_all_doctors') : doctorFilter.length === 1 ? doctorName(doctorFilter[0]) || '1' : `${doctorFilter.length} / ${doctors.length}`} options={doctors.map(d => ({ value: d.id, label: d.name }))} values={doctorFilter} onChange={setDoctorFilter} width={240} />
|
||||||
|
</label>
|
||||||
|
<label className="calendar-filter-label">{t('clinic_status')}
|
||||||
|
<CheckListDropdown label={statusFilter.length === 1 ? t(`clinic_status_${statusFilter[0]}`) : `${statusFilter.length} / 6`} options={['scheduled', 'confirmed', 'attended', 'no_show', 'cancelled', 'deleted'].map(x => ({ value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} width={220} />
|
||||||
|
</label>
|
||||||
|
<label className="calendar-filter-label">{t('clinic_go_to_date')}
|
||||||
|
<DatePicker value={day} onChange={x => x && setCursor(fromKey(x))} label={t('clinic_go_to_date')} />
|
||||||
|
</label>
|
||||||
|
</>}
|
||||||
|
legend={<>
|
||||||
|
{selectedDoctors.map(d => <span key={d.id} className="calendar-legend-item"><span className="calendar-swatch" style={{ background: d.color || 'rgb(var(--accent))' }} />{d.name}</span>)}
|
||||||
|
<span className="calendar-legend-item clinic-block-sample"><span className="calendar-swatch" />{t('clinic_blocks')}</span>
|
||||||
|
<span className="calendar-legend-item clinic-done-sample"><span className="calendar-swatch" />{t('clinic_status_cancelled')} / {t('clinic_status_deleted')}</span>
|
||||||
|
</>}
|
||||||
|
notice={me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>} />}
|
||||||
|
|
||||||
|
{tab === 'patients' && isReception && <>
|
||||||
|
<div className="toolbar"><div className="toolbar-left"><SearchInput value={search} onChange={x => { setSearch(x); setPage(1); }} /></div></div>
|
||||||
|
<ListView getRowKey={p => p.id} rows={patients} onRowClick={p => open('patient', p)}
|
||||||
|
columns={[
|
||||||
|
{ key: 'last_names', label: t('clinic_last_names') },
|
||||||
|
{ key: 'first_names', label: t('clinic_first_names') },
|
||||||
|
{ key: 'identification', label: t('clinic_identification'), render: p => p.identification || '—' },
|
||||||
|
{ key: 'phone', label: t('clinic_phone') },
|
||||||
|
{ key: 'email', label: t('clinic_email'), render: p => p.email || '—' }
|
||||||
|
]}
|
||||||
|
rowActions={p => [
|
||||||
|
{ label: t('common_edit'), icon: Pencil, onClick: () => { open('patient', p); setEditing(true); } },
|
||||||
|
{ label: t('clinic_new_appointment'), icon: CalendarClock, onClick: () => { setTab('agenda'); newAt(day, undefined, p); } }
|
||||||
|
]} />
|
||||||
|
<div className="toolbar-left clinic-pager"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button><span>{page} / {Math.max(1, Math.ceil(patientTotal / PAGE_SIZE))}</span><Button disabled={page * PAGE_SIZE >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div>
|
||||||
|
</>}
|
||||||
|
|
||||||
|
{tab === 'blocks' && <ListView getRowKey={b => `${b.id}-${b.occurrence_start}`} rows={visibleBlocks} onRowClick={b => open('block', b)} initialSort={{ key: 'occurrence_start', dir: 1 }}
|
||||||
|
columns={[
|
||||||
|
{ key: 'occurrence_start', label: t('calendar_start'), render: b => b.occurrence_start.slice(0, 16) },
|
||||||
|
{ key: 'occurrence_end', label: t('calendar_end'), render: b => b.occurrence_end.slice(0, 16) },
|
||||||
|
{ key: 'doctor_user_id', label: t('clinic_doctor'), sortValue: b => doctorName(b.doctor_user_id), render: b => <span className="clinic-name"><span className="calendar-swatch" style={{ background: doctorColor(b.doctor_user_id) }} />{doctorName(b.doctor_user_id)}</span> },
|
||||||
|
{ key: 'kind', label: t('clinic_kind'), render: b => t(`clinic_kind_${b.kind}`) },
|
||||||
|
{ key: 'title', label: t('clinic_title') }
|
||||||
|
]} />}
|
||||||
|
|
||||||
|
{tab === 'config' && isAdmin && <ClinicConfigTab onChanged={() => (me ? loadDoctors() : Promise.resolve())} />}
|
||||||
|
|
||||||
|
<DetailPopup open={!!popup} title={popupTitle} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">
|
||||||
|
{conflict && <InfoBar tone="error">{conflict}</InfoBar>}
|
||||||
|
{affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button size="sm" onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}
|
||||||
|
{editing && <DraftNotice draft={draft} />}
|
||||||
|
{!editing ? it && <>
|
||||||
|
{popup.kind === 'appointment' && it.deleted_at && <InfoBar tone="warning">{t('clinic_deleted_notice')}</InfoBar>}
|
||||||
|
<div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>
|
||||||
|
{actionMode && <>
|
||||||
|
<Field label={t(actionMode === 'delete' ? 'clinic_delete_reason' : 'clinic_cancel_reason')}><input className="input" maxLength={500} value={actionReason} onChange={e => setActionReason(e.target.value)} autoFocus /></Field>
|
||||||
|
<div className="toolbar-left clinic-actions"><Button variant="danger" disabled={!actionReason.trim()} onClick={reasonAction}>{t(actionMode === 'delete' ? 'clinic_delete' : 'clinic_cancel')}</Button><Button onClick={() => { setActionMode(null); setActionReason(''); }}>{t('common_cancel')}</Button></div>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'appointment' && !it.deleted_at && !actionMode && <div className="toolbar-left clinic-actions">
|
||||||
|
{isReception && it.status !== 'cancelled' && <>
|
||||||
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||||
|
<Button icon={Check} disabled={it.status !== 'scheduled'} onClick={() => void setStatus(it, 'confirmed')}>{t('clinic_confirm')}</Button>
|
||||||
|
</>}
|
||||||
|
{it.status !== 'cancelled' && <>
|
||||||
|
<Button icon={UserCheck} disabled={it.status === 'attended'} onClick={() => void setStatus(it, 'attended')}>{t('clinic_status_attended')}</Button>
|
||||||
|
<Button icon={UserX} disabled={it.status === 'no_show'} onClick={() => void setStatus(it, 'no_show')}>{t('clinic_status_no_show')}</Button>
|
||||||
|
</>}
|
||||||
|
{isReception && it.status !== 'cancelled' && <Button icon={XCircle} onClick={() => setActionMode('cancel')}>{t('clinic_cancel')}</Button>}
|
||||||
|
{isReception && <Button variant="danger" icon={Trash2} onClick={() => setActionMode('delete')}>{t('clinic_delete')}</Button>}
|
||||||
|
</div>}
|
||||||
|
{popup.kind === 'patient' && isReception && <>
|
||||||
|
<div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
|
||||||
|
<h3>{t('clinic_history')}</h3>
|
||||||
|
{patientHistory.length ? <ListView getRowKey={a => a.id} rows={patientHistory} initialSort={{ key: 'starts_at', dir: -1 }}
|
||||||
|
columns={[
|
||||||
|
{ key: 'starts_at', label: t('calendar_start'), render: a => <span className={a.deleted_at ? 'clinic-deleted' : undefined}>{a.starts_at.slice(0, 16)}</span> },
|
||||||
|
{ key: 'doctor_name', label: t('clinic_doctor') },
|
||||||
|
{ key: 'type_name', label: t('clinic_type') },
|
||||||
|
{ key: 'status', label: t('clinic_status'), render: a => t(`clinic_status_${statusOf(a)}`) },
|
||||||
|
{ key: 'created_by_name', label: t('clinic_created_by'), render: author }
|
||||||
|
]} /> : <p className="text-muted">—</p>}
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'block' && <div className="toolbar-left clinic-actions">
|
||||||
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||||
|
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
||||||
|
</div>}
|
||||||
|
</> : <>
|
||||||
|
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} externalPatients={externalPatients} onPatientChange={x => void pickApptPatient(x)} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
||||||
|
{popup.kind === 'patient' && <PatientForm draft={draft} unsplit={!!it && !it.name_split} />}
|
||||||
|
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
||||||
|
<div className="toolbar-left clinic-actions">
|
||||||
|
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
|
<Button onClick={cancelEdit}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>}</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.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). */
|
||||||
|
.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-block{color:rgb(var(--warning));background:repeating-linear-gradient(135deg,rgb(var(--warning)/.16) 0 6px,transparent 6px 12px);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px}
|
||||||
|
.meeting-calendar .calendar-legend-item.clinic-block-sample .calendar-swatch{background:repeating-linear-gradient(135deg,rgb(var(--warning)) 0 3px,transparent 3px 6px);border:1px solid rgb(var(--warning)/.6)}
|
||||||
|
.meeting-calendar .calendar-legend-item.clinic-done-sample{color:rgb(var(--muted));text-decoration:line-through}
|
||||||
|
.meeting-calendar .calendar-legend-item.clinic-done-sample .calendar-swatch{background:rgb(var(--muted)/.3)}
|
||||||
|
.meeting-calendar .calendar-legend-item{cursor:default}
|
||||||
|
.clinic-deleted{opacity:.65}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { CalendarDays, Plus, Pencil, Trash2, XCircle } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { useDraftForm, useReopenDraft } 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 { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||||||
|
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
|
||||||
|
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx';
|
||||||
|
import { listProjectOptions } from '../../services/projects.js';
|
||||||
|
import { listUsers } from '../../services/users.js';
|
||||||
|
import { hasModule } from '../../utils/modules.js';
|
||||||
|
import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js';
|
||||||
|
import { clinicMe, clinicAppointments, clinicBlocks } from '../../services/clinic.js';
|
||||||
|
import { CalendarBoard, calendarBounds, dateKey, ecuadorNow } from '../../components/CalendarBoard.jsx';
|
||||||
|
|
||||||
|
const dateTime = (d) => `${dateKey(d)} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}:00`;
|
||||||
|
const plusHour = (s) => { const d = new Date(s.replace(' ', 'T')); d.setHours(d.getHours()+1); return dateTime(d); };
|
||||||
|
const blank = (start = nextSlot()) => ({ title: '', description: '', startsAt: start, endsAt: plusHour(start), allDay: false, location: '', videoUrl: '', projectId: '', reminderMinutes: '', participantUserIds: [], externalEmails: [], recurrence: null });
|
||||||
|
function toForm(row) { return { title: row.title, description: row.description || '', startsAt: row.starts_at, endsAt: row.ends_at, allDay: !!row.all_day, location: row.location || '', videoUrl: row.video_url || '', projectId: row.project_id || '', reminderMinutes: row.reminder_minutes ?? '', participantUserIds: row.participants.map((p) => p.user_id), externalEmails: row.external_emails, recurrence: row.recurrence_json ? JSON.parse(row.recurrence_json) : null, editVersion: row.edit_version }; }
|
||||||
|
// Hora por defecto de una reunion nueva: siguiente media hora (10:31 -> 11:00, 10:10 -> 10:30).
|
||||||
|
const nextSlot = () => { const d = ecuadorNow(); d.setMinutes(d.getMinutes() < 30 ? 30 : 60, 0, 0); return dateTime(d); };
|
||||||
|
const readable = (s, lang, allDay) => new Date(s.replace(' ', 'T')).toLocaleString(lang, allDay ? { dateStyle: 'full' } : { dateStyle: 'full', timeStyle: 'short' });
|
||||||
|
const ALL_LAYERS = { meeting: true, project: true, activity: true, task: true, recurring: true, clinic: true };
|
||||||
|
|
||||||
|
export default function CalendarioPage() {
|
||||||
|
const { t, lang, openDialog, pushToast } = useUi();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const runAction = useAction();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const [view, setView] = useState(() => { try { return localStorage.getItem('plc_calendar_view') || 'month'; } catch { return 'month'; } });
|
||||||
|
const [cursor, setCursor] = useState(ecuadorNow);
|
||||||
|
// Capas visibles del calendario (persistidas): reuniones, vencimientos de actividades y tareas recurrentes.
|
||||||
|
const [layers, setLayers] = useState(() => { try { return { ...ALL_LAYERS, ...JSON.parse(localStorage.getItem('plc_calendar_layers') || '{}') }; } catch { return { ...ALL_LAYERS }; } });
|
||||||
|
// Origen: todo, solo lo mio (lo creo/organizo yo) o solo lo compartido conmigo (lo creo otro y me incluye).
|
||||||
|
const [origin, setOrigin] = useState(() => { try { return localStorage.getItem('plc_calendar_origin') || 'all'; } catch { return 'all'; } });
|
||||||
|
const canProjects = hasModule(user, 'projects');
|
||||||
|
const canClinic = hasModule(user, 'clinic');
|
||||||
|
const { data: clinicStaff } = useAsync(() => canClinic ? clinicMe().catch(() => null) : Promise.resolve(null), [canClinic]);
|
||||||
|
const clinicDoctor = !!clinicStaff?.is_doctor;
|
||||||
|
const showDue = layers.activity && canProjects;
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [newStart, setNewStart] = useState(null);
|
||||||
|
const [warning, setWarning] = useState([]);
|
||||||
|
const [externalInput, setExternalInput] = useState('');
|
||||||
|
const [userChoice, setUserChoice] = useState('');
|
||||||
|
const [context, setContext] = useState(null);
|
||||||
|
const { from, to } = useMemo(() => calendarBounds(cursor, view), [cursor, view]);
|
||||||
|
const { data: meetings, reload } = useAsync(() => listMeetings(from, to), [from, to]);
|
||||||
|
const { data: activities } = useAsync(() => showDue ? listMeetingActivities(from, to) : Promise.resolve([]), [from, to, showDue]);
|
||||||
|
const canRecurring = hasModule(user, 'recurring_tasks');
|
||||||
|
const { data: recurring } = useAsync(() => layers.recurring && canRecurring ? listMeetingRecurring(from, to) : Promise.resolve([]), [from, to, layers.recurring, canRecurring]);
|
||||||
|
const { data: projectDates } = useAsync(() => layers.project && canProjects ? listMeetingProjects(from, to) : Promise.resolve([]), [from, to, layers.project, canProjects]);
|
||||||
|
const { data: myTasks } = useAsync(() => layers.task && canProjects ? listMeetingTasks(from, to) : Promise.resolve([]), [from, to, layers.task, canProjects]);
|
||||||
|
const { data: clinicAppointmentsData } = useAsync(() => clinicDoctor && layers.clinic ? clinicAppointments(from,to,user.id) : Promise.resolve([]), [clinicDoctor,layers.clinic,from,to,user.id]);
|
||||||
|
const { data: clinicBlocksData } = useAsync(() => clinicDoctor && layers.clinic ? clinicBlocks(from,to,user.id) : Promise.resolve([]), [clinicDoctor,layers.clinic,from,to,user.id]);
|
||||||
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
const { data: projects } = useAsync(listProjectOptions, []);
|
||||||
|
useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } });
|
||||||
|
const initial = creating ? blank(newStart || nextSlot()) : selected ? toForm(selected) : blank();
|
||||||
|
const draft = useDraftForm({ formId: creating ? 'meeting-new' : 'meeting-edit', entityId: creating ? null : selected?.id, initial, label: creating ? t('calendar_new') : `${t('nav_calendar')}: ${selected?.title || ''}`, enabled: creating || editing, onDiscard: () => setEditing(false) });
|
||||||
|
const v = draft.values;
|
||||||
|
const canManage = selected && !selected.cancelled_at && (selected.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode));
|
||||||
|
const canRsvp = !selected?.cancelled_at && selected?.participants?.some((p) => p.user_id === user?.id);
|
||||||
|
|
||||||
|
useEffect(() => { try { localStorage.setItem('plc_calendar_view', view); } catch { /* almacenamiento privado */ } }, [view]);
|
||||||
|
useEffect(() => { try { localStorage.setItem('plc_calendar_layers', JSON.stringify(layers)); } catch { /* almacenamiento privado */ } }, [layers]);
|
||||||
|
useEffect(() => { try { localStorage.setItem('plc_calendar_origin', origin); } catch { /* almacenamiento privado */ } }, [origin]);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = Number(searchParams.get('meeting'));
|
||||||
|
if (!id) return;
|
||||||
|
getMeeting(id).then((row) => { setSelected(row); setCreating(false); setEditing(false); }).catch(() => {});
|
||||||
|
setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('meeting'); return next; }, { replace: true });
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!context) return undefined;
|
||||||
|
const onEscape = (e) => { if (e.key === 'Escape') setContext(null); };
|
||||||
|
document.addEventListener('keydown', onEscape);
|
||||||
|
return () => document.removeEventListener('keydown', onEscape);
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
function openNew(day) { setNewStart(day || nextSlot()); setCreating(true); setEditing(true); setSelected(null); setWarning([]); }
|
||||||
|
async function openMeeting(item, edit = false) { try { const row = await getMeeting(item.id); setSelected(row); setCreating(false); setEditing(edit); setWarning([]); } catch (err) { pushToast({tone:'error',message:err.message}); } }
|
||||||
|
function close() { void guardedClose((creating || editing) ? draft : null, () => { setCreating(false); setEditing(false); setSelected(null); }); }
|
||||||
|
async function submit(confirmConflicts = false) {
|
||||||
|
try { await runAction(async () => {
|
||||||
|
const payload = { ...v, participantUserIds: v.participantUserIds.map(Number), externalEmails: v.externalEmails, projectId: v.projectId || null, reminderMinutes: v.reminderMinutes || null, confirmConflicts };
|
||||||
|
const result = creating ? await createMeeting(payload) : await updateMeeting(selected.id, payload);
|
||||||
|
if (result.requiresConfirmation) { setWarning(result.warnings); return; }
|
||||||
|
draft.clear(); setCreating(false); setEditing(false); setSelected(null); setWarning([]); await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
}); } catch { /* useAction ya muestra el error */ }
|
||||||
|
}
|
||||||
|
function save() { if (!creating && !draft.isDirty) return; const go = () => submit(false); if (creating) void go(); else void draft.guardSave(go, { fetchCurrent: async () => toForm(await getMeeting(selected.id)) }); }
|
||||||
|
function confirmSaveWithConflict() { const go = () => submit(true); if (creating) void go(); else void draft.guardSave(go); }
|
||||||
|
function confirmCancel(occurrence = false, item = selected) {
|
||||||
|
openDialog({ tone: 'warning', title: t('calendar_cancel'), message: occurrence ? t('calendar_cancel_occurrence_confirm') : t('calendar_cancel_confirm'), actions: [
|
||||||
|
{ label: t('common_cancel'), variant: 'standard' },
|
||||||
|
{ label: t('calendar_cancel'), variant: 'danger', onClick: () => runAction(async () => {
|
||||||
|
if (occurrence) await cancelMeetingOccurrence(item.id, item.occurrence_start || item.starts_at);
|
||||||
|
else await cancelMeeting(item.id);
|
||||||
|
draft.discard(); setSelected(null); setEditing(false); await reload(); pushToast({ tone: 'success', message: t('calendar_cancelled') });
|
||||||
|
}).catch(() => {}) },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
async function rsvp(status) { try { await runAction(async () => { setSelected(await respondMeeting(selected.id, status)); pushToast({ tone: 'success', message: t('common_saved') }); }); } catch { /* diálogo visible */ } }
|
||||||
|
function addExternal() {
|
||||||
|
const email = externalInput.trim().toLowerCase();
|
||||||
|
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i.test(email) || email.length > 160 || v.externalEmails.length + v.participantUserIds.length >= 50) { pushToast({ tone:'error', message:t('calendar_invalid_email') }); return; }
|
||||||
|
draft.setField('externalEmails', [...new Set([...v.externalEmails, email])]); setExternalInput(''); setWarning([]);
|
||||||
|
}
|
||||||
|
const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` }));
|
||||||
|
const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))];
|
||||||
|
const allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.clinic && clinicDoctor ? [...(clinicAppointmentsData||[]).filter((a)=>!a.deleted_at&&a.status!=='cancelled').map((a)=>({...a,kind:'clinic',day:a.starts_at.slice(0,10),occurrence_start:a.starts_at,clinicLabel:`${a.first_names} ${a.last_names?.slice(0,1)||''}.`})),...(clinicBlocksData||[]).map((b)=>({...b,kind:'clinic',day:b.occurrence_start.slice(0,10),clinicLabel:b.title}))] : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).sort((a,b) => (a.day || '').localeCompare(b.day || ''));
|
||||||
|
|
||||||
|
const availableLayers = [['meeting', t('calendar_layer_meetings')], ...(canProjects ? [['project', t('calendar_layer_projects')], ['activity', t('calendar_layer_activities')], ['task', t('calendar_layer_tasks')]] : []), ...(canRecurring ? [['recurring', t('calendar_layer_recurring')]] : []), ...(clinicDoctor ? [['clinic', t('calendar_layer_clinic')]] : [])];
|
||||||
|
const activeTypes = availableLayers.filter(([kind]) => layers[kind]).map(([kind]) => kind);
|
||||||
|
const allTypesOn = activeTypes.length === availableLayers.length;
|
||||||
|
const typeValues = allTypesOn ? ['all', ...activeTypes] : activeTypes;
|
||||||
|
// "Todo" marca o desmarca todos; para ver solo un tipo: desmarcar Todo y marcar ese tipo.
|
||||||
|
function changeTypes(next) {
|
||||||
|
const keys = Object.keys(ALL_LAYERS);
|
||||||
|
if (next.includes('all') && !typeValues.includes('all')) setLayers({ ...ALL_LAYERS });
|
||||||
|
else if (!next.includes('all') && typeValues.includes('all')) setLayers(Object.fromEntries(keys.map((k) => [k, false])));
|
||||||
|
else setLayers(Object.fromEntries(keys.map((k) => [k, next.includes(k)])));
|
||||||
|
}
|
||||||
|
// Tarea recurrente compartida (soy responsable, no la cree): Tareas recurrentes no me la muestra,
|
||||||
|
// asi que se ve aqui en solo lectura.
|
||||||
|
function openRecurring(item) {
|
||||||
|
if (item.can_open) { navigate(`/tareas-recurrentes?task=${item.id}`); return; }
|
||||||
|
openDialog({ tone: 'info', title: item.name, message: [`${t('calendar_due')}: ${readable(`${item.due_date} 00:00:00`, lang, true)}`, item.project_name && `${t('calendar_project')}: ${item.project_name}`, item.overdue && t('calendar_overdue')].filter(Boolean).join(' · '), actions: [{ label: t('common_close'), variant: 'accent' }] });
|
||||||
|
}
|
||||||
|
function itemButton(item) { return <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}${item.overdue ? ' overdue' : ''}${item.kind === 'recurring' && !item.is_next ? ' projected' : ''}${item.is_closed ? ' done' : ''}${item.shared ? ' shared' : ''}`} title={item.kind === 'meeting' ? item.title : item.kind === 'clinic' ? item.clinicLabel : `${item.name}${item.project_name ? ` · ${item.project_name}` : ''}${item.overdue ? ` (${t('calendar_overdue')})` : ''}`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === 'clinic') navigate('/agenda-medica'); else if (item.kind === 'recurring') openRecurring(item); else if (item.kind === 'project') navigate(`/proyectos/${item.id}`); else if (item.kind === 'task') navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.activity_id}`); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }}
|
||||||
|
onContextMenu={(e) => { if (item.kind !== 'meeting') return; e.preventDefault(); setContext({ item, x: e.clientX, y: e.clientY }); }}
|
||||||
|
onKeyDown={(e) => { if (item.kind === 'meeting' && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) { e.preventDefault(); const r=e.currentTarget.getBoundingClientRect(); setContext({item,x:r.left,y:r.bottom}); } }}>
|
||||||
|
{item.kind === 'meeting' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : item.kind === 'clinic' ? `${item.occurrence_start.slice(11,16)} ${item.clinicLabel}` : item.kind === 'recurring' ? `↻ ${item.name}` : item.kind === 'project' ? `${item.milestone === 'start' ? '▶' : '⚑'} ${item.code} ${item.name}` : item.kind === 'task' ? `☐ ${item.name}` : `◆ ${item.name}`}
|
||||||
|
{item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
|
||||||
|
</button>; }
|
||||||
|
|
||||||
|
return <div className="meeting-calendar">
|
||||||
|
<CalendarBoard title={t('nav_calendar')} icon={CalendarDays} view={view} onViewChange={setView} cursor={cursor} onCursorChange={setCursor}
|
||||||
|
actions={<Button variant="accent" icon={Plus} onClick={() => openNew()}>{t('calendar_new')}</Button>}
|
||||||
|
filters={<>
|
||||||
|
<label className="calendar-filter-label">{t('calendar_filter_origin')}
|
||||||
|
<ComboBox options={['all', 'mine', 'shared'].map((x) => ({ value: x, label: t(`calendar_origin_${x}`) }))} value={origin} onChange={setOrigin} />
|
||||||
|
</label>
|
||||||
|
<label className="calendar-filter-label">{t('calendar_filter_types')}
|
||||||
|
<CheckListDropdown label={allTypesOn ? t('calendar_all_types') : activeTypes.length === 0 ? t('calendar_no_types') : activeTypes.length === 1 ? availableLayers.find(([k]) => k === activeTypes[0])[1] : `${activeTypes.length} / ${availableLayers.length}`} options={[{ value: 'all', label: t('calendar_all_types') }, ...availableLayers.map(([value, label]) => ({ value, label }))]} values={typeValues} onChange={changeTypes} width={240} />
|
||||||
|
</label>
|
||||||
|
</>}
|
||||||
|
legend={<>
|
||||||
|
{availableLayers.filter(([kind]) => layers[kind]).map(([kind, label]) => <span key={kind} className={`calendar-legend-item ${kind}`}><span className="calendar-swatch" />{label}</span>)}
|
||||||
|
<span className="calendar-legend-item overdue"><span className="calendar-swatch" />{t('calendar_overdue')}</span>
|
||||||
|
<span className="calendar-legend-item shared-sample"><span className="calendar-swatch" />{t('calendar_origin_shared')}</span>
|
||||||
|
</>}
|
||||||
|
items={allItems} renderItem={itemButton} onSlot={(key, hour) => openNew(`${key} ${String(hour ?? 9).padStart(2, '0')}:00:00`)} />
|
||||||
|
|
||||||
|
{context && <div className="calendar-context-layer" onClick={() => setContext(null)}><div className="flyout-panel calendar-context" style={{ left:Math.min(context.x, window.innerWidth-220), top:Math.min(context.y, window.innerHeight-190) }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<FlyoutItem onClick={() => { void openMeeting(context.item); setContext(null); }}>{t('calendar_open')}</FlyoutItem>
|
||||||
|
{(context.item.organizer_id === user?.id || ['admin','superadmin'].includes(user?.roleCode)) && <><FlyoutItem icon={Pencil} onClick={() => { void openMeeting(context.item,true); setContext(null); }}>{t('common_edit')}</FlyoutItem><FlyoutSeparator />{context.item.recurrence_json && <FlyoutItem icon={XCircle} onClick={() => { confirmCancel(true,context.item); setContext(null); }}>{t('calendar_cancel_occurrence')}</FlyoutItem>}<FlyoutItem icon={Trash2} danger onClick={() => { confirmCancel(false,context.item); setContext(null); }}>{t('common_delete')}</FlyoutItem></>}
|
||||||
|
</div></div>}
|
||||||
|
|
||||||
|
<DetailPopup open={creating || !!selected} title={creating ? t('calendar_new') : selected?.title} onClose={close} width={760} height={680}>
|
||||||
|
{warning.length > 0 && <InfoBar tone="warning">{t('calendar_conflicts')}: {warning.join(', ')}. <Button size="sm" onClick={confirmSaveWithConflict}>{t('calendar_save_anyway')}</Button></InfoBar>}
|
||||||
|
{editing && <DraftNotice draft={draft} />}
|
||||||
|
{!editing && selected ? <div className="calendar-detail">
|
||||||
|
{selected.cancelled_at && <InfoBar tone="warning">{t('calendar_cancelled')}</InfoBar>}
|
||||||
|
<p><strong>{t('calendar_start')}:</strong> {readable(selected.starts_at, lang, !!selected.all_day)}<br /><strong>{t('calendar_end')}:</strong> {readable(selected.ends_at, lang, !!selected.all_day)}</p>
|
||||||
|
{!!selected.all_day && <p>{t('calendar_all_day')}</p>}
|
||||||
|
{selected.description && <p>{selected.description}</p>}{selected.location && <p>{t('calendar_location')}: {selected.location}</p>}
|
||||||
|
{selected.video_url && <p><a href={selected.video_url} target="_blank" rel="noreferrer">{selected.video_url}</a></p>}
|
||||||
|
{selected.project_name && <p>{t('calendar_project')}: {selected.project_name}</p>}
|
||||||
|
{!!selected.reminder_minutes && <p>{t('calendar_reminder')}: {selected.reminder_minutes === 1440 ? t('calendar_one_day') : `${selected.reminder_minutes} ${t('calendar_minutes')}`}</p>}
|
||||||
|
{selected.recurrence_json && <p>{t('calendar_recurrence')}: {t(`calendar_${JSON.parse(selected.recurrence_json).frequency}`)}</p>}
|
||||||
|
<p>{t('calendar_organizer')}: {selected.organizer_name}</p>
|
||||||
|
<Field label={t('calendar_participants')}><div>{selected.participants.map((p) => <div key={p.user_id}>{p.name} — {t(`calendar_rsvp_${p.rsvp}`)}</div>)}</div></Field>
|
||||||
|
{selected.external_emails.length > 0 && <Field label={t('calendar_external')}><div>{selected.external_emails.map((e) => <div key={e}>{e}</div>)}</div></Field>}
|
||||||
|
{canRsvp && <div className="toolbar-left">{['accepted','declined','tentative'].map((s) => <Button key={s} onClick={() => void rsvp(s)}>{t(`calendar_rsvp_${s}`)}</Button>)}</div>}
|
||||||
|
{canManage && <div className="toolbar-left" style={{ marginTop:16 }}><Button icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={Trash2} variant="danger" onClick={() => confirmCancel()}>{t('common_delete')}</Button></div>}
|
||||||
|
</div> : <div className="calendar-form">
|
||||||
|
<Field label={t('calendar_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title',e.target.value)} /></Field>
|
||||||
|
<Field label={t('calendar_description')} dirty={draft.isFieldDirty('description')}><textarea className="textarea" maxLength={5000} value={v.description} onChange={(e) => draft.setField('description',e.target.value)} /></Field>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt',x)} allDay={v.allDay} label={t('calendar_start')} /></Field>
|
||||||
|
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt',x)} allDay={v.allDay} label={t('calendar_end')} isEnd /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
<label className="calendar-filter"><input type="checkbox" checked={v.allDay} onChange={(e) => { const checked = e.target.checked; draft.setValues((old) => ({ ...old, allDay: checked, startsAt: checked ? `${old.startsAt.slice(0,10)} 00:00:00` : old.startsAt, endsAt: checked ? `${old.endsAt.slice(0,10)} 23:59:00` : old.endsAt })); }} /> {t('calendar_all_day')}</label>
|
||||||
|
<FieldRow><Field label={t('calendar_location')}><input className="input" maxLength={255} value={v.location} onChange={(e) => draft.setField('location',e.target.value)} /></Field><Field label={t('calendar_video_url')}><input className="input" type="url" maxLength={2048} value={v.videoUrl} onChange={(e) => draft.setField('videoUrl',e.target.value)} /></Field></FieldRow>
|
||||||
|
<FieldRow><Field label={t('calendar_project')}><ComboBox options={projectOptions} value={v.projectId} onChange={(x) => draft.setField('projectId',x)} /></Field><Field label={t('calendar_reminder')}><ComboBox options={[{value:'',label:t('calendar_none')}, ...[5,10,15,30,60,1440].map((n) => ({value:n,label:n===1440?t('calendar_one_day'):`${n} ${t('calendar_minutes')}`}))]} value={v.reminderMinutes} onChange={(x) => draft.setField('reminderMinutes',x)} /></Field></FieldRow>
|
||||||
|
<Field label={t('calendar_participants')}><><div className="toolbar-left"><ComboBox options={[{value:'',label:t('common_select')},...userOptions]} value={userChoice} onChange={setUserChoice} /><Button onClick={() => { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')}</Button></div>
|
||||||
|
<div className="calendar-tags">{v.participantUserIds.map((id) => <button key={id} onClick={() => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×</button>)}</div></></Field>
|
||||||
|
<Field label={t('calendar_external')}><><div className="toolbar-left"><input className="input" type="email" value={externalInput} onChange={(e) => setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} /><Button onClick={addExternal}>{t('calendar_add')}</Button></div><div className="calendar-tags">{v.externalEmails.map((e) => <button key={e} onClick={() => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×</button>)}</div></></Field>
|
||||||
|
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x=>draft.setField('recurrence',x)}/>
|
||||||
|
<div className="toolbar-left" style={{ marginTop:18 }}><Button variant="accent" onClick={save} disabled={!v.title.trim() || (!creating && !draft.isDirty)}>{t('common_save')}</Button><Button onClick={() => { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')}</Button></div>
|
||||||
|
</div>}
|
||||||
|
</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles } from 'lucide-react';
|
import { Palette, ListChecks, Flag, FolderKanban, Layers, Mail, Building2, RefreshCw, Bug, Sparkles, Plug } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { Expander } from '../../components/Expander.jsx';
|
import { Expander } from '../../components/Expander.jsx';
|
||||||
@@ -13,6 +13,7 @@ import AiUsagePanel from './AiUsagePanel.jsx';
|
|||||||
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
import CompanySettingsPanel from './CompanySettingsPanel.jsx';
|
||||||
import UpdaterPanel from './UpdaterPanel.jsx';
|
import UpdaterPanel from './UpdaterPanel.jsx';
|
||||||
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
import ErrorLogsPanel from './ErrorLogsPanel.jsx';
|
||||||
|
import ExternalConnectionsPanel from './ExternalConnectionsPanel.jsx';
|
||||||
|
|
||||||
export default function ConfiguracionPage() {
|
export default function ConfiguracionPage() {
|
||||||
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
const { t, theme, setTheme, lang, setLang, buttonMode, setButtonMode } = useUi();
|
||||||
@@ -117,6 +118,10 @@ export default function ConfiguracionPage() {
|
|||||||
/>
|
/>
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|
||||||
|
<Expander icon={Plug} title={t('config_integrations')} draftForms={['ext-connection', 'ext-source']}>
|
||||||
|
<ExternalConnectionsPanel />
|
||||||
|
</Expander>
|
||||||
|
|
||||||
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
<Expander icon={Mail} title={t('config_smtp')} draftForms={['smtp-config']}>
|
||||||
<SmtpSettingsPanel />
|
<SmtpSettingsPanel />
|
||||||
</Expander>
|
</Expander>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* Conexiones externas: mismo esquema que los formularios de la Agenda (pares de campos, acciones al pie). */
|
||||||
|
.ext-panel h3:not(.mt-0){margin:20px 0 4px}
|
||||||
|
.ext-panel h3.mt-0{margin-bottom:4px}
|
||||||
|
.ext-hint{font-size:.78rem;margin:8px 0 0}
|
||||||
|
.ext-form{display:grid;gap:12px;align-content:start}
|
||||||
|
.ext-form .field{min-width:0}
|
||||||
|
.ext-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
|
||||||
|
.ext-detail p{margin:5px 0;overflow-wrap:anywhere}
|
||||||
|
.ext-actions{margin-top:18px;flex-wrap:wrap}
|
||||||
|
.ext-preview{overflow:auto;max-height:240px;border:1px solid rgb(var(--line));border-radius:var(--r-sm)}
|
||||||
|
.ext-preview .list-table{font-size:.75rem;white-space:nowrap}
|
||||||
|
.ext-subtitle{margin:4px 0 0;font-size:.9rem}
|
||||||
|
.ext-map{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px 12px}
|
||||||
|
@media(max-width:640px){.ext-detail{grid-template-columns:1fr}}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Plus, Pencil, PlugZap, RefreshCw, DatabaseZap } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
import * as api from '../../services/integrations.js';
|
||||||
|
import './ExternalConnectionsPanel.css';
|
||||||
|
|
||||||
|
// Conexiones externas (superadmin): conectores de solo lectura y qué tabla alimenta Pacientes y
|
||||||
|
// Empleados. Ver docs/CONEXIONES-EXTERNAS.md. Listado → popup en lectura → Editar.
|
||||||
|
|
||||||
|
const TARGETS = {
|
||||||
|
patients: ['externalId', 'identification', 'fullName', 'phone', 'email'],
|
||||||
|
employees: ['identification', 'lastNames', 'firstNames', 'phone', 'phone2', 'birthDate', 'position', 'department', 'hireDate', 'leaveDate', 'active'],
|
||||||
|
providers: ['externalId', 'identification', 'fullName', 'phone', 'email']
|
||||||
|
};
|
||||||
|
const REQUIRED = { patients: ['externalId', 'fullName'], employees: ['identification'], providers: ['externalId', 'fullName'] };
|
||||||
|
const connForm = (c) => (c ? { name: c.name, baseUrl: c.base_url, token: '', isActive: c.is_active, editVersion: c.edit_version } : { name: '', baseUrl: '', token: '', isActive: true });
|
||||||
|
const sourceForm = (purpose, s) => {
|
||||||
|
const [filterField = '', filterValue = ''] = Object.entries(s?.filter || {})[0] || [];
|
||||||
|
return s
|
||||||
|
? { connectionId: s.connection_id, sourceKey: s.source_key, tableName: s.table_name, mode: s.mode, fieldMap: s.field_map, filterField, filterValue, changedField: s.changed_field || '', editVersion: s.edit_version }
|
||||||
|
: { connectionId: '', sourceKey: '', tableName: '', mode: 'import', fieldMap: {}, filterField: '', filterValue: '', changedField: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function syncSummary(t, r) {
|
||||||
|
if (!r) return t('emp_never');
|
||||||
|
return `${String(r.at).slice(0, 16)} · ${r.received ?? r.total} ${t('int_received')} · ${r.created} ${t('int_created')} · ${r.updated} ${t('int_updated')}${r.deactivated || r.removed ? ` · ${r.deactivated ?? r.removed} ${t('int_deactivated')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExternalConnectionsPanel() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const [connections, setConnections] = useState([]), [sources, setSources] = useState([]);
|
||||||
|
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||||
|
const [remote, setRemote] = useState({ sources: [], tables: [], schema: null, sample: [] });
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const [c, s] = await Promise.all([api.listConnections(), api.listSources()]);
|
||||||
|
setConnections(c); setSources(s); setError('');
|
||||||
|
} catch (e) { setError(e.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void reload(); }, []);
|
||||||
|
|
||||||
|
const sourceOf = (purpose) => sources.find((s) => s.purpose === purpose);
|
||||||
|
const initial = !popup ? {} : popup.kind === 'connection' ? connForm(popup.item) : sourceForm(popup.purpose, sourceOf(popup.purpose));
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: popup?.kind === 'source' ? 'ext-source' : 'ext-connection',
|
||||||
|
entityId: popup?.kind === 'source' ? popup.purpose : popup?.item?.id,
|
||||||
|
initial,
|
||||||
|
label: popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : t('int_connections'),
|
||||||
|
enabled: !!popup && editing,
|
||||||
|
exclude: ['token'],
|
||||||
|
onDiscard: () => { if (popup?.item || popup?.kind === 'source') setEditing(false); else setPopup(null); }
|
||||||
|
});
|
||||||
|
const v = draft.values;
|
||||||
|
|
||||||
|
// Exploración del conector mientras se edita una fuente: fuentes → tablas → estructura + muestra.
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId) return undefined;
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId).then((x) => active && setRemote((r) => ({ ...r, sources: x.sources }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey) return undefined;
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId, v.sourceKey).then((x) => active && setRemote((r) => ({ ...r, tables: x.tables }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId, v.sourceKey]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'source' || !editing || !v.connectionId || !v.sourceKey || !v.tableName) { setRemote((r) => ({ ...r, schema: null, sample: [] })); return undefined; }
|
||||||
|
let active = true;
|
||||||
|
api.browseConnection(v.connectionId, v.sourceKey, v.tableName).then((x) => active && setRemote((r) => ({ ...r, schema: x.schema, sample: x.sample }))).catch((e) => active && setError(e.message));
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [popup?.kind, editing, v.connectionId, v.sourceKey, v.tableName]);
|
||||||
|
|
||||||
|
function open(kind, item = null, extra = {}) {
|
||||||
|
setError('');
|
||||||
|
setRemote({ sources: [], tables: [], schema: null, sample: [] });
|
||||||
|
setPopup({ kind, item, ...extra });
|
||||||
|
setEditing(kind === 'connection' && !item);
|
||||||
|
}
|
||||||
|
const close = () => void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); });
|
||||||
|
|
||||||
|
async function run(fn, okMessage = t('common_saved')) {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
pushToast({ tone: 'success', message: okMessage });
|
||||||
|
return result;
|
||||||
|
} catch (e) { setError(e.message); return null; } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const saved = await run(async () => {
|
||||||
|
if (popup.kind === 'connection') return api.saveConnection({ ...v, token: v.token || undefined }, popup.item?.id);
|
||||||
|
return api.saveSource(popup.purpose, {
|
||||||
|
connectionId: Number(v.connectionId), sourceKey: v.sourceKey, tableName: v.tableName, mode: v.mode, fieldMap: v.fieldMap,
|
||||||
|
filter: v.filterField && v.filterValue ? { [v.filterField]: v.filterValue } : {}, changedField: v.changedField || null, editVersion: v.editVersion
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!saved) return;
|
||||||
|
draft.clear();
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
const test = (id) => run(() => api.testConnection(id), t('int_test_ok'));
|
||||||
|
async function sync(kind) {
|
||||||
|
const r = await run(() => (kind === 'employees' ? api.syncEmployees() : api.syncPatients(kind === 'full')), t('common_saved'));
|
||||||
|
if (r) { await reload(); setPopup((p) => p && { ...p }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = remote.schema?.fields?.map((f) => f.name) || [];
|
||||||
|
const fieldOptions = [{ value: '', label: '—' }, ...fields.map((f) => ({ value: f, label: f }))];
|
||||||
|
const canSave = popup?.kind === 'connection'
|
||||||
|
? !!(v.name?.trim() && v.baseUrl?.trim() && (popup.item || v.token))
|
||||||
|
: !!(v.connectionId && v.sourceKey && v.tableName && REQUIRED[popup?.purpose || 'patients'].every((k) => v.fieldMap?.[k]));
|
||||||
|
const src = popup?.kind === 'source' ? sourceOf(popup.purpose) : null;
|
||||||
|
|
||||||
|
return <div className="ext-panel">
|
||||||
|
{error && !popup && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
<div className="toolbar"><h3 className="mt-0">{t('int_connections')}</h3><Button variant="accent" icon={Plus} onClick={() => open('connection')}>{t('int_new_connection')}</Button></div>
|
||||||
|
<ListView getRowKey={(c) => c.id} rows={connections} onRowClick={(c) => open('connection', c)}
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', label: t('common_name') },
|
||||||
|
{ key: 'base_url', label: t('int_url') },
|
||||||
|
{ key: 'is_active', label: t('common_status'), render: (c) => <Badge color={c.is_active ? 'success' : 'muted'}>{t(c.is_active ? 'common_active' : 'common_inactive')}</Badge> }
|
||||||
|
]}
|
||||||
|
rowActions={(c) => [{ label: t('int_test'), icon: PlugZap, onClick: () => void test(c.id) }, { label: t('common_edit'), icon: Pencil, onClick: () => { open('connection', c); setEditing(true); } }]} />
|
||||||
|
|
||||||
|
<div className="toolbar"><h3>{t('int_sources')}</h3></div>
|
||||||
|
<ListView getRowKey={(p) => p} rows={['patients', 'employees', 'providers']} onRowClick={(p) => open('source', null, { purpose: p })}
|
||||||
|
columns={[
|
||||||
|
{ key: 'purpose', label: t('int_source'), sortable: false, render: (p) => t(`int_purpose_${p}`) },
|
||||||
|
{ key: 'table', label: t('int_table'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? `${s.connection_name} · ${s.source_key}/${s.table_name}` : <Badge color="muted">{t('int_not_configured')}</Badge>; } },
|
||||||
|
{ key: 'mode', label: t('int_mode'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? t(`int_mode_${s.mode}`) : '—'; } },
|
||||||
|
{ key: 'sync', label: t('int_last_sync'), sortable: false, render: (p) => { const s = sourceOf(p); return s ? syncSummary(t, s.last_result) : '—'; } }
|
||||||
|
]} />
|
||||||
|
<p className="text-muted ext-hint">{t('int_schedule_hint')}</p>
|
||||||
|
|
||||||
|
<DetailPopup open={!!popup} title={popup?.kind === 'source' ? t(`int_purpose_${popup.purpose}`) : popup?.item ? popup.item.name : t('int_new_connection')} onClose={close} width={popup?.kind === 'source' ? 980 : 760} height={popup?.kind === 'source' ? 720 : 460}>
|
||||||
|
{popup && <div className="ext-form">
|
||||||
|
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{editing && <DraftNotice draft={draft} />}
|
||||||
|
{!editing ? <>
|
||||||
|
{popup.kind === 'connection' && <>
|
||||||
|
<div className="ext-detail">
|
||||||
|
<p><strong>{t('common_name')}:</strong> {popup.item.name}</p>
|
||||||
|
<p><strong>{t('int_url')}:</strong> {popup.item.base_url}</p>
|
||||||
|
<p><strong>{t('common_status')}:</strong> {t(popup.item.is_active ? 'common_active' : 'common_inactive')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-left ext-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button icon={PlugZap} disabled={busy} onClick={() => void test(popup.item.id)}>{t('int_test')}</Button></div>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'source' && <>
|
||||||
|
{!src ? <InfoBar tone="warning">{t('int_not_configured')}</InfoBar> : <div className="ext-detail">
|
||||||
|
<p><strong>{t('int_connection')}:</strong> {src.connection_name}</p>
|
||||||
|
<p><strong>{t('int_table')}:</strong> {src.source_key}/{src.table_name}</p>
|
||||||
|
<p><strong>{t('int_mode')}:</strong> {t(`int_mode_${src.mode}`)}</p>
|
||||||
|
{src.field_map && <p><strong>{t('int_mapping')}:</strong> {Object.entries(src.field_map).map(([k, f]) => `${t(`int_target_${k}`)} ← ${f}`).join(' · ')}</p>}
|
||||||
|
{src.filter && Object.keys(src.filter).length > 0 && <p><strong>{t('int_filter')}:</strong> {Object.entries(src.filter).map(([k, x]) => `${k} = ${x}`).join(', ')}</p>}
|
||||||
|
{src.changed_field && <p><strong>{t('int_changed_field')}:</strong> {src.changed_field}</p>}
|
||||||
|
<p><strong>{t('int_last_sync')}:</strong> {syncSummary(t, src.last_result)}</p>
|
||||||
|
</div>}
|
||||||
|
<div className="toolbar-left ext-actions">
|
||||||
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{src ? t('common_edit') : t('int_configure')}</Button>
|
||||||
|
{src && popup.purpose === 'employees' && <Button icon={RefreshCw} disabled={busy} onClick={() => void sync('employees')}>{t('emp_update')}</Button>}
|
||||||
|
{src && popup.purpose === 'patients' && src.mode === 'import' && <>
|
||||||
|
<Button icon={RefreshCw} disabled={busy} onClick={() => void sync('changes')}>{t('int_sync_now')}</Button>
|
||||||
|
<Button icon={DatabaseZap} disabled={busy} onClick={() => void sync('full')}>{t('int_sync_full')}</Button>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</> : <>
|
||||||
|
{popup.kind === 'connection' && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('common_name')} dirty={draft.isFieldDirty('name')}><input className="input" maxLength={120} value={v.name} onChange={(e) => draft.setField('name', e.target.value)} /></Field>
|
||||||
|
<Field label={t('int_url')} dirty={draft.isFieldDirty('baseUrl')}><input className="input" maxLength={255} placeholder="http://192.168.0.253:8455" value={v.baseUrl} onChange={(e) => draft.setField('baseUrl', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_token')} hint={popup.item ? t('int_token_keep') : undefined}><PasswordInput value={v.token} onChange={(e) => draft.setField('token', e.target.value)} /></Field>
|
||||||
|
<Field label={t('common_status')}><label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('common_active')}</label></Field>
|
||||||
|
</FieldRow>
|
||||||
|
</>}
|
||||||
|
{popup.kind === 'source' && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_connection')} dirty={draft.isFieldDirty('connectionId')}><ComboBox value={v.connectionId} onChange={(x) => draft.setValues((o) => ({ ...o, connectionId: x, sourceKey: '', tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...connections.filter((c) => c.is_active).map((c) => ({ value: c.id, label: c.name }))]} /></Field>
|
||||||
|
{popup.purpose === 'patients'
|
||||||
|
? <Field label={t('int_mode')} dirty={draft.isFieldDirty('mode')}><ComboBox value={v.mode} onChange={(x) => draft.setField('mode', x)} options={['import', 'live'].map((x) => ({ value: x, label: t(`int_mode_${x}`) }))} /></Field>
|
||||||
|
: <div className="field" />}
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_source')} dirty={draft.isFieldDirty('sourceKey')}><ComboBox value={v.sourceKey} onChange={(x) => draft.setValues((o) => ({ ...o, sourceKey: x, tableName: '' }))} options={[{ value: '', label: t('common_select') }, ...remote.sources.map((s) => ({ value: s.id, label: s.name }))]} /></Field>
|
||||||
|
<Field label={t('int_table')} dirty={draft.isFieldDirty('tableName')}><ComboBox value={v.tableName} onChange={(x) => draft.setField('tableName', x)} options={[{ value: '', label: t('common_select') }, ...remote.tables.filter((x) => !x.error).map((x) => ({ value: x.name, label: `${x.name} (${x.records})` }))]} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{v.tableName && !remote.schema && <p className="text-muted">{t('common_loading')}</p>}
|
||||||
|
{remote.schema && <>
|
||||||
|
<Field label={t('int_preview')}><div className="ext-preview"><table className="list-table"><thead><tr>{fields.map((f) => <th key={f}>{f}</th>)}</tr></thead><tbody>{remote.sample.map((r) => <tr key={r._recno}>{fields.map((f) => <td key={f}>{r[f] === null || r[f] === undefined ? '' : String(r[f])}</td>)}</tr>)}</tbody></table></div></Field>
|
||||||
|
<h4 className="ext-subtitle">{t('int_mapping')}</h4>
|
||||||
|
<div className="ext-map">
|
||||||
|
{TARGETS[popup.purpose].map((k) => <Field key={k} label={`${t(`int_target_${k}`)}${REQUIRED[popup.purpose].includes(k) ? ` (${t('int_required')})` : ''}`}>
|
||||||
|
<ComboBox value={v.fieldMap?.[k] || ''} onChange={(x) => draft.setValues((o) => ({ ...o, fieldMap: { ...o.fieldMap, [k]: x || undefined } }))} options={fieldOptions} />
|
||||||
|
</Field>)}
|
||||||
|
</div>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={`${t('int_filter')} · ${t('int_filter_field')}`}><ComboBox value={v.filterField} onChange={(x) => draft.setField('filterField', x)} options={fieldOptions} /></Field>
|
||||||
|
<Field label={t('int_filter_value')}><input className="input" maxLength={60} value={v.filterValue} onChange={(e) => draft.setField('filterValue', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{popup.purpose === 'patients' && <FieldRow>
|
||||||
|
<Field label={t('int_changed_field')}><ComboBox value={v.changedField} onChange={(x) => draft.setField('changedField', x)} options={[{ value: '', label: '—' }, ...(remote.schema.fields || []).filter((f) => f.type === 'T').map((f) => ({ value: f.name, label: f.name }))]} /></Field>
|
||||||
|
<div className="field" />
|
||||||
|
</FieldRow>}
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
<div className="toolbar-left ext-actions">
|
||||||
|
<Button variant="accent" disabled={busy || !canSave || (!!(popup.item || src) && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
|
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>}
|
||||||
|
</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { Pencil, Save, Plus, Trash2, Power, Copy, Download } from 'lucide-react';
|
import { Pencil, Save, Plus, Trash2, Power, Copy, Download, Sparkles } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import {
|
import {
|
||||||
listDevices, getDevice, updateDevice, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
listDevices, getDevice, updateDevice, fetchDeviceCpuYear, getDevicesDashboard, listSoftware, listSoftwareDevices,
|
||||||
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
|
getDevicesAccess, listDeviceViewers, addDeviceViewer, removeDeviceViewer,
|
||||||
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
listInventoryTokens, createInventoryToken, setInventoryTokenActive, deleteInventoryToken, getInventoryTokenSecret,
|
||||||
} from '../../services/devices.js';
|
} from '../../services/devices.js';
|
||||||
@@ -23,6 +23,7 @@ import { ListView } from '../../components/ListView.jsx';
|
|||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||||
|
import { SoftwareList, SoftwareTypeFilter, matchesSoftwareType } from './SoftwareList.jsx';
|
||||||
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
import { buildInstallerBat, downloadTextFile, GLPI_AGENT_VERSION } from './agentInstaller.js';
|
||||||
|
|
||||||
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
const KINDS = ['desktop', 'laptop', 'server', 'virtual', 'printer', 'network', 'other'];
|
||||||
@@ -69,6 +70,8 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
const setTab = onTabChange; // la pestaña vive en la URL (?deviceTab=)
|
||||||
const [searches, setSearches] = useState({});
|
const [searches, setSearches] = useState({});
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
const [showComponents, setShowComponents] = useState(false);
|
||||||
|
const [softwareType, setSoftwareType] = useState('');
|
||||||
|
const [fetchingYear, setFetchingYear] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
// Borrador de los datos editables del equipo (estado, asignado, departamento, notas).
|
||||||
const draft = useDraftForm({
|
const draft = useDraftForm({
|
||||||
@@ -84,6 +87,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name }));
|
||||||
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
const deptOptions = flat(departments).map((d) => ({ value: d.id, label: d.name }));
|
||||||
|
|
||||||
|
// Año de lanzamiento del procesador: la IA lo consulta solo con el nombre del procesador.
|
||||||
|
function getCpuYear() {
|
||||||
|
setFetchingYear(true);
|
||||||
|
runAction(async () => {
|
||||||
|
await fetchDeviceCpuYear(id);
|
||||||
|
await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
}).catch(() => {}).finally(() => setFetchingYear(false));
|
||||||
|
}
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
}
|
}
|
||||||
@@ -114,6 +127,7 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
{ key: 'hardware', label: t('eq_tab_hardware') },
|
{ key: 'hardware', label: t('eq_tab_hardware') },
|
||||||
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
{ key: 'network', label: t('eq_tab_network'), count: device.networks.length },
|
||||||
{ key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length },
|
{ key: 'software', label: t('eq_tab_software'), count: device.software.filter((r) => showComponents || !r.is_component).length },
|
||||||
|
{ key: 'licenses', label: t('eq_tab_licenses'), count: (device.extra?.licenses?.windows ? 1 : 0) + (device.extra?.licenses?.office?.length || 0) },
|
||||||
{ key: 'history', label: t('eq_tab_history'), count: device.changes.length },
|
{ key: 'history', label: t('eq_tab_history'), count: device.changes.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -147,6 +161,16 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
|
const cpuText = device.cpus.map((c) => `${c.name || ''}${c.cores ? ` · ${c.cores}c/${c.threads || '?'}t` : ''}`).join(' | ');
|
||||||
const hwRows = [
|
const hwRows = [
|
||||||
[t('eq_cpu'), val(cpuText), cpuText],
|
[t('eq_cpu'), val(cpuText), cpuText],
|
||||||
|
[t('eq_cpu_year'), (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{val(device.cpu_year)}
|
||||||
|
{canEdit && cpuText && (
|
||||||
|
<Button size="sm" variant="standard" icon={Sparkles} onClick={getCpuYear} disabled={fetchingYear} title={t('eq_cpu_year_hint')}>
|
||||||
|
{fetchingYear ? t('eq_cpu_year_loading') : t('eq_cpu_year_get')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
), String(device.cpu_year ?? '')],
|
||||||
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
|
[t('eq_ram'), gb(device.ram_mb), gb(device.ram_mb)],
|
||||||
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
|
[t('eq_disk_total'), <>{gb(device.disk_total_mb)} {device.max_disk_used_pct != null && <span className="text-muted">· {t('eq_disk_max_used')} {device.max_disk_used_pct}%</span>}</>, `${gb(device.disk_total_mb)} ${device.max_disk_used_pct ?? ''}`],
|
||||||
];
|
];
|
||||||
@@ -171,7 +195,18 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
const localUsers = only(allLocalUsers, (r) => [r.name, r.sid]);
|
||||||
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
|
const localGroups = only(allLocalGroups, (r) => [r.name, r.sid]);
|
||||||
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
|
const networks = only(device.networks, (r) => [r.description, r.ip, r.mask, r.gateway, r.mac, r.status]);
|
||||||
const software = only(device.software.filter((r) => showComponents || !r.is_component), (r) => [r.name, r.version, r.publisher]);
|
const software = device.software.filter((r) => (showComponents || !r.is_component) && matchesSoftwareType(r, softwareType));
|
||||||
|
const win = device.extra?.licenses?.windows;
|
||||||
|
const channel = (oem) => (oem == null ? null : oem ? t('eq_lic_oem') : t('eq_lic_not_oem'));
|
||||||
|
const winRows = win ? [
|
||||||
|
[t('eq_lic_edition'), val([win.edition, win.version].filter(Boolean).join(' ')), `${win.edition || ''} ${win.version || ''}`],
|
||||||
|
[t('eq_lic_product_id'), val(win.product_id), win.product_id],
|
||||||
|
[t('eq_lic_key'), <code>{val(win.key)}</code>, win.key],
|
||||||
|
[t('eq_lic_channel'), val(channel(win.oem)), channel(win.oem)],
|
||||||
|
[t('eq_lic_owner'), val([win.owner, win.company].filter(Boolean).join(' · ')), `${win.owner || ''} ${win.company || ''}`],
|
||||||
|
] : [];
|
||||||
|
const officeLicenses = only(device.extra?.licenses?.office || [], (r) => [r.name, r.full_name, r.product_id, r.key]);
|
||||||
|
const officeInstalled = only(device.software.filter((r) => !r.is_component && /^microsoft (office|365)\b/i.test(r.name)), (r) => [r.name, r.version]);
|
||||||
const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
|
const changes = only(device.changes, (r) => [fmtDate(r.created_at), t(`eq_change_${r.change_type}`), r.field, r.old_value, r.new_value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,10 +215,13 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<div className="toolbar" style={{ marginBottom: 10 }}>
|
<div className="toolbar" style={{ marginBottom: 10 }}>
|
||||||
{tab === 'software' ? (
|
{tab === 'software' ? (
|
||||||
<label className="checkbox-row">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
<label className="checkbox-row">
|
||||||
{t('eq_show_components')}
|
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||||
</label>
|
{t('eq_show_components')}
|
||||||
|
</label>
|
||||||
|
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||||
|
</div>
|
||||||
) : <div />}
|
) : <div />}
|
||||||
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
<SearchInput value={searches[tab] || ''} onChange={(v) => setSearches({ ...searches, [tab]: v })} />
|
||||||
</div>
|
</div>
|
||||||
@@ -271,9 +309,35 @@ function DeviceDetail({ id, onClose, onChanged, canEdit, tab = 'general', onTabC
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'software' && (
|
{tab === 'software' && (
|
||||||
<SimpleTable empty={emptyText} rows={software} columns={[
|
<SoftwareList
|
||||||
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') }, { key: 'publisher', label: t('eq_publisher') },
|
rows={software} searchValue={searches.software} canEdit={canEdit} onChanged={reload} initialSort={{ key: 'name', dir: 1 }}
|
||||||
]} />
|
columns={[
|
||||||
|
{ key: 'name', label: t('common_name') },
|
||||||
|
{ key: 'version', label: t('eq_version'), render: (r) => dash(r.version), groupRender: () => '—' },
|
||||||
|
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'licenses' && (
|
||||||
|
<>
|
||||||
|
<h4>{t('eq_lic_windows')}</h4>
|
||||||
|
{win ? showRows(winRows) : <div className="text-muted">{t('eq_lic_no_windows')}</div>}
|
||||||
|
{!q && officeLicenses.length === 0 ? (
|
||||||
|
<><h4>{t('eq_lic_office')}</h4><div className="text-muted">{t('eq_lic_no_office')}</div></>
|
||||||
|
) : hwSection(t('eq_lic_office'), officeLicenses, [
|
||||||
|
{ key: 'name', label: t('common_name'), render: (r) => (
|
||||||
|
<>{r.name} {r.msdn && <Badge color="warning">{t('eq_lic_msdn')}</Badge>}</>
|
||||||
|
) },
|
||||||
|
{ key: 'full_name', label: t('eq_lic_channel') },
|
||||||
|
{ key: 'product_id', label: t('eq_lic_product_id') },
|
||||||
|
{ key: 'key', label: t('eq_lic_key'), render: (r) => <code>{dash(r.key)}</code> },
|
||||||
|
])}
|
||||||
|
{hwSection(t('eq_lic_office_installed'), officeInstalled, [
|
||||||
|
{ key: 'name', label: t('common_name') }, { key: 'version', label: t('eq_version') },
|
||||||
|
])}
|
||||||
|
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_lic_hint')}</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'history' && (
|
{tab === 'history' && (
|
||||||
@@ -353,6 +417,7 @@ function DevicesTab({ canEdit }) {
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'hostname', label: t('eq_hostname'), render: (r) => <strong>{dash(r.hostname)}</strong> },
|
{ key: 'hostname', label: t('eq_hostname'), render: (r) => <strong>{dash(r.hostname)}</strong> },
|
||||||
|
{ key: 'assigned_user_name', label: t('eq_assigned_to'), render: (r) => dash(r.assigned_user_name) },
|
||||||
{ key: 'kind', label: t('eq_kind'), render: (r) => t(`eq_kind_${r.kind}`) },
|
{ key: 'kind', label: t('eq_kind'), render: (r) => t(`eq_kind_${r.kind}`) },
|
||||||
{ key: 'model', label: t('eq_manufacturer_model'), sortValue: (r) => r.model || '', render: (r) => dash([r.manufacturer, r.model].filter(Boolean).join(' ')) },
|
{ key: 'model', label: t('eq_manufacturer_model'), sortValue: (r) => r.model || '', render: (r) => dash([r.manufacturer, r.model].filter(Boolean).join(' ')) },
|
||||||
{ key: 'os_name', label: t('eq_os'), render: (r) => dash(r.os_name) },
|
{ key: 'os_name', label: t('eq_os'), render: (r) => dash(r.os_name) },
|
||||||
@@ -391,34 +456,37 @@ function DevicesTab({ canEdit }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SoftwareTab() {
|
function SoftwareTab({ canEdit }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [showComponents, setShowComponents] = useState(false);
|
const [showComponents, setShowComponents] = useState(false);
|
||||||
const { data: rows } = useAsync(() => listSoftware(showComponents), [showComponents]);
|
const [softwareType, setSoftwareType] = useState('');
|
||||||
|
const { data: rows, reload } = useAsync(() => listSoftware(showComponents), [showComponents]);
|
||||||
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
|
const { data: installs } = useAsync(() => (selected ? listSoftwareDevices(selected) : Promise.resolve([])), [selected]);
|
||||||
|
|
||||||
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
if (!rows) return <div className="text-muted">{t('common_loading')}</div>;
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
{ key: 'name', label: t('common_name'), render: (r) => <strong>{r.name}</strong> },
|
||||||
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
{ key: 'publisher', label: t('eq_publisher'), render: (r) => dash(r.publisher) },
|
||||||
{ key: 'version_count', label: t('eq_versions') },
|
{ key: 'version_count', label: t('eq_versions'), groupRender: () => '—' },
|
||||||
{ key: 'device_count', label: t('eq_installed_on') },
|
{ key: 'device_count', label: t('eq_installed_on') },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<label className="checkbox-row">
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
<label className="checkbox-row">
|
||||||
{t('eq_show_components')}
|
<input type="checkbox" checked={showComponents} onChange={(e) => setShowComponents(e.target.checked)} />
|
||||||
</label>
|
{t('eq_show_components')}
|
||||||
|
</label>
|
||||||
|
<SoftwareTypeFilter value={softwareType} onChange={setSoftwareType} />
|
||||||
|
</div>
|
||||||
<SearchInput value={search} onChange={setSearch} />
|
<SearchInput value={search} onChange={setSearch} />
|
||||||
</div>
|
</div>
|
||||||
<ListView
|
<SoftwareList
|
||||||
columns={columns} rows={rows} searchValue={search} getRowKey={(r) => r.name}
|
rows={rows.filter((r) => matchesSoftwareType(r, softwareType))} columns={columns} searchValue={search} canEdit={canEdit}
|
||||||
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''}`} onRowClick={(r) => setSelected(r.name)}
|
onChanged={reload} onRowClick={(r) => setSelected(r.name)} initialSort={{ key: 'device_count', dir: -1 }}
|
||||||
initialSort={{ key: 'device_count', dir: -1 }}
|
|
||||||
/>
|
/>
|
||||||
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
<DetailPopup open={!!selected} title={selected || ''} onClose={() => setSelected(null)} width={620} height={520}>
|
||||||
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
<SimpleTable empty={t('common_loading')} rows={installs || []} columns={[
|
||||||
@@ -619,7 +687,7 @@ export default function EquiposPage() {
|
|||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{tab === 'summary' && <SummaryTab />}
|
{tab === 'summary' && <SummaryTab />}
|
||||||
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
{tab === 'devices' && <DevicesTab canEdit={access.canEdit} />}
|
||||||
{tab === 'software' && <SoftwareTab />}
|
{tab === 'software' && <SoftwareTab canEdit={access.canEdit} />}
|
||||||
{tab === 'agents' && access.canManage && <AgentsTab />}
|
{tab === 'agents' && access.canManage && <AgentsTab />}
|
||||||
{tab === 'share' && access.canManage && <ShareTab />}
|
{tab === 'share' && access.canManage && <ShareTab />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Tag, RotateCcw } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { setSoftwareClassification } from '../../services/devices.js';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
|
||||||
|
const TYPES = ['free', 'paid', 'personal_free', 'component'];
|
||||||
|
const TYPE_COLOR = { free: 'success', paid: 'accent', personal_free: 'warning', component: 'muted' };
|
||||||
|
// El grupo cerrado muestra el tipo de mayor riesgo que tenga dentro: nada queda escondido.
|
||||||
|
const RISK = ['personal_free', 'paid', null, 'free', 'component'];
|
||||||
|
const typeKey = (type) => type || 'unclassified';
|
||||||
|
const getChildren = (row) => row.children;
|
||||||
|
const getRowKey = (row) => row.groupKey || row.id || row.name;
|
||||||
|
|
||||||
|
export function matchesSoftwareType(row, filter) {
|
||||||
|
return !filter || typeKey(row.license_type) === filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrupa por fabricante desde 2 programas; lo que no tiene grupo sigue como fila normal.
|
||||||
|
function groupRows(rows) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const row of rows) if (row.group) groups.set(row.group, [...(groups.get(row.group) || []), row]);
|
||||||
|
const seen = new Set();
|
||||||
|
const result = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const children = row.group ? groups.get(row.group) : null;
|
||||||
|
if (!children || children.length < 2) { result.push(row); continue; }
|
||||||
|
if (seen.has(row.group)) continue;
|
||||||
|
seen.add(row.group);
|
||||||
|
result.push({
|
||||||
|
groupKey: `group:${row.group}`, name: row.group, children,
|
||||||
|
publisher: children.find((c) => c.publisher)?.publisher || null,
|
||||||
|
license_type: RISK.find((type) => children.some((c) => (c.license_type || null) === type)) ?? null,
|
||||||
|
device_count: Math.max(...children.map((c) => c.device_count || 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoftwareTypeFilter({ value, onChange }) {
|
||||||
|
const { t } = useUi();
|
||||||
|
const options = [{ value: '', label: t('eq_sw_type_all') }, ...[...TYPES, 'unclassified'].map((k) => ({ value: k, label: t(`eq_sw_type_${k}`) }))];
|
||||||
|
return <div style={{ width: 210 }}><ComboBox options={options} value={value} onChange={onChange} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SoftwareList({ rows, columns, searchValue, canEdit, onChanged, onRowClick, initialSort }) {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const runAction = useAction();
|
||||||
|
const grouped = useMemo(() => groupRows(rows), [rows]);
|
||||||
|
|
||||||
|
function classify(row, licenseType) {
|
||||||
|
runAction(async () => {
|
||||||
|
await setSoftwareClassification(row.name, licenseType);
|
||||||
|
await onChanged();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
...columns.map((col, index) => (index === 0 ? {
|
||||||
|
...col,
|
||||||
|
render: (r) => (r.children ? <span><strong>{r.name}</strong> <span className="text-muted">· {r.children.length} {t('eq_sw_programs')}</span></span> : col.render ? col.render(r) : r[col.key]),
|
||||||
|
} : { ...col, render: (r) => (r.children && col.groupRender ? col.groupRender(r) : col.render ? col.render(r) : r[col.key]) })),
|
||||||
|
{
|
||||||
|
key: 'license_type', label: t('eq_sw_type'), sortValue: (r) => RISK.indexOf(r.license_type || null),
|
||||||
|
render: (r) => (
|
||||||
|
<span title={r.license_manual ? t('eq_sw_manual') : undefined}>
|
||||||
|
{r.license_type
|
||||||
|
? <Badge color={TYPE_COLOR[r.license_type]}>{t(`eq_sw_type_${r.license_type}`)}{r.license_manual ? ' *' : ''}</Badge>
|
||||||
|
: <Badge color="danger">{t('eq_sw_type_unclassified')}</Badge>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const rowActions = canEdit ? (r) => (r.children ? [] : [
|
||||||
|
...TYPES.filter((type) => type !== r.license_type).map((type) => ({
|
||||||
|
icon: Tag, label: `${t('eq_sw_classify_as')}: ${t(`eq_sw_type_${type}`)}`, onClick: () => classify(r, type),
|
||||||
|
})),
|
||||||
|
...(r.license_manual ? [{ icon: RotateCcw, label: t('eq_sw_classify_auto'), onClick: () => classify(r, null) }] : []),
|
||||||
|
]) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ListView
|
||||||
|
columns={allColumns} rows={grouped} searchValue={searchValue} getRowKey={getRowKey} getChildren={getChildren}
|
||||||
|
getSearchHaystack={(r) => `${r.name} ${r.publisher || ''} ${r.version || ''}`}
|
||||||
|
onRowClick={onRowClick} rowActions={rowActions} initialSort={initialSort}
|
||||||
|
/>
|
||||||
|
<div className="text-muted" style={{ fontSize: '.72rem', marginTop: 12 }}>{t('eq_sw_hint')}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { selfApproveEmailChange, confirmEmailChange } from '../../services/emailChange.js';
|
||||||
|
|
||||||
|
export default function EmailChangeLinkPage({ kind }) {
|
||||||
|
const { t } = useUi();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const [state, setState] = useState('ready');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const isApproval = kind === 'approve';
|
||||||
|
async function accept() {
|
||||||
|
setState('loading');
|
||||||
|
try {
|
||||||
|
const result = await (isApproval ? selfApproveEmailChange(params.get('token')) : confirmEmailChange(params.get('token')));
|
||||||
|
setMessage(isApproval ? t('email_change_self_approved') : `${t('email_change_applied')} ${result.email}`);
|
||||||
|
setState('success');
|
||||||
|
} catch (err) { setMessage(err.message || t('common_error')); setState('error'); }
|
||||||
|
}
|
||||||
|
return <div className="login-shell"><div className="login-card">
|
||||||
|
<h1 className="login-title">{t(isApproval ? 'email_change_approve_title' : 'email_change_confirm_title')}</h1>
|
||||||
|
{state === 'ready' && <Button variant="accent" onClick={accept} disabled={!params.get('token')}>{t('email_change_accept')}</Button>}
|
||||||
|
{state === 'loading' && <div>{t('common_loading')}</div>}
|
||||||
|
{state === 'success' && <div className="login-info">{message}</div>}
|
||||||
|
{state === 'error' && <div className="login-error">{message}</div>}
|
||||||
|
<div className="login-links"><Link to="/login">{t('register_back_to_login')}</Link></div>
|
||||||
|
</div></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { Field } from '../../components/Field.jsx';
|
||||||
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { myEmailChange, requestEmailChange, cancelEmailChange } from '../../services/emailChange.js';
|
||||||
|
|
||||||
|
export function EmailChangeSection() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const [request, setRequest] = useState(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const draft = useDraftForm({ formId: 'account-email-change', initial: { newEmail: '', currentPassword: '' },
|
||||||
|
label: t('email_change_title'), enabled: open, exclude: ['currentPassword'] });
|
||||||
|
useEffect(() => { myEmailChange().then(setRequest).catch(() => {}); }, []);
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault(); setBusy(true); setError('');
|
||||||
|
try { await requestEmailChange(draft.values.newEmail, draft.values.currentPassword); draft.clear(); setOpen(false);
|
||||||
|
setRequest(await myEmailChange()); pushToast({ tone: 'success', message: t('email_change_requested') });
|
||||||
|
} catch (err) { setError(err.message || t('common_error')); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
async function cancel() {
|
||||||
|
try { await cancelEmailChange(); setRequest(await myEmailChange()); pushToast({ tone: 'success', message: t('email_change_cancelled') }); }
|
||||||
|
catch (err) { setError(err.message || t('common_error')); }
|
||||||
|
}
|
||||||
|
const active = ['pending', 'approved'].includes(request?.status);
|
||||||
|
return <div className="card" style={{ padding: 20, marginTop: 16 }}>
|
||||||
|
<h3>{t('email_change_title')}</h3>
|
||||||
|
{request && <p>{t(`email_change_status_${request.status}`)}: {request.newEmail}{request.rejectReason ? ` — ${request.rejectReason}` : ''}</p>}
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
{active ? <Button onClick={cancel}>{t('email_change_cancel')}</Button> : <Button variant="accent" onClick={() => setOpen(true)}>{t('email_change_title')}</Button>}
|
||||||
|
<DetailPopup open={open} title={t('email_change_title')} onClose={() => { draft.discard(); setOpen(false); }} width={480} height={380}>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<Field label={t('email_change_new')}><input className="input" type="email" value={draft.values.newEmail} onChange={(e) => draft.setValues((v) => ({ ...v, newEmail: e.target.value }))} required /></Field>
|
||||||
|
<Field label={t('change_password_current')}><PasswordInput value={draft.values.currentPassword} onChange={(e) => draft.setValues((v) => ({ ...v, currentPassword: e.target.value }))} required /></Field>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
<Button type="submit" variant="accent" disabled={busy}>{t('email_change_request')}</Button>
|
||||||
|
</form>
|
||||||
|
</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { ComboBox } from '../../components/ComboBox.jsx';
|
|||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
import { SessionsList } from '../../components/SessionsList.jsx';
|
import { SessionsList } from '../../components/SessionsList.jsx';
|
||||||
|
import { EmailChangeSection } from './EmailChangeSection.jsx';
|
||||||
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js';
|
import { changePassword, listSessions, revokeSession, revokeOtherSessions } from '../../services/auth.js';
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export default function MiCuentaPage() {
|
|||||||
<Field label={t('user_email')}><div>{user?.email || '—'}</div></Field>
|
<Field label={t('user_email')}><div>{user?.email || '—'}</div></Field>
|
||||||
<Field label={t('user_role')}><div>{user?.roleName || user?.roleCode || '—'}</div></Field>
|
<Field label={t('user_role')}><div>{user?.roleName || user?.roleCode || '—'}</div></Field>
|
||||||
<Field label={t('common_department')}><div>{user?.departmentName || '—'}</div></Field>
|
<Field label={t('common_department')}><div>{user?.departmentName || '—'}</div></Field>
|
||||||
</FieldRow></div>}
|
</FieldRow><EmailChangeSection /></div>}
|
||||||
{tab === 'preferences' && <div className="card" style={{ padding: 20 }}><FieldRow>
|
{tab === 'preferences' && <div className="card" style={{ padding: 20 }}><FieldRow>
|
||||||
<Field label={t('account_language')}><ComboBox value={lang} onChange={setLang} options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]} /></Field>
|
<Field label={t('account_language')}><ComboBox value={lang} onChange={setLang} options={[{ value: 'es-EC', label: t('lang_es') }, { value: 'en-US', label: t('lang_en') }]} /></Field>
|
||||||
<Field label={t('account_theme')}><ComboBox value={theme} onChange={setTheme} options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]} /></Field>
|
<Field label={t('account_theme')}><ComboBox value={theme} onChange={setTheme} options={[{ value: 'dark', label: t('theme_dark') }, { value: 'light', label: t('theme_light') }]} /></Field>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field } from '../../components/Field.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { pendingEmailChanges, approveEmailChange, rejectEmailChange } from '../../services/emailChange.js';
|
||||||
|
|
||||||
|
export function EmailChangeRequests() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [rejecting, setRejecting] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const draft = useDraftForm({ formId: 'email-change-reject', entityId: rejecting?.id,
|
||||||
|
initial: { reason: '' }, label: t('email_change_reject'), enabled: !!rejecting });
|
||||||
|
const reload = () => pendingEmailChanges().then(setRows).catch((err) => setError(err.message));
|
||||||
|
useEffect(() => { reload(); }, []);
|
||||||
|
async function approve(id) {
|
||||||
|
try { await approveEmailChange(id); reload(); pushToast({ tone: 'success', message: t('email_change_approved') }); }
|
||||||
|
catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
async function reject(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
try { await rejectEmailChange(rejecting.id, draft.values.reason); draft.clear(); setRejecting(null); reload(); pushToast({ tone: 'success', message: t('email_change_rejected') }); }
|
||||||
|
catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
return <section className="card" style={{ padding: 20, marginTop: 16 }}>
|
||||||
|
<h3>{t('email_change_requests')} ({rows.length})</h3>
|
||||||
|
{error && <div className="login-error">{error}</div>}
|
||||||
|
{rows.length === 0 && <p className="text-muted">{t('email_change_none')}</p>}
|
||||||
|
{rows.map((r) => <div key={r.id} style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', padding: 12, borderBottom: '1px solid rgb(var(--border))' }}>
|
||||||
|
<span style={{ flex: 1 }}>{r.name}: {r.oldEmail} → {r.newEmail}</span>
|
||||||
|
<Button onClick={() => approve(r.id)}>{t('email_change_approve')}</Button>
|
||||||
|
<Button onClick={() => setRejecting(r)}>{t('email_change_reject')}</Button>
|
||||||
|
</div>)}
|
||||||
|
<DetailPopup open={!!rejecting} title={t('email_change_reject')} onClose={() => { draft.discard(); setRejecting(null); }} width={480} height={300}>
|
||||||
|
<form onSubmit={reject}><Field label={t('email_change_reason')}><textarea className="input" value={draft.values.reason} onChange={(e) => draft.setValues({ reason: e.target.value })} maxLength={1000} required /></Field>
|
||||||
|
<Button type="submit" variant="accent">{t('email_change_reject')}</Button></form>
|
||||||
|
</DetailPopup>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { RefreshCw, UserPlus } from 'lucide-react';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
|
import { listEmployees, listSources, syncEmployees, searchProviders, importProvider } from '../../services/integrations.js';
|
||||||
|
import { syncSummary } from '../Configuracion/ExternalConnectionsPanel.jsx';
|
||||||
|
|
||||||
|
// Empleados importados de la nómina (Conexiones externas). Solo lectura aquí; "Actualizar
|
||||||
|
// empleados" los vuelve a traer a pedido (admin y superadmin).
|
||||||
|
export default function EmployeesPanel() {
|
||||||
|
const { t, pushToast } = useUi();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
// "Agregar desde proveedores": buscar en el origen, elegir y confirmar apellidos/nombres/cargo.
|
||||||
|
const [picking, setPicking] = useState(false), [provSearch, setProvSearch] = useState(''), [found, setFound] = useState([]), [picked, setPicked] = useState(null), [pickError, setPickError] = useState('');
|
||||||
|
const [providerSource, setProviderSource] = useState(undefined);
|
||||||
|
const searchSeq = useRef(0);
|
||||||
|
const words = picked ? picked.fullName.split(/\s+/) : [];
|
||||||
|
const draft = useDraftForm({
|
||||||
|
formId: 'employee-from-provider', entityId: picked?.code ?? null,
|
||||||
|
initial: picked ? { lastNames: words.slice(0, 2).join(' '), firstNames: words.slice(2).join(' '), position: 'MEDICO' } : {},
|
||||||
|
label: t('emp_from_provider'), enabled: !!picked,
|
||||||
|
onDiscard: () => setPicked(null)
|
||||||
|
});
|
||||||
|
const pv = draft.values;
|
||||||
|
const [rows, setRows] = useState(null), [source, setSource] = useState(undefined), [search, setSearch] = useState(''), [error, setError] = useState(''), [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const [e, s] = await Promise.all([listEmployees(), listSources()]);
|
||||||
|
setRows(e); setSource(s.find((x) => x.purpose === 'employees') || null); setProviderSource(s.find((x) => x.purpose === 'providers') || null); setError('');
|
||||||
|
} catch (err) { setError(err.message); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void reload(); }, []);
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await syncEmployees();
|
||||||
|
await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchProv(text) {
|
||||||
|
setProvSearch(text);
|
||||||
|
const seq = ++searchSeq.current;
|
||||||
|
if (text.trim().length < 3) { setFound([]); return; }
|
||||||
|
setTimeout(() => {
|
||||||
|
if (seq !== searchSeq.current) return;
|
||||||
|
searchProviders(text).then((r) => seq === searchSeq.current && setFound(r)).catch((e) => setPickError(e.message));
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
function openPicker() {
|
||||||
|
setPicking(true); setPicked(null); setProvSearch(''); setFound([]); setPickError('');
|
||||||
|
}
|
||||||
|
async function saveProvider() {
|
||||||
|
try {
|
||||||
|
await importProvider(picked.code, pv);
|
||||||
|
draft.clear();
|
||||||
|
setPicked(null);
|
||||||
|
setPicking(false);
|
||||||
|
await reload();
|
||||||
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
|
} catch (e) { setPickError(e.message); }
|
||||||
|
}
|
||||||
|
const closePicker = () => void guardedClose(picked ? draft : null, () => { setPicked(null); setPicking(false); });
|
||||||
|
|
||||||
|
if (!rows) return error ? <InfoBar tone="error">{error}</InfoBar> : <div className="text-muted">{t('common_loading')}</div>;
|
||||||
|
return <div>
|
||||||
|
{error && <InfoBar tone="error">{error}</InfoBar>}
|
||||||
|
{source === null && <InfoBar tone="warning">{t('emp_no_source')}</InfoBar>}
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="toolbar-left"><SearchInput value={search} onChange={setSearch} /><span className="text-muted" style={{ fontSize: '.78rem' }}>{t('int_last_sync')}: {syncSummary(t, source?.last_result)}</span></div>
|
||||||
|
<div className="toolbar-left">
|
||||||
|
<Button icon={UserPlus} disabled={!providerSource} title={providerSource ? undefined : t('emp_no_provider_source')} onClick={openPicker}>{t('emp_from_provider')}</Button>
|
||||||
|
<Button variant="accent" icon={RefreshCw} disabled={busy || !source} onClick={() => void update()}>{t('emp_update')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: '.78rem', margin: '0 0 8px' }}>{t('emp_protected_hint')}</p>
|
||||||
|
<ListView getRowKey={(e) => e.id} rows={rows} searchValue={search} getSearchHaystack={(e) => `${e.last_names} ${e.first_names} ${e.identification} ${e.position || ''} ${e.department_name || ''} ${t(`emp_origin_${e.origin}`)}`}
|
||||||
|
initialSort={{ key: 'last_names', dir: 1 }}
|
||||||
|
columns={[
|
||||||
|
{ key: 'last_names', label: t('int_target_lastNames') },
|
||||||
|
{ key: 'first_names', label: t('int_target_firstNames') },
|
||||||
|
{ key: 'identification', label: t('int_target_identification') },
|
||||||
|
{ key: 'position', label: t('int_target_position'), render: (e) => e.position || '—' },
|
||||||
|
{ key: 'department_name', label: t('int_target_department'), render: (e) => e.department_name || '—' },
|
||||||
|
{ key: 'origin', label: t('emp_origin'), render: (e) => t(`emp_origin_${e.origin}`) },
|
||||||
|
{ key: 'is_active', label: t('common_status'), render: (e) => <Badge color={e.is_active ? 'success' : 'muted'}>{t(e.is_active ? 'common_active' : 'common_inactive')}</Badge> },
|
||||||
|
{ key: 'user_name', label: t('emp_user'), render: (e) => e.user_name || '—' }
|
||||||
|
]} />
|
||||||
|
|
||||||
|
<DetailPopup open={picking} title={t('emp_from_provider')} onClose={closePicker} width={900} height={620}>
|
||||||
|
<div className="emp-pick">
|
||||||
|
{pickError && <InfoBar tone="error">{pickError}</InfoBar>}
|
||||||
|
{!picked ? <>
|
||||||
|
<Field label={t('emp_provider_search')}><input className="input" autoFocus value={provSearch} onChange={(e) => searchProv(e.target.value)} /></Field>
|
||||||
|
{found.length > 0 && <ListView getRowKey={(p) => p.code} rows={found} onRowClick={(p) => { if (!p.employee_id) { setPicked(p); setPickError(''); } }}
|
||||||
|
columns={[
|
||||||
|
{ key: 'fullName', label: t('common_name') },
|
||||||
|
{ key: 'identification', label: t('int_target_identification'), render: (p) => p.identification || '—' },
|
||||||
|
{ key: 'phone', label: t('int_target_phone'), render: (p) => p.phone || '—' },
|
||||||
|
{ key: 'email', label: t('int_target_email'), render: (p) => p.email || '—' },
|
||||||
|
{ key: 'employee_id', label: t('common_status'), sortable: false, render: (p) => (p.employee_id ? <Badge color="muted">{t('emp_already')}</Badge> : '') }
|
||||||
|
]} />}
|
||||||
|
</> : <>
|
||||||
|
<DraftNotice draft={draft} />
|
||||||
|
<InfoBar tone="info">{picked.fullName} · {picked.identification} — {t('emp_provider_pick')}</InfoBar>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_target_lastNames')} dirty={draft.isFieldDirty('lastNames')}><input className="input" maxLength={120} value={pv.lastNames || ''} onChange={(e) => draft.setField('lastNames', e.target.value)} /></Field>
|
||||||
|
<Field label={t('int_target_firstNames')} dirty={draft.isFieldDirty('firstNames')}><input className="input" maxLength={120} value={pv.firstNames || ''} onChange={(e) => draft.setField('firstNames', e.target.value)} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('int_target_position')} dirty={draft.isFieldDirty('position')}><input className="input" maxLength={120} value={pv.position || ''} onChange={(e) => draft.setField('position', e.target.value)} /></Field>
|
||||||
|
<Field label={t('int_target_identification')}><input className="input" readOnly value={picked.identification || ''} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
<div className="toolbar-left" style={{ marginTop: 18 }}>
|
||||||
|
<Button variant="accent" disabled={!pv.lastNames?.trim() || !pv.firstNames?.trim()} onClick={() => void saveProvider()}>{t('common_save')}</Button>
|
||||||
|
<Button onClick={() => draft.discard()}>{t('common_cancel')}</Button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</DetailPopup>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -4,12 +4,17 @@ import { Tabs } from '../../components/Tabs.jsx';
|
|||||||
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
import { useUserDrafts } from '../../hooks/useDraftForm.js';
|
||||||
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
import DepartmentsPanel from './DepartmentsPanel.jsx';
|
||||||
import UsersPanel from './UsersPanel.jsx';
|
import UsersPanel from './UsersPanel.jsx';
|
||||||
|
import EmployeesPanel from './EmployeesPanel.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
|
||||||
export default function OrganizacionPage() {
|
export default function OrganizacionPage() {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
// Pestaña en la URL (?tab=): retorno tras el login/F5 y aviso al cambiar con cambios pendientes.
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const tab = searchParams.get('tab') === 'users' ? 'users' : 'departments';
|
const { user } = useAuth();
|
||||||
|
const canManage = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||||
|
const asked = searchParams.get('tab');
|
||||||
|
const tab = asked === 'users' || (asked === 'employees' && canManage) ? asked : 'departments';
|
||||||
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
const setTab = (key) => setSearchParams({ tab: key }, { replace: true });
|
||||||
const drafts = useUserDrafts();
|
const drafts = useUserDrafts();
|
||||||
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
const dirty = (prefix) => drafts.some((d) => d.formId.startsWith(prefix));
|
||||||
@@ -21,11 +26,12 @@ export default function OrganizacionPage() {
|
|||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
{ key: 'departments', label: t('org_departments'), dirty: dirty('department-') },
|
||||||
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
{ key: 'users', label: t('org_users'), dirty: dirty('user-') },
|
||||||
|
...(canManage ? [{ key: 'employees', label: t('org_employees') }] : []),
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
{tab === 'departments' ? <DepartmentsPanel /> : <UsersPanel />}
|
{tab === 'departments' ? <DepartmentsPanel /> : tab === 'users' ? <UsersPanel /> : <EmployeesPanel />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from '../../context/auth-context.jsx';
|
|||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
import { listUsers, listRoles, createUser, updateUser, setUserActive, approveUser, rejectUser, resendUserEmail, listUserSessions, revokeUserSession, revokeAllUserSessions } from '../../services/users.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
|
import { listEmployees } from '../../services/integrations.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
@@ -19,6 +20,7 @@ import { DraftMark, useGuardedClose } from '../../components/NavigationGuard.jsx
|
|||||||
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
import { useDraftForm, useUserDrafts, hasDraftFor, useReopenDraft } from '../../hooks/useDraftForm.js';
|
||||||
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
import { PasswordInput } from '../../components/PasswordInput.jsx';
|
||||||
import { SessionsList } from '../../components/SessionsList.jsx';
|
import { SessionsList } from '../../components/SessionsList.jsx';
|
||||||
|
import { EmailChangeRequests } from './EmailChangeRequests.jsx';
|
||||||
|
|
||||||
function flatten(nodes, acc = []) {
|
function flatten(nodes, acc = []) {
|
||||||
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
||||||
@@ -26,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();
|
||||||
@@ -46,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);
|
||||||
@@ -78,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) {
|
||||||
@@ -93,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();
|
||||||
@@ -164,6 +178,7 @@ export default function UsersPanel() {
|
|||||||
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
|
{canManage && <Button variant="accent" icon={Plus} onClick={startNew}>{t('user_new')}{pendingNew && !editing && <DraftMark />}</Button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canManage && <EmailChangeRequests />}
|
||||||
<ListView
|
<ListView
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={users}
|
rows={users}
|
||||||
@@ -206,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 />
|
||||||
|
|||||||
@@ -4,8 +4,15 @@ import { useUi } from '../../context/ui-context.jsx';
|
|||||||
import { useAsync } from '../../hooks/useAsync.js';
|
import { useAsync } from '../../hooks/useAsync.js';
|
||||||
import { listActivitiesByProject } from '../../services/activities.js';
|
import { listActivitiesByProject } from '../../services/activities.js';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { listMeetings, listMeetingRecurring } from '../../services/meetings.js';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
import { hasModule } from '../../utils/modules.js';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
|
const DOW = ['Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab', 'Dom'];
|
||||||
|
// Mismos colores que el menu Calendario: reunion = accent, actividad = warning, recurrente = success.
|
||||||
|
const chip = (tone) => ({ color: `rgb(var(--${tone}))`, background: `rgb(var(--${tone}) / .14)`, border: 0, textAlign: 'left', cursor: 'pointer', width: '100%' });
|
||||||
|
const swatch = (tone) => ({ display: 'inline-block', width: 12, height: 12, borderRadius: 3, background: `rgb(var(--${tone}))`, marginRight: 6, verticalAlign: '-1px' });
|
||||||
|
|
||||||
function buildGrid(year, month) {
|
function buildGrid(year, month) {
|
||||||
const first = new Date(year, month, 1);
|
const first = new Date(year, month, 1);
|
||||||
@@ -20,8 +27,16 @@ function buildGrid(year, month) {
|
|||||||
|
|
||||||
export default function ProjectCalendarTab({ projectId }) {
|
export default function ProjectCalendarTab({ projectId }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canMeetings = hasModule(user, 'calendar'), canRecurring = hasModule(user, 'recurring_tasks');
|
||||||
const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
const { data: activities } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
||||||
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); });
|
const [cursor, setCursor] = useState(() => { const n = new Date(); return new Date(n.getFullYear(), n.getMonth(), 1); });
|
||||||
|
const from = `${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
|
||||||
|
const nextMonth = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1);
|
||||||
|
const to = `${nextMonth.getFullYear()}-${String(nextMonth.getMonth()+1).padStart(2,'0')}-01 00:00:00`;
|
||||||
|
const { data: meetings } = useAsync(() => canMeetings ? listMeetings(from, to, projectId).catch(() => []) : Promise.resolve([]), [from, to, projectId, canMeetings]);
|
||||||
|
const { data: recurring } = useAsync(() => canMeetings && canRecurring ? listMeetingRecurring(from, to).catch(() => []) : Promise.resolve([]), [from, to, canMeetings, canRecurring]);
|
||||||
|
|
||||||
const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
|
const cells = useMemo(() => buildGrid(cursor.getFullYear(), cursor.getMonth()), [cursor]);
|
||||||
const todayStr = new Date().toISOString().slice(0, 10);
|
const todayStr = new Date().toISOString().slice(0, 10);
|
||||||
@@ -44,18 +59,28 @@ export default function ProjectCalendarTab({ projectId }) {
|
|||||||
<div style={{ fontWeight: 700 }}>{cursor.toLocaleDateString('es-EC', { month: 'long', year: 'numeric' })}</div>
|
<div style={{ fontWeight: 700 }}>{cursor.toLocaleDateString('es-EC', { month: 'long', year: 'numeric' })}</div>
|
||||||
<Button variant="standard" size="sm" icon={ChevronRight} onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))} />
|
<Button variant="standard" size="sm" icon={ChevronRight} onClick={() => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1))} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-muted" style={{ display:'flex', flexWrap:'wrap', gap:'6px 16px', fontSize:'.78rem', margin:'4px 0 10px' }}>
|
||||||
|
<span><span style={swatch('warning')} />{t('calendar_layer_activities')}</span>
|
||||||
|
{canMeetings && <span><span style={swatch('accent')} />{t('calendar_layer_meetings')}</span>}
|
||||||
|
{canMeetings && canRecurring && <span><span style={swatch('success')} />{t('calendar_layer_recurring')}</span>}
|
||||||
|
{canMeetings && canRecurring && <span><span style={swatch('danger')} />{t('calendar_overdue')}</span>}
|
||||||
|
</div>
|
||||||
<div className="calendar-grid">
|
<div className="calendar-grid">
|
||||||
{DOW.map((d) => <div key={d} className="calendar-dow">{d}</div>)}
|
{DOW.map((d) => <div key={d} className="calendar-dow">{d}</div>)}
|
||||||
{cells.map((date, i) => {
|
{cells.map((date, i) => {
|
||||||
if (!date) return <div key={i} className="calendar-cell muted" />;
|
if (!date) return <div key={i} className="calendar-cell muted" />;
|
||||||
const key = date.toISOString().slice(0, 10);
|
const key = date.toISOString().slice(0, 10);
|
||||||
const items = byDay[key] || [];
|
const items = byDay[key] || [];
|
||||||
|
const meetingItems = (meetings || []).filter((m) => m.occurrence_start.slice(0,10) === key);
|
||||||
|
const recurringItems = (recurring || []).filter((r) => r.project_id === projectId && r.due_date === key);
|
||||||
return (
|
return (
|
||||||
<div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}>
|
<div key={i} className={`calendar-cell ${key === todayStr ? 'today' : ''}`}>
|
||||||
<div className="calendar-day-num">{date.getDate()}</div>
|
<div className="calendar-day-num">{date.getDate()}</div>
|
||||||
{items.slice(0, 3).map((a) => (
|
{items.slice(0, 3).map((a) => (
|
||||||
<div key={a.id} className="calendar-chip" title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
|
<div key={a.id} className="calendar-chip" style={{ color:'rgb(var(--warning))', background:'rgb(var(--warning) / .14)' }} title={a.name}>{a.is_milestone ? '◆ ' : ''}{a.name}</div>
|
||||||
))}
|
))}
|
||||||
|
{meetingItems.map((m) => <button key={`${m.id}-${m.occurrence_start}`} className="calendar-chip" style={chip('accent')} title={m.title} onClick={() => navigate(`/calendario?meeting=${m.id}`)}>{m.occurrence_start.slice(11,16)} {m.title}</button>)}
|
||||||
|
{recurringItems.map((r) => <button key={`r-${r.id}-${r.due_date}`} className="calendar-chip" style={{ ...chip(r.overdue ? 'danger' : 'success'), opacity: r.is_next ? 1 : .72 }} title={r.name} onClick={() => navigate(`/tareas-recurrentes?task=${r.id}`)}>↻ {r.name}</button>)}
|
||||||
{items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>}
|
{items.length > 3 && <div className="text-muted" style={{ fontSize: '.64rem' }}>+{items.length - 3}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Plus, Save, Trash2, RotateCcw, Pencil } from 'lucide-react';
|
import { Plus, Save, Trash2, RotateCcw, Pencil } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
@@ -44,6 +45,7 @@ export default function RecurringTasksPage() {
|
|||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// Borrador del popup (nueva o edicion de una existente).
|
// Borrador del popup (nueva o edicion de una existente).
|
||||||
const draft = useDraftForm({
|
const draft = useDraftForm({
|
||||||
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
|
formId: selected ? 'recurring-edit' : 'recurring-new', entityId: selected?.id ?? null,
|
||||||
@@ -68,6 +70,15 @@ export default function RecurringTasksPage() {
|
|||||||
];
|
];
|
||||||
const unitLabel = { days: t('recurring_task_unit_days'), months: t('recurring_task_unit_months'), years: t('recurring_task_unit_years') };
|
const unitLabel = { days: t('recurring_task_unit_days'), months: t('recurring_task_unit_months'), years: t('recurring_task_unit_years') };
|
||||||
|
|
||||||
|
// Llegada desde el Calendario (?task=id): abre esa tarea como un clic en su fila.
|
||||||
|
useEffect(() => {
|
||||||
|
const id = Number(searchParams.get('task'));
|
||||||
|
if (!id || !items) return;
|
||||||
|
const found = items.find((r) => r.id === id);
|
||||||
|
if (found) startEdit(found);
|
||||||
|
setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete('task'); return next; }, { replace: true });
|
||||||
|
}, [searchParams, items]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
function startNew() {
|
function startNew() {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function getToken() {
|
|||||||
|
|
||||||
async function request(method, path, { body, isForm, extraHeaders, signal } = {}) {
|
async function request(method, path, { body, isForm, extraHeaders, signal } = {}) {
|
||||||
const headers = { ...extraHeaders };
|
const headers = { ...extraHeaders };
|
||||||
const token = getToken();
|
const token = path.startsWith('/auth/email-change/') ? null : getToken();
|
||||||
// Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion.
|
// Nunca operar con un token vencido: se corta la sesion aqui mismo, sin enviar la peticion.
|
||||||
if (token && isTokenExpired(token)) {
|
if (token && isTokenExpired(token)) {
|
||||||
onUnauthorized({ expired: true });
|
onUnauthorized({ expired: true });
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { api } from './api.js';
|
||||||
|
const query=(data)=>`?${new URLSearchParams(data)}`;
|
||||||
|
export const clinicMe=()=>api.get('/clinic/staff/me');
|
||||||
|
export const clinicDoctors=()=>api.get('/clinic/doctors');
|
||||||
|
export const clinicAppointments=(from,to,doctorId)=>api.get(`/clinic/appointments${query({from,to,...(doctorId?{doctorId}:{})})}`);
|
||||||
|
export const clinicBlocks=(from,to,doctorId)=>api.get(`/clinic/blocks${query({from,to,...(doctorId?{doctorId}:{})})}`);
|
||||||
|
export const clinicAvailability=(doctorId,date,typeId)=>api.get(`/clinic/availability${query({doctorId,date,typeId})}`);
|
||||||
|
export const clinicPatients=(search='',page=1)=>api.get(`/clinic/patients${query({search,page})}`);
|
||||||
|
export const clinicPatientHistory=(id)=>api.get(`/clinic/patients/${id}/appointments`);
|
||||||
|
export const clinicSavePatient=(data,id)=>id?api.put(`/clinic/patients/${id}`,data):api.post('/clinic/patients',data);
|
||||||
|
export const clinicSaveAppointment=(data,id)=>id?api.put(`/clinic/appointments/${id}`,data):api.post('/clinic/appointments',data);
|
||||||
|
export const clinicAppointmentStatus=(id,data)=>api.post(`/clinic/appointments/${id}/status`,data);
|
||||||
|
export const clinicCancelAppointment=(id,data)=>api.post(`/clinic/appointments/${id}/cancel`,data);
|
||||||
|
export const clinicDeleteAppointment=(id,data)=>api.post(`/clinic/appointments/${id}/delete`,data);
|
||||||
|
export const clinicSaveBlock=(data,id)=>id?api.put(`/clinic/blocks/${id}`,data):api.post('/clinic/blocks',data);
|
||||||
|
export const clinicDeleteBlock=(id,editVersion)=>api.delete(`/clinic/blocks/${id}?editVersion=${editVersion}`);
|
||||||
|
export const clinicStaff=()=>api.get('/clinic/config/staff');
|
||||||
|
export const clinicSaveStaff=(id,data)=>api.put(`/clinic/config/staff/${id}`,data);
|
||||||
|
export const clinicSchedules=(id)=>api.get(`/clinic/config/schedules/${id}`);
|
||||||
|
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
||||||
|
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 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`);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user