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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user