Compare commits
2 Commits
28444b1485
...
fabe5b530d
| Author | SHA1 | Date | |
|---|---|---|---|
| fabe5b530d | |||
| d0f90542e9 |
@@ -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
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
config.json
|
||||||
|
logs/
|
||||||
|
node_modules/
|
||||||
|
node/
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
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.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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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 |
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Stethoscope, Plus } from 'lucide-react';
|
import { Stethoscope, Plus, Pencil, Check, XCircle, Trash2, CalendarClock, UserCheck, UserX } 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 { useDraftForm } from '../../hooks/useDraftForm.js';
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
@@ -7,101 +7,31 @@ import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
|||||||
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
import { Button } from '../../components/Button.jsx';
|
import { Button } from '../../components/Button.jsx';
|
||||||
import { Field } from '../../components/Field.jsx';
|
import { Field } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
|
||||||
import { CheckListDropdown } from '../../components/CheckListDropdown.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 { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
import { Tabs } from '../../components/Tabs.jsx';
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
import { InfoBar } from '../../components/InfoBar.jsx';
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
import { DatePicker } from '../../components/DatePicker.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 { CalendarBoard, CALENDAR_VIEWS, calendarBounds, dateKey, ecuadorNow, fromKey } from '../../components/CalendarBoard.jsx';
|
||||||
import * as api from '../../services/clinic.js';
|
import * as api from '../../services/clinic.js';
|
||||||
import { listUsers } from '../../services/users.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';
|
import './clinic.css';
|
||||||
const at = (day, hour = '09:00') => `${day} ${hour}:00`;
|
|
||||||
const plus = (s, n) => {
|
const PAGE_SIZE = 20;
|
||||||
const d = new Date(`${s.replace(' ', 'T')}Z`);
|
|
||||||
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
|
|
||||||
};
|
|
||||||
const initialAppointment = (day, doctorId, slot) => ({
|
|
||||||
doctorId: doctorId || '',
|
|
||||||
patientId: '',
|
|
||||||
typeId: '',
|
|
||||||
startsAt: slot || at(day),
|
|
||||||
endsAt: plus(slot || at(day), 30),
|
|
||||||
channel: 'phone',
|
|
||||||
notes: ''
|
|
||||||
});
|
|
||||||
const initialPatient = () => ({
|
|
||||||
firstNames: '',
|
|
||||||
lastNames: '',
|
|
||||||
identification: '',
|
|
||||||
phone: '',
|
|
||||||
email: '',
|
|
||||||
birthDate: '',
|
|
||||||
notes: ''
|
|
||||||
});
|
|
||||||
const initialBlock = (day, doctorId) => ({
|
|
||||||
doctorId: doctorId || '',
|
|
||||||
kind: 'surgery',
|
|
||||||
title: '',
|
|
||||||
startsAt: at(day),
|
|
||||||
endsAt: plus(at(day), 60),
|
|
||||||
recurrence: null
|
|
||||||
});
|
|
||||||
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
|
|
||||||
});
|
|
||||||
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
|
|
||||||
});
|
|
||||||
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
|
|
||||||
});
|
|
||||||
const readStored = (key, fallback) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(key)) ?? fallback;
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
export default function AgendaMedicaPage() {
|
export default function AgendaMedicaPage() {
|
||||||
const {
|
const { t, openDialog, pushToast } = useUi();
|
||||||
t,
|
const { user } = useAuth();
|
||||||
lang,
|
|
||||||
openDialog,
|
|
||||||
pushToast
|
|
||||||
} = useUi();
|
|
||||||
const {
|
|
||||||
user
|
|
||||||
} = useAuth();
|
|
||||||
const guardedClose = useGuardedClose();
|
const guardedClose = useGuardedClose();
|
||||||
const [me, setMe] = useState(null),
|
const [me, setMe] = useState(null),
|
||||||
[error, setError] = useState(''),
|
[error, setError] = useState(''),
|
||||||
[doctors, setDoctors] = useState([]),
|
[doctors, setDoctors] = useState([]),
|
||||||
[types, setTypes] = useState([]),
|
[types, setTypes] = useState([]);
|
||||||
[allTypes, setAllTypes] = useState([]);
|
|
||||||
const [tab, setTab] = useState('agenda'),
|
const [tab, setTab] = useState('agenda'),
|
||||||
// Clave nueva: la 4.8.x podia dejar guardada la vista Dia; ahora siempre arranca en Mes.
|
// 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'; }),
|
[view, setView] = useState(() => { const v = readStored('plc_clinic_view_v3', 'month'); return CALENDAR_VIEWS.includes(v) ? v : 'month'; }),
|
||||||
@@ -116,11 +46,15 @@ export default function AgendaMedicaPage() {
|
|||||||
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
|
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
|
||||||
});
|
});
|
||||||
const [appointments, setAppointments] = useState([]),
|
const [appointments, setAppointments] = useState([]),
|
||||||
[blocks, setBlocks] = useState([]),
|
[blocks, setBlocks] = useState([]);
|
||||||
[patients, setPatients] = useState([]),
|
// Pestaña Pacientes (listado paginado en el servidor).
|
||||||
|
const [patients, setPatients] = useState([]),
|
||||||
[search, setSearch] = useState(''),
|
[search, setSearch] = useState(''),
|
||||||
[page, setPage] = useState(1),
|
[page, setPage] = useState(1),
|
||||||
[patientTotal, setPatientTotal] = useState(0);
|
[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('');
|
||||||
const [popup, setPopup] = useState(null),
|
const [popup, setPopup] = useState(null),
|
||||||
[editing, setEditing] = useState(false),
|
[editing, setEditing] = useState(false),
|
||||||
[conflict, setConflict] = useState(''),
|
[conflict, setConflict] = useState(''),
|
||||||
@@ -131,58 +65,12 @@ export default function AgendaMedicaPage() {
|
|||||||
[patientHistory, setPatientHistory] = useState([]),
|
[patientHistory, setPatientHistory] = useState([]),
|
||||||
[quickPatientId, setQuickPatientId] = useState(null),
|
[quickPatientId, setQuickPatientId] = useState(null),
|
||||||
[reprogram, setReprogram] = useState([]);
|
[reprogram, setReprogram] = useState([]);
|
||||||
const [allUsers, setAllUsers] = useState([]),
|
|
||||||
[staffRows, setStaffRows] = useState([]),
|
|
||||||
[staffUser, setStaffUser] = useState(''),
|
|
||||||
[staffInitial, setStaffInitial] = useState({
|
|
||||||
scope: 'own',
|
|
||||||
isDoctor: true,
|
|
||||||
specialty: '',
|
|
||||||
color: '#0078d4',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
const [typeInitial, setTypeInitial] = useState({
|
|
||||||
name: '',
|
|
||||||
durationMinutes: 30,
|
|
||||||
color: '#0078d4',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
const [scheduleDoctor, setScheduleDoctor] = useState(''),
|
|
||||||
[scheduleInitial, setScheduleInitial] = useState({
|
|
||||||
rows: []
|
|
||||||
});
|
|
||||||
const staffDraft = useDraftForm({
|
|
||||||
formId: 'clinic-staff',
|
|
||||||
entityId: staffUser || null,
|
|
||||||
initial: staffInitial,
|
|
||||||
label: t('clinic_staff'),
|
|
||||||
enabled: tab === 'config'
|
|
||||||
});
|
|
||||||
const typeDraft = useDraftForm({
|
|
||||||
formId: 'clinic-type',
|
|
||||||
entityId: typeInitial.id || null,
|
|
||||||
initial: typeInitial,
|
|
||||||
label: t('clinic_types'),
|
|
||||||
enabled: tab === 'config'
|
|
||||||
});
|
|
||||||
const scheduleDraft = useDraftForm({
|
|
||||||
formId: 'clinic-schedule',
|
|
||||||
entityId: scheduleDoctor || null,
|
|
||||||
initial: scheduleInitial,
|
|
||||||
label: t('clinic_schedules'),
|
|
||||||
enabled: tab === 'config'
|
|
||||||
});
|
|
||||||
const staffForm = staffDraft.values,
|
|
||||||
typeForm = typeDraft.values,
|
|
||||||
scheduleRows = scheduleDraft.values.rows || [];
|
|
||||||
const setStaffForm = staffDraft.setValues,
|
|
||||||
setTypeForm = typeDraft.setValues;
|
|
||||||
const setScheduleRows = rows => scheduleDraft.setField('rows', rows);
|
|
||||||
const isReception = me?.scope === 'all',
|
const isReception = me?.scope === 'all',
|
||||||
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
|
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||||
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
|
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
|
||||||
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
|
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
|
||||||
const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]);
|
const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
try {
|
try {
|
||||||
@@ -194,111 +82,153 @@ export default function AgendaMedicaPage() {
|
|||||||
setError(e.message);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!user?.modules?.includes('clinic')) return;
|
if (!user?.modules?.includes('clinic')) return;
|
||||||
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
|
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
|
||||||
api.clinicMe().then(async staff => {
|
api.clinicMe().then(async staff => {
|
||||||
setMe(staff);
|
setMe(staff);
|
||||||
const data = await api.clinicDoctors();
|
await loadDoctors();
|
||||||
setDoctors(data.doctors);
|
|
||||||
setTypes(data.types);
|
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
if (isAdmin) setTab('config');
|
if (isAdmin) setTab('config');
|
||||||
else setError(e.message);
|
else setError(e.message);
|
||||||
});
|
});
|
||||||
}, [user?.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
void load();
|
if (tab === 'patients' && isReception) loadPatients().catch(e => setError(e.message));
|
||||||
}, [me?.user_id, period.from, period.to]);
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('plc_clinic_view_v3', JSON.stringify(view));
|
|
||||||
} catch {}
|
|
||||||
}, [view]);
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
|
|
||||||
} catch {}
|
|
||||||
}, [doctorFilter]);
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter));
|
|
||||||
} catch {}
|
|
||||||
}, [statusFilter]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (tab === 'patients' && isReception) api.clinicPatients(search, page).then(x => {
|
|
||||||
setPatients(x.items);
|
|
||||||
setPatientTotal(x.total);
|
|
||||||
}).catch(e => setError(e.message));
|
|
||||||
}, [tab, search, page, isReception]);
|
}, [tab, search, page, isReception]);
|
||||||
useEffect(() => {
|
|
||||||
if (tab === 'config' && isAdmin) Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]).then(([rows, users, kindRows]) => {
|
|
||||||
setStaffRows(rows);
|
|
||||||
setAllUsers(users);
|
|
||||||
setAllTypes(kindRows);
|
|
||||||
}).catch(e => setError(e.message));
|
|
||||||
}, [tab, isAdmin]);
|
|
||||||
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
|
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?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient() : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
|
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({
|
const draft = useDraftForm({
|
||||||
formId,
|
formId,
|
||||||
entityId: popup?.item?.id,
|
entityId: popup?.item?.id,
|
||||||
initial,
|
initial,
|
||||||
label: t(`clinic_${popup?.kind || 'appointment'}`),
|
label: t(`clinic_${popup?.kind || 'appointment'}`),
|
||||||
enabled: !!popup && editing,
|
enabled: !!popup && editing,
|
||||||
onDiscard: () => setEditing(false)
|
// 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;
|
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(() => {
|
useEffect(() => {
|
||||||
if (quickPatientId && popup?.kind === 'appointment' && editing) {
|
if (popup?.kind !== 'appointment' || !editing || (!popup.restore && !quickPatientId)) return;
|
||||||
draft.setField('patientId', quickPatientId);
|
draft.setValues(old => ({ ...old, ...(popup.restore || {}), ...(quickPatientId ? { patientId: quickPatientId } : {}) }));
|
||||||
setQuickPatientId(null);
|
setQuickPatientId(null);
|
||||||
}
|
setPopup(p => ({ ...p, restore: null }));
|
||||||
}, [quickPatientId, popup?.kind, editing]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
|
if (popup?.kind !== 'appointment' || !editing || !v.patientId) { setUpcoming([]); return; }
|
||||||
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
|
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)]);
|
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
|
||||||
|
|
||||||
function open(kind, item = null, extra = {}) {
|
function open(kind, item = null, extra = {}) {
|
||||||
setPopup({
|
setPopup({ kind, item, ...extra });
|
||||||
kind,
|
|
||||||
item,
|
|
||||||
...extra
|
|
||||||
});
|
|
||||||
setEditing(!item);
|
setEditing(!item);
|
||||||
setConflict('');
|
setConflict('');
|
||||||
setAffected([]);
|
setAffected([]);
|
||||||
setActionMode(null);
|
setActionMode(null);
|
||||||
setActionReason('');
|
setActionReason('');
|
||||||
|
setPatientHistory([]);
|
||||||
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
|
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
|
||||||
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {});
|
if (kind === 'appointment' && isReception) {
|
||||||
|
// Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
|
||||||
|
setApptSearch('');
|
||||||
|
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;
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
// "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() {
|
function close() {
|
||||||
void guardedClose(editing ? draft : null, () => {
|
void guardedClose(editing ? draft : null, () => {
|
||||||
|
if (popup?.returnTo) return backToAppointment();
|
||||||
setPopup(null);
|
setPopup(null);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
setConflict('');
|
setConflict('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const cancelEdit = () => draft.discard();
|
||||||
async function save(confirmAffected = false) {
|
async function save(confirmAffected = false) {
|
||||||
if (popup.item && !draft.isDirty) return;
|
if (popup.item && !draft.isDirty) return;
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
|
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 === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
|
||||||
if (popup.kind === 'block') result = await api.clinicSaveBlock({
|
if (popup.kind === 'block') result = await api.clinicSaveBlock({ ...v, confirmAffected }, popup.item?.id);
|
||||||
...v,
|
|
||||||
confirmAffected
|
|
||||||
}, popup.item?.id);
|
|
||||||
if (result?.requiresConfirmation) {
|
if (result?.requiresConfirmation) {
|
||||||
setAffected(result.affected);
|
setAffected(result.affected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draft.clear();
|
draft.clear();
|
||||||
if (popup.returnTo) {
|
if (popup.returnTo) {
|
||||||
|
setApptPatients(list => [result, ...list.filter(p => p.id !== result.id)]);
|
||||||
setQuickPatientId(result.id);
|
setQuickPatientId(result.id);
|
||||||
setPopup(popup.returnTo);
|
backToAppointment();
|
||||||
setEditing(true);
|
|
||||||
} else {
|
} else {
|
||||||
setPopup(null);
|
setPopup(null);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -307,32 +237,20 @@ export default function AgendaMedicaPage() {
|
|||||||
setAffected([]);
|
setAffected([]);
|
||||||
if (result?.affected?.length) setReprogram(result.affected);
|
if (result?.affected?.length) setReprogram(result.affected);
|
||||||
await load();
|
await load();
|
||||||
if (tab === 'patients') {
|
if (tab === 'patients') await loadPatients();
|
||||||
const list = await api.clinicPatients(search, page);
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
setPatients(list.items);
|
|
||||||
}
|
|
||||||
pushToast({
|
|
||||||
tone: 'success',
|
|
||||||
message: t('common_saved')
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setConflict(e.message);
|
setConflict(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function actionStatus(status) {
|
async function setStatus(item, status) {
|
||||||
try {
|
try {
|
||||||
await api.clinicAppointmentStatus(popup.item.id, {
|
await api.clinicAppointmentStatus(item.id, { status, editVersion: item.edit_version });
|
||||||
status,
|
if (popup?.item?.id === item.id) setPopup(null);
|
||||||
editVersion: popup.item.edit_version
|
|
||||||
});
|
|
||||||
setPopup(null);
|
|
||||||
await load();
|
await load();
|
||||||
pushToast({
|
pushToast({ tone: 'success', message: t('common_saved') });
|
||||||
tone: 'success',
|
|
||||||
message: t('common_saved')
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setConflict(e.message);
|
if (popup) setConflict(e.message); else setError(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados.
|
// Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados.
|
||||||
@@ -343,17 +261,12 @@ export default function AgendaMedicaPage() {
|
|||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
title: t(remove ? 'clinic_delete' : 'clinic_cancel'),
|
title: t(remove ? 'clinic_delete' : 'clinic_cancel'),
|
||||||
message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'),
|
message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'),
|
||||||
actions: [{
|
actions: [{ label: t('common_cancel') }, {
|
||||||
label: t('common_cancel')
|
|
||||||
}, {
|
|
||||||
label: t(remove ? 'clinic_delete' : 'clinic_cancel'),
|
label: t(remove ? 'clinic_delete' : 'clinic_cancel'),
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, {
|
await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, { reason: actionReason, editVersion: popup.item.edit_version });
|
||||||
reason: actionReason,
|
|
||||||
editVersion: popup.item.edit_version
|
|
||||||
});
|
|
||||||
setPopup(null);
|
setPopup(null);
|
||||||
setActionMode(null);
|
setActionMode(null);
|
||||||
await load();
|
await load();
|
||||||
@@ -364,74 +277,52 @@ export default function AgendaMedicaPage() {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function appointmentMenu(item) {
|
function deleteBlock() {
|
||||||
const actions = [{
|
openDialog({
|
||||||
label: t('common_close')
|
tone: 'warning',
|
||||||
}];
|
title: t('clinic_delete_block'),
|
||||||
if (item.deleted_at) return open('appointment', item);
|
message: t('clinic_delete_block_confirm'),
|
||||||
if (isReception && item.status === 'cancelled') actions.push({
|
actions: [{ label: t('common_cancel') }, {
|
||||||
label: t('clinic_delete'),
|
label: t('common_delete'),
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
onClick: () => {
|
onClick: async () => {
|
||||||
open('appointment', item);
|
try {
|
||||||
setActionMode('delete');
|
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 (item.status === 'cancelled') return openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
|
||||||
if (isReception) {
|
if (isReception) {
|
||||||
actions.push({
|
actions.push({ label: t('clinic_confirm'), onClick: () => void setStatus(item, 'confirmed') });
|
||||||
label: t('clinic_confirm'),
|
actions.push({ label: t('clinic_reprogram'), onClick: () => { open('appointment', item); setEditing(true); } });
|
||||||
onClick: () => api.clinicAppointmentStatus(item.id, {
|
actions.push({ label: t('clinic_cancel'), variant: 'danger', onClick: withReason('cancel') });
|
||||||
status: 'confirmed',
|
actions.push({ label: t('clinic_delete'), variant: 'danger', onClick: withReason('delete') });
|
||||||
editVersion: item.edit_version
|
|
||||||
}).then(load).catch(e => setError(e.message))
|
|
||||||
});
|
|
||||||
actions.push({
|
|
||||||
label: t('clinic_reprogram'),
|
|
||||||
onClick: () => {
|
|
||||||
open('appointment', item);
|
|
||||||
setEditing(true);
|
|
||||||
}
|
}
|
||||||
});
|
for (const status of ['attended', 'no_show']) actions.push({ label: t(`clinic_status_${status}`), onClick: () => void setStatus(item, status) });
|
||||||
actions.push({
|
openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
|
||||||
label: t('clinic_cancel'),
|
|
||||||
variant: 'danger',
|
|
||||||
onClick: () => {
|
|
||||||
open('appointment', item);
|
|
||||||
setActionMode('cancel');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
actions.push({
|
|
||||||
label: t('clinic_delete'),
|
|
||||||
variant: 'danger',
|
|
||||||
onClick: () => {
|
|
||||||
open('appointment', item);
|
|
||||||
setActionMode('delete');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const status of ['attended', 'no_show']) actions.push({
|
|
||||||
label: t(`clinic_status_${status}`),
|
|
||||||
onClick: () => api.clinicAppointmentStatus(item.id, {
|
|
||||||
status,
|
|
||||||
editVersion: item.edit_version
|
|
||||||
}).then(load).catch(e => setError(e.message))
|
|
||||||
});
|
|
||||||
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 doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
|
||||||
const doctorShort = id => (doctors.find(d => d.id === id)?.name || '').split(' ').slice(0, 2).join(' ');
|
const doctorShort = id => doctorName(id).split(' ').slice(0, 2).join(' ');
|
||||||
const statusOf = a => a.deleted_at ? 'deleted' : a.status;
|
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)));
|
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".
|
// 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 || '—';
|
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
|
// 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.
|
// si se mira uno solo). Sin permiso de agendar, el clic solo abre ese dia.
|
||||||
function newAt(d, slot) {
|
function newAt(d, slot, patient) {
|
||||||
if (!isReception || !doctors.length) {
|
if (!isReception || !doctors.length) {
|
||||||
if (!slot) {
|
if (!slot) {
|
||||||
setCursor(fromKey(d));
|
setCursor(fromKey(d));
|
||||||
@@ -443,7 +334,8 @@ export default function AgendaMedicaPage() {
|
|||||||
const first = selectedDoctors.flatMap(doc => (doc.schedules || []).filter(seg => seg.weekday === weekday).map(seg => String(seg.starts_time).slice(0, 5))).sort()[0];
|
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, {
|
open('appointment', null, {
|
||||||
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
|
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
|
||||||
slot: slot || at(d, first || '09:00')
|
slot: slot || at(d, first || '09:00'),
|
||||||
|
patient
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const openItem = (e, kind, item) => {
|
const openItem = (e, kind, item) => {
|
||||||
@@ -454,11 +346,14 @@ export default function AgendaMedicaPage() {
|
|||||||
// Entradas del calendario: color del medico; canceladas/eliminadas tachadas. En Mes/Semana van
|
// Entradas del calendario: color del medico; canceladas/eliminadas tachadas. En Mes/Semana van
|
||||||
// compactas; en Dia/Agenda con medico, tipo y estado.
|
// compactas; en Dia/Agenda con medico, tipo y estado.
|
||||||
const compact = view === 'month' || view === 'week';
|
const compact = view === 'month' || view === 'week';
|
||||||
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 => {
|
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)}`)}`;
|
||||||
e.preventDefault();
|
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}`}`;
|
||||||
e.stopPropagation();
|
const renderItem = ({ kind, data: x }) => kind === 'appointment'
|
||||||
appointmentMenu(x);
|
? <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) }}
|
||||||
}} title={`${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)}`)}`}>{compact ? `${x.starts_at.slice(11, 16)} ${x.first_names} ${x.last_names.slice(0, 1)}.` : `${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)}`)}`}</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={`${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctors.find(d => d.id === x.doctor_user_id)?.name || ''} · ${x.title}`}>{compact ? `${doctorShort(x.doctor_user_id)} · ${x.title}` : `${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctors.find(d => d.id === x.doctor_user_id)?.name || ''} · ${t(`clinic_kind_${x.kind}`) === x.title ? x.title : `${t(`clinic_kind_${x.kind}`)} · ${x.title}`}`}</button>;
|
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.
|
// 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 items = [...visible.map(a => ({ kind: 'appointment', day: a.starts_at.slice(0, 10), start: a.starts_at, data: a })), ...visibleBlocks.flatMap(b => {
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -476,9 +371,6 @@ export default function AgendaMedicaPage() {
|
|||||||
if (selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start <= start && b.occurrence_end >= end)) return 'blocked';
|
if (selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start <= start && b.occurrence_end >= end)) return 'blocked';
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={e => openItem(e, 'block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}</button>;
|
|
||||||
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_created_by'), `${author(popup.item)} · ${String(popup.item.created_at || '').slice(0, 16)}`], [t('clinic_notes'), popup.item.notes], [t('clinic_cancelled_by'), popup.item.cancelled_at && `${popup.item.cancelled_by_name || '—'} · ${popup.item.cancelled_at.slice(0, 16)} · ${popup.item.cancelled_reason || ''}`], [t('clinic_deleted_by'), popup.item.deleted_at && `${popup.item.deleted_by_name || '—'} · ${popup.item.deleted_at.slice(0, 16)} · ${popup.item.deleted_reason || ''}`]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end], [t('clinic_created_by'), popup.item.created_by_name]] : [];
|
|
||||||
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
|
|
||||||
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
|
// 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.
|
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
|
||||||
const hourRange = (() => {
|
const hourRange = (() => {
|
||||||
@@ -494,20 +386,57 @@ export default function AgendaMedicaPage() {
|
|||||||
if (lo >= hi) [lo, hi] = [7, 19];
|
if (lo >= hi) [lo, hi] = [7, 19];
|
||||||
return Array.from({ length: hi - lo }, (_, i) => lo + i);
|
return Array.from({ length: hi - lo }, (_, i) => lo + i);
|
||||||
})();
|
})();
|
||||||
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
|
const it = popup?.item;
|
||||||
})}>{t('clinic_new_block')}</Button>}</div></div>
|
// Tipos activos + el tipo actual de la cita que se edita aunque se haya desactivado.
|
||||||
{error && <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>}
|
const apptTypes = it && popup.kind === 'appointment' && !types.some(z => z.id === it.type_id)
|
||||||
<Tabs tabs={[...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])].map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => {
|
? [...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) }]
|
||||||
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
|
: types;
|
||||||
void guardedClose(current, () => setTab(key));
|
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_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}
|
{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`))}
|
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>}
|
actions={isReception && <Button variant="accent" icon={Plus} onClick={() => newAt(day)}>{t('clinic_new_appointment')}</Button>}
|
||||||
filters={<>
|
filters={<>
|
||||||
<label className="calendar-filter-label">{t('clinic_doctors')}
|
<label className="calendar-filter-label">{t('clinic_doctors')}
|
||||||
<CheckListDropdown label={!doctorFilter.length || doctorFilter.length === doctors.length ? t('clinic_all_doctors') : doctorFilter.length === 1 ? doctors.find(d => d.id === doctorFilter[0])?.name || '1' : `${doctorFilter.length} / ${doctors.length}`} options={doctors.map(d => ({ value: d.id, label: d.name }))} values={doctorFilter} onChange={setDoctorFilter} width={240} />
|
<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>
|
||||||
<label className="calendar-filter-label">{t('clinic_status')}
|
<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} />
|
<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} />
|
||||||
@@ -522,246 +451,83 @@ export default function AgendaMedicaPage() {
|
|||||||
<span className="calendar-legend-item clinic-done-sample"><span className="calendar-swatch" />{t('clinic_status_cancelled')} / {t('clinic_status_deleted')}</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>} />}
|
notice={me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>} />}
|
||||||
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
|
|
||||||
setSearch(e.target.value);
|
{tab === 'patients' && isReception && <>
|
||||||
setPage(1);
|
<div className="toolbar"><div className="toolbar-left"><SearchInput value={search} onChange={x => { setSearch(x); setPage(1); }} /></div></div>
|
||||||
}} /></Field><div className="clinic-list">{patients.map(p => <button key={p.id} className="clinic-row" onClick={() => open('patient', p)}>{p.first_names} {p.last_names} · {p.phone}</button>)}</div><div className="toolbar-left"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button>{page} / {Math.max(1, Math.ceil(patientTotal / 20))}<Button disabled={page * 20 >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div></>}
|
<ListView getRowKey={p => p.id} rows={patients} onRowClick={p => open('patient', p)}
|
||||||
{tab === 'blocks' && <div className="clinic-list">{visibleBlocks.map(b => <div key={`${b.id}-${b.occurrence_start}`}>{renderBlock(b)}</div>)}</div>}
|
columns={[
|
||||||
{tab === 'config' && isAdmin && <div className="clinic-config"><h3>{t('clinic_staff')}</h3><ComboBox value={staffUser} onChange={x => {
|
{ key: 'last_names', label: t('clinic_last_names') },
|
||||||
setStaffUser(x);
|
{ key: 'first_names', label: t('clinic_first_names') },
|
||||||
const s = staffRows.find(r => r.user_id === Number(x));
|
{ key: 'identification', label: t('clinic_identification'), render: p => p.identification || '—' },
|
||||||
setStaffInitial(s ? {
|
{ key: 'phone', label: t('clinic_phone') },
|
||||||
scope: s.scope,
|
{ key: 'email', label: t('clinic_email'), render: p => p.email || '—' }
|
||||||
isDoctor: !!s.is_doctor,
|
]}
|
||||||
specialty: s.specialty || '',
|
rowActions={p => [
|
||||||
color: s.color || '#0078d4',
|
{ label: t('common_edit'), icon: Pencil, onClick: () => { open('patient', p); setEditing(true); } },
|
||||||
isActive: !!s.is_active,
|
{ label: t('clinic_new_appointment'), icon: CalendarClock, onClick: () => { setTab('agenda'); newAt(day, undefined, p); } }
|
||||||
editVersion: s.edit_version
|
]} />
|
||||||
} : {
|
<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>
|
||||||
scope: 'own',
|
</>}
|
||||||
isDoctor: true,
|
|
||||||
specialty: '',
|
{tab === 'blocks' && <ListView getRowKey={b => `${b.id}-${b.occurrence_start}`} rows={visibleBlocks} onRowClick={b => open('block', b)} initialSort={{ key: 'occurrence_start', dir: 1 }}
|
||||||
color: '#0078d4',
|
columns={[
|
||||||
isActive: true
|
{ 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) },
|
||||||
}} options={[{
|
{ 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> },
|
||||||
value: '',
|
{ key: 'kind', label: t('clinic_kind'), render: b => t(`clinic_kind_${b.kind}`) },
|
||||||
label: t('common_select')
|
{ key: 'title', label: t('clinic_title') }
|
||||||
}, ...allUsers.filter(u => u.is_active).map(u => ({
|
]} />}
|
||||||
value: u.id,
|
|
||||||
label: u.name
|
{tab === 'config' && isAdmin && <ClinicConfigTab onChanged={() => (me ? loadDoctors() : Promise.resolve())} />}
|
||||||
}))]} /><Field label={t('clinic_user_id')}><input className="input" type="number" value={staffUser} onChange={e => setStaffUser(e.target.value)} /></Field><ComboBox value={staffForm.scope} onChange={x => setStaffForm({
|
|
||||||
...staffForm,
|
<DetailPopup open={!!popup} title={popupTitle} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">
|
||||||
scope: x
|
{conflict && <InfoBar tone="error">{conflict}</InfoBar>}
|
||||||
})} options={['all', 'own'].map(x => ({
|
{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>}
|
||||||
value: x,
|
{editing && <DraftNotice draft={draft} />}
|
||||||
label: t(`clinic_scope_${x}`)
|
{!editing ? it && <>
|
||||||
}))} /><label><input type="checkbox" checked={staffForm.isDoctor} onChange={e => setStaffForm({
|
{popup.kind === 'appointment' && it.deleted_at && <InfoBar tone="warning">{t('clinic_deleted_notice')}</InfoBar>}
|
||||||
...staffForm,
|
<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>
|
||||||
isDoctor: e.target.checked
|
{actionMode && <>
|
||||||
})} />{t('clinic_is_doctor')}</label><label><input type="checkbox" checked={staffForm.isActive} onChange={e => setStaffForm({
|
<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>
|
||||||
...staffForm,
|
<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>
|
||||||
isActive: e.target.checked
|
</>}
|
||||||
})} />{t('clinic_active')}</label><Field label={t('clinic_specialty')}><input className="input" value={staffForm.specialty} onChange={e => setStaffForm({
|
{popup.kind === 'appointment' && !it.deleted_at && !actionMode && <div className="toolbar-left clinic-actions">
|
||||||
...staffForm,
|
{isReception && it.status !== 'cancelled' && <>
|
||||||
specialty: e.target.value
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||||
})} /></Field><Field label={t('clinic_color')}><input type="color" value={staffForm.color} onChange={e => setStaffForm({
|
<Button icon={Check} disabled={it.status !== 'scheduled'} onClick={() => void setStatus(it, 'confirmed')}>{t('clinic_confirm')}</Button>
|
||||||
...staffForm,
|
</>}
|
||||||
color: e.target.value
|
{it.status !== 'cancelled' && <>
|
||||||
})} /></Field><Button disabled={!staffUser || !!staffForm.editVersion && !staffDraft.isDirty} onClick={async () => {
|
<Button icon={UserCheck} disabled={it.status === 'attended'} onClick={() => void setStatus(it, 'attended')}>{t('clinic_status_attended')}</Button>
|
||||||
try {
|
<Button icon={UserX} disabled={it.status === 'no_show'} onClick={() => void setStatus(it, 'no_show')}>{t('clinic_status_no_show')}</Button>
|
||||||
const saved = await api.clinicSaveStaff(staffUser, staffForm);
|
</>}
|
||||||
staffDraft.clear();
|
{isReception && it.status !== 'cancelled' && <Button icon={XCircle} onClick={() => setActionMode('cancel')}>{t('clinic_cancel')}</Button>}
|
||||||
setStaffInitial({
|
{isReception && <Button variant="danger" icon={Trash2} onClick={() => setActionMode('delete')}>{t('clinic_delete')}</Button>}
|
||||||
scope: saved.scope,
|
</div>}
|
||||||
isDoctor: !!saved.is_doctor,
|
{popup.kind === 'patient' && isReception && <>
|
||||||
specialty: saved.specialty || '',
|
<div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
|
||||||
color: saved.color || 'rgb(var(--accent))',
|
<h3>{t('clinic_history')}</h3>
|
||||||
isActive: !!saved.is_active,
|
{patientHistory.length ? <ListView getRowKey={a => a.id} rows={patientHistory} initialSort={{ key: 'starts_at', dir: -1 }}
|
||||||
editVersion: saved.edit_version
|
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> },
|
||||||
setStaffRows(await api.clinicStaff());
|
{ key: 'doctor_name', label: t('clinic_doctor') },
|
||||||
setDoctors((await api.clinicDoctors()).doctors);
|
{ key: 'type_name', label: t('clinic_type') },
|
||||||
pushToast({
|
{ key: 'status', label: t('clinic_status'), render: a => t(`clinic_status_${statusOf(a)}`) },
|
||||||
tone: 'success',
|
{ key: 'created_by_name', label: t('clinic_created_by'), render: author }
|
||||||
message: t('common_saved')
|
]} /> : <p className="text-muted">—</p>}
|
||||||
});
|
</>}
|
||||||
} catch (e) {
|
{popup.kind === 'block' && <div className="toolbar-left clinic-actions">
|
||||||
setError(e.message);
|
<Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
||||||
}
|
<Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
|
||||||
}}>{t('common_save')}</Button>
|
</div>}
|
||||||
<h3>{t('clinic_types')}</h3><ComboBox value={typeForm.id || ''} onChange={x => {
|
</> : <>
|
||||||
const row = allTypes.find(r => r.id === Number(x));
|
{popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
|
||||||
setTypeInitial(row ? {
|
{popup.kind === 'patient' && <PatientForm draft={draft} />}
|
||||||
id: row.id,
|
{popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
|
||||||
name: row.name,
|
<div className="toolbar-left clinic-actions">
|
||||||
durationMinutes: row.duration_minutes,
|
<Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
|
||||||
color: row.color,
|
<Button onClick={cancelEdit}>{t('common_cancel')}</Button>
|
||||||
isActive: !!row.is_active,
|
</div>
|
||||||
editVersion: row.edit_version
|
</>}
|
||||||
} : {
|
</div>}</DetailPopup>
|
||||||
name: '',
|
</div>;
|
||||||
durationMinutes: 30,
|
|
||||||
color: '#0078d4',
|
|
||||||
isActive: true
|
|
||||||
});
|
|
||||||
}} options={[{
|
|
||||||
value: '',
|
|
||||||
label: t('clinic_new_type')
|
|
||||||
}, ...allTypes.map(x => ({
|
|
||||||
value: x.id,
|
|
||||||
label: x.name
|
|
||||||
}))]} /><Field label={t('clinic_name')}><input className="input" value={typeForm.name} onChange={e => setTypeForm({
|
|
||||||
...typeForm,
|
|
||||||
name: e.target.value
|
|
||||||
})} /></Field><Field label={t('clinic_duration')}><input className="input" type="number" value={typeForm.durationMinutes} onChange={e => setTypeForm({
|
|
||||||
...typeForm,
|
|
||||||
durationMinutes: Number(e.target.value)
|
|
||||||
})} /></Field><input type="color" value={typeForm.color} onChange={e => setTypeForm({
|
|
||||||
...typeForm,
|
|
||||||
color: e.target.value
|
|
||||||
})} /><label><input type="checkbox" checked={typeForm.isActive} onChange={e => setTypeForm({
|
|
||||||
...typeForm,
|
|
||||||
isActive: e.target.checked
|
|
||||||
})} />{t('clinic_active')}</label><Button disabled={!typeForm.name?.trim() || !!typeForm.editVersion && !typeDraft.isDirty} onClick={async () => {
|
|
||||||
try {
|
|
||||||
const saved = await api.clinicSaveType(typeForm, typeForm.id);
|
|
||||||
typeDraft.clear();
|
|
||||||
setTypeInitial({
|
|
||||||
id: saved.id,
|
|
||||||
name: saved.name,
|
|
||||||
durationMinutes: saved.duration_minutes,
|
|
||||||
color: saved.color,
|
|
||||||
isActive: !!saved.is_active,
|
|
||||||
editVersion: saved.edit_version
|
|
||||||
});
|
|
||||||
const data = await api.clinicDoctors();
|
|
||||||
setTypes(data.types);
|
|
||||||
setAllTypes(await api.clinicTypes());
|
|
||||||
pushToast({
|
|
||||||
tone: 'success',
|
|
||||||
message: t('common_saved')
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message);
|
|
||||||
}
|
|
||||||
}}>{t('common_save')}</Button>
|
|
||||||
<h3>{t('clinic_schedules')}</h3><ComboBox value={scheduleDoctor} onChange={async x => {
|
|
||||||
setScheduleDoctor(x);
|
|
||||||
setScheduleInitial({
|
|
||||||
rows: x ? await api.clinicSchedules(x) : []
|
|
||||||
});
|
|
||||||
}} options={[{
|
|
||||||
value: '',
|
|
||||||
label: t('common_select')
|
|
||||||
}, ...doctors.map(d => ({
|
|
||||||
value: d.id,
|
|
||||||
label: d.name
|
|
||||||
}))]} />{scheduleRows.map((s, i) => <div className="clinic-schedule-row" key={i}><ComboBox value={s.weekday} onChange={x => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
|
|
||||||
...r,
|
|
||||||
weekday: Number(x)
|
|
||||||
} : r))} options={['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((x, n) => ({
|
|
||||||
value: n,
|
|
||||||
label: t(`calendar_${x}`)
|
|
||||||
}))} /><input type="time" value={s.startsTime || s.starts_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
|
|
||||||
...r,
|
|
||||||
startsTime: e.target.value
|
|
||||||
} : r))} /><input type="time" value={s.endsTime || s.ends_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
|
|
||||||
...r,
|
|
||||||
endsTime: e.target.value
|
|
||||||
} : r))} /><Button onClick={() => setScheduleRows(scheduleRows.filter((_, j) => j !== i))}>{t('common_delete')}</Button></div>)}<Button onClick={() => setScheduleRows([...scheduleRows, {
|
|
||||||
weekday: 0,
|
|
||||||
startsTime: '09:00',
|
|
||||||
endsTime: '17:00'
|
|
||||||
}])}>{t('calendar_add')}</Button><Button disabled={!scheduleDoctor || !scheduleDraft.isDirty} onClick={async () => {
|
|
||||||
try {
|
|
||||||
await api.clinicSaveSchedules(scheduleDoctor, {
|
|
||||||
segments: scheduleRows.map(s => ({
|
|
||||||
weekday: s.weekday,
|
|
||||||
startsTime: s.startsTime || s.starts_time,
|
|
||||||
endsTime: s.endsTime || s.ends_time
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
const data = await api.clinicDoctors();
|
|
||||||
setDoctors(data.doctors);
|
|
||||||
scheduleDraft.clear();
|
|
||||||
setScheduleInitial({
|
|
||||||
rows: await api.clinicSchedules(scheduleDoctor)
|
|
||||||
});
|
|
||||||
pushToast({
|
|
||||||
tone: 'success',
|
|
||||||
message: t('common_saved')
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setError(e.message);
|
|
||||||
}
|
|
||||||
}}>{t('common_save')}</Button></div>}
|
|
||||||
<DetailPopup open={!!popup} title={t(`clinic_${popup?.kind || 'appointment'}`)} 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 onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}{editing && <DraftNotice draft={draft} />} {!editing ? <><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>{popup.kind === 'appointment' && popup.item.deleted_at && <InfoBar tone="warning">{t('clinic_deleted_notice')}</InfoBar>}{actionMode && <><Field label={t(actionMode === 'delete' ? 'clinic_delete_reason' : 'clinic_cancel_reason')}><input className="input" value={actionReason} onChange={e => setActionReason(e.target.value)} autoFocus /></Field><div className="toolbar-left"><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' && !popup.item.deleted_at && !actionMode && <div className="toolbar-left">{isReception && popup.item.status !== 'cancelled' && <><Button variant="accent" onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setActionMode('cancel')}>{t('clinic_cancel')}</Button></>}{popup.item.status !== 'cancelled' && ['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</Button>)}{isReception && <Button variant="danger" onClick={() => setActionMode('delete')}>{t('clinic_delete')}</Button>}</div>}{popup.kind === 'patient' && isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><h3>{t('clinic_history')}</h3>{patientHistory.map(a => <p key={a.id} className={a.deleted_at ? 'clinic-deleted' : undefined}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${statusOf(a)}`)} · {t('clinic_created_by')}: {author(a)}{a.deleted_at ? ` · ${t('clinic_deleted_by')}: ${a.deleted_by_name || '—'} (${a.deleted_reason || ''})` : ''}</p>)}</>}{popup.kind === 'block' && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button variant="danger" onClick={() => 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
})}>{t('common_delete')}</Button></>}</> : <>
|
|
||||||
{popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {});
|
|
||||||
}} /></Field><Field label={t('clinic_patient')}><ComboBox value={v.patientId} onChange={x => draft.setField('patientId', x)} options={[{
|
|
||||||
value: '',
|
|
||||||
label: t('common_select')
|
|
||||||
}, ...patients.map(p => ({
|
|
||||||
value: p.id,
|
|
||||||
label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}`
|
|
||||||
}))]} /></Field><Button onClick={() => open('patient', null, {
|
|
||||||
returnTo: popup
|
|
||||||
})}>{t('clinic_new_patient')}</Button><Field label={t('clinic_doctor')}><ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
|
|
||||||
value: d.id,
|
|
||||||
label: d.name
|
|
||||||
}))} /></Field><Field label={t('clinic_type')}><ComboBox value={v.typeId} onChange={x => {
|
|
||||||
const ty = types.find(z => z.id === Number(x));
|
|
||||||
draft.setValues(old => ({
|
|
||||||
...old,
|
|
||||||
typeId: x,
|
|
||||||
endsAt: plus(old.startsAt, ty?.duration_minutes || 30)
|
|
||||||
}));
|
|
||||||
}} options={types.map(z => ({
|
|
||||||
value: z.id,
|
|
||||||
label: z.name
|
|
||||||
}))} /></Field><Field label={t('calendar_start')}><DateTimeEditor value={v.startsAt} onChange={x => draft.setValues(old => ({
|
|
||||||
...old,
|
|
||||||
startsAt: x,
|
|
||||||
endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30)
|
|
||||||
}))} label={t('calendar_start')} /></Field><Field label={t('calendar_end')}><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /></Field><Field label={t('clinic_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><Field label={t('clinic_notes')}><textarea className="textarea" value={v.notes} onChange={e => draft.setField('notes', e.target.value)} /></Field><Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map(s => <Button key={s.startsAt} size="sm" onClick={() => draft.setValues(old => ({
|
|
||||||
...old,
|
|
||||||
startsAt: s.startsAt,
|
|
||||||
endsAt: s.endsAt
|
|
||||||
}))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field></>}
|
|
||||||
{popup.kind === 'patient' && [['firstNames', 'clinic_first_names'], ['lastNames', 'clinic_last_names'], ['identification', 'clinic_identification'], ['phone', 'clinic_phone'], ['email', 'clinic_email'], ['birthDate', 'clinic_birth_date'], ['notes', 'clinic_notes']].map(([k, label]) => <Field key={k} label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={e => draft.setField(k, e.target.value)} /></Field>)}
|
|
||||||
{popup.kind === 'block' && <><Field label={t('clinic_doctor')}>{popup.item ? <strong>{doctors.find(d => d.id === v.doctorId)?.name}</strong> : <ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
|
|
||||||
value: d.id,
|
|
||||||
label: d.name
|
|
||||||
}))} />}</Field><Field label={t('clinic_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><Field label={t('clinic_title')}><input className="input" value={v.title} onChange={e => draft.setField('title', e.target.value)} /></Field><DateTimeEditor value={v.startsAt} onChange={x => draft.setField('startsAt', x)} label={t('calendar_start')} /><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /><RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x => draft.setField('recurrence', x)} /></>}
|
|
||||||
<div className="toolbar-left"><Button variant="accent" disabled={!!popup.item && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={() => {
|
|
||||||
draft.discard();
|
|
||||||
if (!popup.item) setPopup(null);else setEditing(false);
|
|
||||||
}}>{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,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)} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user