3e64514b08
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>
124 lines
5.4 KiB
TypeScript
124 lines
5.4 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { Calendar, Link, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
|
import { useToast } from '../contexts/ToastContext.tsx';
|
|
|
|
interface GoogleConnectButtonProps {
|
|
ownerId: string;
|
|
clinicaId?: string;
|
|
isConnected: boolean;
|
|
onStatusChange?: () => void;
|
|
}
|
|
|
|
export const GoogleConnectButton: React.FC<GoogleConnectButtonProps> = ({ ownerId, clinicaId, isConnected, onStatusChange }) => {
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const toast = useToast();
|
|
|
|
const handleConnect = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const params = new URLSearchParams({ ownerId });
|
|
if (clinicaId) params.set('clinicaId', clinicaId);
|
|
const response = await fetch(`/api/auth/google/url?${params}`);
|
|
const data = await response.json();
|
|
if (!response.ok || data.error) {
|
|
toast.error((data.error || 'Falha ao obter URL de autenticação').toUpperCase());
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
const { url } = data;
|
|
|
|
// Abrir em popup para manter o contexto
|
|
const width = 600;
|
|
const height = 700;
|
|
const left = window.screen.width / 2 - width / 2;
|
|
const top = window.screen.height / 2 - height / 2;
|
|
|
|
const popup = window.open(
|
|
url,
|
|
'google-auth',
|
|
`width=${width},height=${height},left=${left},top=${top}`
|
|
);
|
|
|
|
// Detecção robusta de fechamento. As páginas de login do Google enviam
|
|
// Cross-Origin-Opener-Policy, o que BLOQUEIA ler popup.closed (enche o
|
|
// console de warning e não detecta o fechamento). Por isso usamos o
|
|
// retorno de FOCO à janela principal como sinal de que o popup foi fechado;
|
|
// o polling de popup.closed fica só como fallback protegido por try/catch.
|
|
let done = false;
|
|
const finish = () => {
|
|
if (done) return;
|
|
done = true;
|
|
window.removeEventListener('focus', onFocus);
|
|
clearInterval(timer);
|
|
setIsLoading(false);
|
|
if (onStatusChange) onStatusChange();
|
|
};
|
|
const onFocus = () => { setTimeout(finish, 600); };
|
|
window.addEventListener('focus', onFocus);
|
|
const timer = setInterval(() => {
|
|
try { if (!popup || popup.closed) finish(); } catch { /* COOP bloqueia: ignora */ }
|
|
}, 1000);
|
|
|
|
} catch (error) {
|
|
console.error('Erro ao conectar Google Agenda:', error);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDisconnect = async () => {
|
|
if (!confirm('Deseja realmente desconectar esta agenda?')) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
await fetch(`/api/auth/google/${ownerId}`, { method: 'DELETE' });
|
|
if (onStatusChange) onStatusChange();
|
|
} catch (error) {
|
|
console.error('Erro ao desconectar:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (isConnected) {
|
|
return (
|
|
<div className="flex items-center gap-3 bg-emerald-50 border border-emerald-100 p-4 rounded-2xl">
|
|
<div className="p-2 bg-emerald-500 rounded-xl">
|
|
<CheckCircle2 size={18} className="text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-[10px] font-black text-emerald-800 uppercase tracking-tight">Google Agenda Conectada</p>
|
|
<p className="text-[9px] text-emerald-600 font-bold uppercase tracking-widest mt-0.5">Sincronização Ativa</p>
|
|
</div>
|
|
<button
|
|
onClick={handleDisconnect}
|
|
disabled={isLoading}
|
|
className="text-[9px] font-black text-red-500 hover:text-red-700 uppercase tracking-widest px-3 py-1 bg-white border border-red-100 rounded-lg shadow-sm transition-all"
|
|
>
|
|
{isLoading ? <Loader2 size={12} className="animate-spin" /> : 'Desconectar'}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleConnect}
|
|
disabled={isLoading}
|
|
className="w-full group relative overflow-hidden flex items-center gap-4 bg-white border-2 border-gray-100 p-4 rounded-2xl hover:border-teal-500 hover:shadow-xl hover:shadow-teal-50/50 transition-all duration-300"
|
|
>
|
|
<div className="p-3 bg-gray-50 group-hover:bg-teal-600 rounded-xl transition-colors duration-300">
|
|
<Calendar size={20} className="text-gray-400 group-hover:text-white transition-colors duration-300" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="text-xs font-black text-gray-800 uppercase tracking-tight group-hover:text-teal-600">Conectar Google Agenda</p>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Sincronize horários externos</p>
|
|
</div>
|
|
{isLoading && (
|
|
<div className="absolute right-4">
|
|
<Loader2 size={18} className="text-teal-600 animate-spin" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
};
|