fix(agenda): fuso automático (America/Sao_Paulo) + dentista configura seus horários

- TZ=America/Sao_Paulo no backend (compose): a agenda deixa de rodar em UTC —
  horários/slots passam a ser Brasília automaticamente (corrige deslocamento de 3h).
- Acesso do DENTISTA: novo botão "Meus Horários" na agenda do dentista, abrindo
  a config direto nos horários/folgas DELE (HorariosConfig com initialTab/soDentista,
  restrito ao próprio registro). Antes só admin/donoclinica alcançavam a config.
- Config da clínica: gate passa a incluir donoconsultorio (dono de consultório).

Obs.: AgendaView.tsx também traz trabalho acumulado da agenda que estava no
working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-06 04:44:38 +02:00
parent 0a324bf8c8
commit 773e8d4bf2
3 changed files with 71 additions and 40 deletions
+61 -34
View File
@@ -5,6 +5,7 @@ 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, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { HorariosConfig } from '../components/HorariosConfig.tsx';
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
import { AgendaPresence } from '../components/AgendaPresence.tsx';
@@ -343,13 +344,14 @@ const AppointmentModal: React.FC<{
);
};
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar }) => {
export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; controlsOpen?: boolean; onCloseControls?: () => void }> = ({ onNavigate, sidebarCollapsed, onToggleSidebar, controlsOpen = false, onCloseControls }) => {
const { data: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
const [events, setEvents] = useState<any[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
const [showDetails, setShowDetails] = useState(false);
@@ -600,10 +602,25 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
return (
<div className="flex flex-col lg:flex-row lg:gap-4 h-full">
{/* Coluna de controles (desktop: lateral esquerda vertical; mobile: topo compacto) */}
<aside className="shrink-0 mb-3 lg:mb-0 lg:w-56 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100">
{/* Título + recolher menu (desktop, ao lado) + AGENDAR (mobile, à direita) */}
<div className="flex items-center gap-2 mb-3">
{/* Backdrop da gaveta de controles (mobile) — fecha ao tocar fora. Como a sidebar. */}
{controlsOpen && (
<div className="fixed inset-0 bg-black/50 z-20 lg:hidden backdrop-blur-sm transition-opacity" onClick={onCloseControls} />
)}
{/* Coluna de controles — desktop: lateral esquerda fixa; mobile: gaveta deslizante
(oculta por padrão p/ dar o máximo de espaço ao calendário; abre pelo ⋯ do header). */}
<aside className={`
fixed inset-y-0 right-0 z-30 w-72 max-w-[85%] bg-white shadow-2xl overflow-y-auto p-4 transform transition-transform duration-300
${controlsOpen ? 'translate-x-0' : 'translate-x-full'}
lg:static lg:inset-auto lg:z-auto lg:w-56 lg:max-w-none lg:translate-x-0 lg:transform-none lg:transition-none lg:shadow-none lg:bg-transparent lg:p-0 lg:overflow-y-auto lg:pr-3 lg:border-r lg:border-gray-100
shrink-0 lg:mb-0`}>
{/* Fechar a gaveta (só mobile) */}
<button onClick={onCloseControls} title="Fechar"
className="lg:hidden absolute top-3 right-3 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors z-10">
<X size={20} />
</button>
{/* Título + recolher menu (desktop, ao lado). No mobile o AGENDAR e as
ações ficam empilhados abaixo (dentro da gaveta). */}
<div className="flex items-center gap-2 mb-3 pr-8 lg:pr-0">
{onToggleSidebar && (
<button onClick={onToggleSidebar} title={sidebarCollapsed ? 'Mostrar menu lateral' : 'Recolher menu lateral'}
className="hidden lg:flex items-center justify-center w-8 h-8 shrink-0 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors">
@@ -611,56 +628,62 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
</button>
)}
<h2 className="text-xl lg:text-base font-bold text-gray-900 uppercase flex-1 min-w-0 leading-tight break-words">AGENDA CLÍNICA</h2>
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar"
className="lg:hidden shrink-0 bg-teal-600 text-white px-3 py-2 rounded-lg text-xs font-bold uppercase flex items-center gap-1.5 hover:bg-teal-700 shadow-sm">
<Plus size={16} /> AGENDAR
</button>
)}
</div>
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch">
{/* AGENDAR (desktop, full-width no topo da coluna) */}
{/* AGENDAR (full-width no topo da coluna/gaveta — mobile e desktop) */}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar"
className="hidden lg:flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); onCloseControls?.(); }} title="Agendar"
className="flex w-full bg-teal-600 text-white px-4 py-2.5 rounded-lg text-sm font-bold uppercase items-center justify-center gap-2 hover:bg-teal-700 shadow-sm transition-colors">
<Plus size={18} /> AGENDAR
</button>
)}
{/* ícones de ação — mesmo tamanho, distribuídos horizontalmente */}
<div className="flex gap-2 w-full">
{/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
<div className="flex flex-col lg:flex-row gap-2 w-full">
{(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
className="flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} />
<button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} title="Atividade da equipe hoje"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<History size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Atividade da equipe</span>
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema"
className="flex-1 flex items-center justify-center py-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} />}
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg: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 shrink-0" /> : <Download size={20} className="shrink-0" />}
<span className="lg:hidden text-sm font-bold">Importar do Google</span>
</button>
)}
{(currentRole === 'admin' || currentRole === 'donoclinica') && (
<button onClick={() => setIsSettingsOpen(true)} title="Configurações da agenda"
className="flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} />
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} title="Configurações da agenda"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<GearIcon size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Configurações</span>
</button>
)}
{souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda"
className="relative flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} />
<button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
className="w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Clock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Meus Horários</span>
</button>
)}
{souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); onCloseControls?.(); }} title="Pedidos de interesse na sua agenda"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-teal-600 border border-gray-200 rounded-xl transition-all hover:border-teal-200 hover:shadow-sm">
<Bell size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Interesses na agenda</span>
{interessesAguardando > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-red-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{interessesAguardando}</span>
)}
</button>
)}
{(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setShowPendencias(true); loadPendencias(); }} title="Pacientes a reagendar"
className="relative flex-1 flex items-center justify-center py-2.5 bg-white text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} />
<button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} title="Pacientes a reagendar"
className="relative w-full lg:flex-1 flex items-center justify-start lg:justify-center gap-3 lg:gap-0 px-4 lg:px-0 py-2.5 bg-white text-gray-500 lg:text-gray-400 hover:text-amber-600 border border-gray-200 rounded-xl transition-all hover:border-amber-200 hover:shadow-sm">
<CalendarClock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Pacientes a reagendar</span>
{pendencias.length > 0 && (
<span className="absolute -top-1 -right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
<span className="absolute top-1.5 left-6 lg:left-auto lg:-top-1 lg:-right-1 bg-amber-500 text-white text-[9px] font-black rounded-full w-4 h-4 flex items-center justify-center">{pendencias.length}</span>
)}
</button>
)}
@@ -747,7 +770,10 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
onAtender={handleAtender}
onVerWhats={(info) => setWhatsTarget(info)}
podeAtender={!!onNavigate && currentRole !== 'paciente'}
// "Atender" (abre Lançar GTO) é ação CLÍNICA — atende quem trata: dentista,
// biomédico, dono da clínica/consultório, dentista locatário, admin. A recepção
// (funcionario) NÃO atende; ela só remarca/desmarca/agenda.
podeAtender={!!onNavigate && currentRole !== 'paciente' && currentRole !== 'funcionario'}
dentistaRestrito={souDentista}
/>
@@ -861,6 +887,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
initialPatient={reagendarPatient}
/>
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
{showInteresses && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>