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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user