f084656712
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>
108 lines
5.2 KiB
TypeScript
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 `há ${Math.floor(d / 60)} min`;
|
|
if (d < 86400) return `há ${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">Só 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>
|
|
);
|
|
};
|