Agenda UX: nav de fecha con Hoy, linea de hora actual, colores por categoria, tooltips, vista Semana y Reagendar
This commit is contained in:
@@ -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,6 +921,17 @@ 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">
|
||||
{/* 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"
|
||||
@@ -822,6 +939,26 @@ const Agenda: FC = () => {
|
||||
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 })}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user