fix(login): render standalone views edge-to-edge e esconde scrollbar do carrossel de onboarding
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed - OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+384
-130
@@ -1,12 +1,15 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
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 } 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, ScoreBadge, FamiliaChips } from '../components/AgendaDetailModal.tsx';
|
||||
import { AgendaPresence } from '../components/AgendaPresence.tsx';
|
||||
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
||||
import { HybridBackend } from '../services/backend.ts';
|
||||
import { Realtime } from '../services/realtime.ts';
|
||||
import { Dentista, Agendamento, Paciente, DentistaHorario } from '../types.ts';
|
||||
import { useGTOStore } from './LancarGTO.tsx';
|
||||
import { useHybridBackend } from '../hooks/useHybridBackend.ts';
|
||||
@@ -33,7 +36,8 @@ const AppointmentModal: React.FC<{
|
||||
dentists: Dentista[] | null;
|
||||
initialDate?: string;
|
||||
initialAppointment?: Agendamento | null;
|
||||
}> = ({ isOpen, onClose, onSave, dentists, initialDate, initialAppointment }) => {
|
||||
initialPatient?: { id?: string; nome?: string } | null;
|
||||
}> = ({ isOpen, onClose, onSave, dentists, initialDate, initialAppointment, initialPatient }) => {
|
||||
const [selectedPatient, setSelectedPatient] = useState<Paciente | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [duration, setDuration] = useState(15);
|
||||
@@ -42,6 +46,10 @@ const AppointmentModal: React.FC<{
|
||||
const [selectedDentistId, setSelectedDentistId] = useState<string | undefined>(dentists?.[0]?.id);
|
||||
const [procedimento, setProcedimento] = useState('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
||||
// Fase 4 — conflito de horário → painel de interesse
|
||||
const [conflito, setConflito] = useState<any | null>(null);
|
||||
const [tempoEspera, setTempoEspera] = useState(60);
|
||||
const [registrando, setRegistrando] = useState(false);
|
||||
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 400);
|
||||
|
||||
@@ -58,6 +66,7 @@ const AppointmentModal: React.FC<{
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setConflito(null);
|
||||
if (initialAppointment) {
|
||||
setSelectedPatient({ nome: initialAppointment.pacienteNome, cpf: '', id: 'temp' } as any);
|
||||
const startDate = new Date(initialAppointment.start);
|
||||
@@ -68,7 +77,12 @@ const AppointmentModal: React.FC<{
|
||||
setSelectedDentistId(initialAppointment.dentistaId);
|
||||
setProcedimento(initialAppointment.procedimento);
|
||||
} else {
|
||||
setSelectedPatient(null);
|
||||
// Reagendar (a partir da lista "a reagendar"): pré-preenche o paciente.
|
||||
if (initialPatient?.nome) {
|
||||
setSelectedPatient({ nome: initialPatient.nome, cpf: '', id: initialPatient.id || 'temp' } as any);
|
||||
} else {
|
||||
setSelectedPatient(null);
|
||||
}
|
||||
setSearchTerm('');
|
||||
setSelectedDate(initialDate ? initialDate.split('T')[0] : new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime(initialDate ? new Date(initialDate).toTimeString().substring(0, 5) : '');
|
||||
@@ -79,7 +93,7 @@ const AppointmentModal: React.FC<{
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, initialDate, dentists, initialAppointment]);
|
||||
}, [isOpen, initialDate, dentists, initialAppointment, initialPatient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -152,16 +166,17 @@ const AppointmentModal: React.FC<{
|
||||
}
|
||||
const startDateTime = new Date(`${selectedDate}T${selectedTime}:00`);
|
||||
const endDateTime = new Date(startDateTime.getTime() + duration * 60000);
|
||||
const agData: any = {
|
||||
pacienteNome: selectedPatient.nome,
|
||||
dentistaId: selectedDentistId,
|
||||
start: startDateTime.toISOString(),
|
||||
end: endDateTime.toISOString(),
|
||||
status: initialAppointment?.status || 'agendado',
|
||||
procedimento: procedimento.toUpperCase(),
|
||||
};
|
||||
// Vincula o paciente real (id != 'temp') → habilita faltas, família e auto-baixa de pendência.
|
||||
if (selectedPatient.id && selectedPatient.id !== 'temp') agData.pacienteId = selectedPatient.id;
|
||||
try {
|
||||
const agData = {
|
||||
pacienteNome: selectedPatient.nome,
|
||||
dentistaId: selectedDentistId,
|
||||
start: startDateTime.toISOString(),
|
||||
end: endDateTime.toISOString(),
|
||||
status: initialAppointment?.status || 'Pendente',
|
||||
procedimento: procedimento.toUpperCase(),
|
||||
};
|
||||
|
||||
if (initialAppointment?.id) {
|
||||
await HybridBackend.atualizarAgendamento(initialAppointment.id, agData);
|
||||
toast.success(`AGENDAMENTO DE ${selectedPatient.nome} ATUALIZADO!`);
|
||||
@@ -171,15 +186,67 @@ const AppointmentModal: React.FC<{
|
||||
}
|
||||
refreshAgendamentos();
|
||||
onSave();
|
||||
} catch {
|
||||
toast.error("ERRO AO SALVAR AGENDAMENTO.");
|
||||
} catch (err: any) {
|
||||
if (err?.conflito) {
|
||||
setConflito({ ocupado: err.ocupado, agData }); // abre painel de interesse
|
||||
} else {
|
||||
toast.error(err?.message || "ERRO AO SALVAR AGENDAMENTO.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrarInteresse = async () => {
|
||||
if (!conflito) return;
|
||||
setRegistrando(true);
|
||||
try {
|
||||
const currentUserId = (() => { try { return JSON.parse(localStorage.getItem('SCOREODONTO_USER_DATA') || '{}').id; } catch { return undefined; } })();
|
||||
await HybridBackend.registrarInteresse({
|
||||
dentistaId: conflito.agData.dentistaId,
|
||||
clinicaInteressadaId: activeWorkspace?.id,
|
||||
solicitanteUsuarioId: currentUserId,
|
||||
start: conflito.agData.start,
|
||||
end: conflito.agData.end,
|
||||
tempoEsperaMin: tempoEspera || undefined,
|
||||
});
|
||||
toast.success("INTERESSE REGISTRADO! VOCÊ SERÁ AVISADO SE O HORÁRIO LIBERAR.");
|
||||
setConflito(null);
|
||||
onSave();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "ERRO AO REGISTRAR INTERESSE.");
|
||||
} finally { setRegistrando(false); }
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm p-0">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-0">
|
||||
{conflito && (
|
||||
<div className="absolute inset-0 z-[60] bg-black/40 flex items-center justify-center p-4" onClick={() => setConflito(null)}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle size={20} className="text-orange-500" />
|
||||
<h3 className="text-base font-black uppercase text-gray-900">Horário ocupado</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
O profissional já tem compromisso nesse horário (em qualquer clínica). Você pode <strong>registrar interesse</strong> e ser avisado se o horário liberar, ou escolher outro horário.
|
||||
</p>
|
||||
<label className="block text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Aguardar liberar por (minutos)</label>
|
||||
<input type="number" min={0} value={tempoEspera} onChange={e => setTempoEspera(parseInt(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2.5 bg-gray-50 border border-gray-200 rounded-xl text-sm font-bold outline-none focus:border-blue-400" />
|
||||
<p className="text-[10px] text-gray-400 mt-1 mb-4">0 = sem expiração (aguarda até liberar).</p>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => setConflito(null)}
|
||||
className="flex-1 py-3 rounded-xl bg-gray-100 text-gray-600 font-black uppercase text-xs hover:bg-gray-200 transition-all">
|
||||
Escolher outro horário
|
||||
</button>
|
||||
<button type="button" onClick={handleRegistrarInteresse} disabled={registrando}
|
||||
className="flex-[1.4] py-3 rounded-xl bg-blue-600 text-white font-black uppercase text-xs hover:bg-blue-700 transition-all flex items-center justify-center gap-2 disabled:opacity-60">
|
||||
{registrando ? <Loader2 size={14} className="animate-spin" /> : <Bell size={14} />} Registrar interesse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-xl flex flex-col overflow-hidden animate-in fade-in zoom-in-50 duration-200">
|
||||
<div className="px-5 py-4 border-b border-gray-200 flex justify-between items-center flex-shrink-0 bg-gradient-to-r from-orange-50 to-white">
|
||||
<h2 className="text-lg font-bold text-gray-800 flex items-center gap-2 uppercase">
|
||||
@@ -192,20 +259,32 @@ const AppointmentModal: React.FC<{
|
||||
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider">PACIENTE</label>
|
||||
{!selectedPatient ? (
|
||||
<div className="relative mt-2">
|
||||
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value.toUpperCase())} onFocus={() => setIsSearchFocused(true)} onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} placeholder="DIGITE O NOME OU CPF..." className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base" />
|
||||
<input type="text" value={searchTerm} onChange={e => setSearchTerm(e.target.value.toUpperCase())} onFocus={() => setIsSearchFocused(true)} onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)} placeholder="NOME, CPF OU TELEFONE..." className="w-full lg:w-1/2 bg-gray-100 p-3 lg:p-4 rounded-lg font-bold text-gray-800 uppercase focus:border-blue-500 outline-none border-2 border-transparent focus:border-2 text-sm lg:text-base" />
|
||||
{isSearchFocused && debouncedSearchTerm && (
|
||||
<ul className="absolute z-10 w-full lg:w-1/2 bg-white border border-gray-300 rounded-lg mt-1 max-h-48 overflow-y-auto shadow-lg">
|
||||
{isSearching && <li className="p-3 text-sm text-gray-500">BUSCANDO...</li>}
|
||||
{searchResults?.map(p => (
|
||||
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">{p.nome} ({p.cpf})</li>
|
||||
<li key={p.id} onMouseDown={() => handlePatientSelect(p)} className="p-3 hover:bg-gray-100 cursor-pointer uppercase font-semibold text-sm">
|
||||
{p.nome}
|
||||
{p.cpf && <span className="text-gray-400 font-normal ml-1">· CPF {p.cpf}</span>}
|
||||
{p.telefone && <span className="text-gray-400 font-normal ml-1">· TEL {p.telefone}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full lg:w-1/2 bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg mt-2 flex justify-between items-center">
|
||||
<span className="font-bold text-green-800 uppercase text-sm lg:text-base">{selectedPatient.nome}</span>
|
||||
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
||||
<div className="w-full lg:w-1/2 mt-2 space-y-2">
|
||||
<div className="bg-green-50 border border-green-200 p-3 lg:p-4 rounded-lg flex justify-between items-center gap-2">
|
||||
<span className="font-bold text-green-800 uppercase text-sm lg:text-base truncate">{selectedPatient.nome}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{selectedPatient.id && selectedPatient.id !== 'temp' ? <ScoreBadge pacienteId={selectedPatient.id} /> : null}
|
||||
<button type="button" onClick={() => setSelectedPatient(null)} className="text-xs font-bold bg-blue-100 text-blue-700 hover:bg-blue-200 px-3 py-1.5 rounded-md transition-colors">ALTERAR</button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedPatient.id && selectedPatient.id !== 'temp' ? (
|
||||
<FamiliaChips pacienteId={selectedPatient.id} onPick={(m) => setSelectedPatient({ nome: m.nome, cpf: '', id: m.id } as any)} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -274,8 +353,91 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
const [selectedDateInfo, setSelectedDateInfo] = useState<any>(null);
|
||||
const [selectedAppointment, setSelectedAppointment] = useState<Agendamento | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [interesses, setInteresses] = useState<any[]>([]);
|
||||
const [showInteresses, setShowInteresses] = useState(false);
|
||||
const [pendencias, setPendencias] = useState<any[]>([]);
|
||||
const [showPendencias, setShowPendencias] = useState(false);
|
||||
const [reagendarPatient, setReagendarPatient] = useState<{ id?: string; nome?: string } | null>(null);
|
||||
const [atividade, setAtividade] = useState<any[]>([]);
|
||||
const [showAtividade, setShowAtividade] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Lista "a reagendar" (faltas/cancelamentos/remarcações pendentes desta clínica)
|
||||
const loadPendencias = useCallback(async () => {
|
||||
try { setPendencias(await HybridBackend.getPendenciasReagendar()); } catch { /* ignore */ }
|
||||
}, []);
|
||||
useEffect(() => { loadPendencias(); }, [loadPendencias]);
|
||||
const loadAtividade = useCallback(async () => {
|
||||
try { setAtividade(await HybridBackend.getAtividadeAgenda()); } catch { /* ignore */ }
|
||||
}, []);
|
||||
const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' && window.innerWidth < 640);
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 640);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
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; } })();
|
||||
const souDentista = HybridBackend.getCurrentRole() === 'dentista';
|
||||
const loadInteresses = useCallback(async () => {
|
||||
if (!souDentista || !meuUsuarioId) return;
|
||||
try { setInteresses(await HybridBackend.getInteresses({ profissionalId: meuUsuarioId })); } catch { /* ignore */ }
|
||||
}, [souDentista, meuUsuarioId]);
|
||||
useEffect(() => { loadInteresses(); }, [loadInteresses]);
|
||||
const interessesAguardando = interesses.filter(i => i.status === 'aguardando').length;
|
||||
|
||||
// Atualização quase em tempo real: refaz o fetch a cada 20s e ao voltar o foco
|
||||
// à aba/janela, para que agendamentos criados por outra pessoa apareçam sem reload.
|
||||
const liveRef = useRef({ refresh, refreshGoogle, loadPendencias, loadInteresses });
|
||||
liveRef.current = { refresh, refreshGoogle, loadPendencias, loadInteresses };
|
||||
|
||||
// Tempo real (WebSocket): ao receber aviso de mudança, refaz o fetch na hora.
|
||||
// Coalesce vários avisos próximos (ex.: import em lote) num único refetch.
|
||||
const rtDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const unsub = Realtime.subscribe((msg) => {
|
||||
if (msg?.type !== 'agenda') return;
|
||||
if (rtDebounce.current) clearTimeout(rtDebounce.current);
|
||||
rtDebounce.current = setTimeout(() => {
|
||||
liveRef.current.refresh();
|
||||
liveRef.current.loadPendencias();
|
||||
liveRef.current.loadInteresses();
|
||||
}, 350);
|
||||
});
|
||||
return () => { unsub(); if (rtDebounce.current) clearTimeout(rtDebounce.current); };
|
||||
}, []);
|
||||
|
||||
// Fallback (caso o WebSocket caia): refetch leve a cada 60s e ao focar a aba.
|
||||
const tickCount = useRef(0);
|
||||
useEffect(() => {
|
||||
const tick = (force?: boolean) => {
|
||||
if (document.visibilityState === 'hidden') return;
|
||||
liveRef.current.refresh();
|
||||
liveRef.current.loadPendencias();
|
||||
liveRef.current.loadInteresses();
|
||||
tickCount.current += 1;
|
||||
if (force || tickCount.current % 2 === 0) liveRef.current.refreshGoogle();
|
||||
};
|
||||
const iv = setInterval(() => tick(), 60000);
|
||||
const onFocus = () => tick(true);
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onFocus);
|
||||
return () => { clearInterval(iv); window.removeEventListener('focus', onFocus); document.removeEventListener('visibilitychange', onFocus); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (agendamentos && dentists) {
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
@@ -290,31 +452,66 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
|
||||
const mappedEvents = filteredAgendamentos.map(ag => {
|
||||
const dentist = dentists.find(d => d.id === ag.dentistaId);
|
||||
const st = String(ag.status || '').toLowerCase();
|
||||
const faltou = st === 'falta' || st === 'faltou';
|
||||
const inativo = st === 'cancelado' || st === 'remarcar';
|
||||
// Prioridade: status (falta/cancelado) > cor própria do agendamento (ex.: importada do Google) > cor do dentista.
|
||||
const cor = faltou ? '#ef4444' : inativo ? '#cbd5e1' : ((ag as any).cor || (dentist ? dentist.corAgenda : '#94a3b8'));
|
||||
return {
|
||||
id: ag.id,
|
||||
title: `${ag.pacienteNome} - ${ag.procedimento}`,
|
||||
start: ag.start,
|
||||
end: ag.end,
|
||||
backgroundColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'),
|
||||
borderColor: ag.status === 'Faltou' ? '#ef4444' : (dentist ? dentist.corAgenda : '#94a3b8'),
|
||||
backgroundColor: cor,
|
||||
borderColor: cor,
|
||||
extendedProps: { ...ag, isGoogle: false, dentistaNome: dentist?.nome }
|
||||
};
|
||||
});
|
||||
|
||||
// Add Google events if any
|
||||
// Add Google events if any — com cores: usa a cor real (colorId/agenda) e,
|
||||
// quando não houver, escolhe uma cor NÃO usada na agenda (consistente por origem).
|
||||
if (googleEvents && googleEvents.length > 0) {
|
||||
let filteredGoogleEvents = googleEvents;
|
||||
if (isDentist) {
|
||||
filteredGoogleEvents = googleEvents.filter(ev => ev.extendedProps.owner_id === currentUserEmail);
|
||||
filteredGoogleEvents = googleEvents.filter(ev => ev?.extendedProps?.owner_id === currentUserEmail);
|
||||
}
|
||||
mappedEvents.push(...filteredGoogleEvents);
|
||||
const PALETTE = ['#2563eb', '#7c3aed', '#db2777', '#059669', '#d97706', '#0891b2', '#dc2626', '#4f46e5', '#0d9488', '#ca8a04', '#9333ea', '#e11d48', '#16a34a', '#f97316'];
|
||||
const usadas = new Set<string>(mappedEvents.map(e => e.backgroundColor).filter(Boolean));
|
||||
const corPorOrigem: Record<string, string> = {};
|
||||
const proximaCor = () => PALETTE.find(c => !usadas.has(c)) || PALETTE[Object.keys(corPorOrigem).length % PALETTE.length];
|
||||
const coloridos = filteredGoogleEvents.map(ev => {
|
||||
let cor = ev.backgroundColor || null;
|
||||
if (cor) { usadas.add(cor); }
|
||||
else {
|
||||
const origem = ev.owner || 'google';
|
||||
if (!corPorOrigem[origem]) { const c = proximaCor(); corPorOrigem[origem] = c; usadas.add(c); }
|
||||
cor = corPorOrigem[origem];
|
||||
}
|
||||
return { ...ev, backgroundColor: cor, borderColor: cor };
|
||||
});
|
||||
mappedEvents.push(...coloridos);
|
||||
}
|
||||
|
||||
setEvents(mappedEvents);
|
||||
}
|
||||
}, [agendamentos, dentists, googleEvents]);
|
||||
|
||||
const handleDateClick = (arg: any) => { setSelectedDateInfo(arg); setIsModalOpen(true); };
|
||||
const handleDateClick = (arg: any) => { if ((HybridBackend.getActiveWorkspace() as any)?.role === 'dentista') return; setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo(arg); setIsModalOpen(true); };
|
||||
// Selecionar (clicar/arrastar) um intervalo livre também abre o modal de novo agendamento.
|
||||
const handleDateSelect = (info: any) => { if ((HybridBackend.getActiveWorkspace() as any)?.role === 'dentista') return; setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: info.startStr }); setIsModalOpen(true); };
|
||||
|
||||
const handleReagendarPendencia = (p: any) => {
|
||||
setShowPendencias(false);
|
||||
setSelectedAppointment(null);
|
||||
setReagendarPatient({ id: p.paciente_id || undefined, nome: p.paciente_nome });
|
||||
setSelectedDateInfo({ dateStr: new Date().toISOString() });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
const handleDispensarPendencia = async (p: any) => {
|
||||
if (!confirm(`Dispensar "${p.paciente_nome}" da lista de reagendamento?`)) return;
|
||||
try { await HybridBackend.resolverPendencia(p.id, 'dispensado'); toast.success('PENDÊNCIA DISPENSADA.'); loadPendencias(); }
|
||||
catch { toast.error('ERRO AO DISPENSAR.'); }
|
||||
};
|
||||
const handleEventClick = (info: any) => {
|
||||
if (info.event.extendedProps.isGoogle) {
|
||||
toast.info(`EVENTO DO GOOGLE CALENDAR: ${info.event.title}`);
|
||||
@@ -349,7 +546,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAppointment = () => { refresh(); refreshGoogle(); setIsModalOpen(false); setSelectedAppointment(null); };
|
||||
const handleSaveAppointment = () => { refresh(); refreshGoogle(); loadPendencias(); setIsModalOpen(false); setSelectedAppointment(null); setReagendarPatient(null); };
|
||||
|
||||
const onDragEnd = async (result: any) => {
|
||||
if (!result.destination || !dentists) return;
|
||||
@@ -402,7 +599,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="AGENDA CLÍNICA">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="flex gap-2 sm:gap-3 items-center flex-wrap justify-end">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="dentists-legend" direction="horizontal">
|
||||
{(provided) => (
|
||||
@@ -428,32 +625,71 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<AgendaPresence />
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setShowAtividade(true); loadAtividade(); }} title="Atividade da equipe hoje"
|
||||
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">
|
||||
<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} />
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente') && (
|
||||
<button onClick={() => { setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-blue-700 shadow-sm transition-colors">
|
||||
<Plus size={16} /> NOVO AGENDAMENTO
|
||||
{souDentista && (
|
||||
<button onClick={() => { setShowInteresses(true); loadInteresses(); }} title="Pedidos de interesse na sua agenda"
|
||||
className="relative 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">
|
||||
<Bell size={20} />
|
||||
{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>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setShowPendencias(true); loadPendencias(); }} title="Pacientes a reagendar"
|
||||
className="relative p-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} />
|
||||
{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>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(currentRole !== 'paciente' && !souDentista) && (
|
||||
<button onClick={() => { setReagendarPatient(null); setSelectedAppointment(null); setSelectedDateInfo({ dateStr: new Date().toISOString() }); setIsModalOpen(true); }} title="Novo agendamento" className="bg-blue-600 text-white px-3 sm:px-4 py-2 rounded-lg text-sm font-bold uppercase flex items-center gap-2 hover:bg-blue-700 shadow-sm transition-colors">
|
||||
<Plus size={18} /> <span className="hidden sm:inline">NOVO AGENDAMENTO</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 relative" style={{ height: '750px' }}>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-2 sm:p-4 relative h-[calc(100dvh-220px)] sm:h-[750px] min-h-[420px]">
|
||||
<FullCalendar
|
||||
key={isMobile ? 'mobile' : 'desktop'}
|
||||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
|
||||
initialView="timeGridWeek"
|
||||
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
|
||||
initialView={isMobile ? 'timeGridDay' : 'timeGridWeek'}
|
||||
headerToolbar={isMobile
|
||||
? { left: 'prev,next', center: 'title', right: 'today' }
|
||||
: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
|
||||
footerToolbar={isMobile ? { center: 'timeGridDay,dayGridMonth' } : undefined}
|
||||
locale="pt-br"
|
||||
buttonText={{ today: 'HOJE', month: 'MÊS', week: 'SEMANA', day: 'DIA' }}
|
||||
slotMinTime="08:00:00"
|
||||
slotMaxTime="19:00:00"
|
||||
allDaySlot={false}
|
||||
events={events}
|
||||
selectable={true}
|
||||
selectMirror={true}
|
||||
select={handleDateSelect}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
nowIndicator={true}
|
||||
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||
eventContent={(eventInfo) => {
|
||||
const isGoogle = eventInfo.event.extendedProps.isGoogle;
|
||||
@@ -474,104 +710,80 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Details "Popover" (Modal style for real estate) */}
|
||||
{showDetails && selectedAppointment && (
|
||||
<div className="fixed inset-0 bg-black/40 z-[70] flex items-center justify-center backdrop-blur-sm p-0 animate-in fade-in duration-200">
|
||||
<div className="bg-white shadow-2xl w-full h-dvh md:w-[96vw] md:h-[98vh] rounded-none md:rounded-[2rem] flex flex-col overflow-hidden border border-gray-100">
|
||||
{/* Header with quick actions */}
|
||||
<div className="px-8 py-6 border-b border-gray-50 flex justify-between items-center bg-gray-50/30">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-blue-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
||||
title="EDITAR"
|
||||
>
|
||||
<Edit size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteAppointment(selectedAppointment.id)}
|
||||
className="p-2.5 bg-white text-gray-400 hover:text-red-600 rounded-xl border border-gray-100 hover:shadow-md transition-all group"
|
||||
title="EXCLUIR"
|
||||
>
|
||||
<Trash2 size={18} className="group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setShowDetails(false)} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gray-400">
|
||||
<X size={24} />
|
||||
</button>
|
||||
{/* Detalhes do agendamento — modal em pilha (autoria, faltas, família, histórico, ações) */}
|
||||
<AgendaDetailModal
|
||||
key={selectedAppointment?.id}
|
||||
isOpen={showDetails && !!selectedAppointment}
|
||||
onClose={() => { setShowDetails(false); setSelectedAppointment(null); }}
|
||||
appointment={selectedAppointment}
|
||||
dentists={dentists}
|
||||
onChanged={() => { refresh(); refreshGoogle(); loadPendencias(); }}
|
||||
onEditar={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
onAtender={handleAtender}
|
||||
podeAtender={!!onNavigate && currentRole !== 'paciente'}
|
||||
dentistaRestrito={souDentista}
|
||||
/>
|
||||
|
||||
{/* Lista "A REAGENDAR" — pendências de falta/cancelamento/remarcação */}
|
||||
{showPendencias && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowPendencias(false)}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-base font-black uppercase text-gray-900 flex items-center gap-2">
|
||||
<CalendarClock size={18} className="text-amber-600" /> A reagendar
|
||||
<span className="text-[10px] font-bold text-gray-400">({pendencias.length})</span>
|
||||
</h3>
|
||||
<button onClick={() => setShowPendencias(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"><X size={18} /></button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-10 space-y-8">
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-4 bg-blue-50 rounded-2xl">
|
||||
<User size={28} className="text-blue-600" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-xl font-black text-gray-900 leading-none uppercase tracking-tighter">{selectedAppointment.pacienteNome}</h3>
|
||||
<div className="flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-2 bg-gray-100 px-3 py-1 rounded-full w-fit">
|
||||
<Stethoscope size={12} className="text-blue-400" /> {selectedAppointment.procedimento}
|
||||
<div className="p-4 overflow-y-auto custom-scrollbar space-y-2">
|
||||
{pendencias.length === 0 ? (
|
||||
<p className="text-center text-xs text-gray-400 font-bold uppercase py-10">NENHUMA PENDÊNCIA. TUDO EM DIA!</p>
|
||||
) : pendencias.map(p => {
|
||||
const ML: Record<string, string> = { falta: 'FALTOU', cancelado: 'CANCELOU', remarcar: 'PEDIU REMARCAÇÃO' };
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-black text-sm text-gray-800 truncate uppercase">{p.paciente_nome || 'PACIENTE'}</p>
|
||||
<p className="text-[10px] text-gray-400 font-bold uppercase">{ML[p.motivo] || p.motivo} · {new Date(p.created_at).toLocaleDateString('pt-BR')}</p>
|
||||
</div>
|
||||
<button onClick={() => handleReagendarPendencia(p)} className="px-3 py-2 bg-blue-600 text-white rounded-lg text-[10px] font-black uppercase hover:bg-blue-700 transition-colors">Reagendar</button>
|
||||
<button onClick={() => handleDispensarPendencia(p)} title="Dispensar" className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"><X size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 py-6 border-y border-gray-50">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Data e Hora</label>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<CalendarRange size={16} className="text-blue-500" />
|
||||
{new Date(selectedAppointment.start).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<Clock size={16} className="text-blue-500" />
|
||||
{new Date(selectedAppointment.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-gray-700">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-white shadow-sm" style={{ backgroundColor: dentists?.find(d => d.id === selectedAppointment.dentistaId)?.corAgenda }}></div>
|
||||
{(selectedAppointment as any).dentistaNome}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Action Buttons */}
|
||||
<div className="space-y-4">
|
||||
<label className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Status do Atendimento</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Faltou')}
|
||||
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Faltou' ? 'bg-red-50 text-red-600 border-red-200' : 'bg-white border-gray-100 text-gray-500 hover:border-red-200 hover:text-red-500'}`}
|
||||
>
|
||||
<AlertCircle size={16} /> Marcar Falta
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(selectedAppointment.id, 'Confirmado')}
|
||||
className={`flex items-center justify-center gap-2 py-4 rounded-2xl text-xs font-black uppercase tracking-widest transition-all border-2 ${selectedAppointment.status === 'Confirmado' ? 'bg-emerald-50 text-emerald-600 border-emerald-200' : 'bg-white border-gray-100 text-gray-500 hover:border-emerald-200 hover:text-emerald-500'}`}
|
||||
>
|
||||
<CheckCircle2 size={16} /> Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: Ações */}
|
||||
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex gap-4">
|
||||
{onNavigate && currentRole !== 'paciente' && (
|
||||
<button
|
||||
onClick={handleAtender}
|
||||
className="flex-1 py-4 bg-emerald-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-emerald-700 hover:shadow-xl hover:shadow-emerald-200 transition-all shadow-lg shadow-emerald-100"
|
||||
>
|
||||
ATENDER PACIENTE
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowDetails(false); setIsModalOpen(true); }}
|
||||
className="flex-1 py-4 bg-blue-600 text-white rounded-2xl text-xs font-black uppercase tracking-widest hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-200 transition-all shadow-lg shadow-blue-100"
|
||||
>
|
||||
Reagendar Consulta
|
||||
</button>
|
||||
{/* Feed de ATIVIDADE da equipe (hoje) */}
|
||||
{showAtividade && (
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center backdrop-blur-sm p-4" onClick={() => setShowAtividade(false)}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-base font-black uppercase text-gray-900 flex items-center gap-2">
|
||||
<History size={18} className="text-blue-600" /> Atividade de hoje
|
||||
</h3>
|
||||
<button onClick={() => setShowAtividade(false)} className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto custom-scrollbar space-y-2">
|
||||
{atividade.length === 0 ? (
|
||||
<p className="text-center text-xs text-gray-400 font-bold uppercase py-10">NENHUMA ATIVIDADE HOJE.</p>
|
||||
) : atividade.map((a, i) => {
|
||||
const AC: Record<string, string> = { criou: 'agendou', reagendou: 'reagendou', editou: 'editou', cancelou: 'cancelou', faltou: 'marcou falta de', remarcou: 'pediu remarcação de', restaurou: 'restaurou' };
|
||||
const pac = a.pacientenome || a.detalhes?.paciente || '';
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-3 p-2.5 hover:bg-gray-50 rounded-xl">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-2 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-bold text-gray-700">
|
||||
<span className="text-blue-600">{a.actor_nome || 'Alguém'}</span> {AC[a.acao] || a.acao}{pac ? <span className="text-gray-800 uppercase"> {pac}</span> : null}
|
||||
</p>
|
||||
<p className="text-[10px] text-gray-400 font-medium">{new Date(a.at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -585,6 +797,17 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||
/* FullCalendar — responsividade mobile */
|
||||
@media (max-width: 639px) {
|
||||
.fc .fc-toolbar.fc-header-toolbar { margin-bottom: .5rem; flex-wrap: wrap; gap: .4rem; }
|
||||
.fc .fc-toolbar-title { font-size: .95rem; }
|
||||
.fc .fc-button { padding: .25rem .55rem; font-size: .72rem; }
|
||||
.fc .fc-toolbar-chunk { display: flex; align-items: center; }
|
||||
.fc .fc-col-header-cell-cushion { font-size: .65rem; padding: 2px; }
|
||||
.fc .fc-timegrid-slot-label-cushion, .fc .fc-timegrid-axis-cushion { font-size: .6rem; }
|
||||
.fc .fc-timegrid-event .fc-event-main { padding: 1px 2px; }
|
||||
.fc-footer-toolbar.fc-toolbar { margin-top: .5rem; }
|
||||
}
|
||||
`}</style>
|
||||
<AppointmentModal
|
||||
isOpen={isModalOpen}
|
||||
@@ -593,8 +816,39 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void }> = ({
|
||||
dentists={dentists}
|
||||
initialDate={selectedDateInfo?.dateStr}
|
||||
initialAppointment={selectedAppointment}
|
||||
initialPatient={reagendarPatient}
|
||||
/>
|
||||
<AgendaSettingsModal isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} dentists={dentists} />
|
||||
|
||||
{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="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-base font-black uppercase text-gray-900 flex items-center gap-2">
|
||||
<Bell size={18} className="text-blue-600" /> Pedidos de interesse
|
||||
</h3>
|
||||
<button onClick={() => setShowInteresses(false)} className="p-1 rounded-lg hover:bg-gray-100"><X size={18} className="text-gray-400" /></button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto space-y-2">
|
||||
{interesses.length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-10">Nenhum pedido de interesse na sua agenda.</p>
|
||||
)}
|
||||
{interesses.map(i => (
|
||||
<div key={i.id} className="border border-gray-100 rounded-xl p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-bold text-gray-800">
|
||||
{new Date(i.start_time).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded-full ${i.status === 'aguardando' ? 'bg-yellow-50 text-yellow-700' : i.status === 'liberado' ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>{i.status}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-1">Interessada: <span className="font-bold">{i.clinica_nome || i.clinica_interessada_id}</span></p>
|
||||
{i.expira_em && <p className="text-[10px] text-gray-400">Aguarda até: {new Date(i.expira_em).toLocaleString('pt-BR')}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user