feat(sync): Google Sheets bidirectional backup (PG ↔ Sheets)

Backend:
- Add SYNC_TABLES config (pacientes, agendamentos, financeiro, leads, guias)
- Add getSheetsClient(), ensureSheetTab(), pushTableToSheet(), pullTableFromSheet()
- Add schedulePush() debounced 5s auto-push after every write to main tables
- Add GET /api/sync/status, POST /api/sync/push, POST /api/sync/pull
- Update OAuth scope to include spreadsheets

Frontend:
- Redesign SyncView: per-table status grid, two-button operation (Backup/Restore),
  terminal log with color-coded lines, auto-backup info notice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-05-14 05:44:52 +02:00
parent c70b2cde66
commit 63962d51c1
2 changed files with 388 additions and 40 deletions
+128 -38
View File
@@ -849,59 +849,149 @@ export const PlanosView: React.FC = () => {
// --- SYNC ---
const SYNC_TABS = ['pacientes', 'agendamentos', 'financeiro', 'leads', 'guias'];
export const SyncView: React.FC = () => {
const [logs, setLogs] = useState<string[]>([]);
const [syncing, setSyncing] = useState(false);
const [operation, setOperation] = useState<'push' | 'pull' | null>(null);
const [status, setStatus] = useState<any>(null);
const toast = useToast();
const startSync = async () => {
setSyncing(true);
setLogs([]);
const authHeaders = {
'Authorization': `Bearer ${localStorage.getItem('SCOREODONTO_AUTH_TOKEN')}`,
'Content-Type': 'application/json'
};
const loadStatus = async () => {
try {
await HybridBackend.syncGoogleSheetsToPg((log) => {
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] ${log}`]);
});
toast.success("SINCRONIZAÇÃO CONCLUÍDA COM SUCESSO!");
} catch {
toast.error("FALHA NA SINCRONIZAÇÃO.");
const res = await fetch('/api/sync/status', { headers: authHeaders });
if (res.ok) setStatus(await res.json());
} catch {}
};
useEffect(() => { loadStatus(); }, []);
const runSync = async (dir: 'push' | 'pull') => {
setOperation(dir);
setLogs([`>>> ${dir === 'push' ? 'BACKUP PostgreSQL → Google Sheets' : 'RESTAURAR Google Sheets → PostgreSQL'}...`]);
try {
const res = await fetch(`/api/sync/${dir}`, { method: 'POST', headers: authHeaders });
const data = await res.json();
setLogs(data.logs || []);
if (data.success) {
toast.success(dir === 'push' ? 'BACKUP REALIZADO!' : 'DADOS RESTAURADOS!');
loadStatus();
} else {
toast.error('ERRO NA SINCRONIZAÇÃO. VERIFIQUE OS LOGS.');
}
} catch (e: any) {
setLogs(prev => [...prev, `[ERRO] ${e.message}`]);
toast.error('FALHA NA CONEXÃO.');
} finally {
setSyncing(false);
setOperation(null);
}
};
const fmtDate = (iso: string | undefined) => iso
? new Date(iso).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
: '—';
return (
<div className="max-w-4xl mx-auto space-y-6">
<PageHeader title="IMPORTAÇÃO E SINCRONIZAÇÃO" description="GOOGLE SHEETS ➔ POSTGRESQL">
<button
onClick={startSync}
disabled={syncing}
className={`px-4 py-2 rounded-lg flex items-center gap-2 text-xs font-bold uppercase transition-colors shadow-sm ${syncing ? 'bg-gray-300 text-gray-500 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white'}`}
>
<RefreshCw size={18} className={syncing ? 'animate-spin' : ''} />
{syncing ? 'SINCRONIZANDO...' : 'INICIAR IMPORTAÇÃO'}
</button>
<div className="max-w-5xl mx-auto space-y-6">
<PageHeader
title="BACKUP & SINCRONIZAÇÃO"
description="POSTGRESQL ↔ GOOGLE SHEETS"
>
<div className="flex gap-3">
<button
onClick={() => runSync('push')}
disabled={!!operation}
className={`px-4 py-2 rounded-xl flex items-center gap-2 text-xs font-black uppercase transition-all shadow-sm ${operation ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-green-600 hover:bg-green-700 text-white shadow-green-100'}`}
>
<RefreshCw size={15} className={operation === 'push' ? 'animate-spin' : ''} />
{operation === 'push' ? 'ENVIANDO...' : 'BACKUP AGORA'}
</button>
<button
onClick={() => runSync('pull')}
disabled={!!operation}
className={`px-4 py-2 rounded-xl flex items-center gap-2 text-xs font-black uppercase transition-all shadow-sm ${operation ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 text-white shadow-blue-100'}`}
>
<Database size={15} className={operation === 'pull' ? 'animate-spin' : ''} />
{operation === 'pull' ? 'IMPORTANDO...' : 'RESTAURAR DO BACKUP'}
</button>
</div>
</PageHeader>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-6">
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm text-green-400 h-96 overflow-y-auto shadow-inner uppercase">
{logs.length === 0 && <span className="text-gray-500 opacity-50">AGUARDANDO INÍCIO DO PROCESSO...</span>}
{logs.map((log, i) => (
<div key={i} className="mb-1 border-l-2 border-green-700 pl-2 animate-in fade-in">{log}</div>
))}
{syncing && <div className="animate-pulse">_</div>}
{/* Status por tabela */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50">
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">ESTADO DAS TABELAS</h3>
</div>
<div className="divide-y divide-gray-50">
{SYNC_TABS.map(tab => {
const s = status?.syncStatus?.[tab];
const count = status?.counts?.[tab];
return (
<div key={tab} className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${s?.lastPush ? 'bg-green-400' : 'bg-gray-200'}`} />
<span className="text-sm font-black uppercase text-gray-700">{tab}</span>
</div>
<div className="flex items-center gap-8 text-right">
<div>
<p className="text-[9px] font-bold text-gray-400 uppercase">No banco</p>
<p className="text-sm font-black text-gray-800">{count ?? '—'}</p>
</div>
<div>
<p className="text-[9px] font-bold text-gray-400 uppercase">Último backup</p>
<p className="text-xs font-bold text-gray-600">{fmtDate(s?.lastPush)}</p>
</div>
<div>
<p className="text-[9px] font-bold text-gray-400 uppercase">Registros no backup</p>
<p className="text-xs font-bold text-gray-600">{s?.count ?? '—'}</p>
</div>
</div>
</div>
);
})}
</div>
{status?.spreadsheetId && (
<div className="px-6 py-3 bg-gray-50 border-t border-gray-100 flex items-center gap-2">
<span className="text-[10px] font-bold text-gray-400 uppercase">PLANILHA:</span>
<span className="text-[10px] font-mono text-blue-600 truncate">{status.spreadsheetId}</span>
</div>
)}
</div>
{/* Terminal */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-50 flex items-center justify-between">
<h3 className="text-xs font-black text-gray-500 uppercase tracking-widest">LOG DE OPERAÇÃO</h3>
{logs.length > 0 && (
<button onClick={() => setLogs([])} className="text-[10px] font-bold text-gray-400 hover:text-gray-600 uppercase">LIMPAR</button>
)}
</div>
<div className="p-4">
<div className="bg-gray-950 rounded-xl p-4 font-mono text-xs text-green-400 min-h-[160px] max-h-80 overflow-y-auto">
{logs.length === 0
? <span className="text-gray-600">AGUARDANDO OPERAÇÃO...</span>
: logs.map((log, i) => (
<div key={i} className={`mb-1 pl-2 border-l-2 ${log.startsWith('[ERRO]') ? 'border-red-500 text-red-400' : log.startsWith('[✓]') ? 'border-green-400 text-green-300' : 'border-gray-700'}`}>
{log}
</div>
))
}
{operation && <div className="animate-pulse text-green-500 mt-1"></div>}
</div>
</div>
<div className="bg-yellow-50 p-4 border-t border-yellow-100 flex items-start gap-3">
<div className="text-yellow-600 mt-0.5"><Database size={18} /></div>
<div>
<h4 className="text-sm font-bold text-yellow-800 uppercase">NOTA DE SEGURANÇA</h4>
<p className="text-xs text-yellow-700 mt-1 uppercase font-medium">
ESTA AÇÃO TODOS OS DADOS DA PLANILHA MESTRE E ATUALIZA O BANCO DE DADOS POSTGRESQL.
O GOOGLE SHEETS PERMANECE COMO FONTE SEGURA DE BACKUP. CONFLITOS SERÃO RESOLVIDOS PRIORIZANDO A DATA DE MODIFICAÇÃO MAIS RECENTE.
</p>
</div>
<div className="bg-amber-50 border-t border-amber-100 px-6 py-3 flex items-start gap-3">
<Database size={14} className="text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-amber-700 font-bold uppercase leading-relaxed">
BACKUP AUTOMÁTICO: cada alteração de paciente, agendamento, financeiro ou lead é enviada
para a planilha automaticamente (com delay de 5s para agrupar alterações). Use "Restaurar" para
recuperar dados da planilha caso o banco seja reiniciado.
</p>
</div>
</div>
</div>