diff --git a/AGENTS.md b/AGENTS.md
index 3b1bb00..dacba56 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -145,6 +145,33 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
guardar solo si hubo cambios reales.
- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos)
van en pestañas reales, nunca apiladas ni navegando a otra pantalla.
+- **Formularios en popup = modelo "Nueva reunión" de Calendario** (`pages/Calendario/CalendarioPage.jsx`):
+ rejilla de 2 columnas (campos cortos en pares, texto largo a lo ancho), fecha + hora con
+ `DateTimeEditor`, botones al pie. Cualquier formulario nuevo o existente que no siga esto se
+ ajusta a ese modelo.
+- Un popup "Nuevo …" abre limpio con el contexto del clic (día, hora, médico): nada de la apertura
+ anterior (búsquedas, selecciones) se arrastra. Solo vuelve lo que el usuario dejó como borrador
+ ("Dejar pendiente"), con el aviso de borrador recuperado.
+
+## Delegar a Codex (u otro agente) en este proyecto
+
+Además de la regla del orquestador: Claude escribe en el encargo las reglas que aplican (no "lee los
+md") y nombra **solo** los archivos/secciones necesarios de esta lista, según la tarea:
+
+| Si la tarea toca… | Nombrar |
+|---|---|
+| Pantallas/formularios | `docs/UI-UX-WINDOWS.md` (sección del componente) + pantalla modelo (formularios: `pages/Calendario/CalendarioPage.jsx`, listados: `pages/Organizacion/UsersPanel.jsx`) |
+| Formularios con edición | `docs/SESION-Y-BORRADORES.md` § Comportamiento (`useDraftForm`) |
+| Backend / permisos | `docs/SEGURIDAD-Y-CALIDAD.md` (sección aplicable), `API.md` § del módulo |
+| Base de datos | `BASE_DE_DATOS.md` § del módulo + última migración |
+
+Reglas fijas que siempre van escritas en el encargo: rejilla de 2 columnas como "Nueva reunión";
+listado primero y popup en lectura → Editar; nada se arrastra entre aperturas de un popup; textos en
+`i18n/messages.js` (es y en); reutilizar componentes de `src/components` sin crear variantes.
+Si necesita herramientas, se le dicen en el encargo (build: `npx vite build` en `frontend/`; navegador:
+Playwright global del orquestador; usuarios de prueba de dev: ver `ESTADO.md`).
+Codex desarrolla y **Claude revisa y prueba siempre** lo que entregue (diff contra estas reglas, build,
+navegador si cambia pantalla) antes de darlo por bueno.
## Flujos criticos
diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md
index 3270a13..4a5a222 100644
--- a/BASE_DE_DATOS.md
+++ b/BASE_DE_DATOS.md
@@ -17,7 +17,8 @@ La recurrencia se almacena como JSON de texto y se expande al consultar, sin fil
## Agenda médica (migración 036, v4.7.0)
-`backend/database/migrations/036_clinic.sql` está creada y **no se ha aplicado** en ninguna BD.
+`backend/database/migrations/036_clinic.sql` y `037_clinic_traceability.sql`: **aplicadas en dev**
+(2026-10-28/29), **pendientes en producción** (las aplica el actualizador).
Usa `utf8mb4`, FKs hacia `users`, `clinic_staff`, pacientes y tipos, e índices de rangos por
médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bf049c..a6be9b4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog - project.linkedcorp.net
+## [4.11.0] - 2026-10-29
+### Added
+- Agenda médica: al elegir el paciente de una cita, aviso (no bloquea) con sus citas futuras
+ agendadas/confirmadas, de cualquier fecha y médico.
+- Agenda médica: "Nueva cita" desde la fila de un paciente; "Nuevo paciente" desde la cita precarga
+ la cédula buscada; historial de citas del paciente en listado.
+### Changed
+- Agenda médica refactorizada con el modelo de "Nueva reunión" de Calendario: formularios de cita,
+ paciente y bloqueo en pares de campos con `DateTimeEditor`; Pacientes y Bloqueos como `ListView`;
+ Configuración (personal, tipos, horario) como listados con popup en lectura → Editar (se quitó el
+ campo "ID de usuario"). Código dividido en `ClinicForms.jsx`, `ClinicConfigTab.jsx`, `clinicUtils.js`.
+### Fixed
+- Agenda médica: el paciente/cédula de una cita ya no se arrastra a la siguiente (borrador huérfano
+ al cancelar o cerrar "Nuevo paciente"); las notas de la cita se conservan al volver del paciente.
+- Agenda médica: "Descartar cambios" en una cita, paciente o bloqueo nuevo ya no tumba la página.
+- Agenda médica: búsquedas y huecos libres ya no se pisan con respuestas viejas; el paciente elegido
+ no desaparece al buscar; el tipo desactivado de una cita se conserva al editarla.
+- Diálogo "Cambios sin guardar": los botones ya no se salen por la izquierda (pasan a otra línea).
+ Sin migración.
+
## [4.10.0] - 2026-10-29
### Added
- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura
diff --git a/DECISIONES.md b/DECISIONES.md
index 8e83738..95c8fed 100644
--- a/DECISIONES.md
+++ b/DECISIONES.md
@@ -10,3 +10,6 @@
| 2 | 2026-10-29 | Datos de sistemas externos vía **conectores** (API HTTP de solo lectura instalada en el servidor origen, contrato v1 en `data/conectores/README.md`); el primero es FoxPro | Más seguro (el sistema no guarda credenciales del origen; el origen decide qué tablas/campos salen) y escalable (otra tecnología = otro conector, mismo contrato) | Conexión directa desde el backend por SMB/SFTP a los DBF o con usuario de BD; ODBC de FoxPro | vigente |
| 3 | 2026-10-29 | Conector FoxPro en Node con `dbffile`, `node.exe` portable en `C:\api_project\foxpro`, tarea programada al arranque; lee una copia temporal de DBF+FPT | Mismo stack del proyecto, sin instalar nada en el server, no choca con los bloqueos de SAGA | Servicio .NET/Go; abrir los DBF originales; usar índices CDX | vigente |
| 4 | 2026-10-29 | Por fuente, el superadmin elige **importar** o **consulta en vivo**; empleados de nómina siempre se importan con botón "Actualizar empleados" (admin y superadmin) | Pedido del usuario (ver `docs/CONEXIONES-EXTERNAS.md`) | — | vigente |
+| 5 | 2026-10-29 | Formularios en popup siguen el modelo "Nueva reunión" de Calendario; listados con `ListView` y popup lectura → Editar (aplicado a toda la Agenda médica) | Consistencia pedida por el usuario; la Agenda (hecha por Codex sin guías) tenía un campo por línea y formularios siempre abiertos | Mantener el diseño propio de la Agenda | vigente |
+| 6 | 2026-10-29 | Citas futuras del mismo paciente: **aviso, no bloqueo**, al elegir el paciente (cualquier fecha/médico) | Pedido del usuario; recepción decide | Bloquear o pedir confirmación al guardar | vigente |
+| 7 | 2026-10-29 | Al delegar a Codex: reglas escritas en el encargo + solo los `.md`/secciones necesarios + herramientas; Claude revisa y prueba siempre (también en el orquestador) | Lo entregado sin guías salió fuera de las reglas | Delegar con "lee la documentación" | vigente |
diff --git a/ESTADO.md b/ESTADO.md
index 13b04cb..581d9d9 100644
--- a/ESTADO.md
+++ b/ESTADO.md
@@ -1,6 +1,6 @@
# ESTADO — project.linkedcorp.net
-Ultima actualizacion: 2026-10-29 (v4.10.0 commit local, sin push ni desplegar)
+Ultima actualizacion: 2026-10-29 (v4.11.0 commit + push, sin desplegar)
Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP.
@@ -15,6 +15,9 @@ autorregistro/aprobacion/SMTP.
## Agenda médica (v4.7.0, sin desplegar)
+- 2026-10-29 (v4.11.0): refactor de la pantalla en dev. Formularios cita/paciente/bloqueo con el modelo de "Nueva reunión" (`ClinicForms.jsx`), Pacientes y Bloqueos en `ListView`, Configuración como listados + popup lectura/Editar (`ClinicConfigTab.jsx`; se quitó el campo "ID de usuario"). Corregido: paciente/cédula que se arrastraba a la siguiente cita (borrador huérfano al cancelar "Nuevo paciente") y caída al "Descartar cambios" en un registro nuevo. Probado en navegador (recepción y admin temporal), sin errores de consola.
+- 2026-10-29 (v4.11.0): aviso sin bloqueo al elegir paciente en una cita si tiene citas futuras agendadas/confirmadas (lista hasta 5; excluye la cita que se edita). Diálogo compartido "Cambios sin guardar": botones ya no se desbordan (`app.css`).
+- 2026-10-29: BD de dev con datos de prueba de la agenda (15-oct a 28-nov): 7 médicos (`own`) y recepción `pruiz`, `jcabrera` (`all`) `@agenda.prueba` + mgonzalez, 60 pacientes (nota `[DATO DE PRUEBA]`), 4 tipos "(prueba)", 569 citas, 6 bloqueos. Script: `seed-agenda.cjs` del scratchpad de la sesión (con `--limpiar` los borra).
- v4.9.1: la agenda usa el componente compartido `CalendarBoard` (el mismo del Calendario); siempre
abre en Mes (`plc_clinic_view_v3`). En prod se veía vacía por la vista Día guardada y sin médicos.
- v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
diff --git a/SPEC.md b/SPEC.md
index dddb6cb..95ba811 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -102,9 +102,18 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa
y tipos de cita desde la pestaña Configuración de agenda.
- Vistas (v4.8.0; desde v4.8.1 abre en Mes con la barra del Calendario; la elegida se recuerda): **Día** con una columna por médico; **Semana** como
calendario de 7 días con las citas de todos los médicos filtrados, coloreadas por médico; **Mes**
- como calendario mensual (hasta 4 entradas por día + "N más"; clic en un día abre su vista Día);
+ como calendario mensual (hasta 5 entradas por día + "N más" que lleva al día; desde v4.9.0 clic en un día u hora libre abre la cita nueva con esa fecha);
**Lista** con citas y bloqueos. Leyenda de colores por médico en Semana y Mes.
Crear cita desde un hueco precarga médico/hora; el tipo fija la duración por defecto.
+- Formularios (desde la refactorización posterior a v4.10.0) con el modelo de "Nueva reunión" de
+ Calendario: campos en pares, fecha + hora con `DateTimeEditor`, notas a lo ancho. Pacientes y
+ Bloqueos son listados (`ListView`); Configuración (personal, tipos, horario) son listados con popup
+ en lectura → Editar. El detalle de un paciente muestra su historial de citas.
+- "Nuevo paciente" desde la cita abre el formulario del paciente encima (precarga la cédula si lo
+ buscado es numérico); guardar, cancelar o cerrar vuelve a la cita. Cada cita nueva abre limpia.
+- **Aviso, no bloqueo:** al elegir el paciente de una cita, si tiene citas futuras agendadas o
+ confirmadas (cualquier fecha y médico, sin contar la que se edita) se listan como advertencia y
+ se puede agendar igual. Sin citas futuras no se muestra nada.
- Una cita no cancelada solo cabe si está completa dentro de un tramo de atención y no cruza
otra cita del médico, un bloqueo suyo (también recurrente) ni otra cita del paciente con
cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
diff --git a/VERSION b/VERSION
index 2da4316..a162ea7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-4.10.0
+4.11.0
diff --git a/backend/package-lock.json b/backend/package-lock.json
index c77dcbe..a05a426 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-backend",
- "version": "4.10.0",
+ "version": "4.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-backend",
- "version": "4.10.0",
+ "version": "4.11.0",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
diff --git a/backend/package.json b/backend/package.json
index cc558d7..9077498 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
- "version": "4.10.0",
+ "version": "4.11.0",
"private": true,
"type": "module",
"main": "src/server.js",
diff --git a/docs/SESION-Y-BORRADORES.md b/docs/SESION-Y-BORRADORES.md
index 01d4b6c..b887e68 100644
--- a/docs/SESION-Y-BORRADORES.md
+++ b/docs/SESION-Y-BORRADORES.md
@@ -42,6 +42,11 @@ abierta `?activity=&activityTab=`, Organización `?tab=`, Equipos `?tab=&device=
pide recargar para revisar la versión actual. En Info de proyecto/actividad, Equipos, SMTP y
Empresa además se consulta el servidor justo antes de guardar.
+- **Formulario abierto desde otro** (ej. "Nuevo paciente" desde una cita): guardar, cancelar o
+ cerrar vuelve al formulario de origen con su borrador; nunca se cierra el de origen dejando su
+ borrador huérfano (eso hacía que la siguiente cita nueva apareciera con el paciente anterior).
+ Descartar un registro **nuevo** cierra el popup; uno existente vuelve a lectura.
+
## Formularios sin recuperación (a propósito)
| Formulario | Motivo |
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e6aa2c8..29f6aaf 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "project-linkedcorp-frontend",
- "version": "4.10.0",
+ "version": "4.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "project-linkedcorp-frontend",
- "version": "4.10.0",
+ "version": "4.11.0",
"dependencies": {
"dompurify": "^3.4.16",
"highlight.js": "^11.12.0",
diff --git a/frontend/package.json b/frontend/package.json
index 9f7d89d..06f26a1 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
- "version": "4.10.0",
+ "version": "4.11.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js
index 59c9f5c..c111a3b 100644
--- a/frontend/src/i18n/messages.js
+++ b/frontend/src/i18n/messages.js
@@ -53,7 +53,11 @@ export const messages = {
clinic_kind_other: 'Otro',
clinic_title: 'Título',
clinic_staff: 'Personal',
- clinic_user_id: 'ID de usuario',
+ clinic_user: 'Usuario',
+ clinic_scope: 'Rol en la agenda',
+ clinic_add_staff: 'Agregar personal',
+ clinic_add_segment: 'Agregar tramo',
+ clinic_patient_upcoming: 'Este paciente ya tiene citas pendientes (puedes agendar igual):',
clinic_scope_all: 'Recepción (todos)',
clinic_scope_own: 'Médico (propia)',
clinic_is_doctor: 'Es médico',
@@ -743,7 +747,11 @@ export const messages = {
clinic_kind_other: 'Other',
clinic_title: 'Title',
clinic_staff: 'Staff',
- clinic_user_id: 'User ID',
+ clinic_user: 'User',
+ clinic_scope: 'Agenda role',
+ clinic_add_staff: 'Add staff',
+ clinic_add_segment: 'Add time slot',
+ clinic_patient_upcoming: 'This patient already has upcoming appointments (you can still book):',
clinic_scope_all: 'Reception (all)',
clinic_scope_own: 'Doctor (own)',
clinic_is_doctor: 'Is doctor',
diff --git a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
index fc90120..5545818 100644
--- a/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
+++ b/frontend/src/pages/AgendaMedica/AgendaMedicaPage.jsx
@@ -1,5 +1,5 @@
-import { useEffect, useMemo, useState } from 'react';
-import { Stethoscope, Plus } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Stethoscope, Plus, Pencil, Check, XCircle, Trash2, CalendarClock, UserCheck, UserX } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
@@ -7,101 +7,31 @@ import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx';
-import { ComboBox } from '../../components/ComboBox.jsx';
import { CheckListDropdown } from '../../components/CheckListDropdown.jsx';
-import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
-import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { DatePicker } from '../../components/DatePicker.jsx';
+import { ListView } from '../../components/ListView.jsx';
+import { SearchInput } from '../../components/SearchInput.jsx';
import { CalendarBoard, CALENDAR_VIEWS, calendarBounds, dateKey, ecuadorNow, fromKey } from '../../components/CalendarBoard.jsx';
import * as api from '../../services/clinic.js';
-import { listUsers } from '../../services/users.js';
+import { draftKey, removeDraft } from '../../utils/drafts.js';
+import { AppointmentForm, PatientForm, BlockForm } from './ClinicForms.jsx';
+import { ClinicConfigTab } from './ClinicConfigTab.jsx';
+import { at, plus, readStored, initialAppointment, initialPatient, initialBlock, formAppointment, formPatient, formBlock } from './clinicUtils.js';
import './clinic.css';
-const at = (day, hour = '09:00') => `${day} ${hour}:00`;
-const plus = (s, n) => {
- const d = new Date(`${s.replace(' ', 'T')}Z`);
- return new Date(d.getTime() + n * 60000).toISOString().slice(0, 19).replace('T', ' ');
-};
-const initialAppointment = (day, doctorId, slot) => ({
- doctorId: doctorId || '',
- patientId: '',
- typeId: '',
- startsAt: slot || at(day),
- endsAt: plus(slot || at(day), 30),
- channel: 'phone',
- notes: ''
-});
-const initialPatient = () => ({
- firstNames: '',
- lastNames: '',
- identification: '',
- phone: '',
- email: '',
- birthDate: '',
- notes: ''
-});
-const initialBlock = (day, doctorId) => ({
- doctorId: doctorId || '',
- kind: 'surgery',
- title: '',
- startsAt: at(day),
- endsAt: plus(at(day), 60),
- recurrence: null
-});
-const formAppointment = a => ({
- doctorId: a.doctor_user_id,
- patientId: a.patient_id,
- typeId: a.type_id,
- startsAt: a.starts_at,
- endsAt: a.ends_at,
- channel: a.channel,
- notes: a.notes || '',
- editVersion: a.edit_version
-});
-const formPatient = p => ({
- firstNames: p.first_names,
- lastNames: p.last_names,
- identification: p.identification || '',
- phone: p.phone,
- email: p.email || '',
- birthDate: p.birth_date || '',
- notes: p.notes || '',
- editVersion: p.edit_version
-});
-const formBlock = b => ({
- doctorId: b.doctor_user_id,
- kind: b.kind,
- title: b.title,
- startsAt: b.starts_at,
- endsAt: b.ends_at,
- recurrence: b.recurrence_json ? JSON.parse(b.recurrence_json) : null,
- editVersion: b.edit_version
-});
-const readStored = (key, fallback) => {
- try {
- return JSON.parse(localStorage.getItem(key)) ?? fallback;
- } catch {
- return fallback;
- }
-};
+
+const PAGE_SIZE = 20;
+
export default function AgendaMedicaPage() {
- const {
- t,
- lang,
- openDialog,
- pushToast
- } = useUi();
- const {
- user
- } = useAuth();
+ const { t, openDialog, pushToast } = useUi();
+ const { user } = useAuth();
const guardedClose = useGuardedClose();
const [me, setMe] = useState(null),
[error, setError] = useState(''),
[doctors, setDoctors] = useState([]),
- [types, setTypes] = useState([]),
- [allTypes, setAllTypes] = useState([]);
+ [types, setTypes] = useState([]);
const [tab, setTab] = useState('agenda'),
// Clave nueva: la 4.8.x podia dejar guardada la vista Dia; ahora siempre arranca en Mes.
[view, setView] = useState(() => { const v = readStored('plc_clinic_view_v3', 'month'); return CALENDAR_VIEWS.includes(v) ? v : 'month'; }),
@@ -116,11 +46,15 @@ export default function AgendaMedicaPage() {
return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
});
const [appointments, setAppointments] = useState([]),
- [blocks, setBlocks] = useState([]),
- [patients, setPatients] = useState([]),
+ [blocks, setBlocks] = useState([]);
+ // Pestaña Pacientes (listado paginado en el servidor).
+ const [patients, setPatients] = useState([]),
[search, setSearch] = useState(''),
[page, setPage] = useState(1),
[patientTotal, setPatientTotal] = useState(0);
+ // Selector de paciente dentro de la cita: estado propio, se reinicia en cada cita.
+ const [apptPatients, setApptPatients] = useState([]),
+ [apptSearch, setApptSearch] = useState('');
const [popup, setPopup] = useState(null),
[editing, setEditing] = useState(false),
[conflict, setConflict] = useState(''),
@@ -131,58 +65,12 @@ export default function AgendaMedicaPage() {
[patientHistory, setPatientHistory] = useState([]),
[quickPatientId, setQuickPatientId] = useState(null),
[reprogram, setReprogram] = useState([]);
- const [allUsers, setAllUsers] = useState([]),
- [staffRows, setStaffRows] = useState([]),
- [staffUser, setStaffUser] = useState(''),
- [staffInitial, setStaffInitial] = useState({
- scope: 'own',
- isDoctor: true,
- specialty: '',
- color: '#0078d4',
- isActive: true
- });
- const [typeInitial, setTypeInitial] = useState({
- name: '',
- durationMinutes: 30,
- color: '#0078d4',
- isActive: true
- });
- const [scheduleDoctor, setScheduleDoctor] = useState(''),
- [scheduleInitial, setScheduleInitial] = useState({
- rows: []
- });
- const staffDraft = useDraftForm({
- formId: 'clinic-staff',
- entityId: staffUser || null,
- initial: staffInitial,
- label: t('clinic_staff'),
- enabled: tab === 'config'
- });
- const typeDraft = useDraftForm({
- formId: 'clinic-type',
- entityId: typeInitial.id || null,
- initial: typeInitial,
- label: t('clinic_types'),
- enabled: tab === 'config'
- });
- const scheduleDraft = useDraftForm({
- formId: 'clinic-schedule',
- entityId: scheduleDoctor || null,
- initial: scheduleInitial,
- label: t('clinic_schedules'),
- enabled: tab === 'config'
- });
- const staffForm = staffDraft.values,
- typeForm = typeDraft.values,
- scheduleRows = scheduleDraft.values.rows || [];
- const setStaffForm = staffDraft.setValues,
- setTypeForm = typeDraft.setValues;
- const setScheduleRows = rows => scheduleDraft.setField('rows', rows);
const isReception = me?.scope === 'all',
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]);
+
async function load() {
if (!me) return;
try {
@@ -194,111 +82,153 @@ export default function AgendaMedicaPage() {
setError(e.message);
}
}
+ async function loadDoctors() {
+ const data = await api.clinicDoctors();
+ setDoctors(data.doctors);
+ setTypes(data.types);
+ }
+ async function loadPatients() {
+ const x = await api.clinicPatients(search, page);
+ setPatients(x.items);
+ setPatientTotal(x.total);
+ }
useEffect(() => {
if (!user?.modules?.includes('clinic')) return;
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
api.clinicMe().then(async staff => {
setMe(staff);
- const data = await api.clinicDoctors();
- setDoctors(data.doctors);
- setTypes(data.types);
+ await loadDoctors();
}).catch(e => {
if (isAdmin) setTab('config');
else setError(e.message);
});
}, [user?.id]);
+ useEffect(() => { void load(); }, [me?.user_id, period.from, period.to]);
+ useEffect(() => { try { localStorage.setItem('plc_clinic_view_v3', JSON.stringify(view)); } catch { /* almacenamiento privado */ } }, [view]);
+ useEffect(() => { try { localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter)); } catch { /* almacenamiento privado */ } }, [doctorFilter]);
+ useEffect(() => { try { localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter)); } catch { /* almacenamiento privado */ } }, [statusFilter]);
useEffect(() => {
- void load();
- }, [me?.user_id, period.from, period.to]);
- useEffect(() => {
- try {
- localStorage.setItem('plc_clinic_view_v3', JSON.stringify(view));
- } catch {}
- }, [view]);
- useEffect(() => {
- try {
- localStorage.setItem('plc_clinic_doctors', JSON.stringify(doctorFilter));
- } catch {}
- }, [doctorFilter]);
- useEffect(() => {
- try {
- localStorage.setItem('plc_clinic_status', JSON.stringify(statusFilter));
- } catch {}
- }, [statusFilter]);
- useEffect(() => {
- if (tab === 'patients' && isReception) api.clinicPatients(search, page).then(x => {
- setPatients(x.items);
- setPatientTotal(x.total);
- }).catch(e => setError(e.message));
+ if (tab === 'patients' && isReception) loadPatients().catch(e => setError(e.message));
}, [tab, search, page, isReception]);
- useEffect(() => {
- if (tab === 'config' && isAdmin) Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]).then(([rows, users, kindRows]) => {
- setStaffRows(rows);
- setAllUsers(users);
- setAllTypes(kindRows);
- }).catch(e => setError(e.message));
- }, [tab, isAdmin]);
+
const formId = popup?.kind === 'appointment' ? 'clinic-appointment' : popup?.kind === 'patient' ? 'clinic-patient' : 'clinic-block';
- const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot) : popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient() : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
+ const initial = popup?.kind === 'appointment' ? popup.item ? formAppointment(popup.item) : initialAppointment(day, popup.doctorId, popup.slot, popup.patient?.id)
+ : popup?.kind === 'patient' ? popup.item ? formPatient(popup.item) : initialPatient(popup.prefill)
+ : popup?.kind === 'block' ? popup.item ? formBlock(popup.item) : initialBlock(day, popup.doctorId) : {};
const draft = useDraftForm({
formId,
entityId: popup?.item?.id,
initial,
label: t(`clinic_${popup?.kind || 'appointment'}`),
enabled: !!popup && editing,
- onDiscard: () => setEditing(false)
+ // Descartar (botón Cancelar, aviso de borrador o diálogo al salir): un registro existente vuelve
+ // a lectura; uno nuevo se cierra; el paciente creado desde una cita vuelve a la cita.
+ onDiscard: () => {
+ if (popup?.returnTo) backToAppointment();
+ else if (popup?.item) setEditing(false);
+ else setPopup(null);
+ }
});
const v = draft.values;
+ // Al volver de "Nuevo paciente": se reponen los valores que tenía la cita (en memoria, no en el
+ // borrador) y se elige el paciente recién creado.
useEffect(() => {
- if (quickPatientId && popup?.kind === 'appointment' && editing) {
- draft.setField('patientId', quickPatientId);
- setQuickPatientId(null);
- }
- }, [quickPatientId, popup?.kind, editing]);
+ if (popup?.kind !== 'appointment' || !editing || (!popup.restore && !quickPatientId)) return;
+ draft.setValues(old => ({ ...old, ...(popup.restore || {}), ...(quickPatientId ? { patientId: quickPatientId } : {}) }));
+ setQuickPatientId(null);
+ setPopup(p => ({ ...p, restore: null }));
+ }, [quickPatientId, popup?.kind, popup?.restore, editing]);
+ // Aviso (no bloquea): citas futuras pendientes del paciente elegido, sin contar la que se edita.
+ const [upcoming, setUpcoming] = useState([]);
useEffect(() => {
- if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return;
- api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([]));
+ if (popup?.kind !== 'appointment' || !editing || !v.patientId) { setUpcoming([]); return; }
+ let active = true;
+ const n = ecuadorNow();
+ const now = `${dateKey(n)} ${String(n.getHours()).padStart(2, '0')}:${String(n.getMinutes()).padStart(2, '0')}:00`;
+ api.clinicPatientHistory(v.patientId).then(rows => {
+ if (active) setUpcoming(rows.filter(a => !a.deleted_at && ['scheduled', 'confirmed'].includes(a.status) && a.starts_at > now && a.id !== popup.item?.id).sort((x, y) => x.starts_at.localeCompare(y.starts_at)));
+ }).catch(() => active && setUpcoming([]));
+ return () => { active = false; };
+ }, [popup?.kind, popup?.item?.id, editing, v.patientId]);
+ useEffect(() => {
+ if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) { setSlots([]); return; }
+ let active = true;
+ api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(x => active && setSlots(x)).catch(() => active && setSlots([]));
+ return () => { active = false; };
}, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
+
function open(kind, item = null, extra = {}) {
- setPopup({
- kind,
- item,
- ...extra
- });
+ setPopup({ kind, item, ...extra });
setEditing(!item);
setConflict('');
setAffected([]);
setActionMode(null);
setActionReason('');
+ setPatientHistory([]);
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) {
+ // Búsqueda propia de la cita: arranca vacía en cada cita y no toca la pestaña Pacientes.
+ setApptSearch('');
+ const current = item ? [{ id: item.patient_id, first_names: item.first_names, last_names: item.last_names }] : extra.patient ? [extra.patient] : [];
+ setApptPatients(current);
+ api.clinicPatients('', 1).then(x => setApptPatients([...current, ...x.items.filter(p => !current.some(c => c.id === p.id))])).catch(() => {});
+ }
+ }
+ // Búsqueda del selector: el paciente ya elegido se conserva en la lista y una respuesta vieja no
+ // pisa a la más reciente.
+ const searchSeq = useRef(0);
+ function searchApptPatients(text) {
+ setApptSearch(text);
+ const seq = ++searchSeq.current;
+ api.clinicPatients(text, 1).then(x => {
+ if (seq !== searchSeq.current) return;
+ setApptPatients(prev => {
+ const selected = prev.find(p => p.id === Number(draft.values.patientId));
+ return selected && !x.items.some(p => p.id === selected.id) ? [selected, ...x.items] : x.items;
+ });
+ }).catch(() => {});
+ }
+ // "Nuevo paciente" desde la cita: la cita queda abierta debajo y se vuelve a ella al guardar,
+ // cancelar o cerrar. Si lo buscado parece una cédula, se precarga.
+ function newPatientFromAppointment() {
+ const prefill = /^\d{5,}$/.test(apptSearch.trim()) ? apptSearch.trim() : '';
+ // La cita se guarda en memoria y su borrador se quita: mientras el paciente está abierto no
+ // debe quedar un borrador de cita huérfano (si se sale a otro menú, la siguiente cita abre limpia).
+ if (user) removeDraft(draftKey(user.id, 'clinic-appointment', popup.item?.id));
+ setPopup({ kind: 'patient', item: null, prefill, returnTo: { ...popup, restore: draft.values } });
+ setEditing(true);
+ setConflict('');
+ }
+ function backToAppointment() {
+ setPopup(popup.returnTo);
+ setEditing(true);
+ setConflict('');
}
function close() {
void guardedClose(editing ? draft : null, () => {
+ if (popup?.returnTo) return backToAppointment();
setPopup(null);
setEditing(false);
setConflict('');
});
}
+ const cancelEdit = () => draft.discard();
async function save(confirmAffected = false) {
if (popup.item && !draft.isDirty) return;
try {
let result;
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id);
if (popup.kind === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
- if (popup.kind === 'block') result = await api.clinicSaveBlock({
- ...v,
- confirmAffected
- }, popup.item?.id);
+ if (popup.kind === 'block') result = await api.clinicSaveBlock({ ...v, confirmAffected }, popup.item?.id);
if (result?.requiresConfirmation) {
setAffected(result.affected);
return;
}
draft.clear();
if (popup.returnTo) {
+ setApptPatients(list => [result, ...list.filter(p => p.id !== result.id)]);
setQuickPatientId(result.id);
- setPopup(popup.returnTo);
- setEditing(true);
+ backToAppointment();
} else {
setPopup(null);
setEditing(false);
@@ -307,32 +237,20 @@ export default function AgendaMedicaPage() {
setAffected([]);
if (result?.affected?.length) setReprogram(result.affected);
await load();
- if (tab === 'patients') {
- const list = await api.clinicPatients(search, page);
- setPatients(list.items);
- }
- pushToast({
- tone: 'success',
- message: t('common_saved')
- });
+ if (tab === 'patients') await loadPatients();
+ pushToast({ tone: 'success', message: t('common_saved') });
} catch (e) {
setConflict(e.message);
}
}
- async function actionStatus(status) {
+ async function setStatus(item, status) {
try {
- await api.clinicAppointmentStatus(popup.item.id, {
- status,
- editVersion: popup.item.edit_version
- });
- setPopup(null);
+ await api.clinicAppointmentStatus(item.id, { status, editVersion: item.edit_version });
+ if (popup?.item?.id === item.id) setPopup(null);
await load();
- pushToast({
- tone: 'success',
- message: t('common_saved')
- });
+ pushToast({ tone: 'success', message: t('common_saved') });
} catch (e) {
- setConflict(e.message);
+ if (popup) setConflict(e.message); else setError(e.message);
}
}
// Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados.
@@ -343,17 +261,12 @@ export default function AgendaMedicaPage() {
tone: 'warning',
title: t(remove ? 'clinic_delete' : 'clinic_cancel'),
message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'),
- actions: [{
- label: t('common_cancel')
- }, {
+ actions: [{ label: t('common_cancel') }, {
label: t(remove ? 'clinic_delete' : 'clinic_cancel'),
variant: 'danger',
onClick: async () => {
try {
- await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, {
- reason: actionReason,
- editVersion: popup.item.edit_version
- });
+ await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, { reason: actionReason, editVersion: popup.item.edit_version });
setPopup(null);
setActionMode(null);
await load();
@@ -364,74 +277,52 @@ export default function AgendaMedicaPage() {
}]
});
}
- function appointmentMenu(item) {
- 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'),
- onClick: () => api.clinicAppointmentStatus(item.id, {
- status: 'confirmed',
- editVersion: item.edit_version
- }).then(load).catch(e => setError(e.message))
- });
- actions.push({
- label: t('clinic_reprogram'),
- onClick: () => {
- open('appointment', item);
- setEditing(true);
- }
- });
- actions.push({
- label: t('clinic_cancel'),
- variant: 'danger',
- onClick: () => {
- open('appointment', item);
- setActionMode('cancel');
- }
- });
- actions.push({
- label: t('clinic_delete'),
- variant: 'danger',
- onClick: () => {
- open('appointment', item);
- setActionMode('delete');
- }
- });
- }
- for (const status of ['attended', 'no_show']) actions.push({
- label: t(`clinic_status_${status}`),
- onClick: () => api.clinicAppointmentStatus(item.id, {
- status,
- editVersion: item.edit_version
- }).then(load).catch(e => setError(e.message))
- });
+ function deleteBlock() {
openDialog({
- title: `${item.first_names} ${item.last_names}`,
- message: item.starts_at,
- actions
+ tone: 'warning',
+ title: t('clinic_delete_block'),
+ message: t('clinic_delete_block_confirm'),
+ actions: [{ label: t('common_cancel') }, {
+ label: t('common_delete'),
+ variant: 'danger',
+ onClick: async () => {
+ try {
+ await api.clinicDeleteBlock(popup.item.id, popup.item.edit_version);
+ setPopup(null);
+ await load();
+ } catch (e) {
+ setConflict(e.message);
+ }
+ }
+ }]
});
}
+ function appointmentMenu(item) {
+ const actions = [{ label: t('common_close') }];
+ if (item.deleted_at) return open('appointment', item);
+ const withReason = mode => () => { open('appointment', item); setActionMode(mode); };
+ if (isReception && item.status === 'cancelled') actions.push({ label: t('clinic_delete'), variant: 'danger', onClick: withReason('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'), onClick: () => void setStatus(item, 'confirmed') });
+ actions.push({ label: t('clinic_reprogram'), onClick: () => { open('appointment', item); setEditing(true); } });
+ actions.push({ label: t('clinic_cancel'), variant: 'danger', onClick: withReason('cancel') });
+ actions.push({ label: t('clinic_delete'), variant: 'danger', onClick: withReason('delete') });
+ }
+ for (const status of ['attended', 'no_show']) actions.push({ label: t(`clinic_status_${status}`), onClick: () => void setStatus(item, status) });
+ openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
+ }
+
+ const doctorName = id => doctors.find(d => d.id === id)?.name || '';
const doctorColor = id => doctors.find(d => d.id === id)?.color || 'rgb(var(--accent))';
- const doctorShort = id => (doctors.find(d => d.id === id)?.name || '').split(' ').slice(0, 2).join(' ');
+ const doctorShort = id => doctorName(id).split(' ').slice(0, 2).join(' ');
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) {
+ function newAt(d, slot, patient) {
if (!isReception || !doctors.length) {
if (!slot) {
setCursor(fromKey(d));
@@ -443,7 +334,8 @@ export default function AgendaMedicaPage() {
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')
+ slot: slot || at(d, first || '09:00'),
+ patient
});
}
const openItem = (e, kind, item) => {
@@ -454,11 +346,14 @@ export default function AgendaMedicaPage() {
// Entradas del calendario: color del medico; canceladas/eliminadas tachadas. En Mes/Semana van
// compactas; en Dia/Agenda con medico, tipo y estado.
const compact = view === 'month' || view === 'week';
- const renderItem = ({ kind, data: x }) => kind === 'appointment' ? : ;
+ const apptLong = x => `${x.starts_at.slice(11, 16)}–${x.ends_at.slice(11, 16)} ${x.first_names} ${x.last_names} · ${x.doctor_name} · ${x.type_name} · ${t(`clinic_status_${statusOf(x)}`)}`;
+ const blockLong = x => `${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctorName(x.doctor_user_id)} · ${t(`clinic_kind_${x.kind}`) === x.title ? x.title : `${t(`clinic_kind_${x.kind}`)} · ${x.title}`}`;
+ const renderItem = ({ kind, data: x }) => kind === 'appointment'
+ ?
+ : ;
// Un bloqueo de varios dias (vacaciones) aparece en cada dia que cubre.
const items = [...visible.map(a => ({ kind: 'appointment', day: a.starts_at.slice(0, 10), start: a.starts_at, data: a })), ...visibleBlocks.flatMap(b => {
const out = [];
@@ -476,9 +371,6 @@ export default function AgendaMedicaPage() {
if (selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start <= start && b.occurrence_end >= end)) return 'blocked';
return undefined;
};
- 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
{label}: {String(val)}
)}{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' && <>>}> : <> - {popup.kind === 'appointment' && <>{label}: {String(val)}
)}—
} + >} + {popup.kind === 'block' &&{t(`calendar_${WEEKDAYS[s.weekday]}`)}: {String(s.starts_time).slice(0, 5)}–{String(s.ends_time).slice(0, 5)}
) :—
}{label}: {val}
)}