feat: version 4.9.0 -- Agenda medica: clic en dia abre cita, autor fijo y borrado logico

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 07:57:36 -05:00
parent 00aaf1ebef
commit 366447dffc
17 changed files with 162 additions and 56 deletions
+2 -1
View File
@@ -45,8 +45,9 @@ Configuración exige además rol admin/superadmin. Fechas `YYYY-MM-DD HH:mm:ss`
| `GET /staff/me` | alcance y condición de médico | | `GET /staff/me` | alcance y condición de médico |
| `GET /doctors` | médicos visibles con horario y tipos activos | | `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 /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 | | `GET /appointments?from=&to=[&doctorId=][&patientId=]` | citas del rango, incluidas canceladas y eliminadas (`deleted_at`) para trazabilidad; `patientId` solo recepción |
| `POST /appointments`, `PUT /appointments/:id` | crear o reprogramar/editar; `PUT` exige `editVersion` | | `POST /appointments`, `PUT /appointments/:id` | crear o reprogramar/editar; `PUT` exige `editVersion` |
| `POST /appointments/:id/delete` | `{reason,editVersion}` borrado lógico (solo recepción): libera el horario, conserva la fila con quién/cuándo/motivo |
| `POST /appointments/:id/status` | `{status,editVersion}`; médico propio solo `attended`/`no_show` | | `POST /appointments/:id/status` | `{status,editVersion}`; médico propio solo `attended`/`no_show` |
| `POST /appointments/:id/cancel` | `{reason,editVersion}`, solo recepción | | `POST /appointments/:id/cancel` | `{reason,editVersion}`, solo recepción |
| `GET /blocks?from=&to=[&doctorId=]` | ocurrencias expandidas del rango | | `GET /blocks?from=&to=[&doctorId=]` | ocurrencias expandidas del rango |
+2 -2
View File
@@ -27,8 +27,8 @@ médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guaya
| `clinic_patients` | identificación única opcional, contacto mínimo, nacimiento, nota corta, 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_schedules` | tramos por médico y día de semana, lunes=0 |
| `clinic_appointment_types` | nombre, duración, color y estado | | `clinic_appointment_types` | nombre, duración, color y estado |
| `clinic_appointments` | cita, médico, paciente, tipo, estado, canal, cancelación y versión | | `clinic_appointments` | cita, médico, paciente, tipo, estado, canal, cancelación y versión; autor fijo (`created_by`, `created_by_kind` user/ai_agent, `created_by_name`) y borrado lógico (`deleted_at/by/by_name/reason`) — migración 037 |
| `clinic_blocks` | bloqueo de médico, clase y recurrencia con las reglas de reuniones | | `clinic_blocks` | bloqueo de médico, clase y recurrencia con las reglas de reuniones; `created_by_name` y borrado lógico (`deleted_at/by/by_name`) |
La reserva bloquea la fila `clinic_staff` del médico y la del paciente antes de verificar choques; 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 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
+14
View File
@@ -1,5 +1,19 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [4.9.0] - 2026-10-29
### Changed
- Agenda médica: clic en un día del mes (o en una hora libre de Semana/Día) abre directamente el
formulario de cita nueva con esa fecha; clic en una cita la abre en modo lectura con **Editar**.
Antes el clic en el día saltaba a la vista Día. "+N más" sí lleva al día.
### Added
- Agenda médica: cada cita guarda quién la agendó (usuario, o "Agente IA" para el futuro canal de
WhatsApp) y no cambia al editarla; se muestra en el detalle y en el historial del paciente.
- Agenda médica: **Eliminar cita** con motivo, como borrado lógico (libera el horario pero la cita
queda registrada con quién, cuándo y por qué). Los bloqueos también se eliminan de forma lógica.
El filtro Estado permite ver Canceladas y Eliminadas. Migración 037.
### Fixed
- Agenda médica: clic en una cita dentro de Semana/Día ya no abre además una cita nueva encima.
## [4.8.1] - 2026-10-28 ## [4.8.1] - 2026-10-28
### Fixed ### Fixed
- Agenda médica: abre en vista **Mes** con la misma barra que el Calendario (Hoy, anterior/siguiente, - Agenda médica: abre en vista **Mes** con la misma barra que el Calendario (Hoy, anterior/siguiente,
+5
View File
@@ -6,6 +6,11 @@ autorregistro/aprobacion/SMTP.
## Agenda médica (v4.7.0, sin desplegar) ## Agenda médica (v4.7.0, sin desplegar)
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
lectura (Editar, Confirmar, Cancelar, Eliminar). Autor fijo de la cita (`created_by_kind`
user/ai_agent → "Agente IA" para el futuro WhatsApp). Borrado lógico de citas (con motivo) y
bloqueos; filtro Estado incluye Canceladas/Eliminadas. Migración 037 aplicada en dev (2 veces OK).
Probado en dev: 41/41 pruebas de API + navegador, datos temporales borrados.
- v4.8.1: abre en Mes con barra igual al Calendario; aviso si no hay médicos (en prod se veía vacío - v4.8.1: abre en Mes con barra igual al Calendario; aviso si no hay médicos (en prod se veía vacío
porque la 4.8.0 abría en Día y aún no hay médicos configurados). porque la 4.8.0 abría en Día y aún no hay médicos configurados).
- v4.8.0: vistas Mes (calendario) y Semana con todos los médicos filtrados, color por médico. Sin - v4.8.0: vistas Mes (calendario) y Semana con todos los médicos filtrados, color por médico. Sin
+1 -1
View File
@@ -1 +1 @@
4.8.1 4.9.0
@@ -0,0 +1,17 @@
-- Trazabilidad de Agenda médica: quién agendó (usuario o agente IA, fijo desde la creación) y
-- borrado lógico de citas y bloqueos (nunca se borran filas).
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS created_by_kind ENUM('user','ai_agent') NOT NULL DEFAULT 'user' AFTER created_by;
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS created_by_name VARCHAR(160) NULL AFTER created_by_kind;
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER cancelled_reason;
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_by INT NULL AFTER deleted_at;
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_by_name VARCHAR(160) NULL AFTER deleted_by;
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS deleted_reason VARCHAR(500) NULL AFTER deleted_by_name;
ALTER TABLE clinic_appointments ADD COLUMN IF NOT EXISTS cancelled_by_name VARCHAR(160) NULL AFTER cancelled_by;
UPDATE clinic_appointments a JOIN users u ON u.id = a.created_by SET a.created_by_name = u.name WHERE a.created_by_name IS NULL;
UPDATE clinic_appointments a JOIN users u ON u.id = a.cancelled_by SET a.cancelled_by_name = u.name WHERE a.cancelled_by_name IS NULL;
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS created_by_name VARCHAR(160) NULL AFTER created_by;
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS deleted_at DATETIME NULL AFTER edit_version;
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS deleted_by INT NULL AFTER deleted_at;
ALTER TABLE clinic_blocks ADD COLUMN IF NOT EXISTS deleted_by_name VARCHAR(160) NULL AFTER deleted_by;
UPDATE clinic_blocks b JOIN users u ON u.id = b.created_by SET b.created_by_name = u.name WHERE b.created_by_name IS NULL;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.8.1", "version": "4.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.8.1", "version": "4.9.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.8.1", "version": "4.9.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+1
View File
@@ -39,6 +39,7 @@ get('/appointments', (q, s) => clinic.appointments(q, s));
write('post', '/appointments', (body, user, staff) => clinic.saveAppointment(body, user, staff)); 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('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/cancel', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff, true));
write('post', '/appointments/:id/delete', (body, user, staff, n) => clinic.deleteAppointment(n, body, user, staff));
write('post', '/appointments/:id/status', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff)); write('post', '/appointments/:id/status', (body, user, staff, n) => clinic.changeStatus(n, body, user, staff));
get('/blocks', (q, s) => clinic.blocks(q, s)); get('/blocks', (q, s) => clinic.blocks(q, s));
write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, staff)); write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, staff));
+31 -12
View File
@@ -57,11 +57,13 @@ export async function schedules(doctorId) {
export async function types() { export async function types() {
return exec(pool, 'SELECT * FROM clinic_appointment_types ORDER BY name'); return exec(pool, 'SELECT * FROM clinic_appointment_types ORDER BY name');
} }
function selectAppointments(db, from, to, doctorId, patientId, excludeId) { // Por defecto solo citas vigentes (las que ocupan horario); con all=true tambien canceladas y
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] : [])]); // eliminadas, para mostrarlas con su trazabilidad.
function selectAppointments(db, from, to, doctorId, patientId, excludeId, all = false) {
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 ${all ? '1=1' : "a.status<>'cancelled' AND a.deleted_at IS NULL"} 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) { 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]); const rows = await exec(db, 'SELECT * FROM clinic_blocks WHERE doctor_user_id=? AND starts_at<? AND deleted_at IS NULL', [doctorId, to]);
return rows.flatMap(r => expand(r, from, to)); return rows.flatMap(r => expand(r, from, to));
} }
export async function appointments(q, staff) { export async function appointments(q, staff) {
@@ -70,7 +72,7 @@ export async function appointments(q, staff) {
if (doctorId) doctorAllowed(staff, doctorId); if (doctorId) doctorAllowed(staff, doctorId);
const patientId = q.patientId ? id(q.patientId) : null; const patientId = q.patientId ? id(q.patientId) : null;
if (patientId && staff.scope !== 'all') throw new HttpError(403, 'Solo recepción consulta el historial'); 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); return selectAppointments(pool, from, to, staff.scope === 'own' ? staff.user_id : doctorId, patientId, null, true);
} }
export async function blocks(q, staff) { export async function blocks(q, staff) {
const [from, to] = range(q); const [from, to] = range(q);
@@ -113,7 +115,7 @@ export async function patientHistory(patientId, staff) {
const n = id(patientId); const n = id(patientId);
const [patient] = await exec(pool, 'SELECT id FROM clinic_patients WHERE id=?', [n]); const [patient] = await exec(pool, 'SELECT id FROM clinic_patients WHERE id=?', [n]);
if (!patient) throw new HttpError(404, 'Paciente no encontrado'); 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]); return exec(pool, 'SELECT a.id,a.starts_at,a.ends_at,a.status,a.channel,a.created_by_kind,a.created_by_name,a.deleted_at,a.deleted_by_name,a.deleted_reason,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) { export async function savePatient(raw, user, staff, patientId = null) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes'); if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción gestiona pacientes');
@@ -179,6 +181,7 @@ export async function saveAppointment(raw, user, staff, appointmentId = null) {
if (appointmentId) { if (appointmentId) {
[before] = await exec(db, 'SELECT * FROM clinic_appointments WHERE id=?', [id(appointmentId)]); [before] = await exec(db, 'SELECT * FROM clinic_appointments WHERE id=?', [id(appointmentId)]);
if (!before) throw new HttpError(404, 'Cita no encontrada'); if (!before) throw new HttpError(404, 'Cita no encontrada');
if (before.deleted_at) throw new HttpError(409, 'Cita eliminada');
if (before.status === 'cancelled') throw new HttpError(409, 'Cita cancelada'); 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'); 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. } // Se bloquean filas de médicos en orden para mover entre dos agendas.
@@ -190,7 +193,7 @@ export async function saveAppointment(raw, user, staff, appointmentId = null) {
} }
await validateSlot(db, d, before?.id); await validateSlot(db, d, before?.id);
let result; 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 = 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,created_by_kind,created_by_name) VALUES (?,?,?,?,?,?,?,?,'user',?)", [d.doctorId, d.patientId, d.typeId, d.start, d.end, d.channel, d.notes, user.id, user.name]);
if (before && !result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona'); if (before && !result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
const newId = before?.id || result.insertId; const newId = before?.id || result.insertId;
await audit(user, before ? 'update' : 'create', 'clinic_appointment', newId, db); await audit(user, before ? 'update' : 'create', 'clinic_appointment', newId, db);
@@ -211,13 +214,28 @@ export async function changeStatus(appointmentId, raw, user, staff, cancel = fal
const status = cancel ? 'cancelled' : raw.status; 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') 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 (!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.deleted_at) throw new HttpError(409, 'Cita eliminada');
if (row.status === 'cancelled') throw new HttpError(409, 'Cita cancelada'); if (row.status === 'cancelled') throw new HttpError(409, 'Cita cancelada');
const editVersion = version(raw.editVersion); const editVersion = version(raw.editVersion);
if (!cancel && row.status === status && Number(row.edit_version) === editVersion) return row; if (!cancel && row.status === status && Number(row.edit_version) === editVersion) return row;
const reason = cancel ? text(raw.reason, 500, true) : null; 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]); const result = await exec(pool, 'UPDATE clinic_appointments SET status=?,cancelled_at=?,cancelled_by=?,cancelled_by_name=?,cancelled_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=?', [status, cancel ? guayaquilNow() : null, cancel ? user.id : null, cancel ? user.name : null, reason, n, editVersion]);
if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona'); if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
await audit(user, cancel ? 'cancel' : 'status', 'clinic_appointment', n); await logAudit({ userId: user.id, action: cancel ? 'cancel' : 'status', entityType: 'clinic_appointment', entityId: n, detail: cancel ? { reason } : { from: row.status, to: status } });
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0];
}
// Borrado logico: la cita deja de ocupar horario y de verse por defecto, pero queda con quien la
// elimino, cuando y por que (y su autor original intacto).
export async function deleteAppointment(appointmentId, raw, user, staff) {
if (staff.scope !== 'all') throw new HttpError(403, 'Solo recepción elimina citas');
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');
if (row.deleted_at) throw new HttpError(409, 'Cita ya eliminada');
const reason = text(raw.reason, 500, true);
const result = await exec(pool, 'UPDATE clinic_appointments SET deleted_at=?,deleted_by=?,deleted_by_name=?,deleted_reason=?,edit_version=edit_version+1 WHERE id=? AND edit_version=? AND deleted_at IS NULL', [guayaquilNow(), user.id, user.name, reason, n, version(raw.editVersion)]);
if (!result.affectedRows) throw new HttpError(409, 'Cita modificada por otra persona');
await logAudit({ userId: user.id, action: 'delete', entityType: 'clinic_appointment', entityId: n, detail: { reason } });
return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0]; return (await exec(pool, 'SELECT * FROM clinic_appointments WHERE id=?', [n]))[0];
} }
export async function availability(q, staff) { export async function availability(q, staff) {
@@ -270,7 +288,7 @@ export async function saveBlock(raw, user, staff, blockId = null) {
if (!doctor) throw new HttpError(400, 'Médico no disponible'); if (!doctor) throw new HttpError(400, 'Médico no disponible');
let before = null; let before = null;
if (blockId) { if (blockId) {
[before] = await exec(db, 'SELECT * FROM clinic_blocks WHERE id=?', [id(blockId)]); [before] = await exec(db, 'SELECT * FROM clinic_blocks WHERE id=? AND deleted_at IS NULL', [id(blockId)]);
if (!before || before.doctor_user_id !== doctorId) throw new HttpError(404, 'Bloqueo no encontrado'); 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'); if (Number(before.edit_version) !== version(raw.editVersion)) throw new HttpError(409, 'Bloqueo modificado por otra persona');
} }
@@ -289,7 +307,7 @@ export async function saveBlock(raw, user, staff, blockId = null) {
}; };
} }
let result; 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 = 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,created_by_name) VALUES (?,?,?,?,?,?,?,?)', [doctorId, kind, title, start, end, recurrence ? JSON.stringify(recurrence) : null, user.id, user.name]);
if (before && !result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona'); if (before && !result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
const newId = before?.id || result.insertId; const newId = before?.id || result.insertId;
await audit(user, before ? 'update' : 'create', 'clinic_block', newId, db); await audit(user, before ? 'update' : 'create', 'clinic_block', newId, db);
@@ -307,10 +325,11 @@ export async function saveBlock(raw, user, staff, blockId = null) {
} }
export async function deleteBlock(blockId, raw, user, staff) { export async function deleteBlock(blockId, raw, user, staff) {
const n = id(blockId); const n = id(blockId);
const [row] = await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=?', [n]); const [row] = await exec(pool, 'SELECT * FROM clinic_blocks WHERE id=? AND deleted_at IS NULL', [n]);
if (!row) throw new HttpError(404, 'Bloqueo no encontrado'); if (!row) throw new HttpError(404, 'Bloqueo no encontrado');
doctorAllowed(staff, row.doctor_user_id); doctorAllowed(staff, row.doctor_user_id);
const result = await exec(pool, 'DELETE FROM clinic_blocks WHERE id=? AND edit_version=?', [n, version(raw.editVersion)]); // Borrado logico: deja de bloquear horario pero la fila queda para trazabilidad.
const result = await exec(pool, 'UPDATE clinic_blocks SET deleted_at=?,deleted_by=?,deleted_by_name=?,edit_version=edit_version+1 WHERE id=? AND edit_version=? AND deleted_at IS NULL', [guayaquilNow(), user.id, user.name, n, version(raw.editVersion)]);
if (!result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona'); if (!result.affectedRows) throw new HttpError(409, 'Bloqueo modificado por otra persona');
await audit(user, 'delete', 'clinic_block', n); await audit(user, 'delete', 'clinic_block', n);
return { return {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.8.1", "version": "4.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.8.1", "version": "4.9.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.8.1", "version": "4.9.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -2
View File
@@ -21,7 +21,7 @@ export const messages = {
clinic_block: 'Bloqueo', clinic_block: 'Bloqueo',
clinic_view_day: 'Día', clinic_view_day: 'Día',
clinic_view_week: 'Semana', clinic_view_week: 'Semana',
clinic_view_list: 'Agenda', clinic_view_month: 'Mes', clinic_no_doctors: 'Aún no hay médicos configurados en la agenda. Pide a un administrador que los registre.', clinic_no_doctors_admin: 'Aún no hay médicos configurados. Regístralos con su horario en la pestaña Configuración de agenda.', clinic_more: 'más', clinic_view_list: 'Agenda', clinic_view_month: 'Mes', clinic_no_doctors: 'Aún no hay médicos configurados en la agenda. Pide a un administrador que los registre.', clinic_no_doctors_admin: 'Aún no hay médicos configurados. Regístralos con su horario en la pestaña Configuración de agenda.', clinic_more: 'más', clinic_status_deleted: 'Eliminada', clinic_delete: 'Eliminar cita', clinic_delete_reason: 'Motivo de eliminación', clinic_delete_confirm: 'La cita se marcará como eliminada: deja de ocupar el horario, pero queda registrada con el motivo para la trazabilidad.', clinic_deleted_notice: 'Cita eliminada: se conserva solo para consulta.', clinic_created_by: 'Agendado por', clinic_cancelled_by: 'Cancelada por', clinic_deleted_by: 'Eliminada por', clinic_ai_agent: 'Agente IA',
clinic_doctors: 'Médicos', clinic_doctors: 'Médicos',
clinic_doctor: 'Médico', clinic_doctor: 'Médico',
clinic_status: 'Estado', clinic_status: 'Estado',
@@ -713,7 +713,7 @@ export const messages = {
clinic_block: 'Block', clinic_block: 'Block',
clinic_view_day: 'Day', clinic_view_day: 'Day',
clinic_view_week: 'Week', clinic_view_week: 'Week',
clinic_view_list: 'Agenda', clinic_view_month: 'Month', clinic_no_doctors: 'No doctors are set up in the schedule yet. Ask an administrator to add them.', clinic_no_doctors_admin: 'No doctors are set up yet. Add them with their hours in the Schedule settings tab.', clinic_more: 'more', clinic_view_list: 'Agenda', clinic_view_month: 'Month', clinic_no_doctors: 'No doctors are set up in the schedule yet. Ask an administrator to add them.', clinic_no_doctors_admin: 'No doctors are set up yet. Add them with their hours in the Schedule settings tab.', clinic_more: 'more', clinic_status_deleted: 'Deleted', clinic_delete: 'Delete appointment', clinic_delete_reason: 'Deletion reason', clinic_delete_confirm: 'The appointment will be marked as deleted: it frees the time slot but stays recorded with the reason for traceability.', clinic_deleted_notice: 'Deleted appointment: kept for reference only.', clinic_created_by: 'Booked by', clinic_cancelled_by: 'Cancelled by', clinic_deleted_by: 'Deleted by', clinic_ai_agent: 'AI agent',
clinic_doctors: 'Doctors', clinic_doctors: 'Doctors',
clinic_doctor: 'Doctor', clinic_doctor: 'Doctor',
clinic_status: 'Status', clinic_status: 'Status',
@@ -135,8 +135,8 @@ export default function AgendaMedicaPage() {
[conflict, setConflict] = useState(''), [conflict, setConflict] = useState(''),
[affected, setAffected] = useState([]), [affected, setAffected] = useState([]),
[slots, setSlots] = useState([]), [slots, setSlots] = useState([]),
[cancelReason, setCancelReason] = useState(''), [actionReason, setActionReason] = useState(''),
[cancelMode, setCancelMode] = useState(false), [actionMode, setActionMode] = useState(null),
[patientHistory, setPatientHistory] = useState([]), [patientHistory, setPatientHistory] = useState([]),
[quickPatientId, setQuickPatientId] = useState(null), [quickPatientId, setQuickPatientId] = useState(null),
[reprogram, setReprogram] = useState([]); [reprogram, setReprogram] = useState([]);
@@ -296,7 +296,8 @@ export default function AgendaMedicaPage() {
setEditing(!item); setEditing(!item);
setConflict(''); setConflict('');
setAffected([]); setAffected([]);
setCancelMode(false); setActionMode(null);
setActionReason('');
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message)); if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {}); if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {});
} }
@@ -362,25 +363,27 @@ export default function AgendaMedicaPage() {
setConflict(e.message); setConflict(e.message);
} }
} }
function cancelAppointment() { // Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados.
if (!cancelReason.trim()) return; function reasonAction() {
if (!actionReason.trim()) return;
const remove = actionMode === 'delete';
openDialog({ openDialog({
tone: 'warning', tone: 'warning',
title: t('clinic_cancel'), title: t(remove ? 'clinic_delete' : 'clinic_cancel'),
message: t('clinic_cancel_confirm'), message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'),
actions: [{ actions: [{
label: t('common_cancel') label: t('common_cancel')
}, { }, {
label: t('clinic_cancel'), label: t(remove ? 'clinic_delete' : 'clinic_cancel'),
variant: 'danger', variant: 'danger',
onClick: async () => { onClick: async () => {
try { try {
await api.clinicCancelAppointment(popup.item.id, { await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, {
reason: cancelReason, reason: actionReason,
editVersion: popup.item.edit_version editVersion: popup.item.edit_version
}); });
setPopup(null); setPopup(null);
setCancelMode(false); setActionMode(null);
await load(); await load();
} catch (e) { } catch (e) {
setConflict(e.message); setConflict(e.message);
@@ -393,6 +396,16 @@ export default function AgendaMedicaPage() {
const actions = [{ const actions = [{
label: t('common_close') label: t('common_close')
}]; }];
if (item.deleted_at) return open('appointment', item);
if (isReception && item.status === 'cancelled') actions.push({
label: t('clinic_delete'),
variant: 'danger',
onClick: () => {
open('appointment', item);
setActionMode('delete');
}
});
if (item.status === 'cancelled') return openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
if (isReception) { if (isReception) {
actions.push({ actions.push({
label: t('clinic_confirm'), label: t('clinic_confirm'),
@@ -413,7 +426,15 @@ export default function AgendaMedicaPage() {
variant: 'danger', variant: 'danger',
onClick: () => { onClick: () => {
open('appointment', item); open('appointment', item);
setCancelMode(true); setActionMode('cancel');
}
});
actions.push({
label: t('clinic_delete'),
variant: 'danger',
onClick: () => {
open('appointment', item);
setActionMode('delete');
} }
}); });
} }
@@ -447,16 +468,39 @@ export default function AgendaMedicaPage() {
const [y, m] = day.split('-').map(Number), d = new Date(Date.UTC(y, m - 1 + n, 1)); const [y, m] = day.split('-').map(Number), d = new Date(Date.UTC(y, m - 1 + n, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-01`; return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-01`;
}; };
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(a.status)); const statusOf = a => a.deleted_at ? 'deleted' : a.status;
const visible = appointments.filter(a => selectedDoctors.some(d => d.id === a.doctor_user_id) && statusFilter.includes(statusOf(a)));
// Quien agendo: fijo desde la creacion; si fue el agente de WhatsApp dice "Agente IA".
const author = a => a.created_by_kind === 'ai_agent' ? t('clinic_ai_agent') : a.created_by_name || '—';
// Clic en un dia/hora libre: cita nueva a la primera hora de atencion de ese dia (medico preelegido
// si se mira uno solo). Sin permiso de agendar, el clic solo abre ese dia.
function newAt(d, slot) {
if (!isReception || !doctors.length) {
setDay(d);
setView('day');
return;
}
const weekday = (new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7;
const first = selectedDoctors.flatMap(doc => (doc.schedules || []).filter(seg => seg.weekday === weekday).map(seg => String(seg.starts_time).slice(0, 5))).sort()[0];
open('appointment', null, {
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined,
slot: slot || at(d, first || '09:00')
});
}
const openItem = (e, kind, item) => {
e.stopPropagation();
open(kind, item);
};
const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id)); const visibleBlocks = blocks.filter(b => selectedDoctors.some(d => d.id === b.doctor_user_id));
const renderEvent = (a, byDoctor = false) => <button key={`a-${a.id}`} className="clinic-event appointment" style={{ const renderEvent = (a, byDoctor = false) => <button key={`a-${a.id}`} className={`clinic-event appointment status-${statusOf(a)}`} style={{
borderLeftColor: byDoctor ? doctorColor(a.doctor_user_id) : a.type_color borderLeftColor: byDoctor ? doctorColor(a.doctor_user_id) : a.type_color
}} onClick={() => open('appointment', a)} onContextMenu={e => { }} onClick={e => openItem(e, 'appointment', a)} onContextMenu={e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
appointmentMenu(a); appointmentMenu(a);
}} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}>{byDoctor ? `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names.slice(0, 1)}. · ${a.type_name}` : `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${a.status}`)}`}</button>; }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${statusOf(a)}`)}`}>{byDoctor ? `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names.slice(0, 1)}. · ${a.type_name}` : `${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${a.type_name} · ${t(`clinic_status_${statusOf(a)}`)}`}</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 renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={e => openItem(e, 'block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}</button>;
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_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]] : []; const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_created_by'), `${author(popup.item)} · ${String(popup.item.created_at || '').slice(0, 16)}`], [t('clinic_notes'), popup.item.notes], [t('clinic_cancelled_by'), popup.item.cancelled_at && `${popup.item.cancelled_by_name || '—'} · ${popup.item.cancelled_at.slice(0, 16)} · ${popup.item.cancelled_reason || ''}`], [t('clinic_deleted_by'), popup.item.deleted_at && `${popup.item.deleted_by_name || '—'} · ${popup.item.deleted_at.slice(0, 16)} · ${popup.item.deleted_reason || ''}`]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end], [t('clinic_created_by'), popup.item.created_by_name]] : [];
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>; if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado // Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00. // con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
@@ -489,7 +533,7 @@ export default function AgendaMedicaPage() {
}))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({ }))} /><CheckListDropdown label={t('clinic_doctors')} options={doctors.map(d => ({
value: d.id, value: d.id,
label: d.name label: d.name
}))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show'].map(x => ({ }))} values={doctorFilter} onChange={setDoctorFilter} /><CheckListDropdown label={t('clinic_status')} options={['scheduled', 'confirmed', 'attended', 'no_show', 'cancelled', 'deleted'].map(x => ({
value: x, value: x,
label: t(`clinic_status_${x}`) label: t(`clinic_status_${x}`)
}))} values={statusFilter} onChange={setStatusFilter} /><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /></div></div> }))} values={statusFilter} onChange={setStatusFilter} /><DatePicker value={day} onChange={setDay} label={t('calendar_date')} /></div></div>
@@ -507,7 +551,7 @@ export default function AgendaMedicaPage() {
return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, { return <div key={hour} className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} onClick={() => working && !blocked && isReception && open('appointment', null, {
doctorId: d.id, doctorId: d.id,
slot: start 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>; })} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}><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({ })}</div>)}</div> : view === 'week' ? <div className="clinic-week-grid">{Array.from({
length: 7 length: 7
}, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}</h3>{hourRange.map(h => { }, (_, i) => move(period.from.slice(0, 10), i)).map(d => <div className="clinic-doctor" key={d}><h3>{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}</h3>{hourRange.map(h => {
@@ -518,14 +562,11 @@ export default function AgendaMedicaPage() {
// solo aplica cuando se mira un medico (con varios, cada bloqueo se ve como entrada). // solo aplica cuando se mira un medico (con varios, cada bloqueo se ve como entrada).
working = selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end)), working = selectedDoctors.some(doc => doc.schedules?.some(seg => seg.weekday === weekday && `${d} ${seg.starts_time}` <= start && `${d} ${seg.ends_time}` >= end)),
blocked = selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start < end && b.occurrence_end > start); blocked = selectedDoctors.length === 1 && 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, { return <div className={`clinic-hour ${working ? '' : 'closed'}${blocked ? ' blocked' : ''}`} key={h} onClick={() => working && !blocked && newAt(d, start)} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(a => renderEvent(a, true))}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>;
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined, })}</div>)}</div> : view === 'month' ? <MonthGrid day={day} weekLabels={weekLabels} todayKey={today()} onDayClick={d => newAt(d)} renderDay={d => {
slot: start const items = [...visibleBlocks.filter(b => b.occurrence_start.slice(0, 10) <= d && b.occurrence_end.slice(0, 10) >= d).map(b => ({ start: b.occurrence_start, node: <button type="button" key={`b-${b.id}-${b.occurrence_start}`} className="clinic-chip block" style={{ borderLeftColor: doctorColor(b.doctor_user_id) }} title={b.title} onClick={e => openItem(e, 'block', b)}>{doctorShort(b.doctor_user_id)} · {b.title}</button> })),
})}><small>{String(h).padStart(2, '0')}:00</small>{visible.filter(a => a.starts_at >= start && a.starts_at < end).map(a => renderEvent(a, true))}{visibleBlocks.filter(b => b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}</div>; ...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: <button type="button" key={`a-${a.id}`} className={`clinic-chip status-${statusOf(a)}`} style={{ borderLeftColor: doctorColor(a.doctor_user_id) }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name} · ${t(`clinic_status_${statusOf(a)}`)}`} onClick={e => openItem(e, 'appointment', a)} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); appointmentMenu(a); }}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.</button> }))].sort((x, y) => x.start.localeCompare(y.start));
})}</div>)}</div> : view === 'month' ? <MonthGrid day={day} weekLabels={weekLabels} todayKey={today()} onDayClick={d => { setDay(d); setView('day'); }} renderDay={d => { return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && <button type="button" className="clinic-more" onClick={e => { e.stopPropagation(); setDay(d); setView('day'); }}>+{items.length - 4} {t('clinic_more')}</button>}</>;
const items = [...visibleBlocks.filter(b => b.occurrence_start.slice(0, 10) <= d && b.occurrence_end.slice(0, 10) >= d).map(b => ({ start: b.occurrence_start, node: <div key={`b-${b.id}-${b.occurrence_start}`} className="clinic-chip block" style={{ borderLeftColor: doctorColor(b.doctor_user_id) }} title={b.title}>{doctorShort(b.doctor_user_id)} · {b.title}</div> })),
...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: <div key={`a-${a.id}`} className="clinic-chip" style={{ borderLeftColor: doctorColor(a.doctor_user_id) }} title={`${a.starts_at.slice(11, 16)} ${a.first_names} ${a.last_names} · ${a.doctor_name}`}>{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.</div> }))].sort((x, y) => x.start.localeCompare(y.start));
return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && <div className="text-muted" style={{ fontSize: '.66rem' }}>+{items.length - 4} {t('clinic_more')}</div>}</>;
}} /> : <div className="clinic-list">{[...visible.map(a => ({ }} /> : <div className="clinic-list">{[...visible.map(a => ({
start: a.starts_at, start: a.starts_at,
node: renderEvent(a) node: renderEvent(a)
@@ -707,7 +748,7 @@ export default function AgendaMedicaPage() {
setError(e.message); setError(e.message);
} }
}}>{t('common_save')}</Button></div>} }}>{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({ <DetailPopup open={!!popup} title={t(`clinic_${popup?.kind || 'appointment'}`)} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">{conflict && <InfoBar tone="error">{conflict}</InfoBar>}{affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}{editing && <DraftNotice draft={draft} />} {!editing ? <><div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>{popup.kind === 'appointment' && popup.item.deleted_at && <InfoBar tone="warning">{t('clinic_deleted_notice')}</InfoBar>}{actionMode && <><Field label={t(actionMode === 'delete' ? 'clinic_delete_reason' : 'clinic_cancel_reason')}><input className="input" value={actionReason} onChange={e => setActionReason(e.target.value)} autoFocus /></Field><div className="toolbar-left"><Button variant="danger" disabled={!actionReason.trim()} onClick={reasonAction}>{t(actionMode === 'delete' ? 'clinic_delete' : 'clinic_cancel')}</Button><Button onClick={() => { setActionMode(null); setActionReason(''); }}>{t('common_cancel')}</Button></div></>}{popup.kind === 'appointment' && !popup.item.deleted_at && !actionMode && <div className="toolbar-left">{isReception && popup.item.status !== 'cancelled' && <><Button variant="accent" onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setActionMode('cancel')}>{t('clinic_cancel')}</Button></>}{popup.item.status !== 'cancelled' && ['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</Button>)}{isReception && <Button variant="danger" onClick={() => setActionMode('delete')}>{t('clinic_delete')}</Button>}</div>}{popup.kind === 'patient' && isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><h3>{t('clinic_history')}</h3>{patientHistory.map(a => <p key={a.id} className={a.deleted_at ? 'clinic-deleted' : undefined}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${statusOf(a)}`)} · {t('clinic_created_by')}: {author(a)}{a.deleted_at ? ` · ${t('clinic_deleted_by')}: ${a.deleted_by_name || '—'} (${a.deleted_reason || ''})` : ''}</p>)}</>}{popup.kind === 'block' && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button variant="danger" onClick={() => openDialog({
tone: 'warning', tone: 'warning',
title: t('clinic_delete_block'), title: t('clinic_delete_block'),
message: t('clinic_delete_block_confirm'), message: t('clinic_delete_block_confirm'),
@@ -730,13 +771,13 @@ export default function AgendaMedicaPage() {
{popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => { {popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value); setSearch(e.target.value);
api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {}); api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {});
}} /></Field><ComboBox value={v.patientId} onChange={x => draft.setField('patientId', x)} options={[{ }} /></Field><Field label={t('clinic_patient')}><ComboBox value={v.patientId} onChange={x => draft.setField('patientId', x)} options={[{
value: '', value: '',
label: t('common_select') label: t('common_select')
}, ...patients.map(p => ({ }, ...patients.map(p => ({
value: p.id, value: p.id,
label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}` label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}`
}))]} /><Button onClick={() => open('patient', null, { }))]} /></Field><Button onClick={() => open('patient', null, {
returnTo: popup 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 => ({ })}>{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, value: d.id,
@@ -20,3 +20,10 @@
.clinic-period{display:inline-block}.clinic-period::first-letter{text-transform:uppercase} .clinic-period{display:inline-block}.clinic-period::first-letter{text-transform:uppercase}
.clinic-toolbar .clinic-controls .combobox{width:140px;min-width:0;flex:0 0 140px} .clinic-toolbar .clinic-controls .combobox{width:140px;min-width:0;flex:0 0 140px}
.clinic-toolbar .clinic-controls .datepicker-trigger{flex:0 0 150px;width:150px} .clinic-toolbar .clinic-controls .datepicker-trigger{flex:0 0 150px;width:150px}
/* Chips del mes clicables; canceladas/eliminadas tachadas y atenuadas (solo si el filtro las muestra). */
button.clinic-chip{display:block;width:100%;text-align:left;border-top:0;border-right:0;border-bottom:0;color:rgb(var(--body));cursor:pointer;font:inherit;font-size:.68rem}
button.clinic-chip:hover,.clinic-event:hover{filter:brightness(.95)}
.clinic-more{border:0;background:none;padding:2px 0;color:rgb(var(--accent));font-size:.66rem;cursor:pointer}
.clinic-chip.status-cancelled,.clinic-chip.status-deleted,.clinic-event.status-cancelled,.clinic-event.status-deleted{text-decoration:line-through;opacity:.6}
.clinic-chip.status-deleted,.clinic-event.status-deleted{border-left-style:dashed}
.clinic-deleted{opacity:.65}
@@ -144,7 +144,7 @@ export default function CalendarioPage() {
} }
const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` })); const userOptions = (users || []).filter((u) => u.is_active && u.status === 'active' && u.id !== user?.id && !v.participantUserIds.includes(u.id)).map((u) => ({ value: u.id, label: `${u.name} (${u.email})` }));
const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))]; const projectOptions = [{ value: '', label: t('calendar_none') }, ...(Array.isArray(projects) ? projects : projects?.items || []).map((p) => ({ value: p.id, label: p.name }))];
const allItems = [...(layers.meeting ? meetings || [] : []).map((m) => ({ ...m, kind: 'meeting', day: m.occurrence_start.slice(0,10) })), ...(showDue ? (activities || []).map((a) => ({ ...a, kind: 'activity', day: a.due_date })) : []), ...(layers.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 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||[]).filter((a)=>!a.deleted_at&&a.status!=='cancelled').map((a)=>({...a,kind:'clinic',day:a.starts_at.slice(0,10),occurrence_start:a.starts_at,clinicLabel:`${a.first_names} ${a.last_names?.slice(0,1)||''}.`})),...(clinicBlocksData||[]).map((b)=>({...b,kind:'clinic',day:b.occurrence_start.slice(0,10),clinicLabel:b.title}))] : []), ...(layers.recurring && canRecurring ? (recurring || []).map((r) => ({ ...r, kind: 'recurring', day: r.due_date, occurrence_start: r.due_date })) : []), ...(layers.project && canProjects ? (projectDates || []).map((p) => ({ ...p, kind: 'project', day: p.due_date, occurrence_start: `${p.milestone}-${p.due_date}` })) : []), ...(layers.task && canProjects ? (myTasks || []).map((x) => ({ ...x, kind: 'task', day: x.due_date, occurrence_start: x.due_date })) : [])].filter((item) => origin === 'all' || (origin === 'shared' ? item.shared : !item.shared)).sort((a,b) => (a.day || '').localeCompare(b.day || ''));
const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {}); const byDay = allItems.reduce((map, item) => { (map[item.day] ||= []).push(item); return map; }, {});
const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i)); const days = view === 'month' ? Array.from({ length: Math.ceil((((first.getDay()+6)%7) + (last-first)/86400000)/7)*7 }, (_,i) => shift(first, i - ((first.getDay()+6)%7))) : Array.from({ length: (last-first)/86400000 }, (_,i) => shift(first,i));
const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')]; const weekLabels = [t('calendar_mon'),t('calendar_tue'),t('calendar_wed'),t('calendar_thu'),t('calendar_fri'),t('calendar_sat'),t('calendar_sun')];
+1
View File
@@ -11,6 +11,7 @@ export const clinicSavePatient=(data,id)=>id?api.put(`/clinic/patients/${id}`,da
export const clinicSaveAppointment=(data,id)=>id?api.put(`/clinic/appointments/${id}`,data):api.post('/clinic/appointments',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 clinicAppointmentStatus=(id,data)=>api.post(`/clinic/appointments/${id}/status`,data);
export const clinicCancelAppointment=(id,data)=>api.post(`/clinic/appointments/${id}/cancel`,data); export const clinicCancelAppointment=(id,data)=>api.post(`/clinic/appointments/${id}/cancel`,data);
export const clinicDeleteAppointment=(id,data)=>api.post(`/clinic/appointments/${id}/delete`,data);
export const clinicSaveBlock=(data,id)=>id?api.put(`/clinic/blocks/${id}`,data):api.post('/clinic/blocks',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 clinicDeleteBlock=(id,editVersion)=>api.delete(`/clinic/blocks/${id}?editVersion=${editVersion}`);
export const clinicStaff=()=>api.get('/clinic/config/staff'); export const clinicStaff=()=>api.get('/clinic/config/staff');