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 {error}; // Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado // con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00. const hourRange = (() => { @@ -494,274 +386,148 @@ export default function AgendaMedicaPage() { if (lo >= hi) [lo, hi] = [7, 19]; return Array.from({ length: hi - lo }, (_, i) => lo + i); })(); - return

{t('nav_clinic')}

{isReception && tab === 'patients' && }{tab === 'blocks' && }
- {error && {error}}{reprogram.length > 0 && {t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}} - ({ key, label: t(label) }))} active={tab} onChange={key => { - const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null; - void guardedClose(current, () => setTab(key)); - }} /> - {tab === 'agenda' && newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))} - actions={isReception && } - filters={<> - - - + + const it = popup?.item; + // Tipos activos + el tipo actual de la cita que se edita aunque se haya desactivado. + const apptTypes = it && popup.kind === 'appointment' && !types.some(z => z.id === it.type_id) + ? [...types, { id: it.type_id, name: it.type_name, duration_minutes: Math.round((new Date(it.ends_at.replace(' ', 'T')) - new Date(it.starts_at.replace(' ', 'T'))) / 60000) }] + : types; + const detail = !it ? [] : popup.kind === 'appointment' ? [ + [t('clinic_patient'), `${it.first_names} ${it.last_names}`], [t('clinic_doctor'), it.doctor_name], + [t('clinic_type'), it.type_name], [t('clinic_status'), t(`clinic_status_${statusOf(it)}`)], + [t('calendar_start'), it.starts_at.slice(0, 16)], [t('calendar_end'), it.ends_at.slice(0, 16)], + [t('clinic_channel'), t(`clinic_channel_${it.channel}`)], [t('clinic_created_by'), `${author(it)} · ${String(it.created_at || '').slice(0, 16)}`], + [t('clinic_notes'), it.notes], + [t('clinic_cancelled_by'), it.cancelled_at && `${it.cancelled_by_name || '—'} · ${it.cancelled_at.slice(0, 16)} · ${it.cancelled_reason || ''}`], + [t('clinic_deleted_by'), it.deleted_at && `${it.deleted_by_name || '—'} · ${it.deleted_at.slice(0, 16)} · ${it.deleted_reason || ''}`] + ] : popup.kind === 'patient' ? [ + [t('clinic_first_names'), it.first_names], [t('clinic_last_names'), it.last_names], + [t('clinic_identification'), it.identification], [t('clinic_phone'), it.phone], + [t('clinic_email'), it.email], [t('clinic_birth_date'), it.birth_date], [t('clinic_notes'), it.notes] + ] : [ + [t('clinic_doctor'), doctorName(it.doctor_user_id)], [t('clinic_kind'), t(`clinic_kind_${it.kind}`)], + [t('clinic_title'), it.title], [t('clinic_created_by'), it.created_by_name], + [t('calendar_start'), it.occurrence_start.slice(0, 16)], [t('calendar_end'), it.occurrence_end.slice(0, 16)] + ]; + const popupTitle = !popup ? '' : it + ? popup.kind === 'appointment' ? `${t('clinic_appointment')}: ${it.first_names} ${it.last_names}` : popup.kind === 'patient' ? `${it.first_names} ${it.last_names}` : `${t('clinic_block')}: ${it.title}` + : t(popup.kind === 'appointment' ? 'clinic_new_appointment' : popup.kind === 'patient' ? 'clinic_new_patient' : 'clinic_new_block'); + const canSave = popup?.kind === 'appointment' ? !!(v.patientId && v.doctorId && v.typeId) + : popup?.kind === 'patient' ? !!(v.firstNames?.trim() && v.lastNames?.trim() && v.phone?.trim()) + : popup?.kind === 'block' ? !!(v.doctorId && v.title?.trim()) : false; + + if (error && !me && !isAdmin) return {error}; + + const tabs = [...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])]; + return
+
+

{t('nav_clinic')}

+
+ {isReception && tab === 'patients' && } + {tab === 'blocks' && } +
+
+ {error && me && {error}} + {reprogram.length > 0 && {t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}} + ({ key, label: t(label) }))} active={tab} onChange={key => void guardedClose(popup && editing ? draft : null, () => setTab(key))} /> + + {tab === 'agenda' && newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))} + actions={isReception && } + filters={<> + + + + } + legend={<> + {selectedDoctors.map(d => {d.name})} + {t('clinic_blocks')} + {t('clinic_status_cancelled')} / {t('clinic_status_deleted')} + } + notice={me && doctors.length === 0 && {isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}} />} + + {tab === 'patients' && isReception && <> +
{ setSearch(x); setPage(1); }} />
+ p.id} rows={patients} onRowClick={p => open('patient', p)} + columns={[ + { key: 'last_names', label: t('clinic_last_names') }, + { key: 'first_names', label: t('clinic_first_names') }, + { key: 'identification', label: t('clinic_identification'), render: p => p.identification || '—' }, + { key: 'phone', label: t('clinic_phone') }, + { key: 'email', label: t('clinic_email'), render: p => p.email || '—' } + ]} + rowActions={p => [ + { label: t('common_edit'), icon: Pencil, onClick: () => { open('patient', p); setEditing(true); } }, + { label: t('clinic_new_appointment'), icon: CalendarClock, onClick: () => { setTab('agenda'); newAt(day, undefined, p); } } + ]} /> +
{page} / {Math.max(1, Math.ceil(patientTotal / PAGE_SIZE))}
} - legend={<> - {selectedDoctors.map(d => {d.name})} - {t('clinic_blocks')} - {t('clinic_status_cancelled')} / {t('clinic_status_deleted')} - } - notice={me && doctors.length === 0 && {isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}} />} - {tab === 'patients' && isReception && <> { - setSearch(e.target.value); - setPage(1); - }} />
{patients.map(p => )}
{page} / {Math.max(1, Math.ceil(patientTotal / 20))}
} - {tab === 'blocks' &&
{visibleBlocks.map(b =>
{renderBlock(b)}
)}
} - {tab === 'config' && isAdmin &&

{t('clinic_staff')}

{ - setStaffUser(x); - const s = staffRows.find(r => r.user_id === Number(x)); - setStaffInitial(s ? { - scope: s.scope, - isDoctor: !!s.is_doctor, - specialty: s.specialty || '', - color: s.color || '#0078d4', - isActive: !!s.is_active, - editVersion: s.edit_version - } : { - scope: 'own', - isDoctor: true, - specialty: '', - color: '#0078d4', - isActive: true - }); - }} options={[{ - value: '', - label: t('common_select') - }, ...allUsers.filter(u => u.is_active).map(u => ({ - value: u.id, - label: u.name - }))]} /> setStaffUser(e.target.value)} /> setStaffForm({ - ...staffForm, - scope: x - })} options={['all', 'own'].map(x => ({ - value: x, - label: t(`clinic_scope_${x}`) - }))} /> setStaffForm({ - ...staffForm, - specialty: e.target.value - })} /> setStaffForm({ - ...staffForm, - color: e.target.value - })} /> -

{t('clinic_types')}

{ - const row = allTypes.find(r => r.id === Number(x)); - setTypeInitial(row ? { - id: row.id, - name: row.name, - durationMinutes: row.duration_minutes, - color: row.color, - isActive: !!row.is_active, - editVersion: row.edit_version - } : { - name: '', - durationMinutes: 30, - color: '#0078d4', - isActive: true - }); - }} options={[{ - value: '', - label: t('clinic_new_type') - }, ...allTypes.map(x => ({ - value: x.id, - label: x.name - }))]} /> setTypeForm({ - ...typeForm, - name: e.target.value - })} /> setTypeForm({ - ...typeForm, - durationMinutes: Number(e.target.value) - })} /> setTypeForm({ - ...typeForm, - color: e.target.value - })} /> -

{t('clinic_schedules')}

{ - setScheduleDoctor(x); - setScheduleInitial({ - rows: x ? await api.clinicSchedules(x) : [] - }); - }} options={[{ - value: '', - label: t('common_select') - }, ...doctors.map(d => ({ - value: d.id, - label: d.name - }))]} />{scheduleRows.map((s, i) =>
setScheduleRows(scheduleRows.map((r, j) => j === i ? { - ...r, - weekday: Number(x) - } : r))} options={['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'].map((x, n) => ({ - value: n, - label: t(`calendar_${x}`) - }))} /> setScheduleRows(scheduleRows.map((r, j) => j === i ? { - ...r, - startsTime: e.target.value - } : r))} /> setScheduleRows(scheduleRows.map((r, j) => j === i ? { - ...r, - endsTime: e.target.value - } : r))} />
)}
} - {popup &&
{conflict && {conflict}}{affected.length > 0 && {t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} }{editing && } {!editing ? <>
{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) =>

{label}: {String(val)}

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

{t('clinic_history')}

{patientHistory.map(a =>

{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${statusOf(a)}`)} · {t('clinic_created_by')}: {author(a)}{a.deleted_at ? ` · ${t('clinic_deleted_by')}: ${a.deleted_by_name || '—'} (${a.deleted_reason || ''})` : ''}

)}}{popup.kind === 'block' && <>} : <> - {popup.kind === 'appointment' && <> { - setSearch(e.target.value); - api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {}); - }} /> draft.setField('patientId', x)} options={[{ - value: '', - label: t('common_select') - }, ...patients.map(p => ({ - value: p.id, - label: `${p.first_names} ${p.last_names} · ${p.identification || p.phone}` - }))]} /> draft.setField('doctorId', x)} options={doctors.map(d => ({ - value: d.id, - label: d.name - }))} /> { - const ty = types.find(z => z.id === Number(x)); - draft.setValues(old => ({ - ...old, - typeId: x, - endsAt: plus(old.startsAt, ty?.duration_minutes || 30) - })); - }} options={types.map(z => ({ - value: z.id, - label: z.name - }))} /> draft.setValues(old => ({ - ...old, - startsAt: x, - endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30) - }))} label={t('calendar_start')} /> draft.setField('endsAt', x)} label={t('calendar_end')} /> draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map(x => ({ - value: x, - label: t(`clinic_channel_${x}`) - }))} />