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ó
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -125,6 +125,14 @@ class SkeenCita(models.Model):
|
||||
# Recordatorio
|
||||
reminder_sent = fields.Boolean(string='Recordatorio Enviado', default=False)
|
||||
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
|
||||
payment_state = fields.Selection([
|
||||
|
||||
@@ -351,6 +351,10 @@ class SkeenFrontendController(http.Controller):
|
||||
if kw.get('search'):
|
||||
s = kw.get('search')
|
||||
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_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
|
||||
Citas = request.env['skeen.cita'].sudo()
|
||||
@@ -585,6 +589,39 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
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)
|
||||
@require_role('recepcion')
|
||||
def appointment_package_finished(self, appointment_id, **kw):
|
||||
@@ -735,6 +772,10 @@ class SkeenFrontendController(http.Controller):
|
||||
'is_first_visit': False,
|
||||
'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,
|
||||
# 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,
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user