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 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]; +} 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) => + patch({ + weekdays: value.weekdays.includes(index) ? value.weekdays.filter(x => x !== index) : [...value.weekdays, index] + })} /> + {' '}{t(`calendar_${name}`)} + )}} + >} + >; +} 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 => 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}`)}; + const renderBlock = b => open('block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}; + 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' && open('appointment', null, { + doctorId: singleDoctor + })}>{t('clinic_new_appointment')}}{isReception && tab === 'patients' && open('patient')}>{t('clinic_new_patient')}}{tab === 'blocks' && open('block', null, { + doctorId: singleDoctor + })}>{t('clinic_new_block')}} + {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' && <> setDay(move(day, view === 'week' ? -7 : -1))}>{t('calendar_previous')} setDay(move(day, view === 'week' ? 7 : 1))}>{t('calendar_next')} ({ + 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 => open('patient', p)}>{p.first_names} {p.last_names} · {p.phone})} setPage(page - 1)}>{t('calendar_previous')}{page} / {Math.max(1, Math.ceil(patientTotal / 20))}= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}>} + {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, + isDoctor: e.target.checked + })} />{t('clinic_is_doctor')} setStaffForm({ + ...staffForm, + isActive: e.target.checked + })} />{t('clinic_active')} setStaffForm({ + ...staffForm, + specialty: e.target.value + })} /> setStaffForm({ + ...staffForm, + color: e.target.value + })} /> { + 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')} + {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 + })} /> setTypeForm({ + ...typeForm, + isActive: e.target.checked + })} />{t('clinic_active')} { + 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')} + {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))} /> setScheduleRows(scheduleRows.filter((_, j) => j !== i))}>{t('common_delete')})} setScheduleRows([...scheduleRows, { + weekday: 0, + startsTime: '09:00', + endsTime: '17:00' + }])}>{t('calendar_add')} { + 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')}} + {popup && {conflict && {conflict}}{affected.length > 0 && {t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} void save(true)}>{t('clinic_confirm_affected')}}{editing && } {!editing ? <>{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => {label}: {String(val)})}{cancelMode && setCancelReason(e.target.value)} />}{cancelMode && {t('clinic_cancel')}}{popup.kind === 'appointment' && {isReception && <> setEditing(true)}>{t('common_edit')} setEditing(true)}>{t('clinic_reprogram')} void actionStatus('confirmed')}>{t('clinic_confirm')} setCancelMode(true)}>{t('clinic_cancel')}>}{['attended', 'no_show'].map(x => void actionStatus(x)}>{t(`clinic_status_${x}`)})}}{popup.kind === 'patient' && isReception && <> setEditing(true)}>{t('common_edit')}{t('clinic_history')}{patientHistory.map(a => {a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)})}>}{popup.kind === 'block' && <> setEditing(true)}>{t('common_edit')} 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')}>}> : <> + {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}` + }))]} /> open('patient', null, { + returnTo: popup + })}>{t('clinic_new_patient')} 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}`) + }))} /> draft.setField('notes', e.target.value)} />{slots.slice(0, 80).map(s => draft.setValues(old => ({ + ...old, + startsAt: s.startsAt, + endsAt: s.endsAt + }))}>{s.startsAt.slice(11, 16)})}>} + {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]) => draft.setField(k, e.target.value)} />)} + {popup.kind === 'block' && <>{popup.item ? {doctors.find(d => d.id === v.doctorId)?.name} : draft.setField('doctorId', x)} options={doctors.map(d => ({ + value: d.id, + label: d.name + }))} />} draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map(x => ({ + value: x, + label: t(`clinic_kind_${x}`) + }))} /> draft.setField('title', e.target.value)} /> draft.setField('startsAt', x)} label={t('calendar_start')} /> draft.setField('endsAt', x)} label={t('calendar_end')} /> draft.setField('recurrence', x)} />>} + void save()}>{t('common_save')} { + draft.discard(); + if (!popup.item) setPopup(null);else setEditing(false); + }}>{t('common_cancel')}>}}; +} diff --git a/frontend/src/pages/AgendaMedica/clinic.css b/frontend/src/pages/AgendaMedica/clinic.css new file mode 100644 index 0000000..036ab41 --- /dev/null +++ b/frontend/src/pages/AgendaMedica/clinic.css @@ -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} diff --git a/frontend/src/pages/Calendario/CalendarioPage.jsx b/frontend/src/pages/Calendario/CalendarioPage.jsx index 07d6f55..af9f4e5 100644 --- a/frontend/src/pages/Calendario/CalendarioPage.jsx +++ b/frontend/src/pages/Calendario/CalendarioPage.jsx @@ -11,7 +11,8 @@ import { Button } from '../../components/Button.jsx'; import { Field, FieldRow } from '../../components/Field.jsx'; import { ComboBox } from '../../components/ComboBox.jsx'; import { CheckListDropdown } from '../../components/CheckListDropdown.jsx'; -import { DatePicker } from '../../components/DatePicker.jsx'; +import { DateTimeEditor } from '../../components/DateTimeEditor.jsx'; +import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx'; import { DetailPopup } from '../../components/DetailPopup.jsx'; import { InfoBar } from '../../components/InfoBar.jsx'; import { FlyoutItem, FlyoutSeparator } from '../../components/Flyout.jsx'; @@ -19,6 +20,7 @@ import { listProjectOptions } from '../../services/projects.js'; import { listUsers } from '../../services/users.js'; import { hasModule } from '../../utils/modules.js'; import { listMeetings, listMeetingActivities, listMeetingRecurring, listMeetingTasks, listMeetingProjects, getMeeting, createMeeting, updateMeeting, cancelMeeting, cancelMeetingOccurrence, respondMeeting } from '../../services/meetings.js'; +import { clinicMe, clinicAppointments, clinicBlocks } from '../../services/clinic.js'; import './calendar.css'; 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 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 }; } -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). 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' }); -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`} />>}; -} function bounds(cursor, view) { let first, last; if (view === 'month') { first = new Date(cursor.getFullYear(), cursor.getMonth(), 1); last = new Date(cursor.getFullYear(), cursor.getMonth()+1, 1); } @@ -51,7 +45,7 @@ function bounds(cursor, view) { 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() { 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). const [origin, setOrigin] = useState(() => { try { return localStorage.getItem('plc_calendar_origin') || 'all'; } catch { return 'all'; } }); const canProjects = hasModule(user, 'projects'); + const canClinic = hasModule(user, 'clinic'); + const { data: clinicStaff } = useAsync(() => canClinic ? clinicMe().catch(() => null) : Promise.resolve(null), [canClinic]); + const clinicDoctor = !!clinicStaff?.is_doctor; const showDue = layers.activity && canProjects; const [selected, setSelected] = useState(null); const [creating, setCreating] = useState(false); @@ -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: projectDates } = useAsync(() => layers.project && canProjects ? listMeetingProjects(from, to) : Promise.resolve([]), [from, to, layers.project, canProjects]); const { data: myTasks } = useAsync(() => layers.task && canProjects ? listMeetingTasks(from, to) : Promise.resolve([]), [from, to, layers.task, canProjects]); + const { data: clinicAppointmentsData } = useAsync(() => clinicDoctor && layers.clinic ? clinicAppointments(from,to,user.id) : Promise.resolve([]), [clinicDoctor,layers.clinic,from,to,user.id]); + const { data: clinicBlocksData } = useAsync(() => clinicDoctor && layers.clinic ? clinicBlocks(from,to,user.id) : Promise.resolve([]), [clinicDoctor,layers.clinic,from,to,user.id]); const { data: users } = useAsync(listUsers, []); const { data: projects } = useAsync(listProjectOptions, []); useReopenDraft({ newFormId:'meeting-new', editFormId:'meeting-edit', items:meetings, openNew: () => openNew(), openEdit: (item) => { void openMeeting(item, true); } }); @@ -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 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 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 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 allTypesOn = activeTypes.length === availableLayers.length; 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; } 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 { 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}`); }} + function itemButton(item) { return { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === 'clinic') navigate('/agenda-medica'); else if (item.kind === 'recurring') openRecurring(item); else if (item.kind === 'project') navigate(`/proyectos/${item.id}`); else if (item.kind === 'task') navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.activity_id}`); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }} onContextMenu={(e) => { if (item.kind !== 'meeting') return; e.preventDefault(); setContext({ item, x: e.clientX, y: e.clientY }); }} onKeyDown={(e) => { if (item.kind === 'meeting' && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) { e.preventDefault(); const r=e.currentTarget.getBoundingClientRect(); setContext({item,x:r.left,y:r.bottom}); } }}> - {item.kind === 'meeting' ? `${item.occurrence_start.slice(11,16)} ${item.title}` : item.kind === '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' && { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯} ; } @@ -237,9 +236,7 @@ export default function CalendarioPage() { <> { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')} {v.participantUserIds.map((id) => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×)}> <> setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} />{t('calendar_add')}{v.externalEmails.map((e) => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×)}> - ({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})} /> - {v.recurrence && <> draft.setField('recurrence',{...v.recurrence,interval:Number(e.target.value)})} /> draft.setField('recurrence',{...v.recurrence,count:e.target.value?Number(e.target.value):null,until:null})} /> draft.setField('recurrence',{...v.recurrence,until:e.target.value||null,count:null})} /> - {v.recurrence.frequency==='weekly' && {weekLabels.map((label,index) => draft.setField('recurrence',{...v.recurrence,weekdays:v.recurrence.weekdays.includes(index)?v.recurrence.weekdays.filter((x)=>x!==index):[...v.recurrence.weekdays,index]})} /> {label})}}>} + draft.setField('recurrence',x)}/> {t('common_save')} { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')} } diff --git a/frontend/src/pages/Calendario/calendar.css b/frontend/src/pages/Calendario/calendar.css index 705799c..a75a0d6 100644 --- a/frontend/src/pages/Calendario/calendar.css +++ b/frontend/src/pages/Calendario/calendar.css @@ -47,3 +47,6 @@ .meeting-calendar .calendar-entry.shared { box-shadow:inset 3px 0 0 currentColor; padding-left:8px; } .meeting-calendar .calendar-legend-item.shared-sample { cursor:default; color:rgb(var(--muted)); } .meeting-calendar .calendar-legend-item.shared-sample .calendar-swatch { background:rgb(var(--muted) / .25); box-shadow:inset 3px 0 0 rgb(var(--body)); } + +.meeting-calendar .calendar-entry.clinic { color:rgb(var(--cal-task)); background:rgb(var(--cal-task)/.2); border-left:3px solid rgb(var(--cal-task)); } +.meeting-calendar .calendar-legend-item.clinic .calendar-swatch { background:rgb(var(--cal-task)); } diff --git a/frontend/src/services/clinic.js b/frontend/src/services/clinic.js new file mode 100644 index 0000000..cb92b34 --- /dev/null +++ b/frontend/src/services/clinic.js @@ -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); diff --git a/frontend/src/utils/drafts.js b/frontend/src/utils/drafts.js index 542db56..f715c06 100644 --- a/frontend/src/utils/drafts.js +++ b/frontend/src/utils/drafts.js @@ -14,6 +14,12 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000; const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i; const DRAFT_FIELDS = { '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-edit': ['editVersion', 'startsAt', 'endsAt', 'allDay', 'projectId', 'reminderMinutes', 'participantUserIds', 'recurrence'], 'project-info': ['editVersion', 'projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'], diff --git a/frontend/src/utils/modules.js b/frontend/src/utils/modules.js index 45859dc..4c8596e 100644 --- a/frontend/src/utils/modules.js +++ b/frontend/src/utils/modules.js @@ -2,6 +2,7 @@ export const MODULE_LABEL_KEYS = { projects: 'nav_projects', calendar: 'nav_calendar', + clinic: 'nav_clinic', recurring_tasks: 'nav_recurring_tasks', notes: 'nav_notes', vault: 'nav_passwords',
{label}: {String(val)}
{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}