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:
+44
-2
@@ -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 {
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user