diff --git a/backend/server.js b/backend/server.js index 2d63601..367d47d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) => { diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index c8dede5..cb3909f 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -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 => { + 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 => { const clinicaId = HybridBackend.getActiveWorkspace()?.id || ''; diff --git a/frontend/views/ConfiguracoesView.tsx b/frontend/views/ConfiguracoesView.tsx index 467d90d..aee47c6 100644 --- a/frontend/views/ConfiguracoesView.tsx +++ b/frontend/views/ConfiguracoesView.tsx @@ -204,10 +204,16 @@ const BackupSheetsCard: React.FC = () => { const toast = useToast(); const [loading, setLoading] = useState(true); const [st, setSt] = useState(null); - const [busy, setBusy] = useState<'' | 'push' | 'pull' | 'setid'>(''); + const [busy, setBusy] = useState<'' | 'push' | 'pull' | 'setid' | 'export'>(''); const [logs, setLogs] = useState([]); 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 && Abrir planilha ↗}
+ {/* Exportar pacientes (.csv) — sempre disponível, não depende do Google */} +
+
+

Backup dos pacientes (.csv)

+

Baixa todos os pacientes desta unidade. Guarde para contingência (ex.: usar no seu outro sistema se o servidor cair).

+
+ +
+ {loading ? (
) : !st?.connected ? (