feat(agenda): importar agendamentos do Google para o sistema (admin/dono)

- POST /api/agenda/importar-google: janela -7d/+Ndias (default 60), idempotente por google_event_id, só dono/admin
- eventos importados viram agendamentos editáveis (status agendado, google_event_id preenchido)
- getGoogleEvents filtra os já importados (anti-duplicação)
- frontend: botão Download no cabeçalho (admin/dono) com confirmação e resultado
- testado: 113 criados / 113 pulados na 2a execução (idempotente); dados de teste limpos

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-06-09 18:03:46 +02:00
parent b97c5a07b7
commit 1cfd184886
3 changed files with 72 additions and 3 deletions
+44 -2
View File
@@ -1219,6 +1219,9 @@ app.get('/api/google/events', async (req, res) => {
if (!clinicaId) return res.json([]);
// Tokens só desta clínica (cada conta Google é vinculada à sua clínica).
const { rows: tokenRows } = await pool.query('SELECT * FROM google_tokens WHERE clinica_id = $1', [clinicaId]);
// Eventos já IMPORTADOS para o sistema não são re-exibidos como evento do Google (evita duplicar).
const { rows: impRows } = await pool.query('SELECT google_event_id FROM agendamentos WHERE clinica_id = $1 AND google_event_id IS NOT NULL', [clinicaId]);
const importedSet = new Set(impRows.map(r => r.google_event_id));
const allEvents = [];
const now = new Date();
const timeMin = now.toISOString();
@@ -1260,9 +1263,9 @@ app.get('/api/google/events', async (req, res) => {
if (cl[0]?.nome_fantasia) ownerName = cl[0].nome_fantasia;
}
// Ignora eventos que o PRÓPRIO sistema empurrou (backup) → não duplica na agenda.
// Ignora eventos que o sistema empurrou (backup) OU que já foram importados → não duplica.
const mapped = response.data.items
.filter(item => !item.extendedProperties?.private?.scoreodonto)
.filter(item => !item.extendedProperties?.private?.scoreodonto && !importedSet.has(item.id))
.map(item => ({
id: `google_${item.id}`,
title: `[${ownerName}] ${item.summary || '(Sem Título)'}`,
@@ -2004,6 +2007,45 @@ app.get('/api/agenda/atividade', authGuard, async (req, res) => {
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Importa agendamentos do Google Calendar p/ DENTRO do sistema (viram agendamentos editáveis).
// Janela: últimos 7 dias + próximos N dias (default 60). Idempotente (dedup por google_event_id).
app.post('/api/agenda/importar-google', authGuard, async (req, res) => {
try {
const { clinicaId, dias } = req.body || {};
if (!clinicaId) return res.status(400).json({ success: false, message: 'clinicaId obrigatório.' });
// Só dono/admin da clínica importa.
if (await nivelNaClinica(req.authUser.userId, clinicaId) < 99999) {
return res.status(403).json({ success: false, message: 'Apenas o dono/admin pode importar.' });
}
const auth = await getClinicOAuth(clinicaId);
if (!auth) return res.status(400).json({ success: false, message: 'Nenhuma conta Google conectada nesta clínica.' });
const calendar = google.calendar({ version: 'v3', auth });
const now = Date.now();
const timeMin = new Date(now - 7 * 86400000).toISOString();
const timeMax = new Date(now + (Number(dias) || 60) * 86400000).toISOString();
const resp = await calendar.events.list({ calendarId: 'primary', timeMin, timeMax, singleEvents: true, orderBy: 'startTime', maxResults: 250 });
const items = (resp.data.items || []).filter(it => !it.extendedProperties?.private?.scoreodonto);
const actor = req.authUser?.userId || null;
const actorNm = await actorNome(actor);
let criados = 0, pulados = 0;
for (const it of items) {
const start = it.start?.dateTime || it.start?.date;
if (!start) { pulados++; continue; }
const { rows: ex } = await pool.query('SELECT 1 FROM agendamentos WHERE google_event_id = $1 AND clinica_id = $2 LIMIT 1', [it.id, clinicaId]);
if (ex.length) { pulados++; continue; }
const end = it.end?.dateTime || it.end?.date || start;
const id = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
await pool.query(
`INSERT INTO agendamentos (id, clinica_id, pacientenome, start_time, end_time, status, procedimento, google_event_id, created_by, created_by_nome, version)
VALUES ($1,$2,$3,$4,$5,'agendado',$6,$7,$8,$9,1)`,
[id, clinicaId, it.summary || 'Importado do Google', start, end, 'IMPORTADO (GOOGLE)', it.id, actor, actorNm]);
await registrarAudit(pool, { clinicaId, entidade: 'agendamento', entidadeId: id, acao: 'criou', actorId: actor, actorNome: actorNm, detalhes: { origem: 'google', summary: it.summary } });
criados++;
}
res.json({ success: true, criados, pulados, total: items.length });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
});
// Grupo familiar do paciente (para chips compactos no modal de agenda).
app.get('/api/pacientes/:id/familia', authGuard, async (req, res) => {
try {
+9
View File
@@ -789,6 +789,15 @@ export const HybridBackend = {
return { grupo: j.grupo || null, membros: j.membros || [] };
},
// Importa agendamentos do Google p/ dentro do sistema (admin/dono). Idempotente.
importarGoogleAgenda: async (clinicaId?: string, dias?: number): Promise<{ success: boolean; criados?: number; pulados?: number; total?: number; message?: string }> => {
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
const res = await apiFetch(`${API_URL}/agenda/importar-google`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clinicaId: cid, dias }),
});
return await res.json().catch(() => ({ success: false }));
},
// Feed de atividade da equipe (auditoria) — default: hoje.
getAtividadeAgenda: async (clinicaId?: string): Promise<any[]> => {
const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || '';
+19 -1
View File
@@ -3,7 +3,7 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History } from 'lucide-react';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { AgendaDetailModal, FaltasBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
@@ -369,6 +369,18 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
const loadAtividade = useCallback(async () => {
try { setAtividade(await HybridBackend.getAtividadeAgenda()); } catch { /* ignore */ }
}, []);
const [importando, setImportando] = useState(false);
const handleImportarGoogle = async () => {
if (!confirm('Importar os agendamentos do Google (última semana + próximos) para o sistema? Eles passam a ser editáveis aqui.')) return;
setImportando(true);
try {
const r = await HybridBackend.importarGoogleAgenda();
if (!r.success) { toast.error(r.message || 'ERRO AO IMPORTAR.'); return; }
toast.success(`IMPORTADOS: ${r.criados ?? 0} (${r.pulados ?? 0} já existiam).`);
refresh(); refreshGoogle();
} catch { toast.error('ERRO AO IMPORTAR.'); }
finally { setImportando(false); }
};
// Fase 4 Parte 2 — o dentista vê os pedidos de interesse na própria agenda
const meuUsuarioId = (() => { try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').id; } catch { return undefined; } })();
@@ -573,6 +585,12 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
<History size={20} />
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="p-2.5 bg-white text-gray-400 hover:text-emerald-600 border border-gray-200 rounded-xl transition-all hover:border-emerald-200 hover:shadow-sm disabled:opacity-50">
{importando ? <Loader2 size={20} className="animate-spin" /> : <Download size={20} />}
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
<button onClick={() => setIsSettingsOpen(true)} className="p-2.5 bg-white text-gray-400 hover:text-blue-600 border border-gray-200 rounded-xl transition-all hover:border-blue-200 hover:shadow-sm">
<GearIcon size={20} />