Citas: doble confirmacion (1ra telefonica con quien/cuando, 2da = llegada/check-in) + filtro rapido Por confirmar
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 } from 'lucide-react';
|
||||
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, Edit2, 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 [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores');
|
||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow'>('');
|
||||
const [quickFilter, setQuickFilter] = useState<'' | 'libres' | 'primera' | 'checkin' | 'noshow' | 'porconfirmar'>('');
|
||||
const [detailApt, setDetailApt] = useState<Appointment | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' });
|
||||
@@ -136,17 +136,52 @@ const Agenda: FC = () => {
|
||||
if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
|
||||
return start + (a.duration ?? 30) / 60 <= nowFloat;
|
||||
};
|
||||
// Sin 1ra confirmación telefónica (misma regla que pending_confirm del API)
|
||||
const isPendingConfirm = (a: Appointment): boolean =>
|
||||
!a.phone_confirmed && !['cancelled', 'no_show', 'done'].includes(a.state);
|
||||
|
||||
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;
|
||||
if (quickFilter === 'porconfirmar' && !isPendingConfirm(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;
|
||||
const countPorConfirmar = filteredAppointments.filter(isPendingConfirm).length;
|
||||
|
||||
// Indicador de doble confirmación: sin confirmar (gris) / teléfono (azul) / en clínica (verde)
|
||||
const confirmIndicator = (apt: Appointment, size = 12) => {
|
||||
if (['arrived', 'in_progress', 'done'].includes(apt.state)) {
|
||||
return (
|
||||
<UserCheck size={size} className="inline shrink-0 text-emerald-600"
|
||||
aria-label="2da confirmación: en clínica" />
|
||||
);
|
||||
}
|
||||
if (apt.phone_confirmed) {
|
||||
return (
|
||||
<Phone size={size} className="inline shrink-0 text-blue-600"
|
||||
aria-label={`Confirmada por teléfono${apt.phone_confirmed_by ? ` por ${apt.phone_confirmed_by}` : ''}`} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Phone size={size} className="inline shrink-0 text-theme-muted opacity-50"
|
||||
aria-label="Sin confirmar por teléfono" />
|
||||
);
|
||||
};
|
||||
|
||||
const confirmTitle = (apt: Appointment): string => {
|
||||
if (['arrived', 'in_progress', 'done'].includes(apt.state)) return '2da confirmación: llegó a la clínica';
|
||||
if (apt.phone_confirmed) {
|
||||
const quien = apt.phone_confirmed_by ? ` por ${apt.phone_confirmed_by}` : '';
|
||||
const cuando = apt.phone_confirmed_at ? ` el ${apt.phone_confirmed_at}` : '';
|
||||
return `1ra confirmación (teléfono)${quien}${cuando}`;
|
||||
}
|
||||
return 'Sin confirmar por teléfono';
|
||||
};
|
||||
|
||||
// ---- Vista por médico (grid doctores x bloques de 15 min) ----
|
||||
const GRID_START = 8; // horario clínica: 8:00
|
||||
@@ -444,6 +479,21 @@ const Agenda: FC = () => {
|
||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
||||
};
|
||||
|
||||
const handlePhoneConfirm = async (apt: Appointment, confirmed: boolean) => {
|
||||
try {
|
||||
setUpdating(apt.id);
|
||||
const res = await odooApi.phoneConfirm(apt.id, confirmed);
|
||||
toast.success(confirmed ? 'Cita confirmada por teléfono' : 'Confirmación telefónica retirada');
|
||||
if (res.status === 'success' && detailApt?.id === apt.id) setDetailApt(res.appointment);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error('Error al actualizar la confirmación telefónica');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||
try {
|
||||
setPkgUpdating(id);
|
||||
@@ -630,6 +680,7 @@ const Agenda: FC = () => {
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Servicio</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Doctor</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Estado</th>
|
||||
<th className="text-center p-3 text-xs font-medium text-theme-muted uppercase" title="Doble confirmación: teléfono / llegada">Conf.</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Pago</th>
|
||||
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
|
||||
</tr>
|
||||
@@ -862,6 +913,7 @@ const Agenda: FC = () => {
|
||||
{ key: 'primera', label: 'Primera vez', count: countPrimera },
|
||||
{ key: 'checkin', label: 'Check-in', count: countCheckin },
|
||||
{ key: 'noshow', label: 'No show', count: countNoShow },
|
||||
{ key: 'porconfirmar', label: 'Por confirmar', count: countPorConfirmar },
|
||||
] as const).map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
@@ -978,6 +1030,7 @@ const Agenda: FC = () => {
|
||||
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||
>
|
||||
<div className="font-semibold truncate">
|
||||
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
|
||||
{apt.time} · {apt.patient}
|
||||
</div>
|
||||
<div className="truncate opacity-75">{apt.service}</div>
|
||||
@@ -1076,6 +1129,7 @@ const Agenda: FC = () => {
|
||||
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
|
||||
>
|
||||
<div className="font-semibold truncate">
|
||||
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
|
||||
{apt.time} · {firstName(apt.patient)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1122,6 +1176,17 @@ const Agenda: FC = () => {
|
||||
<td className="p-3">
|
||||
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePhoneConfirm(apt, !apt.phone_confirmed)}
|
||||
disabled={updating === apt.id || ['arrived', 'in_progress', 'done', 'cancelled', 'no_show'].includes(apt.state)}
|
||||
title={confirmTitle(apt)}
|
||||
className="inline-flex disabled:cursor-default"
|
||||
>
|
||||
{confirmIndicator(apt, 16)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{apt.payment_state}</td>
|
||||
<td className="p-3">
|
||||
<ActionButtons apt={apt} />
|
||||
@@ -1143,6 +1208,7 @@ const Agenda: FC = () => {
|
||||
{ label: 'Hora', value: apt.time },
|
||||
{ label: 'Doctor', value: apt.doctor || '-' },
|
||||
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> },
|
||||
{ label: 'Confirmación', value: <span title={confirmTitle(apt)}>{confirmIndicator(apt, 14)}</span> },
|
||||
{ label: 'Pago', value: apt.payment_state },
|
||||
]}
|
||||
actions={<ActionButtons apt={apt} />}
|
||||
@@ -1525,17 +1591,52 @@ const Agenda: FC = () => {
|
||||
<p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Doble confirmación: 1ra teléfono, 2da llegada (check-in) */}
|
||||
<div className="p-3 bg-theme-bg rounded-xl space-y-2">
|
||||
<p className="text-xs font-medium text-theme-muted uppercase">Confirmaciones</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-theme-heading">1ra confirmación (teléfono)</p>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{detailApt.phone_confirmed
|
||||
? `Confirmada${detailApt.phone_confirmed_by ? ` por ${detailApt.phone_confirmed_by}` : ''}${detailApt.phone_confirmed_at ? ` · ${detailApt.phone_confirmed_at}` : ''}`
|
||||
: 'Sin confirmar'}
|
||||
</p>
|
||||
</div>
|
||||
{!['cancelled', 'no_show', 'done'].includes(detailApt.state) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={detailApt.phone_confirmed ? 'outline' : 'primary'}
|
||||
onClick={() => handlePhoneConfirm(detailApt, !detailApt.phone_confirmed)}
|
||||
disabled={updating === detailApt.id}
|
||||
>
|
||||
<Phone size={16} className="mr-1.5" />
|
||||
{detailApt.phone_confirmed ? 'Quitar confirmación' : 'Confirmar por teléfono'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2 border-t border-theme-border">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-theme-heading">2da confirmación (llegada)</p>
|
||||
<p className="text-xs text-theme-muted">
|
||||
{['arrived', 'in_progress', 'done'].includes(detailApt.state)
|
||||
? 'Paciente en clínica (check-in realizado)'
|
||||
: 'Pendiente de llegada'}
|
||||
</p>
|
||||
</div>
|
||||
{detailApt.state === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'arrive')} disabled={updating === detailApt.id}>
|
||||
<UserCheck size={16} className="mr-1.5" /> Llegó
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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ó
|
||||
|
||||
Reference in New Issue
Block a user