Agenda UX: nav de fecha con Hoy, linea de hora actual, colores por categoria, tooltips, vista Semana y Reagendar

This commit is contained in:
2026-09-01 21:59:48 +00:00
parent bf36fb5586
commit 7261da3ab1

View File

@@ -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<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
@@ -74,6 +74,10 @@ const Agenda: FC = () => {
const [savingBloqueo, setSavingBloqueo] = useState(false);
const [dragApt, setDragApt] = useState<Appointment | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(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<HTMLDivElement | null>(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<string, string> = {
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<string, string> = {
consulta: 'border-l-sky-500',
tratamiento: 'border-l-violet-500',
procedimiento: 'border-l-amber-500',
paquete: 'border-l-emerald-600',
};
const categoryLabels: Record<string, string> = {
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<HTMLElement>, 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 = () => {
<span className="text-xs text-theme-muted">
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
</span>
<span className="inline-flex items-center gap-x-3 gap-y-1 text-xs text-theme-muted">
· Barra por categoría:
{Object.entries(categoryLabels).map(([key, label]) => (
<span key={key} className="inline-flex items-center gap-1">
<span className={`inline-block h-3 w-1 rounded-full border-l-2 ${categoryBarClass[key]}`} />
{label}
</span>
))}
</span>
</div>
);
// 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<string, string> = date ? { date } : {};
// Vista Semana: el API ya soporta date_from/date_to (una sola llamada por rango)
const params: Record<string, string> = 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 = () => {
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row sm:items-end gap-3 sm:gap-4 mb-4 sm:mb-6">
<Input
label="Fecha"
type="date"
value={date}
onChange={(e) => 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 */}
<div className="flex items-end gap-1.5">
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => shiftDate(-1)}
title={viewMode === 'semana' ? 'Semana anterior' : 'Día anterior'}
>
<ChevronLeft size={16} />
</Button>
<Input
label="Fecha"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="sm:max-w-[180px]"
/>
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => shiftDate(1)}
title={viewMode === 'semana' ? 'Semana siguiente' : 'Día siguiente'}
>
<ChevronRight size={16} />
</Button>
<Button
variant="outline"
size="sm"
className="mb-[1px]"
onClick={() => setDate(todayStr)}
disabled={date === todayStr}
title="Ir a hoy"
>
Hoy
</Button>
</div>
<Select
label="Estado"
options={stateOptions}
@@ -857,6 +994,15 @@ const Agenda: FC = () => {
<Rows3 size={16} className="mr-1" />
Por hora
</Button>
<Button
variant={viewMode === 'semana' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('semana')}
title="Semana completa: 7 días por médico"
>
<CalendarRange size={16} className="mr-1" />
Semana
</Button>
</div>
</div>
@@ -942,7 +1088,7 @@ const Agenda: FC = () => {
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{viewMode !== 'lista' && (
{(viewMode === 'doctores' || viewMode === 'horas') && (
<button
type="button"
onClick={() => setQuickFilter(quickFilter === 'libres' ? '' : 'libres')}
@@ -1061,10 +1207,15 @@ const Agenda: FC = () => {
</tr>
</thead>
<tbody>
{gridSlots.map((slot) => (
<tr key={slot}>
<td className="sticky left-0 z-10 bg-theme-surface px-2 py-1 text-[11px] font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
{gridSlots.map((slot) => {
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<tr key={slot} data-grid-slot={slot.toFixed(2)}>
<td className={`sticky left-0 z-10 bg-theme-surface px-2 py-1 text-[11px] font-semibold align-top whitespace-nowrap border-b border-theme-border ${isNowSlot ? 'text-theme-accent border-t-2 border-t-theme-accent' : 'text-theme-muted'}`}>
{horaFloatStr(slot)}
{isNowSlot && (
<span className="ml-1 rounded bg-theme-accent px-1 text-[9px] font-medium text-theme-inverse">ahora</span>
)}
</td>
{gridColumns.map((col) => {
const cell = gridCellAppointments(col.doctorId, slot);
@@ -1076,7 +1227,7 @@ const Agenda: FC = () => {
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''} ${isNowSlot ? 'border-t-2 border-t-theme-accent' : ''}`}
>
{bloqueo ? (
<div
@@ -1099,12 +1250,13 @@ const Agenda: FC = () => {
cell.map((apt) => (
<div
key={apt.id}
onClick={() => openAptDetail(apt)}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
{...chipDragProps(apt)}
className={`mb-1 rounded-lg border-l-4 px-2 py-1 text-[11px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
} ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
@@ -1118,7 +1270,8 @@ const Agenda: FC = () => {
);
})}
</tr>
))}
);
})}
</tbody>
</table>
</div>
@@ -1133,18 +1286,22 @@ const Agenda: FC = () => {
/>
) : (
<div>
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<div ref={horasScrollRef} className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[190px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{gridSlots.map((slot) => (
<th key={slot} className="min-w-[52px] p-1 text-center text-[10px] font-medium text-theme-muted border-b border-l border-theme-border">
{gridSlots.map((slot) => {
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<th key={slot} data-grid-slot={slot.toFixed(2)} className={`min-w-[52px] p-1 text-center text-[10px] font-medium border-b border-l border-theme-border ${isNowSlot ? 'text-theme-accent border-b-2 border-b-theme-accent' : 'text-theme-muted'}`}>
{horaFloatStr(slot)}
{isNowSlot && <span className="block text-[8px] font-semibold uppercase">ahora</span>}
</th>
))}
);
})}
</tr>
</thead>
<tbody>
@@ -1169,13 +1326,14 @@ const Agenda: FC = () => {
const cell = gridCellAppointments(col.doctorId, slot);
const bloqueo = bloqueoEnCelda(col.doctorId, slot);
const cellKey = `${col.key}-${slot}`;
const isNowSlot = nowSlot !== null && slot === nowSlot;
return (
<td
key={slot}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, slot); }}
className={`p-0.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
className={`p-0.5 align-top border-b border-theme-border transition-colors ${isNowSlot ? 'border-l-2 border-l-theme-accent bg-theme-accent-bg/40' : 'border-l'} ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
@@ -1198,12 +1356,13 @@ const Agenda: FC = () => {
cell.map((apt) => (
<div
key={apt.id}
onClick={() => openAptDetail(apt)}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
{...chipDragProps(apt)}
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card transition-shadow hover:shadow-soft ${
DRAGGABLE_STATES.includes(apt.state) ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
} ${gridStateClass[apt.state] || 'border-theme-border-strong bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
} ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'} ${quickFilter === 'libres' ? 'opacity-40' : ''}`}
>
<div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
@@ -1223,6 +1382,100 @@ const Agenda: FC = () => {
{gridLegend}
</div>
)
) : viewMode === 'semana' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista semanal."
/>
) : (
<div>
<p className="text-sm text-theme-muted mb-2">
Semana del {weekDates[0]} al {weekDates[6]} · usa ‹ › para moverte por semana
</p>
<div className="overflow-x-auto rounded-2xl border border-theme-border shadow-card">
<table className="w-full border-collapse bg-theme-surface">
<thead>
<tr>
<th className="sticky left-0 z-10 min-w-[150px] bg-theme-surface p-2.5 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Médico
</th>
{weekDates.map((d, i) => (
<th
key={d}
className={`min-w-[132px] p-2 text-center text-[11px] font-medium border-b border-l border-theme-border ${
d === todayStr ? 'bg-theme-accent-bg text-theme-accent' : 'text-theme-muted'
}`}
>
{WEEKDAY_LABELS[i]} {d.slice(8, 10)}/{d.slice(5, 7)}
{d === todayStr && <span className="block text-[9px] font-semibold uppercase">hoy</span>}
</th>
))}
</tr>
</thead>
<tbody>
{gridColumns.map((col) => (
<tr key={col.key}>
<td className="sticky left-0 z-10 bg-theme-surface p-2.5 border-b border-theme-border">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-[10px] font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-theme-heading">{col.label}</p>
<p className="text-[10px] text-theme-muted">
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
</div>
</td>
{weekDates.map((d) => {
const dayApts = gridAppointments
.filter((a) => a.date === d && (col.doctorId ? a.doctor_id === col.doctorId : !a.doctor_id))
.sort((a, b) => (timeToFloat(a.time) ?? 0) - (timeToFloat(b.time) ?? 0));
const dayBloqueo = col.doctorId
? bloqueos.find((b) => b.doctor_id === col.doctorId && b.date === d && b.all_day)
: undefined;
return (
<td key={d} className={`p-1 align-top border-b border-l border-theme-border ${d === todayStr ? 'bg-theme-accent-bg/30' : ''}`}>
{dayBloqueo ? (
<div
className="rounded border border-theme-border py-1.5 text-center text-[10px] font-medium text-theme-muted"
style={{ backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(0,0,0,0.05) 6px, rgba(0,0,0,0.05) 12px)' }}
title={`Bloqueado — ${dayBloqueo.motivo || 'sin motivo'}`}
>
<Ban size={10} className="inline mr-0.5" />
{dayBloqueo.motivo || 'Bloqueado'}
</div>
) : dayApts.length === 0 ? (
<div className="rounded border border-dashed border-theme-border py-1.5 text-center text-[10px] text-theme-muted">
Libre
</div>
) : (
dayApts.map((apt) => (
<div
key={apt.id}
onClick={() => { setTooltip(null); openAptDetail(apt); }}
onMouseEnter={(e) => showAptTooltip(e, apt)}
onMouseLeave={() => setTooltip(null)}
className={`mb-0.5 rounded border-l-2 px-1 py-0.5 text-[10px] leading-tight shadow-card cursor-pointer transition-shadow hover:shadow-soft ${aptCategoryBar(apt)} ${gridStateClass[apt.state] || 'bg-theme-bg text-theme-heading'}`}
>
<span className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
<span className="font-semibold">{apt.time}</span> {firstName(apt.patient)}
</div>
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : visibleAppointments.length === 0 ? (
<EmptyState
title="Sin citas"
@@ -1297,6 +1550,25 @@ const Agenda: FC = () => {
</Card.Body>
</Card>
{/* Tooltip flotante de cita (hover en tarjetas de las vistas de grid) */}
{tooltip && (
<div
className="pointer-events-none fixed z-50 w-64 rounded-xl border border-theme-border bg-theme-surface p-3 text-xs shadow-soft"
style={tooltip.above
? { left: tooltip.x, top: tooltip.y, transform: 'translateY(calc(-100% - 6px))' }
: { left: tooltip.x, top: tooltip.y + 6 }}
>
<p className="font-semibold text-theme-heading">{tooltip.apt.time} · {tooltip.apt.patient}</p>
<p className="mt-1 text-theme-muted">
{tooltip.apt.service}
{tooltip.apt.service_category ? ` · ${categoryLabels[tooltip.apt.service_category] || tooltip.apt.service_category}` : ''}
</p>
<p className="text-theme-muted">Tel: {tooltip.apt.phone || '—'}</p>
<p className="text-theme-muted">Médico: {tooltip.apt.doctor || 'Sin asignar'}</p>
<p className="mt-1 text-theme-muted">{confirmTitle(tooltip.apt)}</p>
</div>
)}
{/* Modal de bloqueos de agenda */}
<Modal
isOpen={bloqueosOpen}
@@ -1615,7 +1887,7 @@ const Agenda: FC = () => {
<Modal
isOpen={!!detailApt}
onClose={() => setDetailApt(null)}
title={editMode ? 'Editar reserva' : 'Detalle de la cita'}
title={editMode ? 'Reagendar cita' : 'Detalle de la cita'}
maxWidth="md"
footer={
editMode ? (
@@ -1725,7 +1997,7 @@ const Agenda: FC = () => {
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
<Edit2 size={16} className="mr-1.5" /> Editar reserva
<CalendarClock size={16} className="mr-1.5" /> Reagendar
</Button>
</div>
</div>
@@ -1733,7 +2005,8 @@ const Agenda: FC = () => {
{detailApt && editMode && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
{detailApt.patient} · {detailApt.service}
{detailApt.patient} · {detailApt.service} — cambia fecha, hora y (opcional) médico sin borrar la cita.
Se aplican las validaciones de horario (8:00-19:00) y solapamiento por médico.
</p>
<div className="grid grid-cols-2 gap-4">
<Input
@@ -1742,12 +2015,9 @@ const Agenda: FC = () => {
value={editForm.date}
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
/>
<Input
<Select
label="Hora *"
type="time"
min="08:00"
max="19:00"
step={900}
options={QUARTER_TIME_OPTIONS}
value={editForm.time}
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
/>