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:
2026-09-30 09:46:51 +00:00
parent 85a281add1
commit 169864958d
11 changed files with 401 additions and 39 deletions

View File

@@ -12,7 +12,7 @@ import {
MobileCard,
toast,
} from './ui';
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm, AGENDA_PERM_ITEMS } from '../services/odoo';
import { ROLE_LABELS, useAuth } from '../lib/auth';
import { MENU_ITEMS } from '../lib/menu-items';
@@ -44,6 +44,9 @@ const UsuariosPanel: FC = () => {
const [permUser, setPermUser] = useState<FrontendUser | null>(null);
const [permCustom, setPermCustom] = useState(false);
const [permSelected, setPermSelected] = useState<string[]>([]);
// Permisos de Agenda (independientes de los de menú): lista propia o herencia.
const [agendaCustom, setAgendaCustom] = useState(false);
const [agendaSelected, setAgendaSelected] = useState<AgendaPerm[]>([]);
const [permSaving, setPermSaving] = useState(false);
const load = useCallback(async () => {
@@ -149,17 +152,30 @@ const UsuariosPanel: FC = () => {
setPermUser(u);
setPermCustom(custom);
setPermSelected(custom && u.allowed_menus ? u.allowed_menus : MENU_ITEMS.map((m) => m.href));
// 'ver' siempre viene en la lista resuelta del backend; aquí solo se
// manejan las 4 acciones configurables.
const agenda = Array.isArray(u.agenda_perms_custom) ? u.agenda_perms_custom : null;
setAgendaCustom(agenda !== null);
setAgendaSelected(agenda ? agenda.filter((p) => p !== 'ver') : ['agendar', 'editar', 'eliminar', 'visitas']);
};
const togglePerm = (href: string) => {
setPermSelected((prev) => (prev.includes(href) ? prev.filter((h) => h !== href) : [...prev, href]));
};
const toggleAgendaPerm = (perm: AgendaPerm) => {
setAgendaSelected((prev) => (prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]));
};
const savePermisos = async () => {
if (!permUser) return;
try {
setPermSaving(true);
await odooApi.updateFrontendUser(permUser.id, { allowed_menus: permCustom ? permSelected : [] });
await odooApi.updateFrontendUser(permUser.id, {
allowed_menus: permCustom ? permSelected : [],
// null = hereda del rol; la lista siempre lleva 'ver' aunque se desmarque todo
agenda_perms: agendaCustom ? ['ver', ...agendaSelected] : null,
});
toast.success('Permisos actualizados (se aplican en el próximo inicio de sesión del usuario)');
setPermUser(null);
await load();
@@ -372,6 +388,51 @@ const UsuariosPanel: FC = () => {
</div>
</>
)}
{/* Permisos de acción dentro de la Agenda */}
<div className="pt-4 border-t border-theme-border space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-theme-heading">Agenda</h4>
<span className="text-xs text-theme-muted">
{agendaCustom ? 'Permisos propios' : 'Hereda del rol'}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-theme-heading cursor-pointer">
<input
type="checkbox"
checked={!agendaCustom}
onChange={(e) => setAgendaCustom(!e.target.checked)}
className="rounded border-theme-border-strong"
/>
Permitir todo lo que deja el rol (default)
</label>
{agendaCustom && (
<div className="space-y-1.5">
{AGENDA_PERM_ITEMS.map((item) => {
const fijo = item.key === 'ver';
const activo = fijo || agendaSelected.includes(item.key);
return (
<label
key={item.key}
className={`flex items-start gap-2 p-2.5 border border-theme-border rounded-lg text-sm ${fijo ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-theme-bg'} text-theme-heading`}
>
<input
type="checkbox"
checked={activo}
disabled={fijo}
onChange={() => !fijo && toggleAgendaPerm(item.key)}
className="mt-0.5 rounded border-theme-border-strong"
/>
<span>
<span className="font-medium">{item.label}</span>
<span className="block text-xs text-theme-muted">{item.nota}</span>
</span>
</label>
);
})}
</div>
)}
</div>
</div>
</Modal>
</>

View File

@@ -53,12 +53,14 @@ interface Props {
ownDoctorId?: number | null;
/** Precarga del cierre rápido desde la cabecera de una columna */
initial?: BloqueoQuickClose | null;
/** Sin permiso de 'Agendar cita': el modal queda en solo consulta */
puedeGestionar?: boolean;
/** Avisa a la agenda para recargar sus bloqueos */
onChanged: () => void;
}
const BloqueosModal: FC<Props> = ({
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, onChanged,
isOpen, onClose, date, doctors, isMedico, ownDoctorId, initial, puedeGestionar = true, onChanged,
}) => {
const [doctorIds, setDoctorIds] = useState<number[]>([]);
const [dateFrom, setDateFrom] = useState(date);
@@ -271,7 +273,13 @@ const BloqueosModal: FC<Props> = ({
>
<div className="space-y-5">
{/* Alta / edición */}
<div className="p-3 bg-theme-bg rounded-xl space-y-3">
{!puedeGestionar && (
<p className="text-xs text-theme-muted bg-theme-bg rounded-lg p-2.5">
Solo consulta: tu perfil no tiene permiso de <span className="font-medium">Agendar cita</span>,
que es el que habilita cerrar y reabrir agendas.
</p>
)}
<div className={`p-3 bg-theme-bg rounded-xl space-y-3 ${puedeGestionar ? '' : 'hidden'}`}>
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-theme-muted uppercase">
{editing ? `Editando bloqueo #${editing.id}` : 'Nuevo bloqueo'}
@@ -472,7 +480,7 @@ const BloqueosModal: FC<Props> = ({
{b.motivo && <span className="text-theme-muted"> {b.motivo}</span>}
</span>
<span className="flex items-center gap-1 shrink-0">
{b.grupo && (
{puedeGestionar && b.grupo && (
<button
type="button"
onClick={() => void remove(b, true)}
@@ -482,12 +490,16 @@ const BloqueosModal: FC<Props> = ({
serie
</button>
)}
<Button variant="ghost" size="sm" onClick={() => startEdit(b)} title="Editar">
<Pencil size={14} className="text-theme-heading" />
</Button>
<Button variant="ghost" size="sm" onClick={() => void remove(b)} title="Eliminar">
<Trash2 size={14} className="text-rose-500" />
</Button>
{puedeGestionar && (
<>
<Button variant="ghost" size="sm" onClick={() => startEdit(b)} title="Editar">
<Pencil size={14} className="text-theme-heading" />
</Button>
<Button variant="ghost" size="sm" onClick={() => void remove(b)} title="Eliminar">
<Trash2 size={14} className="text-rose-500" />
</Button>
</>
)}
</span>
</li>
))}

View File

@@ -24,6 +24,9 @@ interface Props {
onChange: (action: string, state?: string) => void;
/** Oculta la bitácora (vista compacta) */
compact?: boolean;
/** Permisos de Agenda: Cancelar exige 'Eliminar citas', No se presentó 'Editar citas' */
puedeCancelar?: boolean;
puedeNoShow?: boolean;
}
/**
@@ -33,7 +36,7 @@ interface Props {
* checklist y piden confirmación. La confirmación telefónica y la llegada
* siguen sincronizadas con phone_confirmed / arrived_at desde el backend.
*/
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact }) => {
export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, compact, puedeCancelar = true, puedeNoShow = true }) => {
const [confirmarSalida, setConfirmarSalida] = useState<'cancel' | 'no_show' | null>(null);
const idx = stepIndex(apt.state);
const fueraDelFlujo = idx < 0;
@@ -117,6 +120,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
<ChevronRight size={14} />
</button>
<span className="ml-auto flex items-center gap-2">
{puedeNoShow && (
<button
type="button"
onClick={() => setConfirmarSalida('no_show')}
@@ -127,6 +131,8 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
<UserX size={13} />
No se presentó
</button>
)}
{puedeCancelar && (
<button
type="button"
onClick={() => setConfirmarSalida('cancel')}
@@ -137,6 +143,7 @@ export const EstadoChecklist: FC<Props> = ({ apt, updating, history, onChange, c
<XCircle size={13} />
Cancelar
</button>
)}
</span>
</div>

View File

@@ -29,6 +29,8 @@ interface PanelProps {
onQuitar: (entry: ListaEspera) => void;
onAdd: () => void;
onRefresh: () => void;
/** Permiso 'Agendar cita': sin él el panel es solo consulta */
puedeGestionar?: boolean;
}
/**
@@ -36,7 +38,7 @@ interface PanelProps {
* prioridad y antigüedad, con acciones de agendar / avisar / quitar.
*/
export const ListaEsperaPanel: FC<PanelProps> = ({
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh,
entries, loading, loadingAgendaId, onAgendar, onQuitar, onAdd, onRefresh, puedeGestionar = true,
}) => {
const [abierto, setAbierto] = useState(true);
return (
@@ -59,7 +61,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
<Button size="sm" variant="ghost" onClick={onRefresh} title="Recargar la lista de espera">
Actualizar
</Button>
<Button size="sm" variant="outline" onClick={onAdd}>
<Button size="sm" variant="outline" onClick={onAdd} disabled={!puedeGestionar}>
<Plus size={14} className="mr-1" />
Agregar paciente
</Button>
@@ -106,6 +108,7 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
size="sm"
onClick={() => onAgendar(e)}
loading={loadingAgendaId === e.id}
disabled={!puedeGestionar}
title="Agendar con el paciente precargado"
>
<CalendarPlus size={14} className="mr-1" />
@@ -125,15 +128,17 @@ export const ListaEsperaPanel: FC<PanelProps> = ({
) : (
<span className="text-[11px] text-theme-muted">Sin teléfono</span>
)}
<button
type="button"
onClick={() => onQuitar(e)}
className="ml-auto inline-flex items-center gap-1 text-xs text-rose-600 hover:underline"
title="Quitar de la lista de espera"
>
<Trash2 size={13} />
Quitar
</button>
{puedeGestionar && (
<button
type="button"
onClick={() => onQuitar(e)}
className="ml-auto inline-flex items-center gap-1 text-xs text-rose-600 hover:underline"
title="Quitar de la lista de espera"
>
<Trash2 size={13} />
Quitar
</button>
)}
</div>
</div>
);

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
import type { FC, ReactNode } from 'react';
import { odooApi, type FrontendUser, type FrontendRole } from '../services/odoo';
import { odooApi, type FrontendUser, type FrontendRole, type AgendaPerm } from '../services/odoo';
const TOKEN_KEY = 'skeen_token';
const USER_KEY = 'skeen_user';
@@ -75,6 +75,7 @@ interface AuthContextValue {
logout: () => Promise<void>;
hasRole: (min: FrontendRole) => boolean;
canSee: (href: string) => boolean;
canAgenda: (perm: AgendaPerm) => boolean;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
@@ -219,8 +220,19 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
return true;
};
// Acciones dentro de la Agenda. El backend resuelve la herencia del rol y
// manda la lista efectiva en user.agenda_perms; el fallback por rango cubre
// la sesión cacheada anterior al despliegue.
const canAgenda = (perm: AgendaPerm): boolean => {
if (!user) return false;
const todas: AgendaPerm[] = ['ver', 'agendar', 'editar', 'eliminar', 'visitas'];
const perms = user.agenda_perms
?? (ROLE_RANK[user.role] >= ROLE_RANK.recepcion ? todas : ['ver']);
return perms.includes(perm);
};
return (
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee }}>
<AuthContext.Provider value={{ user, token, loading, bootError, retryBoot, login, logout, hasRole, canSee, canAgenda }}>
{children}
</AuthContext.Provider>
);

View File

@@ -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) && (

View File

@@ -884,6 +884,17 @@ export interface WacrmLead {
export type FrontendRole = 'admin' | 'medico' | 'recepcion' | 'enfermeria' | 'cosmetologia' | 'psicologa' | 'farmacia' | 'gerencia' | 'marketing' | 'lectura';
// Acciones de la Agenda (espejo de AGENDA_PERMS en skeen_whatsapp/roles.py).
export type AgendaPerm = 'ver' | 'agendar' | 'editar' | 'eliminar' | 'visitas';
export const AGENDA_PERM_ITEMS: { key: AgendaPerm; label: string; nota: string }[] = [
{ key: 'ver', label: 'Consultar agenda', nota: 'Siempre activa: sin ella no hay pantalla que ver.' },
{ key: 'agendar', label: 'Agendar cita', nota: 'Cita normal, cita rápida, lista de espera y cierres de agenda.' },
{ key: 'editar', label: 'Editar citas', nota: 'Reprogramar, mover de horario y marcar confirmaciones.' },
{ key: 'eliminar', label: 'Eliminar citas', nota: 'Borrar una cita o cancelarla.' },
{ key: 'visitas', label: 'Generar visitas', nota: 'Check-in (En cabina) y todo el expediente del día.' },
];
export interface FrontendUser {
id: number;
login: string;
@@ -893,6 +904,10 @@ export interface FrontendUser {
last_login?: string | null;
active?: boolean;
allowed_menus?: string[] | null;
// Acciones de Agenda que el backend resolvió (lista efectiva del usuario).
agenda_perms?: AgendaPerm[] | null;
// Lista propia del usuario; null = hereda del rol. La usa solo el editor.
agenda_perms_custom?: AgendaPerm[] | null;
// Solo rol 'medico': hr.employee vinculado (null si no hay vínculo)
employee_id?: number | null;
employee_name?: string | null;
@@ -1305,7 +1320,7 @@ export const odooApi = {
return data;
},
async updateFrontendUser(id: number, payload: { name?: string; login?: string; role?: FrontendRole; active?: boolean; password?: string; must_change_password?: boolean; allowed_menus?: string[] | null }): Promise<{ status: string; user: FrontendUser; message?: string }> {
async updateFrontendUser(id: number, payload: { name?: string; login?: string; role?: FrontendRole; active?: boolean; password?: string; must_change_password?: boolean; allowed_menus?: string[] | null; agenda_perms?: AgendaPerm[] | null }): Promise<{ status: string; user: FrontendUser; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/auth/users/${id}`, payload);
return data;
},