From 1cfd184886bf069ddd56eef71579292fb917544c Mon Sep 17 00:00:00 2001 From: VPS 4 Builder Date: Tue, 9 Jun 2026 18:03:46 +0200 Subject: [PATCH] feat(agenda): importar agendamentos do Google para o sistema (admin/dono) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/server.js | 46 +++++++++++++++++++++++++++++++++-- frontend/services/backend.ts | 9 +++++++ frontend/views/AgendaView.tsx | 20 ++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/backend/server.js b/backend/server.js index fdb8675..ddfc2ce 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 { diff --git a/frontend/services/backend.ts b/frontend/services/backend.ts index 317c050..9660129 100644 --- a/frontend/services/backend.ts +++ b/frontend/services/backend.ts @@ -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 => { const cid = clinicaId || HybridBackend.getActiveWorkspace()?.id || ''; diff --git a/frontend/views/AgendaView.tsx b/frontend/views/AgendaView.tsx index cc76810..a040220 100644 --- a/frontend/views/AgendaView.tsx +++ b/frontend/views/AgendaView.tsx @@ -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 }> = ({ )} + {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( + + )} {(currentRole === 'admin' || currentRole === 'donoclinica') && (