Merge branch 'claude/youthful-mendel-0c569f'
This commit is contained in:
+260
-2
@@ -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
@@ -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