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