diff --git a/AGENTS.md b/AGENTS.md index 67203b6..ff5951a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - Proyectos (con miembros) - Actividades + Tareas + dependencias simples - Dashboard (KPIs) +- Agenda médica (citas, pacientes, bloqueos, horarios y tipos; v4.7.0) - Calendario y Gantt de solo lectura - Comentarios y adjuntos - Notificaciones in-app diff --git a/API.md b/API.md index 33a4cea..7568f5b 100644 --- a/API.md +++ b/API.md @@ -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`. 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) Todas las rutas requieren JWT y módulo `calendar`. Las fechas son `YYYY-MM-DD HH:mm:ss` en hora diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index b0778c8..7773c80 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -10,11 +10,30 @@ - Charset: `utf8mb4` - 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. `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. +## 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 - La base ya existe: sí (creada en dev el 2026-09-03, usuario `project_linkedcorp`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e24c40..1ae123d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 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 ### 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. diff --git a/ESTADO.md b/ESTADO.md index b57b248..fe4ce68 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -1,9 +1,21 @@ # 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 + 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) - Implementado en Mi cuenta y Organización → Usuarios, con enlaces públicos de aprobación propia del superadmin y confirmación del correo nuevo. diff --git a/SPEC.md b/SPEC.md index 5fe74dc..61d1c8d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 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 - Vista de solo lectura por proyecto: barras por actividad (inicio/fin/avance), sin edición directa desde el gráfico en el MVP. diff --git a/VERSION b/VERSION index 6016e8a..f6cdf40 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.6.0 +4.7.0 diff --git a/backend/database/migrations/036_clinic.sql b/backend/database/migrations/036_clinic.sql new file mode 100644 index 0000000..c19fd8e --- /dev/null +++ b/backend/database/migrations/036_clinic.sql @@ -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; diff --git a/backend/package-lock.json b/backend/package-lock.json index 9925b02..ddef376 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "3.0.1", + "version": "4.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "3.0.1", + "version": "4.7.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 3a810c5..c2054c2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.6.0", + "version": "4.7.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 23b5cbc..75f2cbf 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -29,6 +29,7 @@ import devicesRoutes from './modules/devices/routes.js'; import inventoryIngestRoutes from './modules/devices/ingest.js'; import aiRoutes from './modules/ai/routes.js'; import meetingsRoutes from './modules/meetings/routes.js'; +import clinicRoutes from './modules/clinic/routes.js'; export const app = express(); @@ -74,6 +75,7 @@ app.use('/api/vault', vaultRoutes); app.use('/api/devices', devicesRoutes); app.use('/api/ai', aiRoutes); 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 // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/middleware/modules.js b/backend/src/middleware/modules.js index 55689e1..3ca4c4a 100644 --- a/backend/src/middleware/modules.js +++ b/backend/src/middleware/modules.js @@ -3,14 +3,15 @@ import { fail } from '../utils/http.js'; // 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. -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. export async function loadModules(userId, roleCode) { if (roleCode === 'superadmin') return [...MODULES]; - const [rows] = await pool.query('SELECT module FROM user_modules WHERE user_id = ? AND enabled = 0', [userId]); - const disabled = new Set(rows.map((r) => r.module)); - return MODULES.filter((m) => !disabled.has(m)); + const [rows] = await pool.query('SELECT module, enabled FROM user_modules WHERE user_id = ?', [userId]); + const enabled = new Map(rows.map((r) => [r.module, !!r.enabled])); + // 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) { diff --git a/backend/src/modules/clinic/routes.js b/backend/src/modules/clinic/routes.js new file mode 100644 index 0000000..8dd0147 --- /dev/null +++ b/backend/src/modules/clinic/routes.js @@ -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; diff --git a/backend/src/modules/clinic/service.js b/backend/src/modules/clinic/service.js new file mode 100644 index 0000000..51b7c11 --- /dev/null +++ b/backend/src/modules/clinic/service.js @@ -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? ${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 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]; +} diff --git a/backend/src/utils/audit.js b/backend/src/utils/audit.js index 0df1cee..e79919a 100644 --- a/backend/src/utils/audit.js +++ b/backend/src/utils/audit.js @@ -1,7 +1,7 @@ import { pool } from '../config/db.js'; -export async function logAudit({ userId, action, entityType, entityId, detail }) { - await pool.query( +export async function logAudit({ userId, action, entityType, entityId, detail, connection = pool }) { + await connection.query( `INSERT INTO audit_log (user_id, action, entity_type, entity_id, detail_json) VALUES (?, ?, ?, ?, ?)`, [userId, action, entityType, entityId ?? null, detail ? JSON.stringify(detail) : null] ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74784a3..39945df 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "3.0.1", + "version": "4.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "3.0.1", + "version": "4.7.0", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", diff --git a/frontend/package.json b/frontend/package.json index 69f6508..ca68d73 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.6.0", + "version": "4.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index e74e8d5..98e87a1 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -26,6 +26,7 @@ import EquiposPage from '../pages/Equipos/EquiposPage.jsx'; import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; import MiCuentaPage from '../pages/MiCuenta/MiCuentaPage.jsx'; import CalendarioPage from '../pages/Calendario/CalendarioPage.jsx'; +import AgendaMedicaPage from '../pages/AgendaMedica/AgendaMedicaPage.jsx'; // Router de datos (createBrowserRouter): necesario para useBlocker, que avisa al salir de una // seccion con cambios sin guardar (ver components/NavigationGuard.jsx). Mismas rutas de antes. @@ -45,6 +46,7 @@ const router = createBrowserRouter(createRoutesFromElements( } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/DateTimeEditor.jsx b/frontend/src/components/DateTimeEditor.jsx new file mode 100644 index 0000000..175688e --- /dev/null +++ b/frontend/src/components/DateTimeEditor.jsx @@ -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
{!allDay && <> setTime(h,minute)} placeholder={`${label} HH`} />: setTime(hour,m)} placeholder={`${label} MM`} />}
; +} diff --git a/frontend/src/components/NavigationView.jsx b/frontend/src/components/NavigationView.jsx index c56acb7..d4eb6ba 100644 --- a/frontend/src/components/NavigationView.jsx +++ b/frontend/src/components/NavigationView.jsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom'; import { useUserDrafts } from '../hooks/useDraftForm.js'; 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 { useUi } from '../context/ui-context.jsx'; import { useAuth } from '../context/auth-context.jsx'; @@ -15,6 +15,7 @@ const ITEMS = [ { to: '/', icon: LayoutDashboard, labelKey: 'nav_dashboard', end: true }, { to: '/proyectos', module: 'projects', icon: FolderKanban, labelKey: 'nav_projects' }, { to: '/calendario', module: 'calendar', icon: CalendarDays, labelKey: 'nav_calendar' }, + { to: '/agenda-medica', module: 'clinic', icon: Stethoscope, labelKey: 'nav_clinic' }, { to: '/tareas-recurrentes', module: 'recurring_tasks', icon: Repeat, labelKey: 'nav_recurring_tasks' }, { to: '/notas', module: 'notes', icon: StickyNote, labelKey: 'nav_notes' }, { to: '/contrasenas', module: 'vault', icon: KeyRound, labelKey: 'nav_passwords' }, diff --git a/frontend/src/components/RecurrenceEditor.jsx b/frontend/src/components/RecurrenceEditor.jsx new file mode 100644 index 0000000..fe471f2 --- /dev/null +++ b/frontend/src/components/RecurrenceEditor.jsx @@ -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 <> + + ({ + 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 + })} /> + + {value && <> + + patch({ + interval: Number(e.target.value) + })} /> + patch({ + count: e.target.value ? Number(e.target.value) : null, + until: null + })} /> + patch({ + until: x || null, + count: null + })} label={t('calendar_until')} /> + + {value.frequency === 'weekly' &&
{labels.map((name, index) => )}
} + } + ; +} diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 2e03bee..a51d857 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -1,6 +1,72 @@ export const messages = { 'es-EC': { email_change_title: 'Cambiar correo', email_change_new: 'Correo nuevo', email_change_request: 'Solicitar cambio', email_change_requested: 'Solicitud creada', email_change_cancel: 'Cancelar solicitud', email_change_cancelled: 'Solicitud cancelada', email_change_requests: 'Solicitudes de cambio de correo', email_change_none: 'No hay solicitudes pendientes', email_change_approve: 'Aprobar', email_change_reject: 'Rechazar', email_change_reason: 'Motivo del rechazo', email_change_approved: 'Cambio aprobado', email_change_rejected: 'Cambio rechazado', email_change_approve_title: 'Aprobar mi cambio de correo', email_change_confirm_title: 'Confirmar correo nuevo', email_change_accept: 'Aceptar', email_change_self_approved: 'Aprobado. Revisa el correo nuevo para confirmarlo.', email_change_applied: 'Correo actualizado. Todas tus sesiones se cerraron. Inicia sesión con:', email_change_status_pending: 'Pendiente de aprobación', email_change_status_approved: 'Aprobada: revisa tu correo nuevo', email_change_status_rejected: 'Rechazada', email_change_status_cancelled: 'Cancelada', email_change_status_expired: 'Vencida', email_change_status_failed: 'Fallida', email_change_status_applied: 'Aplicada', + clinic_status_cancelled: 'Cancelada', + clinic_active: 'Activo', + clinic_reprogram: 'Reprogramar', + clinic_history: 'Historial de citas', + clinic_cancel_confirm: 'Se cancelará la cita y quedará el motivo registrado.', + clinic_delete_block: 'Eliminar bloqueo', + clinic_delete_block_confirm: 'Se eliminará el bloqueo.', + nav_clinic: 'Agenda médica', + clinic_agenda: 'Agenda', + clinic_patients: 'Pacientes', + clinic_blocks: 'Bloqueos', + clinic_config: 'Configuración de agenda', + clinic_new_appointment: 'Nueva cita', + clinic_new_patient: 'Nuevo paciente', + clinic_new_block: 'Nuevo bloqueo', + clinic_appointment: 'Cita', + clinic_patient: 'Paciente', + clinic_block: 'Bloqueo', + clinic_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', 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', @@ -627,6 +693,72 @@ export const messages = { }, 'en-US': { email_change_title: 'Change email', email_change_new: 'New email', email_change_request: 'Request change', email_change_requested: 'Request created', email_change_cancel: 'Cancel request', email_change_cancelled: 'Request cancelled', email_change_requests: 'Email change requests', email_change_none: 'No pending requests', email_change_approve: 'Approve', email_change_reject: 'Reject', email_change_reason: 'Rejection reason', email_change_approved: 'Change approved', email_change_rejected: 'Change rejected', email_change_approve_title: 'Approve my email change', email_change_confirm_title: 'Confirm new email', email_change_accept: 'Accept', email_change_self_approved: 'Approved. Check your new email to confirm it.', email_change_applied: 'Email updated. All sessions were closed. Sign in with:', email_change_status_pending: 'Pending approval', email_change_status_approved: 'Approved: check your new email', email_change_status_rejected: 'Rejected', email_change_status_cancelled: 'Cancelled', email_change_status_expired: 'Expired', email_change_status_failed: 'Failed', email_change_status_applied: 'Applied', + clinic_status_cancelled: 'Cancelled', + clinic_active: 'Active', + clinic_reprogram: 'Reschedule', + clinic_history: 'Appointment history', + clinic_cancel_confirm: 'The appointment will be cancelled and the reason recorded.', + clinic_delete_block: 'Delete block', + clinic_delete_block_confirm: 'The block will be deleted.', + nav_clinic: 'Medical schedule', + clinic_agenda: 'Schedule', + clinic_patients: 'Patients', + clinic_blocks: 'Blocks', + clinic_config: 'Schedule settings', + clinic_new_appointment: 'New appointment', + clinic_new_patient: 'New patient', + clinic_new_block: 'New block', + clinic_appointment: 'Appointment', + clinic_patient: 'Patient', + clinic_block: 'Block', + clinic_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', 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', diff --git a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx new file mode 100644 index 0000000..2ffad61 --- /dev/null +++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx @@ -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 => ; + const renderBlock = b => ; + 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 {error}; + // 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

{t('nav_clinic')}

{isReception && tab === 'agenda' && }{isReception && tab === 'patients' && }{tab === 'blocks' && }
+ {error && {error}}{reprogram.length > 0 && {t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}} + ({ 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' && <>
({ + value: x, + label: t(`clinic_view_${x}`) + }))} /> ({ + value: d.id, + label: d.name + }))} values={doctorFilter} onChange={setDoctorFilter} /> ({ + value: x, + label: t(`clinic_status_${x}`) + }))} values={statusFilter} onChange={setStatusFilter} />
+ {view === 'day' ?
{selectedDoctors.map(d =>

{d.name}

{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
working && !blocked && isReception && open('appointment', null, { + doctorId: d.id, + slot: start + })}>{hour}:00{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)}
; + })}
)}
: view === 'week' ?
{Array.from({ + length: 7 + }, (_, i) => move(period.from.slice(0, 10), i)).map(d =>

{d} · {doctors.find(x => x.id === weekDoctorId)?.name}

{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
working && !blocked && isReception && open('appointment', null, { + doctorId: weekDoctorId, + slot: start + })}>{String(h).padStart(2, '0')}:00{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)}
; + })}
)}
:
{[...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) =>
{x.start.slice(0, 10)}{x.node}
)}
}} + {tab === 'patients' && isReception && <> { + setSearch(e.target.value); + setPage(1); + }} />
{patients.map(p => )}
{page} / {Math.max(1, Math.ceil(patientTotal / 20))}
} + {tab === 'blocks' &&
{visibleBlocks.map(b =>
{renderBlock(b)}
)}
} + {tab === 'config' && isAdmin &&

{t('clinic_staff')}

{ + 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 + }))]} /> setStaffUser(e.target.value)} /> setStaffForm({ + ...staffForm, + scope: x + })} options={['all', 'own'].map(x => ({ + value: x, + label: t(`clinic_scope_${x}`) + }))} /> setStaffForm({ + ...staffForm, + specialty: e.target.value + })} /> setStaffForm({ + ...staffForm, + color: e.target.value + })} /> +

{t('clinic_types')}

{ + 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 + }))]} /> setTypeForm({ + ...typeForm, + name: e.target.value + })} /> setTypeForm({ + ...typeForm, + durationMinutes: Number(e.target.value) + })} /> setTypeForm({ + ...typeForm, + color: e.target.value + })} /> +

{t('clinic_schedules')}

{ + 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) =>
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}`) + }))} /> setScheduleRows(scheduleRows.map((r, j) => j === i ? { + ...r, + startsTime: e.target.value + } : r))} /> setScheduleRows(scheduleRows.map((r, j) => j === i ? { + ...r, + endsTime: e.target.value + } : r))} />
)}
} + {popup &&
{conflict && {conflict}}{affected.length > 0 && {t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} }{editing && } {!editing ? <>
{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) =>

{label}: {String(val)}

)}
{cancelMode && setCancelReason(e.target.value)} />}{cancelMode && }{popup.kind === 'appointment' &&
{isReception && <>}{['attended', 'no_show'].map(x => )}
}{popup.kind === 'patient' && isReception && <>

{t('clinic_history')}

{patientHistory.map(a =>

{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}

)}}{popup.kind === 'block' && <>} : <> + {popup.kind === 'appointment' && <> { + setSearch(e.target.value); + api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {}); + }} /> 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}` + }))]} /> draft.setField('doctorId', x)} options={doctors.map(d => ({ + value: d.id, + label: d.name + }))} /> { + 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 + }))} /> draft.setValues(old => ({ + ...old, + startsAt: x, + endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30) + }))} label={t('calendar_start')} /> draft.setField('endsAt', x)} label={t('calendar_end')} /> draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map(x => ({ + value: x, + label: t(`clinic_channel_${x}`) + }))} />