Compare commits

..

2 Commits

Author SHA1 Message Date
Milton Gonzalez fabe5b530d feat: version 4.11.0 -- Agenda medica refactorizada, aviso de citas futuras y correcciones
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:30:21 -05:00
Milton Gonzalez d0f90542e9 feat: version 4.10.0 -- Conectores de datos externos y conector FoxPro
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:32:08 -05:00
34 changed files with 1683 additions and 565 deletions
+31 -1
View File
@@ -82,6 +82,8 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
- Notificaciones in-app - Notificaciones in-app
- Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`) - Equipos (inventario TI alimentado por agentes GLPI — ver `docs/ARQUITECTURA-INVENTARIO.md`)
- Configuración (catálogos: estados, prioridades, tipos de proyecto) - Configuración (catálogos: estados, prioridades, tipos de proyecto)
- Conexiones externas (en curso): datos de otros sistemas vía conectores de solo lectura en el
servidor origen (`data/conectores/`, primero FoxPro) — ver `docs/CONEXIONES-EXTERNAS.md`
## Restricciones tecnicas ## Restricciones tecnicas
@@ -143,6 +145,33 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
guardar solo si hubo cambios reales. guardar solo si hubo cambios reales.
- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos) - Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos)
van en pestañas reales, nunca apiladas ni navegando a otra pantalla. van en pestañas reales, nunca apiladas ni navegando a otra pantalla.
- **Formularios en popup = modelo "Nueva reunión" de Calendario** (`pages/Calendario/CalendarioPage.jsx`):
rejilla de 2 columnas (campos cortos en pares, texto largo a lo ancho), fecha + hora con
`DateTimeEditor`, botones al pie. Cualquier formulario nuevo o existente que no siga esto se
ajusta a ese modelo.
- Un popup "Nuevo …" abre limpio con el contexto del clic (día, hora, médico): nada de la apertura
anterior (búsquedas, selecciones) se arrastra. Solo vuelve lo que el usuario dejó como borrador
("Dejar pendiente"), con el aviso de borrador recuperado.
## Delegar a Codex (u otro agente) en este proyecto
Además de la regla del orquestador: Claude escribe en el encargo las reglas que aplican (no "lee los
md") y nombra **solo** los archivos/secciones necesarios de esta lista, según la tarea:
| Si la tarea toca… | Nombrar |
|---|---|
| Pantallas/formularios | `docs/UI-UX-WINDOWS.md` (sección del componente) + pantalla modelo (formularios: `pages/Calendario/CalendarioPage.jsx`, listados: `pages/Organizacion/UsersPanel.jsx`) |
| Formularios con edición | `docs/SESION-Y-BORRADORES.md` § Comportamiento (`useDraftForm`) |
| Backend / permisos | `docs/SEGURIDAD-Y-CALIDAD.md` (sección aplicable), `API.md` § del módulo |
| Base de datos | `BASE_DE_DATOS.md` § del módulo + última migración |
Reglas fijas que siempre van escritas en el encargo: rejilla de 2 columnas como "Nueva reunión";
listado primero y popup en lectura → Editar; nada se arrastra entre aperturas de un popup; textos en
`i18n/messages.js` (es y en); reutilizar componentes de `src/components` sin crear variantes.
Si necesita herramientas, se le dicen en el encargo (build: `npx vite build` en `frontend/`; navegador:
Playwright global del orquestador; usuarios de prueba de dev: ver `ESTADO.md`).
Codex desarrolla y **Claude revisa y prueba siempre** lo que entregue (diff contra estas reglas, build,
navegador si cambia pantalla) antes de darlo por bueno.
## Flujos criticos ## Flujos criticos
@@ -169,7 +198,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
+2 -1
View File
@@ -17,7 +17,8 @@ La recurrencia se almacena como JSON de texto y se expande al consultar, sin fil
## Agenda médica (migración 036, v4.7.0) ## Agenda médica (migración 036, v4.7.0)
`backend/database/migrations/036_clinic.sql` está creada y **no se ha aplicado** en ninguna BD. `backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
(2026-10-28/29), **pendientes en producción** (las aplica el actualizador).
Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por
médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`. médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
+30
View File
@@ -1,5 +1,35 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [4.11.0] - 2026-10-29
### Added
- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
agendadas/confirmadas, de cualquier fecha y médico.
- Agenda médica: "Nueva cita" desde la fila de un paciente; "Nuevo paciente" desde la cita precarga
la cédula buscada; historial de citas del paciente en listado.
### Changed
- Agenda médica refactorizada con el modelo de "Nueva reunión" de Calendario: formularios de cita,
paciente y bloqueo en pares de campos con `DateTimeEditor`; Pacientes y Bloqueos como `ListView`;
Configuración (personal, tipos, horario) como listados con popup en lectura → Editar (se quitó el
campo "ID de usuario"). Código dividido en `ClinicForms.jsx`, `ClinicConfigTab.jsx`, `clinicUtils.js`.
### Fixed
- Agenda médica: el paciente/cédula de una cita ya no se arrastra a la siguiente (borrador huérfano
al cancelar o cerrar "Nuevo paciente"); las notas de la cita se conservan al volver del paciente.
- Agenda médica: "Descartar cambios" en una cita, paciente o bloqueo nuevo ya no tumba la página.
- Agenda médica: búsquedas y huecos libres ya no se pisan con respuestas viejas; el paciente elegido
no desaparece al buscar; el tipo desactivado de una cita se conserva al editarla.
- Diálogo "Cambios sin guardar": los botones ya no se salen por la izquierda (pasan a otra línea).
Sin migración.
## [4.10.0] - 2026-10-29
### Added
- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura
que se instala en el servidor origen; el sistema no guarda credenciales del origen.
- Conector FoxPro 1.0.0 (`data/conectores/foxpro/`): lee DBF/FPT desde una copia temporal (no
bloquea a SAGA), token SHA-256, IPs permitidas, tablas/campos permitidos y denegados, límites de
carga, scripts de instalación como tarea de Windows (`C:\api_project\foxpro`). 16 pruebas locales.
- Documentación: `docs/CONEXIONES-EXTERNAS.md` (requisitos y diseño) y `DECISIONES.md`.
Sin cambios en la aplicación ni migración.
## [4.9.1] - 2026-10-29 ## [4.9.1] - 2026-10-29
### Fixed ### Fixed
- Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas - Agenda médica: ahora usa el mismo calendario que Calendario (barra Hoy/anterior/siguiente, vistas
+15
View File
@@ -0,0 +1,15 @@
# 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 |
+13 -1
View File
@@ -1,11 +1,23 @@
# ESTADO — project.linkedcorp.net # ESTADO — project.linkedcorp.net
Ultima actualizacion: 2026-10-28 (v4.7.0 en archivos, sin commit ni desplegar) Ultima actualizacion: 2026-10-29 (v4.11.0 commit + push, sin desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo + Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP. autorregistro/aprobacion/SMTP.
## Conexiones externas (2026-10-29, en curso, sin commit)
- Diseño y requisitos acordados en `docs/CONEXIONES-EXTERNAS.md`; decisiones #2–#4 en `DECISIONES.md`.
- Hecho: conector FoxPro 1.0.0 en `data/conectores/foxpro/` (API solo lectura, contrato v1 en
`data/conectores/README.md`). `npm test` 16/16 OK con DBF temporal. Sin probar con tablas reales
de SAGA ni los `.ps1` en Windows.
- Siguiente: con permiso del usuario, revisar por SSH `192.168.0.253` las carpetas de nómina y
clientes; instalar en `C:\api_project\foxpro`; luego el módulo del sistema.
## Agenda médica (v4.7.0, sin desplegar) ## Agenda médica (v4.7.0, sin desplegar)
- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
- 2026-10-29: BD de dev con datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre - v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos. abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en - v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
+10 -1
View File
@@ -102,9 +102,18 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
y tipos de cita desde la pestaña Configuración de agenda. y tipos de cita desde la pestaña Configuración de agenda.
- Vistas (v4.8.0; desde v4.8.1 abre en Mes con la barra del Calendario; la elegida se recuerda): **Día** con una columna por médico; **Semana** como - Vistas (v4.8.0; desde v4.8.1 abre en Mes con la barra del Calendario; la elegida se recuerda): **Día** con una columna por médico; **Semana** como
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes** calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
como calendario mensual (hasta 4 entradas por día + "N más"; clic en un día abre su vista Día); como calendario mensual (hasta 5 entradas por día + "N más" que lleva al día; desde v4.9.0 clic en un día u hora libre abre la cita nueva con esa fecha);
**Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes. **Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes.
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto. Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
- Formularios (desde la refactorización posterior a v4.10.0) con el modelo de "Nueva reunión" de
Calendario: campos en pares, fecha + hora con `DateTimeEditor`, notas a lo ancho. Pacientes y
Bloqueos son listados (`ListView`); Configuración (personal, tipos, horario) son listados con popup
en lectura → Editar. El detalle de un paciente muestra su historial de citas.
- "Nuevo paciente" desde la cita abre el formulario del paciente encima (precarga la cédula si lo
buscado es numérico); guardar, cancelar o cerrar vuelve a la cita. Cada cita nueva abre limpia.
- **Aviso, no bloqueo:** al elegir el paciente de una cita, si tiene citas futuras agendadas o
confirmadas (cualquier fecha y médico, sin contar la que se edita) se listan como advertencia y
se puede agendar igual. Sin citas futuras no se muestra nada.
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza - Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
+1 -1
View File
@@ -1 +1 @@
4.9.1 4.11.0
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.9.1", "version": "4.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.9.1", "version": "4.11.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.9.1", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+50
View File
@@ -0,0 +1,50 @@
# 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.0.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, 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`).
Cada fila trae `_recno` (posición en el origen). `version` cambia cuando el origen se modifica:
el sistema la compara para saber si hace falta volver a sincronizar.
Tipos de dato en JSON: texto recortado (vacío → `null`), números, booleanos, fecha `YYYY-MM-DD`,
fecha-hora ISO 8601.
Errores: `{ error: "mensaje" }` con `400` parámetro inválido, `401` token, `403` IP, `404`
fuente/tabla, `405` método, `423` tabla bloqueada en el origen, `429` exceso de peticiones,
`503` conector ocupado, `500` error interno (detalle solo en el log del conector).
+4
View File
@@ -0,0 +1,4 @@
config.json
logs/
node_modules/
node/
+44
View File
@@ -0,0 +1,44 @@
# Conector FoxPro 1.0.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.
- 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: copiar `node.exe` portable (Node 20 LTS o superior, Windows x64) a
`C:\api_project\foxpro\node\node.exe`. Así no hay que instalar Node en el servidor.
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 (16 pruebas).
- `npm run token`: genera token + hash.
@@ -0,0 +1,34 @@
{
"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:\\Sistemas\\nomina",
"encoding": "cp1252",
"tables": "*",
"denyFields": ["SUELDO", "SALARIO", "CLAVE", "PASSWORD"]
},
{
"id": "clientes",
"name": "Clientes SAGA",
"path": "C:\\Sistemas\\saga",
"encoding": "cp1252",
"tables": { "CLIENTES": { "fields": ["CODIGO", "CEDULA", "NOMBRE", "TELEFONO", "EMAIL", "FECNAC"] } }
}
],
"limits": {
"maxPageSize": 1000,
"requestsPerMinute": 120,
"maxConcurrentReads": 2,
"maxQueuedReads": 10,
"cacheMaxRecordsPerTable": 100000,
"cacheMaxTables": 10,
"requestTimeoutMs": 30000
},
"logDir": "logs"
}
+47
View File
@@ -0,0 +1,47 @@
{
"name": "linkedcorp-conector-foxpro",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "linkedcorp-conector-foxpro",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"dbffile": "1.14.1"
}
},
"node_modules/dbffile": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dbffile/-/dbffile-1.14.1.tgz",
"integrity": "sha512-X5+w+H5J3hVsKPu1/WAWhe9JMoqe/9RojCRGIV1+4tyGkXv1p2rQ7L+vw8jtQWppWqGc7XLaz5O8sCSAeAfjaQ==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.7.3"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "linkedcorp-conector-foxpro",
"version": "1.0.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,124 @@
'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'),
}));
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('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);
});
+81
View File
@@ -0,0 +1,81 @@
'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),
},
logDir: path.resolve(ROOT, raw.logDir || 'logs'),
};
}
module.exports = { load, ROOT, ID_RE };
+227
View File
@@ -0,0 +1,227 @@
'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) {
if (Number.isNaN(value.getTime())) 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);
if (hit && hit.version === sig.version) {
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, 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() : '';
const qFields = text ? (q.qFields ? pick(q.qFields, 'qFields') : fields) : [];
const matches = data.records.filter((r) => {
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, total: matches.length, offset, limit, fields, rows };
}
module.exports = { listTables, getSchema, readRows, HttpError };
+157
View File
@@ -0,0 +1,157 @@
'use strict';
// Conector FoxPro — API de solo lectura (contrato v1, ver ../README.md).
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const config = require('./config');
const dbf = require('./dbf');
const pkg = require('../package.json');
const cfg = config.load();
const { HttpError } = dbf;
// --- Log de acceso (sin tokens ni valores de búsqueda: pueden ser datos personales) ---
fs.mkdirSync(cfg.logDir, { recursive: true });
function log(line) {
const day = new Date().toISOString().slice(0, 10);
fs.appendFile(path.join(cfg.logDir, `acceso-${day}.log`), `${new Date().toISOString()} ${line}\n`, () => {});
}
// --- IPs permitidas (IPv4 exacta o CIDR) ---
function ipToInt(ip) {
const p = ip.split('.').map(Number);
if (p.length !== 4 || p.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null;
return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
}
const allow = cfg.allowIps.map((rule) => {
const [base, bits = '32'] = rule.split('/');
const b = ipToInt(base);
const n = Number(bits);
if (b === null || !(n >= 0 && n <= 32)) throw new Error(`config.json: allowIps inválida: ${rule}`);
const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
return { net: (b & mask) >>> 0, mask };
});
function ipAllowed(ip) {
const v = ipToInt(ip);
return v !== null && allow.some((r) => ((v & r.mask) >>> 0) === r.net);
}
// --- Token Bearer (se guarda solo su SHA-256) ---
const hashes = cfg.tokenHashes.map((h) => Buffer.from(h, 'hex'));
function tokenOk(req) {
const m = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || '');
if (!m) return false;
const h = crypto.createHash('sha256').update(m[1]).digest();
return hashes.some((x) => crypto.timingSafeEqual(x, h));
}
// --- Límite de peticiones por IP y de lecturas simultáneas (no cargar el servidor de SAGA) ---
const hits = new Map();
function rateOk(ip) {
const now = Date.now();
const w = hits.get(ip) || { start: now, n: 0 };
if (now - w.start >= 60000) {
w.start = now;
w.n = 0;
}
w.n += 1;
hits.set(ip, w);
return w.n <= cfg.limits.requestsPerMinute;
}
let active = 0;
const queue = [];
async function withReadSlot(fn) {
if (active >= cfg.limits.maxConcurrentReads) {
if (queue.length >= cfg.limits.maxQueuedReads) throw new HttpError(503, 'Conector ocupado; reintente');
await new Promise((resolve) => queue.push(resolve));
}
active += 1;
try {
return await fn();
} finally {
active -= 1;
const next = queue.shift();
if (next) next();
}
}
// --- Rutas ---
function findSource(id) {
const s = cfg.sources.find((x) => x.id === id);
if (!s) throw new HttpError(404, 'Fuente no encontrada');
return s;
}
async function route(parts, query) {
// parts: ['v1', ...]
if (parts[0] !== 'v1') throw new HttpError(404, 'No encontrado');
const [, a, sourceId, b, table, c] = parts;
if (a === 'health' && parts.length === 2) {
return { ok: true, connector: 'foxpro', contract: 'v1', version: pkg.version, time: new Date().toISOString() };
}
if (a !== 'sources') throw new HttpError(404, 'No encontrado');
if (parts.length === 2) {
return { sources: cfg.sources.map((s) => ({ id: s.id, name: s.name })) };
}
const source = findSource(sourceId);
if (parts.length === 4 && b === 'tables') return { source: source.id, tables: await withReadSlot(() => dbf.listTables(source)) };
if (parts.length === 6 && b === 'tables' && c === 'schema') return { source: source.id, ...(await withReadSlot(() => dbf.getSchema(source, table))) };
if (parts.length === 6 && b === 'tables' && c === 'rows') return { source: source.id, ...(await withReadSlot(() => dbf.readRows(source, table, query, cfg.limits))) };
throw new HttpError(404, 'No encontrado');
}
function send(res, status, body) {
const data = JSON.stringify(body);
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(data),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
});
res.end(data);
}
async function handler(req, res) {
const t0 = Date.now();
const ip = String(req.socket.remoteAddress || '').replace(/^::ffff:/, '');
let status = 500;
let pathname = '-';
try {
if (!ipAllowed(ip)) throw new HttpError(403, 'Origen no permitido');
if (!rateOk(ip)) throw new HttpError(429, 'Demasiadas peticiones');
if (!tokenOk(req)) throw new HttpError(401, 'Token inválido');
if (req.method !== 'GET') throw new HttpError(405, 'Solo lectura (GET)');
const url = new URL(req.url, 'http://x');
pathname = url.pathname;
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const body = await route(parts, Object.fromEntries(url.searchParams));
status = 200;
send(res, status, body);
} catch (err) {
status = err instanceof HttpError ? err.status : 500;
if (status === 500) log(`ERROR ${ip} ${pathname} ${err.stack || err}`);
send(res, status, { error: status === 500 ? 'Error interno del conector' : err.message });
} finally {
log(`${ip} ${req.method} ${pathname} ${status} ${Date.now() - t0}ms`);
}
}
const server = cfg.tls
? https.createServer({ pfx: fs.readFileSync(cfg.tls.pfx), passphrase: process.env[cfg.tls.passphraseEnv] }, handler)
: http.createServer(handler);
server.requestTimeout = cfg.limits.requestTimeoutMs;
server.headersTimeout = 10000;
server.listen(cfg.port, cfg.host, () => {
const msg = `conector foxpro ${pkg.version} escuchando en ${cfg.tls ? 'https' : 'http'}://${cfg.host}:${cfg.port}`;
log(msg);
console.log(msg);
});
+61
View File
@@ -0,0 +1,61 @@
# 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.0.0 hecho** (`data/conectores/foxpro/`, 16 pruebas locales
OK, sin probar con tablas reales ni en Windows). **Módulo del sistema: pendiente** (solo diseño).
## 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 previstas: `Sistemas\nomina` (empleados) y la tabla de clientes de SAGA (pacientes de la
Agenda médica). Nombres reales de tablas y campos: **por confirmar** en el servidor.
## 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)
- 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.
## Pendientes
- [ ] Revisar en el server (SSH, solo lectura, con permiso del usuario) las carpetas de nómina y
clientes: tablas, campos, versión de los DBF y codificación (cp1252/cp850).
- [ ] Instalar el conector en `C:\api_project\foxpro` (node.exe portable + `config.json` + token +
`instalar-servicio.ps1`) y probar desde el backend.
- [ ] Módulo del sistema: migración (conexiones, fuentes/mapeos, empleados, vínculo usuario↔empleado),
backend `modules/integrations`, pantalla en Configuración, botón en Organización, alta de
usuario desde nómina, búsqueda de pacientes externa en la Agenda.
+5
View File
@@ -42,6 +42,11 @@ abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=
pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y
Empresa además se consulta el servidor justo antes de guardar. Empresa además se consulta el servidor justo antes de guardar.
- **Formulario abierto desde otro** (ej. "Nuevo paciente" desde una cita): guardar, cancelar o
cerrar vuelve al formulario de origen con su borrador; nunca se cierra el de origen dejando su
borrador huérfano (eso hacía que la siguiente cita nueva apareciera con el paciente anterior).
Descartar un registro **nuevo** cierra el popup; uno existente vuelve a lectura.
## Formularios sin recuperación (a propósito) ## Formularios sin recuperación (a propósito)
| Formulario | Motivo | | Formulario | Motivo |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.9.1", "version": "4.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.9.1", "version": "4.11.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.9.1", "version": "4.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -2
View File
@@ -53,7 +53,11 @@ export const messages = {
clinic_kind_other: 'Otro', clinic_kind_other: 'Otro',
clinic_title: 'Título', clinic_title: 'Título',
clinic_staff: 'Personal', clinic_staff: 'Personal',
clinic_user_id: 'ID de usuario', clinic_user: 'Usuario',
clinic_scope: 'Rol en la agenda',
clinic_add_staff: 'Agregar personal',
clinic_add_segment: 'Agregar tramo',
clinic_patient_upcoming: 'Este paciente ya tiene citas pendientes (puedes agendar igual):',
clinic_scope_all: 'Recepción (todos)', clinic_scope_all: 'Recepción (todos)',
clinic_scope_own: 'Médico (propia)', clinic_scope_own: 'Médico (propia)',
clinic_is_doctor: 'Es médico', clinic_is_doctor: 'Es médico',
@@ -743,7 +747,11 @@ export const messages = {
clinic_kind_other: 'Other', clinic_kind_other: 'Other',
clinic_title: 'Title', clinic_title: 'Title',
clinic_staff: 'Staff', clinic_staff: 'Staff',
clinic_user_id: 'User ID', clinic_user: 'User',
clinic_scope: 'Agenda role',
clinic_add_staff: 'Add staff',
clinic_add_segment: 'Add time slot',
clinic_patient_upcoming: 'This patient already has upcoming appointments (you can still book):',
clinic_scope_all: 'Reception (all)', clinic_scope_all: 'Reception (all)',
clinic_scope_own: 'Doctor (own)', clinic_scope_own: 'Doctor (own)',
clinic_is_doctor: 'Is doctor', clinic_is_doctor: 'Is doctor',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { ListView } from '../../components/ListView.jsx';
import { Badge } from '../../components/Badge.jsx';
import * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import { WEEKDAYS } from './clinicUtils.js';
// Configuración de la agenda (admin/superadmin): Personal, Tipos de cita y Horario de atención.
// Cada sección es un listado; "Agregar"/clic en fila abre un popup en lectura y "Editar" habilita.
const staffForm = (s) => s ? { userId: s.user_id, scope: s.scope, isDoctor: !!s.is_doctor, specialty: s.specialty || '', color: s.color || '#0078d4', isActive: !!s.is_active, editVersion: s.edit_version } : { userId: '', scope: 'own', isDoctor: true, specialty: '', color: '#0078d4', isActive: true };
const typeForm = (x) => x ? { name: x.name, durationMinutes: x.duration_minutes, color: x.color, isActive: !!x.is_active, editVersion: x.edit_version } : { name: '', durationMinutes: 30, color: '#0078d4', isActive: true };
const scheduleForm = (rows) => ({ rows: rows.map((s) => ({ weekday: s.weekday, startsTime: String(s.starts_time).slice(0, 5), endsTime: String(s.ends_time).slice(0, 5) })) });
const swatch = (color) => <span className="calendar-swatch" style={{ background: color || 'rgb(var(--accent))' }} />;
export function ClinicConfigTab({ onChanged }) {
const { t, pushToast } = useUi();
const guardedClose = useGuardedClose();
const [staff, setStaff] = useState([]), [scheduleDoctors, setScheduleDoctors] = useState([]), [users, setUsers] = useState([]), [types, setTypes] = useState([]);
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [loadError, setLoadError] = useState('');
async function reload() {
try {
const [s, u, ty] = await Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]);
// Horario por médico (todos los médicos del personal, aunque quien configura no sea personal).
const docs = s.filter((x) => x.is_doctor);
const segs = await Promise.all(docs.map((d) => api.clinicSchedules(d.user_id)));
setStaff(s); setUsers(u); setTypes(ty);
setScheduleDoctors(docs.map((d, i) => ({ id: d.user_id, name: d.name, color: d.color, schedules: segs[i] })));
setLoadError('');
} catch (e) { setLoadError(e.message); }
}
useEffect(() => { void reload(); }, []);
const initial = !popup ? {} : popup.kind === 'staff' ? staffForm(popup.item) : popup.kind === 'type' ? typeForm(popup.item) : scheduleForm(popup.rows || []);
const draft = useDraftForm({
formId: popup?.kind === 'staff' ? 'clinic-staff' : popup?.kind === 'type' ? 'clinic-type' : 'clinic-schedule',
entityId: popup?.kind === 'staff' ? popup.item?.user_id : popup?.kind === 'type' ? popup.item?.id : popup?.doctor?.id,
initial,
label: t(popup?.kind === 'staff' ? 'clinic_staff' : popup?.kind === 'type' ? 'clinic_types' : 'clinic_schedules'),
enabled: !!popup && editing,
onDiscard: () => { if (popup?.item || popup?.kind === 'schedule') setEditing(false); else setPopup(null); }
});
const v = draft.values;
async function open(kind, item = null, extra = {}) {
setError('');
if (kind === 'schedule') extra.rows = extra.doctor.schedules;
setPopup({ kind, item, ...extra });
setEditing(!item && kind !== 'schedule');
}
function close() { void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); }); }
const cancelEdit = () => draft.discard();
async function save() {
try {
if (popup.kind === 'staff') await api.clinicSaveStaff(v.userId, v);
if (popup.kind === 'type') await api.clinicSaveType(v, popup.item?.id);
if (popup.kind === 'schedule') await api.clinicSaveSchedules(popup.doctor.id, { segments: rows });
} catch (e) { setError(e.message); return; }
draft.clear();
setPopup(null);
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
// Guardado ya hecho: si falla el refresco, se avisa fuera del popup (que ya está cerrado).
try { await reload(); await onChanged(); } catch (e) { setLoadError(e.message); }
}
const staffIds = new Set(staff.map((s) => s.user_id));
const scheduleText = (d) => (d.schedules || []).map((s) => `${t(`calendar_${WEEKDAYS[s.weekday]}`)} ${String(s.starts_time).slice(0, 5)}–${String(s.ends_time).slice(0, 5)}`).join(' · ') || '—';
const rows = (v?.rows || []).map((r) => ({ weekday: Number(r.weekday), startsTime: r.startsTime ?? String(r.starts_time || '').slice(0, 5), endsTime: r.endsTime ?? String(r.ends_time || '').slice(0, 5) }));
const setRows = (next) => draft.setField('rows', next);
const canSave = popup?.kind === 'staff' ? !!v.userId : popup?.kind === 'type' ? !!v.name?.trim() && Number(v.durationMinutes) > 0 : true;
const detail = !popup?.item ? [] : popup.kind === 'staff' ? [
[t('clinic_user'), popup.item.name], [t('clinic_scope'), t(`clinic_scope_${popup.item.scope}`)], [t('clinic_is_doctor'), t(popup.item.is_doctor ? 'common_yes' : 'common_no')],
[t('clinic_specialty'), popup.item.specialty || '—'], [t('clinic_color'), popup.item.color ? <>{swatch(popup.item.color)} {popup.item.color}</> : '—'], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : popup.kind === 'type' ? [
[t('clinic_name'), popup.item.name], [t('clinic_duration'), popup.item.duration_minutes], [t('clinic_color'), <>{swatch(popup.item.color)} {popup.item.color}</>], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : [];
return <div className="clinic-config">
{loadError && <InfoBar tone="error">{loadError}</InfoBar>}
<div className="toolbar"><h3 className="mt-0">{t('clinic_staff')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('staff')}>{t('clinic_add_staff')}</Button></div>
<ListView getRowKey={(s) => s.user_id} rows={staff} onRowClick={(s) => void open('staff', s)}
columns={[
{ key: 'name', label: t('clinic_user'), render: (s) => <span className="clinic-name">{s.is_doctor ? swatch(s.color) : null}{s.name}</span> },
{ key: 'scope', label: t('clinic_scope'), render: (s) => t(`clinic_scope_${s.scope}`) },
{ key: 'specialty', label: t('clinic_specialty'), render: (s) => s.specialty || '—' },
{ key: 'is_active', label: t('common_status'), render: (s) => <Badge color={s.is_active ? 'success' : 'muted'}>{t(s.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(s) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('staff', s).then(() => setEditing(true)); } }]} />
<div className="toolbar"><h3>{t('clinic_types')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('type')}>{t('clinic_new_type')}</Button></div>
<ListView getRowKey={(x) => x.id} rows={types} onRowClick={(x) => void open('type', x)}
columns={[
{ key: 'name', label: t('clinic_name'), render: (x) => <span className="clinic-name">{swatch(x.color)}{x.name}</span> },
{ key: 'duration_minutes', label: t('clinic_duration') },
{ key: 'is_active', label: t('common_status'), render: (x) => <Badge color={x.is_active ? 'success' : 'muted'}>{t(x.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(x) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('type', x).then(() => setEditing(true)); } }]} />
<h3>{t('clinic_schedules')}</h3>
<ListView getRowKey={(d) => d.id} rows={scheduleDoctors} onRowClick={(d) => void open('schedule', null, { doctor: d })}
columns={[
{ key: 'name', label: t('clinic_doctor'), render: (d) => <span className="clinic-name">{swatch(d.color)}{d.name}</span> },
{ key: 'schedules', label: t('clinic_schedules'), sortable: false, render: scheduleText }
]} />
<DetailPopup open={!!popup} title={popup?.kind === 'schedule' ? `${t('clinic_schedules')}: ${popup.doctor.name}` : popup?.kind === 'staff' ? (popup.item ? popup.item.name : t('clinic_add_staff')) : (popup?.item ? popup.item.name : t('clinic_new_type'))} onClose={close} width={760} height={popup?.kind === 'schedule' ? 680 : 480}>
{popup && <div className="clinic-form">
{error && <InfoBar tone="error">{error}</InfoBar>}
{editing && <DraftNotice draft={draft} />}
{!editing ? <>
{popup.kind === 'schedule'
? <div className="clinic-detail">{(popup.rows || []).length ? popup.rows.map((s) => <p key={s.id}><strong>{t(`calendar_${WEEKDAYS[s.weekday]}`)}:</strong> {String(s.starts_time).slice(0, 5)}–{String(s.ends_time).slice(0, 5)}</p>) : <p>—</p>}</div>
: <div className="clinic-detail">{detail.map(([label, val]) => <p key={label}><strong>{label}:</strong> {val}</p>)}</div>}
<div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
</> : <>
{popup.kind === 'staff' && <>
<FieldRow>
<Field label={t('clinic_user')} dirty={draft.isFieldDirty('userId')}>{popup.item ? <input className="input" readOnly value={popup.item.name} /> : <ComboBox value={v.userId} onChange={(x) => draft.setField('userId', x)} options={[{ value: '', label: t('common_select') }, ...users.filter((u) => u.is_active && !staffIds.has(u.id)).map((u) => ({ value: u.id, label: u.name }))]} />}</Field>
<Field label={t('clinic_scope')} dirty={draft.isFieldDirty('scope')}><ComboBox value={v.scope} onChange={(x) => draft.setValues((old) => ({ ...old, scope: x, isDoctor: x === 'own' ? true : old.isDoctor }))} options={['all', 'own'].map((x) => ({ value: x, label: t(`clinic_scope_${x}`) }))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_specialty')} dirty={draft.isFieldDirty('specialty')}><input className="input" maxLength={120} value={v.specialty} disabled={!v.isDoctor} onChange={(e) => draft.setField('specialty', e.target.value)} /></Field>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} disabled={!v.isDoctor} onChange={(e) => draft.setField('color', e.target.value)} /></Field>
</FieldRow>
<div className="clinic-checks">
<label className="calendar-filter"><input type="checkbox" checked={v.isDoctor} disabled={v.scope === 'own'} onChange={(e) => draft.setField('isDoctor', e.target.checked)} /> {t('clinic_is_doctor')}</label>
<label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('clinic_active')}</label>
</div>
</>}
{popup.kind === 'type' && <>
<FieldRow>
<Field label={t('clinic_name')} dirty={draft.isFieldDirty('name')}><input className="input" maxLength={120} value={v.name} onChange={(e) => draft.setField('name', e.target.value)} /></Field>
<Field label={t('clinic_duration')} dirty={draft.isFieldDirty('durationMinutes')}><input className="input" type="number" min={5} max={480} step={5} value={v.durationMinutes} onChange={(e) => draft.setField('durationMinutes', Number(e.target.value))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} onChange={(e) => draft.setField('color', e.target.value)} /></Field>
<Field label={t('common_status')}><label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('clinic_active')}</label></Field>
</FieldRow>
</>}
{popup.kind === 'schedule' && <>
{rows.map((s, i) => <div className="clinic-schedule-row" key={i}>
<Field label={t('calendar_day')}><ComboBox value={s.weekday} onChange={(x) => setRows(rows.map((r, j) => j === i ? { ...r, weekday: Number(x) } : r))} options={WEEKDAYS.map((x, n) => ({ value: n, label: t(`calendar_${x}`) }))} /></Field>
<Field label={t('calendar_start')}><input className="input" type="time" value={s.startsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, startsTime: e.target.value } : r))} /></Field>
<Field label={t('calendar_end')}><input className="input" type="time" value={s.endsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, endsTime: e.target.value } : r))} /></Field>
<Button icon={Trash2} variant="danger" aria-label={t('common_delete')} title={t('common_delete')} onClick={() => setRows(rows.filter((_, j) => j !== i))} />
</div>)}
<div><Button icon={Plus} onClick={() => setRows([...rows, { weekday: 0, startsTime: '09:00', endsTime: '17:00' }])}>{t('clinic_add_segment')}</Button></div>
</>}
<div className="toolbar-left clinic-actions"><Button variant="accent" disabled={!canSave || (!!popup.item || popup.kind === 'schedule') && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={cancelEdit}>{t('common_cancel')}</Button></div>
</>}
</div>}
</DetailPopup>
</div>;
}
@@ -0,0 +1,79 @@
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}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
export function AppointmentForm({ draft, doctors, types, patients, 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={(x) => draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) }))]} />
<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 }) {
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 <>
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
<FieldRow>
{input('email', 'clinic_email', { maxLength: 160, type: 'email' })}
<Field label={t('clinic_birth_date')} dirty={draft.isFieldDirty('birthDate')}><DatePicker value={v.birthDate || ''} onChange={(x) => draft.setField('birthDate', x || '')} label={t('clinic_birth_date')} /></Field>
</FieldRow>
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes || ''} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
</>;
}
export function BlockForm({ draft, doctors, fixedDoctor }) {
const { t } = useUi();
const v = draft.values;
return <>
<FieldRow>
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}>{fixedDoctor ? <input className="input" readOnly value={doctors.find((d) => d.id === v.doctorId)?.name || ''} /> : <ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} />}</Field>
<Field label={t('clinic_kind')} dirty={draft.isFieldDirty('kind')}><ComboBox value={v.kind} onChange={(x) => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map((x) => ({ value: x, label: t(`clinic_kind_${x}`) }))} /></Field>
</FieldRow>
<Field label={t('clinic_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title', e.target.value)} /></Field>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt', x)} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={(x) => draft.setField('recurrence', x)} />
</>;
}
+23 -1
View File
@@ -1,4 +1,26 @@
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}} .clinic-page{padding:16px;min-width:0}
.clinic-page .page-title{display:flex;align-items:center;gap:8px}
/* Formularios: misma estructura que "Nueva reunión" (FieldRow en pares, acciones al pie). */
.clinic-form{display:grid;gap:12px;align-content:start}
.clinic-form .field{min-width:0}
.clinic-inline{display:flex;gap:8px;align-items:center}
.clinic-inline>:first-child{flex:1;min-width:0}
.clinic-actions{margin-top:18px;flex-wrap:wrap}
.clinic-checks{display:flex;gap:24px;flex-wrap:wrap}
.clinic-color{padding:2px;height:36px;max-width:120px}
.clinic-upcoming{margin:6px 0 0;padding-left:18px}
.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}
.clinic-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}
.clinic-name{display:inline-flex;align-items:center;gap:8px}
.clinic-name .calendar-swatch,.clinic-detail .calendar-swatch{display:inline-block;width:10px;height:10px;border-radius:3px;flex:none}
.clinic-pager{margin-top:12px;align-items:center}
.clinic-config{display:grid;gap:8px}
.clinic-config h3:not(.mt-0){margin:20px 0 4px}
.clinic-config h3.mt-0{margin-bottom:4px}
.clinic-schedule-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(110px,1fr) minmax(110px,1fr) auto;gap:8px;align-items:end}
.clinic-schedule-row>.btn{margin-bottom:2px}
@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}.clinic-schedule-row{grid-template-columns:1fr 1fr}}
/* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */ /* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */
.meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px} .meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px}
.meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)} .meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)}
@@ -0,0 +1,70 @@
// Utilidades de la Agenda médica (fechas "YYYY-MM-DD HH:MM:SS" en hora de Ecuador, sin zona).
export const at = (day, hour = '09:00') => `${day} ${hour}:00`;
export const plus = (s, n) => {
const d = new Date(`${s.replace(' ', 'T')}Z`);
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
};
export const readStored = (key, fallback) => {
try {
return JSON.parse(localStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};
export const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
export const initialAppointment = (day, doctorId, slot, patientId) => ({
doctorId: doctorId || '',
patientId: patientId || '',
typeId: '',
startsAt: slot || at(day),
endsAt: plus(slot || at(day), 30),
channel: 'phone',
notes: ''
});
export const initialPatient = (identification = '') => ({
firstNames: '',
lastNames: '',
identification,
phone: '',
email: '',
birthDate: '',
notes: ''
});
export const initialBlock = (day, doctorId) => ({
doctorId: doctorId || '',
kind: 'surgery',
title: '',
startsAt: at(day),
endsAt: plus(at(day), 60),
recurrence: null
});
export const formAppointment = (a) => ({
doctorId: a.doctor_user_id,
patientId: a.patient_id,
typeId: a.type_id,
startsAt: a.starts_at,
endsAt: a.ends_at,
channel: a.channel,
notes: a.notes || '',
editVersion: a.edit_version
});
export const formPatient = (p) => ({
firstNames: p.first_names,
lastNames: p.last_names,
identification: p.identification || '',
phone: p.phone,
email: p.email || '',
birthDate: p.birth_date || '',
notes: p.notes || '',
editVersion: p.edit_version
});
export const formBlock = (b) => ({
doctorId: b.doctor_user_id,
kind: b.kind,
title: b.title,
startsAt: b.starts_at,
endsAt: b.ends_at,
recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
editVersion: b.edit_version
});
+2 -2
View File
@@ -303,14 +303,14 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px; display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px;
} }
.dialog-surface { .dialog-surface {
width: 420px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg); width: 460px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4); overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4);
} }
.dialog-stripe { height: 4px; } .dialog-stripe { height: 4px; }
.dialog-body { padding: 20px; } .dialog-body { padding: 20px; }
.dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; } .dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
.dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; } .dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); } .dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); }
/* --- InfoBar --- */ /* --- InfoBar --- */
.infobar { .infobar {
+1 -1
View File
@@ -14,7 +14,7 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i; const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = { const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'], 'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'clinic-staff': ['editVersion','scope','isDoctor','specialty','color','isActive'], 'clinic-staff': ['editVersion','userId','scope','isDoctor','specialty','color','isActive'],
'clinic-type': ['editVersion','name','durationMinutes','color','isActive'], 'clinic-type': ['editVersion','name','durationMinutes','color','isActive'],
'clinic-schedule': ['rows'], 'clinic-schedule': ['rows'],
'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'], 'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'],