feat: version 4.7.0 -- Agenda medica

Modulo clinic (habilitado por usuario): citas de pacientes para medicos con choques
bloqueantes (medico, paciente con cualquier medico, quirofano/bloqueos recurrentes y
horario de atencion), validados en transaccion con bloqueo de filas para que dos
recepcionistas no tomen el mismo hueco. Recepcion (alcance todos) y medico (alcance
propio). Vista Dia por columnas de medico, Semana y Lista; pacientes con historial;
bloqueos; configuracion de personal, horarios y tipos solo admin/superadmin. Capa de
citas en el calendario personal del medico. Migracion 036.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-28 12:57:43 -05:00
parent bdc62403f4
commit 1e25022630
29 changed files with 1649 additions and 34 deletions
+1
View File
@@ -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
+31
View File
@@ -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
+20 -1
View File
@@ -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`).
+6
View File
@@ -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.
+13 -1
View File
@@ -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.
+19
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
4.6.0
4.7.0
+102
View File
@@ -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;
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "4.6.0",
"version": "4.7.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2
View File
@@ -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).
+5 -4
View File
@@ -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) {
+51
View File
@@ -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;
+392
View File
@@ -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];
}
+2 -2
View File
@@ -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]
);
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "4.6.0",
"version": "4.7.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -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(
<Route path="mi-cuenta" element={<MiCuentaPage />} />
<Route path="proyectos" element={<ModuleRoute module="projects"><ProyectosPage /></ModuleRoute>} />
<Route path="calendario" element={<ModuleRoute module="calendar"><CalendarioPage /></ModuleRoute>} />
<Route path="agenda-medica" element={<ModuleRoute module="clinic"><AgendaMedicaPage /></ModuleRoute>} />
<Route path="proyectos/:id" element={<ModuleRoute module="projects"><ProyectoDetallePage /></ModuleRoute>} />
<Route path="tareas-recurrentes" element={<ModuleRoute module="recurring_tasks"><RecurringTasksPage /></ModuleRoute>} />
<Route path="notas" element={<ModuleRoute module="notes"><NotasPage /></ModuleRoute>} />
@@ -0,0 +1,10 @@
import { DatePicker } from './DatePicker.jsx';
import { ComboBox } from './ComboBox.jsx';
const hours = Array.from({length:24},(_,i) => ({value:String(i).padStart(2,'0'),label:String(i).padStart(2,'0')}));
const minutes = Array.from({length:12},(_,i) => ({value:String(i*5).padStart(2,'0'),label:String(i*5).padStart(2,'0')}));
export function DateTimeEditor({ value, onChange, allDay, label, isEnd = false }) {
const date = value.slice(0,10), hour = value.slice(11,13), minute = value.slice(14,16);
const setDate = (next) => onChange(`${next} ${allDay ? (isEnd ? '23:59' : '00:00') : `${hour}:${minute}`}:00`);
const setTime = (h,m) => onChange(`${date} ${h}:${m}:00`);
return <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
}
+2 -1
View File
@@ -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' },
@@ -0,0 +1,55 @@
import { useUi } from '../context/ui-context.jsx';
import { Field, FieldRow } from './Field.jsx';
import { ComboBox } from './ComboBox.jsx';
import { DatePicker } from './DatePicker.jsx';
// Reuniones y bloqueos clínicos comparten las mismas reglas de recurrencia del backend.
export function RecurrenceEditor({
value,
start,
onChange
}) {
const {
t
} = useUi();
const weekday = (new Date(`${start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7;
const labels = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const patch = fields => onChange({
...value,
...fields
});
return <>
<Field label={t('calendar_recurrence')}>
<ComboBox options={['none', 'daily', 'weekly', 'monthly'].map(x => ({
value: x,
label: t(`calendar_${x}`)
}))} value={value?.frequency || 'none'} onChange={x => onChange(x === 'none' ? null : {
frequency: x,
interval: 1,
weekdays: x === 'weekly' ? [weekday] : [],
count: 10
})} />
</Field>
{value && <>
<FieldRow>
<Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={value.interval} onChange={e => patch({
interval: Number(e.target.value)
})} /></Field>
<Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={value.count || ''} onChange={e => patch({
count: e.target.value ? Number(e.target.value) : null,
until: null
})} /></Field>
<Field label={t('calendar_until')}><DatePicker value={value.until || ''} onChange={x => patch({
until: x || null,
count: null
})} label={t('calendar_until')} /></Field>
</FieldRow>
{value.frequency === 'weekly' && <div className="calendar-weekdays">{labels.map((name, index) => <label key={name}>
<input type="checkbox" checked={value.weekdays.includes(index)} onChange={() => patch({
weekdays: value.weekdays.includes(index) ? value.weekdays.filter(x => x !== index) : [...value.weekdays, index]
})} />
{' '}{t(`calendar_${name}`)}
</label>)}</div>}
</>}
</>;
}
+132
View File
@@ -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',
@@ -0,0 +1,743 @@
import { useEffect, useMemo, useState } from 'react';
import { Stethoscope, Plus, ChevronLeft, ChevronRight } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { DatePicker } from '../../components/DatePicker.jsx';
import * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import './clinic.css';
const dayKey = d => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const today = () => {
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Guayaquil',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date()).map(p => [p.type, p.value]));
return `${parts.year}-${parts.month}-${parts.day}`;
};
const move = (day, n) => dayKey(new Date(new Date(`${day}T12:00:00Z`).getTime() + n * 86400000));
const at = (day, hour = '09:00') => `${day} ${hour}:00`;
const plus = (s, n) => {
const d = new Date(`${s.replace(' ', 'T')}Z`);
return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
};
const initialAppointment = (day, doctorId, slot) => ({
doctorId: doctorId || '',
patientId: '',
typeId: '',
startsAt: slot || at(day),
endsAt: plus(slot || at(day), 30),
channel: 'phone',
notes: ''
});
const initialPatient = () => ({
firstNames: '',
lastNames: '',
identification: '',
phone: '',
email: '',
birthDate: '',
notes: ''
});
const initialBlock = (day, doctorId) => ({
doctorId: doctorId || '',
kind: 'surgery',
title: '',
startsAt: at(day),
endsAt: plus(at(day), 60),
recurrence: null
});
const formAppointment = a => ({
doctorId: a.doctor_user_id,
patientId: a.patient_id,
typeId: a.type_id,
startsAt: a.starts_at,
endsAt: a.ends_at,
channel: a.channel,
notes: a.notes || '',
editVersion: a.edit_version
});
const formPatient = p => ({
firstNames: p.first_names,
lastNames: p.last_names,
identification: p.identification || '',
phone: p.phone,
email: p.email || '',
birthDate: p.birth_date || '',
notes: p.notes || '',
editVersion: p.edit_version
});
const formBlock = b => ({
doctorId: b.doctor_user_id,
kind: b.kind,
title: b.title,
startsAt: b.starts_at,
endsAt: b.ends_at,
recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
editVersion: b.edit_version
});
const readStored = (key, fallback) => {
try {
return JSON.parse(localStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};
export default function AgendaMedicaPage() {
const {
t,
openDialog,
pushToast
} = useUi();
const {
user
} = useAuth();
const guardedClose = useGuardedClose();
const [me, setMe] = useState(null),
[error, setError] = useState(''),
[doctors, setDoctors] = useState([]),
[types, setTypes] = useState([]),
[allTypes, setAllTypes] = useState([]);
const [tab, setTab] = useState('agenda'),
[view, setView] = useState('day'),
[day, setDay] = useState(today);
const [doctorFilter, setDoctorFilter] = useState(() => {
const value = readStored('plc_clinic_doctors', []);
return Array.isArray(value) ? value : [];
});
const [statusFilter, setStatusFilter] = useState(() => {
const value = readStored('plc_clinic_status', ['scheduled', 'confirmed', 'attended', 'no_show']);
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
});
const [appointments, setAppointments] = useState([]),
[blocks, setBlocks] = useState([]),
[patients, setPatients] = useState([]),
[search, setSearch] = useState(''),
[page, setPage] = useState(1),
[patientTotal, setPatientTotal] = useState(0);
const [popup, setPopup] = useState(null),
[editing, setEditing] = useState(false),
[conflict, setConflict] = useState(''),
[affected, setAffected] = useState([]),
[slots, setSlots] = useState([]),
[cancelReason, setCancelReason] = useState(''),
[cancelMode, setCancelMode] = useState(false),
[patientHistory, setPatientHistory] = useState([]),
[quickPatientId, setQuickPatientId] = useState(null),
[reprogram, setReprogram] = useState([]);
const [allUsers, setAllUsers] = useState([]),
[staffRows, setStaffRows] = useState([]),
[staffUser, setStaffUser] = useState(''),
[staffInitial, setStaffInitial] = useState({
scope: 'own',
isDoctor: true,
specialty: '',
color: '#0078d4',
isActive: true
});
const [typeInitial, setTypeInitial] = useState({
name: '',
durationMinutes: 30,
color: '#0078d4',
isActive: true
});
const [scheduleDoctor, setScheduleDoctor] = useState(''),
[scheduleInitial, setScheduleInitial] = useState({
rows: []
});
const staffDraft = useDraftForm({
formId: 'clinic-staff',
entityId: staffUser || null,
initial: staffInitial,
label: t('clinic_staff'),
enabled: tab === 'config'
});
const typeDraft = useDraftForm({
formId: 'clinic-type',
entityId: typeInitial.id || null,
initial: typeInitial,
label: t('clinic_types'),
enabled: tab === 'config'
});
const scheduleDraft = useDraftForm({
formId: 'clinic-schedule',
entityId: scheduleDoctor || null,
initial: scheduleInitial,
label: t('clinic_schedules'),
enabled: tab === 'config'
});
const staffForm = staffDraft.values,
typeForm = typeDraft.values,
scheduleRows = scheduleDraft.values.rows || [];
const setStaffForm = staffDraft.setValues,
setTypeForm = typeDraft.setValues;
const setScheduleRows = rows => scheduleDraft.setField('rows', rows);
const isReception = me?.scope === 'all',
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
const period = useMemo(() => {
let from = day,
to = move(day, 1);
if (view === 'week') {
const d = new Date(`${day}T12:00:00Z`),
offset = (d.getUTCDay() + 6) % 7;
from = move(day, -offset);
to = move(from, 7);
}
if (view === 'list') to = move(day, 30);
return {
from: at(from, '00:00'),
to: at(to, '00:00')
};
}, [day, view]);
async function load() {
if (!me) return;
try {
const [a, b] = await Promise.all([api.clinicAppointments(period.from, period.to), api.clinicBlocks(period.from, period.to)]);
setAppointments(a);
setBlocks(b);
setError('');
} catch (e) {
setError(e.message);
}
}
useEffect(() => {
if (!user?.modules?.includes('clinic')) return;
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
api.clinicMe().then(async staff => {
setMe(staff);
const data = await api.clinicDoctors();
setDoctors(data.doctors);
setTypes(data.types);
}).catch(e => {
if (isAdmin) setTab('config');
else setError(e.message);
});
}, [user?.id]);
useEffect(() => {
void load();
}, [me?.user_id, period.from, period.to]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
} catch {}
}, [doctorFilter]);
useEffect(() => {
try {
localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter));
} catch {}
}, [statusFilter]);
useEffect(() => {
if (tab === 'patients' && isReception) api.clinicPatients(search, page).then(x => {
setPatients(x.items);
setPatientTotal(x.total);
}).catch(e => setError(e.message));
}, [tab, search, page, isReception]);
useEffect(() => {
if (tab === 'config' && isAdmin) Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]).then(([rows, users, kindRows]) => {
setStaffRows(rows);
setAllUsers(users);
setAllTypes(kindRows);
}).catch(e => setError(e.message));
}, [tab, isAdmin]);
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot) : popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient() : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
const draft = useDraftForm({
formId,
entityId: popup?.item?.id,
initial,
label: t(`clinic_${popup?.kind || 'appointment'}`),
enabled: !!popup && editing,
onDiscard: () => setEditing(false)
});
const v = draft.values;
useEffect(() => {
if (quickPatientId && popup?.kind === 'appointment' && editing) {
draft.setField('patientId', quickPatientId);
setQuickPatientId(null);
}
}, [quickPatientId, popup?.kind, editing]);
useEffect(() => {
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
function open(kind, item = null, extra = {}) {
setPopup({
kind,
item,
...extra
});
setEditing(!item);
setConflict('');
setAffected([]);
setCancelMode(false);
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {});
}
function close() {
void guardedClose(editing ? draft : null, () => {
setPopup(null);
setEditing(false);
setConflict('');
});
}
async function save(confirmAffected = false) {
if (popup.item && !draft.isDirty) return;
try {
let result;
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
if (popup.kind === 'block') result = await api.clinicSaveBlock({
...v,
confirmAffected
}, popup.item?.id);
if (result?.requiresConfirmation) {
setAffected(result.affected);
return;
}
draft.clear();
if (popup.returnTo) {
setQuickPatientId(result.id);
setPopup(popup.returnTo);
setEditing(true);
} else {
setPopup(null);
setEditing(false);
}
setConflict('');
setAffected([]);
if (result?.affected?.length) setReprogram(result.affected);
await load();
if (tab === 'patients') {
const list = await api.clinicPatients(search, page);
setPatients(list.items);
}
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setConflict(e.message);
}
}
async function actionStatus(status) {
try {
await api.clinicAppointmentStatus(popup.item.id, {
status,
editVersion: popup.item.edit_version
});
setPopup(null);
await load();
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setConflict(e.message);
}
}
function cancelAppointment() {
if (!cancelReason.trim()) return;
openDialog({
tone: 'warning',
title: t('clinic_cancel'),
message: t('clinic_cancel_confirm'),
actions: [{
label: t('common_cancel')
}, {
label: t('clinic_cancel'),
variant: 'danger',
onClick: async () => {
try {
await api.clinicCancelAppointment(popup.item.id, {
reason: cancelReason,
editVersion: popup.item.edit_version
});
setPopup(null);
setCancelMode(false);
await load();
} catch (e) {
setConflict(e.message);
}
}
}]
});
}
const weekDoctorId = singleDoctor;
function appointmentMenu(item) {
const actions = [{
label: t('common_close')
}];
if (isReception) {
actions.push({
label: t('clinic_confirm'),
onClick: () => api.clinicAppointmentStatus(item.id, {
status: 'confirmed',
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
actions.push({
label: t('clinic_reprogram'),
onClick: () => {
open('appointment', item);
setEditing(true);
}
});
actions.push({
label: t('clinic_cancel'),
variant: 'danger',
onClick: () => {
open('appointment', item);
setCancelMode(true);
}
});
}
for (const status of ['attended', 'no_show']) actions.push({
label: t(`clinic_status_${status}`),
onClick: () => api.clinicAppointmentStatus(item.id, {
status,
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
openDialog({
title: `${item.first_names} ${item.last_names}`,
message: item.starts_at,
actions
});
}
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status) && (view !== 'week' || a.doctor_user_id === weekDoctorId));
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id) && (view !== 'week' || b.doctor_user_id === weekDoctorId));
const renderEvent = a => <button key={`a-${a.id}`} className="clinic-event appointment" style={{
borderLeftColor: a.type_color
}} onClick={() => open('appointment', a)} onContextMenu={e => {
e.preventDefault();
appointmentMenu(a);
}} title={`${a.first_names} ${a.last_names}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</button>;
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={() => open('block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}</button>;
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_notes'), popup.item.notes]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end]] : [];
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
const hourRange = (() => {
let lo = 24, hi = 0;
for (const d of view === 'week' ? doctors.filter(x => x.id === weekDoctorId) : selectedDoctors) for (const seg of d.schedules || []) {
lo = Math.min(lo, Number(String(seg.starts_time).slice(0, 2)));
hi = Math.max(hi, Math.ceil(Number(String(seg.ends_time).slice(0, 2)) + Number(String(seg.ends_time).slice(3, 5)) / 60));
}
for (const x of [...visible.map(a => [a.starts_at, a.ends_at]), ...visibleBlocks.map(b => [b.occurrence_start, b.occurrence_end])]) {
lo = Math.min(lo, Number(String(x[0]).slice(11, 13)));
hi = Math.max(hi, Math.min(24, Math.ceil(Number(String(x[1]).slice(11, 13)) + Number(String(x[1]).slice(14, 16)) / 60)));
}
if (lo >= hi) [lo, hi] = [7, 19];
return Array.from({ length: hi - lo }, (_, i) => lo + i);
})();
return <div className="clinic-page"><div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div><div className="toolbar-left">{isReception && tab === 'agenda' && <Button variant="accent" icon={Plus} onClick={() => open('appointment', null, {
doctorId: singleDoctor
})}>{t('clinic_new_appointment')}</Button>}{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, {
doctorId: singleDoctor
})}>{t('clinic_new_block')}</Button>}</div></div>
{error && <InfoBar tone="error">{error}</InfoBar>}{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>}
<Tabs tabs={[...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])].map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => {
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null;
void guardedClose(current, () => setTab(key));
}} />
{tab === 'agenda' && <><div className="clinic-controls"><Button icon={ChevronLeft} onClick={() => setDay(move(day, view === 'week' ? -7 : -1))}>{t('calendar_previous')}</Button><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /><Button icon={ChevronRight} onClick={() => setDay(move(day, view === 'week' ? 7 : 1))}>{t('calendar_next')}</Button><ComboBox value={view} onChange={setView} options={['day', 'week', 'list'].map(x => ({
value: x,
label: t(`clinic_view_${x}`)
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show'].map(x => ({
value: x,
label: t(`clinic_status_${x}`)
}))} values={statusFilter} onChange={setStatusFilter} /></div>
{view === 'day' ? <div className="clinic-day-grid">{selectedDoctors.map(d => <div className="clinic-doctor" key={d.id}><h3 style={{
borderTop: `3px solid ${d.color || 'rgb(var(--accent))'}`
}}>{d.name}</h3>{hourRange.map(h => {
const hour = String(h).padStart(2, '0'),
start = at(day, `${hour}:00`),
end = plus(start, 60),
weekday = (new Date(`${day}T12:00:00Z`).getUTCDay() + 6) % 7,
working = d.schedules?.some(s => s.weekday === weekday && `${day} ${s.starts_time}` <= start && `${day} ${s.ends_time}` >= end);
const blocked = visibleBlocks.some(b => b.doctor_user_id === d.id && b.occurrence_start < end && b.occurrence_end > start);
return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: d.id,
slot: start
})}><small>{hour}:00</small>{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
length: 7
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{d} · {doctors.find(x => x.id === weekDoctorId)?.name}</h3>{hourRange.map(h => {
const start = at(d, `${String(h).padStart(2, '0')}:00`),
end = plus(start, 60),
doctor = doctors.find(x => x.id === weekDoctorId),
weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7,
working = doctor?.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end);
const blocked = visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start);
return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: weekDoctorId,
slot: start
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
})}</div>)}</div> : <div className="clinic-list">{[...visible.map(a => ({
start: a.starts_at,
node: renderEvent(a)
})), ...visibleBlocks.map(b => ({
start: b.occurrence_start,
node: renderBlock(b)
}))].sort((a, b) => a.start.localeCompare(b.start)).map((x, i) => <div key={i}><strong>{x.start.slice(0, 10)}</strong>{x.node}</div>)}</div>}</>}
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value);
setPage(1);
}} /></Field><div className="clinic-list">{patients.map(p => <button key={p.id} className="clinic-row" onClick={() => open('patient', p)}>{p.first_names} {p.last_names} · {p.phone}</button>)}</div><div className="toolbar-left"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button>{page} / {Math.max(1, Math.ceil(patientTotal / 20))}<Button disabled={page * 20 >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div></>}
{tab === 'blocks' && <div className="clinic-list">{visibleBlocks.map(b => <div key={`${b.id}-${b.occurrence_start}`}>{renderBlock(b)}</div>)}</div>}
{tab === 'config' && isAdmin && <div className="clinic-config"><h3>{t('clinic_staff')}</h3><ComboBox value={staffUser} onChange={x => {
setStaffUser(x);
const s = staffRows.find(r => r.user_id === Number(x));
setStaffInitial(s ? {
scope: s.scope,
isDoctor: !!s.is_doctor,
specialty: s.specialty || '',
color: s.color || '#0078d4',
isActive: !!s.is_active,
editVersion: s.edit_version
} : {
scope: 'own',
isDoctor: true,
specialty: '',
color: '#0078d4',
isActive: true
});
}} options={[{
value: '',
label: t('common_select')
}, ...allUsers.filter(u => u.is_active).map(u => ({
value: u.id,
label: u.name
}))]} /><Field label={t('clinic_user_id')}><input className="input" type="number" value={staffUser} onChange={e => setStaffUser(e.target.value)} /></Field><ComboBox value={staffForm.scope} onChange={x => setStaffForm({
...staffForm,
scope: x
})} options={['all', 'own'].map(x => ({
value: x,
label: t(`clinic_scope_${x}`)
}))} /><label><input type="checkbox" checked={staffForm.isDoctor} onChange={e => setStaffForm({
...staffForm,
isDoctor: e.target.checked
})} />{t('clinic_is_doctor')}</label><label><input type="checkbox" checked={staffForm.isActive} onChange={e => setStaffForm({
...staffForm,
isActive: e.target.checked
})} />{t('clinic_active')}</label><Field label={t('clinic_specialty')}><input className="input" value={staffForm.specialty} onChange={e => setStaffForm({
...staffForm,
specialty: e.target.value
})} /></Field><Field label={t('clinic_color')}><input type="color" value={staffForm.color} onChange={e => setStaffForm({
...staffForm,
color: e.target.value
})} /></Field><Button disabled={!staffUser || !!staffForm.editVersion && !staffDraft.isDirty} onClick={async () => {
try {
const saved = await api.clinicSaveStaff(staffUser, staffForm);
staffDraft.clear();
setStaffInitial({
scope: saved.scope,
isDoctor: !!saved.is_doctor,
specialty: saved.specialty || '',
color: saved.color || 'rgb(var(--accent))',
isActive: !!saved.is_active,
editVersion: saved.edit_version
});
setStaffRows(await api.clinicStaff());
setDoctors((await api.clinicDoctors()).doctors);
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button>
<h3>{t('clinic_types')}</h3><ComboBox value={typeForm.id || ''} onChange={x => {
const row = allTypes.find(r => r.id === Number(x));
setTypeInitial(row ? {
id: row.id,
name: row.name,
durationMinutes: row.duration_minutes,
color: row.color,
isActive: !!row.is_active,
editVersion: row.edit_version
} : {
name: '',
durationMinutes: 30,
color: '#0078d4',
isActive: true
});
}} options={[{
value: '',
label: t('clinic_new_type')
}, ...allTypes.map(x => ({
value: x.id,
label: x.name
}))]} /><Field label={t('clinic_name')}><input className="input" value={typeForm.name} onChange={e => setTypeForm({
...typeForm,
name: e.target.value
})} /></Field><Field label={t('clinic_duration')}><input className="input" type="number" value={typeForm.durationMinutes} onChange={e => setTypeForm({
...typeForm,
durationMinutes: Number(e.target.value)
})} /></Field><input type="color" value={typeForm.color} onChange={e => setTypeForm({
...typeForm,
color: e.target.value
})} /><label><input type="checkbox" checked={typeForm.isActive} onChange={e => setTypeForm({
...typeForm,
isActive: e.target.checked
})} />{t('clinic_active')}</label><Button disabled={!typeForm.name?.trim() || !!typeForm.editVersion && !typeDraft.isDirty} onClick={async () => {
try {
const saved = await api.clinicSaveType(typeForm, typeForm.id);
typeDraft.clear();
setTypeInitial({
id: saved.id,
name: saved.name,
durationMinutes: saved.duration_minutes,
color: saved.color,
isActive: !!saved.is_active,
editVersion: saved.edit_version
});
const data = await api.clinicDoctors();
setTypes(data.types);
setAllTypes(await api.clinicTypes());
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button>
<h3>{t('clinic_schedules')}</h3><ComboBox value={scheduleDoctor} onChange={async x => {
setScheduleDoctor(x);
setScheduleInitial({
rows: x ? await api.clinicSchedules(x) : []
});
}} options={[{
value: '',
label: t('common_select')
}, ...doctors.map(d => ({
value: d.id,
label: d.name
}))]} />{scheduleRows.map((s, i) => <div className="clinic-schedule-row" key={i}><ComboBox value={s.weekday} onChange={x => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
weekday: Number(x)
} : r))} options={['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((x, n) => ({
value: n,
label: t(`calendar_${x}`)
}))} /><input type="time" value={s.startsTime || s.starts_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
startsTime: e.target.value
} : r))} /><input type="time" value={s.endsTime || s.ends_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
endsTime: e.target.value
} : r))} /><Button onClick={() => setScheduleRows(scheduleRows.filter((_, j) => j !== i))}>{t('common_delete')}</Button></div>)}<Button onClick={() => setScheduleRows([...scheduleRows, {
weekday: 0,
startsTime: '09:00',
endsTime: '17:00'
}])}>{t('calendar_add')}</Button><Button disabled={!scheduleDoctor || !scheduleDraft.isDirty} onClick={async () => {
try {
await api.clinicSaveSchedules(scheduleDoctor, {
segments: scheduleRows.map(s => ({
weekday: s.weekday,
startsTime: s.startsTime || s.starts_time,
endsTime: s.endsTime || s.ends_time
}))
});
const data = await api.clinicDoctors();
setDoctors(data.doctors);
scheduleDraft.clear();
setScheduleInitial({
rows: await api.clinicSchedules(scheduleDoctor)
});
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button></div>}
<DetailPopup open={!!popup} title={t(`clinic_${popup?.kind || 'appointment'}`)} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">{conflict && <InfoBar tone="error">{conflict}</InfoBar>}{affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}{editing && <DraftNotice draft={draft} />} {!editing ? <><div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>{cancelMode && <Field label={t('clinic_cancel_reason')}><input className="input" value={cancelReason} onChange={e => setCancelReason(e.target.value)} /></Field>}{cancelMode && <Button variant="danger" disabled={!cancelReason.trim()} onClick={cancelAppointment}>{t('clinic_cancel')}</Button>}{popup.kind === 'appointment' && <div className="toolbar-left">{isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button onClick={() => setEditing(true)}>{t('clinic_reprogram')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setCancelMode(true)}>{t('clinic_cancel')}</Button></>}{['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</Button>)}</div>}{popup.kind === 'patient' && isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><h3>{t('clinic_history')}</h3>{patientHistory.map(a => <p key={a.id}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</p>)}</>}{popup.kind === 'block' && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button variant="danger" onClick={() => openDialog({
tone: 'warning',
title: t('clinic_delete_block'),
message: t('clinic_delete_block_confirm'),
actions: [{
label: t('common_cancel')
}, {
label: t('common_delete'),
variant: 'danger',
onClick: async () => {
try {
await api.clinicDeleteBlock(popup.item.id, popup.item.edit_version);
setPopup(null);
await load();
} catch (e) {
setConflict(e.message);
}
}
}]
})}>{t('common_delete')}</Button></>}</> : <>
{popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value);
api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {});
}} /></Field><ComboBox value={v.patientId} onChange={x => draft.setField('patientId', x)} options={[{
value: '',
label: t('common_select')
}, ...patients.map(p => ({
value: p.id,
label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}`
}))]} /><Button onClick={() => open('patient', null, {
returnTo: popup
})}>{t('clinic_new_patient')}</Button><Field label={t('clinic_doctor')}><ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} /></Field><Field label={t('clinic_type')}><ComboBox value={v.typeId} onChange={x => {
const ty = types.find(z => z.id === Number(x));
draft.setValues(old => ({
...old,
typeId: x,
endsAt: plus(old.startsAt, ty?.duration_minutes || 30)
}));
}} options={types.map(z => ({
value: z.id,
label: z.name
}))} /></Field><Field label={t('calendar_start')}><DateTimeEditor value={v.startsAt} onChange={x => draft.setValues(old => ({
...old,
startsAt: x,
endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30)
}))} label={t('calendar_start')} /></Field><Field label={t('calendar_end')}><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /></Field><Field label={t('clinic_channel')}><ComboBox value={v.channel} onChange={x => draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map(x => ({
value: x,
label: t(`clinic_channel_${x}`)
}))} /></Field><Field label={t('clinic_notes')}><textarea className="textarea" value={v.notes} onChange={e => draft.setField('notes', e.target.value)} /></Field><Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map(s => <Button key={s.startsAt} size="sm" onClick={() => draft.setValues(old => ({
...old,
startsAt: s.startsAt,
endsAt: s.endsAt
}))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field></>}
{popup.kind === 'patient' && [['firstNames', 'clinic_first_names'], ['lastNames', 'clinic_last_names'], ['identification', 'clinic_identification'], ['phone', 'clinic_phone'], ['email', 'clinic_email'], ['birthDate', 'clinic_birth_date'], ['notes', 'clinic_notes']].map(([k, label]) => <Field key={k} label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={e => draft.setField(k, e.target.value)} /></Field>)}
{popup.kind === 'block' && <><Field label={t('clinic_doctor')}>{popup.item ? <strong>{doctors.find(d => d.id === v.doctorId)?.name}</strong> : <ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} />}</Field><Field label={t('clinic_kind')}><ComboBox value={v.kind} onChange={x => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map(x => ({
value: x,
label: t(`clinic_kind_${x}`)
}))} /></Field><Field label={t('clinic_title')}><input className="input" value={v.title} onChange={e => draft.setField('title', e.target.value)} /></Field><DateTimeEditor value={v.startsAt} onChange={x => draft.setField('startsAt', x)} label={t('calendar_start')} /><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /><RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x => draft.setField('recurrence', x)} /></>}
<div className="toolbar-left"><Button variant="accent" disabled={!!popup.item && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={() => {
draft.discard();
if (!popup.item) setPopup(null);else setEditing(false);
}}>{t('common_cancel')}</Button></div></>}</div>}</DetailPopup></div>;
}
@@ -0,0 +1,8 @@
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-tabs,.clinic-controls,.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-day-grid{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(170px,1fr);overflow-x:auto;gap:8px}.clinic-doctor{background:rgb(var(--surface));border:1px solid rgb(var(--line));border-radius:var(--r-lg);min-width:170px}.clinic-doctor h3{position:sticky;top:0;background:rgb(var(--surface));padding:10px;margin:0;font-size:.9rem;z-index:1}.clinic-hour{min-height:60px;border-top:1px solid rgb(var(--line));padding:4px;cursor:pointer}.clinic-hour.closed{background:rgb(var(--muted)/.12);cursor:default}.clinic-hour small{display:block;color:rgb(var(--muted));margin-bottom:4px}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-day-grid{grid-auto-columns:minmax(220px,80vw)}.clinic-controls>*{max-width:100%}.clinic-detail{grid-template-columns:1fr}}
.clinic-week-grid{display:grid;grid-template-columns:repeat(7,minmax(165px,1fr));gap:6px;overflow-x:auto}.clinic-week-grid .clinic-doctor{min-width:165px}@media(max-width:900px){.clinic-week-grid{grid-template-columns:repeat(7,165px)}}
.clinic-controls .datepicker-trigger{flex:0 0 190px;width:190px}
.clinic-controls .combobox{flex:0 0 auto;min-width:150px}
/* Hora ocupada por un bloqueo (quirofano, vacaciones...): no se puede agendar. */
.clinic-hour.blocked{background:repeating-linear-gradient(135deg,rgb(var(--warning)/.10) 0 8px,transparent 8px 16px);cursor:not-allowed}
@@ -11,7 +11,8 @@ import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { 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 <div className="meeting-datetime"><DatePicker value={date} onChange={setDate} label={label} />{!allDay && <><ComboBox options={hours} value={hour} onChange={(h) => setTime(h,minute)} placeholder={`${label} HH`} /><span>:</span><ComboBox options={minutes} value={minute} onChange={(m) => setTime(hour,m)} placeholder={`${label} MM`} /></>}</div>;
}
function bounds(cursor, view) {
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 <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}${item.overdue ? ' overdue' : ''}${item.kind === 'recurring' && !item.is_next ? ' projected' : ''}${item.is_closed ? ' done' : ''}${item.shared ? ' shared' : ''}`} title={item.kind === 'meeting' ? item.title : `${item.name}${item.project_name ? ` · ${item.project_name}` : ''}${item.overdue ? ` (${t('calendar_overdue')})` : ''}`}
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === 'recurring') openRecurring(item); else if (item.kind === 'project') navigate(`/proyectos/${item.id}`); else if (item.kind === 'task') navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.activity_id}`); else navigate(`/proyectos/${item.project_id}?tab=activities&activity=${item.id}`); }}
function itemButton(item) { return <button key={`${item.kind}-${item.id}-${item.occurrence_start || ''}`} className={`calendar-entry ${item.kind}${item.overdue ? ' overdue' : ''}${item.kind === 'recurring' && !item.is_next ? ' projected' : ''}${item.is_closed ? ' done' : ''}${item.shared ? ' shared' : ''}`} title={item.kind === 'meeting' ? item.title : item.kind === 'clinic' ? item.clinicLabel : `${item.name}${item.project_name ? ` · ${item.project_name}` : ''}${item.overdue ? ` (${t('calendar_overdue')})` : ''}`}
onClick={(e) => { e.stopPropagation(); if (item.kind === 'meeting') void openMeeting(item); else if (item.kind === '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' && <span className="calendar-entry-more" onClick={(e) => { e.stopPropagation(); setContext({ item, x: e.clientX, y: e.clientY }); }}>⋯</span>}
</button>; }
@@ -237,9 +236,7 @@ export default function CalendarioPage() {
<Field label={t('calendar_participants')}><><div className="toolbar-left"><ComboBox options={[{value:'',label:t('common_select')},...userOptions]} value={userChoice} onChange={setUserChoice} /><Button onClick={() => { if (userChoice) { draft.setField('participantUserIds',[...v.participantUserIds,Number(userChoice)]); setUserChoice(''); } }}>{t('calendar_add')}</Button></div>
<div className="calendar-tags">{v.participantUserIds.map((id) => <button key={id} onClick={() => draft.setField('participantUserIds',v.participantUserIds.filter((x) => x!==id))}>{users?.find((u) => u.id===id)?.name || id} ×</button>)}</div></></Field>
<Field label={t('calendar_external')}><><div className="toolbar-left"><input className="input" type="email" value={externalInput} onChange={(e) => setExternalInput(e.target.value)} onKeyDown={(e) => { if(e.key==='Enter'){ e.preventDefault(); addExternal(); } }} /><Button onClick={addExternal}>{t('calendar_add')}</Button></div><div className="calendar-tags">{v.externalEmails.map((e) => <button key={e} onClick={() => draft.setField('externalEmails',v.externalEmails.filter((x) => x!==e))}>{e} ×</button>)}</div></></Field>
<Field label={t('calendar_recurrence')}><ComboBox options={['none','daily','weekly','monthly'].map((x) => ({value:x,label:t(`calendar_${x}`)}))} value={v.recurrence?.frequency || 'none'} onChange={(x) => draft.setField('recurrence',x==='none'?null:{frequency:x,interval:1,weekdays:x==='weekly'?[(new Date(v.startsAt.replace(' ','T')).getDay()+6)%7]:[],count:10})} /></Field>
{v.recurrence && <><FieldRow><Field label={t('calendar_interval')}><input className="input" type="number" min="1" max="365" value={v.recurrence.interval} onChange={(e) => draft.setField('recurrence',{...v.recurrence,interval:Number(e.target.value)})} /></Field><Field label={t('calendar_count')}><input className="input" type="number" min="1" max="365" value={v.recurrence.count || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,count:e.target.value?Number(e.target.value):null,until:null})} /></Field><Field label={t('calendar_until')}><input className="input" type="date" value={v.recurrence.until || ''} onChange={(e) => draft.setField('recurrence',{...v.recurrence,until:e.target.value||null,count:null})} /></Field></FieldRow>
{v.recurrence.frequency==='weekly' && <div className="calendar-weekdays">{weekLabels.map((label,index) => <label key={label}><input type="checkbox" checked={v.recurrence.weekdays.includes(index)} onChange={() => draft.setField('recurrence',{...v.recurrence,weekdays:v.recurrence.weekdays.includes(index)?v.recurrence.weekdays.filter((x)=>x!==index):[...v.recurrence.weekdays,index]})} /> {label}</label>)}</div>}</>}
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x=>draft.setField('recurrence',x)}/>
<div className="toolbar-left" style={{ marginTop:18 }}><Button variant="accent" onClick={save} disabled={!v.title.trim() || (!creating && !draft.isDirty)}>{t('common_save')}</Button><Button onClick={() => { draft.discard(); if (creating) setCreating(false); else setEditing(false); }}>{t('common_cancel')}</Button></div>
</div>}
</DetailPopup>
@@ -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)); }
+21
View File
@@ -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);
+6
View File
@@ -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'],
+1
View File
@@ -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',