feat: version 4.7.0 -- Agenda medica
Modulo clinic (habilitado por usuario): citas de pacientes para medicos con choques bloqueantes (medico, paciente con cualquier medico, quirofano/bloqueos recurrentes y horario de atencion), validados en transaccion con bloqueo de filas para que dos recepcionistas no tomen el mismo hueco. Recepcion (alcance todos) y medico (alcance propio). Vista Dia por columnas de medico, Semana y Lista; pacientes con historial; bloqueos; configuracion de personal, horarios y tipos solo admin/superadmin. Capa de citas en el calendario personal del medico. Migracion 036. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
|
|||||||
- Proyectos (con miembros)
|
- Proyectos (con miembros)
|
||||||
- Actividades + Tareas + dependencias simples
|
- Actividades + Tareas + dependencias simples
|
||||||
- Dashboard (KPIs)
|
- Dashboard (KPIs)
|
||||||
|
- Agenda médica (citas, pacientes, bloqueos, horarios y tipos; v4.7.0)
|
||||||
- Calendario y Gantt de solo lectura
|
- Calendario y Gantt de solo lectura
|
||||||
- Comentarios y adjuntos
|
- Comentarios y adjuntos
|
||||||
- Notificaciones in-app
|
- Notificaciones in-app
|
||||||
|
|||||||
@@ -33,6 +33,37 @@ campo en multipart. La API responde `428` si falta o es inválido y `409` si otr
|
|||||||
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
el registro. En empresa y SMTP la respuesta `GET` incluye `edit_version`.
|
||||||
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
|
Las reuniones siguen el mismo contrato (`PUT /api/meetings/:id`).
|
||||||
|
|
||||||
|
## Agenda médica (v4.7.0)
|
||||||
|
|
||||||
|
Todas las rutas bajo `/api/clinic` exigen JWT, módulo `clinic` y fila activa en `clinic_staff`.
|
||||||
|
Sin la fila se responde 403. `scope=own` solo accede a su médico; `scope=all` gestiona todos.
|
||||||
|
Configuración exige además rol admin/superadmin. Fechas `YYYY-MM-DD HH:mm:ss` en hora civil
|
||||||
|
`America/Guayaquil`; rangos de listado de máximo 93 días.
|
||||||
|
|
||||||
|
| Método y ruta | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `GET /staff/me` | alcance y condición de médico |
|
||||||
|
| `GET /doctors` | médicos visibles con horario y tipos activos |
|
||||||
|
| `GET /availability?doctorId=&date=&typeId=` | huecos de 15 minutos con duración del tipo |
|
||||||
|
| `GET /appointments?from=&to=[&doctorId=][&patientId=]` | citas no canceladas del rango; `patientId` solo recepción |
|
||||||
|
| `POST /appointments`, `PUT /appointments/:id` | crear o reprogramar/editar; `PUT` exige `editVersion` |
|
||||||
|
| `POST /appointments/:id/status` | `{status,editVersion}`; médico propio solo `attended`/`no_show` |
|
||||||
|
| `POST /appointments/:id/cancel` | `{reason,editVersion}`, solo recepción |
|
||||||
|
| `GET /blocks?from=&to=[&doctorId=]` | ocurrencias expandidas del rango |
|
||||||
|
| `POST /blocks`, `PUT /blocks/:id` | bloqueo; `{confirmAffected:true}` para guardar si afecta citas |
|
||||||
|
| `DELETE /blocks/:id?editVersion=` | eliminar bloqueo con versión |
|
||||||
|
| `GET /patients?search=&page=&pageSize=` | búsqueda paginada, solo recepción |
|
||||||
|
| `GET /patients/:id/appointments` | últimas 100 citas, solo recepción |
|
||||||
|
| `POST /patients`, `PUT /patients/:id` | alta y edición, solo recepción; `PUT` exige `editVersion` |
|
||||||
|
| `GET /config/staff`, `PUT /config/staff/:id` | personal y permiso interno |
|
||||||
|
| `GET/PUT /config/schedules/:id` | tramos; `PUT` recibe `{segments:[{weekday,startsTime,endsTime}]}` |
|
||||||
|
| `GET/POST /config/types`, `PUT /config/types/:id` | tipos de cita |
|
||||||
|
|
||||||
|
Una cita fuera del horario o en choque devuelve 409 con motivo concreto. Un bloqueo afectado
|
||||||
|
responde `{requiresConfirmation:true,affected:[...]}` y no se guarda sin confirmación. Las
|
||||||
|
ediciones con versión obsoleta devuelven 409. Las altas, ediciones, cambios de estado y
|
||||||
|
cancelaciones se auditan sin copiar datos de pacientes al detalle de auditoría.
|
||||||
|
|
||||||
## Calendario de reuniones (v4.4.0)
|
## Calendario de reuniones (v4.4.0)
|
||||||
|
|
||||||
Todas las rutas requieren JWT y módulo `calendar`. Las fechas son `YYYY-MM-DD HH:mm:ss` en hora
|
Todas las rutas requieren JWT y módulo `calendar`. Las fechas son `YYYY-MM-DD HH:mm:ss` en hora
|
||||||
|
|||||||
+20
-1
@@ -10,11 +10,30 @@
|
|||||||
- Charset: `utf8mb4`
|
- Charset: `utf8mb4`
|
||||||
- Collation: `utf8mb4_unicode_ci`
|
- Collation: `utf8mb4_unicode_ci`
|
||||||
|
|
||||||
Migración `034_meetings.sql` (v4.4.0): pendiente de aplicar tanto en desarrollo como en
|
Migración `034_meetings.sql` (v4.4.0): aplicada en desarrollo, pendiente en
|
||||||
producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de notificaciones.
|
producción. Crea tablas e índices de reuniones y amplía el tipo de entidad de notificaciones.
|
||||||
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
|
`DATETIME` se guarda como hora civil de `America/Guayaquil`; la conexión usa `dateStrings: true`.
|
||||||
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
|
La recurrencia se almacena como JSON de texto y se expande al consultar, sin filas por ocurrencia.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por
|
||||||
|
médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
|
||||||
|
|
||||||
|
| Tabla | Uso |
|
||||||
|
|---|---|
|
||||||
|
| `clinic_staff` | permiso `all`/`own`, condición de médico, especialidad, color, activo, versión |
|
||||||
|
| `clinic_patients` | identificación única opcional, contacto mínimo, nacimiento, nota corta, versión |
|
||||||
|
| `clinic_schedules` | tramos por médico y día de semana, lunes=0 |
|
||||||
|
| `clinic_appointment_types` | nombre, duración, color y estado |
|
||||||
|
| `clinic_appointments` | cita, médico, paciente, tipo, estado, canal, cancelación y versión |
|
||||||
|
| `clinic_blocks` | bloqueo de médico, clase y recurrencia con las reglas de reuniones |
|
||||||
|
|
||||||
|
La reserva bloquea la fila `clinic_staff` del médico y la del paciente antes de verificar choques;
|
||||||
|
los cambios de horario y bloqueos se serializan por la misma fila del médico. La migración crea una fila inicial `all` para el superadmin más antiguo; el resto se habilita
|
||||||
|
desde la UI. No hay migración aplicada ni conexión a BD en este cambio.
|
||||||
|
|
||||||
## Fuente de verdad
|
## Fuente de verdad
|
||||||
|
|
||||||
- La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`).
|
- La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`).
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# Changelog - project.linkedcorp.net
|
||||||
|
|
||||||
|
## [4.7.0] - 2026-10-28
|
||||||
|
### Added
|
||||||
|
- Agenda médica para médicos y recepción: citas, pacientes, bloqueos recurrentes, horarios, tipos de cita, permisos por personal y disponibilidad.
|
||||||
|
- Validación bloqueante de horario y choques de médico, paciente y bloqueos dentro de transacciones con bloqueo de filas.
|
||||||
|
- Capa de citas y bloqueos propios del médico en el calendario personal. Migración 036 creada, no aplicada.
|
||||||
|
|
||||||
## [4.6.0] - 2026-10-28
|
## [4.6.0] - 2026-10-28
|
||||||
### Added
|
### Added
|
||||||
- Cambio de correo con aprobación por rol, confirmación de correo nuevo, auditoría y revocación de todas las sesiones al aplicar. Migración 035 pendiente de aplicar.
|
- Cambio de correo con aprobación por rol, confirmación de correo nuevo, auditoría y revocación de todas las sesiones al aplicar. Migración 035 pendiente de aplicar.
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
# ESTADO — project.linkedcorp.net
|
# ESTADO — project.linkedcorp.net
|
||||||
|
|
||||||
Ultima actualizacion: 2026-10-28 (v4.6.0 en archivos, sin commit ni desplegar)
|
Ultima actualizacion: 2026-10-28 (v4.7.0 en archivos, sin commit ni 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.
|
||||||
|
|
||||||
|
## Agenda médica (v4.7.0, sin desplegar)
|
||||||
|
|
||||||
|
- Módulo `clinic` con permisos por `clinic_staff` (`all` recepción, `own` médico), pacientes, horarios, tipos, citas y bloqueos recurrentes.
|
||||||
|
- Migración `036_clinic.sql` **aplicada en dev** (2026-10-28), no en producción (la aplica el actualizador).
|
||||||
|
- Probado en dev con usuarios/pacientes temporales (ya borrados), 29/29: configuración por admin que no es
|
||||||
|
personal, 403 sin módulo o sin staff, horario de atención, choque médico, choque paciente con otro médico,
|
||||||
|
cita contigua permitida, quirófano recurrente (también la semana siguiente), bloqueo sobre cita pide
|
||||||
|
confirmación, disponibilidad, concurrencia (6 intentos simultáneos de 2 recepcionistas al mismo hueco →
|
||||||
|
1 creada, 5 × 409), médico solo ve/gestiona lo suyo, cancelar exige motivo, auditoría. UI Día revisada.
|
||||||
|
- La migración crea el primer staff `all` para el superadmin más antiguo. Configurar el resto del personal, tipos y horarios desde la pestaña de configuración antes de agendar. `clinic` está desactivado por defecto para otros usuarios y el superadmin lo habilita en Organización.
|
||||||
|
- Sin commit, push ni despliegue.
|
||||||
|
|
||||||
## Cambio de correo con aprobación (v4.6.0, sin desplegar)
|
## Cambio de correo con aprobación (v4.6.0, sin desplegar)
|
||||||
|
|
||||||
- Implementado en Mi cuenta y Organización → Usuarios, con enlaces públicos de aprobación propia del superadmin y confirmación del correo nuevo.
|
- Implementado en Mi cuenta y Organización → Usuarios, con enlaces públicos de aprobación propia del superadmin y confirmación del correo nuevo.
|
||||||
|
|||||||
@@ -93,6 +93,25 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
|
|||||||
- La pestaña Calendario del proyecto muestra todas sus actividades y todas las reuniones
|
- La pestaña Calendario del proyecto muestra todas sus actividades y todas las reuniones
|
||||||
vinculadas al proyecto (requiere acceso de lectura al proyecto).
|
vinculadas al proyecto (requiere acceso de lectura al proyecto).
|
||||||
|
|
||||||
|
### 7 bis. Agenda médica (v4.7.0)
|
||||||
|
- `clinic` se habilita explícitamente por usuario (desactivado por defecto; superadmin siempre tiene el módulo). Además exige una fila activa en `clinic_staff`: `all` permite
|
||||||
|
a recepción ver/agendar con todos los médicos, gestionar pacientes y bloqueos; `own` permite
|
||||||
|
a un médico ver su agenda, gestionar sus bloqueos y marcar atendida/no asistió. No crea citas.
|
||||||
|
Los médicos conservan el resto de sus módulos y no se crea un rol del sistema.
|
||||||
|
- Admin/superadmin con acceso a `clinic` y fila de staff configuran personal, horarios por tramos
|
||||||
|
y tipos de cita desde la pestaña Configuración de agenda.
|
||||||
|
- Día muestra columnas por médico; Semana muestra un médico; Lista muestra citas y bloqueos.
|
||||||
|
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
|
||||||
|
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
|
||||||
|
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
|
||||||
|
cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
|
||||||
|
en una transacción para evitar reservas simultáneas del mismo hueco.
|
||||||
|
- Crear/editar un bloqueo que cruce citas devuelve las citas afectadas; solo se guarda tras
|
||||||
|
`confirmAffected=true`. No cancela las citas: recepción debe reprogramarlas.
|
||||||
|
- Datos de paciente mínimos, sin historia clínica; cambios auditados sin incluir datos sensibles
|
||||||
|
en el detalle. `DATETIME` representa hora civil `America/Guayaquil`.
|
||||||
|
- El calendario personal muestra al médico sus citas activas y bloqueos en la capa Citas médicas.
|
||||||
|
|
||||||
### 8. Gantt
|
### 8. Gantt
|
||||||
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
|
- Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa
|
||||||
desde el gráfico en el MVP.
|
desde el gráfico en el MVP.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_staff (
|
||||||
|
user_id INT PRIMARY KEY,
|
||||||
|
scope ENUM('all','own') NOT NULL DEFAULT 'own',
|
||||||
|
is_doctor TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
specialty VARCHAR(120) NULL,
|
||||||
|
color VARCHAR(7) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_clinic_staff_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_patients (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
first_names VARCHAR(120) NOT NULL,
|
||||||
|
last_names VARCHAR(120) NOT NULL,
|
||||||
|
identification VARCHAR(40) NULL UNIQUE,
|
||||||
|
phone VARCHAR(40) NOT NULL,
|
||||||
|
email VARCHAR(160) NULL,
|
||||||
|
birth_date DATE NULL,
|
||||||
|
notes VARCHAR(500) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_clinic_patient_name (last_names, first_names),
|
||||||
|
INDEX idx_clinic_patient_phone (phone),
|
||||||
|
CONSTRAINT fk_clinic_patient_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_schedules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
doctor_user_id INT NOT NULL,
|
||||||
|
weekday TINYINT UNSIGNED NOT NULL,
|
||||||
|
starts_time TIME NOT NULL,
|
||||||
|
ends_time TIME NOT NULL,
|
||||||
|
INDEX idx_clinic_schedule_doctor_day (doctor_user_id, weekday),
|
||||||
|
CONSTRAINT fk_clinic_schedule_doctor FOREIGN KEY (doctor_user_id) REFERENCES clinic_staff(user_id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_appointment_types (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
duration_minutes SMALLINT UNSIGNED NOT NULL,
|
||||||
|
color VARCHAR(7) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_appointments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
doctor_user_id INT NOT NULL,
|
||||||
|
patient_id INT NOT NULL,
|
||||||
|
type_id INT NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
status ENUM('scheduled','confirmed','attended','no_show','cancelled') NOT NULL DEFAULT 'scheduled',
|
||||||
|
channel ENUM('phone','whatsapp','in_person','other') NOT NULL DEFAULT 'phone',
|
||||||
|
notes VARCHAR(500) NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
cancelled_at DATETIME NULL,
|
||||||
|
cancelled_by INT NULL,
|
||||||
|
cancelled_reason VARCHAR(500) NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_clinic_appointment_doctor_range (doctor_user_id, starts_at, ends_at, status),
|
||||||
|
INDEX idx_clinic_appointment_patient_range (patient_id, starts_at, ends_at, status),
|
||||||
|
CONSTRAINT fk_clinic_appointment_doctor FOREIGN KEY (doctor_user_id) REFERENCES clinic_staff(user_id),
|
||||||
|
CONSTRAINT fk_clinic_appointment_patient FOREIGN KEY (patient_id) REFERENCES clinic_patients(id),
|
||||||
|
CONSTRAINT fk_clinic_appointment_type FOREIGN KEY (type_id) REFERENCES clinic_appointment_types(id),
|
||||||
|
CONSTRAINT fk_clinic_appointment_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT fk_clinic_appointment_canceller FOREIGN KEY (cancelled_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS clinic_blocks (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
doctor_user_id INT NOT NULL,
|
||||||
|
kind ENUM('surgery','vacation','other') NOT NULL,
|
||||||
|
title VARCHAR(160) NOT NULL,
|
||||||
|
starts_at DATETIME NOT NULL,
|
||||||
|
ends_at DATETIME NOT NULL,
|
||||||
|
recurrence_json TEXT NULL,
|
||||||
|
created_by INT NULL,
|
||||||
|
edit_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_clinic_block_doctor_range (doctor_user_id, starts_at, ends_at),
|
||||||
|
CONSTRAINT fk_clinic_block_doctor FOREIGN KEY (doctor_user_id) REFERENCES clinic_staff(user_id),
|
||||||
|
CONSTRAINT fk_clinic_block_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Bootstrap del único configurador inicial. El resto del personal se habilita desde la UI.
|
||||||
|
INSERT IGNORE INTO clinic_staff (user_id, scope, is_doctor, is_active)
|
||||||
|
SELECT u.id, 'all', 0, 1 FROM users u JOIN roles r ON r.id=u.role_id
|
||||||
|
WHERE r.code='superadmin' AND u.is_active=1 ORDER BY u.id LIMIT 1;
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "3.0.1",
|
"version": "4.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "3.0.1",
|
"version": "4.7.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.6.0",
|
"version": "4.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import devicesRoutes from './modules/devices/routes.js';
|
|||||||
import inventoryIngestRoutes from './modules/devices/ingest.js';
|
import inventoryIngestRoutes from './modules/devices/ingest.js';
|
||||||
import aiRoutes from './modules/ai/routes.js';
|
import aiRoutes from './modules/ai/routes.js';
|
||||||
import meetingsRoutes from './modules/meetings/routes.js';
|
import meetingsRoutes from './modules/meetings/routes.js';
|
||||||
|
import clinicRoutes from './modules/clinic/routes.js';
|
||||||
|
|
||||||
export const app = express();
|
export const app = express();
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ app.use('/api/vault', vaultRoutes);
|
|||||||
app.use('/api/devices', devicesRoutes);
|
app.use('/api/devices', devicesRoutes);
|
||||||
app.use('/api/ai', aiRoutes);
|
app.use('/api/ai', aiRoutes);
|
||||||
app.use('/api/meetings', meetingsRoutes);
|
app.use('/api/meetings', meetingsRoutes);
|
||||||
|
app.use('/api/clinic', clinicRoutes);
|
||||||
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
// Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no
|
||||||
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
// interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas
|
||||||
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
// especifico registrado despues (ver ESTADO.md, deuda tecnica).
|
||||||
|
|||||||
@@ -3,14 +3,15 @@ import { fail } from '../utils/http.js';
|
|||||||
|
|
||||||
// Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no
|
// Modulos que el superadmin puede habilitar/deshabilitar por usuario. Panel y Configuracion no
|
||||||
// estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin.
|
// estan aqui: el Panel lo ve todo usuario y Configuracion sigue siendo solo de superadmin.
|
||||||
export const MODULES = ['projects', 'calendar', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
|
export const MODULES = ['projects', 'calendar', 'clinic', 'recurring_tasks', 'notes', 'vault', 'devices', 'organization'];
|
||||||
|
|
||||||
// Sin fila en user_modules = habilitado. El superadmin siempre tiene todo.
|
// Sin fila en user_modules = habilitado. El superadmin siempre tiene todo.
|
||||||
export async function loadModules(userId, roleCode) {
|
export async function loadModules(userId, roleCode) {
|
||||||
if (roleCode === 'superadmin') return [...MODULES];
|
if (roleCode === 'superadmin') return [...MODULES];
|
||||||
const [rows] = await pool.query('SELECT module FROM user_modules WHERE user_id = ? AND enabled = 0', [userId]);
|
const [rows] = await pool.query('SELECT module, enabled FROM user_modules WHERE user_id = ?', [userId]);
|
||||||
const disabled = new Set(rows.map((r) => r.module));
|
const enabled = new Map(rows.map((r) => [r.module, !!r.enabled]));
|
||||||
return MODULES.filter((m) => !disabled.has(m));
|
// Clinic exige activación explícita; los módulos previos conservan su valor por defecto.
|
||||||
|
return MODULES.filter((m) => m === 'clinic' ? enabled.get(m) === true : enabled.get(m) !== false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasModule(user, module) {
|
export function hasModule(user, module) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
|
import { requireModule } from '../../middleware/modules.js';
|
||||||
|
import { asyncHandler, ok } from '../../utils/http.js';
|
||||||
|
import * as clinic from './service.js';
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth, requireModule('clinic'));
|
||||||
|
// Configuracion (solo admin/superadmin): no exige ser personal de la agenda, por eso va antes
|
||||||
|
// del middleware que resuelve clinicStaff.
|
||||||
|
router.get('/config/staff', asyncHandler(async (req, res) => ok(res, await clinic.configStaff(req.currentUser))));
|
||||||
|
router.put('/config/staff/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveStaff(req.body, req.currentUser, req.params.id))));
|
||||||
|
router.get('/config/schedules/:id', asyncHandler(async (req, res) => {
|
||||||
|
clinic.requireConfig(req.currentUser);
|
||||||
|
ok(res, await clinic.schedules(req.params.id));
|
||||||
|
}));
|
||||||
|
router.put('/config/schedules/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveSchedules(req.params.id, req.body, req.currentUser))));
|
||||||
|
router.get('/config/types', asyncHandler(async (req, res) => {
|
||||||
|
clinic.requireConfig(req.currentUser);
|
||||||
|
ok(res, await clinic.types());
|
||||||
|
}));
|
||||||
|
router.post('/config/types', asyncHandler(async (req, res) => ok(res, await clinic.saveType(req.body, req.currentUser), 201)));
|
||||||
|
router.put('/config/types/:id', asyncHandler(async (req, res) => ok(res, await clinic.saveType(req.body, req.currentUser, req.params.id))));
|
||||||
|
router.use(asyncHandler(async (req, res, next) => {
|
||||||
|
req.clinicStaff = await clinic.staffFor(req.currentUser);
|
||||||
|
next();
|
||||||
|
}));
|
||||||
|
const get = (path, fn) => router.get(path, asyncHandler(async (req, res) => ok(res, await fn(req.query, req.clinicStaff, req.currentUser))));
|
||||||
|
const write = (method, path, fn) => router[method](path, asyncHandler(async (req, res) => ok(res, await fn(req.body, req.currentUser, req.clinicStaff, req.params.id), method === 'post' && !req.params.id ? 201 : 200)));
|
||||||
|
get('/staff/me', async (q, s) => s);
|
||||||
|
get('/doctors', async (q, s) => Promise.all([clinic.doctors(s), clinic.types()]).then(async ([doctors, types]) => ({
|
||||||
|
doctors: await Promise.all(doctors.map(async d => ({
|
||||||
|
...d,
|
||||||
|
schedules: await clinic.schedules(d.id)
|
||||||
|
}))),
|
||||||
|
types: types.filter(t => t.is_active)
|
||||||
|
})));
|
||||||
|
get('/availability', (q, s) => clinic.availability(q, s));
|
||||||
|
get('/appointments', (q, s) => clinic.appointments(q, s));
|
||||||
|
write('post', '/appointments', (body, user, staff) => clinic.saveAppointment(body, user, staff));
|
||||||
|
write('put', '/appointments/:id', (body, user, staff, n) => clinic.saveAppointment(body, user, staff, n));
|
||||||
|
write('post', '/appointments/:id/cancel', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff, true));
|
||||||
|
write('post', '/appointments/:id/status', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff));
|
||||||
|
get('/blocks', (q, s) => clinic.blocks(q, s));
|
||||||
|
write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, staff));
|
||||||
|
write('put', '/blocks/:id', (body, user, staff, n) => clinic.saveBlock(body, user, staff, n));
|
||||||
|
router.delete('/blocks/:id', asyncHandler(async (req, res) => ok(res, await clinic.deleteBlock(req.params.id, req.query, req.currentUser, req.clinicStaff))));
|
||||||
|
get('/patients', (q, s) => clinic.patients(q, s));
|
||||||
|
router.get('/patients/:id/appointments', asyncHandler(async (req, res) => ok(res, await clinic.patientHistory(req.params.id, req.clinicStaff))));
|
||||||
|
write('post', '/patients', (body, user, staff) => clinic.savePatient(body, user, staff));
|
||||||
|
write('put', '/patients/:id', (body, user, staff, n) => clinic.savePatient(body, user, staff, n));
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { pool } from '../../config/db.js';
|
||||||
|
import { HttpError } from '../../utils/http.js';
|
||||||
|
import { requireEditVersion } from '../../utils/editVersion.js';
|
||||||
|
import { logAudit } from '../../utils/audit.js';
|
||||||
|
import { addMinutes, durationMinutes, expand, validateRecurrence, guayaquilNow } from '../meetings/recurrence.js';
|
||||||
|
const dateRe = /^\d{4}-\d\d-\d\d \d\d:\d\d(?::\d\d)?$/;
|
||||||
|
const id = value => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(400, 'ID inválido');
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const date = value => {
|
||||||
|
if (typeof value !== 'string' || !dateRe.test(value)) throw new HttpError(400, 'Fecha inválida');
|
||||||
|
const s = `${value.slice(0, 16)}:00`;
|
||||||
|
const d = new Date(`${s.replace(' ', 'T')}Z`);
|
||||||
|
if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 19).replace('T', ' ') !== s) throw new HttpError(400, 'Fecha inválida');
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const text = (v, max, required = false) => {
|
||||||
|
const s = String(v ?? '').trim();
|
||||||
|
if (required && !s || s.length > max || /[\u0000-\u001f\u007f]/.test(s)) throw new HttpError(400, 'Texto inválido');
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const version = v => requireEditVersion(Number(v));
|
||||||
|
const range = q => {
|
||||||
|
const from = date(q.from),
|
||||||
|
to = date(q.to);
|
||||||
|
if (from >= to || durationMinutes(from, to) > 93 * 1440) throw new HttpError(400, 'Rango inválido (máximo 93 días)');
|
||||||
|
return [from, to];
|
||||||
|
};
|
||||||
|
const exec = async (db, sql, params = []) => (await db.query(sql, params))[0];
|
||||||
|
const audit = async (user, action, entityType, entityId, connection = pool) => logAudit({
|
||||||
|
userId: user.id,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
connection
|
||||||
|
});
|
||||||
|
const roleAdmin = user => ['admin', 'superadmin'].includes(user.roleCode);
|
||||||
|
export function requireConfig(user) {
|
||||||
|
if (!roleAdmin(user)) throw new HttpError(403, 'Solo administración configura la agenda');
|
||||||
|
}
|
||||||
|
export async function staffFor(user) {
|
||||||
|
const rows = await exec(pool, 'SELECT s.*, u.name FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.user_id=? AND s.is_active=1 AND u.is_active=1', [user.id]);
|
||||||
|
if (!rows.length) throw new HttpError(403, 'No estás habilitado como personal de Agenda médica');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
function doctorAllowed(staff, doctorId) {
|
||||||
|
if (staff.scope === 'own' && (!staff.is_doctor || doctorId !== staff.user_id)) throw new HttpError(403, 'Solo puedes acceder a tu propia agenda');
|
||||||
|
}
|
||||||
|
export async function doctors(staff) {
|
||||||
|
return exec(pool, `SELECT s.user_id AS id, u.name, s.specialty, s.color, s.scope, s.is_doctor FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.is_doctor=1 AND s.is_active=1 AND u.is_active=1 ${staff.scope === 'own' ? 'AND s.user_id=?' : ''} ORDER BY u.name`, staff.scope === 'own' ? [staff.user_id] : []);
|
||||||
|
}
|
||||||
|
export async function schedules(doctorId) {
|
||||||
|
return exec(pool, 'SELECT id,doctor_user_id,weekday,starts_time,ends_time FROM clinic_schedules WHERE doctor_user_id=? ORDER BY weekday,starts_time', [id(doctorId)]);
|
||||||
|
}
|
||||||
|
export async function types() {
|
||||||
|
return exec(pool, 'SELECT * FROM clinic_appointment_types ORDER BY name');
|
||||||
|
}
|
||||||
|
function selectAppointments(db, from, to, doctorId, patientId, excludeId) {
|
||||||
|
return exec(db, `SELECT a.*, p.first_names,p.last_names,t.name AS type_name,t.color AS type_color,u.name AS doctor_name FROM clinic_appointments a JOIN clinic_patients p ON p.id=a.patient_id JOIN clinic_appointment_types t ON t.id=a.type_id JOIN users u ON u.id=a.doctor_user_id WHERE a.status<>'cancelled' AND a.starts_at<? AND a.ends_at>? ${doctorId ? 'AND a.doctor_user_id=?' : ''} ${patientId ? 'AND a.patient_id=?' : ''} ${excludeId ? 'AND a.id<>?' : ''} ORDER BY a.starts_at`, [to, from, ...(doctorId ? [doctorId] : []), ...(patientId ? [patientId] : []), ...(excludeId ? [excludeId] : [])]);
|
||||||
|
}
|
||||||
|
async function blocksIn(db, doctorId, from, to) {
|
||||||
|
const rows = await exec(db, 'SELECT * FROM clinic_blocks WHERE doctor_user_id=? AND starts_at<?', [doctorId, to]);
|
||||||
|
return rows.flatMap(r => expand(r, from, to));
|
||||||
|
}
|
||||||
|
export async function appointments(q, staff) {
|
||||||
|
const [from, to] = range(q);
|
||||||
|
const doctorId = q.doctorId ? id(q.doctorId) : null;
|
||||||
|
if (doctorId) doctorAllowed(staff, doctorId);
|
||||||
|
const patientId = q.patientId ? id(q.patientId) : null;
|
||||||
|
if (patientId && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial');
|
||||||
|
return selectAppointments(pool, from, to, staff.scope === 'own' ? staff.user_id : doctorId, patientId, null);
|
||||||
|
}
|
||||||
|
export async function blocks(q, staff) {
|
||||||
|
const [from, to] = range(q);
|
||||||
|
const doctorId = q.doctorId ? id(q.doctorId) : null;
|
||||||
|
if (doctorId) doctorAllowed(staff, doctorId);
|
||||||
|
const ds = doctorId ? [doctorId] : (await doctors(staff)).map(d => d.id);
|
||||||
|
return (await Promise.all(ds.map(d => blocksIn(pool, d, from, to)))).flat();
|
||||||
|
}
|
||||||
|
export async function patients(q, staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
|
||||||
|
const search = text(q.search, 120);
|
||||||
|
const page = q.page === undefined ? 1 : Number(q.page),
|
||||||
|
pageSize = q.pageSize === undefined ? 20 : Number(q.pageSize);
|
||||||
|
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 50 || !Number.isSafeInteger((page - 1) * pageSize)) throw new HttpError(400, 'Paginación inválida');
|
||||||
|
const like = `%${search}%`;
|
||||||
|
const where = 'is_active=1 AND (first_names LIKE ? OR last_names LIKE ? OR identification LIKE ? OR phone LIKE ?)';
|
||||||
|
const [count] = await exec(pool, `SELECT COUNT(*) AS n FROM clinic_patients WHERE ${where}`, [like, like, like, like]);
|
||||||
|
const items = await exec(pool, `SELECT * FROM clinic_patients WHERE ${where} ORDER BY last_names,first_names LIMIT ? OFFSET ?`, [like, like, like, like, pageSize, (page - 1) * pageSize]);
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: count.n,
|
||||||
|
page,
|
||||||
|
pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function patientData(raw) {
|
||||||
|
const first = text(raw.firstNames, 120, true),
|
||||||
|
last = text(raw.lastNames, 120, true),
|
||||||
|
identification = text(raw.identification, 40) || null,
|
||||||
|
phone = text(raw.phone, 40, true),
|
||||||
|
email = text(raw.email, 160) || null,
|
||||||
|
notes = text(raw.notes, 500) || null,
|
||||||
|
birth = raw.birthDate ? String(raw.birthDate) : null;
|
||||||
|
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new HttpError(400, 'Correo inválido');
|
||||||
|
if (birth && (!/^\d{4}-\d\d-\d\d$/.test(birth) || date(`${birth} 00:00:00`).slice(0, 10) !== birth)) throw new HttpError(400, 'Nacimiento inválido');
|
||||||
|
return [first, last, identification, phone, email, birth, notes];
|
||||||
|
}
|
||||||
|
export async function patientHistory(patientId, staff) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial');
|
||||||
|
const n = id(patientId);
|
||||||
|
const [patient] = await exec(pool, 'SELECT id FROM clinic_patients WHERE id=?', [n]);
|
||||||
|
if (!patient) throw new HttpError(404, 'Paciente no encontrado');
|
||||||
|
return exec(pool, 'SELECT a.id,a.starts_at,a.ends_at,a.status,a.channel,u.name AS doctor_name,t.name AS type_name FROM clinic_appointments a JOIN users u ON u.id=a.doctor_user_id JOIN clinic_appointment_types t ON t.id=a.type_id WHERE a.patient_id=? ORDER BY a.starts_at DESC LIMIT 100', [n]);
|
||||||
|
}
|
||||||
|
export async function savePatient(raw, user, staff, patientId = null) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
|
||||||
|
const data = patientData(raw);
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
if (patientId) result = await exec(pool, 'UPDATE clinic_patients SET first_names=?,last_names=?,identification=?,phone=?,email=?,birth_date=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [...data, id(patientId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_patients (first_names,last_names,identification,phone,email,birth_date,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [...data, user.id]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Identificación ya registrada');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (patientId && !result.affectedRows) throw new HttpError(409, 'Paciente modificado por otra persona');
|
||||||
|
const newId = patientId || result.insertId;
|
||||||
|
await audit(user, patientId ? 'update' : 'create', 'clinic_patient', newId);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_patients WHERE id=?', [newId]))[0];
|
||||||
|
}
|
||||||
|
function appointmentData(raw, staff) {
|
||||||
|
const doctorId = id(raw.doctorId),
|
||||||
|
patientId = id(raw.patientId),
|
||||||
|
typeId = id(raw.typeId),
|
||||||
|
start = date(raw.startsAt),
|
||||||
|
end = date(raw.endsAt);
|
||||||
|
doctorAllowed(staff, doctorId);
|
||||||
|
if (end <= start || durationMinutes(start, end) > 24 * 60) throw new HttpError(400, 'Horario inválido');
|
||||||
|
const channel = raw.channel || 'phone';
|
||||||
|
if (!['phone', 'whatsapp', 'in_person', 'other'].includes(channel)) throw new HttpError(400, 'Canal inválido');
|
||||||
|
return {
|
||||||
|
doctorId,
|
||||||
|
patientId,
|
||||||
|
typeId,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
channel,
|
||||||
|
notes: text(raw.notes, 500) || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function validateSlot(db, d, excludeId) {
|
||||||
|
const [doctor] = await exec(db, 'SELECT s.user_id FROM clinic_staff s JOIN users u ON u.id=s.user_id WHERE s.user_id=? AND s.is_doctor=1 AND s.is_active=1 AND u.is_active=1', [d.doctorId]);
|
||||||
|
if (!doctor) throw new HttpError(400, 'Médico no disponible');
|
||||||
|
const [patient] = await exec(db, 'SELECT id FROM clinic_patients WHERE id=? AND is_active=1', [d.patientId]);
|
||||||
|
if (!patient) throw new HttpError(400, 'Paciente no disponible');
|
||||||
|
const [type] = await exec(db, 'SELECT id FROM clinic_appointment_types WHERE id=? AND is_active=1', [d.typeId]);
|
||||||
|
if (!type) throw new HttpError(400, 'Tipo de cita no disponible');
|
||||||
|
const weekday = (new Date(`${d.start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const day = d.start.slice(0, 10);
|
||||||
|
const available = await exec(db, `SELECT id FROM clinic_schedules WHERE doctor_user_id=? AND weekday=? AND CONCAT(?, ' ', starts_time)<=? AND CONCAT(?, ' ', ends_time)>=?`, [d.doctorId, weekday, day, d.start, day, d.end]);
|
||||||
|
if (!available.length || d.end.slice(0, 10) !== day) throw new HttpError(409, 'Fuera del horario de atención del médico');
|
||||||
|
const doctorClash = await selectAppointments(db, d.start, d.end, d.doctorId, null, excludeId);
|
||||||
|
const hhmm = (s) => String(s).slice(11, 16);
|
||||||
|
if (doctorClash.length) { const c = doctorClash[0]; throw new HttpError(409, `El médico ya tiene una cita de ${hhmm(c.starts_at)} a ${hhmm(c.ends_at)} (${c.first_names} ${c.last_names})`); }
|
||||||
|
const block = (await blocksIn(db, d.doctorId, d.start, d.end))[0];
|
||||||
|
if (block) throw new HttpError(409, `El médico no está disponible de ${hhmm(block.occurrence_start)} a ${hhmm(block.occurrence_end)}: ${block.title}`);
|
||||||
|
const patientClash = await selectAppointments(db, d.start, d.end, null, d.patientId, excludeId);
|
||||||
|
if (patientClash.length) { const c = patientClash[0]; throw new HttpError(409, `El paciente ya tiene una cita de ${hhmm(c.starts_at)} a ${hhmm(c.ends_at)} con ${c.doctor_name}`); }
|
||||||
|
}
|
||||||
|
export async function saveAppointment(raw, user, staff, appointmentId = null) {
|
||||||
|
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción agenda citas');
|
||||||
|
const d = appointmentData(raw, staff);
|
||||||
|
const db = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await db.beginTransaction();
|
||||||
|
let before = null;
|
||||||
|
if (appointmentId) {
|
||||||
|
[before] = await exec(db, 'SELECT * FROM clinic_appointments WHERE id=?', [id(appointmentId)]);
|
||||||
|
if (!before) throw new HttpError(404, 'Cita no encontrada');
|
||||||
|
if (before.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
|
||||||
|
if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
} // Se bloquean filas de médicos en orden para mover entre dos agendas.
|
||||||
|
for (const doctorId of [...new Set([d.doctorId, ...(before ? [before.doctor_user_id] : [])])].sort((a, b) => a - b)) await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? FOR UPDATE', [doctorId]);
|
||||||
|
for (const patientId of [...new Set([d.patientId, ...(before ? [before.patient_id] : [])])].sort((a, b) => a - b)) await exec(db, 'SELECT id FROM clinic_patients WHERE id=? FOR UPDATE', [patientId]);
|
||||||
|
if (before) {
|
||||||
|
const [fresh] = await exec(db, 'SELECT edit_version FROM clinic_appointments WHERE id=?', [before.id]);
|
||||||
|
if (Number(fresh.edit_version) !== Number(before.edit_version)) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
}
|
||||||
|
await validateSlot(db, d, before?.id);
|
||||||
|
let result;
|
||||||
|
if (before) result = await exec(db, 'UPDATE clinic_appointments SET doctor_user_id=?,patient_id=?,type_id=?,starts_at=?,ends_at=?,channel=?,notes=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, before.id, version(raw.editVersion)]);else result = await exec(db, 'INSERT INTO clinic_appointments (doctor_user_id,patient_id,type_id,starts_at,ends_at,channel,notes,created_by) VALUES (?,?,?,?,?,?,?,?)', [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, user.id]);
|
||||||
|
if (before && !result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
const newId = before?.id || result.insertId;
|
||||||
|
await audit(user, before ? 'update' : 'create', 'clinic_appointment', newId, db);
|
||||||
|
await db.commit();
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [newId]))[0];
|
||||||
|
} catch (e) {
|
||||||
|
await db.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function changeStatus(appointmentId, raw, user, staff, cancel = false) {
|
||||||
|
const n = id(appointmentId);
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]);
|
||||||
|
if (!row) throw new HttpError(404, 'Cita no encontrada');
|
||||||
|
doctorAllowed(staff, row.doctor_user_id);
|
||||||
|
const status = cancel ? 'cancelled' : raw.status;
|
||||||
|
if (cancel && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción cancela citas');
|
||||||
|
if (!cancel && !(staff.scope === 'all' ? ['scheduled', 'confirmed', 'attended', 'no_show'].includes(status) : ['attended', 'no_show'].includes(status))) throw new HttpError(403, 'Estado no permitido');
|
||||||
|
if (row.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
|
||||||
|
const editVersion = version(raw.editVersion);
|
||||||
|
if (!cancel && row.status === status && Number(row.edit_version) === editVersion) return row;
|
||||||
|
const reason = cancel ? text(raw.reason, 500, true) : null;
|
||||||
|
const result = await exec(pool, 'UPDATE clinic_appointments SET status=?,cancelled_at=?,cancelled_by=?,cancelled_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [status, cancel ? guayaquilNow() : null, cancel ? user.id : null, reason, n, editVersion]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
|
||||||
|
await audit(user, cancel ? 'cancel' : 'status', 'clinic_appointment', n);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0];
|
||||||
|
}
|
||||||
|
export async function availability(q, staff) {
|
||||||
|
const doctorId = id(q.doctorId),
|
||||||
|
day = String(q.date || '');
|
||||||
|
doctorAllowed(staff, doctorId);
|
||||||
|
const [doctor] = await exec(pool, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 AND is_active=1', [doctorId]);
|
||||||
|
if (!doctor) throw new HttpError(404, 'Médico no encontrado');
|
||||||
|
if (!/^\d{4}-\d\d-\d\d$/.test(day)) throw new HttpError(400, 'Fecha inválida');
|
||||||
|
date(`${day} 00:00:00`);
|
||||||
|
const [type] = await exec(pool, 'SELECT duration_minutes FROM clinic_appointment_types WHERE id=? AND is_active=1', [id(q.typeId)]);
|
||||||
|
if (!type) throw new HttpError(400, 'Tipo inválido');
|
||||||
|
const weekday = (new Date(`${day}T00:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const segments = await exec(pool, 'SELECT starts_time,ends_time FROM clinic_schedules WHERE doctor_user_id=? AND weekday=?', [doctorId, weekday]);
|
||||||
|
const from = `${day} 00:00:00`,
|
||||||
|
to = addMinutes(from, 1440);
|
||||||
|
const busy = await selectAppointments(pool, from, to, doctorId, null, null),
|
||||||
|
blocked = await blocksIn(pool, doctorId, from, to);
|
||||||
|
const slots = [];
|
||||||
|
for (const s of segments) {
|
||||||
|
for (let start = `${day} ${s.starts_time}`; addMinutes(start, type.duration_minutes) <= `${day} ${s.ends_time}`; start = addMinutes(start, 15)) {
|
||||||
|
const end = addMinutes(start, type.duration_minutes);
|
||||||
|
if (!busy.some(x => x.starts_at < end && x.ends_at > start) && !blocked.some(x => x.occurrence_start < end && x.occurrence_end > start)) slots.push({
|
||||||
|
startsAt: start,
|
||||||
|
endsAt: end
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
export async function saveBlock(raw, user, staff, blockId = null) {
|
||||||
|
const doctorId = id(raw.doctorId);
|
||||||
|
doctorAllowed(staff, doctorId);
|
||||||
|
const kind = raw.kind;
|
||||||
|
if (!['surgery', 'vacation', 'other'].includes(kind)) throw new HttpError(400, 'Tipo de bloqueo inválido');
|
||||||
|
const title = text(raw.title, 160, true),
|
||||||
|
start = date(raw.startsAt),
|
||||||
|
end = date(raw.endsAt);
|
||||||
|
if (end <= start || durationMinutes(start, end) > 31 * 1440) throw new HttpError(400, 'Horario inválido');
|
||||||
|
const recurrence = validateRecurrence(raw.recurrence, start);
|
||||||
|
const proposed = expand({
|
||||||
|
starts_at: start,
|
||||||
|
ends_at: end,
|
||||||
|
recurrence_json: recurrence ? JSON.stringify(recurrence) : null
|
||||||
|
}, start, '9999-12-31 23:59:59');
|
||||||
|
const db = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await db.beginTransaction();
|
||||||
|
const [doctor] = await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 AND is_active=1 FOR UPDATE', [doctorId]);
|
||||||
|
if (!doctor) throw new HttpError(400, 'Médico no disponible');
|
||||||
|
let before = null;
|
||||||
|
if (blockId) {
|
||||||
|
[before] = await exec(db, 'SELECT * FROM clinic_blocks WHERE id=?', [id(blockId)]);
|
||||||
|
if (!before || before.doctor_user_id !== doctorId) throw new HttpError(404, 'Bloqueo no encontrado');
|
||||||
|
if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Bloqueo modificado por otra persona');
|
||||||
|
}
|
||||||
|
const affected = [];
|
||||||
|
const candidates = await selectAppointments(db, proposed[0].occurrence_start, proposed.at(-1).occurrence_end, doctorId, null, null);
|
||||||
|
for (const c of candidates) if (proposed.some(p => p.occurrence_start < c.ends_at && p.occurrence_end > c.starts_at)) affected.push({
|
||||||
|
id: c.id,
|
||||||
|
starts_at: c.starts_at,
|
||||||
|
patient_name: `${c.first_names} ${c.last_names}`
|
||||||
|
});
|
||||||
|
if (affected.length && raw.confirmAffected !== true) {
|
||||||
|
await db.rollback();
|
||||||
|
return {
|
||||||
|
requiresConfirmation: true,
|
||||||
|
affected
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let result;
|
||||||
|
if (before) result = await exec(db, 'UPDATE clinic_blocks SET kind=?,title=?,starts_at=?,ends_at=?,recurrence_json=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, before.id, before.edit_version]);else result = await exec(db, 'INSERT INTO clinic_blocks (doctor_user_id,kind,title,starts_at,ends_at,recurrence_json,created_by) VALUES (?,?,?,?,?,?,?)', [doctorId, kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, user.id]);
|
||||||
|
if (before && !result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
|
||||||
|
const newId = before?.id || result.insertId;
|
||||||
|
await audit(user, before ? 'update' : 'create', 'clinic_block', newId, db);
|
||||||
|
await db.commit();
|
||||||
|
return {
|
||||||
|
block: (await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [newId]))[0],
|
||||||
|
affected
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
await db.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function deleteBlock(blockId, raw, user, staff) {
|
||||||
|
const n = id(blockId);
|
||||||
|
const [row] = await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [n]);
|
||||||
|
if (!row) throw new HttpError(404, 'Bloqueo no encontrado');
|
||||||
|
doctorAllowed(staff, row.doctor_user_id);
|
||||||
|
const result = await exec(pool, 'DELETE FROM clinic_blocks WHERE id=? AND edit_version=?', [n, version(raw.editVersion)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
|
||||||
|
await audit(user, 'delete', 'clinic_block', n);
|
||||||
|
return {
|
||||||
|
id: n
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function configStaff(user) {
|
||||||
|
requireConfig(user);
|
||||||
|
return exec(pool, 'SELECT s.*,u.name,u.email FROM clinic_staff s JOIN users u ON u.id=s.user_id ORDER BY u.name');
|
||||||
|
}
|
||||||
|
export async function saveStaff(raw, user, staffId) {
|
||||||
|
requireConfig(user);
|
||||||
|
const n = id(staffId),
|
||||||
|
scope = raw.scope;
|
||||||
|
if (!['all', 'own'].includes(scope)) throw new HttpError(400, 'Alcance inválido');
|
||||||
|
const doctor = !!raw.isDoctor;
|
||||||
|
if (scope === 'own' && !doctor) throw new HttpError(400, 'El alcance propio exige médico');
|
||||||
|
const specialty = text(raw.specialty, 120) || null,
|
||||||
|
color = raw.color || null;
|
||||||
|
if (color && !/^#[0-9a-fA-F]{6}$/.test(color)) throw new HttpError(400, 'Color inválido');
|
||||||
|
const [target] = await exec(pool, 'SELECT id FROM users WHERE id=? AND is_active=1', [n]);
|
||||||
|
if (!target) throw new HttpError(404, 'Usuario no encontrado');
|
||||||
|
if (raw.editVersion) {
|
||||||
|
const result = await exec(pool, 'UPDATE clinic_staff SET scope=?,is_doctor=?,specialty=?,color=?,is_active=?,edit_version=edit_version+1 WHERE user_id=? AND edit_version=?', [scope, doctor, specialty, color, raw.isActive === false ? 0 : 1, n, version(raw.editVersion)]);
|
||||||
|
if (!result.affectedRows) throw new HttpError(409, 'Personal modificado por otra persona');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await exec(pool, 'INSERT INTO clinic_staff (user_id,scope,is_doctor,specialty,color,is_active) VALUES (?,?,?,?,?,?)', [n, scope, doctor, specialty, color, raw.isActive === false ? 0 : 1]);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === 'ER_DUP_ENTRY') throw new HttpError(409, 'Personal creado por otra persona');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await audit(user, 'update', 'clinic_staff', n);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_staff WHERE user_id=?', [n]))[0];
|
||||||
|
}
|
||||||
|
export async function saveSchedules(doctorId, raw, user) {
|
||||||
|
requireConfig(user);
|
||||||
|
const n = id(doctorId),
|
||||||
|
rows = raw.segments;
|
||||||
|
if (!Array.isArray(rows) || rows.length > 30) throw new HttpError(400, 'Horario inválido');
|
||||||
|
const parsed = rows.map(s => {
|
||||||
|
const weekday = Number(s.weekday),
|
||||||
|
start = String(s.startsTime || ''),
|
||||||
|
end = String(s.endsTime || '');
|
||||||
|
if (!Number.isInteger(weekday) || weekday < 0 || weekday > 6 || !/^\d\d:\d\d(?::\d\d)?$/.test(start) || !/^\d\d:\d\d(?::\d\d)?$/.test(end) || start >= end || start.slice(0, 5) > '23:59' || end.slice(0, 5) > '23:59') throw new HttpError(400, 'Tramo inválido');
|
||||||
|
return [weekday, start, end];
|
||||||
|
});
|
||||||
|
for (let i = 0; i < parsed.length; i++) for (let j = i + 1; j < parsed.length; j++) if (parsed[i][0] === parsed[j][0] && parsed[i][1] < parsed[j][2] && parsed[i][2] > parsed[j][1]) throw new HttpError(400, 'Tramos superpuestos');
|
||||||
|
const db = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await db.beginTransaction();
|
||||||
|
const [doctor] = await exec(db, 'SELECT user_id FROM clinic_staff WHERE user_id=? AND is_doctor=1 FOR UPDATE', [n]);
|
||||||
|
if (!doctor) throw new HttpError(404, 'Médico no encontrado');
|
||||||
|
await exec(db, 'DELETE FROM clinic_schedules WHERE doctor_user_id=?', [n]);
|
||||||
|
for (const r of parsed) await exec(db, 'INSERT INTO clinic_schedules (doctor_user_id,weekday,starts_time,ends_time) VALUES (?,?,?,?)', [n, ...r]);
|
||||||
|
await audit(user, 'update', 'clinic_schedule', n, db);
|
||||||
|
await db.commit();
|
||||||
|
return schedules(n);
|
||||||
|
} catch (e) {
|
||||||
|
await db.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
db.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function saveType(raw, user, typeId = null) {
|
||||||
|
requireConfig(user);
|
||||||
|
const name = text(raw.name, 120, true),
|
||||||
|
duration = Number(raw.durationMinutes),
|
||||||
|
color = raw.color;
|
||||||
|
if (!Number.isInteger(duration) || duration < 5 || duration > 480 || !/^#[0-9a-fA-F]{6}$/.test(color || '')) throw new HttpError(400, 'Tipo inválido');
|
||||||
|
let result;
|
||||||
|
if (typeId) result = await exec(pool, 'UPDATE clinic_appointment_types SET name=?,duration_minutes=?,color=?,is_active=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [name, duration, color, raw.isActive === false ? 0 : 1, id(typeId), version(raw.editVersion)]);else result = await exec(pool, 'INSERT INTO clinic_appointment_types (name,duration_minutes,color,is_active) VALUES (?,?,?,?)', [name, duration, color, raw.isActive === false ? 0 : 1]);
|
||||||
|
if (typeId && !result.affectedRows) throw new HttpError(409, 'Tipo modificado por otra persona');
|
||||||
|
const n = typeId || result.insertId;
|
||||||
|
await audit(user, typeId ? 'update' : 'create', 'clinic_appointment_type', n);
|
||||||
|
return (await exec(pool, 'SELECT * FROM clinic_appointment_types WHERE id=?', [n]))[0];
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pool } from '../config/db.js';
|
import { pool } from '../config/db.js';
|
||||||
|
|
||||||
export async function logAudit({ userId, action, entityType, entityId, detail }) {
|
export async function logAudit({ userId, action, entityType, entityId, detail, connection = pool }) {
|
||||||
await pool.query(
|
await connection.query(
|
||||||
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`,
|
||||||
[userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null]
|
[userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null]
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "3.0.1",
|
"version": "4.7.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"version": "3.0.1",
|
"version": "4.7.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.6.0",
|
"version": "4.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import EquiposPage from '../pages/Equipos/EquiposPage.jsx';
|
|||||||
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx';
|
||||||
import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx';
|
import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx';
|
||||||
import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx';
|
import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx';
|
||||||
|
import AgendaMedicaPage from '../pages/AgendaMedica/AgendaMedicaPage.jsx';
|
||||||
|
|
||||||
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
|
// Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una
|
||||||
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
|
// seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes.
|
||||||
@@ -45,6 +46,7 @@ const router = createBrowserRouter(createRoutesFromElements(
|
|||||||
<Route path="mi-cuenta" element={<MiCuentaPage />} />
|
<Route path="mi-cuenta" element={<MiCuentaPage />} />
|
||||||
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
|
||||||
<Route path="calendario" element={<ModuleRoute module="calendar"><CalendarioPage /></ModuleRoute>} />
|
<Route path="calendario" element={<ModuleRoute module="calendar"><CalendarioPage /></ModuleRoute>} />
|
||||||
|
<Route path="agenda-medica" element={<ModuleRoute module="clinic"><AgendaMedicaPage /></ModuleRoute>} />
|
||||||
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
|
||||||
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
|
||||||
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
|
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { DatePicker } from './DatePicker.jsx';
|
||||||
|
import { ComboBox } from './ComboBox.jsx';
|
||||||
|
const hours = Array.from({length:24},(_,i) => ({value:String(i).padStart(2,'0'),label:String(i).padStart(2,'0')}));
|
||||||
|
const minutes = Array.from({length:12},(_,i) => ({value:String(i*5).padStart(2,'0'),label:String(i*5).padStart(2,'0')}));
|
||||||
|
export function DateTimeEditor({ value, onChange, allDay, label, isEnd = false }) {
|
||||||
|
const date = value.slice(0,10), hour = value.slice(11,13), minute = value.slice(14,16);
|
||||||
|
const setDate = (next) => onChange(`${next} ${allDay ? (isEnd ? '23:59' : '00:00') : `${hour}:${minute}`}:00`);
|
||||||
|
const setTime = (h,m) => onChange(`${date} ${h}:${m}:00`);
|
||||||
|
return <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useUserDrafts } from '../hooks/useDraftForm.js';
|
import { useUserDrafts } from '../hooks/useDraftForm.js';
|
||||||
import { DraftMark } from './NavigationGuard.jsx';
|
import { DraftMark } from './NavigationGuard.jsx';
|
||||||
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor, CalendarDays } from 'lucide-react';
|
import { LayoutDashboard, FolderKanban, Building2, Settings, Repeat, StickyNote, KeyRound, Monitor, CalendarDays, Stethoscope } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
import { useAuth } from '../context/auth-context.jsx';
|
import { useAuth } from '../context/auth-context.jsx';
|
||||||
@@ -15,6 +15,7 @@ const ITEMS = [
|
|||||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
{ to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true },
|
||||||
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
|
{ to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' },
|
||||||
{ to: '/calendario', module: 'calendar', icon: CalendarDays, labelKey: 'nav_calendar' },
|
{ to: '/calendario', module: 'calendar', icon: CalendarDays, labelKey: 'nav_calendar' },
|
||||||
|
{ to: '/agenda-medica', module: 'clinic', icon: Stethoscope, labelKey: 'nav_clinic' },
|
||||||
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
{ to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' },
|
||||||
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
|
{ to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' },
|
||||||
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
|
{ to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' },
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { Field, FieldRow } from './Field.jsx';
|
||||||
|
import { ComboBox } from './ComboBox.jsx';
|
||||||
|
import { DatePicker } from './DatePicker.jsx';
|
||||||
|
|
||||||
|
// Reuniones y bloqueos clínicos comparten las mismas reglas de recurrencia del backend.
|
||||||
|
export function RecurrenceEditor({
|
||||||
|
value,
|
||||||
|
start,
|
||||||
|
onChange
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
t
|
||||||
|
} = useUi();
|
||||||
|
const weekday = (new Date(`${start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7;
|
||||||
|
const labels = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
const patch = fields => onChange({
|
||||||
|
...value,
|
||||||
|
...fields
|
||||||
|
});
|
||||||
|
return <>
|
||||||
|
<Field label={t('calendar_recurrence')}>
|
||||||
|
<ComboBox options={['none', 'daily', 'weekly', 'monthly'].map(x => ({
|
||||||
|
value: x,
|
||||||
|
label: t(`calendar_${x}`)
|
||||||
|
}))} value={value?.frequency || 'none'} onChange={x => onChange(x === 'none' ? null : {
|
||||||
|
frequency: x,
|
||||||
|
interval: 1,
|
||||||
|
weekdays: x === 'weekly' ? [weekday] : [],
|
||||||
|
count: 10
|
||||||
|
})} />
|
||||||
|
</Field>
|
||||||
|
{value && <>
|
||||||
|
<FieldRow>
|
||||||
|
<Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={value.interval} onChange={e => patch({
|
||||||
|
interval: Number(e.target.value)
|
||||||
|
})} /></Field>
|
||||||
|
<Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={value.count || ''} onChange={e => patch({
|
||||||
|
count: e.target.value ? Number(e.target.value) : null,
|
||||||
|
until: null
|
||||||
|
})} /></Field>
|
||||||
|
<Field label={t('calendar_until')}><DatePicker value={value.until || ''} onChange={x => patch({
|
||||||
|
until: x || null,
|
||||||
|
count: null
|
||||||
|
})} label={t('calendar_until')} /></Field>
|
||||||
|
</FieldRow>
|
||||||
|
{value.frequency === 'weekly' && <div className="calendar-weekdays">{labels.map((name, index) => <label key={name}>
|
||||||
|
<input type="checkbox" checked={value.weekdays.includes(index)} onChange={() => patch({
|
||||||
|
weekdays: value.weekdays.includes(index) ? value.weekdays.filter(x => x !== index) : [...value.weekdays, index]
|
||||||
|
})} />
|
||||||
|
{' '}{t(`calendar_${name}`)}
|
||||||
|
</label>)}</div>}
|
||||||
|
</>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,72 @@
|
|||||||
export const messages = {
|
export const messages = {
|
||||||
'es-EC': {
|
'es-EC': {
|
||||||
email_change_title: 'Cambiar correo', email_change_new: 'Correo nuevo', email_change_request: 'Solicitar cambio', email_change_requested: 'Solicitud creada', email_change_cancel: 'Cancelar solicitud', email_change_cancelled: 'Solicitud cancelada', email_change_requests: 'Solicitudes de cambio de correo', email_change_none: 'No hay solicitudes pendientes', email_change_approve: 'Aprobar', email_change_reject: 'Rechazar', email_change_reason: 'Motivo del rechazo', email_change_approved: 'Cambio aprobado', email_change_rejected: 'Cambio rechazado', email_change_approve_title: 'Aprobar mi cambio de correo', email_change_confirm_title: 'Confirmar correo nuevo', email_change_accept: 'Aceptar', email_change_self_approved: 'Aprobado. Revisa el correo nuevo para confirmarlo.', email_change_applied: 'Correo actualizado. Todas tus sesiones se cerraron. Inicia sesión con:', email_change_status_pending: 'Pendiente de aprobación', email_change_status_approved: 'Aprobada: revisa tu correo nuevo', email_change_status_rejected: 'Rechazada', email_change_status_cancelled: 'Cancelada', email_change_status_expired: 'Vencida', email_change_status_failed: 'Fallida', email_change_status_applied: 'Aplicada',
|
email_change_title: 'Cambiar correo', email_change_new: 'Correo nuevo', email_change_request: 'Solicitar cambio', email_change_requested: 'Solicitud creada', email_change_cancel: 'Cancelar solicitud', email_change_cancelled: 'Solicitud cancelada', email_change_requests: 'Solicitudes de cambio de correo', email_change_none: 'No hay solicitudes pendientes', email_change_approve: 'Aprobar', email_change_reject: 'Rechazar', email_change_reason: 'Motivo del rechazo', email_change_approved: 'Cambio aprobado', email_change_rejected: 'Cambio rechazado', email_change_approve_title: 'Aprobar mi cambio de correo', email_change_confirm_title: 'Confirmar correo nuevo', email_change_accept: 'Aceptar', email_change_self_approved: 'Aprobado. Revisa el correo nuevo para confirmarlo.', email_change_applied: 'Correo actualizado. Todas tus sesiones se cerraron. Inicia sesión con:', email_change_status_pending: 'Pendiente de aprobación', email_change_status_approved: 'Aprobada: revisa tu correo nuevo', email_change_status_rejected: 'Rechazada', email_change_status_cancelled: 'Cancelada', email_change_status_expired: 'Vencida', email_change_status_failed: 'Fallida', email_change_status_applied: 'Aplicada',
|
||||||
|
clinic_status_cancelled: 'Cancelada',
|
||||||
|
clinic_active: 'Activo',
|
||||||
|
clinic_reprogram: 'Reprogramar',
|
||||||
|
clinic_history: 'Historial de citas',
|
||||||
|
clinic_cancel_confirm: 'Se cancelará la cita y quedará el motivo registrado.',
|
||||||
|
clinic_delete_block: 'Eliminar bloqueo',
|
||||||
|
clinic_delete_block_confirm: 'Se eliminará el bloqueo.',
|
||||||
|
nav_clinic: 'Agenda médica',
|
||||||
|
clinic_agenda: 'Agenda',
|
||||||
|
clinic_patients: 'Pacientes',
|
||||||
|
clinic_blocks: 'Bloqueos',
|
||||||
|
clinic_config: 'Configuración de agenda',
|
||||||
|
clinic_new_appointment: 'Nueva cita',
|
||||||
|
clinic_new_patient: 'Nuevo paciente',
|
||||||
|
clinic_new_block: 'Nuevo bloqueo',
|
||||||
|
clinic_appointment: 'Cita',
|
||||||
|
clinic_patient: 'Paciente',
|
||||||
|
clinic_block: 'Bloqueo',
|
||||||
|
clinic_view_day: 'Día',
|
||||||
|
clinic_view_week: 'Semana',
|
||||||
|
clinic_view_list: 'Lista',
|
||||||
|
clinic_doctors: 'Médicos',
|
||||||
|
clinic_doctor: 'Médico',
|
||||||
|
clinic_status: 'Estado',
|
||||||
|
clinic_status_scheduled: 'Programada',
|
||||||
|
clinic_status_confirmed: 'Confirmada',
|
||||||
|
clinic_status_attended: 'Atendida',
|
||||||
|
clinic_status_no_show: 'No asistió',
|
||||||
|
clinic_search_patient: 'Buscar por nombre, cédula o teléfono',
|
||||||
|
clinic_type: 'Tipo de cita',
|
||||||
|
clinic_channel: 'Canal',
|
||||||
|
clinic_channel_phone: 'Teléfono',
|
||||||
|
clinic_channel_whatsapp: 'WhatsApp',
|
||||||
|
clinic_channel_in_person: 'Presencial',
|
||||||
|
clinic_channel_other: 'Otro',
|
||||||
|
clinic_notes: 'Notas',
|
||||||
|
clinic_available: 'Huecos disponibles',
|
||||||
|
clinic_confirm: 'Confirmar',
|
||||||
|
clinic_cancel: 'Cancelar cita',
|
||||||
|
clinic_cancel_reason: 'Motivo de cancelación',
|
||||||
|
clinic_affected: 'Citas afectadas; hay que reprogramarlas',
|
||||||
|
clinic_confirm_affected: 'Confirmar bloqueo',
|
||||||
|
clinic_first_names: 'Nombres',
|
||||||
|
clinic_last_names: 'Apellidos',
|
||||||
|
clinic_identification: 'Cédula / identificación',
|
||||||
|
clinic_phone: 'Teléfono',
|
||||||
|
clinic_email: 'Correo',
|
||||||
|
clinic_birth_date: 'Nacimiento',
|
||||||
|
clinic_kind: 'Clase',
|
||||||
|
clinic_kind_surgery: 'Quirófano',
|
||||||
|
clinic_kind_vacation: 'Vacaciones',
|
||||||
|
clinic_kind_other: 'Otro',
|
||||||
|
clinic_title: 'Título',
|
||||||
|
clinic_staff: 'Personal',
|
||||||
|
clinic_user_id: 'ID de usuario',
|
||||||
|
clinic_scope_all: 'Recepción (todos)',
|
||||||
|
clinic_scope_own: 'Médico (propia)',
|
||||||
|
clinic_is_doctor: 'Es médico',
|
||||||
|
clinic_specialty: 'Especialidad',
|
||||||
|
clinic_color: 'Color',
|
||||||
|
clinic_types: 'Tipos de cita',
|
||||||
|
clinic_new_type: 'Nuevo tipo',
|
||||||
|
clinic_name: 'Nombre',
|
||||||
|
clinic_duration: 'Duración (minutos)',
|
||||||
|
clinic_schedules: 'Horario de atención',
|
||||||
|
calendar_layer_clinic: 'Citas médicas',
|
||||||
nav_calendar: 'Calendario',
|
nav_calendar: 'Calendario',
|
||||||
calendar_date: 'Fecha',
|
calendar_date: 'Fecha',
|
||||||
notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás',
|
notification_meeting_created: 'Nueva reunión', notification_meeting_updated: 'Reunión modificada', notification_meeting_cancelled: 'Reunión cancelada', notification_meeting_occurrence_cancelled: 'Ocurrencia cancelada', notification_meeting_reminder: 'Recordatorio de reunión', notification_meeting_rsvp_accepted: 'Aceptó la reunión', notification_meeting_rsvp_declined: 'Rechazó la reunión', notification_meeting_rsvp_tentative: 'Respondió quizás',
|
||||||
@@ -627,6 +693,72 @@ export const messages = {
|
|||||||
},
|
},
|
||||||
'en-US': {
|
'en-US': {
|
||||||
email_change_title: 'Change email', email_change_new: 'New email', email_change_request: 'Request change', email_change_requested: 'Request created', email_change_cancel: 'Cancel request', email_change_cancelled: 'Request cancelled', email_change_requests: 'Email change requests', email_change_none: 'No pending requests', email_change_approve: 'Approve', email_change_reject: 'Reject', email_change_reason: 'Rejection reason', email_change_approved: 'Change approved', email_change_rejected: 'Change rejected', email_change_approve_title: 'Approve my email change', email_change_confirm_title: 'Confirm new email', email_change_accept: 'Accept', email_change_self_approved: 'Approved. Check your new email to confirm it.', email_change_applied: 'Email updated. All sessions were closed. Sign in with:', email_change_status_pending: 'Pending approval', email_change_status_approved: 'Approved: check your new email', email_change_status_rejected: 'Rejected', email_change_status_cancelled: 'Cancelled', email_change_status_expired: 'Expired', email_change_status_failed: 'Failed', email_change_status_applied: 'Applied',
|
email_change_title: 'Change email', email_change_new: 'New email', email_change_request: 'Request change', email_change_requested: 'Request created', email_change_cancel: 'Cancel request', email_change_cancelled: 'Request cancelled', email_change_requests: 'Email change requests', email_change_none: 'No pending requests', email_change_approve: 'Approve', email_change_reject: 'Reject', email_change_reason: 'Rejection reason', email_change_approved: 'Change approved', email_change_rejected: 'Change rejected', email_change_approve_title: 'Approve my email change', email_change_confirm_title: 'Confirm new email', email_change_accept: 'Accept', email_change_self_approved: 'Approved. Check your new email to confirm it.', email_change_applied: 'Email updated. All sessions were closed. Sign in with:', email_change_status_pending: 'Pending approval', email_change_status_approved: 'Approved: check your new email', email_change_status_rejected: 'Rejected', email_change_status_cancelled: 'Cancelled', email_change_status_expired: 'Expired', email_change_status_failed: 'Failed', email_change_status_applied: 'Applied',
|
||||||
|
clinic_status_cancelled: 'Cancelled',
|
||||||
|
clinic_active: 'Active',
|
||||||
|
clinic_reprogram: 'Reschedule',
|
||||||
|
clinic_history: 'Appointment history',
|
||||||
|
clinic_cancel_confirm: 'The appointment will be cancelled and the reason recorded.',
|
||||||
|
clinic_delete_block: 'Delete block',
|
||||||
|
clinic_delete_block_confirm: 'The block will be deleted.',
|
||||||
|
nav_clinic: 'Medical schedule',
|
||||||
|
clinic_agenda: 'Schedule',
|
||||||
|
clinic_patients: 'Patients',
|
||||||
|
clinic_blocks: 'Blocks',
|
||||||
|
clinic_config: 'Schedule settings',
|
||||||
|
clinic_new_appointment: 'New appointment',
|
||||||
|
clinic_new_patient: 'New patient',
|
||||||
|
clinic_new_block: 'New block',
|
||||||
|
clinic_appointment: 'Appointment',
|
||||||
|
clinic_patient: 'Patient',
|
||||||
|
clinic_block: 'Block',
|
||||||
|
clinic_view_day: 'Day',
|
||||||
|
clinic_view_week: 'Week',
|
||||||
|
clinic_view_list: 'List',
|
||||||
|
clinic_doctors: 'Doctors',
|
||||||
|
clinic_doctor: 'Doctor',
|
||||||
|
clinic_status: 'Status',
|
||||||
|
clinic_status_scheduled: 'Scheduled',
|
||||||
|
clinic_status_confirmed: 'Confirmed',
|
||||||
|
clinic_status_attended: 'Attended',
|
||||||
|
clinic_status_no_show: 'No show',
|
||||||
|
clinic_search_patient: 'Search name, ID or phone',
|
||||||
|
clinic_type: 'Appointment type',
|
||||||
|
clinic_channel: 'Channel',
|
||||||
|
clinic_channel_phone: 'Phone',
|
||||||
|
clinic_channel_whatsapp: 'WhatsApp',
|
||||||
|
clinic_channel_in_person: 'In person',
|
||||||
|
clinic_channel_other: 'Other',
|
||||||
|
clinic_notes: 'Notes',
|
||||||
|
clinic_available: 'Available slots',
|
||||||
|
clinic_confirm: 'Confirm',
|
||||||
|
clinic_cancel: 'Cancel appointment',
|
||||||
|
clinic_cancel_reason: 'Cancellation reason',
|
||||||
|
clinic_affected: 'Affected appointments; rescheduling needed',
|
||||||
|
clinic_confirm_affected: 'Confirm block',
|
||||||
|
clinic_first_names: 'First names',
|
||||||
|
clinic_last_names: 'Last names',
|
||||||
|
clinic_identification: 'ID',
|
||||||
|
clinic_phone: 'Phone',
|
||||||
|
clinic_email: 'Email',
|
||||||
|
clinic_birth_date: 'Date of birth',
|
||||||
|
clinic_kind: 'Kind',
|
||||||
|
clinic_kind_surgery: 'Surgery',
|
||||||
|
clinic_kind_vacation: 'Vacation',
|
||||||
|
clinic_kind_other: 'Other',
|
||||||
|
clinic_title: 'Title',
|
||||||
|
clinic_staff: 'Staff',
|
||||||
|
clinic_user_id: 'User ID',
|
||||||
|
clinic_scope_all: 'Reception (all)',
|
||||||
|
clinic_scope_own: 'Doctor (own)',
|
||||||
|
clinic_is_doctor: 'Is doctor',
|
||||||
|
clinic_specialty: 'Specialty',
|
||||||
|
clinic_color: 'Color',
|
||||||
|
clinic_types: 'Appointment types',
|
||||||
|
clinic_new_type: 'New type',
|
||||||
|
clinic_name: 'Name',
|
||||||
|
clinic_duration: 'Duration (minutes)',
|
||||||
|
clinic_schedules: 'Working hours',
|
||||||
|
calendar_layer_clinic: 'Medical appointments',
|
||||||
nav_calendar: 'Calendar',
|
nav_calendar: 'Calendar',
|
||||||
calendar_date: 'Date',
|
calendar_date: 'Date',
|
||||||
notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe',
|
notification_meeting_created: 'New meeting', notification_meeting_updated: 'Meeting updated', notification_meeting_cancelled: 'Meeting cancelled', notification_meeting_occurrence_cancelled: 'Occurrence cancelled', notification_meeting_reminder: 'Meeting reminder', notification_meeting_rsvp_accepted: 'Accepted meeting', notification_meeting_rsvp_declined: 'Declined meeting', notification_meeting_rsvp_tentative: 'Replied maybe',
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Stethoscope, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
|
import { useDraftForm } from '../../hooks/useDraftForm.js';
|
||||||
|
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
|
||||||
|
import { DraftNotice } from '../../components/DraftNotice.jsx';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||||||
|
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
|
||||||
|
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
import { Tabs } from '../../components/Tabs.jsx';
|
||||||
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
|
import { DatePicker } from '../../components/DatePicker.jsx';
|
||||||
|
import * as api from '../../services/clinic.js';
|
||||||
|
import { listUsers } from '../../services/users.js';
|
||||||
|
import './clinic.css';
|
||||||
|
const dayKey = d => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
|
||||||
|
const today = () => {
|
||||||
|
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Guayaquil',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).formatToParts(new Date()).map(p => [p.type, p.value]));
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
};
|
||||||
|
const move = (day, n) => dayKey(new Date(new Date(`${day}T12:00:00Z`).getTime() + n * 86400000));
|
||||||
|
const at = (day, hour = '09:00') => `${day} ${hour}:00`;
|
||||||
|
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', ' ');
|
||||||
|
};
|
||||||
|
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() {
|
||||||
|
const {
|
||||||
|
t,
|
||||||
|
openDialog,
|
||||||
|
pushToast
|
||||||
|
} = useUi();
|
||||||
|
const {
|
||||||
|
user
|
||||||
|
} = useAuth();
|
||||||
|
const guardedClose = useGuardedClose();
|
||||||
|
const [me, setMe] = useState(null),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[doctors, setDoctors] = useState([]),
|
||||||
|
[types, setTypes] = useState([]),
|
||||||
|
[allTypes, setAllTypes] = useState([]);
|
||||||
|
const [tab, setTab] = useState('agenda'),
|
||||||
|
[view, setView] = useState('day'),
|
||||||
|
[day, setDay] = useState(today);
|
||||||
|
const [doctorFilter, setDoctorFilter] = useState(() => {
|
||||||
|
const value = readStored('plc_clinic_doctors', []);
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
});
|
||||||
|
const [statusFilter, setStatusFilter] = useState(() => {
|
||||||
|
const value = readStored('plc_clinic_status', ['scheduled', 'confirmed', 'attended', 'no_show']);
|
||||||
|
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
|
||||||
|
});
|
||||||
|
const [appointments, setAppointments] = useState([]),
|
||||||
|
[blocks, setBlocks] = useState([]),
|
||||||
|
[patients, setPatients] = useState([]),
|
||||||
|
[search, setSearch] = useState(''),
|
||||||
|
[page, setPage] = useState(1),
|
||||||
|
[patientTotal, setPatientTotal] = useState(0);
|
||||||
|
const [popup, setPopup] = useState(null),
|
||||||
|
[editing, setEditing] = useState(false),
|
||||||
|
[conflict, setConflict] = useState(''),
|
||||||
|
[affected, setAffected] = useState([]),
|
||||||
|
[slots, setSlots] = useState([]),
|
||||||
|
[cancelReason, setCancelReason] = useState(''),
|
||||||
|
[cancelMode, setCancelMode] = useState(false),
|
||||||
|
[patientHistory, setPatientHistory] = useState([]),
|
||||||
|
[quickPatientId, setQuickPatientId] = useState(null),
|
||||||
|
[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',
|
||||||
|
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
|
||||||
|
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
|
||||||
|
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
|
||||||
|
const period = useMemo(() => {
|
||||||
|
let from = day,
|
||||||
|
to = move(day, 1);
|
||||||
|
if (view === 'week') {
|
||||||
|
const d = new Date(`${day}T12:00:00Z`),
|
||||||
|
offset = (d.getUTCDay() + 6) % 7;
|
||||||
|
from = move(day, -offset);
|
||||||
|
to = move(from, 7);
|
||||||
|
}
|
||||||
|
if (view === 'list') to = move(day, 30);
|
||||||
|
return {
|
||||||
|
from: at(from, '00:00'),
|
||||||
|
to: at(to, '00:00')
|
||||||
|
};
|
||||||
|
}, [day, view]);
|
||||||
|
async function load() {
|
||||||
|
if (!me) return;
|
||||||
|
try {
|
||||||
|
const [a, b] = await Promise.all([api.clinicAppointments(period.from, period.to), api.clinicBlocks(period.from, period.to)]);
|
||||||
|
setAppointments(a);
|
||||||
|
setBlocks(b);
|
||||||
|
setError('');
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.modules?.includes('clinic')) return;
|
||||||
|
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
|
||||||
|
api.clinicMe().then(async staff => {
|
||||||
|
setMe(staff);
|
||||||
|
const data = await api.clinicDoctors();
|
||||||
|
setDoctors(data.doctors);
|
||||||
|
setTypes(data.types);
|
||||||
|
}).catch(e => {
|
||||||
|
if (isAdmin) setTab('config');
|
||||||
|
else setError(e.message);
|
||||||
|
});
|
||||||
|
}, [user?.id]);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [me?.user_id, period.from, period.to]);
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('plc_clinic_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]);
|
||||||
|
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 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 draft = useDraftForm({
|
||||||
|
formId,
|
||||||
|
entityId: popup?.item?.id,
|
||||||
|
initial,
|
||||||
|
label: t(`clinic_${popup?.kind || 'appointment'}`),
|
||||||
|
enabled: !!popup && editing,
|
||||||
|
onDiscard: () => setEditing(false)
|
||||||
|
});
|
||||||
|
const v = draft.values;
|
||||||
|
useEffect(() => {
|
||||||
|
if (quickPatientId && popup?.kind === 'appointment' && editing) {
|
||||||
|
draft.setField('patientId', quickPatientId);
|
||||||
|
setQuickPatientId(null);
|
||||||
|
}
|
||||||
|
}, [quickPatientId, popup?.kind, editing]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
|
||||||
|
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
|
||||||
|
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
|
||||||
|
function open(kind, item = null, extra = {}) {
|
||||||
|
setPopup({
|
||||||
|
kind,
|
||||||
|
item,
|
||||||
|
...extra
|
||||||
|
});
|
||||||
|
setEditing(!item);
|
||||||
|
setConflict('');
|
||||||
|
setAffected([]);
|
||||||
|
setCancelMode(false);
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
void guardedClose(editing ? draft : null, () => {
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
setConflict('');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function save(confirmAffected = false) {
|
||||||
|
if (popup.item && !draft.isDirty) return;
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
|
||||||
|
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
|
||||||
|
if (popup.kind === 'block') result = await api.clinicSaveBlock({
|
||||||
|
...v,
|
||||||
|
confirmAffected
|
||||||
|
}, popup.item?.id);
|
||||||
|
if (result?.requiresConfirmation) {
|
||||||
|
setAffected(result.affected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.clear();
|
||||||
|
if (popup.returnTo) {
|
||||||
|
setQuickPatientId(result.id);
|
||||||
|
setPopup(popup.returnTo);
|
||||||
|
setEditing(true);
|
||||||
|
} else {
|
||||||
|
setPopup(null);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
setConflict('');
|
||||||
|
setAffected([]);
|
||||||
|
if (result?.affected?.length) setReprogram(result.affected);
|
||||||
|
await load();
|
||||||
|
if (tab === 'patients') {
|
||||||
|
const list = await api.clinicPatients(search, page);
|
||||||
|
setPatients(list.items);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
tone: 'success',
|
||||||
|
message: t('common_saved')
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function actionStatus(status) {
|
||||||
|
try {
|
||||||
|
await api.clinicAppointmentStatus(popup.item.id, {
|
||||||
|
status,
|
||||||
|
editVersion: popup.item.edit_version
|
||||||
|
});
|
||||||
|
setPopup(null);
|
||||||
|
await load();
|
||||||
|
pushToast({
|
||||||
|
tone: 'success',
|
||||||
|
message: t('common_saved')
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function cancelAppointment() {
|
||||||
|
if (!cancelReason.trim()) return;
|
||||||
|
openDialog({
|
||||||
|
tone: 'warning',
|
||||||
|
title: t('clinic_cancel'),
|
||||||
|
message: t('clinic_cancel_confirm'),
|
||||||
|
actions: [{
|
||||||
|
label: t('common_cancel')
|
||||||
|
}, {
|
||||||
|
label: t('clinic_cancel'),
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: async () => {
|
||||||
|
try {
|
||||||
|
await api.clinicCancelAppointment(popup.item.id, {
|
||||||
|
reason: cancelReason,
|
||||||
|
editVersion: popup.item.edit_version
|
||||||
|
});
|
||||||
|
setPopup(null);
|
||||||
|
setCancelMode(false);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setConflict(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const weekDoctorId = singleDoctor;
|
||||||
|
function appointmentMenu(item) {
|
||||||
|
const actions = [{
|
||||||
|
label: t('common_close')
|
||||||
|
}];
|
||||||
|
if (isReception) {
|
||||||
|
actions.push({
|
||||||
|
label: t('clinic_confirm'),
|
||||||
|
onClick: () => api.clinicAppointmentStatus(item.id, {
|
||||||
|
status: 'confirmed',
|
||||||
|
editVersion: item.edit_version
|
||||||
|
}).then(load).catch(e => setError(e.message))
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
label: t('clinic_reprogram'),
|
||||||
|
onClick: () => {
|
||||||
|
open('appointment', item);
|
||||||
|
setEditing(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
actions.push({
|
||||||
|
label: t('clinic_cancel'),
|
||||||
|
variant: 'danger',
|
||||||
|
onClick: () => {
|
||||||
|
open('appointment', item);
|
||||||
|
setCancelMode(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status) && (view !== 'week' || a.doctor_user_id === weekDoctorId));
|
||||||
|
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id) && (view !== 'week' || b.doctor_user_id === weekDoctorId));
|
||||||
|
const renderEvent = a => <button key={`a-${a.id}`} className="clinic-event appointment" style={{
|
||||||
|
borderLeftColor: a.type_color
|
||||||
|
}} onClick={() => open('appointment', a)} onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
appointmentMenu(a);
|
||||||
|
}} title={`${a.first_names} ${a.last_names}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</button>;
|
||||||
|
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={() => open('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_notes'), popup.item.notes]] : 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]] : [];
|
||||||
|
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
|
||||||
|
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
|
||||||
|
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
|
||||||
|
const hourRange = (() => {
|
||||||
|
let lo = 24, hi = 0;
|
||||||
|
for (const d of view === 'week' ? doctors.filter(x => x.id === weekDoctorId) : selectedDoctors) for (const seg of d.schedules || []) {
|
||||||
|
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2)));
|
||||||
|
hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
|
||||||
|
}
|
||||||
|
for (const x of [...visible.map(a => [a.starts_at, a.ends_at]), ...visibleBlocks.map(b => [b.occurrence_start, b.occurrence_end])]) {
|
||||||
|
lo = Math.min(lo, Number(String(x[0]).slice(11, 13)));
|
||||||
|
hi = Math.max(hi, Math.min(24, Math.ceil(Number(String(x[1]).slice(11, 13)) + Number(String(x[1]).slice(14, 16)) / 60)));
|
||||||
|
}
|
||||||
|
if (lo >= hi) [lo, hi] = [7, 19];
|
||||||
|
return Array.from({ length: hi - lo }, (_, i) => lo + i);
|
||||||
|
})();
|
||||||
|
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 === 'agenda' && <Button variant="accent" icon={Plus} onClick={() => open('appointment', null, {
|
||||||
|
doctorId: singleDoctor
|
||||||
|
})}>{t('clinic_new_appointment')}</Button>}{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 && <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={[...(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 => {
|
||||||
|
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
|
||||||
|
void guardedClose(current, () => setTab(key));
|
||||||
|
}} />
|
||||||
|
{tab === 'agenda' && <><div className="clinic-controls"><Button icon={ChevronLeft} onClick={() => setDay(move(day, view === 'week' ? -7 : -1))}>{t('calendar_previous')}</Button><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /><Button icon={ChevronRight} onClick={() => setDay(move(day, view === 'week' ? 7 : 1))}>{t('calendar_next')}</Button><ComboBox value={view} onChange={setView} options={['day', 'week', 'list'].map(x => ({
|
||||||
|
value: x,
|
||||||
|
label: t(`clinic_view_${x}`)
|
||||||
|
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.name
|
||||||
|
}))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show'].map(x => ({
|
||||||
|
value: x,
|
||||||
|
label: t(`clinic_status_${x}`)
|
||||||
|
}))} values={statusFilter} onChange={setStatusFilter} /></div>
|
||||||
|
{view === 'day' ? <div className="clinic-day-grid">{selectedDoctors.map(d => <div className="clinic-doctor" key={d.id}><h3 style={{
|
||||||
|
borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
|
||||||
|
}}>{d.name}</h3>{hourRange.map(h => {
|
||||||
|
const hour = String(h).padStart(2, '0'),
|
||||||
|
start = at(day, `${hour}:00`),
|
||||||
|
end = plus(start, 60),
|
||||||
|
weekday = (new Date(`${day}T12:00:00Z`).getUTCDay() + 6) % 7,
|
||||||
|
working = d.schedules?.some(s => s.weekday === weekday && `${day} ${s.starts_time}` <= start && `${day} ${s.ends_time}` >= end);
|
||||||
|
const blocked = visibleBlocks.some(b => b.doctor_user_id === d.id && b.occurrence_start < end && b.occurrence_end > start);
|
||||||
|
return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, {
|
||||||
|
doctorId: d.id,
|
||||||
|
slot: start
|
||||||
|
})}><small>{hour}:00</small>{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
|
||||||
|
})}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
|
||||||
|
length: 7
|
||||||
|
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{d} · {doctors.find(x => x.id === weekDoctorId)?.name}</h3>{hourRange.map(h => {
|
||||||
|
const start = at(d, `${String(h).padStart(2, '0')}:00`),
|
||||||
|
end = plus(start, 60),
|
||||||
|
doctor = doctors.find(x => x.id === weekDoctorId),
|
||||||
|
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
|
||||||
|
working = doctor?.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end);
|
||||||
|
const blocked = visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
|
||||||
|
return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && isReception && open('appointment', null, {
|
||||||
|
doctorId: weekDoctorId,
|
||||||
|
slot: start
|
||||||
|
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
|
||||||
|
})}</div>)}</div> : <div className="clinic-list">{[...visible.map(a => ({
|
||||||
|
start: a.starts_at,
|
||||||
|
node: renderEvent(a)
|
||||||
|
})), ...visibleBlocks.map(b => ({
|
||||||
|
start: b.occurrence_start,
|
||||||
|
node: renderBlock(b)
|
||||||
|
}))].sort((a, b) => a.start.localeCompare(b.start)).map((x, i) => <div key={i}><strong>{x.start.slice(0, 10)}</strong>{x.node}</div>)}</div>}</>}
|
||||||
|
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}} /></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></>}
|
||||||
|
{tab === 'blocks' && <div className="clinic-list">{visibleBlocks.map(b => <div key={`${b.id}-${b.occurrence_start}`}>{renderBlock(b)}</div>)}</div>}
|
||||||
|
{tab === 'config' && isAdmin && <div className="clinic-config"><h3>{t('clinic_staff')}</h3><ComboBox value={staffUser} onChange={x => {
|
||||||
|
setStaffUser(x);
|
||||||
|
const s = staffRows.find(r => r.user_id === Number(x));
|
||||||
|
setStaffInitial(s ? {
|
||||||
|
scope: s.scope,
|
||||||
|
isDoctor: !!s.is_doctor,
|
||||||
|
specialty: s.specialty || '',
|
||||||
|
color: s.color || '#0078d4',
|
||||||
|
isActive: !!s.is_active,
|
||||||
|
editVersion: s.edit_version
|
||||||
|
} : {
|
||||||
|
scope: 'own',
|
||||||
|
isDoctor: true,
|
||||||
|
specialty: '',
|
||||||
|
color: '#0078d4',
|
||||||
|
isActive: true
|
||||||
|
});
|
||||||
|
}} options={[{
|
||||||
|
value: '',
|
||||||
|
label: t('common_select')
|
||||||
|
}, ...allUsers.filter(u => u.is_active).map(u => ({
|
||||||
|
value: u.id,
|
||||||
|
label: u.name
|
||||||
|
}))]} /><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,
|
||||||
|
scope: x
|
||||||
|
})} options={['all', 'own'].map(x => ({
|
||||||
|
value: x,
|
||||||
|
label: t(`clinic_scope_${x}`)
|
||||||
|
}))} /><label><input type="checkbox" checked={staffForm.isDoctor} onChange={e => setStaffForm({
|
||||||
|
...staffForm,
|
||||||
|
isDoctor: e.target.checked
|
||||||
|
})} />{t('clinic_is_doctor')}</label><label><input type="checkbox" checked={staffForm.isActive} onChange={e => setStaffForm({
|
||||||
|
...staffForm,
|
||||||
|
isActive: e.target.checked
|
||||||
|
})} />{t('clinic_active')}</label><Field label={t('clinic_specialty')}><input className="input" value={staffForm.specialty} onChange={e => setStaffForm({
|
||||||
|
...staffForm,
|
||||||
|
specialty: e.target.value
|
||||||
|
})} /></Field><Field label={t('clinic_color')}><input type="color" value={staffForm.color} onChange={e => setStaffForm({
|
||||||
|
...staffForm,
|
||||||
|
color: e.target.value
|
||||||
|
})} /></Field><Button disabled={!staffUser || !!staffForm.editVersion && !staffDraft.isDirty} onClick={async () => {
|
||||||
|
try {
|
||||||
|
const saved = await api.clinicSaveStaff(staffUser, staffForm);
|
||||||
|
staffDraft.clear();
|
||||||
|
setStaffInitial({
|
||||||
|
scope: saved.scope,
|
||||||
|
isDoctor: !!saved.is_doctor,
|
||||||
|
specialty: saved.specialty || '',
|
||||||
|
color: saved.color || 'rgb(var(--accent))',
|
||||||
|
isActive: !!saved.is_active,
|
||||||
|
editVersion: saved.edit_version
|
||||||
|
});
|
||||||
|
setStaffRows(await api.clinicStaff());
|
||||||
|
setDoctors((await api.clinicDoctors()).doctors);
|
||||||
|
pushToast({
|
||||||
|
tone: 'success',
|
||||||
|
message: t('common_saved')
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
}
|
||||||
|
}}>{t('common_save')}</Button>
|
||||||
|
<h3>{t('clinic_types')}</h3><ComboBox value={typeForm.id || ''} onChange={x => {
|
||||||
|
const row = allTypes.find(r => r.id === Number(x));
|
||||||
|
setTypeInitial(row ? {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
durationMinutes: row.duration_minutes,
|
||||||
|
color: row.color,
|
||||||
|
isActive: !!row.is_active,
|
||||||
|
editVersion: row.edit_version
|
||||||
|
} : {
|
||||||
|
name: '',
|
||||||
|
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>{cancelMode && <Field label={t('clinic_cancel_reason')}><input className="input" value={cancelReason} onChange={e => setCancelReason(e.target.value)} /></Field>}{cancelMode && <Button variant="danger" disabled={!cancelReason.trim()} onClick={cancelAppointment}>{t('clinic_cancel')}</Button>}{popup.kind === 'appointment' && <div className="toolbar-left">{isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button onClick={() => setEditing(true)}>{t('clinic_reprogram')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setCancelMode(true)}>{t('clinic_cancel')}</Button></>}{['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</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}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</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><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}`
|
||||||
|
}))]} /><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,8 @@
|
|||||||
|
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-tabs,.clinic-controls,.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-day-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(170px,1fr);overflow-x:auto;gap:8px}.clinic-doctor{background:rgb(var(--surface));border:1px solid rgb(var(--line));border-radius:var(--r-lg);min-width:170px}.clinic-doctor h3{position:sticky;top:0;background:rgb(var(--surface));padding:10px;margin:0;font-size:.9rem;z-index:1}.clinic-hour{min-height:60px;border-top:1px solid rgb(var(--line));padding:4px;cursor:pointer}.clinic-hour.closed{background:rgb(var(--muted)/.12);cursor:default}.clinic-hour small{display:block;color:rgb(var(--muted));margin-bottom:4px}.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-day-grid{grid-auto-columns:minmax(220px,80vw)}.clinic-controls>*{max-width:100%}.clinic-detail{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
.clinic-week-grid{display:grid;grid-template-columns:repeat(7,minmax(165px,1fr));gap:6px;overflow-x:auto}.clinic-week-grid .clinic-doctor{min-width:165px}@media(max-width:900px){.clinic-week-grid{grid-template-columns:repeat(7,165px)}}
|
||||||
|
|
||||||
|
.clinic-controls .datepicker-trigger{flex:0 0 190px;width:190px}
|
||||||
|
.clinic-controls .combobox{flex:0 0 auto;min-width:150px}
|
||||||
|
/* Hora ocupada por un bloqueo (quirofano, vacaciones...): no se puede agendar. */
|
||||||
|
.clinic-hour.blocked{background:repeating-linear-gradient(135deg,rgb(var(--warning)/.10) 0 8px,transparent 8px 16px);cursor:not-allowed}
|
||||||
@@ -11,7 +11,8 @@ import { Button } from '../../components/Button.jsx';
|
|||||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
|
||||||
import { DatePicker } from '../../components/DatePicker.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 { InfoBar } from '../../components/InfoBar.jsx';
|
import { InfoBar } from '../../components/InfoBar.jsx';
|
||||||
import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx';
|
import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx';
|
||||||
@@ -19,6 +20,7 @@ import { listProjectOptions } from '../../services/projects.js';
|
|||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
import { hasModule } from '../../utils/modules.js';
|
import { hasModule } from '../../utils/modules.js';
|
||||||
import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js';
|
import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js';
|
||||||
|
import { clinicMe, clinicAppointments, clinicBlocks } from '../../services/clinic.js';
|
||||||
import './calendar.css';
|
import './calendar.css';
|
||||||
|
|
||||||
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
const dateKey = (d) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
@@ -31,17 +33,9 @@ const shift = (d, days) => new Date(d.getFullYear(), d.getMonth(), d.getDate() +
|
|||||||
const plusHour = (s) => { const d = new Date(s.replace(' ', 'T')); d.setHours(d.getHours()+1); return dateTime(d); };
|
const plusHour = (s) => { const d = new Date(s.replace(' ', 'T')); d.setHours(d.getHours()+1); return dateTime(d); };
|
||||||
const blank = (start = nextSlot()) => ({ title: '', description: '', startsAt: start, endsAt: plusHour(start), allDay: false, location: '', videoUrl: '', projectId: '', reminderMinutes: '', participantUserIds: [], externalEmails: [], recurrence: null });
|
const blank = (start = nextSlot()) => ({ title: '', description: '', startsAt: start, endsAt: plusHour(start), allDay: false, location: '', videoUrl: '', projectId: '', reminderMinutes: '', participantUserIds: [], externalEmails: [], recurrence: null });
|
||||||
function toForm(row) { return { title: row.title, description: row.description || '', startsAt: row.starts_at, endsAt: row.ends_at, allDay: !!row.all_day, location: row.location || '', videoUrl: row.video_url || '', projectId: row.project_id || '', reminderMinutes: row.reminder_minutes ?? '', participantUserIds: row.participants.map((p) => p.user_id), externalEmails: row.external_emails, recurrence: row.recurrence_json ? JSON.parse(row.recurrence_json) : null, editVersion: row.edit_version }; }
|
function toForm(row) { return { title: row.title, description: row.description || '', startsAt: row.starts_at, endsAt: row.ends_at, allDay: !!row.all_day, location: row.location || '', videoUrl: row.video_url || '', projectId: row.project_id || '', reminderMinutes: row.reminder_minutes ?? '', participantUserIds: row.participants.map((p) => p.user_id), externalEmails: row.external_emails, recurrence: row.recurrence_json ? JSON.parse(row.recurrence_json) : null, editVersion: row.edit_version }; }
|
||||||
const hours = Array.from({length:24},(_,i) => ({value:String(i).padStart(2,'0'),label:String(i).padStart(2,'0')}));
|
|
||||||
const minutes = Array.from({length:12},(_,i) => ({value:String(i*5).padStart(2,'0'),label:String(i*5).padStart(2,'0')}));
|
|
||||||
// Hora por defecto de una reunion nueva: siguiente media hora (10:31 -> 11:00, 10:10 -> 10:30).
|
// Hora por defecto de una reunion nueva: siguiente media hora (10:31 -> 11:00, 10:10 -> 10:30).
|
||||||
const nextSlot = () => { const d = ecuadorNow(); d.setMinutes(d.getMinutes() < 30 ? 30 : 60, 0, 0); return dateTime(d); };
|
const nextSlot = () => { const d = ecuadorNow(); d.setMinutes(d.getMinutes() < 30 ? 30 : 60, 0, 0); return dateTime(d); };
|
||||||
const readable = (s, lang, allDay) => new Date(s.replace(' ', 'T')).toLocaleString(lang, allDay ? { dateStyle: 'full' } : { dateStyle: 'full', timeStyle: 'short' });
|
const readable = (s, lang, allDay) => new Date(s.replace(' ', 'T')).toLocaleString(lang, allDay ? { dateStyle: 'full' } : { dateStyle: 'full', timeStyle: 'short' });
|
||||||
function DateTimeEditor({ value, onChange, allDay, label, isEnd = false }) {
|
|
||||||
const date = value.slice(0,10), hour = value.slice(11,13), minute = value.slice(14,16);
|
|
||||||
const setDate = (next) => onChange(`${next} ${allDay ? (isEnd ? '23:59' : '00:00') : `${hour}:${minute}`}:00`);
|
|
||||||
const setTime = (h,m) => onChange(`${date} ${h}:${m}:00`);
|
|
||||||
return <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
|
|
||||||
}
|
|
||||||
function bounds(cursor, view) {
|
function bounds(cursor, view) {
|
||||||
let first, last;
|
let first, last;
|
||||||
if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1); }
|
if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1); }
|
||||||
@@ -51,7 +45,7 @@ function bounds(cursor, view) {
|
|||||||
return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last };
|
return { from: `${dateKey(first)} 00:00:00`, to: `${dateKey(last)} 00:00:00`, first, last };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_LAYERS = { meeting: true, project: true, activity: true, task: true, recurring: true };
|
const ALL_LAYERS = { meeting: true, project: true, activity: true, task: true, recurring: true, clinic: true };
|
||||||
|
|
||||||
export default function CalendarioPage() {
|
export default function CalendarioPage() {
|
||||||
const { t, lang, openDialog, pushToast } = useUi();
|
const { t, lang, openDialog, pushToast } = useUi();
|
||||||
@@ -67,6 +61,9 @@ export default function CalendarioPage() {
|
|||||||
// Origen: todo, solo lo mio (lo creo/organizo yo) o solo lo compartido conmigo (lo creo otro y me incluye).
|
// Origen: todo, solo lo mio (lo creo/organizo yo) o solo lo compartido conmigo (lo creo otro y me incluye).
|
||||||
const [origin, setOrigin] = useState(() => { try { return localStorage.getItem('plc_calendar_origin') || 'all'; } catch { return 'all'; } });
|
const [origin, setOrigin] = useState(() => { try { return localStorage.getItem('plc_calendar_origin') || 'all'; } catch { return 'all'; } });
|
||||||
const canProjects = hasModule(user, 'projects');
|
const canProjects = hasModule(user, 'projects');
|
||||||
|
const canClinic = hasModule(user, 'clinic');
|
||||||
|
const { data: clinicStaff } = useAsync(() => canClinic ? clinicMe().catch(() => null) : Promise.resolve(null), [canClinic]);
|
||||||
|
const clinicDoctor = !!clinicStaff?.is_doctor;
|
||||||
const showDue = layers.activity && canProjects;
|
const showDue = layers.activity && canProjects;
|
||||||
const [selected, setSelected] = useState(null);
|
const [selected, setSelected] = useState(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -83,6 +80,8 @@ export default function CalendarioPage() {
|
|||||||
const { data: recurring } = useAsync(() => layers.recurring && canRecurring ? listMeetingRecurring(from, to) : Promise.resolve([]), [from, to, layers.recurring, canRecurring]);
|
const { data: recurring } = useAsync(() => layers.recurring && canRecurring ? listMeetingRecurring(from, to) : Promise.resolve([]), [from, to, layers.recurring, canRecurring]);
|
||||||
const { data: projectDates } = useAsync(() => layers.project && canProjects ? listMeetingProjects(from, to) : Promise.resolve([]), [from, to, layers.project, canProjects]);
|
const { data: projectDates } = useAsync(() => layers.project && canProjects ? listMeetingProjects(from, to) : Promise.resolve([]), [from, to, layers.project, canProjects]);
|
||||||
const { data: myTasks } = useAsync(() => layers.task && canProjects ? listMeetingTasks(from, to) : Promise.resolve([]), [from, to, layers.task, canProjects]);
|
const { data: myTasks } = useAsync(() => layers.task && canProjects ? listMeetingTasks(from, to) : Promise.resolve([]), [from, to, layers.task, canProjects]);
|
||||||
|
const { data: clinicAppointmentsData } = useAsync(() => clinicDoctor && layers.clinic ? clinicAppointments(from,to,user.id) : Promise.resolve([]), [clinicDoctor,layers.clinic,from,to,user.id]);
|
||||||
|
const { data: clinicBlocksData } = useAsync(() => clinicDoctor && layers.clinic ? clinicBlocks(from,to,user.id) : Promise.resolve([]), [clinicDoctor,layers.clinic,from,to,user.id]);
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
const { data: projects } = useAsync(listProjectOptions, []);
|
const { data: projects } = useAsync(listProjectOptions, []);
|
||||||
useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } });
|
useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } });
|
||||||
@@ -145,12 +144,12 @@ export default function CalendarioPage() {
|
|||||||
}
|
}
|
||||||
const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` }));
|
const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` }));
|
||||||
const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))];
|
const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))];
|
||||||
const allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).sort((a,b) => (a.day || '').localeCompare(b.day || ''));
|
const allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.clinic && clinicDoctor ? [...(clinicAppointmentsData||[]).map((a)=>({...a,kind:'clinic',day:a.starts_at.slice(0,10),occurrence_start:a.starts_at,clinicLabel:`${a.first_names} ${a.last_names?.slice(0,1)||''}.`})),...(clinicBlocksData||[]).map((b)=>({...b,kind:'clinic',day:b.occurrence_start.slice(0,10),clinicLabel:b.title}))] : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).sort((a,b) => (a.day || '').localeCompare(b.day || ''));
|
||||||
const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {});
|
const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {});
|
||||||
const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i));
|
const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i));
|
||||||
const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')];
|
const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')];
|
||||||
|
|
||||||
const availableLayers = [['meeting', t('calendar_layer_meetings')], ...(canProjects ? [['project', t('calendar_layer_projects')], ['activity', t('calendar_layer_activities')], ['task', t('calendar_layer_tasks')]] : []), ...(canRecurring ? [['recurring', t('calendar_layer_recurring')]] : [])];
|
const availableLayers = [['meeting', t('calendar_layer_meetings')], ...(canProjects ? [['project', t('calendar_layer_projects')], ['activity', t('calendar_layer_activities')], ['task', t('calendar_layer_tasks')]] : []), ...(canRecurring ? [['recurring', t('calendar_layer_recurring')]] : []), ...(clinicDoctor ? [['clinic', t('calendar_layer_clinic')]] : [])];
|
||||||
const activeTypes = availableLayers.filter(([kind]) => layers[kind]).map(([kind]) => kind);
|
const activeTypes = availableLayers.filter(([kind]) => layers[kind]).map(([kind]) => kind);
|
||||||
const allTypesOn = activeTypes.length === availableLayers.length;
|
const allTypesOn = activeTypes.length === availableLayers.length;
|
||||||
const typeValues = allTypesOn ? ['all', ...activeTypes] : activeTypes;
|
const typeValues = allTypesOn ? ['all', ...activeTypes] : activeTypes;
|
||||||
@@ -167,11 +166,11 @@ export default function CalendarioPage() {
|
|||||||
if (item.can_open) { navigate(`/tareas-recurrentes?task=${item.id}`); return; }
|
if (item.can_open) { navigate(`/tareas-recurrentes?task=${item.id}`); return; }
|
||||||
openDialog({ tone: 'info', title: item.name, message: [`${t('calendar_due')}: ${readable(`${item.due_date} 00:00:00`, lang, true)}`, item.project_name && `${t('calendar_project')}: ${item.project_name}`, item.overdue && t('calendar_overdue')].filter(Boolean).join(' · '), actions: [{ label: t('common_close'), variant: 'accent' }] });
|
openDialog({ tone: 'info', title: item.name, message: [`${t('calendar_due')}: ${readable(`${item.due_date} 00:00:00`, lang, true)}`, item.project_name && `${t('calendar_project')}: ${item.project_name}`, item.overdue && t('calendar_overdue')].filter(Boolean).join(' · '), actions: [{ label: t('common_close'), variant: 'accent' }] });
|
||||||
}
|
}
|
||||||
function itemButton(item) { return <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}${item.overdue ? ' overdue' : ''}${item.kind === 'recurring' && !item.is_next ? ' projected' : ''}${item.is_closed ? ' done' : ''}${item.shared ? ' shared' : ''}`} title={item.kind === 'meeting' ? item.title : `${item.name}${item.project_name ? ` · ${item.project_name}` : ''}${item.overdue ? ` (${t('calendar_overdue')})` : ''}`}
|
function itemButton(item) { return <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}${item.overdue ? ' overdue' : ''}${item.kind === 'recurring' && !item.is_next ? ' projected' : ''}${item.is_closed ? ' done' : ''}${item.shared ? ' shared' : ''}`} title={item.kind === 'meeting' ? item.title : item.kind === 'clinic' ? item.clinicLabel : `${item.name}${item.project_name ? ` · ${item.project_name}` : ''}${item.overdue ? ` (${t('calendar_overdue')})` : ''}`}
|
||||||
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === 'recurring') openRecurring(item); else if (item.kind === 'project') navigate(`/proyectos/${item.id}`); else if (item.kind === 'task') navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.activity_id}`); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }}
|
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === 'clinic') navigate('/agenda-medica'); else if (item.kind === 'recurring') openRecurring(item); else if (item.kind === 'project') navigate(`/proyectos/${item.id}`); else if (item.kind === 'task') navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.activity_id}`); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }}
|
||||||
onContextMenu={(e) => { if (item.kind !== 'meeting') return; e.preventDefault(); setContext({ item, x: e.clientX, y: e.clientY }); }}
|
onContextMenu={(e) => { if (item.kind !== 'meeting') return; e.preventDefault(); setContext({ item, x: e.clientX, y: e.clientY }); }}
|
||||||
onKeyDown={(e) => { if (item.kind === 'meeting' && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) { e.preventDefault(); const r=e.currentTarget.getBoundingClientRect(); setContext({item,x:r.left,y:r.bottom}); } }}>
|
onKeyDown={(e) => { if (item.kind === 'meeting' && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) { e.preventDefault(); const r=e.currentTarget.getBoundingClientRect(); setContext({item,x:r.left,y:r.bottom}); } }}>
|
||||||
{item.kind === 'meeting' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : item.kind === 'recurring' ? `↻ ${item.name}` : item.kind === 'project' ? `${item.milestone === 'start' ? '▶' : '⚑'} ${item.code} ${item.name}` : item.kind === 'task' ? `☐ ${item.name}` : `◆ ${item.name}`}
|
{item.kind === 'meeting' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : item.kind === 'clinic' ? `${item.occurrence_start.slice(11,16)} ${item.clinicLabel}` : item.kind === 'recurring' ? `↻ ${item.name}` : item.kind === 'project' ? `${item.milestone === 'start' ? '▶' : '⚑'} ${item.code} ${item.name}` : item.kind === 'task' ? `☐ ${item.name}` : `◆ ${item.name}`}
|
||||||
{item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
|
{item.kind === 'meeting' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
|
||||||
</button>; }
|
</button>; }
|
||||||
|
|
||||||
@@ -237,9 +236,7 @@ export default function CalendarioPage() {
|
|||||||
<Field label={t('calendar_participants')}><><div className="toolbar-left"><ComboBox options={[{value:'',label:t('common_select')},...userOptions]} value={userChoice} onChange={setUserChoice} /><Button onClick={() => { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')}</Button></div>
|
<Field label={t('calendar_participants')}><><div className="toolbar-left"><ComboBox options={[{value:'',label:t('common_select')},...userOptions]} value={userChoice} onChange={setUserChoice} /><Button onClick={() => { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')}</Button></div>
|
||||||
<div className="calendar-tags">{v.participantUserIds.map((id) => <button key={id} onClick={() => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×</button>)}</div></></Field>
|
<div className="calendar-tags">{v.participantUserIds.map((id) => <button key={id} onClick={() => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×</button>)}</div></></Field>
|
||||||
<Field label={t('calendar_external')}><><div className="toolbar-left"><input className="input" type="email" value={externalInput} onChange={(e) => setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} /><Button onClick={addExternal}>{t('calendar_add')}</Button></div><div className="calendar-tags">{v.externalEmails.map((e) => <button key={e} onClick={() => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×</button>)}</div></></Field>
|
<Field label={t('calendar_external')}><><div className="toolbar-left"><input className="input" type="email" value={externalInput} onChange={(e) => setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} /><Button onClick={addExternal}>{t('calendar_add')}</Button></div><div className="calendar-tags">{v.externalEmails.map((e) => <button key={e} onClick={() => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×</button>)}</div></></Field>
|
||||||
<Field label={t('calendar_recurrence')}><ComboBox options={['none','daily','weekly','monthly'].map((x) => ({value:x,label:t(`calendar_${x}`)}))} value={v.recurrence?.frequency || 'none'} onChange={(x) => draft.setField('recurrence',x==='none'?null:{frequency:x,interval:1,weekdays:x==='weekly'?[(new Date(v.startsAt.replace(' ','T')).getDay()+6)%7]:[],count:10})} /></Field>
|
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x=>draft.setField('recurrence',x)}/>
|
||||||
{v.recurrence && <><FieldRow><Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={v.recurrence.interval} onChange={(e) => draft.setField('recurrence',{...v.recurrence,interval:Number(e.target.value)})} /></Field><Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={v.recurrence.count || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,count:e.target.value?Number(e.target.value):null,until:null})} /></Field><Field label={t('calendar_until')}><input className="input" type="date" value={v.recurrence.until || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,until:e.target.value||null,count:null})} /></Field></FieldRow>
|
|
||||||
{v.recurrence.frequency==='weekly' && <div className="calendar-weekdays">{weekLabels.map((label,index) => <label key={label}><input type="checkbox" checked={v.recurrence.weekdays.includes(index)} onChange={() => draft.setField('recurrence',{...v.recurrence,weekdays:v.recurrence.weekdays.includes(index)?v.recurrence.weekdays.filter((x)=>x!==index):[...v.recurrence.weekdays,index]})} /> {label}</label>)}</div>}</>}
|
|
||||||
<div className="toolbar-left" style={{ marginTop:18 }}><Button variant="accent" onClick={save} disabled={!v.title.trim() || (!creating && !draft.isDirty)}>{t('common_save')}</Button><Button onClick={() => { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')}</Button></div>
|
<div className="toolbar-left" style={{ marginTop:18 }}><Button variant="accent" onClick={save} disabled={!v.title.trim() || (!creating && !draft.isDirty)}>{t('common_save')}</Button><Button onClick={() => { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')}</Button></div>
|
||||||
</div>}
|
</div>}
|
||||||
</DetailPopup>
|
</DetailPopup>
|
||||||
|
|||||||
@@ -47,3 +47,6 @@
|
|||||||
.meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; }
|
.meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; }
|
||||||
.meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); }
|
.meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); }
|
||||||
.meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); }
|
.meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); }
|
||||||
|
|
||||||
|
.meeting-calendar .calendar-entry.clinic { color:rgb(var(--cal-task)); background:rgb(var(--cal-task)/.2); border-left:3px solid rgb(var(--cal-task)); }
|
||||||
|
.meeting-calendar .calendar-legend-item.clinic .calendar-swatch { background:rgb(var(--cal-task)); }
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { api } from './api.js';
|
||||||
|
const query=(data)=>`?${new URLSearchParams(data)}`;
|
||||||
|
export const clinicMe=()=>api.get('/clinic/staff/me');
|
||||||
|
export const clinicDoctors=()=>api.get('/clinic/doctors');
|
||||||
|
export const clinicAppointments=(from,to,doctorId)=>api.get(`/clinic/appointments${query({from,to,...(doctorId?{doctorId}:{})})}`);
|
||||||
|
export const clinicBlocks=(from,to,doctorId)=>api.get(`/clinic/blocks${query({from,to,...(doctorId?{doctorId}:{})})}`);
|
||||||
|
export const clinicAvailability=(doctorId,date,typeId)=>api.get(`/clinic/availability${query({doctorId,date,typeId})}`);
|
||||||
|
export const clinicPatients=(search='',page=1)=>api.get(`/clinic/patients${query({search,page})}`);
|
||||||
|
export const clinicPatientHistory=(id)=>api.get(`/clinic/patients/${id}/appointments`);
|
||||||
|
export const clinicSavePatient=(data,id)=>id?api.put(`/clinic/patients/${id}`,data):api.post('/clinic/patients',data);
|
||||||
|
export const clinicSaveAppointment=(data,id)=>id?api.put(`/clinic/appointments/${id}`,data):api.post('/clinic/appointments',data);
|
||||||
|
export const clinicAppointmentStatus=(id,data)=>api.post(`/clinic/appointments/${id}/status`,data);
|
||||||
|
export const clinicCancelAppointment=(id,data)=>api.post(`/clinic/appointments/${id}/cancel`,data);
|
||||||
|
export const clinicSaveBlock=(data,id)=>id?api.put(`/clinic/blocks/${id}`,data):api.post('/clinic/blocks',data);
|
||||||
|
export const clinicDeleteBlock=(id,editVersion)=>api.delete(`/clinic/blocks/${id}?editVersion=${editVersion}`);
|
||||||
|
export const clinicStaff=()=>api.get('/clinic/config/staff');
|
||||||
|
export const clinicSaveStaff=(id,data)=>api.put(`/clinic/config/staff/${id}`,data);
|
||||||
|
export const clinicSchedules=(id)=>api.get(`/clinic/config/schedules/${id}`);
|
||||||
|
export const clinicSaveSchedules=(id,data)=>api.put(`/clinic/config/schedules/${id}`,data);
|
||||||
|
export const clinicTypes=()=>api.get('/clinic/config/types');
|
||||||
|
export const clinicSaveType=(data,id)=>id?api.put(`/clinic/config/types/${id}`,data):api.post('/clinic/config/types',data);
|
||||||
@@ -14,6 +14,12 @@ 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-type': ['editVersion','name','durationMinutes','color','isActive'],
|
||||||
|
'clinic-schedule': ['rows'],
|
||||||
|
'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'],
|
||||||
|
'clinic-patient': ['editVersion','birthDate'],
|
||||||
|
'clinic-block': ['editVersion','doctorId','kind','startsAt','endsAt','recurrence'],
|
||||||
'meeting-new': ['startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
|
'meeting-new': ['startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
|
||||||
'meeting-edit': ['editVersion', 'startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
|
'meeting-edit': ['editVersion', 'startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'],
|
||||||
'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
|
'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
export const MODULE_LABEL_KEYS = {
|
export const MODULE_LABEL_KEYS = {
|
||||||
projects: 'nav_projects',
|
projects: 'nav_projects',
|
||||||
calendar: 'nav_calendar',
|
calendar: 'nav_calendar',
|
||||||
|
clinic: 'nav_clinic',
|
||||||
recurring_tasks: 'nav_recurring_tasks',
|
recurring_tasks: 'nav_recurring_tasks',
|
||||||
notes: 'nav_notes',
|
notes: 'nav_notes',
|
||||||
vault: 'nav_passwords',
|
vault: 'nav_passwords',
|
||||||
|
|||||||
Reference in New Issue
Block a user