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 type { FC } from 'react';
import { useEffect, useState, useCallback } 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 Layout from '../components/Layout';
import { import {
Card, Card,
@@ -59,7 +59,7 @@ const Agenda: FC = () => {
const [stateFilter, setStateFilter] = useState(''); const [stateFilter, setStateFilter] = useState('');
const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : '')); const [doctorFilter, setDoctorFilter] = useState(() => (medicoEmployeeId ? String(medicoEmployeeId) : ''));
const [viewMode, setViewMode] = useState<'lista' | 'doctores' | 'horas'>('doctores'); 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 [detailApt, setDetailApt] = useState<Appointment | null>(null);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [editForm, setEditForm] = useState({ date: '', time: '', doctor_id: '', notes: '' }); 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; if (a.state !== 'confirmed' || a.date !== todayStr || start === null) return false;
return start + (a.duration ?? 30) / 60 <= nowFloat; 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) => { const visibleAppointments = filteredAppointments.filter((apt) => {
if (quickFilter === 'primera' && !apt.is_first_visit) return false; if (quickFilter === 'primera' && !apt.is_first_visit) return false;
if (quickFilter === 'checkin' && !isCheckinDue(apt)) return false; if (quickFilter === 'checkin' && !isCheckinDue(apt)) return false;
if (quickFilter === 'noshow' && !isNoShowDue(apt)) return false; if (quickFilter === 'noshow' && !isNoShowDue(apt)) return false;
if (quickFilter === 'porconfirmar' && !isPendingConfirm(apt)) return false;
return true; return true;
}); });
const countPrimera = filteredAppointments.filter((a) => a.is_first_visit).length; const countPrimera = filteredAppointments.filter((a) => a.is_first_visit).length;
const countCheckin = filteredAppointments.filter(isCheckinDue).length; const countCheckin = filteredAppointments.filter(isCheckinDue).length;
const countNoShow = filteredAppointments.filter(isNoShowDue).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) ---- // ---- Vista por médico (grid doctores x bloques de 15 min) ----
const GRID_START = 8; // horario clínica: 8:00 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', 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) => { const handlePackageFinished = async (id: number, finished: boolean) => {
try { try {
setPkgUpdating(id); 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 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 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-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 hidden sm:table-cell">Pago</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th> <th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Acciones</th>
</tr> </tr>
@@ -862,6 +913,7 @@ const Agenda: FC = () => {
{ key: 'primera', label: 'Primera vez', count: countPrimera }, { key: 'primera', label: 'Primera vez', count: countPrimera },
{ key: 'checkin', label: 'Check-in', count: countCheckin }, { key: 'checkin', label: 'Check-in', count: countCheckin },
{ key: 'noshow', label: 'No show', count: countNoShow }, { key: 'noshow', label: 'No show', count: countNoShow },
{ key: 'porconfirmar', label: 'Por confirmar', count: countPorConfirmar },
] as const).map((chip) => ( ] as const).map((chip) => (
<button <button
key={chip.key} key={chip.key}
@@ -978,6 +1030,7 @@ const Agenda: FC = () => {
title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`} title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
> >
<div className="font-semibold truncate"> <div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-1 align-middle">{confirmIndicator(apt)}</span>
{apt.time} · {apt.patient} {apt.time} · {apt.patient}
</div> </div>
<div className="truncate opacity-75">{apt.service}</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}`} title={`${apt.time} · ${apt.patient} · ${apt.service} · ${apt.state}`}
> >
<div className="font-semibold truncate"> <div className="font-semibold truncate">
<span title={confirmTitle(apt)} className="mr-0.5 align-middle">{confirmIndicator(apt, 10)}</span>
{apt.time} · {firstName(apt.patient)} {apt.time} · {firstName(apt.patient)}
</div> </div>
</div> </div>
@@ -1122,6 +1176,17 @@ const Agenda: FC = () => {
<td className="p-3"> <td className="p-3">
<Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge>
</td> </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 text-sm text-theme-muted hidden sm:table-cell">{apt.payment_state}</td>
<td className="p-3"> <td className="p-3">
<ActionButtons apt={apt} /> <ActionButtons apt={apt} />
@@ -1143,6 +1208,7 @@ const Agenda: FC = () => {
{ label: 'Hora', value: apt.time }, { label: 'Hora', value: apt.time },
{ label: 'Doctor', value: apt.doctor || '-' }, { label: 'Doctor', value: apt.doctor || '-' },
{ label: 'Estado', value: <Badge variant={badgeForAppointmentState(apt.state)}>{apt.state}</Badge> }, { 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 }, { label: 'Pago', value: apt.payment_state },
]} ]}
actions={<ActionButtons apt={apt} />} actions={<ActionButtons apt={apt} />}
@@ -1525,17 +1591,52 @@ const Agenda: FC = () => {
<p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p> <p className="text-sm text-theme-heading whitespace-pre-wrap">{detailApt.notes}</p>
</div> </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"> <div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
{detailApt.state === 'pending' && ( {detailApt.state === 'pending' && (
<Button size="sm" onClick={() => handleStatus(detailApt.id, 'confirm')} disabled={updating === detailApt.id}> <Button size="sm" onClick={() => handleStatus(detailApt.id, 'confirm')} disabled={updating === detailApt.id}>
<CheckCircle size={16} className="mr-1.5" /> Confirmar <CheckCircle size={16} className="mr-1.5" /> Confirmar
</Button> </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') && ( {(detailApt.state === 'confirmed' || detailApt.state === 'arrived') && (
<Button size="sm" variant="danger" onClick={() => handleStatus(detailApt.id, 'no_show')} disabled={updating === detailApt.id}> <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ó <UserX size={16} className="mr-1.5" /> No se presentó

View File

@@ -68,6 +68,10 @@ export interface Appointment {
notes?: string; notes?: string;
package_finished?: boolean; package_finished?: boolean;
package_finished_date?: string | null; 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 { export interface Bloqueo {
@@ -915,6 +919,11 @@ export const odooApi = {
return data; 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 // Chat interno entre usuarios de la plataforma
async getChatUsers(): Promise<{ status: string; users: ChatUser[] }> { async getChatUsers(): Promise<{ status: string; users: ChatUser[] }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/users`); const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/chat/users`);

View File

@@ -125,6 +125,14 @@ class SkeenCita(models.Model):
# Recordatorio # Recordatorio
reminder_sent = fields.Boolean(string='Recordatorio Enviado', default=False) reminder_sent = fields.Boolean(string='Recordatorio Enviado', default=False)
reminder_date = fields.Datetime(string='Fecha Recordatorio') reminder_date = fields.Datetime(string='Fecha Recordatorio')
# Doble confirmación:
# 1ra = confirmación telefónica (recepción marca al paciente)
# 2da = llegada a la clínica (check-in existente, state 'arrived')
phone_confirmed = fields.Boolean(string='Confirmada por Teléfono', default=False, tracking=True)
phone_confirmed_at = fields.Datetime(string='Fecha Confirmación Tel.', readonly=True, copy=False)
phone_confirmed_by = fields.Char(string='Confirmada por', readonly=True, copy=False,
help='Login del usuario del frontend que confirmó por teléfono')
# Pago # Pago
payment_state = fields.Selection([ payment_state = fields.Selection([

View File

@@ -351,6 +351,10 @@ class SkeenFrontendController(http.Controller):
if kw.get('search'): if kw.get('search'):
s = kw.get('search') s = kw.get('search')
domain += ['|', '|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s), ('servicio_id.name', 'ilike', s)] domain += ['|', '|', ('name', 'ilike', s), ('partner_id.name', 'ilike', s), ('servicio_id.name', 'ilike', s)]
# pending_confirm=1: citas activas SIN 1ra confirmación telefónica
if kw.get('pending_confirm') in ('1', 'true', 'True'):
domain.append(('phone_confirmed', '=', False))
domain.append(('state', 'not in', ('cancelled', 'no_show', 'done')))
page = max(int(kw.get('page', 1) or 1), 1) page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200) page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
Citas = request.env['skeen.cita'].sudo() Citas = request.env['skeen.cita'].sudo()
@@ -585,6 +589,39 @@ class SkeenFrontendController(http.Controller):
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/phone_confirm', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
def appointment_phone_confirm(self, appointment_id, **kw):
"""Toggle de la 1ra confirmación (telefónica) de una cita.
Body: {"confirmed": true|false}. Si no se envía, invierte el valor actual.
Registra quién (login del usuario frontend) y cuándo confirmó.
Permiso: recepción+ (mismo nivel que check-in/no-show; el rol medico
tiene el mismo rango y también puede confirmar).
"""
try:
cita = request.env['skeen.cita'].sudo().browse(appointment_id)
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
data = _parse_json_body()
confirmed = bool(data.get('confirmed', not cita.phone_confirmed))
if confirmed:
cita.write({
'phone_confirmed': True,
'phone_confirmed_at': fields.Datetime.now(),
'phone_confirmed_by': request.frontend_user.login,
})
else:
cita.write({
'phone_confirmed': False,
'phone_confirmed_at': False,
'phone_confirmed_by': False,
})
return json_response({'status': 'success', 'appointment': self._appointment_to_dict(cita)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/appointments/<int:appointment_id>/package_finished', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion') @require_role('recepcion')
def appointment_package_finished(self, appointment_id, **kw): def appointment_package_finished(self, appointment_id, **kw):
@@ -735,6 +772,10 @@ class SkeenFrontendController(http.Controller):
'is_first_visit': False, 'is_first_visit': False,
'package_finished': cita.package_finished, 'package_finished': cita.package_finished,
'package_finished_date': cita.package_finished_date.strftime('%Y-%m-%d %H:%M') if cita.package_finished_date else None, 'package_finished_date': cita.package_finished_date.strftime('%Y-%m-%d %H:%M') if cita.package_finished_date else None,
# 1ra confirmación (teléfono). La 2da (llegada) se refleja en state.
'phone_confirmed': cita.phone_confirmed,
'phone_confirmed_at': cita.phone_confirmed_at.strftime('%Y-%m-%d %H:%M') if cita.phone_confirmed_at else None,
'phone_confirmed_by': cita.phone_confirmed_by or None,
} }
# ============================================================ # ============================================================