chore(ops): migrate clube67 to newwhats.clube67.com directory
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-18 03:26:41 +02:00
parent 298b1c64b0
commit 5ec6bd6354
576 changed files with 29016 additions and 235673 deletions
@@ -0,0 +1,363 @@
import React, { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Save, Mail, Calendar, Settings as GearIcon, CheckCircle2 } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { Settings, Dentista } from '../types.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { GoogleConnectButton } from './GoogleConnectButton.tsx';
import { Link as LinkIcon, Copy, Share2 } from 'lucide-react';
interface AgendaSettingsModalProps {
isOpen: boolean;
onClose: () => void;
dentists: Dentista[] | null;
}
export const AgendaSettingsModal: React.FC<AgendaSettingsModalProps> = ({ isOpen, onClose, dentists }) => {
const [page, setPage] = useState(1);
const [config, setConfig] = useState<Settings>({
clinicEmail: '',
clinicCalendarId: '',
googleApiKey: '',
googleCalendarIds: {}
});
const [isSaving, setIsSaving] = useState(false);
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const toast = useToast();
const fetchGoogleStatus = async () => {
try {
const response = await fetch('http://localhost:3005/api/auth/google/status');
const data = await response.json();
setConnectedAccounts(data);
} catch (error) {
console.error("Erro ao buscar status do Google:", error);
}
};
useEffect(() => {
if (isOpen) {
const loadSettings = async () => {
try {
const settings = await HybridBackend.getSettings();
setConfig({
clinicEmail: settings.clinicEmail || '',
clinicCalendarId: settings.clinicCalendarId || '',
googleApiKey: settings.googleApiKey || '',
googleCalendarIds: settings.googleCalendarIds || {}
});
} catch (error) {
console.error("Erro ao carregar configurações:", error);
}
};
loadSettings();
fetchGoogleStatus();
setPage(1); // Reset to first page
}
}, [isOpen]);
const copyInviteLink = (dentistId: string) => {
const url = `http://localhost:3005/api/auth/google/url?dentistId=${dentistId}`;
navigator.clipboard.writeText(url);
toast.success("LINK DE CONVITE COPIADO!");
};
const handleSave = async () => {
setIsSaving(true);
try {
await HybridBackend.saveSettings(config);
toast.success("CONFIGURAÇÕES SALVAS COM SUCESSO!");
onClose();
} catch (error) {
toast.error("ERRO AO SALVAR CONFIGURAÇÕES.");
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
const totalPages = 3;
return (
<div className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center backdrop-blur-md p-4">
<div className="bg-white rounded-[2.5rem] shadow-2xl w-full max-w-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-300 flex flex-col h-[550px] border border-gray-100">
{/* Header */}
<div className="px-10 py-8 border-b border-gray-50 flex justify-between items-center bg-gradient-to-br from-gray-50 to-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-blue-600 rounded-2xl shadow-lg shadow-blue-200">
<GearIcon size={24} className="text-white animate-spin-slow" />
</div>
<div>
<h2 className="text-xl font-black text-gray-900 uppercase tracking-tighter">Configurações da Agenda</h2>
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mt-0.5">Integrações e Comunicação</p>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-xl transition-colors text-gray-400">
<X size={24} />
</button>
</div>
{/* Progress Bar */}
<div className="h-1.5 w-full bg-gray-100 flex">
<div
className="h-full bg-blue-600 transition-all duration-500 ease-out shadow-[0_0_10px_rgba(37,99,235,0.5)]"
style={{ width: `${(page / totalPages) * 100}%` }}
></div>
</div>
{/* Content Area - Horizontal Slider Simulation */}
<div className="flex-1 relative overflow-hidden bg-white">
<div
className="absolute inset-0 flex transition-transform duration-500 ease-in-out transform-gpu"
style={{ transform: `translateX(-${(page - 1) * 100}%)` }}
>
{/* Page 1: Clinic Emails */}
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700">
<div className="flex items-center gap-3 mb-8">
<Mail className="text-blue-600" size={24} />
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">E-mails da Clínica</h3>
</div>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-gray-400 uppercase tracking-[0.2em] ml-1">E-mail para Notificações</label>
<input
type="email"
placeholder="EX: CONTATO@CLINICA.COM"
className="w-full bg-gray-50 border-2 border-transparent focus:border-blue-600 focus:bg-white rounded-2xl p-4 font-bold text-gray-800 outline-none transition-all shadow-sm"
value={config.clinicEmail}
onChange={(e) => setConfig({ ...config, clinicEmail: e.target.value.toUpperCase() })}
/>
<p className="text-[10px] text-gray-400 italic ml-1">* Este e-mail será usado para alertas de sistema e envio de comprovantes.</p>
</div>
</div>
</div>
{/* Page 2: Google Setup Tutorial & Central Config */}
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700 overflow-y-auto custom-scrollbar">
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-3">
<Calendar className="text-emerald-600" size={24} />
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">Configuração Google Agenda</h3>
</div>
<a href="https://console.cloud.google.com/apis/library/calendar-json.googleapis.com" target="_blank" rel="noreferrer" className="text-[9px] font-black text-white bg-black px-3 py-1.5 rounded-lg hover:opacity-80 transition-all uppercase tracking-widest">
1. Ativar API
</a>
</div>
<div className="grid grid-cols-2 gap-6 mb-8">
<div className="space-y-4">
<div className="bg-emerald-50 rounded-2xl p-4 border border-emerald-100">
<div className="flex items-center gap-2 mb-2">
<div className="w-5 h-5 bg-emerald-600 text-white text-[10px] font-black flex items-center justify-center rounded-full">1</div>
<span className="text-[10px] font-black text-emerald-800 uppercase">Google API Key</span>
</div>
<input
type="password"
placeholder="COLE A CHAVE AQUI"
className="w-full bg-white border border-emerald-200 rounded-xl px-4 py-2 text-xs font-bold text-gray-700 outline-none focus:ring-2 focus:ring-emerald-100"
value={config.googleApiKey}
onChange={(e) => setConfig({ ...config, googleApiKey: e.target.value })}
/>
<a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noreferrer" className="text-[8px] font-black text-emerald-600 hover:underline mt-2 inline-block uppercase">Criar Credenciais </a>
</div>
</div>
<div className="space-y-4">
<div className="bg-blue-50 rounded-2xl p-4 border border-blue-100">
<div className="flex items-center gap-2 mb-2">
<div className="w-5 h-5 bg-blue-600 text-white text-[10px] font-black flex items-center justify-center rounded-full">2</div>
<span className="text-[10px] font-black text-blue-800 uppercase">ID Agenda Clínica</span>
</div>
<input
type="text"
placeholder="ID DA AGENDA"
className="w-full bg-white border border-blue-200 rounded-xl px-4 py-2 text-xs font-bold text-gray-700 outline-none focus:ring-2 focus:ring-blue-100"
value={config.clinicCalendarId}
onChange={(e) => setConfig({ ...config, clinicCalendarId: e.target.value })}
/>
<a href="https://calendar.google.com/calendar/r/settings" target="_blank" rel="noreferrer" className="text-[8px] font-black text-blue-600 hover:underline mt-2 inline-block uppercase">Pegar ID nas Configs </a>
</div>
</div>
</div>
<div className="bg-gray-900 rounded-3xl p-6 text-white">
<h4 className="text-[10px] font-black text-blue-400 uppercase tracking-[0.2em] mb-4">Guia de Integração</h4>
<ul className="space-y-3">
<li className="flex gap-3 text-xs">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
<span className="text-gray-300">Crie um projeto no <strong>Google Cloud Console</strong>.</span>
</li>
<li className="flex gap-3 text-xs">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
<span className="text-gray-300">No menu 'Biblioteca', ative a <strong>Google Calendar API</strong>.</span>
</li>
<li className="flex gap-3 text-xs">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
<div className="flex flex-col gap-1">
<span className="text-gray-300">Pegue o <strong>ID da Agenda</strong> nas configurações (Integrar agenda).</span>
<a href="https://calendar.google.com/calendar/r/settings" target="_blank" rel="noreferrer" className="text-[9px] font-black text-blue-400 hover:underline flex items-center gap-1 uppercase">
Abrir Configurações de Agenda
</a>
</div>
</li>
<li className="flex gap-3 text-xs">
<CheckCircle2 size={16} className="text-emerald-500 shrink-0" />
<span className="text-gray-300">No Google Agenda, torne a agenda <strong>Pública</strong> nas autorizações de acesso.</span>
</li>
</ul>
</div>
</div>
{/* Page 3: Google Calendar Mapping */}
<div className="w-full shrink-0 p-10 animate-in fade-in duration-700 overflow-hidden flex flex-col">
<div className="flex justify-between items-center mb-8">
<div className="flex items-center gap-3">
<Calendar className="text-emerald-600" size={24} />
<h3 className="text-sm font-black text-gray-800 uppercase tracking-tight">Agendas dos Dentistas</h3>
</div>
<a href="https://calendar.google.com/calendar/u/0/r/settings" target="_blank" rel="noreferrer" className="text-[9px] font-black text-white bg-gray-900 px-3 py-1.5 rounded-lg hover:bg-black transition-colors uppercase tracking-widest">
Abrir Google Calendar
</a>
</div>
<div className="space-y-4 max-h-[250px] overflow-y-auto pr-4 custom-scrollbar">
<div className="bg-blue-50/50 p-4 rounded-2xl border border-blue-100 mb-2">
<p className="text-[10px] text-blue-800 font-bold mb-1 uppercase tracking-wider">Nova Integração Inteligente (OAuth2)</p>
<p className="text-[10px] text-blue-600 italic">Agora você pode conectar agendas sem precisar torná-las públicas ou usar API Keys.</p>
</div>
{/* ADMIN / CLINIC CONNECTION */}
<div className="space-y-3 mb-6">
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Sua Conta (Administrador)</h4>
<GoogleConnectButton
ownerId="admin"
isConnected={connectedAccounts.some(a => a.owner_id === 'admin' || a.owner_id.includes('@'))}
onStatusChange={fetchGoogleStatus}
/>
</div>
<div className="space-y-4">
<h4 className="text-[10px] font-black text-gray-400 uppercase tracking-widest ml-1">Contas dos Dentistas</h4>
{dentists?.length ? dentists.map((dentist) => {
const isConnected = connectedAccounts.some(a => a.owner_id === dentist.id);
return (
<div key={dentist.id} className="bg-gray-50 rounded-2xl p-4 border border-transparent hover:border-gray-200 transition-all">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: dentist.corAgenda }}></div>
<span className="text-xs font-black text-gray-800 uppercase">{dentist.nome}</span>
</div>
{isConnected ? (
<span className="text-[9px] font-black text-emerald-600 bg-emerald-100 px-2 py-1 rounded-lg uppercase">Conectado</span>
) : (
<span className="text-[9px] font-black text-gray-400 uppercase">Não Vinculado</span>
)}
</div>
<div className="flex gap-2">
<div className="flex-1">
<GoogleConnectButton
ownerId={dentist.id}
isConnected={isConnected}
onStatusChange={fetchGoogleStatus}
/>
</div>
{!isConnected && (
<button
onClick={() => copyInviteLink(dentist.id)}
className="p-4 bg-gray-100 text-gray-600 rounded-2xl hover:bg-gray-200 transition-all flex items-center justify-center group"
title="Gerar Link para o Dentista"
>
<Share2 size={18} className="group-hover:scale-110 transition-transform" />
</button>
)}
</div>
{/* Legacy ID support remains for backward compatibility */}
<div className="mt-3 pt-3 border-t border-gray-200/50">
<label className="text-[9px] font-bold text-gray-400 uppercase ml-1">ID Alternativo (API Key/Legado)</label>
<input
type="text"
placeholder="ID DA AGENDA GOOGLE (SE NÃO USAR OAUTH2)"
className="w-full bg-white/50 border border-gray-200 rounded-xl px-4 py-2 text-[10px] font-bold text-gray-500 outline-none focus:ring-1 focus:ring-blue-100 mt-1"
value={config.googleCalendarIds?.[dentist.id] || ''}
onChange={(e) => setConfig({
...config,
googleCalendarIds: {
...config.googleCalendarIds,
[dentist.id]: e.target.value
}
})}
/>
</div>
</div>
);
}) : (
<p className="text-center text-xs text-gray-400 py-10 uppercase font-bold">Nenhum dentista cadastrado.</p>
)}
</div>
</div>
</div>
</div>
</div>
{/* Footer Navigation */}
<div className="px-10 py-8 bg-gray-50/50 border-t border-gray-100 flex justify-between items-center">
<div className="flex gap-2">
{page > 1 && (
<button
onClick={() => setPage(page - 1)}
className="flex items-center gap-2 px-6 py-3 text-xs font-black text-gray-500 hover:text-gray-900 bg-white rounded-2xl border border-gray-200 hover:shadow-md transition-all uppercase tracking-widest"
>
<ChevronLeft size={16} /> Anterior
</button>
)}
</div>
<div className="flex gap-4">
{page < totalPages ? (
<button
onClick={() => setPage(page + 1)}
className="flex items-center gap-2 px-8 py-4 bg-blue-600 text-white rounded-[1.5rem] 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"
>
Próximo <ChevronRight size={16} />
</button>
) : (
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 px-10 py-4 bg-emerald-600 text-white rounded-[1.5rem] 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 disabled:bg-gray-400"
>
{isSaving ? 'Salvando...' : <><Save size={16} /> Salvar Tudo</>}
</button>
)}
</div>
</div>
</div>
<style>{`
.animate-spin-slow {
animation: spin 8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
`}</style>
</div>
);
};
@@ -0,0 +1,96 @@
import React, { ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw, XCircle } from 'lucide-react';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
// FIX: Switched to constructor-based state initialization to resolve issues
// where the TypeScript toolchain might not correctly interpret public class fields,
// causing `this.props` and `this.setState` to appear unavailable. This is a more
// widely compatible approach.
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
this.setState({ errorInfo });
}
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-red-50 flex items-center justify-center p-6 font-sans">
<div className="max-w-3xl w-full bg-white rounded-2xl shadow-2xl overflow-hidden border border-red-100">
<div className="bg-red-600 p-6 flex items-center gap-4">
<div className="bg-white/20 p-3 rounded-full text-white">
<XCircle size={32} />
</div>
<div>
<h1 className="text-2xl font-bold text-white uppercase">ERRO CRÍTICO NO SISTEMA</h1>
<p className="text-red-100 text-xs font-bold uppercase mt-1">O APLICATIVO ENCONTROU UM PROBLEMA INESPERADO</p>
</div>
</div>
<div className="p-8">
<div className="mb-6">
<p className="text-gray-700 font-bold uppercase mb-2">O QUE ACONTECEU?</p>
<p className="text-gray-600 text-sm">
Ocorreu uma falha durante a renderização da interface. Tente recarregar a página.
Se o problema persistir, envie o log abaixo para o suporte técnico.
</p>
</div>
{this.state.error && (
<div className="bg-gray-900 rounded-lg p-4 mb-6 overflow-x-auto border-l-4 border-red-500">
<div className="flex items-center gap-2 text-red-400 font-bold text-xs uppercase mb-2">
<AlertTriangle size={14} /> Stack Trace / Log Técnico
</div>
<pre className="text-green-400 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words">
{this.state.error.toString()}
<br />
{this.state.errorInfo?.componentStack}
</pre>
</div>
)}
<div className="flex gap-4">
<button
onClick={this.handleReload}
className="bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-bold uppercase flex items-center gap-2 transition-colors shadow-lg"
>
<RefreshCw size={18} /> RECARREGAR SISTEMA
</button>
<button
onClick={() => { localStorage.clear(); window.location.reload(); }}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-6 py-3 rounded-lg font-bold uppercase flex items-center gap-2 transition-colors"
>
LIMPAR CACHE E REINICIAR
</button>
</div>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import { Calendar, Link, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
interface GoogleConnectButtonProps {
ownerId: string; // Ex: 'admin' ou o ID do dentista
isConnected: boolean;
onStatusChange?: () => void;
}
export const GoogleConnectButton: React.FC<GoogleConnectButtonProps> = ({ ownerId, isConnected, onStatusChange }) => {
const [isLoading, setIsLoading] = useState(false);
const handleConnect = async () => {
setIsLoading(true);
try {
const response = await fetch(`http://localhost:3005/api/auth/google/url?dentistId=${ownerId}`);
const { url } = await response.json();
// Abrir em popup para manter o contexto
const width = 600;
const height = 700;
const left = window.screen.width / 2 - width / 2;
const top = window.screen.height / 2 - height / 2;
const popup = window.open(
url,
'google-auth',
`width=${width},height=${height},left=${left},top=${top}`
);
// Verificar se o popup fechou a cada 1s
const checkPopup = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(checkPopup);
setIsLoading(false);
if (onStatusChange) onStatusChange();
}
}, 1000);
} catch (error) {
console.error('Erro ao conectar Google Agenda:', error);
setIsLoading(false);
}
};
const handleDisconnect = async () => {
if (!confirm('Deseja realmente desconectar esta agenda?')) return;
setIsLoading(true);
try {
await fetch(`http://localhost:3005/api/auth/google/${ownerId}`, { method: 'DELETE' });
if (onStatusChange) onStatusChange();
} catch (error) {
console.error('Erro ao desconectar:', error);
} finally {
setIsLoading(false);
}
};
if (isConnected) {
return (
<div className="flex items-center gap-3 bg-emerald-50 border border-emerald-100 p-4 rounded-2xl">
<div className="p-2 bg-emerald-500 rounded-xl">
<CheckCircle2 size={18} className="text-white" />
</div>
<div className="flex-1">
<p className="text-[10px] font-black text-emerald-800 uppercase tracking-tight">Google Agenda Conectada</p>
<p className="text-[9px] text-emerald-600 font-bold uppercase tracking-widest mt-0.5">Sincronização Ativa</p>
</div>
<button
onClick={handleDisconnect}
disabled={isLoading}
className="text-[9px] font-black text-red-500 hover:text-red-700 uppercase tracking-widest px-3 py-1 bg-white border border-red-100 rounded-lg shadow-sm transition-all"
>
{isLoading ? <Loader2 size={12} className="animate-spin" /> : 'Desconectar'}
</button>
</div>
);
}
return (
<button
onClick={handleConnect}
disabled={isLoading}
className="w-full group relative overflow-hidden flex items-center gap-4 bg-white border-2 border-gray-100 p-4 rounded-2xl hover:border-blue-500 hover:shadow-xl hover:shadow-blue-50/50 transition-all duration-300"
>
<div className="p-3 bg-gray-50 group-hover:bg-blue-600 rounded-xl transition-colors duration-300">
<Calendar size={20} className="text-gray-400 group-hover:text-white transition-colors duration-300" />
</div>
<div className="text-left">
<p className="text-xs font-black text-gray-800 uppercase tracking-tight group-hover:text-blue-600">Conectar Google Agenda</p>
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Sincronize horários externos</p>
</div>
{isLoading && (
<div className="absolute right-4">
<Loader2 size={18} className="text-blue-600 animate-spin" />
</div>
)}
</button>
);
};
@@ -0,0 +1,80 @@
import React from 'react';
import { Building2, ChevronDown, User, LogOut } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
export const Header: React.FC = () => {
const workspaces = HybridBackend.getWorkspaces();
const activeWorkspace = HybridBackend.getActiveWorkspace();
const userName = HybridBackend.getCurrentUserName();
const userEmail = HybridBackend.getCurrentUser();
const handleWorkspaceChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
HybridBackend.switchWorkspace(e.target.value);
};
const handleLogout = () => {
if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
HybridBackend.logout();
}
};
return (
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-4 md:px-8 sticky top-0 z-20 shadow-sm">
{/* Clinic Selector */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="hidden sm:flex w-10 h-10 bg-blue-50 rounded-xl items-center justify-center text-blue-600 shadow-inner">
<Building2 size={20} />
</div>
<div className="relative w-full max-w-[200px] md:max-w-[300px]">
<select
value={activeWorkspace?.id || ''}
onChange={handleWorkspaceChange}
className="w-full bg-gray-50 border border-gray-200 text-gray-800 text-xs font-bold uppercase py-2 pl-3 pr-8 rounded-lg outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all appearance-none cursor-pointer truncate"
>
{workspaces.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.nome}
</option>
))}
</select>
<div className="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<ChevronDown size={14} />
</div>
</div>
</div>
{/* User Actions */}
<div className="flex items-center gap-2 md:gap-4 ml-4">
<div className="hidden md:flex flex-col items-end mr-2">
<span className="text-[10px] font-bold text-gray-400 uppercase leading-none mb-1">Unidade Ativa</span>
<span className="text-xs font-bold text-blue-600 uppercase border border-blue-100 bg-blue-50 px-2 py-0.5 rounded shadow-sm">
{activeWorkspace?.role || 'Acesso'}
</span>
</div>
<div className="h-10 w-px bg-gray-100 hidden sm:block mx-1"></div>
<div className="flex items-center gap-3">
<div className="text-right hidden sm:block">
<p className="text-xs font-bold text-gray-800 truncate max-w-[120px]" title={userName}>
{userName}
</p>
<p className="text-[9px] text-gray-400 font-bold truncate max-w-[120px]" title={userEmail}>
{userEmail}
</p>
</div>
<div className="w-9 h-9 bg-gray-100 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer border border-white shadow-sm">
<User size={18} />
</div>
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-all"
title="SAIR"
>
<LogOut size={18} />
</button>
</div>
</div>
</header>
);
};
@@ -0,0 +1,165 @@
import React, { useState, useEffect } from 'react';
import { Bell, X, Calendar, DollarSign, AlertCircle, UserCheck, MessageSquare, ShieldAlert } from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { Notificacao } from '../types.ts';
export const NotificationCenter: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [notificacoes, setNotificacoes] = useState<Notificacao[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const fetch = async () => {
try {
const data = await HybridBackend.getNotifications();
setNotificacoes(data);
setUnreadCount(data.filter(n => !n.lida).length);
} catch (err) {
console.error("Erro ao carregar notificações");
}
};
useEffect(() => {
fetch();
const interval = setInterval(fetch, 30000); // Polling cada 30s
return () => clearInterval(interval);
}, []);
const toggleOpen = () => setIsOpen(!isOpen);
const markAsRead = async (id: string) => {
await HybridBackend.markNotificationRead(id);
setNotificacoes(prev => prev.map(n => n.id === id ? { ...n, lida: true } : n));
setUnreadCount(prev => Math.max(0, prev - 1));
};
const getIcon = (tipo: string) => {
switch (tipo) {
case 'agenda': return <Calendar size={18} className="text-blue-500" />;
case 'financeiro': return <DollarSign size={18} className="text-emerald-500" />;
case 'lead': return <UserCheck size={18} className="text-amber-500" />;
case 'sistema': return <ShieldAlert size={18} className="text-red-500" />;
default: return <MessageSquare size={18} className="text-gray-500" />;
}
};
const getTypeLabel = (tipo: string) => {
switch (tipo) {
case 'agenda': return 'Agenda';
case 'financeiro': return 'Financeiro';
case 'lead': return 'Novo Lead';
case 'sistema': return 'Sistema';
default: return 'Geral';
}
};
return (
<div className="relative z-[1002]">
<div className="relative">
<button
onClick={toggleOpen}
className="group relative w-11 h-11 flex items-center justify-center rounded-xl bg-white/80 backdrop-blur-md border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.06)] hover:shadow-[0_8px_32px_rgba(59,130,246,0.15)] hover:border-blue-400/50 transition-all duration-300"
>
<Bell size={22} className="text-slate-600 group-hover:text-blue-600 transition-colors" />
{unreadCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 flex h-5 w-5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-5 w-5 bg-red-500 text-[10px] font-bold text-white items-center justify-center shadow-lg">
{unreadCount}
</span>
</span>
)}
</button>
{isOpen && (
<>
<div className="fixed inset-0" onClick={toggleOpen} />
<div className="absolute right-0 mt-4 w-[380px] bg-white/95 backdrop-blur-lg rounded-2xl shadow-[0_20px_50px_rgba(0,0,0,0.15)] border border-white/20 overflow-hidden animate-in fade-in zoom-in-95 slide-in-from-top-4 duration-300 transform origin-top-right">
<div className="px-6 py-5 bg-gradient-to-r from-slate-50 to-white border-b border-slate-100 flex justify-between items-center">
<div>
<h3 className="font-bold text-slate-800 text-lg tracking-tight">Centro de Notificações</h3>
<p className="text-[11px] text-slate-400 font-medium uppercase tracking-wider">Você tem {unreadCount} novas mensagens</p>
</div>
<button
onClick={toggleOpen}
className="p-2 hover:bg-slate-100 rounded-full transition-colors"
>
<X size={18} className="text-slate-400" />
</button>
</div>
<div className="max-h-[450px] overflow-y-auto custom-scrollbar bg-slate-50/30">
{notificacoes.length === 0 ? (
<div className="py-20 flex flex-col items-center justify-center text-center px-8">
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
<Bell size={32} className="text-slate-300" />
</div>
<p className="text-slate-500 font-medium">Tudo limpo por aqui!</p>
<p className="text-xs text-slate-400 mt-1">Não notificações para mostrar no momento.</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{notificacoes.map(notif => (
<div
key={notif.id}
className={`group p-5 hover:bg-white transition-all duration-200 cursor-default ${!notif.lida ? 'bg-blue-50/40 relative' : ''}`}
>
{!notif.lida && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 rounded-r-full" />
)}
<div className="flex gap-4">
<div className={`mt-1 h-10 w-10 flex-shrink-0 flex items-center justify-center rounded-xl ${notif.tipo === 'agenda' ? 'bg-blue-100 text-blue-600' :
notif.tipo === 'financeiro' ? 'bg-emerald-100 text-emerald-600' :
notif.tipo === 'lead' ? 'bg-amber-100 text-amber-600' :
'bg-red-100 text-red-600'
}`}>
{getIcon(notif.tipo)}
</div>
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] font-bold text-slate-400 uppercase tracking-wide">
{getTypeLabel(notif.tipo)}
</span>
<span className="text-[10px] text-slate-400">
{new Date(notif.data).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<h4 className={`text-sm font-bold leading-tight ${!notif.lida ? 'text-slate-900' : 'text-slate-600'}`}>
{notif.titulo}
</h4>
<p className="text-xs text-slate-500 mt-1 line-clamp-2 leading-relaxed">
{notif.mensagem}
</p>
{!notif.lida && (
<button
onClick={() => markAsRead(notif.id)}
className="text-[11px] text-blue-600 font-bold mt-3 opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
>
MARCAR COMO LIDA
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="p-4 bg-white border-t border-slate-100 text-center">
<button
onClick={() => {
window.location.hash = '/notificacoes';
setIsOpen(false);
}}
className="text-xs font-bold text-slate-500 hover:text-blue-600 transition-colors tracking-widest uppercase"
>
Histórico Completo
</button>
</div>
</div>
</>
)}
</div>
</div>
);
};
@@ -0,0 +1,22 @@
import React from 'react';
import { NotificationCenter } from './NotificationCenter.tsx';
interface PageHeaderProps {
title: string;
description?: string;
children?: React.ReactNode;
}
export const PageHeader: React.FC<PageHeaderProps> = ({ title, description, children }) => {
return (
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 uppercase">{title}</h2>
{description && <p className="text-gray-500 text-xs uppercase font-bold mt-1">{description}</p>}
</div>
<div className="flex items-center gap-3">
{children}
</div>
</div>
);
};
@@ -0,0 +1,152 @@
import React from 'react';
import {
Users, Calendar as CalendarIcon, LayoutDashboard, Stethoscope,
DollarSign, RefreshCw, Database, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3
} from 'lucide-react';
import { HybridBackend } from '../services/backend.ts';
import { GoogleConnectButton } from './GoogleConnectButton.tsx';
import { useState, useEffect } from 'react';
interface SidebarProps {
activeTab: string;
setActiveTab: (t: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) => {
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const currentRole = HybridBackend.getCurrentRole();
const userName = HybridBackend.getCurrentUserName();
const userEmail = HybridBackend.getCurrentUser();
const activeWorkspace = HybridBackend.getActiveWorkspace();
const clinColor = activeWorkspace?.cor || '#2563eb';
const fetchGoogleStatus = async () => {
try {
const response = await fetch('http://localhost:3005/api/auth/google/status');
const data = await response.json();
setConnectedAccounts(data);
} catch (error) {
console.error("Erro ao buscar status do Google:", error);
}
};
useEffect(() => {
fetchGoogleStatus();
}, []);
const menuItems = [
{ id: 'dashboard', label: 'VISÃO GERAL', icon: LayoutDashboard },
{ id: 'clinicas', label: 'MINHAS CLÍNICAS', icon: Building2 },
{ id: 'leads', label: 'GESTÃO DE LEADS', icon: Megaphone },
{ id: 'pacientes', label: 'PACIENTES', icon: Users },
{ id: 'tratamentos', label: 'MEUS TRATAMENTOS', icon: ClipboardList },
{ id: 'lancar-gto', label: 'LANÇAR GTO', icon: ClipboardList },
{ id: 'agenda', label: 'AGENDA', icon: CalendarIcon },
{ id: 'ortodontia', label: 'ORTODONTIA', icon: Activity },
{ id: 'financeiro', label: 'FINANCEIRO', icon: DollarSign },
{ id: 'relatorios', label: 'RELATÓRIOS', icon: BarChart3 },
{ id: 'dentistas', label: 'DENTISTAS', icon: Stethoscope },
{ id: 'especialidades', label: 'ESPECIALIDADES', icon: Award },
{ id: 'procedimentos', label: 'PROCEDIMENTOS', icon: ClipboardList },
{ id: 'planos', label: 'PLANOS / CONVÊNIOS', icon: BookOpen },
{ id: 'notificacoes', label: 'NOTIFICAÇÕES', icon: Bell },
{ id: 'sync', label: 'SINCRONIZAÇÃO', icon: RefreshCw },
].filter(item => {
if (['admin', 'donoclinica'].includes(currentRole)) return true;
if (currentRole === 'paciente') return ['tratamentos', 'notificacoes'].includes(item.id);
if (currentRole === 'dentista') return ['dashboard', 'pacientes', 'tratamentos', 'agenda', 'ortodontia', 'notificacoes', 'clinicas'].includes(item.id);
if (currentRole === 'funcionario') return ['dashboard', 'leads', 'pacientes', 'tratamentos', 'lancar-gto', 'agenda', 'financeiro', 'relatorios', 'notificacoes', 'clinicas'].includes(item.id);
return false;
});
const handleLogout = () => {
if (window.confirm('TEM CERTEZA QUE DESEJA SAIR DO SISTEMA?')) {
HybridBackend.logout();
}
};
return (
<div className="w-64 bg-white border-r border-gray-200 h-screen flex flex-col shadow-xl md:shadow-none">
<div className="p-6 flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shadow-lg transition-colors duration-500"
style={{ backgroundColor: clinColor }}
>
<Database size={18} />
</div>
<div>
<span className="font-bold text-lg text-gray-800 block leading-tight tracking-tight">SCOREODONTO</span>
<span className="text-[10px] text-gray-400 font-bold uppercase tracking-wider">MYSQL + GOOGLE</span>
</div>
</div>
<nav className="flex-1 px-4 py-2 space-y-1 overflow-y-auto custom-scrollbar">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => setActiveTab(item.id)}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-[11px] font-bold uppercase transition-all duration-300 group ${isActive
? 'text-white'
: 'text-gray-500 hover:bg-gray-50'
}`}
style={{
backgroundColor: isActive ? clinColor : 'transparent',
boxShadow: isActive ? `0 10px 15px -5px ${clinColor}44` : 'none'
}}
>
<Icon size={18} className={`${isActive ? 'text-white' : 'text-gray-400 group-hover:text-gray-600'} transition-colors`}
style={{ color: isActive ? '#fff' : undefined }} />
{item.label}
</button>
);
})}
</nav>
<div className="p-4 border-t border-gray-100 bg-gray-50/50 space-y-4">
{/* User Profile Section */}
<div className="bg-white p-3 rounded-2xl border border-gray-100 shadow-sm">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center text-gray-500 border border-gray-200">
<User size={20} />
</div>
<div className="min-w-0">
<p className="text-xs font-black text-gray-800 uppercase truncate leading-none mb-1">{userName}</p>
<p className="text-[9px] font-bold text-gray-400 truncate">{userEmail}</p>
</div>
</div>
<div
className="flex items-center gap-2 p-2 rounded-lg border transition-all duration-500"
style={{
backgroundColor: `${clinColor}08`,
borderColor: `${clinColor}22`
}}
>
<div className="w-2 h-2 rounded-full shadow-sm" style={{ backgroundColor: clinColor }}></div>
<span className="text-[9px] font-black uppercase truncate" style={{ color: clinColor }}>
{activeWorkspace?.nome || 'Sem Unidade'}
</span>
</div>
</div>
{currentRole === 'admin' && (
<GoogleConnectButton
ownerId="admin"
isConnected={connectedAccounts.some(a => a.owner_id === 'admin' || a.owner_id === userEmail)}
onStatusChange={fetchGoogleStatus}
/>
)}
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-2 text-red-500 hover:bg-red-50 rounded-xl text-[10px] font-black uppercase transition-all"
>
<LogOut size={16} />
SAIR DO SISTEMA
</button>
</div>
</div>
);
};
@@ -0,0 +1,38 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle, AlertTriangle, Info, X } from 'lucide-react';
import { useToast } from '../contexts/ToastContext.tsx';
const icons = {
success: <CheckCircle className="text-green-500" size={20} />,
error: <AlertTriangle className="text-red-500" size={20} />,
info: <Info className="text-blue-500" size={20} />,
};
const toastColors = {
success: 'bg-green-50 border-green-200',
error: 'bg-red-50 border-red-200',
info: 'bg-blue-50 border-blue-200',
};
export const ToastContainer: React.FC = () => {
const { toasts } = useToast();
const portalElement = document.getElementById('toast-container');
if (!portalElement) return null;
return createPortal(
<div className="fixed top-6 right-6 z-[100] space-y-3">
{toasts.map((toast) => (
<div
key={toast.id}
className={`w-80 p-4 rounded-xl shadow-lg flex items-start gap-3 border animate-in slide-in-from-top-4 ${toastColors[toast.type]}`}
>
<div className="flex-shrink-0 mt-0.5">{icons[toast.type]}</div>
<p className="flex-1 text-sm text-gray-800 font-bold uppercase">{toast.message}</p>
</div>
))}
</div>,
portalElement
);
};