feat(config): importar pacientes de planilha Google existente (somente-leitura)

- backend POST /api/clinica-sync/import-pacientes (le aba 'pacientes' de um ID/URL, nao altera sheets_id nem a planilha)
- endpoint set-id (opcional) para apontar planilha de backup
- frontend: campo de ID/URL + botao Importar no card Backup; rotulo Restaurar->Importar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-10 01:24:44 +02:00
parent e6a8e67997
commit 1e4f4dee2f
3 changed files with 91 additions and 3 deletions
+49
View File
@@ -1023,6 +1023,55 @@ app.post('/api/clinica-sync/pull', tenantGuard, async (req, res) => {
}
});
// Importa SOMENTE pacientes de uma planilha EXISTENTE (ID/URL), sem tocar no sheets_id
// de backup (não-destrutivo: nunca limpa/sobrescreve a planilha fonte). Lê a aba "pacientes".
app.post('/api/clinica-sync/import-pacientes', tenantGuard, async (req, res) => {
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
let sheetsId = String(req.body?.sheetsId || '').trim();
const logs = [];
try {
if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' });
const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); if (m) sheetsId = m[1];
const sheets = await getClinicSheetsClient(clinicaId);
let title;
try { const meta = await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'properties.title' }); title = meta.data.properties.title; }
catch { return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade.' }); }
logs.push(`[←] LENDO "${title}" → ABA PACIENTES...`);
const n = await pullClinicTable(CLINIC_SYNC_TABLES[0], sheets, sheetsId, clinicaId); // [0] = pacientes
logs.push(`[✓] ${n} PACIENTES IMPORTADOS`);
res.json({ success: true, imported: n, logs });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message;
res.status(400).json({ success: false, message: msg, logs: [...logs, `[ERRO] ${msg}`] });
}
});
// Define a planilha (ID/URL) a ser usada por esta clínica → habilita IMPORTAR de uma
// planilha EXISTENTE (ex.: migração de pacientes). Valida o acesso com a conta da unidade.
app.post('/api/clinica-sync/set-id', tenantGuard, async (req, res) => {
const clinicaId = req.body?.clinicaId || req.query.clinicaId;
let sheetsId = String(req.body?.sheetsId || '').trim();
try {
if (!clinicaId || !sheetsId) return res.status(400).json({ success: false, message: 'clinicaId e ID da planilha são obrigatórios.' });
const m = sheetsId.match(/\/d\/([a-zA-Z0-9-_]+)/); // aceita URL completa
if (m) sheetsId = m[1];
const sheets = await getClinicSheetsClient(clinicaId);
let title, abas = [];
try {
const meta = await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'properties.title,sheets.properties.title' });
title = meta.data.properties.title;
abas = (meta.data.sheets || []).map(s => s.properties.title);
} catch {
return res.status(400).json({ success: false, message: 'Não consegui abrir essa planilha. Confira o ID e se ela está compartilhada com a conta Google desta unidade (' + sheetsId + ').' });
}
await pool.query('UPDATE clinicas SET sheets_id = $1 WHERE id = $2', [sheetsId, clinicaId]);
res.json({ success: true, spreadsheetId: sheetsId, spreadsheetUrl: clinicSheetUrl(sheetsId), title, abas });
} catch (err) {
const msg = err.message === 'NO_GOOGLE_TOKEN' ? 'CONECTE A CONTA GOOGLE DESTA UNIDADE PRIMEIRO (AGENDA → CONFIGURAÇÕES).' : err.message;
res.status(400).json({ success: false, message: msg });
}
});
// --- GOOGLE AUTH ROUTES ---
app.get('/api/auth/google/url', (req, res) => {
if (!googleCreds.clientId || !googleCreds.clientSecret) {
+9
View File
@@ -215,6 +215,15 @@ export const HybridBackend = {
return res.json();
},
// Importa SOMENTE pacientes de uma planilha EXISTENTE (ID/URL), sem alterar o backup.
importPacientesDaPlanilha: async (sheetsId: string): Promise<any> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/clinica-sync/import-pacientes`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clinicaId, sheetsId }),
});
return res.json();
},
// --- SUPERADMIN: MODO VISUALIZAÇÃO ("ver como" perfil, sem dados de terceiros) ---
// Cria um workspace sintético com a role escolhida. Não há clínica real associada
// (id inexistente), então as telas escopadas por clínica vêm vazias — nenhum dado
+33 -3
View File
@@ -204,8 +204,9 @@ const BackupSheetsCard: React.FC = () => {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [st, setSt] = useState<any>(null);
const [busy, setBusy] = useState<'' | 'push' | 'pull'>('');
const [busy, setBusy] = useState<'' | 'push' | 'pull' | 'setid'>('');
const [logs, setLogs] = useState<string[]>([]);
const [sheetIdInput, setSheetIdInput] = useState('');
const load = async () => {
setLoading(true);
@@ -213,6 +214,19 @@ const BackupSheetsCard: React.FC = () => {
};
useEffect(() => { load(); }, []);
const importarPlanilha = async () => {
if (!sheetIdInput.trim()) return;
if (!window.confirm('Importar pacientes desta planilha? (pacientes com mesmo id são atualizados; a planilha NÃO é alterada)')) return;
setBusy('setid'); setLogs([]);
try {
const r = await HybridBackend.importPacientesDaPlanilha(sheetIdInput.trim());
setLogs(r.logs || []);
if (!r.success) { toast.error(r.message || 'ERRO AO IMPORTAR.'); return; }
toast.success(`${r.imported ?? 0} PACIENTES IMPORTADOS!`);
load();
} catch { toast.error('ERRO AO IMPORTAR.'); } finally { setBusy(''); }
};
const push = async () => {
setBusy('push'); setLogs([]);
try {
@@ -223,7 +237,7 @@ const BackupSheetsCard: React.FC = () => {
} catch { toast.error('ERRO NO BACKUP.'); } finally { setBusy(''); }
};
const pull = async () => {
if (!window.confirm('RESTAURAR vai sobrescrever os dados desta unidade com os da planilha. Continuar?')) return;
if (!window.confirm('IMPORTAR vai trazer os dados da planilha para esta unidade (registros com mesmo id são sobrescritos). Continuar?')) return;
setBusy('pull'); setLogs([]);
try {
const r = await HybridBackend.clinicaSyncPull();
@@ -258,12 +272,28 @@ const BackupSheetsCard: React.FC = () => {
))}
</div>
{st.lastSync?.lastPush && <p className="text-[10px] text-gray-400">Último backup: {new Date(st.lastSync.lastPush).toLocaleString('pt-BR')}</p>}
{/* Importar de uma planilha EXISTENTE (cole o ID ou o link) */}
<div className="bg-blue-50/60 border border-blue-100 rounded-xl p-3 space-y-2">
<p className="text-[10px] font-black text-blue-700 uppercase tracking-widest">Importar de uma planilha existente</p>
<p className="text-[10px] text-blue-600 leading-snug">Cole o ID ou o link da planilha. Precisa ter uma aba <strong>PACIENTES</strong> com as colunas <strong>id, nome, cpf, telefone, email, ultimoTratamento, convenio, dataNascimento</strong>. Compartilhe a planilha com <strong>{st.googleEmail}</strong>. (A planilha não é alterada.)</p>
<div className="flex gap-2">
<input value={sheetIdInput} onChange={e => setSheetIdInput(e.target.value)}
placeholder="ID ou link da planilha do Google"
className="flex-1 min-w-0 border border-blue-200 rounded-lg px-3 py-2 text-xs font-medium focus:outline-none focus:border-blue-400" />
<button onClick={importarPlanilha} disabled={!!busy || !sheetIdInput.trim()}
className="px-3 py-2 bg-blue-600 text-white rounded-lg text-[10px] font-black uppercase hover:bg-blue-700 disabled:opacity-50 whitespace-nowrap flex items-center gap-1.5">
{busy === 'setid' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Importar
</button>
</div>
</div>
<div className="flex gap-2">
<button onClick={push} disabled={!!busy} className="flex-1 py-2.5 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-[11px] font-black uppercase disabled:opacity-50 flex items-center justify-center gap-2">
{busy === 'push' ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />} Enviar backup
</button>
<button onClick={pull} disabled={!!busy || !st.spreadsheetId} className="flex-1 py-2.5 border border-gray-200 rounded-xl text-[11px] font-black uppercase text-gray-600 hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2">
{busy === 'pull' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Restaurar
{busy === 'pull' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Importar
</button>
</div>
{logs.length > 0 && (