Mejoras integrales: visitas, POS, WACRM, reportes, temas y módulos legacy

- Módulo Visitas completo (auto desde agenda, insumos con descargo de
  inventario, fotos antes/después y documentos, receta imprimible)
- Punto de Venta (catálogo + ticket sticky, cobro con cambio, pago con
  puntos monedero, ticket imprimible)
- WACRM: leads automáticos desde WhatsApp, asignación de conversaciones
  y leads a agentes, conversión lead→paciente, ficha del paciente en chat
- Pacientes: completitud de expediente, alertas clínicas, historial
  unificado con detalle, foto, WhatsApp, estado de cuenta, filtros
  rápidos (VIP/recientes/médico), documentos (expediente escaneado + galería)
- Agenda: vistas por médico y por hora, filtros rápidos (libres, primera
  vez, check-in, no-show), modal de acciones, bloqueos por médico,
  drag&drop para mover citas
- Reportes: 18 pestañas (diario, cortes, ingresos, inventario, adeudos,
  comisiones, pagos, devoluciones, top clientes, horas, paquetes,
  vendedores, concentrado, recomendaciones, KPIs) con exportación Excel
- Temas: nuevo tema Clásico (look legacy AdminLTE) con submenús tipo
  treeview, selector de tema; accesos rápidos personalizables con 3
  presentaciones; búsqueda global; notificaciones reales
- Configuración: secciones (clínica, usuarios con permisos por sección,
  recetas, catálogos de diagnósticos y procedimientos)
- Inventario: alertas de caducidad y sugerencia de compra, cron diario
  que descuenta artículos caducados, compras/bajas
- Consultas Médicas, página Expedientes, importadores delta
  (citas/visitas legacy idempotentes), depuración de duplicados
- Infra: tema Tailwind conectado (@config), gzip en nginx, secuencias
  Odoo corregidas (noupdate, company_id), rollback en validaciones
This commit is contained in:
2026-08-13 23:30:08 +00:00
parent a718592291
commit 01f6007e30
109 changed files with 19067 additions and 931 deletions

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, Plus, PackageCheck, Sparkles, RotateCcw } from 'lucide-react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, Edit2, Ban, Trash2 } from 'lucide-react';
import Layout from '../components/Layout';
import {
Card,
@@ -17,7 +17,8 @@ import {
toast,
badgeForAppointmentState,
} from '../components/ui';
import { odooApi, type Appointment, type Patient, type Service, type Doctor } from '../services/odoo';
import { useSearchParams } from 'react-router-dom';
import { odooApi, type Appointment, type Patient, type Service, type Doctor, type Bloqueo } from '../services/odoo';
const stateOptions = [
{ value: '', label: 'Todos los estados' },
@@ -28,6 +29,7 @@ const stateOptions = [
];
const Agenda: FC = () => {
const [searchParams] = useSearchParams();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [services, setServices] = useState<Service[]>([]);
@@ -36,12 +38,24 @@ const Agenda: FC = () => {
const [submitting, setSubmitting] = useState(false);
const [updating, setUpdating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [date, setDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [date, setDate] = useState<string>(() => searchParams.get('date') || new Date().toISOString().split('T')[0]);
const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState('');
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
const [savingEdit, setSavingEdit] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [slots, setSlots] = useState<{ time: number; time_str: string }[]>([]);
const [slotsLoading, setSlotsLoading] = useState(false);
const [bloqueos, setBloqueos] = useState<Bloqueo[]>([]);
const [bloqueosOpen, setBloqueosOpen] = useState(false);
const [bloqueoForm, setBloqueoForm] = useState({ doctor_id: '', date: '', all_day: false, time_from: '13:00', time_to: '14:00', motivo: '' });
const [savingBloqueo, setSavingBloqueo] = useState(false);
const [dragApt, setDragApt] = useState<Appointment | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [form, setForm] = useState({
patient_id: '',
@@ -73,13 +87,212 @@ const Agenda: FC = () => {
return true;
});
// ---- Filtros rápidos ----
const timeToFloat = (t: string): number | null => {
const m = /^(\d{1,2}):(\d{2})/.exec((t || '').trim());
return m ? parseInt(m[1], 10) + parseInt(m[2], 10) / 60 : null;
};
const nowDate = new Date();
const nowFloat = nowDate.getHours() + nowDate.getMinutes() / 60;
const todayStr = `${nowDate.getFullYear()}-${String(nowDate.getMonth() + 1).padStart(2, '0')}-${String(nowDate.getDate()).padStart(2, '0')}`;
// Candidata a check-in: confirmada, de hoy, ya empezó y aún no termina
const isCheckinDue = (a: Appointment): boolean => {
const start = timeToFloat(a.time);
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
const end = start + (a.duration ?? 30) / 60;
return start <= nowFloat && nowFloat < end;
};
// Candidata a no-show: confirmada, de hoy, ya debió terminar
const isNoShowDue = (a: Appointment): boolean => {
const start = timeToFloat(a.time);
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
return start + (a.duration ?? 30) / 60 <= nowFloat;
};
const visibleAppointments = filteredAppointments.filter((apt) => {
if (quickFilter === 'primera' && !apt.is_first_visit) return false;
if (quickFilter === 'checkin' && !isCheckinDue(apt)) return false;
if (quickFilter === 'noshow' && !isNoShowDue(apt)) return false;
return true;
});
const countPrimera = filteredAppointments.filter((a) => a.is_first_visit).length;
const countCheckin = filteredAppointments.filter(isCheckinDue).length;
const countNoShow = filteredAppointments.filter(isNoShowDue).length;
// ---- Vista por médico (grid doctores x horas) ----
const GRID_BASE_START = 9; // horario clínica: 9:00
const GRID_BASE_END = 18; // horario clínica: 18:00
const aptHour = (t: string): number | null => {
const m = /^(\d{1,2}):?(\d{2})?/.exec((t || '').trim());
if (!m) return null;
const h = parseInt(m[1], 10);
return Number.isNaN(h) ? null : h;
};
// Las canceladas no ocupan horario: el hueco queda libre
const gridAppointments = visibleAppointments.filter((a) => a.state !== 'cancelled');
const bookedHours = gridAppointments
.map((a) => aptHour(a.time))
.filter((h): h is number => h !== null);
const gridStart = bookedHours.length ? Math.min(GRID_BASE_START, ...bookedHours) : GRID_BASE_START;
const gridEnd = bookedHours.length ? Math.max(GRID_BASE_END, ...bookedHours) : GRID_BASE_END;
const gridHours = Array.from({ length: gridEnd - gridStart + 1 }, (_, i) => gridStart + i);
const gridDoctors = doctorFilter
? doctors.filter((d) => String(d.id) === doctorFilter)
: doctors;
const hasUnassigned = !doctorFilter && gridAppointments.some((a) => !a.doctor_id);
const gridColumns: { key: string; label: string; subtitle: string; doctorId: number | null }[] = [
...gridDoctors.map((d) => ({ key: `d-${d.id}`, label: d.name, subtitle: d.job_title || '', doctorId: d.id })),
...(hasUnassigned ? [{ key: 'none', label: 'Sin asignar', subtitle: '', doctorId: null }] : []),
];
const gridCellAppointments = (doctorId: number | null, hour: number) =>
gridAppointments.filter((a) =>
(doctorId ? a.doctor_id === doctorId : !a.doctor_id) && aptHour(a.time) === hour);
const gridDoctorCount = (doctorId: number | null) =>
gridAppointments.filter((a) => (doctorId ? a.doctor_id === doctorId : !a.doctor_id)).length;
// Bloqueo que cubre una celda (médico × hora)
const bloqueoEnCelda = (doctorId: number | null, hour: number) =>
doctorId
? bloqueos.find((b) => b.doctor_id === doctorId && (b.all_day || (hour >= b.time_from && hour < b.time_to)))
: undefined;
// Drag & drop para mover citas (solo pendientes/confirmadas)
const DRAGGABLE_STATES = ['pending', 'confirmed'];
const handleDrop = async (doctorId: number | null, hour: number) => {
if (!dragApt) return;
const apt = dragApt;
setDragApt(null);
setDropTarget(null);
if ((apt.doctor_id ?? null) === doctorId && aptHour(apt.time) === hour) return;
const nombreCol = doctorId ? (doctors.find((d) => d.id === doctorId)?.name || '') : 'Sin asignar';
const horaStr = `${String(hour).padStart(2, '0')}:00`;
if (!window.confirm(`¿Mover la cita de ${apt.patient} a ${nombreCol} ${horaStr}?`)) return;
try {
await odooApi.updateAppointment(apt.id, {
doctor_id: doctorId ?? null,
time: hour as unknown as string,
});
toast.success('Cita movida');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo mover la cita');
console.error(err);
}
};
const chipDragProps = (apt: Appointment) =>
DRAGGABLE_STATES.includes(apt.state)
? {
draggable: true,
onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setDragApt(apt); },
onDragEnd: () => { setDragApt(null); setDropTarget(null); },
}
: {};
const openBloqueos = () => {
setBloqueoForm({ doctor_id: doctorFilter, date, all_day: false, time_from: '13:00', time_to: '14:00', motivo: '' });
setBloqueosOpen(true);
};
const saveBloqueo = async () => {
if (!bloqueoForm.doctor_id || !bloqueoForm.date) {
toast.error('Médico y fecha son obligatorios');
return;
}
try {
setSavingBloqueo(true);
await odooApi.createBloqueo({
doctor_id: parseInt(bloqueoForm.doctor_id, 10),
date: bloqueoForm.date,
all_day: bloqueoForm.all_day,
time_from: parseTimeToFloat(bloqueoForm.time_from) ?? 9,
time_to: parseTimeToFloat(bloqueoForm.time_to) ?? 10,
motivo: bloqueoForm.motivo,
});
toast.success('Bloqueo creado');
await load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al crear bloqueo');
console.error(err);
} finally {
setSavingBloqueo(false);
}
};
const removeBloqueo = async (id: number) => {
try {
await odooApi.deleteBloqueo(id);
toast.success('Bloqueo eliminado');
await load();
} catch (err) {
toast.error('Error al eliminar bloqueo');
console.error(err);
}
};
const horaFloatStr = (h: number) => {
const hh = Math.floor(h);
const mm = Math.round((h - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
};
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)
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',
};
const gridStateLegend: { state: string; label: string; dot: string }[] = [
{ state: 'pending', label: 'Pendiente', dot: 'bg-brand-coral' },
{ state: 'confirmed', label: 'Confirmada', dot: 'bg-brand-mint border border-emerald-700' },
{ state: 'arrived', label: 'Llegó', dot: 'bg-brand-sky border border-sky-700' },
{ state: 'in_progress', label: 'En curso', dot: 'bg-brand-lilac border border-violet-600' },
{ state: 'done', label: 'Completada', dot: 'bg-theme-border-strong' },
];
const firstName = (full: string) => (full || '').trim().split(' ')[0] || '-';
const gridLegend = (
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2">
{gridStateLegend.map((item) => (
<span key={item.state} className="inline-flex items-center gap-1.5 text-xs text-theme-muted">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${item.dot}`} />
{item.label}
</span>
))}
<span className="text-xs text-theme-muted">
· Las citas canceladas no bloquean horario: su hueco aparece como libre.
</span>
</div>
);
const load = useCallback(async () => {
try {
setLoading(true);
setError(null);
const params: Record<string, string> = date ? { date } : {};
const res = await odooApi.getAppointments(params);
if (res.status === 'success') setAppointments(res.appointments);
if (res.status === 'success') {
setAppointments(res.appointments);
setBloqueos(res.bloqueos || []);
}
} catch (err) {
setError('Error al cargar citas');
toast.error('Error al cargar citas');
@@ -147,6 +360,7 @@ const Agenda: FC = () => {
setUpdating(id);
await odooApi.updateAppointmentStatus(id, action);
toast.success('Estado actualizado');
setDetailApt(null);
await load();
} catch (err) {
toast.error('Error al actualizar estado');
@@ -156,6 +370,47 @@ const Agenda: FC = () => {
}
};
const openAptDetail = (apt: Appointment) => {
setDetailApt(apt);
setEditMode(false);
setEditForm({
date: apt.date,
time: apt.time,
doctor_id: apt.doctor_id ? String(apt.doctor_id) : '',
notes: apt.notes || '',
});
};
const saveAptEdit = async () => {
if (!detailApt) return;
if (!editForm.date || !editForm.time) {
toast.error('Fecha y hora son obligatorias');
return;
}
try {
setSavingEdit(true);
await odooApi.updateAppointment(detailApt.id, {
date: editForm.date,
time: parseTimeToFloat(editForm.time) as unknown as string,
doctor_id: editForm.doctor_id ? parseInt(editForm.doctor_id, 10) : undefined,
notes: editForm.notes,
});
toast.success('Reserva actualizada');
setDetailApt(null);
await load();
} catch (err) {
toast.error('Error al actualizar la reserva');
console.error(err);
} finally {
setSavingEdit(false);
}
};
const aptStateLabels: Record<string, string> = {
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
};
const handlePackageFinished = async (id: number, finished: boolean) => {
try {
setPkgUpdating(id);
@@ -341,6 +596,17 @@ const Agenda: FC = () => {
<CheckCheck size={16} className="text-blue-600" />
</Button>
)}
{(apt.state === 'confirmed' || apt.state === 'arrived') && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStatus(apt.id, 'no_show')}
disabled={updating === apt.id}
title="No se presentó (no show)"
>
<UserX size={16} className="text-rose-500" />
</Button>
)}
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
apt.package_finished ? (
<Button
@@ -381,6 +647,10 @@ const Agenda: FC = () => {
return (
<Layout title="Agenda" subtitle="Gestión de citas">
<PageHeader title="Agenda" subtitle="Filtra por fecha, estado o médico">
<Button variant="outline" onClick={openBloqueos}>
<Ban size={16} className="mr-2" />
Bloqueos
</Button>
<Button variant="outline" onClick={() => setExpressOpen(true)}>
<Sparkles size={16} className="mr-2" />
Valoración express
@@ -415,13 +685,287 @@ const Agenda: FC = () => {
onChange={(e) => setDoctorFilter(e.target.value)}
className="sm:max-w-[220px]"
/>
<div className="flex gap-1 sm:ml-auto">
<Button
variant={viewMode === 'lista' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('lista')}
title="Vista de lista"
>
<List size={16} className="mr-1" />
Lista
</Button>
<Button
variant={viewMode === 'doctores' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('doctores')}
title="Médicos en columnas, horas en filas"
>
<LayoutGrid size={16} className="mr-1" />
Por médico
</Button>
<Button
variant={viewMode === 'horas' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('horas')}
title="Médicos en filas, horas en columnas"
>
<Rows3 size={16} className="mr-1" />
Por hora
</Button>
</div>
</div>
{/* Filtros rápidos */}
<div className="flex flex-wrap items-center gap-2 mb-4 sm:mb-5">
{viewMode !== 'lista' && (
<button
type="button"
onClick={() => setQuickFilter(quickFilter === 'libres' ? '' : 'libres')}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
quickFilter === 'libres'
? 'bg-brand-mint text-theme-heading border-emerald-700'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
Espacios libres
</button>
)}
{([
{ key: 'primera', label: 'Primera vez', count: countPrimera },
{ key: 'checkin', label: 'Check-in', count: countCheckin },
{ key: 'noshow', label: 'No show', count: countNoShow },
] as const).map((chip) => (
<button
key={chip.key}
type="button"
onClick={() => setQuickFilter(quickFilter === chip.key ? '' : chip.key)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all ${
quickFilter === chip.key
? 'bg-theme-accent text-theme-inverse border-theme-accent'
: 'bg-theme-bg text-theme-muted border-theme-border hover:border-theme-border-strong'
}`}
>
{chip.label}
<span className={`ml-1.5 inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] ${
quickFilter === chip.key ? 'bg-theme-surface/30 text-theme-inverse' : 'bg-theme-surface text-theme-heading border border-theme-border'
}`}>
{chip.count}
</span>
</button>
))}
{quickFilter && (
<button
type="button"
onClick={() => setQuickFilter('')}
className="text-xs text-theme-muted underline underline-offset-2 hover:text-theme-heading"
>
Quitar filtro
</button>
)}
</div>
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
{loading ? (
<Skeleton count={6} className="h-12 w-full" />
) : filteredAppointments.length === 0 ? (
) : viewMode === 'doctores' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por médico."
/>
) : (
<div>
<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 w-20 bg-theme-surface p-3 text-left text-xs font-medium text-theme-muted uppercase border-b border-theme-border">
Hora
</th>
{gridColumns.map((col) => (
<th key={col.key} className="min-w-[180px] p-3 border-b border-l border-theme-border">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(col.label)}
</span>
<div className="min-w-0 text-left">
<p className="truncate text-sm font-semibold text-theme-heading normal-case">
{col.label}
</p>
<p className="truncate text-[11px] font-normal text-theme-muted normal-case">
{col.subtitle ? `${col.subtitle} · ` : ''}
{gridDoctorCount(col.doctorId)} {gridDoctorCount(col.doctorId) === 1 ? 'cita' : 'citas'}
</p>
</div>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{gridHours.map((hour) => (
<tr key={hour}>
<td className="sticky left-0 z-10 bg-theme-surface p-3 text-xs font-semibold text-theme-muted align-top whitespace-nowrap border-b border-theme-border">
{`${String(hour).padStart(2, '0')}:00`}
</td>
{gridColumns.map((col) => {
const cell = gridCellAppointments(col.doctorId, hour);
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
const cellKey = `${col.key}-${hour}`;
return (
<td
key={col.key}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
className={`p-1.5 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
className="rounded-xl border border-theme-border py-3.5 text-center text-[11px] 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 — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={12} className="inline mr-1" />
Bloqueado{bloqueo.motivo ? ` — ${bloqueo.motivo}` : ''}
</div>
) : cell.length === 0 ? (
<div className={`rounded-xl border py-3.5 text-center text-[11px] font-medium transition-colors ${
quickFilter === 'libres'
? 'bg-brand-mint border-emerald-700 text-theme-heading font-semibold'
: 'border-dashed border-theme-border-strong text-theme-muted hover:bg-theme-accent-bg'
}`}>
Libre
</div>
) : (
cell.map((apt) => (
<div
key={apt.id}
onClick={() => openAptDetail(apt)}
{...chipDragProps(apt)}
className={`mb-1.5 rounded-xl border-l-4 px-2.5 py-1.5 text-xs 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}`}
>
<div className="font-semibold truncate">
{apt.time} · {apt.patient}
</div>
<div className="truncate opacity-75">{apt.service}</div>
</div>
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : viewMode === 'horas' ? (
gridColumns.length === 0 ? (
<EmptyState
title="Sin médicos"
subtitle="No hay médicos dados de alta para mostrar en la vista por hora."
/>
) : (
<div>
<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-[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>
{gridHours.map((hour) => (
<th key={hour} className="min-w-[96px] p-2.5 text-center text-xs font-medium text-theme-muted border-b border-l border-theme-border">
{`${String(hour).padStart(2, '0')}:00`}
</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>
{gridHours.map((hour) => {
const cell = gridCellAppointments(col.doctorId, hour);
const bloqueo = bloqueoEnCelda(col.doctorId, hour);
const cellKey = `${col.key}-${hour}`;
return (
<td
key={hour}
onDragOver={(e) => { if (dragApt && !bloqueo) { e.preventDefault(); setDropTarget(cellKey); } }}
onDragLeave={() => setDropTarget((t) => (t === cellKey ? null : t))}
onDrop={(e) => { e.preventDefault(); handleDrop(col.doctorId, hour); }}
className={`p-1 align-top border-b border-l border-theme-border transition-colors ${dropTarget === cellKey ? 'bg-theme-accent-bg' : ''}`}
>
{bloqueo ? (
<div
className="rounded-lg border border-theme-border py-2 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 — ${bloqueo.motivo || 'sin motivo'}`}
>
<Ban size={11} className="inline" />
</div>
) : cell.length === 0 ? (
<div
className={`rounded-lg border py-2 transition-colors ${
quickFilter === 'libres'
? 'bg-brand-mint border-emerald-700'
: 'border-dashed border-theme-border hover:bg-theme-accent-bg'
}`}
title="Libre"
/>
) : (
cell.map((apt) => (
<div
key={apt.id}
onClick={() => openAptDetail(apt)}
{...chipDragProps(apt)}
className={`mb-1 rounded-lg border-l-2 px-1.5 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}`}
>
<div className="font-semibold truncate">
{apt.time} · {firstName(apt.patient)}
</div>
</div>
))
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{gridLegend}
</div>
)
) : visibleAppointments.length === 0 ? (
<EmptyState
title="Sin citas"
subtitle="No hay citas para los filtros seleccionados."
@@ -435,14 +979,18 @@ const Agenda: FC = () => {
<table className="w-full">
<TableHeader />
<tbody className="divide-y">
{filteredAppointments.map((apt) => (
{visibleAppointments.map((apt) => (
<tr key={apt.id} className="hover:bg-theme-bg">
<td className="p-3 text-sm text-theme-heading">
<Clock size={14} className="inline mr-1 text-theme-muted" />
{apt.time}
</td>
<td className="p-3 text-sm font-medium text-theme-heading">{apt.patient}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.service}</td>
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">
<button type="button" onClick={() => openAptDetail(apt)} className="hover:underline hover:text-theme-heading text-left">
{apt.service}
</button>
</td>
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
<td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
@@ -459,7 +1007,7 @@ const Agenda: FC = () => {
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{filteredAppointments.map((apt) => (
{visibleAppointments.map((apt) => (
<MobileCard
key={apt.id}
title={apt.patient}
@@ -479,6 +1027,98 @@ const Agenda: FC = () => {
</Card.Body>
</Card>
{/* Modal de bloqueos de agenda */}
<Modal
isOpen={bloqueosOpen}
onClose={() => setBloqueosOpen(false)}
title={`Bloqueos de agenda — ${date}`}
maxWidth="lg"
footer={<Button variant="outline" onClick={() => setBloqueosOpen(false)}>Cerrar</Button>}
>
<div className="space-y-4">
{/* Alta rápida */}
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
<p className="text-xs font-medium text-theme-muted uppercase">Nuevo bloqueo</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Select
label="Médico *"
options={[{ value: '', label: 'Seleccionar médico...' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={bloqueoForm.doctor_id}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, doctor_id: e.target.value })}
/>
<Input
label="Fecha *"
type="date"
value={bloqueoForm.date}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, date: e.target.value })}
/>
<Input
label="Motivo"
placeholder="Comida, Vacaciones, Junta..."
value={bloqueoForm.motivo}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, motivo: e.target.value })}
/>
<label className="flex items-center gap-2 self-end pb-2 cursor-pointer">
<input
type="checkbox"
checked={bloqueoForm.all_day}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, all_day: e.target.checked })}
className="w-4 h-4 rounded border-theme-border-strong"
/>
<span className="text-sm text-theme-heading">Todo el día</span>
</label>
{!bloqueoForm.all_day && (
<>
<Input
label="Desde"
type="time"
value={bloqueoForm.time_from}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, time_from: e.target.value })}
/>
<Input
label="Hasta"
type="time"
value={bloqueoForm.time_to}
onChange={(e) => setBloqueoForm({ ...bloqueoForm, time_to: e.target.value })}
/>
</>
)}
</div>
<div className="flex justify-end">
<Button size="sm" onClick={saveBloqueo} loading={savingBloqueo}>
<Plus size={14} className="mr-1" />
Crear bloqueo
</Button>
</div>
</div>
{/* Lista del día */}
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Bloqueos de este día</p>
{bloqueos.length === 0 ? (
<p className="text-sm text-theme-muted py-4 text-center">No hay bloqueos en la fecha seleccionada.</p>
) : (
<ul className="space-y-1.5 max-h-56 overflow-y-auto">
{bloqueos.map((b) => (
<li key={b.id} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">
<span className="font-medium">{b.doctor}</span>
<span className="text-theme-muted">
{' · '}{b.all_day ? 'Todo el día' : `${horaFloatStr(b.time_from)}–${horaFloatStr(b.time_to)}`}
{b.motivo ? ` · ${b.motivo}` : ''}
</span>
</span>
<Button variant="ghost" size="sm" onClick={() => removeBloqueo(b.id)} title="Eliminar">
<Trash2 size={14} className="text-rose-500" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
<Modal
isOpen={createOpen}
onClose={() => setCreateOpen(false)}
@@ -635,6 +1275,124 @@ const Agenda: FC = () => {
)}
</div>
</Modal>
{/* Modal detalle de cita: confirmar / no show / editar reserva */}
<Modal
isOpen={!!detailApt}
onClose={() => setDetailApt(null)}
title={editMode ? 'Editar reserva' : 'Detalle de la cita'}
maxWidth="md"
footer={
editMode ? (
<>
<Button variant="outline" onClick={() => setEditMode(false)}>Volver</Button>
<Button onClick={saveAptEdit} loading={savingEdit}>Guardar cambios</Button>
</>
) : undefined
}
>
{detailApt && !editMode && (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={badgeForAppointmentState(detailApt.state)}>
{aptStateLabels[detailApt.state] || detailApt.state}
</Badge>
{detailApt.is_first_visit && (
<Badge variant="primary">Primera vez</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Paciente</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.patient}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Servicio</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.service}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Fecha y hora</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.date} · {detailApt.time}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Médico</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.doctor || 'Sin asignar'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Duración</p>
<p className="text-sm font-medium text-theme-heading">{detailApt.duration ? `${detailApt.duration} min` : '-'}</p>
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Precio</p>
<p className="text-sm font-medium text-theme-heading">${detailApt.price}</p>
</div>
</div>
{detailApt.notes && (
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Notas</p>
<p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
{detailApt.state === 'pending' && (
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'confirm')} disabled={updating === detailApt.id}>
<CheckCircle size={16} className="mr-1.5" /> Confirmar
</Button>
)}
{detailApt.state === 'confirmed' && (
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'arrive')} disabled={updating === detailApt.id}>
<UserCheck size={16} className="mr-1.5" /> Llegó
</Button>
)}
{(detailApt.state === 'confirmed' || detailApt.state === 'arrived') && (
<Button size="sm" variant="danger" onClick={() => handleStatus(detailApt.id, 'no_show')} disabled={updating === detailApt.id}>
<UserX size={16} className="mr-1.5" /> No se presentó
</Button>
)}
{detailApt.state !== 'done' && detailApt.state !== 'cancelled' && detailApt.state !== 'no_show' && (
<Button size="sm" variant="outline" onClick={() => handleStatus(detailApt.id, 'done')} disabled={updating === detailApt.id}>
<CheckCheck size={16} className="mr-1.5" /> Completar
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
<Edit2 size={16} className="mr-1.5" /> Editar reserva
</Button>
</div>
</div>
)}
{detailApt && editMode && (
<div className="space-y-4">
<p className="text-sm text-theme-muted">
{detailApt.patient} · {detailApt.service}
</p>
<div className="grid grid-cols-2 gap-4">
<Input
label="Fecha *"
type="date"
value={editForm.date}
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
/>
<Input
label="Hora *"
type="time"
value={editForm.time}
onChange={(e) => setEditForm({ ...editForm, time: e.target.value })}
/>
</div>
<Select
label="Médico"
options={[{ value: '', label: 'Sin cambios' }, ...doctors.map((d) => ({ value: String(d.id), label: d.name }))]}
value={editForm.doctor_id}
onChange={(e) => setEditForm({ ...editForm, doctor_id: e.target.value })}
/>
<TextArea
label="Notas"
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
/>
</div>
)}
</Modal>
</Layout>
);
};