permisos de agenda: selector de acciones por usuario (ver, agendar, editar, eliminar, visitas)
- roles.py: AGENDA_PERMS, AGENDA_PERM_KEYS, WRITE_RANK_MIN, agenda_defaults_for, resolve_agenda_perms, AGENDA_STATUS_PERM, AGENDA_STATE_PERM, _agenda_status_perm - frontend_user.py: campo agenda_perms, _agenda_perms_list(), to_public_dict() expone agenda_perms y agenda_perms_custom - auth.py: require_agenda(perm) decorador + agenda_perm_error() - frontend.py: @require_agenda aplicado a 22 endpoints + branch en update_appointment_status por accion - auth_users_update: whitelist acepta agenda_perms - Odoo upgrade: columna skeen_frontend_user.agenda_perms - Frontend: types AgendaPerm, AGENDA_PERM_ITEMS, canAgenda() en auth.tsx - UsuariosPanel: checkboxes de permisos de agenda en modal de permisos - Agenda.tsx: gates por permiso en crear/editar/eliminar/visitas/dragdrop/bloqueos - EstadoChecklist/BloqueosModal/ListaEspera: hide actions without permission Verificacion API: 18/18 tests OK (admin hereda, recorte=403, lect=403, restore=null)
This commit is contained in:
@@ -26,7 +26,7 @@ import {
|
||||
badgeForAppointmentState,
|
||||
} from '../components/ui';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData } from '../services/odoo';
|
||||
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { todayLocal } from '../lib/utils';
|
||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
@@ -139,7 +139,7 @@ const QUARTER_TIME_OPTIONS = (() => {
|
||||
const Agenda: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { user, canAgenda } = useAuth();
|
||||
const { openPatient } = usePatientDrawer();
|
||||
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
|
||||
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
|
||||
@@ -149,6 +149,21 @@ const Agenda: FC = () => {
|
||||
const isCosmetologa = user?.role === 'cosmetologia';
|
||||
const medicoEmployeeId = isMedico && user?.employee_id ? user.employee_id : null;
|
||||
const propiaColumnaId = isCosmetologa && user?.employee_id ? user.employee_id : null;
|
||||
// Permisos de acción dentro de la Agenda (selector por usuario, resueltos
|
||||
// en el backend). El guard real está en la API; aquí solo se oculta lo que
|
||||
// no se puede usar, para no ofrecer botones que devuelven 403.
|
||||
const puedeAgendar = canAgenda('agendar');
|
||||
const puedeEditar = canAgenda('editar');
|
||||
const puedeEliminar = canAgenda('eliminar');
|
||||
const puedeVisitas = canAgenda('visitas');
|
||||
// Aviso uniforme cuando una acción está recortada (el backend responde 403
|
||||
// de todas formas; esto evita el round-trip y explica por qué).
|
||||
const exigePermiso = (perm: AgendaPerm): boolean => {
|
||||
if (canAgenda(perm)) return false;
|
||||
const etiqueta = AGENDA_PERM_ITEMS.find((p) => p.key === perm)?.label ?? perm;
|
||||
toast.error(`Sin permiso: ${etiqueta}`);
|
||||
return true;
|
||||
};
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
@@ -503,6 +518,8 @@ const Agenda: FC = () => {
|
||||
const DRAGGABLE_STATES = ['pending', 'confirmed'];
|
||||
const handleDrop = async (doctorId: number | null, slot: number) => {
|
||||
if (!dragApt) return;
|
||||
if (exigePermiso('editar')) { setDragApt(null); return; }
|
||||
if (exigePermiso('editar')) { setDragApt(null); return; }
|
||||
const apt = dragApt;
|
||||
setDragApt(null);
|
||||
setDropTarget(null);
|
||||
@@ -544,7 +561,7 @@ const Agenda: FC = () => {
|
||||
};
|
||||
|
||||
const chipDragProps = (apt: Appointment) =>
|
||||
DRAGGABLE_STATES.includes(apt.state)
|
||||
DRAGGABLE_STATES.includes(apt.state) && puedeEditar
|
||||
? {
|
||||
draggable: true,
|
||||
onDragStart: (e: React.DragEvent) => { e.stopPropagation(); setTooltip(null); setDragApt(apt); },
|
||||
@@ -939,6 +956,7 @@ const Agenda: FC = () => {
|
||||
* la cita; si se cancela el modal, simplemente no se toca nada.
|
||||
*/
|
||||
const agendarDesdeLista = (entry: ListaEspera) => {
|
||||
if (exigePermiso('agendar')) return;
|
||||
setPickedPatient({ id: String(entry.patient_id), name: entry.patient });
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
@@ -1100,7 +1118,34 @@ const Agenda: FC = () => {
|
||||
fetchSlots(form.service_id, form.date, form.doctor_id, form.duration);
|
||||
}, [form.service_id, form.date, form.doctor_id, form.duration, fetchSlots]);
|
||||
|
||||
// Permiso de Agenda que exige cada acción de estado. Espejo de
|
||||
// AGENDA_STATUS_PERM / AGENDA_STATE_PERM en el backend: si aquí no cuadra,
|
||||
// la API devuelve 403 igual, así que solo evita el click inútil.
|
||||
const permisoParaAccion = (action: string, apt: Appointment | null, destino?: string): AgendaPerm => {
|
||||
if (action === 'arrive') return 'visitas';
|
||||
if (action === 'cancel') return 'eliminar';
|
||||
if (action === 'waitlist') return 'agendar';
|
||||
if (action === 'next' && apt) {
|
||||
const actual = stepIndex(apt.state);
|
||||
const siguiente = actual >= 0 && actual < CHECKLIST_STEPS.length - 1 ? CHECKLIST_STEPS[actual + 1].state : null;
|
||||
// 'next' solo recorre CHECKLIST_STEPS: el único destino especial es la llegada.
|
||||
if (siguiente === 'arrived') return 'visitas';
|
||||
}
|
||||
if (action === 'set_state') {
|
||||
if (destino === 'arrived') return 'visitas';
|
||||
if (destino === 'cancelled') return 'eliminar';
|
||||
}
|
||||
return 'editar';
|
||||
};
|
||||
|
||||
const handleStatus = async (id: number, action: string, state?: string) => {
|
||||
const aptParaPermiso = appointments.find((a) => a.id === id) || (detailApt?.id === id ? detailApt : null);
|
||||
const permiso = permisoParaAccion(action, aptParaPermiso, state);
|
||||
if (!canAgenda(permiso)) {
|
||||
const etiqueta = AGENDA_PERM_ITEMS.find((p) => p.key === permiso)?.label ?? permiso;
|
||||
toast.error(`Sin permiso: ${etiqueta}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setUpdating(id);
|
||||
// Datos del hueco que se libera (para avisar a la lista de espera)
|
||||
@@ -1168,6 +1213,7 @@ const Agenda: FC = () => {
|
||||
|
||||
const saveAptEdit = async () => {
|
||||
if (!detailApt) return;
|
||||
if (exigePermiso('editar')) return;
|
||||
if (!editForm.date || !editForm.time) {
|
||||
toast.error('Fecha y hora son obligatorias');
|
||||
return;
|
||||
@@ -1268,6 +1314,7 @@ const Agenda: FC = () => {
|
||||
};
|
||||
|
||||
const handlePackageFinished = async (id: number, finished: boolean) => {
|
||||
if (exigePermiso('editar')) return;
|
||||
try {
|
||||
setPkgUpdating(id);
|
||||
await odooApi.packageFinished(id, finished);
|
||||
@@ -1379,6 +1426,7 @@ const Agenda: FC = () => {
|
||||
|
||||
// Editar una cita existente: abre el modal de creación precargado y guarda con PUT.
|
||||
const openEditCita = (apt: Appointment) => {
|
||||
if (exigePermiso('editar')) return;
|
||||
setQuickMode(false);
|
||||
setDetailApt(null);
|
||||
setEditMode(false);
|
||||
@@ -1428,6 +1476,7 @@ const Agenda: FC = () => {
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (exigePermiso('agendar')) return;
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
setPickedPatient(null);
|
||||
@@ -1449,6 +1498,7 @@ const Agenda: FC = () => {
|
||||
// justDroppedRef: tras un drop real el click posterior se ignora).
|
||||
const justDroppedRef = useRef(false);
|
||||
const openCreateAt = (opts?: { date?: string; time?: string; doctorId?: number | null }) => {
|
||||
if (exigePermiso('agendar')) return;
|
||||
setQuickMode(false);
|
||||
setEditingId(null);
|
||||
setPickedPatient(null);
|
||||
@@ -1586,7 +1636,7 @@ const Agenda: FC = () => {
|
||||
|
||||
const ActionButtons = ({ apt }: { apt: Appointment }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||
{puedeEditar && apt.state !== 'confirmed' && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1597,18 +1647,18 @@ const Agenda: FC = () => {
|
||||
<CheckCircle size={16} className="text-theme-heading" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.state === 'confirmed' && (
|
||||
{puedeVisitas && apt.state === 'confirmed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleStatus(apt.id, 'arrive')}
|
||||
disabled={updating === apt.id}
|
||||
title="Llegada"
|
||||
title="Llegada (genera la visita del día)"
|
||||
>
|
||||
<UserCheck size={16} className="text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.state === 'arrived' && (
|
||||
{puedeEditar && apt.state === 'arrived' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1619,7 +1669,7 @@ const Agenda: FC = () => {
|
||||
<Play size={16} className="text-violet-600" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||
{puedeEditar && apt.state !== 'done' && apt.state !== 'cancelled' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1630,7 +1680,7 @@ const Agenda: FC = () => {
|
||||
<CheckCheck size={16} className="text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
{(apt.state === 'confirmed' || apt.state === 'arrived') && (
|
||||
{puedeEditar && (apt.state === 'confirmed' || apt.state === 'arrived') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1641,7 +1691,7 @@ const Agenda: FC = () => {
|
||||
<UserX size={16} className="text-rose-500" />
|
||||
</Button>
|
||||
)}
|
||||
{apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
||||
{puedeEditar && apt.service_category === 'paquete' && apt.state !== 'cancelled' && (
|
||||
apt.package_finished ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1664,7 +1714,7 @@ const Agenda: FC = () => {
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{apt.state !== 'cancelled' && (
|
||||
{puedeEliminar && apt.state !== 'cancelled' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1699,10 +1749,12 @@ const Agenda: FC = () => {
|
||||
<Sparkles size={16} className="mr-2" />
|
||||
Valoración express
|
||||
</Button>
|
||||
{puedeAgendar && (
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Nueva cita
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<Card>
|
||||
@@ -1979,6 +2031,7 @@ const Agenda: FC = () => {
|
||||
entries={listaEspera.filter((e) => e.state === 'esperando')}
|
||||
loading={loading}
|
||||
loadingAgendaId={listaAgendando}
|
||||
puedeGestionar={puedeAgendar}
|
||||
onAgendar={agendarDesdeLista}
|
||||
onQuitar={(e) => void quitarDeListaEspera(e)}
|
||||
onAdd={() => { setListaPrefill({ doctorId: doctorFilter || undefined }); setListaModalOpen(true); }}
|
||||
@@ -2599,6 +2652,7 @@ const Agenda: FC = () => {
|
||||
isMedico={isMedico}
|
||||
ownDoctorId={medicoEmployeeId}
|
||||
initial={bloqueoQuick}
|
||||
puedeGestionar={puedeAgendar}
|
||||
onChanged={() => { void load({ silent: true }); }}
|
||||
/>
|
||||
|
||||
@@ -2983,6 +3037,8 @@ const Agenda: FC = () => {
|
||||
apt={detailApt}
|
||||
updating={updating === detailApt.id}
|
||||
history={stateHistory}
|
||||
puedeCancelar={puedeEliminar}
|
||||
puedeNoShow={puedeEditar}
|
||||
onChange={(action, state) => handleStatus(detailApt.id, action, state)}
|
||||
/>
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
@@ -3009,12 +3065,16 @@ const Agenda: FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
|
||||
{puedeEditar && (
|
||||
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}>
|
||||
<CalendarClock size={16} className="mr-1.5" /> Reagendar
|
||||
</Button>
|
||||
)}
|
||||
{puedeEditar && (
|
||||
<Button size="sm" variant="outline" onClick={() => openEditCita(detailApt)}>
|
||||
<Pencil size={16} className="mr-1.5" /> Editar
|
||||
</Button>
|
||||
)}
|
||||
{/* Cobrar a la cita: abre el POS con paciente/servicio/médico precargados.
|
||||
Solo en estados cobrables (oculto en cancelada/no show). */}
|
||||
{!['cancelled', 'no_show'].includes(detailApt.state) && (
|
||||
|
||||
Reference in New Issue
Block a user