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:
@@ -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 {
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user