feat(pacientes): import inteligente (acha aba pacientes + mapeia colunas) + export CSV

- import: detecta aba com 'paciente' no nome, escolhe a que casa mais colunas, mapeia por sinonimo (nome/sobrenome, telefone/tefefoneprincipal, convenio/credenciada/plano, datanasci...), gera id do CPF se faltar; importa so campos do sistema
- export: GET /api/pacientes/export -> CSV (sem ultimo tratamento, sem leads), BOM p/ Excel, download no front
- card Configuracoes: botao Baixar CSV (sempre) + importar de planilha (Google)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-10 05:17:26 +02:00
parent 1e4f4dee2f
commit e5e02b7438
3 changed files with 117 additions and 9 deletions
+84 -8
View File
@@ -1023,8 +1023,69 @@ 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".
// --- IMPORT/EXPORT DE PACIENTES (CSV) ---
// Normaliza cabeçalho (minúsculo, sem acento, só letras/números) e dicionário de sinônimos.
const _normHdr = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]/g, '');
const PAC_FIELD_SYNS = {
id: ['id'],
nome: ['nome', 'name', 'paciente', 'nomecompleto', 'nomepaciente'],
sobrenome: ['sobrenome', 'surname'],
cpf: ['cpf', 'documento'],
telefone: ['telefone', 'tefefoneprincipal', 'telefoneprincipal', 'whatsapp', 'celular', 'fone', 'contato', 'telefone1'],
email: ['email'],
convenio: ['convenio', 'credenciada', 'plano', 'planos', 'planosaude'],
datanascimento: ['datanascimento', 'datanasci', 'nascimento', 'dtnasc', 'dn'],
};
function mapPacHeaders(headers) {
const norm = (headers || []).map(_normHdr);
const map = {};
for (const [field, syns] of Object.entries(PAC_FIELD_SYNS)) {
map[field] = norm.findIndex(h => syns.includes(h));
}
return map;
}
// Acha a melhor aba "pacientes" (mais colunas casadas) e importa só os campos do sistema.
async function smartImportPacientes(sheets, spreadsheetId, clinicaId) {
const meta = await sheets.spreadsheets.get({ spreadsheetId, fields: 'sheets.properties.title' });
const tabs = (meta.data.sheets || []).map(s => s.properties.title);
const cands = tabs.filter(t => _normHdr(t).includes('paciente'));
if (!cands.length) throw new Error('Nenhuma aba com "pacientes" no nome foi encontrada na planilha.');
let best = null;
for (const tab of cands) {
const r = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${tab}!A1:ZZ1` });
const headers = (r.data.values || [])[0] || [];
const map = mapPacHeaders(headers);
const score = Object.values(map).filter(i => i >= 0).length;
if (!best || score > best.score) best = { tab, headers, map, score };
}
if (best.map.nome < 0) throw new Error(`Aba "${best.tab}": não encontrei a coluna NOME.`);
const resp = await sheets.spreadsheets.values.get({ spreadsheetId, range: `${best.tab}!A:ZZ` });
const rows = resp.data.values || [];
const get = (row, i) => (i >= 0 && i < row.length ? String(row[i] || '').trim() : '');
let imported = 0, skipped = 0;
for (const row of rows.slice(1)) {
if (!row.some(c => String(c || '').trim() !== '')) continue;
let nome = get(row, best.map.nome);
if (best.map.sobrenome >= 0) { const sn = get(row, best.map.sobrenome); if (sn) nome = `${nome} ${sn}`.trim(); }
nome = nome.toUpperCase();
if (!nome) { skipped++; continue; }
const cpf = get(row, best.map.cpf); const cpfDigits = cpf.replace(/\D/g, '');
let id = get(row, best.map.id) || cpfDigits || ('imp_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7));
try {
await pool.query(
`INSERT INTO pacientes (id,clinica_id,nome,cpf,telefone,email,convenio,datanascimento)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (id) DO UPDATE SET clinica_id=EXCLUDED.clinica_id,nome=EXCLUDED.nome,cpf=EXCLUDED.cpf,telefone=EXCLUDED.telefone,email=EXCLUDED.email,convenio=EXCLUDED.convenio,datanascimento=EXCLUDED.datanascimento`,
[id.substring(0, 50), clinicaId, nome.substring(0, 255), cpf.substring(0, 20) || null, get(row, best.map.telefone).substring(0, 30) || null, get(row, best.map.email).substring(0, 150) || null, get(row, best.map.convenio).substring(0, 100) || null, get(row, best.map.datanascimento).substring(0, 20) || null]
);
imported++;
} catch { skipped++; }
}
const matched = Object.entries(best.map).filter(([, i]) => i >= 0).map(([f]) => f.toUpperCase());
return { tab: best.tab, matched, imported, skipped };
}
// Importa pacientes de uma planilha EXISTENTE (ID/URL). NÃO altera o sheets_id nem a planilha fonte.
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();
@@ -1033,19 +1094,34 @@ app.post('/api/clinica-sync/import-pacientes', tenantGuard, async (req, res) =>
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; }
try { await sheets.spreadsheets.get({ spreadsheetId: sheetsId, fields: 'spreadsheetId' }); }
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 });
const r = await smartImportPacientes(sheets, sheetsId, clinicaId);
logs.push(`[←] ABA "${r.tab}" — colunas casadas: ${r.matched.join(', ')}`);
logs.push(`[✓] ${r.imported} PACIENTES IMPORTADOS${r.skipped ? ` (${r.skipped} ignorados)` : ''}`);
res.json({ success: true, imported: r.imported, skipped: r.skipped, tab: r.tab, matched: r.matched, 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}`] });
}
});
// Export CSV dos pacientes (o bem mais precioso). Sem "ultimo tratamento". Não precisa de Google.
app.get('/api/pacientes/export', authGuard, async (req, res) => {
try {
const clinicaId = req.query.clinicaId;
if (!clinicaId) return res.status(400).send('clinicaId obrigatório');
const cols = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'datanascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes'];
const header = ['id', 'nome', 'cpf', 'telefone', 'email', 'convenio', 'dataNascimento', 'logradouro', 'numero', 'complemento', 'bairro', 'cidade', 'estado', 'cep', 'observacoes'];
const { rows } = await pool.query(`SELECT ${cols.join(',')} FROM pacientes WHERE clinica_id = $1 ORDER BY nome`, [clinicaId]);
const esc = v => { v = v == null ? '' : String(v); return /[",\n;]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; };
const csv = '' + [header.join(',')].concat(rows.map(r => cols.map(c => esc(r[c])).join(','))).join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="pacientes.csv"`);
res.send(csv);
} catch (err) { res.status(500).send('erro ao exportar'); }
});
// 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) => {
+14
View File
@@ -215,6 +215,20 @@ export const HybridBackend = {
return res.json();
},
// Exporta os pacientes da unidade como CSV (download). Não precisa de Google.
exportPacientesCsv: async (): Promise<void> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/pacientes/export?clinicaId=${clinicaId}`, {});
if (!res.ok) throw new Error('Falha ao exportar.');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pacientes_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
},
// Importa SOMENTE pacientes de uma planilha EXISTENTE (ID/URL), sem alterar o backup.
importPacientesDaPlanilha: async (sheetsId: string): Promise<any> => {
const clinicaId = HybridBackend.getActiveWorkspace()?.id || '';
+19 -1
View File
@@ -204,10 +204,16 @@ const BackupSheetsCard: React.FC = () => {
const toast = useToast();
const [loading, setLoading] = useState(true);
const [st, setSt] = useState<any>(null);
const [busy, setBusy] = useState<'' | 'push' | 'pull' | 'setid'>('');
const [busy, setBusy] = useState<'' | 'push' | 'pull' | 'setid' | 'export'>('');
const [logs, setLogs] = useState<string[]>([]);
const [sheetIdInput, setSheetIdInput] = useState('');
const exportarCsv = async () => {
setBusy('export');
try { await HybridBackend.exportPacientesCsv(); toast.success('BACKUP (.CSV) BAIXADO!'); }
catch { toast.error('ERRO AO EXPORTAR.'); } finally { setBusy(''); }
};
const load = async () => {
setLoading(true);
try { setSt(await HybridBackend.getClinicaSyncStatus()); } catch { /* ignore */ } finally { setLoading(false); }
@@ -254,6 +260,18 @@ const BackupSheetsCard: React.FC = () => {
{st?.spreadsheetUrl && <a href={st.spreadsheetUrl} target="_blank" rel="noreferrer" className="text-[10px] font-black text-emerald-600 uppercase hover:underline">Abrir planilha </a>}
</div>
<div className="p-6 space-y-4">
{/* Exportar pacientes (.csv) — sempre disponível, não depende do Google */}
<div className="flex items-center justify-between gap-3 bg-gray-50 rounded-xl p-3">
<div className="min-w-0">
<p className="text-[11px] font-black text-gray-700 uppercase">Backup dos pacientes (.csv)</p>
<p className="text-[10px] text-gray-400 leading-snug">Baixa todos os pacientes desta unidade. Guarde para contingência (ex.: usar no seu outro sistema se o servidor cair).</p>
</div>
<button onClick={exportarCsv} disabled={!!busy}
className="px-3 py-2 bg-gray-900 text-white rounded-xl text-[10px] font-black uppercase hover:bg-gray-800 disabled:opacity-50 whitespace-nowrap flex items-center gap-1.5">
{busy === 'export' ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />} Baixar CSV
</button>
</div>
{loading ? (
<div className="flex justify-center py-4"><Loader2 size={20} className="animate-spin text-gray-300" /></div>
) : !st?.connected ? (