diff --git a/backend/newwhats/agenda-bridge.js b/backend/newwhats/agenda-bridge.js index 67cfdc1..cd45678 100644 --- a/backend/newwhats/agenda-bridge.js +++ b/backend/newwhats/agenda-bridge.js @@ -310,6 +310,36 @@ export function createAgendaBridge(pool) { } }); + // ── POST /pedido ────────────────────────────────────────────────────────────── + // A Secretária IA registra um pedido de horário FORA da janela livre (zona cinza: + // mais cedo/mais tarde) para a SECRETÁRIA HUMANA confirmar. A IA não agenda esses. + // Body: { clinica_id, data (YYYY-MM-DD), hora (HH:MM), hora_fim?, dentista_nome?, + // nome_cliente, telefone?, procedimento? } + router.post('/pedido', async (req, res) => { + const b = req.body || {}; + if (!b.clinica_id || !b.data || !b.hora) return res.status(400).json({ error: 'clinica_id, data e hora obrigatórios' }); + try { + // Dia totalmente fechado (feriado/fechamento) → não adianta criar pedido. + const fer = await resolverFeriado(pool, b.clinica_id, b.data); + if (fer && fer.fecha) return res.json({ ok: false, motivo: `Fechado nesse dia (${fer.nome}).` }); + // Resolve o dentista pelo nome (opcional). + let dentistaId = null; + if (b.dentista_nome) { + const { rows } = await pool.query( + `SELECT id FROM dentistas WHERE clinica_id = $1 AND lower(nome) LIKE '%'||lower($2)||'%' LIMIT 1`, + [b.clinica_id, b.dentista_nome]); + dentistaId = rows[0]?.id || null; + } + const id = `ped_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await pool.query( + `INSERT INTO agenda_pedidos (id, clinica_id, dentista_id, data, hora_inicio, hora_fim, paciente_nome, paciente_telefone, procedimento, status) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,'pendente')`, + [id, b.clinica_id, dentistaId, b.data, hm(b.hora), b.hora_fim ? hm(b.hora_fim) : null, + b.nome_cliente || null, (String(b.telefone || '').replace(/\D/g, '')) || null, b.procedimento || null]); + res.json({ ok: true, pedido_id: id }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + // ── POST /book ──────────────────────────────────────────────────────────────── // Body: { clinica_id, dentista_id, start, end?, paciente_nome, paciente_celular, procedimento? } router.post('/book', async (req, res) => { diff --git a/backend/newwhats/agenda-config.js b/backend/newwhats/agenda-config.js index 8fc0e21..b5ce0cd 100644 --- a/backend/newwhats/agenda-config.js +++ b/backend/newwhats/agenda-config.js @@ -84,7 +84,9 @@ export function createAgendaConfig(pool) { try { const { rows } = await pool.query( `SELECT id, dia_semana, to_char(hora_inicio,'HH24:MI') AS hora_inicio, - to_char(hora_fim,'HH24:MI') AS hora_fim, ativo + to_char(hora_fim,'HH24:MI') AS hora_fim, + to_char(hora_inicio_flex,'HH24:MI') AS hora_inicio_flex, + to_char(hora_fim_flex,'HH24:MI') AS hora_fim_flex, ativo FROM dentistas_horarios WHERE dentista_id = $1 ORDER BY dia_semana, hora_inicio`, [req.params.dentistaId]); res.json({ horarios: rows }); @@ -109,9 +111,13 @@ export function createAgendaConfig(pool) { for (const h of linhas) { const dow = Number(h.dia_semana); const ini = hhmm(h.hora_inicio); const fim = hhmm(h.hora_fim); if (!(dow >= 0 && dow <= 6) || !ini || !fim || ini >= fim) continue; + // Janela estendida (zona cinza): flex_inicio ≤ inicio e flex_fim ≥ fim. + let fIni = hhmm(h.hora_inicio_flex), fFim = hhmm(h.hora_fim_flex); + if (fIni && fIni >= ini) fIni = null; // só vale se for MAIS cedo + if (fFim && fFim <= fim) fFim = null; // só vale se for MAIS tarde await client.query( - `INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, ativo) - VALUES ($1,$2,$3,$4,$5,$6,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim]); + `INSERT INTO dentistas_horarios (id, dentista_id, clinica_id, dia_semana, hora_inicio, hora_fim, hora_inicio_flex, hora_fim_flex, ativo) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,1)`, [uid('dh'), dentistaId, dent.clinica_id, dow, ini, fim, fIni, fFim]); } await client.query('COMMIT'); res.json({ ok: true }); @@ -210,6 +216,72 @@ export function createAgendaConfig(pool) { } catch (e) { res.status(500).json({ error: e.message }); } }); + // ── Pedidos pendentes (zona cinza) — a secretária humana confirma/recusa ────── + async function ehMembro(clinicaId, userId) { + if (await isDono(pool, clinicaId, userId)) return true; + try { const { rows } = await pool.query('SELECT 1 FROM vinculos WHERE clinica_id = $1 AND usuario_id = $2 LIMIT 1', [clinicaId, userId]); return rows.length > 0; } + catch { return false; } + } + + router.get('/clinica/:clinicaId/pedidos', async (req, res) => { + if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' }); + const status = req.query.status ? String(req.query.status) : 'pendente'; + try { + const { rows } = await pool.query( + `SELECT p.id, to_char(p.data,'YYYY-MM-DD') AS data, to_char(p.hora_inicio,'HH24:MI') AS hora_inicio, + to_char(p.hora_fim,'HH24:MI') AS hora_fim, p.paciente_nome, p.paciente_telefone, p.procedimento, + p.dentista_id, d.nome AS dentista_nome, p.status, p.created_at + FROM agenda_pedidos p LEFT JOIN dentistas d ON d.id = p.dentista_id + WHERE p.clinica_id = $1 AND p.status = $2 ORDER BY p.created_at DESC LIMIT 100`, + [req.params.clinicaId, status]); + res.json({ pedidos: rows, total: rows.length }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + + // Confirma → cria o agendamento real e marca o pedido como confirmado. + // Body: { dentista_id? (se o pedido não tinha), duracao_min? } + router.post('/clinica/:clinicaId/pedidos/:id/confirmar', async (req, res) => { + const clinicaId = req.params.clinicaId; + if (!(await ehMembro(clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' }); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const { rows } = await client.query('SELECT * FROM agenda_pedidos WHERE id = $1 AND clinica_id = $2 FOR UPDATE', [req.params.id, clinicaId]); + const ped = rows[0]; + if (!ped) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'Pedido não encontrado.' }); } + if (ped.status !== 'pendente') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'Pedido já resolvido.' }); } + const dentistaId = req.body?.dentista_id || ped.dentista_id; + if (!dentistaId) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'Escolha o dentista para confirmar.' }); } + const dur = Math.max(10, Number(req.body?.duracao_min) || 30); + const hi = String(ped.hora_inicio).slice(0, 5); + const start = `${(ped.data instanceof Date ? ped.data.toISOString().slice(0, 10) : String(ped.data).slice(0, 10))} ${hi}:00`; + const endD = new Date(`${start.replace(' ', 'T')}`); endD.setMinutes(endD.getMinutes() + dur); + const end = `${start.slice(0, 10)} ${String(endD.getHours()).padStart(2, '0')}:${String(endD.getMinutes()).padStart(2, '0')}:00`; + const { rows: dd } = await client.query('SELECT setor_id FROM dentistas WHERE id = $1', [dentistaId]); + const agId = `ag_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; + await client.query( + `INSERT INTO agendamentos (id, clinica_id, pacientenome, pacientecelular, dentistaid, procedimento, + start_time, end_time, status, setor_id, sala_id, created_by_nome, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'agendado',$9,$10,'Secretária (confirmado)',NOW(),NOW())`, + [agId, clinicaId, ped.paciente_nome || 'Cliente WhatsApp', ped.paciente_telefone || null, dentistaId, + ped.procedimento || 'Consulta', start, end, dd[0]?.setor_id || null, ped.sala_id || null]); + await client.query(`UPDATE agenda_pedidos SET status='confirmado', agendamento_id=$1, resolved_at=NOW(), resolved_by=$2 WHERE id=$3`, + [agId, req.nwUser.userId, req.params.id]); + await client.query('COMMIT'); + res.json({ ok: true, agendamento_id: agId }); + } catch (e) { try { await client.query('ROLLBACK'); } catch {} res.status(500).json({ error: e.message }); } + finally { client.release(); } + }); + + router.post('/clinica/:clinicaId/pedidos/:id/recusar', async (req, res) => { + if (!(await ehMembro(req.params.clinicaId, req.nwUser.userId))) return res.status(403).json({ error: 'Sem acesso.' }); + try { + await pool.query(`UPDATE agenda_pedidos SET status='recusado', resolved_at=NOW(), resolved_by=$1 WHERE id=$2 AND clinica_id=$3 AND status='pendente'`, + [req.nwUser.userId, req.params.id, req.params.clinicaId]); + res.json({ ok: true }); + } catch (e) { res.status(500).json({ error: e.message }); } + }); + // Lista dentistas da clínica (p/ a UI do dono escolher quem configurar / e o // dentista achar seu registro). Retorna também se o usuário logado é o dentista. router.get('/clinica/:clinicaId/dentistas', async (req, res) => { diff --git a/backend/newwhats/agenda-schema.js b/backend/newwhats/agenda-schema.js index 061b92e..9ebbc7f 100644 --- a/backend/newwhats/agenda-schema.js +++ b/backend/newwhats/agenda-schema.js @@ -49,6 +49,36 @@ export async function ensureAgendaSchema(pool) { // ── B (salas): agendamentos.sala_id — permite agendar num quarto alugado. // Aditivo/nullable: NÃO afeta o fluxo existente (clínica) quando nulo. await pool.query(`ALTER TABLE agendamentos ADD COLUMN IF NOT EXISTS sala_id varchar`); + + // ── Janela ESTENDIDA do dentista (zona cinza): a IA agenda livre em + // [hora_inicio, hora_fim]; entre a estendida e a livre exige confirmação + // humana. Nulo = sem zona cinza (só a janela livre). + await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_inicio_flex time without time zone`); + await pool.query(`ALTER TABLE dentistas_horarios ADD COLUMN IF NOT EXISTS hora_fim_flex time without time zone`); + + // ── agenda_pedidos — pedidos da ZONA CINZA aguardando a secretária humana. + // A IA cria (status 'pendente') e diz "vou confirmar"; a humana confirma + // (vira agendamento) ou recusa. + await pool.query(` + CREATE TABLE IF NOT EXISTS agenda_pedidos ( + id varchar PRIMARY KEY, + clinica_id varchar NOT NULL, + dentista_id varchar, + sala_id varchar, + data date NOT NULL, + hora_inicio time without time zone NOT NULL, + hora_fim time without time zone, + paciente_nome varchar, + paciente_telefone varchar, + procedimento varchar, + origem varchar DEFAULT 'secretaria_ia', + status varchar DEFAULT 'pendente', -- pendente | confirmado | recusado + agendamento_id varchar, -- preenchido ao confirmar + created_at timestamptz DEFAULT now(), + resolved_at timestamptz, + resolved_by varchar + )`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_agenda_pedidos_clinica_status ON agenda_pedidos (clinica_id, status, created_at)`); return true; } catch (e) { // Não derruba o boot do satélite se o banco estiver indisponível no momento. diff --git a/frontend/components/HorariosConfig.tsx b/frontend/components/HorariosConfig.tsx index 198b2cc..f02a3e8 100644 --- a/frontend/components/HorariosConfig.tsx +++ b/frontend/components/HorariosConfig.tsx @@ -2,7 +2,7 @@ // horários dos dentistas. Consome /api/nw/agenda-config (o backend garante o // ownership: horário/feriado da clínica = dono; horário do dentista = ele ou dono). import React, { useState, useEffect, useCallback } from 'react'; -import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User } from 'lucide-react'; +import { X, Clock, CalendarOff, Plus, Trash2, Save, Loader2, User, Copy } from 'lucide-react'; const API = (import.meta as any).env?.VITE_API_URL || '/api'; const BASE = `${API}/nw/agenda-config`; @@ -20,13 +20,18 @@ async function req(path: string, opts: RequestInit = {}) { return data; } -interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string } +interface Faixa { dia_semana: number; hora_inicio: string; hora_fim: string; hora_inicio_flex?: string | null; hora_fim_flex?: string | null } interface Dentista { id: string; nome: string; usuario_id: string | null; eh_voce: boolean } // Editor de grade semanal (7 dias, cada um com 0..n faixas). Reusado p/ clínica e dentista. -const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean }> = ({ faixas, onChange, disabled }) => { +const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void; disabled?: boolean; withFlex?: boolean }> = ({ faixas, onChange, disabled, withFlex }) => { const porDia = (d: number) => faixas.filter((f) => f.dia_semana === d); const setDia = (d: number, novas: Faixa[]) => onChange([...faixas.filter((f) => f.dia_semana !== d), ...novas]); + // Copia as faixas do dia `d` para TODOS os outros dias. + const duplicarParaTodos = (d: number) => { + const base = porDia(d).map((f) => ({ hora_inicio: f.hora_inicio, hora_fim: f.hora_fim })); + onChange(DIAS.flatMap((_, x) => base.map((f) => ({ dia_semana: x, ...f })))); + }; return (
{DIAS.map((nome, d) => { @@ -36,24 +41,45 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
{nome}
{fs.length === 0 && Fechado} - {fs.map((f, i) => ( -
- { const n = [...fs]; n[i] = { ...f, hora_inicio: e.target.value }; setDia(d, n); }} - className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" /> - até - { const n = [...fs]; n[i] = { ...f, hora_fim: e.target.value }; setDia(d, n); }} - className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" /> - {!disabled && } + {fs.map((f, i) => { + const upd = (patch: Partial) => { const n = [...fs]; n[i] = { ...f, ...patch }; setDia(d, n); }; + return ( +
+
+ upd({ hora_inicio: e.target.value })} + className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" /> + até + upd({ hora_fim: e.target.value })} + className="bg-gray-50 border-2 border-transparent focus:border-teal-600 rounded-xl px-2 py-1.5 text-sm font-bold text-gray-800 outline-none" /> + {withFlex && IA agenda livre} + {!disabled && } +
+ {withFlex && ( +
+ Aceito confirmar também: + antes, a partir de + upd({ hora_inicio_flex: e.target.value || null })} + className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" /> + e depois, até + upd({ hora_fim_flex: e.target.value || null })} + className="bg-amber-50 border-2 border-transparent focus:border-amber-500 rounded-lg px-2 py-1 text-xs font-bold text-gray-800 outline-none w-24" /> +
+ )}
- ))} + ); + })} {!disabled && ( )} + {!disabled && fs.length > 0 && ( + + )}
); @@ -112,7 +138,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl useEffect(() => { if (!dentSel) { setDentFaixas([]); setFolgas([]); return; } req(`/dentista/${dentSel}/horarios`).then((h) => - setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim }))) + setDentFaixas((h.horarios || []).map((r: any) => ({ dia_semana: r.dia_semana, hora_inicio: r.hora_inicio, hora_fim: r.hora_fim, hora_inicio_flex: r.hora_inicio_flex, hora_fim_flex: r.hora_fim_flex }))) ).catch((e) => flash(setErro, e.message)); carregarFolgas(dentSel); }, [dentSel, carregarFolgas]); @@ -231,8 +257,8 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl {saving ? : } Salvar
- {dentSel ? :

Nenhum dentista.

} -

Sem horário próprio, o dentista atende sempre que a clínica está aberta. Cada dentista (ou o dono) edita os seus.

+ {dentSel ? :

Nenhum dentista.

} +

A IA agenda LIVRE na janela principal. Na janela "aceito confirmar" (mais cedo/mais tarde), ela não confirma sozinha — cria um pedido para a secretária humana decidir.

{dentSel && (
diff --git a/frontend/components/PedidosPendentes.tsx b/frontend/components/PedidosPendentes.tsx new file mode 100644 index 0000000..1a564a9 --- /dev/null +++ b/frontend/components/PedidosPendentes.tsx @@ -0,0 +1,115 @@ +// Fila da secretária humana: pedidos de horário da ZONA CINZA que a Secretária IA +// registrou ("vou confirmar e retorno"). A humana confirma (vira agendamento) ou +// recusa. Consome /api/nw/agenda-config/clinica/:id/pedidos*. +import React, { useState, useEffect, useCallback } from 'react'; +import { X, CalendarClock, Check, Ban, Loader2, Phone, User } from 'lucide-react'; + +const API = (import.meta as any).env?.VITE_API_URL || '/api'; +const BASE = `${API}/nw/agenda-config`; + +async function req(path: string, opts: RequestInit = {}) { + const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); + const res = await fetch(`${BASE}${path}`, { ...opts, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) } }); + const t = await res.text(); const d = t ? JSON.parse(t) : {}; + if (!res.ok) throw new Error(d.error || `Erro ${res.status}`); + return d; +} + +const dm = (s: string) => s ? s.split('-').reverse().join('/') : s; + +export const PedidosPendentes: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; onChange?: () => void }> = ({ isOpen, onClose, clinicaId, onChange }) => { + const [pedidos, setPedidos] = useState([]); + const [dentistas, setDentistas] = useState([]); + const [sel, setSel] = useState>({}); // pedido_id → dentista_id escolhido + const [busy, setBusy] = useState(null); + const [erro, setErro] = useState(null); + const [loading, setLoading] = useState(false); + + const carregar = useCallback(async () => { + if (!clinicaId) return; + setLoading(true); setErro(null); + try { + const p = await req(`/clinica/${clinicaId}/pedidos?status=pendente`); + setPedidos(p.pedidos || []); + const d = await req(`/clinica/${clinicaId}/dentistas`); + setDentistas(d.dentistas || []); + } catch (e: any) { setErro(e.message); } finally { setLoading(false); } + }, [clinicaId]); + + useEffect(() => { if (isOpen) carregar(); }, [isOpen, carregar]); + + const confirmar = async (p: any) => { + const dentistaId = p.dentista_id || sel[p.id]; + if (!dentistaId) { setErro('Escolha o dentista para confirmar.'); return; } + setBusy(p.id); setErro(null); + try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/confirmar`, { method: 'POST', body: JSON.stringify({ dentista_id: dentistaId }) }); await carregar(); onChange?.(); } + catch (e: any) { setErro(e.message); } finally { setBusy(null); } + }; + const recusar = async (p: any) => { + setBusy(p.id); setErro(null); + try { await req(`/clinica/${clinicaId}/pedidos/${p.id}/recusar`, { method: 'POST' }); await carregar(); onChange?.(); } + catch (e: any) { setErro(e.message); } finally { setBusy(null); } + }; + + if (!isOpen) return null; + return ( +
+
+
+
+
+
+

Pedidos aguardando confirmação

+

Horários fora do padrão que a Secretária IA encaminhou

+
+
+ +
+ + {erro &&
{erro}
} + +
+ {loading ? ( +
+ ) : pedidos.length === 0 ? ( +

Nenhum pedido pendente. 🎉

+ ) : pedidos.map((p) => ( +
+
+
+
+ {dm(p.data)} às {p.hora_inicio}{p.hora_fim ? `–${p.hora_fim}` : ''} +
+
{p.paciente_nome || 'Cliente'} + {p.paciente_telefone && {p.paciente_telefone}}
+ {p.procedimento &&
{p.procedimento}
} +
+
+ {p.dentista_id ? ( + {p.dentista_nome} + ) : ( + + )} +
+ + +
+
+
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/views/AgendaView.tsx b/frontend/views/AgendaView.tsx index 2e855a5..3467ed9 100644 --- a/frontend/views/AgendaView.tsx +++ b/frontend/views/AgendaView.tsx @@ -6,6 +6,7 @@ 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, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx'; import { HorariosConfig } from '../components/HorariosConfig.tsx'; +import { PedidosPendentes } from '../components/PedidosPendentes.tsx'; import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx'; import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx'; import { AgendaPresence } from '../components/AgendaPresence.tsx'; @@ -352,6 +353,25 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar const [isModalOpen, setIsModalOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [showMeusHorarios, setShowMeusHorarios] = useState(false); + const [showPedidos, setShowPedidos] = useState(false); + const [pedidosCount, setPedidosCount] = useState(0); + // Pedidos da zona cinza aguardando a secretária humana — badge + poll a cada 30 min. + useEffect(() => { + const cid = (HybridBackend.getActiveWorkspace() as any)?.id; + const role = HybridBackend.getCurrentRole(); + if (!cid || !['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(role)) return; + let alive = true; + const poll = async () => { + try { + const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); + const r = await fetch(`/api/nw/agenda-config/clinica/${cid}/pedidos?status=pendente`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }); + if (r.ok && alive) { const j = await r.json(); setPedidosCount(j.total || (j.pedidos || []).length || 0); } + } catch { /* silencioso */ } + }; + poll(); + const t = setInterval(poll, 30 * 60 * 1000); // 30 min + return () => { alive = false; clearInterval(t); }; + }, []); const [selectedDateInfo, setSelectedDateInfo] = useState(null); const [selectedAppointment, setSelectedAppointment] = useState(null); const [showDetails, setShowDetails] = useState(false); @@ -653,6 +673,14 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar Importar do Google )} + {['admin', 'donoclinica', 'donoconsultorio', 'funcionario'].includes(currentRole) && ( + + )} {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (