From 366447dffc52f7a08fd531a3252cbf961412df89 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 29 Oct 2026 07:57:36 -0500 Subject: [PATCH] feat: version 4.9.0 -- Agenda medica: clic en dia abre cita, autor fijo y borrado logico Co-Authored-By: Claude Opus 5.5 --- API.md | 3 +- BASE_DE_DATOS.md | 4 +- CHANGELOG.md | 14 +++ ESTADO.md | 5 + VERSION | 2 +- .../migrations/037_clinic_traceability.sql | 17 +++ backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/modules/clinic/routes.js | 1 + backend/src/modules/clinic/service.js | 43 ++++++-- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/i18n/messages.js | 4 +- .../pages/AgendaMedica/AgendaMedicaPage.jsx | 103 ++++++++++++------ frontend/src/pages/AgendaMedica/clinic.css | 7 ++ .../src/pages/Calendario/CalendarioPage.jsx | 2 +- frontend/src/services/clinic.js | 1 + 17 files changed, 162 insertions(+), 56 deletions(-) create mode 100644 backend/database/migrations/037_clinic_traceability.sql diff --git a/API.md b/API.md index 7568f5b..a359c67 100644 --- a/API.md +++ b/API.md @@ -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 /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 | +| `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/: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/cancel` | `{reason,editVersion}`, solo recepción | | `GET /blocks?from=&to=[&doctorId=]` | ocurrencias expandidas del rango | diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 7773c80..3270a13 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -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_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 | +| `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; `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; 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8928da3..b2c78e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # 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 ### Fixed - Agenda médica: abre en vista **Mes** con la misma barra que el Calendario (Hoy, anterior/siguiente, diff --git a/ESTADO.md b/ESTADO.md index 6e06e8a..ba548f5 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -6,6 +6,11 @@ autorregistro/aprobacion/SMTP. ## 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 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 diff --git a/VERSION b/VERSION index 697e993..6ed7776 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.8.1 +4.9.0 diff --git a/backend/database/migrations/037_clinic_traceability.sql b/backend/database/migrations/037_clinic_traceability.sql new file mode 100644 index 0000000..8a862f8 --- /dev/null +++ b/backend/database/migrations/037_clinic_traceability.sql @@ -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; diff --git a/backend/package-lock.json b/backend/package-lock.json index 204e355..337b06e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-backend", - "version": "4.8.1", + "version": "4.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-backend", - "version": "4.8.1", + "version": "4.9.0", "dependencies": { "bcryptjs": "^2.4.3", "cors": "^2.8.5", diff --git a/backend/package.json b/backend/package.json index 964fb16..ccd6fbb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.8.1", + "version": "4.9.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/clinic/routes.js b/backend/src/modules/clinic/routes.js index 8dd0147..c8bb9dc 100644 --- a/backend/src/modules/clinic/routes.js +++ b/backend/src/modules/clinic/routes.js @@ -39,6 +39,7 @@ 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/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)); get('/blocks', (q, s) => clinic.blocks(q, s)); write('post', '/blocks', (body, user, staff) => clinic.saveBlock(body, user, staff)); diff --git a/backend/src/modules/clinic/service.js b/backend/src/modules/clinic/service.js index 51b7c11..d7b6fa7 100644 --- a/backend/src/modules/clinic/service.js +++ b/backend/src/modules/clinic/service.js @@ -57,11 +57,13 @@ export async function schedules(doctorId) { export async function types() { return exec(pool, 'SELECT * FROM clinic_appointment_types ORDER BY name'); } -function selectAppointments(db, from, to, doctorId, patientId, excludeId) { - return exec(db, `SELECT a.*, p.first_names,p.last_names,t.name AS type_name,t.color AS type_color,u.name AS doctor_name FROM clinic_appointments a JOIN clinic_patients p ON p.id=a.patient_id JOIN clinic_appointment_types t ON t.id=a.type_id JOIN users u ON u.id=a.doctor_user_id WHERE a.status<>'cancelled' AND a.starts_at? ${doctorId ? 'AND a.doctor_user_id=?' : ''} ${patientId ? 'AND a.patient_id=?' : ''} ${excludeId ? 'AND a.id<>?' : ''} ORDER BY a.starts_at`, [to, from, ...(doctorId ? [doctorId] : []), ...(patientId ? [patientId] : []), ...(excludeId ? [excludeId] : [])]); +// Por defecto solo citas vigentes (las que ocupan horario); con all=true tambien canceladas y +// 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? ${doctorId ? 'AND a.doctor_user_id=?' : ''} ${patientId ? 'AND a.patient_id=?' : ''} ${excludeId ? 'AND a.id<>?' : ''} ORDER BY a.starts_at`, [to, from, ...(doctorId ? [doctorId] : []), ...(patientId ? [patientId] : []), ...(excludeId ? [excludeId] : [])]); } async function blocksIn(db, doctorId, from, to) { - const rows = await exec(db, 'SELECT * FROM clinic_blocks WHERE doctor_user_id=? AND starts_at expand(r, from, to)); } export async function appointments(q, staff) { @@ -70,7 +72,7 @@ export async function appointments(q, staff) { 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); + return selectAppointments(pool, from, to, staff.scope === 'own' ? staff.user_id : doctorId, patientId, null, true); } export async function blocks(q, staff) { const [from, to] = range(q); @@ -113,7 +115,7 @@ export async function patientHistory(patientId, staff) { 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]); + 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) { 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) { [before] = await exec(db, 'SELECT * FROM clinic_appointments WHERE id=?', [id(appointmentId)]); 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 (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. @@ -190,7 +193,7 @@ export async function saveAppointment(raw, user, staff, appointmentId = null) { } 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 = 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'); const newId = before?.id || result.insertId; 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; 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.deleted_at) throw new HttpError(409, 'Cita eliminada'); 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]); + 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'); - 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]; } 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'); let before = null; 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 (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; - 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'); const newId = before?.id || result.insertId; 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) { 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'); 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'); await audit(user, 'delete', 'clinic_block', n); return { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c6fe251..fc2be1e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "project-linkedcorp-frontend", - "version": "4.8.1", + "version": "4.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "project-linkedcorp-frontend", - "version": "4.8.1", + "version": "4.9.0", "dependencies": { "dompurify": "^3.4.16", "highlight.js": "^11.12.0", diff --git a/frontend/package.json b/frontend/package.json index 90cb44f..264f57f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.8.1", + "version": "4.9.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 642c532..79d1d08 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -21,7 +21,7 @@ export const messages = { clinic_block: 'Bloqueo', clinic_view_day: 'Día', 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_doctor: 'Médico', clinic_status: 'Estado', @@ -713,7 +713,7 @@ export const messages = { clinic_block: 'Block', clinic_view_day: 'Day', 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_doctor: 'Doctor', clinic_status: 'Status', diff --git a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx index 96759e7..7c93f26 100644 --- a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx +++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx @@ -135,8 +135,8 @@ export default function AgendaMedicaPage() { [conflict, setConflict] = useState(''), [affected, setAffected] = useState([]), [slots, setSlots] = useState([]), - [cancelReason, setCancelReason] = useState(''), - [cancelMode, setCancelMode] = useState(false), + [actionReason, setActionReason] = useState(''), + [actionMode, setActionMode] = useState(null), [patientHistory, setPatientHistory] = useState([]), [quickPatientId, setQuickPatientId] = useState(null), [reprogram, setReprogram] = useState([]); @@ -296,7 +296,8 @@ export default function AgendaMedicaPage() { setEditing(!item); setConflict(''); setAffected([]); - setCancelMode(false); + setActionMode(null); + setActionReason(''); 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(() => {}); } @@ -362,25 +363,27 @@ export default function AgendaMedicaPage() { setConflict(e.message); } } - function cancelAppointment() { - if (!cancelReason.trim()) return; + // Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados. + function reasonAction() { + if (!actionReason.trim()) return; + const remove = actionMode === 'delete'; openDialog({ tone: 'warning', - title: t('clinic_cancel'), - message: t('clinic_cancel_confirm'), + title: t(remove ? 'clinic_delete' : 'clinic_cancel'), + message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'), actions: [{ label: t('common_cancel') }, { - label: t('clinic_cancel'), + label: t(remove ? 'clinic_delete' : 'clinic_cancel'), variant: 'danger', onClick: async () => { try { - await api.clinicCancelAppointment(popup.item.id, { - reason: cancelReason, + await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, { + reason: actionReason, editVersion: popup.item.edit_version }); setPopup(null); - setCancelMode(false); + setActionMode(null); await load(); } catch (e) { setConflict(e.message); @@ -393,6 +396,16 @@ export default function AgendaMedicaPage() { const actions = [{ 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) { actions.push({ label: t('clinic_confirm'), @@ -413,7 +426,15 @@ export default function AgendaMedicaPage() { variant: 'danger', onClick: () => { 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)); 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 renderEvent = (a, byDoctor = false) => ; - const renderBlock = b => ; - const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_notes'), popup.item.notes]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end]] : []; + }} 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)}`)}`}; + const renderBlock = b => ; + const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_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 {error}; // Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado // con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00. @@ -489,7 +533,7 @@ export default function AgendaMedicaPage() { }))} /> ({ value: d.id, label: d.name - }))} values={doctorFilter} onChange={setDoctorFilter} /> ({ + }))} values={doctorFilter} onChange={setDoctorFilter} /> ({ value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} /> @@ -507,7 +551,7 @@ export default function AgendaMedicaPage() { return
working && !blocked && isReception && open('appointment', null, { doctorId: d.id, slot: start - })}>{hour}:00{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}
; + })} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}>{hour}:00{visible.filter(a => a.doctor_user_id === d.id && a.starts_at >= start && a.starts_at < end).map(renderEvent)}{visibleBlocks.filter(b => b.doctor_user_id === d.id && b.occurrence_start >= start && b.occurrence_start < end).map(renderBlock)}; })})} : view === 'week' ?
{Array.from({ length: 7 }, (_, i) => move(period.from.slice(0, 10), i)).map(d =>

{weekLabels[(new Date(`${d}T12:00:00Z`).getUTCDay() + 6) % 7]} {Number(d.slice(8, 10))}

{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). 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); - return
working && !blocked && isReception && open('appointment', null, { - doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined, - slot: start - })}>{String(h).padStart(2, '0')}:00{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)}
; - })}
)}
: view === 'month' ? { setDay(d); setView('day'); }} renderDay={d => { - 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:
{doctorShort(b.doctor_user_id)} · {b.title}
})), - ...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node:
{a.starts_at.slice(11, 16)} {a.first_names} {a.last_names.slice(0, 1)}.
}))].sort((x, y) => x.start.localeCompare(y.start)); - return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 &&
+{items.length - 4} {t('clinic_more')}
}; + return
working && !blocked && newAt(d, start)} title={working && !blocked && isReception ? t('clinic_new_appointment') : undefined}>{String(h).padStart(2, '0')}:00{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)}
; + })})} : view === 'month' ? newAt(d)} renderDay={d => { + 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: })), + ...visible.filter(a => a.starts_at.slice(0, 10) === d).map(a => ({ start: a.starts_at, node: }))].sort((x, y) => x.start.localeCompare(y.start)); + return <>{items.slice(0, 4).map(x => x.node)}{items.length > 4 && }; }} /> :
{[...visible.map(a => ({ start: a.starts_at, node: renderEvent(a) @@ -707,7 +748,7 @@ export default function AgendaMedicaPage() { setError(e.message); } }}>{t('common_save')}
} - {popup &&
{conflict && {conflict}}{affected.length > 0 && {t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} }{editing && } {!editing ? <>
{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) =>

{label}: {String(val)}

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

{t('clinic_history')}

{patientHistory.map(a =>

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

)}}{popup.kind === 'block' && <>}{editing && } {!editing ? <>
{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) =>

{label}: {String(val)}

)}
{popup.kind === 'appointment' && popup.item.deleted_at && {t('clinic_deleted_notice')}}{actionMode && <> setActionReason(e.target.value)} autoFocus />
}{popup.kind === 'appointment' && !popup.item.deleted_at && !actionMode &&
{isReception && popup.item.status !== 'cancelled' && <>}{popup.item.status !== 'cancelled' && ['attended', 'no_show'].map(x => )}{isReception && }
}{popup.kind === 'patient' && isReception && <>

{t('clinic_history')}

{patientHistory.map(a =>

{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 || ''})` : ''}

)}}{popup.kind === 'block' && <> draft.setField('doctorId', x)} options={doctors.map(d => ({ value: d.id, diff --git a/frontend/src/pages/AgendaMedica/clinic.css b/frontend/src/pages/AgendaMedica/clinic.css index b00ec79..659eaed 100644 --- a/frontend/src/pages/AgendaMedica/clinic.css +++ b/frontend/src/pages/AgendaMedica/clinic.css @@ -20,3 +20,10 @@ .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 .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} diff --git a/frontend/src/pages/Calendario/CalendarioPage.jsx b/frontend/src/pages/Calendario/CalendarioPage.jsx index af9f4e5..a059752 100644 --- a/frontend/src/pages/Calendario/CalendarioPage.jsx +++ b/frontend/src/pages/Calendario/CalendarioPage.jsx @@ -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 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 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')]; diff --git a/frontend/src/services/clinic.js b/frontend/src/services/clinic.js index cb92b34..4a0aef1 100644 --- a/frontend/src/services/clinic.js +++ b/frontend/src/services/clinic.js @@ -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 clinicAppointmentStatus=(id,data)=>api.post(`/clinic/appointments/${id}/status`,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 clinicDeleteBlock=(id,editVersion)=>api.delete(`/clinic/blocks/${id}?editVersion=${editVersion}`); export const clinicStaff=()=>api.get('/clinic/config/staff');