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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Generated
+2
-2
@@ -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,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",
|
||||||
|
|||||||
@@ -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')];
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user