feat: version 4.11.0 -- Agenda medica refactorizada, aviso de citas futuras y correcciones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-29 15:30:21 -05:00
parent d0f90542e9
commit fabe5b530d
20 changed files with 746 additions and 564 deletions
+27
View File
@@ -145,6 +145,33 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador):
guardar solo si hubo cambios reales. guardar solo si hubo cambios reales.
- Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos) - Secciones de una misma entidad (Proyecto: Info/Actividades/Gantt/Calendario/Comentarios/Documentos)
van en pestañas reales, nunca apiladas ni navegando a otra pantalla. 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 ## Flujos criticos
+2 -1
View File
@@ -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) ## 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 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`. médico y por paciente. `DATETIME` se interpreta en hora civil de `America/Guayaquil`.
+20
View File
@@ -1,5 +1,25 @@
# Changelog - project.linkedcorp.net # 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 ## [4.10.0] - 2026-10-29
### Added ### Added
- Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura - Conectores de datos externos (contrato v1, `data/conectores/README.md`): API HTTP de solo lectura
+3
View File
@@ -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 | | 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 | | 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 | | 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 |
+4 -1
View File
@@ -1,6 +1,6 @@
# ESTADO — project.linkedcorp.net # 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 + Fase actual: **En produccion** (`https://project.linkedcorp.net`, v1.1.0). MVP fase 1 operativo +
autorregistro/aprobacion/SMTP. autorregistro/aprobacion/SMTP.
@@ -15,6 +15,9 @@ autorregistro/aprobacion/SMTP.
## Agenda médica (v4.7.0, sin desplegar) ## 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 - 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. 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 - v4.9.0: clic en un día/hora libre abre la cita nueva con esa fecha; clic en una cita la abre en
+10 -1
View File
@@ -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. 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 - 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** 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. **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. 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 - 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 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 cualquier médico. Son errores 409 bloqueantes; la validación se repite bajo bloqueo de filas
+1 -1
View File
@@ -1 +1 @@
4.10.0 4.11.0
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.10.0", "version": "4.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.10.0", "version": "4.11.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "4.10.0", "version": "4.11.0",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+5
View File
@@ -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 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. 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) ## Formularios sin recuperación (a propósito)
| Formulario | Motivo | | Formulario | Motivo |
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.10.0", "version": "4.11.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"version": "4.10.0", "version": "4.11.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.16", "dompurify": "^3.4.16",
"highlight.js": "^11.12.0", "highlight.js": "^11.12.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "4.10.0", "version": "4.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10 -2
View File
@@ -53,7 +53,11 @@ export const messages = {
clinic_kind_other: 'Otro', clinic_kind_other: 'Otro',
clinic_title: 'Título', clinic_title: 'Título',
clinic_staff: 'Personal', 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_all: 'Recepción (todos)',
clinic_scope_own: 'Médico (propia)', clinic_scope_own: 'Médico (propia)',
clinic_is_doctor: 'Es médico', clinic_is_doctor: 'Es médico',
@@ -743,7 +747,11 @@ export const messages = {
clinic_kind_other: 'Other', clinic_kind_other: 'Other',
clinic_title: 'Title', clinic_title: 'Title',
clinic_staff: 'Staff', 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_all: 'Reception (all)',
clinic_scope_own: 'Doctor (own)', clinic_scope_own: 'Doctor (own)',
clinic_is_doctor: 'Is doctor', clinic_is_doctor: 'Is doctor',
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Stethoscope, Plus } from 'lucide-react'; import { Stethoscope, Plus, Pencil, Check, XCircle, Trash2, CalendarClock, UserCheck, UserX } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx'; import { useUi } from '../../context/ui-context.jsx';
import { useAuth } from '../../context/auth-context.jsx'; import { useAuth } from '../../context/auth-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js'; import { useDraftForm } from '../../hooks/useDraftForm.js';
@@ -7,101 +7,31 @@ import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx'; import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx'; import { Button } from '../../components/Button.jsx';
import { Field } from '../../components/Field.jsx'; import { Field } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { CheckListDropdown } from '../../components/CheckListDropdown.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 { DetailPopup } from '../../components/DetailPopup.jsx';
import { Tabs } from '../../components/Tabs.jsx'; import { Tabs } from '../../components/Tabs.jsx';
import { InfoBar } from '../../components/InfoBar.jsx'; import { InfoBar } from '../../components/InfoBar.jsx';
import { DatePicker } from '../../components/DatePicker.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 { CalendarBoard, CALENDAR_VIEWS, calendarBounds, dateKey, ecuadorNow, fromKey } from '../../components/CalendarBoard.jsx';
import * as api from '../../services/clinic.js'; 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'; import './clinic.css';
const at = (day, hour = '09:00') => `${day} ${hour}:00`;
const plus = (s, n) => { const PAGE_SIZE = 20;
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;
}
};
export default function AgendaMedicaPage() { export default function AgendaMedicaPage() {
const { const { t, openDialog, pushToast } = useUi();
t, const { user } = useAuth();
lang,
openDialog,
pushToast
} = useUi();
const {
user
} = useAuth();
const guardedClose = useGuardedClose(); const guardedClose = useGuardedClose();
const [me, setMe] = useState(null), const [me, setMe] = useState(null),
[error, setError] = useState(''), [error, setError] = useState(''),
[doctors, setDoctors] = useState([]), [doctors, setDoctors] = useState([]),
[types, setTypes] = useState([]), [types, setTypes] = useState([]);
[allTypes, setAllTypes] = useState([]);
const [tab, setTab] = useState('agenda'), const [tab, setTab] = useState('agenda'),
// Clave nueva: la 4.8.x podia dejar guardada la vista Dia; ahora siempre arranca en Mes. // 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'; }), [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']; return Array.isArray(value) ? value : ['scheduled', 'confirmed', 'attended', 'no_show'];
}); });
const [appointments, setAppointments] = useState([]), const [appointments, setAppointments] = useState([]),
[blocks, setBlocks] = useState([]), [blocks, setBlocks] = useState([]);
[patients, setPatients] = useState([]), // Pestaña Pacientes (listado paginado en el servidor).
const [patients, setPatients] = useState([]),
[search, setSearch] = useState(''), [search, setSearch] = useState(''),
[page, setPage] = useState(1), [page, setPage] = useState(1),
[patientTotal, setPatientTotal] = useState(0); [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), const [popup, setPopup] = useState(null),
[editing, setEditing] = useState(false), [editing, setEditing] = useState(false),
[conflict, setConflict] = useState(''), [conflict, setConflict] = useState(''),
@@ -131,58 +65,12 @@ export default function AgendaMedicaPage() {
[patientHistory, setPatientHistory] = useState([]), [patientHistory, setPatientHistory] = useState([]),
[quickPatientId, setQuickPatientId] = useState(null), [quickPatientId, setQuickPatientId] = useState(null),
[reprogram, setReprogram] = useState([]); [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', const isReception = me?.scope === 'all',
isAdmin = ['admin', 'superadmin'].includes(user?.roleCode); isAdmin = ['admin', 'superadmin'].includes(user?.roleCode);
const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id; const singleDoctor = doctorFilter.length ? doctorFilter[0] : doctors[0]?.id;
const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id)); const selectedDoctors = doctors.filter(d => !doctorFilter.length || doctorFilter.includes(d.id));
const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]); const period = useMemo(() => calendarBounds(cursor, view), [cursor, view]);
async function load() { async function load() {
if (!me) return; if (!me) return;
try { try {
@@ -194,111 +82,153 @@ export default function AgendaMedicaPage() {
setError(e.message); 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(() => { useEffect(() => {
if (!user?.modules?.includes('clinic')) return; if (!user?.modules?.includes('clinic')) return;
// Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion). // Admin/superadmin sin fila de personal: solo puede configurar la agenda (pestana Configuracion).
api.clinicMe().then(async staff => { api.clinicMe().then(async staff => {
setMe(staff); setMe(staff);
const data = await api.clinicDoctors(); await loadDoctors();
setDoctors(data.doctors);
setTypes(data.types);
}).catch(e => { }).catch(e => {
if (isAdmin) setTab('config'); if (isAdmin) setTab('config');
else setError(e.message); else setError(e.message);
}); });
}, [user?.id]); }, [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(() => { useEffect(() => {
void load(); if (tab === 'patients' && isReception) loadPatients().catch(e => setError(e.message));
}, [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));
}, [tab, search, page, isReception]); }, [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 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({ const draft = useDraftForm({
formId, formId,
entityId: popup?.item?.id, entityId: popup?.item?.id,
initial, initial,
label: t(`clinic_${popup?.kind || 'appointment'}`), label: t(`clinic_${popup?.kind || 'appointment'}`),
enabled: !!popup && editing, 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; 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(() => { useEffect(() => {
if (quickPatientId && popup?.kind === 'appointment' && editing) { if (popup?.kind !== 'appointment' || !editing || (!popup.restore && !quickPatientId)) return;
draft.setField('patientId', quickPatientId); draft.setValues(old => ({ ...old, ...(popup.restore || {}), ...(quickPatientId ? { patientId: quickPatientId } : {}) }));
setQuickPatientId(null); setQuickPatientId(null);
} setPopup(p => ({ ...p, restore: null }));
}, [quickPatientId, popup?.kind, editing]); }, [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(() => { useEffect(() => {
if (popup?.kind !== 'appointment' || !editing || !v.doctorId || !v.typeId || !v.startsAt) return; if (popup?.kind !== 'appointment' || !editing || !v.patientId) { setUpcoming([]); return; }
api.clinicAvailability(v.doctorId, v.startsAt.slice(0, 10), v.typeId).then(setSlots).catch(() => setSlots([])); 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)]); }, [popup?.kind, editing, v.doctorId, v.typeId, v.startsAt?.slice(0, 10)]);
function open(kind, item = null, extra = {}) { function open(kind, item = null, extra = {}) {
setPopup({ setPopup({ kind, item, ...extra });
kind,
item,
...extra
});
setEditing(!item); setEditing(!item);
setConflict(''); setConflict('');
setAffected([]); setAffected([]);
setActionMode(null); setActionMode(null);
setActionReason(''); setActionReason('');
setPatientHistory([]);
if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message)); if (kind === 'patient' && item) api.clinicPatientHistory(item.id).then(setPatientHistory).catch(e => setConflict(e.message));
if (kind === 'appointment' && isReception) api.clinicPatients('', 1).then(x => setPatients(x.items)).catch(() => {}); if (kind === 'appointment' && isReception) {
// 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() { function close() {
void guardedClose(editing ? draft : null, () => { void guardedClose(editing ? draft : null, () => {
if (popup?.returnTo) return backToAppointment();
setPopup(null); setPopup(null);
setEditing(false); setEditing(false);
setConflict(''); setConflict('');
}); });
} }
const cancelEdit = () => draft.discard();
async function save(confirmAffected = false) { async function save(confirmAffected = false) {
if (popup.item && !draft.isDirty) return; if (popup.item && !draft.isDirty) return;
try { try {
let result; let result;
if (popup.kind === 'appointment') result = await api.clinicSaveAppointment(v, popup.item?.id); 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 === 'patient') result = await api.clinicSavePatient(v, popup.item?.id);
if (popup.kind === 'block') result = await api.clinicSaveBlock({ if (popup.kind === 'block') result = await api.clinicSaveBlock({ ...v, confirmAffected }, popup.item?.id);
...v,
confirmAffected
}, popup.item?.id);
if (result?.requiresConfirmation) { if (result?.requiresConfirmation) {
setAffected(result.affected); setAffected(result.affected);
return; return;
} }
draft.clear(); draft.clear();
if (popup.returnTo) { if (popup.returnTo) {
setApptPatients(list => [result, ...list.filter(p => p.id !== result.id)]);
setQuickPatientId(result.id); setQuickPatientId(result.id);
setPopup(popup.returnTo); backToAppointment();
setEditing(true);
} else { } else {
setPopup(null); setPopup(null);
setEditing(false); setEditing(false);
@@ -307,32 +237,20 @@ export default function AgendaMedicaPage() {
setAffected([]); setAffected([]);
if (result?.affected?.length) setReprogram(result.affected); if (result?.affected?.length) setReprogram(result.affected);
await load(); await load();
if (tab === 'patients') { if (tab === 'patients') await loadPatients();
const list = await api.clinicPatients(search, page); pushToast({ tone: 'success', message: t('common_saved') });
setPatients(list.items);
}
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) { } catch (e) {
setConflict(e.message); setConflict(e.message);
} }
} }
async function actionStatus(status) { async function setStatus(item, status) {
try { try {
await api.clinicAppointmentStatus(popup.item.id, { await api.clinicAppointmentStatus(item.id, { status, editVersion: item.edit_version });
status, if (popup?.item?.id === item.id) setPopup(null);
editVersion: popup.item.edit_version
});
setPopup(null);
await load(); await load();
pushToast({ pushToast({ tone: 'success', message: t('common_saved') });
tone: 'success',
message: t('common_saved')
});
} catch (e) { } 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. // Cancelar o eliminar (borrado logico): ambos exigen motivo y quedan registrados.
@@ -343,17 +261,12 @@ export default function AgendaMedicaPage() {
tone: 'warning', tone: 'warning',
title: t(remove ? 'clinic_delete' : 'clinic_cancel'), title: t(remove ? 'clinic_delete' : 'clinic_cancel'),
message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'), message: t(remove ? 'clinic_delete_confirm' : 'clinic_cancel_confirm'),
actions: [{ actions: [{ label: t('common_cancel') }, {
label: t('common_cancel')
}, {
label: t(remove ? 'clinic_delete' : 'clinic_cancel'), label: t(remove ? 'clinic_delete' : 'clinic_cancel'),
variant: 'danger', variant: 'danger',
onClick: async () => { onClick: async () => {
try { try {
await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, { await (remove ? api.clinicDeleteAppointment : api.clinicCancelAppointment)(popup.item.id, { reason: actionReason, editVersion: popup.item.edit_version });
reason: actionReason,
editVersion: popup.item.edit_version
});
setPopup(null); setPopup(null);
setActionMode(null); setActionMode(null);
await load(); await load();
@@ -364,74 +277,52 @@ export default function AgendaMedicaPage() {
}] }]
}); });
} }
function appointmentMenu(item) { function deleteBlock() {
const actions = [{ openDialog({
label: t('common_close') tone: 'warning',
}]; title: t('clinic_delete_block'),
if (item.deleted_at) return open('appointment', item); message: t('clinic_delete_block_confirm'),
if (isReception && item.status === 'cancelled') actions.push({ actions: [{ label: t('common_cancel') }, {
label: t('clinic_delete'), label: t('common_delete'),
variant: 'danger', variant: 'danger',
onClick: () => { onClick: async () => {
open('appointment', item); try {
setActionMode('delete'); 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 (item.status === 'cancelled') return openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
if (isReception) { if (isReception) {
actions.push({ actions.push({ label: t('clinic_confirm'), onClick: () => void setStatus(item, 'confirmed') });
label: t('clinic_confirm'), actions.push({ label: t('clinic_reprogram'), onClick: () => { open('appointment', item); setEditing(true); } });
onClick: () => api.clinicAppointmentStatus(item.id, { actions.push({ label: t('clinic_cancel'), variant: 'danger', onClick: withReason('cancel') });
status: 'confirmed', actions.push({ label: t('clinic_delete'), variant: 'danger', onClick: withReason('delete') });
editVersion: item.edit_version
}).then(load).catch(e => setError(e.message))
});
actions.push({
label: t('clinic_reprogram'),
onClick: () => {
open('appointment', item);
setEditing(true);
} }
}); for (const status of ['attended', 'no_show']) actions.push({ label: t(`clinic_status_${status}`), onClick: () => void setStatus(item, status) });
actions.push({ openDialog({ title: `${item.first_names} ${item.last_names}`, message: item.starts_at, actions });
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))
});
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 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 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))); 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". // 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 || '—'; 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 // 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. // 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 (!isReception || !doctors.length) {
if (!slot) { if (!slot) {
setCursor(fromKey(d)); 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]; 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, { open('appointment', null, {
doctorId: selectedDoctors.length === 1 ? selectedDoctors[0].id : undefined, 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) => { 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 // Entradas del calendario: color del medico; canceladas/eliminadas tachadas. En Mes/Semana van
// compactas; en Dia/Agenda con medico, tipo y estado. // compactas; en Dia/Agenda con medico, tipo y estado.
const compact = view === 'month' || view === 'week'; const compact = view === 'month' || view === 'week';
const renderItem = ({ kind, data: x }) => kind === 'appointment' ? <button key={`a-${x.id}`} className={`calendar-entry clinic-appt${['cancelled', 'deleted'].includes(statusOf(x)) ? ' done' : ''}${x.deleted_at ? ' deleted' : ''}`} style={{ '--doctor': doctorColor(x.doctor_user_id) }} onClick={e => openItem(e, 'appointment', x)} onContextMenu={e => { 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)}`)}`;
e.preventDefault(); 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}`}`;
e.stopPropagation(); const renderItem = ({ kind, data: x }) => kind === 'appointment'
appointmentMenu(x); ? <button key={`a-${x.id}`} className={`calendar-entry clinic-appt${['cancelled', 'deleted'].includes(statusOf(x)) ? ' done' : ''}${x.deleted_at ? ' deleted' : ''}`} style={{ '--doctor': doctorColor(x.doctor_user_id) }}
}} title={`${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)}`)}`}>{compact ? `${x.starts_at.slice(11, 16)} ${x.first_names} ${x.last_names.slice(0, 1)}.` : `${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)}`)}`}</button> : <button key={`b-${x.id}-${x.occurrence_start}`} className="calendar-entry clinic-block" style={{ '--doctor': doctorColor(x.doctor_user_id) }} onClick={e => openItem(e, 'block', x)} title={`${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctors.find(d => d.id === x.doctor_user_id)?.name || ''} · ${x.title}`}>{compact ? `${doctorShort(x.doctor_user_id)} · ${x.title}` : `${x.occurrence_start.slice(11, 16)}–${x.occurrence_end.slice(11, 16)} ${doctors.find(d => d.id === x.doctor_user_id)?.name || ''} · ${t(`clinic_kind_${x.kind}`) === x.title ? x.title : `${t(`clinic_kind_${x.kind}`)} · ${x.title}`}`}</button>; onClick={e => openItem(e, 'appointment', x)} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); appointmentMenu(x); }}
title={apptLong(x)}>{compact ? `${x.starts_at.slice(11, 16)} ${x.first_names} ${x.last_names.slice(0, 1)}.` : apptLong(x)}</button>
: <button key={`b-${x.id}-${x.occurrence_start}`} className="calendar-entry clinic-block" style={{ '--doctor': doctorColor(x.doctor_user_id) }}
onClick={e => openItem(e, 'block', x)} title={blockLong(x)}>{compact ? `${doctorShort(x.doctor_user_id)} · ${x.title}` : blockLong(x)}</button>;
// Un bloqueo de varios dias (vacaciones) aparece en cada dia que cubre. // 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 items = [...visible.map(a => ({ kind: 'appointment', day: a.starts_at.slice(0, 10), start: a.starts_at, data: a })), ...visibleBlocks.flatMap(b => {
const out = []; 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'; if (selectedDoctors.length === 1 && visibleBlocks.some(b => b.occurrence_start <= start && b.occurrence_end >= end)) return 'blocked';
return undefined; return undefined;
}; };
const renderBlock = b => <button key={`b-${b.id}-${b.occurrence_start}`} className="clinic-event block" onClick={e => openItem(e, 'block', b)} title={b.title}>{b.occurrence_start.slice(11, 16)} {doctors.find(d => d.id === b.doctor_user_id)?.name} · {b.title}</button>;
const detail = popup?.item ? popup.kind === 'appointment' ? [[t('clinic_doctor'), popup.item.doctor_name], [t('clinic_patient'), `${popup.item.first_names} ${popup.item.last_names}`], [t('clinic_type'), popup.item.type_name], [t('calendar_start'), popup.item.starts_at], [t('calendar_end'), popup.item.ends_at], [t('clinic_status'), t(`clinic_status_${popup.item.status}`)], [t('clinic_channel'), t(`clinic_channel_${popup.item.channel}`)], [t('clinic_created_by'), `${author(popup.item)} · ${String(popup.item.created_at || '').slice(0, 16)}`], [t('clinic_notes'), popup.item.notes], [t('clinic_cancelled_by'), popup.item.cancelled_at && `${popup.item.cancelled_by_name || '—'} · ${popup.item.cancelled_at.slice(0, 16)} · ${popup.item.cancelled_reason || ''}`], [t('clinic_deleted_by'), popup.item.deleted_at && `${popup.item.deleted_by_name || '—'} · ${popup.item.deleted_at.slice(0, 16)} · ${popup.item.deleted_reason || ''}`]] : popup.kind === 'patient' ? [[t('clinic_first_names'), popup.item.first_names], [t('clinic_last_names'), popup.item.last_names], [t('clinic_identification'), popup.item.identification], [t('clinic_phone'), popup.item.phone], [t('clinic_email'), popup.item.email], [t('clinic_birth_date'), popup.item.birth_date], [t('clinic_notes'), popup.item.notes]] : [[t('clinic_doctor'), doctors.find(d => d.id === popup.item.doctor_user_id)?.name], [t('clinic_kind'), t(`clinic_kind_${popup.item.kind}`)], [t('clinic_title'), popup.item.title], [t('calendar_start'), popup.item.occurrence_start], [t('calendar_end'), popup.item.occurrence_end], [t('clinic_created_by'), popup.item.created_by_name]] : [];
if (error && !me) return <InfoBar tone="error">{error}</InfoBar>;
// Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado // Horas visibles: desde la primera hasta la ultima de atencion de los medicos mostrados, ampliado
// con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00. // con cualquier cita o bloqueo fuera de ese rango; sin horarios cargados, 07:00-19:00.
const hourRange = (() => { const hourRange = (() => {
@@ -494,20 +386,57 @@ export default function AgendaMedicaPage() {
if (lo >= hi) [lo, hi] = [7, 19]; if (lo >= hi) [lo, hi] = [7, 19];
return Array.from({ length: hi - lo }, (_, i) => lo + i); return Array.from({ length: hi - lo }, (_, i) => lo + i);
})(); })();
return <div className="clinic-page"><div className="toolbar"><div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div><div className="toolbar-left">{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, {
doctorId: singleDoctor const it = popup?.item;
})}>{t('clinic_new_block')}</Button>}</div></div> // Tipos activos + el tipo actual de la cita que se edita aunque se haya desactivado.
{error && <InfoBar tone="error">{error}</InfoBar>}{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>} const apptTypes = it && popup.kind === 'appointment' && !types.some(z => z.id === it.type_id)
<Tabs tabs={[...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])].map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => { ? [...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) }]
const current = popup ? draft : tab === 'config' ? [staffDraft, typeDraft, scheduleDraft].find(x => x.isDirty) : null; : types;
void guardedClose(current, () => setTab(key)); 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 <InfoBar tone="error">{error}</InfoBar>;
const tabs = [...(me ? [['agenda', 'clinic_agenda'], ...(isReception ? [['patients', 'clinic_patients']] : []), ['blocks', 'clinic_blocks']] : []), ...(isAdmin ? [['config', 'clinic_config']] : [])];
return <div className="clinic-page">
<div className="toolbar">
<div className="toolbar-left"><h2 className="page-title mt-0"><Stethoscope size={22} />{t('nav_clinic')}</h2></div>
<div className="toolbar-left">
{isReception && tab === 'patients' && <Button variant="accent" icon={Plus} onClick={() => open('patient')}>{t('clinic_new_patient')}</Button>}
{tab === 'blocks' && <Button variant="accent" icon={Plus} onClick={() => open('block', null, { doctorId: singleDoctor })}>{t('clinic_new_block')}</Button>}
</div>
</div>
{error && me && <InfoBar tone="error">{error}</InfoBar>}
{reprogram.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {reprogram.map(a => `#${a.id} ${a.patient_name} · ${a.starts_at}`).join('; ')}</InfoBar>}
<Tabs tabs={tabs.map(([key, label]) => ({ key, label: t(label) }))} active={tab} onChange={key => void guardedClose(popup && editing ? draft : null, () => setTab(key))} />
{tab === 'agenda' && <CalendarBoard view={view} onViewChange={setView} cursor={cursor} onCursorChange={setCursor} items={items} renderItem={renderItem} hours={hourRange} hourState={hourState} maxPerDay={5} {tab === 'agenda' && <CalendarBoard view={view} onViewChange={setView} cursor={cursor} onCursorChange={setCursor} items={items} renderItem={renderItem} hours={hourRange} hourState={hourState} maxPerDay={5}
onSlot={(d, h) => newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))} onSlot={(d, h) => newAt(d, h == null ? undefined : at(d, `${String(h).padStart(2, '0')}:00`))}
actions={isReception && <Button variant="accent" icon={Plus} onClick={() => newAt(day)}>{t('clinic_new_appointment')}</Button>} actions={isReception && <Button variant="accent" icon={Plus} onClick={() => newAt(day)}>{t('clinic_new_appointment')}</Button>}
filters={<> filters={<>
<label className="calendar-filter-label">{t('clinic_doctors')} <label className="calendar-filter-label">{t('clinic_doctors')}
<CheckListDropdown label={!doctorFilter.length || doctorFilter.length === doctors.length ? t('clinic_all_doctors') : doctorFilter.length === 1 ? doctors.find(d => d.id === doctorFilter[0])?.name || '1' : `${doctorFilter.length} / ${doctors.length}`} options={doctors.map(d => ({ value: d.id, label: d.name }))} values={doctorFilter} onChange={setDoctorFilter} width={240} /> <CheckListDropdown label={!doctorFilter.length || doctorFilter.length === doctors.length ? t('clinic_all_doctors') : doctorFilter.length === 1 ? doctorName(doctorFilter[0]) || '1' : `${doctorFilter.length} / ${doctors.length}`} options={doctors.map(d => ({ value: d.id, label: d.name }))} values={doctorFilter} onChange={setDoctorFilter} width={240} />
</label> </label>
<label className="calendar-filter-label">{t('clinic_status')} <label className="calendar-filter-label">{t('clinic_status')}
<CheckListDropdown label={statusFilter.length === 1 ? t(`clinic_status_${statusFilter[0]}`) : `${statusFilter.length} / 6`} options={['scheduled', 'confirmed', 'attended', 'no_show', 'cancelled', 'deleted'].map(x => ({ value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} width={220} /> <CheckListDropdown label={statusFilter.length === 1 ? t(`clinic_status_${statusFilter[0]}`) : `${statusFilter.length} / 6`} options={['scheduled', 'confirmed', 'attended', 'no_show', 'cancelled', 'deleted'].map(x => ({ value: x, label: t(`clinic_status_${x}`) }))} values={statusFilter} onChange={setStatusFilter} width={220} />
@@ -522,246 +451,83 @@ export default function AgendaMedicaPage() {
<span className="calendar-legend-item clinic-done-sample"><span className="calendar-swatch" />{t('clinic_status_cancelled')} / {t('clinic_status_deleted')}</span> <span className="calendar-legend-item clinic-done-sample"><span className="calendar-swatch" />{t('clinic_status_cancelled')} / {t('clinic_status_deleted')}</span>
</>} </>}
notice={me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>} />} notice={me && doctors.length === 0 && <InfoBar tone="warning">{isAdmin ? t('clinic_no_doctors_admin') : t('clinic_no_doctors')}</InfoBar>} />}
{tab === 'patients' && isReception && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value); {tab === 'patients' && isReception && <>
setPage(1); <div className="toolbar"><div className="toolbar-left"><SearchInput value={search} onChange={x => { setSearch(x); setPage(1); }} /></div></div>
}} /></Field><div className="clinic-list">{patients.map(p => <button key={p.id} className="clinic-row" onClick={() => open('patient', p)}>{p.first_names} {p.last_names} · {p.phone}</button>)}</div><div className="toolbar-left"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button>{page} / {Math.max(1, Math.ceil(patientTotal / 20))}<Button disabled={page * 20 >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div></>} <ListView getRowKey={p => p.id} rows={patients} onRowClick={p => open('patient', p)}
{tab === 'blocks' && <div className="clinic-list">{visibleBlocks.map(b => <div key={`${b.id}-${b.occurrence_start}`}>{renderBlock(b)}</div>)}</div>} columns={[
{tab === 'config' && isAdmin && <div className="clinic-config"><h3>{t('clinic_staff')}</h3><ComboBox value={staffUser} onChange={x => { { key: 'last_names', label: t('clinic_last_names') },
setStaffUser(x); { key: 'first_names', label: t('clinic_first_names') },
const s = staffRows.find(r => r.user_id === Number(x)); { key: 'identification', label: t('clinic_identification'), render: p => p.identification || '—' },
setStaffInitial(s ? { { key: 'phone', label: t('clinic_phone') },
scope: s.scope, { key: 'email', label: t('clinic_email'), render: p => p.email || '—' }
isDoctor: !!s.is_doctor, ]}
specialty: s.specialty || '', rowActions={p => [
color: s.color || '#0078d4', { label: t('common_edit'), icon: Pencil, onClick: () => { open('patient', p); setEditing(true); } },
isActive: !!s.is_active, { label: t('clinic_new_appointment'), icon: CalendarClock, onClick: () => { setTab('agenda'); newAt(day, undefined, p); } }
editVersion: s.edit_version ]} />
} : { <div className="toolbar-left clinic-pager"><Button disabled={page <= 1} onClick={() => setPage(page - 1)}>{t('calendar_previous')}</Button><span>{page} / {Math.max(1, Math.ceil(patientTotal / PAGE_SIZE))}</span><Button disabled={page * PAGE_SIZE >= patientTotal} onClick={() => setPage(page + 1)}>{t('calendar_next')}</Button></div>
scope: 'own', </>}
isDoctor: true,
specialty: '', {tab === 'blocks' && <ListView getRowKey={b => `${b.id}-${b.occurrence_start}`} rows={visibleBlocks} onRowClick={b => open('block', b)} initialSort={{ key: 'occurrence_start', dir: 1 }}
color: '#0078d4', columns={[
isActive: true { key: 'occurrence_start', label: t('calendar_start'), render: b => b.occurrence_start.slice(0, 16) },
}); { key: 'occurrence_end', label: t('calendar_end'), render: b => b.occurrence_end.slice(0, 16) },
}} options={[{ { key: 'doctor_user_id', label: t('clinic_doctor'), sortValue: b => doctorName(b.doctor_user_id), render: b => <span className="clinic-name"><span className="calendar-swatch" style={{ background: doctorColor(b.doctor_user_id) }} />{doctorName(b.doctor_user_id)}</span> },
value: '', { key: 'kind', label: t('clinic_kind'), render: b => t(`clinic_kind_${b.kind}`) },
label: t('common_select') { key: 'title', label: t('clinic_title') }
}, ...allUsers.filter(u => u.is_active).map(u => ({ ]} />}
value: u.id,
label: u.name {tab === 'config' && isAdmin && <ClinicConfigTab onChanged={() => (me ? loadDoctors() : Promise.resolve())} />}
}))]} /><Field label={t('clinic_user_id')}><input className="input" type="number" value={staffUser} onChange={e => setStaffUser(e.target.value)} /></Field><ComboBox value={staffForm.scope} onChange={x => setStaffForm({
...staffForm, <DetailPopup open={!!popup} title={popupTitle} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">
scope: x {conflict && <InfoBar tone="error">{conflict}</InfoBar>}
})} options={['all', 'own'].map(x => ({ {affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button size="sm" onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}
value: x, {editing && <DraftNotice draft={draft} />}
label: t(`clinic_scope_${x}`) {!editing ? it && <>
}))} /><label><input type="checkbox" checked={staffForm.isDoctor} onChange={e => setStaffForm({ {popup.kind === 'appointment' && it.deleted_at && <InfoBar tone="warning">{t('clinic_deleted_notice')}</InfoBar>}
...staffForm, <div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>
isDoctor: e.target.checked {actionMode && <>
})} />{t('clinic_is_doctor')}</label><label><input type="checkbox" checked={staffForm.isActive} onChange={e => setStaffForm({ <Field label={t(actionMode === 'delete' ? 'clinic_delete_reason' : 'clinic_cancel_reason')}><input className="input" maxLength={500} value={actionReason} onChange={e => setActionReason(e.target.value)} autoFocus /></Field>
...staffForm, <div className="toolbar-left clinic-actions"><Button variant="danger" disabled={!actionReason.trim()} onClick={reasonAction}>{t(actionMode === 'delete' ? 'clinic_delete' : 'clinic_cancel')}</Button><Button onClick={() => { setActionMode(null); setActionReason(''); }}>{t('common_cancel')}</Button></div>
isActive: e.target.checked </>}
})} />{t('clinic_active')}</label><Field label={t('clinic_specialty')}><input className="input" value={staffForm.specialty} onChange={e => setStaffForm({ {popup.kind === 'appointment' && !it.deleted_at && !actionMode && <div className="toolbar-left clinic-actions">
...staffForm, {isReception && it.status !== 'cancelled' && <>
specialty: e.target.value <Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
})} /></Field><Field label={t('clinic_color')}><input type="color" value={staffForm.color} onChange={e => setStaffForm({ <Button icon={Check} disabled={it.status !== 'scheduled'} onClick={() => void setStatus(it, 'confirmed')}>{t('clinic_confirm')}</Button>
...staffForm, </>}
color: e.target.value {it.status !== 'cancelled' && <>
})} /></Field><Button disabled={!staffUser || !!staffForm.editVersion && !staffDraft.isDirty} onClick={async () => { <Button icon={UserCheck} disabled={it.status === 'attended'} onClick={() => void setStatus(it, 'attended')}>{t('clinic_status_attended')}</Button>
try { <Button icon={UserX} disabled={it.status === 'no_show'} onClick={() => void setStatus(it, 'no_show')}>{t('clinic_status_no_show')}</Button>
const saved = await api.clinicSaveStaff(staffUser, staffForm); </>}
staffDraft.clear(); {isReception && it.status !== 'cancelled' && <Button icon={XCircle} onClick={() => setActionMode('cancel')}>{t('clinic_cancel')}</Button>}
setStaffInitial({ {isReception && <Button variant="danger" icon={Trash2} onClick={() => setActionMode('delete')}>{t('clinic_delete')}</Button>}
scope: saved.scope, </div>}
isDoctor: !!saved.is_doctor, {popup.kind === 'patient' && isReception && <>
specialty: saved.specialty || '', <div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
color: saved.color || 'rgb(var(--accent))', <h3>{t('clinic_history')}</h3>
isActive: !!saved.is_active, {patientHistory.length ? <ListView getRowKey={a => a.id} rows={patientHistory} initialSort={{ key: 'starts_at', dir: -1 }}
editVersion: saved.edit_version columns={[
}); { key: 'starts_at', label: t('calendar_start'), render: a => <span className={a.deleted_at ? 'clinic-deleted' : undefined}>{a.starts_at.slice(0, 16)}</span> },
setStaffRows(await api.clinicStaff()); { key: 'doctor_name', label: t('clinic_doctor') },
setDoctors((await api.clinicDoctors()).doctors); { key: 'type_name', label: t('clinic_type') },
pushToast({ { key: 'status', label: t('clinic_status'), render: a => t(`clinic_status_${statusOf(a)}`) },
tone: 'success', { key: 'created_by_name', label: t('clinic_created_by'), render: author }
message: t('common_saved') ]} /> : <p className="text-muted">—</p>}
}); </>}
} catch (e) { {popup.kind === 'block' && <div className="toolbar-left clinic-actions">
setError(e.message); <Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
} <Button variant="danger" icon={Trash2} onClick={deleteBlock}>{t('common_delete')}</Button>
}}>{t('common_save')}</Button> </div>}
<h3>{t('clinic_types')}</h3><ComboBox value={typeForm.id || ''} onChange={x => { </> : <>
const row = allTypes.find(r => r.id === Number(x)); {popup.kind === 'appointment' && <AppointmentForm draft={draft} doctors={doctors} types={apptTypes} patients={apptPatients} search={apptSearch} onSearch={searchApptPatients} onNewPatient={newPatientFromAppointment} slots={slots} upcoming={upcoming} />}
setTypeInitial(row ? { {popup.kind === 'patient' && <PatientForm draft={draft} />}
id: row.id, {popup.kind === 'block' && <BlockForm draft={draft} doctors={doctors} fixedDoctor={!!it} />}
name: row.name, <div className="toolbar-left clinic-actions">
durationMinutes: row.duration_minutes, <Button variant="accent" disabled={!canSave || (!!it && !draft.isDirty)} onClick={() => void save()}>{t('common_save')}</Button>
color: row.color, <Button onClick={cancelEdit}>{t('common_cancel')}</Button>
isActive: !!row.is_active, </div>
editVersion: row.edit_version </>}
} : { </div>}</DetailPopup>
name: '', </div>;
durationMinutes: 30,
color: '#0078d4',
isActive: true
});
}} options={[{
value: '',
label: t('clinic_new_type')
}, ...allTypes.map(x => ({
value: x.id,
label: x.name
}))]} /><Field label={t('clinic_name')}><input className="input" value={typeForm.name} onChange={e => setTypeForm({
...typeForm,
name: e.target.value
})} /></Field><Field label={t('clinic_duration')}><input className="input" type="number" value={typeForm.durationMinutes} onChange={e => setTypeForm({
...typeForm,
durationMinutes: Number(e.target.value)
})} /></Field><input type="color" value={typeForm.color} onChange={e => setTypeForm({
...typeForm,
color: e.target.value
})} /><label><input type="checkbox" checked={typeForm.isActive} onChange={e => setTypeForm({
...typeForm,
isActive: e.target.checked
})} />{t('clinic_active')}</label><Button disabled={!typeForm.name?.trim() || !!typeForm.editVersion && !typeDraft.isDirty} onClick={async () => {
try {
const saved = await api.clinicSaveType(typeForm, typeForm.id);
typeDraft.clear();
setTypeInitial({
id: saved.id,
name: saved.name,
durationMinutes: saved.duration_minutes,
color: saved.color,
isActive: !!saved.is_active,
editVersion: saved.edit_version
});
const data = await api.clinicDoctors();
setTypes(data.types);
setAllTypes(await api.clinicTypes());
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button>
<h3>{t('clinic_schedules')}</h3><ComboBox value={scheduleDoctor} onChange={async x => {
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) => <div className="clinic-schedule-row" key={i}><ComboBox value={s.weekday} onChange={x => 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}`)
}))} /><input type="time" value={s.startsTime || s.starts_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
startsTime: e.target.value
} : r))} /><input type="time" value={s.endsTime || s.ends_time?.slice(0, 5) || ''} onChange={e => setScheduleRows(scheduleRows.map((r, j) => j === i ? {
...r,
endsTime: e.target.value
} : r))} /><Button onClick={() => setScheduleRows(scheduleRows.filter((_, j) => j !== i))}>{t('common_delete')}</Button></div>)}<Button onClick={() => setScheduleRows([...scheduleRows, {
weekday: 0,
startsTime: '09:00',
endsTime: '17:00'
}])}>{t('calendar_add')}</Button><Button disabled={!scheduleDoctor || !scheduleDraft.isDirty} onClick={async () => {
try {
await api.clinicSaveSchedules(scheduleDoctor, {
segments: scheduleRows.map(s => ({
weekday: s.weekday,
startsTime: s.startsTime || s.starts_time,
endsTime: s.endsTime || s.ends_time
}))
});
const data = await api.clinicDoctors();
setDoctors(data.doctors);
scheduleDraft.clear();
setScheduleInitial({
rows: await api.clinicSchedules(scheduleDoctor)
});
pushToast({
tone: 'success',
message: t('common_saved')
});
} catch (e) {
setError(e.message);
}
}}>{t('common_save')}</Button></div>}
<DetailPopup open={!!popup} title={t(`clinic_${popup?.kind || 'appointment'}`)} onClose={close} width={760} height={680}>{popup && <div className="clinic-form">{conflict && <InfoBar tone="error">{conflict}</InfoBar>}{affected.length > 0 && <InfoBar tone="warning">{t('clinic_affected')}: {affected.map(a => `#${a.id} ${a.patient_name}`).join(', ')} <Button onClick={() => void save(true)}>{t('clinic_confirm_affected')}</Button></InfoBar>}{editing && <DraftNotice draft={draft} />} {!editing ? <><div className="clinic-detail">{detail.filter(([, val]) => val !== null && val !== undefined && val !== '').map(([label, val]) => <p key={label}><strong>{label}:</strong> {String(val)}</p>)}</div>{popup.kind === 'appointment' && popup.item.deleted_at && <InfoBar tone="warning">{t('clinic_deleted_notice')}</InfoBar>}{actionMode && <><Field label={t(actionMode === 'delete' ? 'clinic_delete_reason' : 'clinic_cancel_reason')}><input className="input" value={actionReason} onChange={e => setActionReason(e.target.value)} autoFocus /></Field><div className="toolbar-left"><Button variant="danger" disabled={!actionReason.trim()} onClick={reasonAction}>{t(actionMode === 'delete' ? 'clinic_delete' : 'clinic_cancel')}</Button><Button onClick={() => { setActionMode(null); setActionReason(''); }}>{t('common_cancel')}</Button></div></>}{popup.kind === 'appointment' && !popup.item.deleted_at && !actionMode && <div className="toolbar-left">{isReception && popup.item.status !== 'cancelled' && <><Button variant="accent" onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button disabled={popup.item.status === 'confirmed'} onClick={() => void actionStatus('confirmed')}>{t('clinic_confirm')}</Button><Button onClick={() => setActionMode('cancel')}>{t('clinic_cancel')}</Button></>}{popup.item.status !== 'cancelled' && ['attended', 'no_show'].map(x => <Button key={x} disabled={popup.item.status === x} onClick={() => void actionStatus(x)}>{t(`clinic_status_${x}`)}</Button>)}{isReception && <Button variant="danger" onClick={() => setActionMode('delete')}>{t('clinic_delete')}</Button>}</div>}{popup.kind === 'patient' && isReception && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><h3>{t('clinic_history')}</h3>{patientHistory.map(a => <p key={a.id} className={a.deleted_at ? 'clinic-deleted' : undefined}>{a.starts_at} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${statusOf(a)}`)} · {t('clinic_created_by')}: {author(a)}{a.deleted_at ? ` · ${t('clinic_deleted_by')}: ${a.deleted_by_name || '—'} (${a.deleted_reason || ''})` : ''}</p>)}</>}{popup.kind === 'block' && <><Button onClick={() => setEditing(true)}>{t('common_edit')}</Button><Button variant="danger" onClick={() => openDialog({
tone: 'warning',
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);
}
}
}]
})}>{t('common_delete')}</Button></>}</> : <>
{popup.kind === 'appointment' && <><Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={e => {
setSearch(e.target.value);
api.clinicPatients(e.target.value, 1).then(x => setPatients(x.items)).catch(() => {});
}} /></Field><Field label={t('clinic_patient')}><ComboBox value={v.patientId} onChange={x => 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}`
}))]} /></Field><Button onClick={() => open('patient', null, {
returnTo: popup
})}>{t('clinic_new_patient')}</Button><Field label={t('clinic_doctor')}><ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} /></Field><Field label={t('clinic_type')}><ComboBox value={v.typeId} onChange={x => {
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
}))} /></Field><Field label={t('calendar_start')}><DateTimeEditor value={v.startsAt} onChange={x => draft.setValues(old => ({
...old,
startsAt: x,
endsAt: plus(x, types.find(z => z.id === Number(old.typeId))?.duration_minutes || 30)
}))} label={t('calendar_start')} /></Field><Field label={t('calendar_end')}><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /></Field><Field label={t('clinic_channel')}><ComboBox value={v.channel} onChange={x => draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map(x => ({
value: x,
label: t(`clinic_channel_${x}`)
}))} /></Field><Field label={t('clinic_notes')}><textarea className="textarea" value={v.notes} onChange={e => draft.setField('notes', e.target.value)} /></Field><Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map(s => <Button key={s.startsAt} size="sm" onClick={() => draft.setValues(old => ({
...old,
startsAt: s.startsAt,
endsAt: s.endsAt
}))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field></>}
{popup.kind === 'patient' && [['firstNames', 'clinic_first_names'], ['lastNames', 'clinic_last_names'], ['identification', 'clinic_identification'], ['phone', 'clinic_phone'], ['email', 'clinic_email'], ['birthDate', 'clinic_birth_date'], ['notes', 'clinic_notes']].map(([k, label]) => <Field key={k} label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={e => draft.setField(k, e.target.value)} /></Field>)}
{popup.kind === 'block' && <><Field label={t('clinic_doctor')}>{popup.item ? <strong>{doctors.find(d => d.id === v.doctorId)?.name}</strong> : <ComboBox value={v.doctorId} onChange={x => draft.setField('doctorId', x)} options={doctors.map(d => ({
value: d.id,
label: d.name
}))} />}</Field><Field label={t('clinic_kind')}><ComboBox value={v.kind} onChange={x => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map(x => ({
value: x,
label: t(`clinic_kind_${x}`)
}))} /></Field><Field label={t('clinic_title')}><input className="input" value={v.title} onChange={e => draft.setField('title', e.target.value)} /></Field><DateTimeEditor value={v.startsAt} onChange={x => draft.setField('startsAt', x)} label={t('calendar_start')} /><DateTimeEditor value={v.endsAt} onChange={x => draft.setField('endsAt', x)} label={t('calendar_end')} /><RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={x => draft.setField('recurrence', x)} /></>}
<div className="toolbar-left"><Button variant="accent" disabled={!!popup.item && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={() => {
draft.discard();
if (!popup.item) setPopup(null);else setEditing(false);
}}>{t('common_cancel')}</Button></div></>}</div>}</DetailPopup></div>;
} }
@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { useDraftForm } from '../../hooks/useDraftForm.js';
import { useGuardedClose } from '../../components/NavigationGuard.jsx';
import { DraftNotice } from '../../components/DraftNotice.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { ListView } from '../../components/ListView.jsx';
import { Badge } from '../../components/Badge.jsx';
import * as api from '../../services/clinic.js';
import { listUsers } from '../../services/users.js';
import { WEEKDAYS } from './clinicUtils.js';
// Configuración de la agenda (admin/superadmin): Personal, Tipos de cita y Horario de atención.
// Cada sección es un listado; "Agregar"/clic en fila abre un popup en lectura y "Editar" habilita.
const staffForm = (s) => s ? { userId: s.user_id, scope: s.scope, isDoctor: !!s.is_doctor, specialty: s.specialty || '', color: s.color || '#0078d4', isActive: !!s.is_active, editVersion: s.edit_version } : { userId: '', scope: 'own', isDoctor: true, specialty: '', color: '#0078d4', isActive: true };
const typeForm = (x) => x ? { name: x.name, durationMinutes: x.duration_minutes, color: x.color, isActive: !!x.is_active, editVersion: x.edit_version } : { name: '', durationMinutes: 30, color: '#0078d4', isActive: true };
const scheduleForm = (rows) => ({ rows: rows.map((s) => ({ weekday: s.weekday, startsTime: String(s.starts_time).slice(0, 5), endsTime: String(s.ends_time).slice(0, 5) })) });
const swatch = (color) => <span className="calendar-swatch" style={{ background: color || 'rgb(var(--accent))' }} />;
export function ClinicConfigTab({ onChanged }) {
const { t, pushToast } = useUi();
const guardedClose = useGuardedClose();
const [staff, setStaff] = useState([]), [scheduleDoctors, setScheduleDoctors] = useState([]), [users, setUsers] = useState([]), [types, setTypes] = useState([]);
const [popup, setPopup] = useState(null), [editing, setEditing] = useState(false), [error, setError] = useState(''), [loadError, setLoadError] = useState('');
async function reload() {
try {
const [s, u, ty] = await Promise.all([api.clinicStaff(), listUsers(), api.clinicTypes()]);
// Horario por médico (todos los médicos del personal, aunque quien configura no sea personal).
const docs = s.filter((x) => x.is_doctor);
const segs = await Promise.all(docs.map((d) => api.clinicSchedules(d.user_id)));
setStaff(s); setUsers(u); setTypes(ty);
setScheduleDoctors(docs.map((d, i) => ({ id: d.user_id, name: d.name, color: d.color, schedules: segs[i] })));
setLoadError('');
} catch (e) { setLoadError(e.message); }
}
useEffect(() => { void reload(); }, []);
const initial = !popup ? {} : popup.kind === 'staff' ? staffForm(popup.item) : popup.kind === 'type' ? typeForm(popup.item) : scheduleForm(popup.rows || []);
const draft = useDraftForm({
formId: popup?.kind === 'staff' ? 'clinic-staff' : popup?.kind === 'type' ? 'clinic-type' : 'clinic-schedule',
entityId: popup?.kind === 'staff' ? popup.item?.user_id : popup?.kind === 'type' ? popup.item?.id : popup?.doctor?.id,
initial,
label: t(popup?.kind === 'staff' ? 'clinic_staff' : popup?.kind === 'type' ? 'clinic_types' : 'clinic_schedules'),
enabled: !!popup && editing,
onDiscard: () => { if (popup?.item || popup?.kind === 'schedule') setEditing(false); else setPopup(null); }
});
const v = draft.values;
async function open(kind, item = null, extra = {}) {
setError('');
if (kind === 'schedule') extra.rows = extra.doctor.schedules;
setPopup({ kind, item, ...extra });
setEditing(!item && kind !== 'schedule');
}
function close() { void guardedClose(editing ? draft : null, () => { setPopup(null); setEditing(false); }); }
const cancelEdit = () => draft.discard();
async function save() {
try {
if (popup.kind === 'staff') await api.clinicSaveStaff(v.userId, v);
if (popup.kind === 'type') await api.clinicSaveType(v, popup.item?.id);
if (popup.kind === 'schedule') await api.clinicSaveSchedules(popup.doctor.id, { segments: rows });
} catch (e) { setError(e.message); return; }
draft.clear();
setPopup(null);
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
// Guardado ya hecho: si falla el refresco, se avisa fuera del popup (que ya está cerrado).
try { await reload(); await onChanged(); } catch (e) { setLoadError(e.message); }
}
const staffIds = new Set(staff.map((s) => s.user_id));
const scheduleText = (d) => (d.schedules || []).map((s) => `${t(`calendar_${WEEKDAYS[s.weekday]}`)} ${String(s.starts_time).slice(0, 5)}–${String(s.ends_time).slice(0, 5)}`).join(' · ') || '—';
const rows = (v?.rows || []).map((r) => ({ weekday: Number(r.weekday), startsTime: r.startsTime ?? String(r.starts_time || '').slice(0, 5), endsTime: r.endsTime ?? String(r.ends_time || '').slice(0, 5) }));
const setRows = (next) => draft.setField('rows', next);
const canSave = popup?.kind === 'staff' ? !!v.userId : popup?.kind === 'type' ? !!v.name?.trim() && Number(v.durationMinutes) > 0 : true;
const detail = !popup?.item ? [] : popup.kind === 'staff' ? [
[t('clinic_user'), popup.item.name], [t('clinic_scope'), t(`clinic_scope_${popup.item.scope}`)], [t('clinic_is_doctor'), t(popup.item.is_doctor ? 'common_yes' : 'common_no')],
[t('clinic_specialty'), popup.item.specialty || '—'], [t('clinic_color'), popup.item.color ? <>{swatch(popup.item.color)} {popup.item.color}</> : '—'], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : popup.kind === 'type' ? [
[t('clinic_name'), popup.item.name], [t('clinic_duration'), popup.item.duration_minutes], [t('clinic_color'), <>{swatch(popup.item.color)} {popup.item.color}</>], [t('common_status'), t(popup.item.is_active ? 'common_active' : 'common_inactive')]
] : [];
return <div className="clinic-config">
{loadError && <InfoBar tone="error">{loadError}</InfoBar>}
<div className="toolbar"><h3 className="mt-0">{t('clinic_staff')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('staff')}>{t('clinic_add_staff')}</Button></div>
<ListView getRowKey={(s) => s.user_id} rows={staff} onRowClick={(s) => void open('staff', s)}
columns={[
{ key: 'name', label: t('clinic_user'), render: (s) => <span className="clinic-name">{s.is_doctor ? swatch(s.color) : null}{s.name}</span> },
{ key: 'scope', label: t('clinic_scope'), render: (s) => t(`clinic_scope_${s.scope}`) },
{ key: 'specialty', label: t('clinic_specialty'), render: (s) => s.specialty || '—' },
{ key: 'is_active', label: t('common_status'), render: (s) => <Badge color={s.is_active ? 'success' : 'muted'}>{t(s.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(s) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('staff', s).then(() => setEditing(true)); } }]} />
<div className="toolbar"><h3>{t('clinic_types')}</h3><Button variant="accent" icon={Plus} onClick={() => void open('type')}>{t('clinic_new_type')}</Button></div>
<ListView getRowKey={(x) => x.id} rows={types} onRowClick={(x) => void open('type', x)}
columns={[
{ key: 'name', label: t('clinic_name'), render: (x) => <span className="clinic-name">{swatch(x.color)}{x.name}</span> },
{ key: 'duration_minutes', label: t('clinic_duration') },
{ key: 'is_active', label: t('common_status'), render: (x) => <Badge color={x.is_active ? 'success' : 'muted'}>{t(x.is_active ? 'common_active' : 'common_inactive')}</Badge> }
]}
rowActions={(x) => [{ label: t('common_edit'), icon: Pencil, onClick: () => { void open('type', x).then(() => setEditing(true)); } }]} />
<h3>{t('clinic_schedules')}</h3>
<ListView getRowKey={(d) => d.id} rows={scheduleDoctors} onRowClick={(d) => void open('schedule', null, { doctor: d })}
columns={[
{ key: 'name', label: t('clinic_doctor'), render: (d) => <span className="clinic-name">{swatch(d.color)}{d.name}</span> },
{ key: 'schedules', label: t('clinic_schedules'), sortable: false, render: scheduleText }
]} />
<DetailPopup open={!!popup} title={popup?.kind === 'schedule' ? `${t('clinic_schedules')}: ${popup.doctor.name}` : popup?.kind === 'staff' ? (popup.item ? popup.item.name : t('clinic_add_staff')) : (popup?.item ? popup.item.name : t('clinic_new_type'))} onClose={close} width={760} height={popup?.kind === 'schedule' ? 680 : 480}>
{popup && <div className="clinic-form">
{error && <InfoBar tone="error">{error}</InfoBar>}
{editing && <DraftNotice draft={draft} />}
{!editing ? <>
{popup.kind === 'schedule'
? <div className="clinic-detail">{(popup.rows || []).length ? popup.rows.map((s) => <p key={s.id}><strong>{t(`calendar_${WEEKDAYS[s.weekday]}`)}:</strong> {String(s.starts_time).slice(0, 5)}–{String(s.ends_time).slice(0, 5)}</p>) : <p>—</p>}</div>
: <div className="clinic-detail">{detail.map(([label, val]) => <p key={label}><strong>{label}:</strong> {val}</p>)}</div>}
<div className="toolbar-left clinic-actions"><Button variant="accent" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button></div>
</> : <>
{popup.kind === 'staff' && <>
<FieldRow>
<Field label={t('clinic_user')} dirty={draft.isFieldDirty('userId')}>{popup.item ? <input className="input" readOnly value={popup.item.name} /> : <ComboBox value={v.userId} onChange={(x) => draft.setField('userId', x)} options={[{ value: '', label: t('common_select') }, ...users.filter((u) => u.is_active && !staffIds.has(u.id)).map((u) => ({ value: u.id, label: u.name }))]} />}</Field>
<Field label={t('clinic_scope')} dirty={draft.isFieldDirty('scope')}><ComboBox value={v.scope} onChange={(x) => draft.setValues((old) => ({ ...old, scope: x, isDoctor: x === 'own' ? true : old.isDoctor }))} options={['all', 'own'].map((x) => ({ value: x, label: t(`clinic_scope_${x}`) }))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_specialty')} dirty={draft.isFieldDirty('specialty')}><input className="input" maxLength={120} value={v.specialty} disabled={!v.isDoctor} onChange={(e) => draft.setField('specialty', e.target.value)} /></Field>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} disabled={!v.isDoctor} onChange={(e) => draft.setField('color', e.target.value)} /></Field>
</FieldRow>
<div className="clinic-checks">
<label className="calendar-filter"><input type="checkbox" checked={v.isDoctor} disabled={v.scope === 'own'} onChange={(e) => draft.setField('isDoctor', e.target.checked)} /> {t('clinic_is_doctor')}</label>
<label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('clinic_active')}</label>
</div>
</>}
{popup.kind === 'type' && <>
<FieldRow>
<Field label={t('clinic_name')} dirty={draft.isFieldDirty('name')}><input className="input" maxLength={120} value={v.name} onChange={(e) => draft.setField('name', e.target.value)} /></Field>
<Field label={t('clinic_duration')} dirty={draft.isFieldDirty('durationMinutes')}><input className="input" type="number" min={5} max={480} step={5} value={v.durationMinutes} onChange={(e) => draft.setField('durationMinutes', Number(e.target.value))} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('clinic_color')} dirty={draft.isFieldDirty('color')}><input className="input clinic-color" type="color" value={v.color} onChange={(e) => draft.setField('color', e.target.value)} /></Field>
<Field label={t('common_status')}><label className="calendar-filter"><input type="checkbox" checked={v.isActive} onChange={(e) => draft.setField('isActive', e.target.checked)} /> {t('clinic_active')}</label></Field>
</FieldRow>
</>}
{popup.kind === 'schedule' && <>
{rows.map((s, i) => <div className="clinic-schedule-row" key={i}>
<Field label={t('calendar_day')}><ComboBox value={s.weekday} onChange={(x) => setRows(rows.map((r, j) => j === i ? { ...r, weekday: Number(x) } : r))} options={WEEKDAYS.map((x, n) => ({ value: n, label: t(`calendar_${x}`) }))} /></Field>
<Field label={t('calendar_start')}><input className="input" type="time" value={s.startsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, startsTime: e.target.value } : r))} /></Field>
<Field label={t('calendar_end')}><input className="input" type="time" value={s.endsTime} onChange={(e) => setRows(rows.map((r, j) => j === i ? { ...r, endsTime: e.target.value } : r))} /></Field>
<Button icon={Trash2} variant="danger" aria-label={t('common_delete')} title={t('common_delete')} onClick={() => setRows(rows.filter((_, j) => j !== i))} />
</div>)}
<div><Button icon={Plus} onClick={() => setRows([...rows, { weekday: 0, startsTime: '09:00', endsTime: '17:00' }])}>{t('clinic_add_segment')}</Button></div>
</>}
<div className="toolbar-left clinic-actions"><Button variant="accent" disabled={!canSave || (!!popup.item || popup.kind === 'schedule') && !draft.isDirty} onClick={() => void save()}>{t('common_save')}</Button><Button onClick={cancelEdit}>{t('common_cancel')}</Button></div>
</>}
</div>}
</DetailPopup>
</div>;
}
@@ -0,0 +1,79 @@
import { UserPlus } from 'lucide-react';
import { useUi } from '../../context/ui-context.jsx';
import { InfoBar } from '../../components/InfoBar.jsx';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { DateTimeEditor } from '../../components/DateTimeEditor.jsx';
import { DatePicker } from '../../components/DatePicker.jsx';
import { RecurrenceEditor } from '../../components/RecurrenceEditor.jsx';
import { plus } from './clinicUtils.js';
// Formularios de la Agenda médica con la misma estructura que "Nueva reunión" de Calendario:
// campos cortos en pares (FieldRow), texto largo a lo ancho, fecha + hora con DateTimeEditor.
const patientLabel = (p) => `${p.last_names} ${p.first_names}${p.identification || p.phone ? ` · ${p.identification || p.phone}` : ''}`;
export function AppointmentForm({ draft, doctors, types, patients, search, onSearch, onNewPatient, slots, upcoming = [] }) {
const { t } = useUi();
const v = draft.values;
const duration = (typeId) => types.find((z) => z.id === Number(typeId))?.duration_minutes || 30;
return <>
<FieldRow>
<Field label={t('clinic_search_patient')}><input className="input" value={search} onChange={(e) => onSearch(e.target.value)} /></Field>
<Field label={t('clinic_patient')} dirty={draft.isFieldDirty('patientId')}>
<div className="clinic-inline">
<ComboBox value={v.patientId} onChange={(x) => draft.setField('patientId', x)} options={[{ value: '', label: t('common_select') }, ...patients.map((p) => ({ value: p.id, label: patientLabel(p) }))]} />
<Button icon={UserPlus} onClick={onNewPatient} title={t('clinic_new_patient')} aria-label={t('clinic_new_patient')} />
</div>
</Field>
</FieldRow>
{upcoming.length > 0 && <InfoBar tone="warning">{t('clinic_patient_upcoming')}<ul className="clinic-upcoming">{upcoming.slice(0, 5).map((a) => <li key={a.id}>{a.starts_at.slice(0, 16)} · {a.doctor_name} · {a.type_name} · {t(`clinic_status_${a.status}`)}</li>)}</ul>{upcoming.length > 5 && `+${upcoming.length - 5}`}</InfoBar>}
<FieldRow>
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}><ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} /></Field>
<Field label={t('clinic_type')} dirty={draft.isFieldDirty('typeId')}><ComboBox value={v.typeId} onChange={(x) => draft.setValues((old) => ({ ...old, typeId: x, endsAt: plus(old.startsAt, duration(x)) }))} options={[{ value: '', label: t('common_select') }, ...types.map((z) => ({ value: z.id, label: `${z.name} (${z.duration_minutes} min)` }))]} /></Field>
</FieldRow>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setValues((old) => ({ ...old, startsAt: x, endsAt: plus(x, duration(old.typeId)) }))} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
{slots.length > 0 && <Field label={t('clinic_available')}><div className="clinic-slots">{slots.slice(0, 80).map((s) => <Button key={s.startsAt} size="sm" variant={s.startsAt === v.startsAt ? 'accent' : undefined} onClick={() => draft.setValues((old) => ({ ...old, startsAt: s.startsAt, endsAt: s.endsAt }))}>{s.startsAt.slice(11, 16)}</Button>)}</div></Field>}
<FieldRow>
<Field label={t('clinic_channel')} dirty={draft.isFieldDirty('channel')}><ComboBox value={v.channel} onChange={(x) => draft.setField('channel', x)} options={['phone', 'whatsapp', 'in_person', 'other'].map((x) => ({ value: x, label: t(`clinic_channel_${x}`) }))} /></Field>
<div className="field" />
</FieldRow>
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
</>;
}
export function PatientForm({ draft }) {
const { t } = useUi();
const v = draft.values;
const input = (k, label, extra = {}) => <Field label={t(label)} dirty={draft.isFieldDirty(k)}><input className="input" value={v[k] || ''} onChange={(e) => draft.setField(k, e.target.value)} {...extra} /></Field>;
return <>
<FieldRow>{input('firstNames', 'clinic_first_names', { maxLength: 120 })}{input('lastNames', 'clinic_last_names', { maxLength: 120 })}</FieldRow>
<FieldRow>{input('identification', 'clinic_identification', { maxLength: 40 })}{input('phone', 'clinic_phone', { maxLength: 40, type: 'tel' })}</FieldRow>
<FieldRow>
{input('email', 'clinic_email', { maxLength: 160, type: 'email' })}
<Field label={t('clinic_birth_date')} dirty={draft.isFieldDirty('birthDate')}><DatePicker value={v.birthDate || ''} onChange={(x) => draft.setField('birthDate', x || '')} label={t('clinic_birth_date')} /></Field>
</FieldRow>
<Field label={t('clinic_notes')} dirty={draft.isFieldDirty('notes')}><textarea className="textarea" maxLength={500} value={v.notes || ''} onChange={(e) => draft.setField('notes', e.target.value)} /></Field>
</>;
}
export function BlockForm({ draft, doctors, fixedDoctor }) {
const { t } = useUi();
const v = draft.values;
return <>
<FieldRow>
<Field label={t('clinic_doctor')} dirty={draft.isFieldDirty('doctorId')}>{fixedDoctor ? <input className="input" readOnly value={doctors.find((d) => d.id === v.doctorId)?.name || ''} /> : <ComboBox value={v.doctorId} onChange={(x) => draft.setField('doctorId', x)} options={[{ value: '', label: t('common_select') }, ...doctors.map((d) => ({ value: d.id, label: d.name }))]} />}</Field>
<Field label={t('clinic_kind')} dirty={draft.isFieldDirty('kind')}><ComboBox value={v.kind} onChange={(x) => draft.setField('kind', x)} options={['surgery', 'vacation', 'other'].map((x) => ({ value: x, label: t(`clinic_kind_${x}`) }))} /></Field>
</FieldRow>
<Field label={t('clinic_title')} dirty={draft.isFieldDirty('title')}><input className="input" maxLength={160} value={v.title} onChange={(e) => draft.setField('title', e.target.value)} /></Field>
<FieldRow>
<Field label={t('calendar_start')} dirty={draft.isFieldDirty('startsAt')}><DateTimeEditor value={v.startsAt} onChange={(x) => draft.setField('startsAt', x)} label={t('calendar_start')} /></Field>
<Field label={t('calendar_end')} dirty={draft.isFieldDirty('endsAt')}><DateTimeEditor value={v.endsAt} onChange={(x) => draft.setField('endsAt', x)} label={t('calendar_end')} isEnd /></Field>
</FieldRow>
<RecurrenceEditor value={v.recurrence} start={v.startsAt} onChange={(x) => draft.setField('recurrence', x)} />
</>;
}
+23 -1
View File
@@ -1,4 +1,26 @@
.clinic-page{padding:16px;min-width:0}.clinic-page .page-title{display:flex;align-items:center;gap:8px}.clinic-schedule-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}.clinic-event{display:block;width:100%;border:0;border-left:4px solid rgb(var(--accent));background:rgb(var(--accent)/.16);color:rgb(var(--body));border-radius:4px;text-align:left;padding:6px;margin:2px 0;cursor:pointer;font-size:.78rem}.clinic-event.block{border-left-color:rgb(var(--warning));background:rgb(var(--warning)/.2)}.clinic-list{display:grid;gap:6px;margin:12px 0}.clinic-list>div,.clinic-row{border:1px solid rgb(var(--line));background:rgb(var(--surface));padding:8px;border-radius:var(--r-sm);text-align:left;color:rgb(var(--body))}.clinic-list .clinic-event{display:inline-block;width:auto;min-width:260px;margin-left:12px}.clinic-form{display:grid;gap:12px}.clinic-form .field{min-width:0}.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}.clinic-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:4px 12px}.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}.clinic-config{display:grid;gap:8px;max-width:720px}.clinic-config h3{margin-top:24px}@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}} .clinic-page{padding:16px;min-width:0}
.clinic-page .page-title{display:flex;align-items:center;gap:8px}
/* Formularios: misma estructura que "Nueva reunión" (FieldRow en pares, acciones al pie). */
.clinic-form{display:grid;gap:12px;align-content:start}
.clinic-form .field{min-width:0}
.clinic-inline{display:flex;gap:8px;align-items:center}
.clinic-inline>:first-child{flex:1;min-width:0}
.clinic-actions{margin-top:18px;flex-wrap:wrap}
.clinic-checks{display:flex;gap:24px;flex-wrap:wrap}
.clinic-color{padding:2px;height:36px;max-width:120px}
.clinic-upcoming{margin:6px 0 0;padding-left:18px}
.clinic-slots{display:flex;flex-wrap:wrap;gap:4px}
.clinic-detail{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px 16px}
.clinic-detail p{margin:5px 0;overflow-wrap:anywhere}
.clinic-name{display:inline-flex;align-items:center;gap:8px}
.clinic-name .calendar-swatch,.clinic-detail .calendar-swatch{display:inline-block;width:10px;height:10px;border-radius:3px;flex:none}
.clinic-pager{margin-top:12px;align-items:center}
.clinic-config{display:grid;gap:8px}
.clinic-config h3:not(.mt-0){margin:20px 0 4px}
.clinic-config h3.mt-0{margin-bottom:4px}
.clinic-schedule-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(110px,1fr) minmax(110px,1fr) auto;gap:8px;align-items:end}
.clinic-schedule-row>.btn{margin-bottom:2px}
@media(max-width:640px){.clinic-detail{grid-template-columns:1fr}.clinic-schedule-row{grid-template-columns:1fr 1fr}}
/* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */ /* Entradas de la agenda en el calendario compartido: color del medico (--doctor). */
.meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px} .meeting-calendar .calendar-entry.clinic-appt{color:rgb(var(--body));background:color-mix(in srgb,var(--doctor) 18%,transparent);box-shadow:inset 3px 0 0 var(--doctor);padding-left:8px}
.meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)} .meeting-calendar .calendar-entry.clinic-appt.deleted{background:transparent;outline:1px dashed color-mix(in srgb,var(--doctor) 60%,transparent)}
@@ -0,0 +1,70 @@
// Utilidades de la Agenda médica (fechas "YYYY-MM-DD HH:MM:SS" en hora de Ecuador, sin zona).
export const at = (day, hour = '09:00') => `${day} ${hour}:00`;
export 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', ' ');
};
export const readStored = (key, fallback) => {
try {
return JSON.parse(localStorage.getItem(key)) ?? fallback;
} catch {
return fallback;
}
};
export const WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
export const initialAppointment = (day, doctorId, slot, patientId) => ({
doctorId: doctorId || '',
patientId: patientId || '',
typeId: '',
startsAt: slot || at(day),
endsAt: plus(slot || at(day), 30),
channel: 'phone',
notes: ''
});
export const initialPatient = (identification = '') => ({
firstNames: '',
lastNames: '',
identification,
phone: '',
email: '',
birthDate: '',
notes: ''
});
export const initialBlock = (day, doctorId) => ({
doctorId: doctorId || '',
kind: 'surgery',
title: '',
startsAt: at(day),
endsAt: plus(at(day), 60),
recurrence: null
});
export 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
});
export 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
});
export 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
});
+2 -2
View File
@@ -303,14 +303,14 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px; display: flex; align-items: center; justify-content: center; z-index: 250; padding: 16px;
} }
.dialog-surface { .dialog-surface {
width: 420px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg); width: 460px; max-width: 100%; background: rgb(var(--surface)); border-radius: var(--r-lg);
overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4); overflow: hidden; box-shadow: 0 20px 48px rgb(0 0 0 / .4);
} }
.dialog-stripe { height: 4px; } .dialog-stripe { height: 4px; }
.dialog-body { padding: 20px; } .dialog-body { padding: 20px; }
.dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; } .dialog-title { font-size: 1rem; font-weight: 700; margin-bottom: 8px; }
.dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; } .dialog-message { font-size: .84rem; color: rgb(var(--muted)); line-height: 1.5; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); } .dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid rgb(var(--line)); }
/* --- InfoBar --- */ /* --- InfoBar --- */
.infobar { .infobar {
+1 -1
View File
@@ -14,7 +14,7 @@ const TTL_MS = 7 * 24 * 60 * 60 * 1000;
const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i; const SECRET_FIELD = /pass(word|wd)?|token|secret|^pin$|pin$/i;
const DRAFT_FIELDS = { const DRAFT_FIELDS = {
'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'], 'project-new': ['projectTypeId', 'priorityId', 'statusId', 'requestingDepartmentId', 'responsibleDepartmentId', 'startDate', 'estimatedEndDate', 'riskLevel'],
'clinic-staff': ['editVersion','scope','isDoctor','specialty','color','isActive'], 'clinic-staff': ['editVersion','userId','scope','isDoctor','specialty','color','isActive'],
'clinic-type': ['editVersion','name','durationMinutes','color','isActive'], 'clinic-type': ['editVersion','name','durationMinutes','color','isActive'],
'clinic-schedule': ['rows'], 'clinic-schedule': ['rows'],
'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'], 'clinic-appointment': ['editVersion','doctorId','patientId','typeId','startsAt','endsAt','channel'],