diff --git a/frontend/src/pages/Agenda.tsx b/frontend/src/pages/Agenda.tsx index 5ace19a..6058c08 100644 --- a/frontend/src/pages/Agenda.tsx +++ b/frontend/src/pages/Agenda.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; -import { useEffect, useState, useCallback } from 'react'; -import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, Edit2, Ban, Trash2, Phone } from 'lucide-react'; +import { useEffect, useState, useCallback, useRef } from 'react'; +import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Trash2, Phone } from 'lucide-react'; import Layout from '../components/Layout'; import { Card, @@ -59,7 +59,7 @@ const Agenda: FC = () => { const [stateFilter, setStateFilter] = useState(''); const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : '')); const [staffTypeFilter, setStaffTypeFilter] = useState<'' | 'doctor' | 'cosmetologa'>(''); - const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores'); + const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas' | 'semana'>('doctores'); const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar'>(''); const [detailApt, setDetailApt] = useState(null); const [editMode, setEditMode] = useState(false); @@ -74,6 +74,10 @@ const Agenda: FC = () => { const [savingBloqueo, setSavingBloqueo] = useState(false); const [dragApt, setDragApt] = useState(null); const [dropTarget, setDropTarget] = useState(null); + // Tooltip flotante de tarjeta de cita (hover en vistas de grid) + const [tooltip, setTooltip] = useState<{ apt: Appointment; x: number; y: number; above: boolean } | null>(null); + const horasScrollRef = useRef(null); + const autoScrollKey = useRef(''); const [form, setForm] = useState({ patient_id: '', @@ -124,6 +128,30 @@ const Agenda: FC = () => { const nowFloat = nowDate.getHours() + nowDate.getMinutes() / 60; const todayStr = `${nowDate.getFullYear()}-${String(nowDate.getMonth() + 1).padStart(2, '0')}-${String(nowDate.getDate()).padStart(2, '0')}`; + // Fecha local en formato YYYY-MM-DD (evita el desfase UTC de toISOString) + const fmtLocalDate = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + + // Navegación rápida de fecha: día a día (semana completa en vista Semana) + const shiftDate = (dir: number) => { + const base = new Date(`${date}T12:00:00`); + base.setDate(base.getDate() + dir * (viewMode === 'semana' ? 7 : 1)); + setDate(fmtLocalDate(base)); + }; + + // Semana (lunes a domingo) que contiene la fecha visible + const weekDatesOf = (d: string): string[] => { + const start = new Date(`${d}T12:00:00`); + start.setDate(start.getDate() - ((start.getDay() + 6) % 7)); + return Array.from({ length: 7 }, (_, i) => { + const day = new Date(start); + day.setDate(day.getDate() + i); + return fmtLocalDate(day); + }); + }; + const weekDates = weekDatesOf(date); + const WEEKDAY_LABELS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']; + // Candidata a check-in: confirmada, de hoy, ya empezó y aún no termina const isCheckinDue = (a: Appointment): boolean => { const start = timeToFloat(a.time); @@ -286,7 +314,7 @@ const Agenda: FC = () => { DRAGGABLE_STATES.includes(apt.state) ? { draggable: true, - onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setDragApt(apt); }, + onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setTooltip(null); setDragApt(apt); }, onDragEnd: () => { setDragApt(null); setDropTarget(null); }, } : {}; @@ -342,14 +370,48 @@ const Agenda: FC = () => { const initials = (name: string) => name.split(' ').filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); - // Colores por estado siguiendo la paleta de la marca (coral/mint/sky/lilac) + // Colores por estado siguiendo la paleta de la marca (coral/mint/sky/lilac). + // El color se lleva en el fondo; la barra lateral izquierda indica la categoría del servicio. const gridStateClass: Record = { - pending: 'border-brand-coral bg-theme-accent-bg text-theme-heading', - confirmed: 'border-emerald-700 bg-brand-mint text-theme-heading', - arrived: 'border-sky-700 bg-brand-sky text-theme-heading', - in_progress: 'border-violet-600 bg-brand-lilac text-theme-heading', - done: 'border-theme-border-strong bg-theme-bg text-theme-muted', - no_show: 'border-rose-400 bg-rose-50 text-rose-700', + pending: 'bg-theme-accent-bg text-theme-heading', + confirmed: 'bg-brand-mint text-theme-heading', + arrived: 'bg-brand-sky text-theme-heading', + in_progress: 'bg-brand-lilac text-theme-heading', + done: 'bg-theme-bg text-theme-muted', + no_show: 'bg-rose-50 text-rose-700', + }; + + // Barra lateral por categoría del servicio (service_category ya viene del API) + const categoryBarClass: Record = { + consulta: 'border-l-sky-500', + tratamiento: 'border-l-violet-500', + procedimiento: 'border-l-amber-500', + paquete: 'border-l-emerald-600', + }; + const categoryLabels: Record = { + consulta: 'Consulta', + tratamiento: 'Tratamiento', + procedimiento: 'Procedimiento', + paquete: 'Paquete', + }; + const aptCategoryBar = (apt: Appointment) => + categoryBarClass[apt.service_category || ''] || 'border-l-theme-border-strong'; + + // Slot del grid que contiene la hora actual (solo si la fecha visible es hoy) + const nowSlot = date === todayStr + ? aptSlot(`${String(nowDate.getHours()).padStart(2, '0')}:${String(nowDate.getMinutes()).padStart(2, '0')}`) + : null; + + // Tooltip flotante: posición fija para que no lo recorten los contenedores con overflow + const showAptTooltip = (e: React.MouseEvent, apt: Appointment) => { + const r = e.currentTarget.getBoundingClientRect(); + const above = r.bottom > window.innerHeight - 200; + setTooltip({ + apt, + x: Math.max(8, Math.min(r.left, window.innerWidth - 280)), + y: above ? r.top : r.bottom, + above, + }); }; const gridStateLegend: { state: string; label: string; dot: string }[] = [ @@ -373,14 +435,55 @@ const Agenda: FC = () => { · Las citas canceladas no bloquean horario: su hueco aparece como libre. + + · Barra por categoría: + {Object.entries(categoryLabels).map(([key, label]) => ( + + + {label} + + ))} + ); + // Auto-scroll del grid a ~1h antes de la hora actual. + // Se llama al terminar load() (post-render) para que el Skeleton no remonte + // la tabla y pierda el scroll; solo si la fecha visible es hoy y una sola + // vez por (fecha, vista), para no pelear con el scroll manual del usuario. + const scrollToNowIfToday = useCallback(() => { + if (date !== todayStr) return; + if (viewMode !== 'doctores' && viewMode !== 'horas') return; + const key = `${date}-${viewMode}`; + if (autoScrollKey.current === key) return; + if (nowFloat < GRID_START || nowFloat >= GRID_END) return; + autoScrollKey.current = key; + const target = Math.max( + GRID_START, + Math.floor((nowFloat - 1) * SLOTS_PER_HOUR + 1e-6) / SLOTS_PER_HOUR + ); + setTimeout(() => { + const el = document.querySelector(`[data-grid-slot="${target.toFixed(2)}"]`); + if (!el) return; + if (viewMode === 'horas' && horasScrollRef.current) { + // En "Por hora" el desplazamiento es horizontal dentro del contenedor + horasScrollRef.current.scrollLeft = Math.max(0, (el as HTMLElement).offsetLeft - 200); + } else { + el.scrollIntoView({ block: 'start' }); + window.scrollBy(0, -90); // compensa el encabezado de la página + } + }, 80); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [date, viewMode]); + const load = useCallback(async () => { try { setLoading(true); setError(null); - const params: Record = date ? { date } : {}; + // Vista Semana: el API ya soporta date_from/date_to (una sola llamada por rango) + const params: Record = viewMode === 'semana' + ? { date_from: weekDatesOf(date)[0], date_to: weekDatesOf(date)[6], page_size: '200' } + : date ? { date } : {}; const res = await odooApi.getAppointments(params); if (res.status === 'success') { setAppointments(res.appointments); @@ -392,8 +495,9 @@ const Agenda: FC = () => { console.error(err); } finally { setLoading(false); + scrollToNowIfToday(); } - }, [date]); + }, [date, viewMode, scrollToNowIfToday]); const loadReferences = useCallback(async () => { try { @@ -492,7 +596,9 @@ const Agenda: FC = () => { setDetailApt(null); await load(); } catch (err) { - toast.error('Error al actualizar la reserva'); + // Mostrar el rechazo del servidor (horario 8-19h, solapamiento por médico, bloqueos) + const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast.error(msg || 'Error al actualizar la reserva'); console.error(err); } finally { setSavingEdit(false); @@ -815,13 +921,44 @@ const Agenda: FC = () => {
- setDate(e.target.value)} - className="sm:max-w-[180px]" - /> + {/* Navegación rápida de fecha: día anterior/siguiente (semana en vista Semana) y Hoy */} +
+ + setDate(e.target.value)} + className="sm:max-w-[180px]" + /> + + +
{ value={editForm.date} onChange={(e) => setEditForm({ ...editForm, date: e.target.value })} /> - setEditForm({ ...editForm, time: e.target.value })} />