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:
+84
-8
@@ -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) => {
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user