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 ---
|
// --- GOOGLE AUTH ROUTES ---
|
||||||
app.get('/api/auth/google/url', (req, res) => {
|
app.get('/api/auth/google/url', (req, res) => {
|
||||||
if (!googleCreds.clientId || !googleCreds.clientSecret) {
|
if (!googleCreds.clientId || !googleCreds.clientSecret) {
|
||||||
|
|||||||
@@ -215,6 +215,15 @@ export const HybridBackend = {
|
|||||||
return res.json();
|
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) ---
|
// --- 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
|
// 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
|
// (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 toast = useToast();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [st, setSt] = useState<any>(null);
|
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 [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [sheetIdInput, setSheetIdInput] = useState('');
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -213,6 +214,19 @@ const BackupSheetsCard: React.FC = () => {
|
|||||||
};
|
};
|
||||||
useEffect(() => { load(); }, []);
|
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 () => {
|
const push = async () => {
|
||||||
setBusy('push'); setLogs([]);
|
setBusy('push'); setLogs([]);
|
||||||
try {
|
try {
|
||||||
@@ -223,7 +237,7 @@ const BackupSheetsCard: React.FC = () => {
|
|||||||
} catch { toast.error('ERRO NO BACKUP.'); } finally { setBusy(''); }
|
} catch { toast.error('ERRO NO BACKUP.'); } finally { setBusy(''); }
|
||||||
};
|
};
|
||||||
const pull = async () => {
|
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([]);
|
setBusy('pull'); setLogs([]);
|
||||||
try {
|
try {
|
||||||
const r = await HybridBackend.clinicaSyncPull();
|
const r = await HybridBackend.clinicaSyncPull();
|
||||||
@@ -258,12 +272,28 @@ const BackupSheetsCard: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{st.lastSync?.lastPush && <p className="text-[10px] text-gray-400">Último backup: {new Date(st.lastSync.lastPush).toLocaleString('pt-BR')}</p>}
|
{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">
|
<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">
|
<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
|
{busy === 'push' ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />} Enviar backup
|
||||||
</button>
|
</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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{logs.length > 0 && (
|
{logs.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user