Citas: doble confirmacion (1ra telefonica con quien/cuando, 2da = llegada/check-in) + filtro rapido Por confirmar

This commit is contained in:
2026-08-31 00:45:05 +00:00
parent 681c0a9173
commit 1c31eecb8c
4 changed files with 166 additions and 7 deletions

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, Edit2, Ban, Trash2 } 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ó

View File

@@ -68,6 +68,10 @@ export interface Appointment {
notes?: string;
package_finished?: boolean;
package_finished_date?: string | null;
// 1ra confirmación (teléfono). La 2da es la llegada (state 'arrived').
phone_confirmed?: boolean;
phone_confirmed_at?: string | null;
phone_confirmed_by?: string | null;
}
export interface Bloqueo {
@@ -915,6 +919,11 @@ export const odooApi = {
return data;
},
async phoneConfirm(id: number, confirmed: boolean): Promise<{ status: string; appointment: Appointment }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/appointments/${id}/phone_confirm`, { confirmed });
return data;
},
// Chat interno entre usuarios de la plataforma
async getChatUsers(): Promise<{ status: string; users: ChatUser[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/users`);