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:
+128
-38
@@ -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 LÊ 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>
|
||||
|
||||
Reference in New Issue
Block a user