Files
scoreodonto.com/frontend/components/AgendaPresence.tsx
T
VPS 4 Builder f084656712 feat(agenda): auditoria/autoria, faltas, a-reagendar, presença + RBAC por cargo
Agenda (multi-secretária):
- audit_log genérico + autoria (created/updated/canceled_by) + soft-delete + version (lock otimista)
- status novo vocabulário + índice único PARCIAL (libera slot ao cancelar/faltar)
- endpoints: falta, remarcar, restaurar, historico, pendencias (a reagendar), faltas, familia
- presença: heartbeat (DB última visita + Dragonfly ao vivo, janela 45s)
- frontend: StackModal, AgendaDetailModal, AgendaPresence; lista a-reagendar; pacienteId no fluxo de criação; chips de família + badge de faltas no modal

RBAC por cargo: funcoes.permissoes/nivel; gate de menu por cargo (array vazio herda role); hierarquia só-gerencia-abaixo no backend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 16:10:58 +02:00

108 lines
5.2 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { HybridBackend } from '../services/backend.ts';
import { Eye, Circle } from 'lucide-react';
const initials = (nome: string) => (nome || '?').trim().split(/\s+/).slice(0, 2).map(w => w[0]).join('').toUpperCase();
const COLORS = ['#2563eb', '#7c3aed', '#db2777', '#059669', '#d97706', '#0891b2'];
const colorFor = (s: string) => COLORS[(s || '').split('').reduce((a, c) => a + c.charCodeAt(0), 0) % COLORS.length];
const relTime = (iso: string) => {
const d = (Date.now() - new Date(iso).getTime()) / 1000;
if (d < 60) return 'agora há pouco';
if (d < 3600) return `${Math.floor(d / 60)} min`;
if (d < 86400) return `${Math.floor(d / 3600)} h`;
return new Date(iso).toLocaleDateString('pt-BR');
};
// Barra de presença da agenda: heartbeat (30s) + "quem está online agora" + últimas visitas.
export const AgendaPresence: React.FC = () => {
const [online, setOnline] = useState<any[]>([]);
const [ultimas, setUltimas] = useState<any[]>([]);
const [open, setOpen] = useState(false);
useEffect(() => {
let alive = true;
const tick = async () => {
await HybridBackend.pingPresencaAgenda();
const r = await HybridBackend.getPresencaAgenda();
if (alive) { setOnline(r.online || []); setUltimas(r.ultimas || []); }
};
tick();
const iv = setInterval(tick, 30000);
const onVis = () => { if (document.visibilityState === 'visible') tick(); };
document.addEventListener('visibilitychange', onVis);
return () => { alive = false; clearInterval(iv); document.removeEventListener('visibilitychange', onVis); };
}, []);
const onlineIds = new Set(online.map(o => o.usuario_id));
const ultimasOffline = ultimas.filter(u => !onlineIds.has(u.usuario_id));
return (
<div className="relative">
<button
onClick={() => setOpen(o => !o)}
title="Presença na agenda"
className="flex items-center gap-2 px-2.5 py-2 bg-white border border-gray-200 rounded-xl hover:border-blue-200 hover:shadow-sm transition-all"
>
{online.length > 0 ? (
<div className="flex -space-x-2">
{online.slice(0, 3).map(o => (
<span key={o.usuario_id} title={o.nome}
className="w-6 h-6 rounded-full border-2 border-white flex items-center justify-center text-[9px] font-black text-white shadow-sm"
style={{ backgroundColor: colorFor(o.nome) }}>
{initials(o.nome)}
</span>
))}
{online.length > 3 && (
<span className="w-6 h-6 rounded-full border-2 border-white bg-gray-400 flex items-center justify-center text-[9px] font-black text-white">+{online.length - 3}</span>
)}
</div>
) : (
<Eye size={16} className="text-gray-400" />
)}
<span className="text-[10px] font-black uppercase text-gray-500 hidden sm:inline">
{online.length > 0 ? `${online.length} online` : 'Presença'}
</span>
</button>
{open && (
<>
<div className="fixed inset-0 z-[55]" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 z-[56] overflow-hidden">
<div className="px-4 py-3 border-b border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest flex items-center gap-1.5">
<Circle size={8} className="fill-emerald-500 text-emerald-500" /> Online agora ({online.length})
</p>
</div>
<div className="max-h-72 overflow-y-auto custom-scrollbar">
{online.length === 0 && (
<p className="px-4 py-3 text-[11px] text-gray-400 font-bold uppercase"> você por aqui.</p>
)}
{online.map(o => (
<div key={o.usuario_id} className="flex items-center gap-2.5 px-4 py-2.5">
<span className="relative w-7 h-7 rounded-full flex items-center justify-center text-[9px] font-black text-white shrink-0" style={{ backgroundColor: colorFor(o.nome) }}>
{initials(o.nome)}
<Circle size={9} className="absolute -bottom-0.5 -right-0.5 fill-emerald-500 text-white" />
</span>
<span className="text-xs font-bold text-gray-700 uppercase truncate">{o.nome}</span>
</div>
))}
{ultimasOffline.length > 0 && (
<div className="px-4 py-2 border-t border-gray-50">
<p className="text-[10px] font-black uppercase text-gray-400 tracking-widest mb-1">Últimas visitas</p>
{ultimasOffline.slice(0, 6).map(u => (
<div key={u.usuario_id} className="flex items-center justify-between py-1">
<span className="text-[11px] font-bold text-gray-500 uppercase truncate">{u.usuario_nome}</span>
<span className="text-[9px] text-gray-300 font-bold whitespace-nowrap ml-2">{relTime(u.last_view_at)}</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
};