Files
scoreodonto.com/frontend/components/AgendaPresence.tsx
T
VPS 4 Builder 3e64514b08 feat(financeiro+salas): motor financeiro V1/V2, glosas, GTO→financeiro, contratos de convênio e redesenho de Salas
Financeiro V1 (Épicos 1–4):
- FKs de origem no lançamento (paciente/profissional/procedimento/agendamento/contrato/realizado), soft delete e cron "Atrasado"
- prontuário de procedimentos realizados (fotos antes/depois) + regra sem-comissão (garantia/reabertura do mesmo profissional)
- normalizeFinanceiro: corrige CHECK capitalizado vs enforceUppercase (lançamentos não salvavam pela UI)
- baixa de pagamento (pago_em), despesas (fornecedor/centro de custo/recorrente), competência por data de conclusão
- relatório financeiro (período, paciente, profissional, forma, convênio, glosa)
- orçamento Assinado -> contas a receber; contrato vigente -> cobrança recorrente; renovação automática de contrato

Financeiro V2 (Comissão/Repasse):
- regras de % (padrão/procedimento/override por profissional) + custo de laboratório
- base selecionável (recebido/produção), fechamento persistido, pagar (gera despesa) + comprovante + estorno

Glosas de convênio: por item de GTO, recurso/protocolo/prazo, recuperar/estornar, impacto no relatório

GTO -> Financeiro: finalizar gera RECEITA (convênio a receber); LancarGTO passa a persistir; convênios reais (planos)

Contratos: modelo "Atendimento a Convênios / Repasse (Dentista Credenciado)" + variáveis de convênio

Salas (redesenho): perfil "Dono de Sala"; locação sempre ativa; menus MINHAS/ALUGAR SALAS;
sala como workspace isolado (seletor agrupado Clínicas x Salas, tenantGuard por dono); financeiro de
locação (reserva confirmada -> recebível); Painel da Sala (KPIs, agenda visual, recebíveis, relatório por sala)

Docs: BACKLOG-FINANCEIRO-V1, AUDITORIA-FUNCIONAL-SCOREODONTO, CHECKLIST-DEPLOY-PRODUCAO

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:28:07 +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-teal-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>
);
};