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
+1
View File
@@ -35,6 +35,7 @@ services:
- "./backend/.env" - "./backend/.env"
environment: environment:
- BAILEYS_ENGINE=infinite - BAILEYS_ENGINE=infinite
- TZ=America/Sao_Paulo
- PORT=8018 - PORT=8018
- NODE_ENV=production - NODE_ENV=production
# Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD). # Senha do banco vem do ambiente (.env raiz em DEV; provisionado fora do git em PROD).
+9 -6
View File
@@ -62,8 +62,9 @@ const GradeSemanal: React.FC<{ faixas: Faixa[]; onChange: (f: Faixa[]) => void;
); );
}; };
export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string }> = ({ isOpen, onClose, clinicaId }) => { export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; clinicaId?: string; initialTab?: 'clinica' | 'dentistas'; soDentista?: boolean }> = ({ isOpen, onClose, clinicaId, initialTab, soDentista }) => {
const [aba, setAba] = useState<'clinica' | 'dentistas'>('clinica'); const [aba, setAba] = useState<'clinica' | 'dentistas'>(initialTab || 'clinica');
useEffect(() => { if (isOpen && initialTab) setAba(initialTab); }, [isOpen, initialTab]);
const [erro, setErro] = useState<string | null>(null); const [erro, setErro] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null); const [ok, setOk] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -96,11 +97,13 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
if (!clinicaId) return; if (!clinicaId) return;
try { try {
const d = await req(`/clinica/${clinicaId}/dentistas`); const d = await req(`/clinica/${clinicaId}/dentistas`);
setDentistas(d.dentistas || []); let list: Dentista[] = d.dentistas || [];
const first = (d.dentistas || []).find((x: Dentista) => x.eh_voce) || (d.dentistas || [])[0]; if (soDentista) { const meus = list.filter((x) => x.eh_voce); if (meus.length) list = meus; } // dentista só vê o próprio
setDentistas(list);
const first = list.find((x: Dentista) => x.eh_voce) || list[0];
if (first) setDentSel(first.id); if (first) setDentSel(first.id);
} catch (e: any) { flash(setErro, e.message); } } catch (e: any) { flash(setErro, e.message); }
}, [clinicaId]); }, [clinicaId, soDentista]);
useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]); useEffect(() => { if (isOpen) { carregarClinica(); carregarDentistas(); } }, [isOpen, carregarClinica, carregarDentistas]);
const carregarFolgas = useCallback((id: string) => { const carregarFolgas = useCallback((id: string) => {
@@ -162,7 +165,7 @@ export const HorariosConfig: React.FC<{ isOpen: boolean; onClose: () => void; cl
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 px-8 pt-4 border-b border-gray-50"> <div className="flex gap-1 px-8 pt-4 border-b border-gray-50">
{(['clinica', 'dentistas'] as const).map((t) => ( {(soDentista ? (['dentistas'] as const) : (['clinica', 'dentistas'] as const)).map((t) => (
<button key={t} onClick={() => setAba(t)} <button key={t} onClick={() => setAba(t)}
className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}> className={`px-4 py-2.5 text-xs font-black uppercase tracking-wide rounded-t-xl transition-colors ${aba === t ? 'bg-teal-600 text-white' : 'text-gray-400 hover:text-gray-600'}`}>
{t === 'clinica' ? 'Clínica' : 'Dentistas'} {t === 'clinica' ? 'Clínica' : 'Dentistas'}
+61 -34
View File
@@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; 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 { 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 { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { HorariosConfig } from '../components/HorariosConfig.tsx';
import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx'; import { AgendaDetailModal, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx'; import { WhatsChatDrawer } from './newwhats/WhatsChatDrawer.tsx';
import { AgendaPresence } from '../components/AgendaPresence.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: agendamentos, refresh } = useHybridBackend<Agendamento[]>(HybridBackend.getAgendamentos);
const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas); const { data: dentists, refresh: refreshDentists } = useHybridBackend<Dentista[]>(HybridBackend.getDentistas);
const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents); const { data: googleEvents, refresh: refreshGoogle } = useHybridBackend<any[]>(HybridBackend.getGoogleEvents);
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<any[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [showMeusHorarios, setShowMeusHorarios] = useState(false);
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null); const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null); const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
const [showDetails, setShowDetails] = useState(false); const [showDetails, setShowDetails] = useState(false);
@@ -600,10 +602,25 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
return ( return (
<div className="flex flex-col lg:flex-row lg:gap-4 h-full"> <div className="flex flex-col lg:flex-row lg:gap-4 h-full">
{/* Coluna de controles (desktop: lateral esquerda vertical; mobile: topo compacto) */} {/* Backdrop da gaveta de controles (mobile) — fecha ao tocar fora. Como a sidebar. */}
<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"> {controlsOpen && (
{/* Título + recolher menu (desktop, ao lado) + AGENDAR (mobile, à direita) */} <div className="fixed inset-0 bg-black/50 z-20 lg:hidden backdrop-blur-sm transition-opacity" onClick={onCloseControls} />
<div className="flex items-center gap-2 mb-3"> )}
{/* 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 && ( {onToggleSidebar && (
<button onClick={onToggleSidebar} title={sidebarCollapsed ? 'Mostrar menu lateral' : 'Recolher menu lateral'} <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"> 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> </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> <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>
<div className="flex gap-2 sm:gap-3 items-center flex-wrap lg:flex-col lg:items-stretch"> <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) && ( {(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Agendar" <button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); onCloseControls?.(); }} 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"> 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 <Plus size={18} /> AGENDAR
</button> </button>
)} )}
{/* ícones de ação — mesmo tamanho, distribuídos horizontalmente */} {/* Ações — mobile: empilhadas, ícone + nome, largura cheia; desktop: fileira de ícones */}
<div className="flex gap-2 w-full"> <div className="flex flex-col lg:flex-row gap-2 w-full">
{(currentRole !== 'paciente') && ( {(currentRole !== 'paciente') && (
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje" <button onClick={() => { setShowAtividade(true); loadAtividade(); onCloseControls?.(); }} 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"> 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} /> <History size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Atividade da equipe</span>
</button> </button>
)} )}
{(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && ( {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={handleImportarGoogle} disabled={importando} title="Importar agendamentos do Google para o sistema" <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"> 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" /> : <Download size={20} />} {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> </button>
)} )}
{(currentRole === 'admin' || currentRole === 'donoclinica') && ( {(currentRole === 'admin' || currentRole === 'donoclinica' || currentRole === 'donoconsultorio') && (
<button onClick={() => setIsSettingsOpen(true)} title="Configurações da agenda" <button onClick={() => { setIsSettingsOpen(true); onCloseControls?.(); }} 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"> 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} /> <GearIcon size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Configurações</span>
</button> </button>
)} )}
{souDentista && ( {souDentista && (
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda" <button onClick={() => { setShowMeusHorarios(true); onCloseControls?.(); }} title="Configurar meus dias e horários de atendimento"
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"> 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">
<Bell size={20} /> <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 && ( {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> </button>
)} )}
{(currentRole !== 'paciente' && !souDentista) && ( {(currentRole !== 'paciente' && !souDentista) && (
<button onClick={() => { setShowPendencias(true); loadPendencias(); }} title="Pacientes a reagendar" <button onClick={() => { setShowPendencias(true); loadPendencias(); onCloseControls?.(); }} 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"> 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} /> <CalendarClock size={20} className="shrink-0" />
<span className="lg:hidden text-sm font-bold">Pacientes a reagendar</span>
{pendencias.length > 0 && ( {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> </button>
)} )}
@@ -747,7 +770,10 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }} onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
onAtender={handleAtender} onAtender={handleAtender}
onVerWhats={(info) => setWhatsTarget(info)} 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} dentistaRestrito={souDentista}
/> />
@@ -861,6 +887,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
initialPatient={reagendarPatient} initialPatient={reagendarPatient}
/> />
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} /> <AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
<HorariosConfig isOpen={showMeusHorarios} onClose={() => setShowMeusHorarios(false)} clinicaId={(HybridBackend.getActiveWorkspace() as any)?.id} initialTab="dentistas" soDentista />
{showInteresses && ( {showInteresses && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowInteresses(false)}>