feat(agenda): feed de atividade da equipe (hoje) via audit_log

- backend GET /api/agenda/atividade (auditoria da clínica, default hoje, join paciente)
- frontend: botão History no cabeçalho + painel de feed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-09 16:18:00 +02:00
parent f084656712
commit dc753a11b2
3 changed files with 71 additions and 1 deletions
+17
View File
@@ -1890,6 +1890,23 @@ app.get('/api/agenda/presenca', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Feed de ATIVIDADE da equipe (auditoria da clínica) — default: hoje.
app.get('/api/agenda/atividade', authGuard, async (req, res) => {
try {
const { clinicaId, desde } = req.query;
if (!clinicaId) return res.json({ success: true, atividade: [] });
const since = desde || new Date(new Date().setHours(0, 0, 0, 0)).toISOString();
const { rows } = await pool.query(
`SELECT a.acao, a.actor_nome, a.entidade, a.entidade_id, a.detalhes, a.at, ag.pacientenome
FROM audit_log a
LEFT JOIN agendamentos ag ON ag.id = a.entidade_id
WHERE a.clinica_id = $1 AND a.at >= $2
ORDER BY a.at DESC LIMIT 80`,
[clinicaId, since]);
res.json({ success: true, atividade: rows });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Grupo familiar do paciente (para chips compactos no modal de agenda).
app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => {
try {
+9
View File
@@ -789,6 +789,15 @@ export const HybridBackend = {
return { grupo: j.grupo || null, membros: j.membros || [] };
},
// Feed de atividade da equipe (auditoria) — default: hoje.
getAtividadeAgenda: async (clinicaId?: string): Promise<any[]> => {
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
if (!cid) return [];
const res = await apiFetch(`${API_URL}/agenda/atividade?clinicaId=${cid}`, {});
const j = await res.json().catch(() => ({} as any));
return j.atividade || [];
},
// Presença na agenda (Fase 2): heartbeat + leitura de quem está online / última visita.
pingPresencaAgenda: async (clinicaId?: string) => {
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
+45 -1
View File
@@ -3,7 +3,7 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock } from 'lucide-react';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { AgendaDetailModal, FaltasBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
@@ -357,6 +357,8 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
const [pendencias, setPendencias] = useState<any[]>([]);
const [showPendencias, setShowPendencias] = useState(false);
const [reagendarPatient, setReagendarPatient] = useState<{ id?: string; nome?: string } | null>(null);
const [atividade, setAtividade] = useState<any[]>([]);
const [showAtividade, setShowAtividade] = useState(false);
const toast = useToast();
// Lista "a reagendar" (faltas/cancelamentos/remarcações pendentes desta clínica)
@@ -364,6 +366,9 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
try { setPendencias(await HybridBackend.getPendenciasReagendar()); } catch { /* ignore */ }
}, []);
useEffect(() => { loadPendencias(); }, [loadPendencias]);
const loadAtividade = useCallback(async () => {
try { setAtividade(await HybridBackend.getAtividadeAgenda()); } catch { /* ignore */ }
}, []);
// Fase 4 Parte 2 — o dentista vê os pedidos de interesse na própria agenda
const meuUsuarioId = (() => { try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').id; } catch { return undefined; } })();
@@ -545,6 +550,12 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
</Droppable>
</DragDropContext>
<AgendaPresence />
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
className="p-2.5 bg-white text-gray-400 hover:text-blue-600 border border-gray-200 rounded-xl transition-all hover:border-blue-200 hover:shadow-sm">
<History size={20} />
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-blue-600 border border-gray-200 rounded-xl transition-all hover:border-blue-200 hover:shadow-sm">
<GearIcon size={20} />
@@ -653,6 +664,39 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
</div>
</div>
)}
{/* Feed de ATIVIDADE da equipe (hoje) */}
{showAtividade && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowAtividade(false)}>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-base font-black uppercase text-gray-900 flex items-center gap-2">
<History size={18} className="text-blue-600" /> Atividade de hoje
</h3>
<button onClick={() => setShowAtividade(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"><X size={18} /></button>
</div>
<div className="p-4 overflow-y-auto custom-scrollbar space-y-2">
{atividade.length === 0 ? (
<p className="text-center text-xs text-gray-400 font-bold uppercase py-10">NENHUMA ATIVIDADE HOJE.</p>
) : atividade.map((a, i) => {
const AC: Record<string, string> = { criou: 'agendou', reagendou: 'reagendou', editou: 'editou', cancelou: 'cancelou', faltou: 'marcou falta de', remarcou: 'pediu remarcação de', restaurou: 'restaurou' };
const pac = a.pacientenome || a.detalhes?.paciente || '';
return (
<div key={i} className="flex items-start gap-3 p-2.5 hover:bg-gray-50 rounded-xl">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-2 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs font-bold text-gray-700">
<span className="text-blue-600">{a.actor_nome || 'Alguém'}</span> {AC[a.acao] || a.acao}{pac ? <span className="text-gray-800 uppercase"> {pac}</span> : null}
</p>
<p className="text-[10px] text-gray-400 font-medium">{new Date(a.at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
<style>{`
.animate-in { animation: animate-in 0.2s ease-out; }
@keyframes animate-in {