Merge branch 'claude/youthful-mendel-0c569f'

This commit is contained in:
VPS 4 Builder
2026-05-14 05:44:59 +02:00
2 changed files with 388 additions and 40 deletions
+260 -2
View File
@@ -176,6 +176,184 @@ const createOAuth2Client = () => new google.auth.OAuth2(
process.env.GOOGLE_REDIRECT_URI || `http://localhost:${PORT}/api/oauth2callback`
);
// =============================================================================
// GOOGLE SHEETS BACKUP SYNC
// =============================================================================
const SHEETS_BACKUP_ID = process.env.SHEETS_BACKUP_ID || '1q1sf1GOji2u3qIJEToWfbLCb4rzb_pKLOX_AylMDT4g';
// Which tables sync, how to export from PG and how to import back
const SYNC_TABLES = [
{
tab: 'pacientes',
pgTable: 'pacientes',
query: `SELECT id, nome, cpf, telefone, email,
ultimotratamento as "ultimoTratamento",
convenio, datanascimento as "dataNascimento"
FROM pacientes ORDER BY nome`,
headers: ['id', 'nome', 'cpf', 'telefone', 'email', 'ultimoTratamento', 'convenio', 'dataNascimento'],
upsertSql: `INSERT INTO pacientes (id,nome,cpf,telefone,email,ultimotratamento,convenio,datanascimento)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (id) DO UPDATE SET
nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,
email=EXCLUDED.email,ultimotratamento=EXCLUDED.ultimotratamento,
convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
upsertParams: r => [r.id,r.nome,r.cpf,r.telefone,r.email,r.ultimoTratamento,r.convenio,r.dataNascimento]
},
{
tab: 'agendamentos',
pgTable: 'agendamentos',
query: `SELECT id,clinica_id,pacientenome as "pacienteNome",dentistaid as "dentistaId",
start_time::text,end_time::text,status,procedimento,observacoes
FROM agendamentos ORDER BY start_time DESC`,
headers: ['id','clinica_id','pacienteNome','dentistaId','start_time','end_time','status','procedimento','observacoes'],
upsertSql: `INSERT INTO agendamentos (id,clinica_id,pacientenome,dentistaid,start_time,end_time,status,procedimento,observacoes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
pacientenome=EXCLUDED.pacientenome,status=EXCLUDED.status,
procedimento=EXCLUDED.procedimento,observacoes=EXCLUDED.observacoes`,
upsertParams: r => [r.id,r.clinica_id,r.pacienteNome,r.dentistaId,r.start_time,r.end_time,r.status,r.procedimento,r.observacoes]
},
{
tab: 'financeiro',
pgTable: 'financeiro',
query: `SELECT id,clinica_id,pacientenome as "pacienteNome",descricao,
valor::text,datavencimento::text as "dataVencimento",status,
formapagamento as "formaPagamento",tipo
FROM financeiro ORDER BY datavencimento DESC`,
headers: ['id','clinica_id','pacienteNome','descricao','valor','dataVencimento','status','formaPagamento','tipo'],
upsertSql: `INSERT INTO financeiro (id,clinica_id,pacientenome,descricao,valor,datavencimento,status,formapagamento,tipo)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
pacientenome=EXCLUDED.pacientenome,descricao=EXCLUDED.descricao,
valor=EXCLUDED.valor,datavencimento=EXCLUDED.datavencimento,
status=EXCLUDED.status,formapagamento=EXCLUDED.formapagamento`,
upsertParams: r => [r.id,r.clinica_id,r.pacienteNome,r.descricao,r.valor,r.dataVencimento,r.status,r.formaPagamento,r.tipo]
},
{
tab: 'leads',
pgTable: 'leads',
query: `SELECT id,nome,sobrenome,cpf,whatsapp,
tipointeresse as "tipoInteresse",
datacadastro::text as "dataCadastro",status
FROM leads ORDER BY datacadastro DESC`,
headers: ['id','nome','sobrenome','cpf','whatsapp','tipoInteresse','dataCadastro','status'],
upsertSql: `INSERT INTO leads (id,nome,sobrenome,cpf,whatsapp,tipointeresse,datacadastro,status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (id) DO UPDATE SET
nome=EXCLUDED.nome,whatsapp=EXCLUDED.whatsapp,status=EXCLUDED.status`,
upsertParams: r => [r.id,r.nome,r.sobrenome,r.cpf,r.whatsapp,r.tipoInteresse,r.dataCadastro,r.status]
},
{
tab: 'guias',
pgTable: 'guias_odontologicas',
query: `SELECT id,numeroguiaprestador as "numeroGuiaPrestador",
numeroguiaoperadora as "numeroGuiaOperadora",
datasolicitacao::text as "dataSolicitacao",
tipotratamento as "tipoTratamento",status,
beneficiarioid as "beneficiarioId",
beneficiarionome as "beneficiarioNome",
beneficiarioidentificacao as "beneficiarioIdentificacao"
FROM guias_odontologicas ORDER BY datasolicitacao DESC`,
headers: ['id','numeroGuiaPrestador','numeroGuiaOperadora','dataSolicitacao','tipoTratamento','status','beneficiarioId','beneficiarioNome','beneficiarioIdentificacao'],
upsertSql: `INSERT INTO guias_odontologicas (id,numeroguiaprestador,numeroguiaoperadora,datasolicitacao,tipotratamento,status,beneficiarioid,beneficiarionome,beneficiarioidentificacao)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
ON CONFLICT (id) DO UPDATE SET
status=EXCLUDED.status,numeroguiaoperadora=EXCLUDED.numeroguiaoperadora`,
upsertParams: r => [r.id,r.numeroGuiaPrestador,r.numeroGuiaOperadora,r.dataSolicitacao,r.tipoTratamento,r.status,r.beneficiarioId,r.beneficiarioNome,r.beneficiarioIdentificacao]
}
];
async function getSheetsClient() {
const { rows } = await pool.query('SELECT * FROM google_tokens LIMIT 1');
if (rows.length === 0) throw new Error('NO_GOOGLE_TOKEN');
const t = rows[0];
const auth = createOAuth2Client();
auth.setCredentials({ refresh_token: t.refresh_token, access_token: t.access_token, expiry_date: t.expiry_date });
return google.sheets({ version: 'v4', auth });
}
async function ensureSheetTab(sheets, tabName) {
const meta = await sheets.spreadsheets.get({ spreadsheetId: SHEETS_BACKUP_ID });
const exists = meta.data.sheets.some(s => s.properties.title === tabName);
if (!exists) {
await sheets.spreadsheets.batchUpdate({
spreadsheetId: SHEETS_BACKUP_ID,
requestBody: { requests: [{ addSheet: { properties: { title: tabName } } }] }
});
}
}
async function pushTableToSheet(tableDef, sheets) {
const { rows } = await pool.query(tableDef.query);
await ensureSheetTab(sheets, tableDef.tab);
await sheets.spreadsheets.values.clear({ spreadsheetId: SHEETS_BACKUP_ID, range: `${tableDef.tab}!A:ZZ` });
const sheetRows = [tableDef.headers, ...rows.map(row =>
tableDef.headers.map(h => {
const v = row[h] ?? row[h.toLowerCase()];
return v === null || v === undefined ? '' : String(v);
})
)];
await sheets.spreadsheets.values.update({
spreadsheetId: SHEETS_BACKUP_ID,
range: `${tableDef.tab}!A1`,
valueInputOption: 'RAW',
requestBody: { values: sheetRows }
});
return rows.length;
}
async function pullTableFromSheet(tableDef, sheets) {
await ensureSheetTab(sheets, tableDef.tab);
const resp = await sheets.spreadsheets.values.get({ spreadsheetId: SHEETS_BACKUP_ID, range: `${tableDef.tab}!A:ZZ` });
const sheetRows = resp.data.values || [];
if (sheetRows.length < 2) return 0;
const headers = sheetRows[0];
const dataRows = sheetRows.slice(1).filter(r => r.some(c => c !== ''));
let upserted = 0;
for (const row of dataRows) {
const obj = {};
headers.forEach((h, i) => { obj[h] = i < row.length ? (row[i] || null) : null; });
if (!obj.id) continue;
try {
await pool.query(tableDef.upsertSql, tableDef.upsertParams(obj));
upserted++;
} catch (e) {
console.error(`[SHEETS PULL] ${tableDef.tab} row ${obj.id}:`, e.message);
}
}
return upserted;
}
async function saveSyncStatus(updates) {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
const current = rows[0] ? JSON.parse(rows[0].data) : {};
const merged = { ...current, ...updates };
await pool.query(
`INSERT INTO settings (id,category,data) VALUES ('sheets_sync_status','sync',$1)
ON CONFLICT (id) DO UPDATE SET data=EXCLUDED.data`,
[JSON.stringify(merged)]
);
}
// Debounced auto-push: coalesces rapid writes into one sheet update per table
const _pushTimers = new Map();
function schedulePush(tabName) {
if (_pushTimers.has(tabName)) clearTimeout(_pushTimers.get(tabName));
_pushTimers.set(tabName, setTimeout(async () => {
_pushTimers.delete(tabName);
try {
const tableDef = SYNC_TABLES.find(t => t.tab === tabName);
if (!tableDef) return;
const sheets = await getSheetsClient();
const count = await pushTableToSheet(tableDef, sheets);
await saveSyncStatus({ [tabName]: { lastPush: new Date().toISOString(), count } });
console.log(`[SHEETS] Auto-pushed ${count} rows → "${tabName}"`);
} catch (e) {
if (e.message !== 'NO_GOOGLE_TOKEN') console.error(`[SHEETS] Auto-push error for ${tabName}:`, e.message);
}
}, 5000));
}
// --- GOOGLE AUTH ROUTES ---
app.get('/api/auth/google/url', (req, res) => {
const { dentistId } = req.query;
@@ -184,7 +362,8 @@ app.get('/api/auth/google/url', (req, res) => {
access_type: 'offline',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/calendar.readonly'
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/spreadsheets'
],
state: dentistId || 'admin',
prompt: 'consent'
@@ -501,6 +680,7 @@ app.post('/api/pacientes', async (req, res) => {
const p = req.body;
const ins = buildInsert('pacientes', p);
await pool.query(ins.text, ins.values);
schedulePush('pacientes');
res.json({ success: true, data: p });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -511,6 +691,7 @@ app.put('/api/pacientes/:id', async (req, res) => {
try {
const upd = buildUpdate('pacientes', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
schedulePush('pacientes');
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: err.message });
@@ -718,12 +899,12 @@ app.post('/api/agendamentos', async (req, res) => {
await client.query(insertQuery.text, insertQuery.values);
});
schedulePush('agendamentos');
res.json({ success: true });
} catch (err) {
if (err.code === 'CONF_ERROR') {
return res.status(409).json({ success: false, message: err.message });
}
// PostgreSQL unique violation code is '23505'
if (err.code === '23505') {
return res.status(409).json({
success: false,
@@ -749,6 +930,7 @@ app.put('/api/agendamentos/:id', async (req, res) => {
if (updateQuery) {
await pool.query(updateQuery.text, updateQuery.values);
}
schedulePush('agendamentos');
res.json({ success: true });
} catch (err) {
if (err.code === '23505') {
@@ -968,6 +1150,7 @@ app.post('/api/financeiro', async (req, res) => {
try {
const ins = buildInsert('financeiro', req.body);
await pool.query(ins.text, ins.values);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -976,6 +1159,7 @@ app.put('/api/financeiro/:id', async (req, res) => {
try {
const upd = buildUpdate('financeiro', req.body, 'id', req.params.id);
await pool.query(upd.text, upd.values);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -983,6 +1167,7 @@ app.put('/api/financeiro/:id', async (req, res) => {
app.delete('/api/financeiro/:id', async (req, res) => {
try {
await pool.query('DELETE FROM financeiro WHERE id = $1', [req.params.id]);
schedulePush('financeiro');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -999,6 +1184,7 @@ app.post('/api/leads', async (req, res) => {
try {
const ins = buildInsert('leads', req.body);
await pool.query(ins.text, ins.values);
schedulePush('leads');
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
@@ -1174,6 +1360,78 @@ app.put('/api/gto-builder/:id/finalizar', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// =============================================================================
// SYNC ROUTES — Google Sheets ↔ PostgreSQL
// =============================================================================
app.get('/api/sync/status', tenantGuard, async (req, res) => {
try {
const { rows } = await pool.query("SELECT data FROM settings WHERE id = 'sheets_sync_status'");
const syncStatus = rows[0] ? JSON.parse(rows[0].data) : {};
const counts = {};
for (const t of SYNC_TABLES) {
const { rows: c } = await pool.query(`SELECT COUNT(*) as n FROM ${t.pgTable}`);
counts[t.tab] = parseInt(c[0].n);
}
res.json({ syncStatus, counts, spreadsheetId: SHEETS_BACKUP_ID });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/sync/push — PG → Google Sheets (backup)
app.post('/api/sync/push', tenantGuard, async (req, res) => {
const logs = [];
try {
const sheets = await getSheetsClient();
logs.push(`[OK] AUTENTICADO NO GOOGLE`);
const statusUpdate = {};
for (const tableDef of SYNC_TABLES) {
logs.push(`[→] ENVIANDO: ${tableDef.tab.toUpperCase()}...`);
try {
const count = await pushTableToSheet(tableDef, sheets);
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS`);
statusUpdate[tableDef.tab] = { lastPush: new Date().toISOString(), count };
} catch (e) {
logs.push(`[ERRO] ${tableDef.tab}: ${e.message}`);
}
}
await saveSyncStatus(statusUpdate);
logs.push(`[✓] BACKUP CONCLUÍDO — ${new Date().toLocaleString('pt-BR')}`);
res.json({ success: true, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA. VÁ EM CONFIGURAÇÕES E CONECTE O GOOGLE.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// POST /api/sync/pull — Google Sheets → PG (restaurar backup)
app.post('/api/sync/pull', tenantGuard, async (req, res) => {
const logs = [];
try {
const sheets = await getSheetsClient();
logs.push(`[OK] AUTENTICADO NO GOOGLE`);
for (const tableDef of SYNC_TABLES) {
logs.push(`[←] IMPORTANDO: ${tableDef.tab.toUpperCase()}...`);
try {
const count = await pullTableFromSheet(tableDef, sheets);
logs.push(`[OK] ${tableDef.tab}: ${count} REGISTROS IMPORTADOS`);
} catch (e) {
logs.push(`[ERRO] ${tableDef.tab}: ${e.message}`);
}
}
logs.push(`[✓] RESTAURAÇÃO CONCLUÍDA — ${new Date().toLocaleString('pt-BR')}`);
res.json({ success: true, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN'
? 'NENHUMA CONTA GOOGLE CONECTADA. VÁ EM CONFIGURAÇÕES E CONECTE O GOOGLE.'
: err.message;
logs.push(`[ERRO] ${msg}`);
res.status(err.message === 'NO_GOOGLE_TOKEN' ? 400 : 500).json({ success: false, logs });
}
});
// initTables() was removed as database is native PostgreSQL and schema DDL is maintained externally.
async function generateIntelligentNotifications() {
if (!pool) return;
+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>