feat: migrate ScoreOdonto CRM to Docker, PostgreSQL, and DragonflyDB
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { HybridBackend } from './services/backend.ts';
|
||||
|
||||
import { Sidebar } from './components/Sidebar.tsx';
|
||||
import { Menu } from 'lucide-react';
|
||||
|
||||
import { ToastContainer } from './components/Toast.tsx';
|
||||
|
||||
import { Dashboard } from './views/Dashboard.tsx';
|
||||
import { LeadsView } from './views/LeadsView.tsx';
|
||||
import { PublicContactForm } from './views/PublicContactForm.tsx';
|
||||
import { PatientsView } from './views/PatientsView.tsx';
|
||||
import { AgendaView } from './views/AgendaView.tsx';
|
||||
import { OrthoView } from './views/OrthoView.tsx';
|
||||
import { FinanceiroView } from './views/FinanceiroView.tsx';
|
||||
import { MeusTratamentos } from './views/MeusTratamentos';
|
||||
import { LancarGTO } from './views/LancarGTO';
|
||||
import { LoginView } from './views/LoginView.tsx';
|
||||
import { LandingPage } from './views/LandingPage.tsx';
|
||||
import { UpdateDbView } from './views/UpdateDbView.tsx';
|
||||
import { ClinicasView } from './views/ClinicasView.tsx';
|
||||
import { DentistRegisterView } from './views/DentistRegisterView.tsx';
|
||||
import { DentistasView, EspecialidadesView, PlanosView, SyncView, ProcedimentosView } from './views/AdminViews.tsx';
|
||||
import { NotificationsView } from './views/NotificationsView.tsx';
|
||||
import { ReportsView } from './views/ReportsView.tsx';
|
||||
|
||||
export type ViewKey =
|
||||
| 'landing'
|
||||
| 'dashboard'
|
||||
| 'leads'
|
||||
| 'public'
|
||||
| 'pacientes'
|
||||
| 'agenda'
|
||||
| 'ortodontia'
|
||||
| 'financeiro'
|
||||
| 'login'
|
||||
| 'update'
|
||||
| 'dentistas'
|
||||
| 'especialidades'
|
||||
| 'procedimentos'
|
||||
| 'planos'
|
||||
| 'tratamentos'
|
||||
| 'lancar-gto'
|
||||
| 'notificacoes'
|
||||
| 'clinicas'
|
||||
| 'sync'
|
||||
| 'relatorios'
|
||||
| 'cadastro-dentista';
|
||||
|
||||
// ------- URL <-> VIEW mapping -------
|
||||
const ROUTE_MAP: Record<string, ViewKey> = {
|
||||
'/': 'landing',
|
||||
'/dashboard': 'dashboard',
|
||||
'/landing': 'landing',
|
||||
'/leads': 'leads',
|
||||
'/contato': 'public',
|
||||
'/pacientes': 'pacientes',
|
||||
'/agenda': 'agenda',
|
||||
'/ortodontia': 'ortodontia',
|
||||
'/financeiro': 'financeiro',
|
||||
'/login': 'login',
|
||||
'/update': 'update',
|
||||
'/clinicas': 'clinicas',
|
||||
'/admin/dentistas': 'dentistas',
|
||||
'/admin/especialidades': 'especialidades',
|
||||
'/admin/procedimentos': 'procedimentos',
|
||||
'/admin/planos': 'planos',
|
||||
'/admin/sync': 'sync',
|
||||
'/tratamentos': 'tratamentos',
|
||||
'/lancar-gto': 'lancar-gto',
|
||||
'/notificacoes': 'notificacoes',
|
||||
'/relatorios': 'relatorios',
|
||||
'/cadastro-dentista': 'cadastro-dentista',
|
||||
};
|
||||
|
||||
const VIEW_TO_ROUTE: Record<ViewKey, string> = {
|
||||
landing: '/',
|
||||
dashboard: '/dashboard',
|
||||
leads: '/leads',
|
||||
public: '/contato',
|
||||
pacientes: '/pacientes',
|
||||
agenda: '/agenda',
|
||||
ortodontia: '/ortodontia',
|
||||
financeiro: '/financeiro',
|
||||
login: '/login',
|
||||
update: '/update',
|
||||
clinicas: '/clinicas',
|
||||
dentistas: '/admin/dentistas',
|
||||
especialidades: '/admin/especialidades',
|
||||
procedimentos: '/admin/procedimentos',
|
||||
planos: '/admin/planos',
|
||||
tratamentos: '/tratamentos',
|
||||
'lancar-gto': '/lancar-gto',
|
||||
notificacoes: '/notificacoes',
|
||||
relatorios: '/relatorios',
|
||||
sync: '/admin/sync',
|
||||
'cadastro-dentista': '/cadastro-dentista'
|
||||
};
|
||||
|
||||
/** Get the hash path, e.g. "/#/pacientes" → "/pacientes" */
|
||||
function getHashPath(): string {
|
||||
const hash = window.location.hash;
|
||||
if (hash.startsWith('#/')) return hash.slice(1); // "#/pacientes" → "/pacientes"
|
||||
if (hash === '#' || hash === '') return '/';
|
||||
return '/';
|
||||
}
|
||||
|
||||
/** Resolve current URL to a ViewKey */
|
||||
function resolveViewFromUrl(): ViewKey {
|
||||
// Legacy support: ?p=contato
|
||||
if (window.location.search.includes('p=contato')) return 'public';
|
||||
// Legacy support: #update_db (old non-slash hash)
|
||||
if (window.location.hash === '#update_db') return 'update';
|
||||
|
||||
const path = getHashPath();
|
||||
return ROUTE_MAP[path] ?? 'dashboard';
|
||||
}
|
||||
|
||||
/** Push a new hash-based URL without reloading the page */
|
||||
function navigate(view: ViewKey) {
|
||||
const route = VIEW_TO_ROUTE[view];
|
||||
window.location.hash = route;
|
||||
}
|
||||
|
||||
// ------- App -------
|
||||
const App: React.FC = () => {
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(false);
|
||||
const currentRole = HybridBackend.getCurrentRole();
|
||||
const activeWorkspace = HybridBackend.getActiveWorkspace();
|
||||
const clinColor = activeWorkspace?.cor || '#2563eb';
|
||||
|
||||
const isViewAllowed = (view: ViewKey, role: string): boolean => {
|
||||
if (['landing', 'login', 'public', 'update'].includes(view)) return true;
|
||||
if (role === 'admin' || role === 'donoclinica') return true;
|
||||
|
||||
const permissions: Record<string, ViewKey[]> = {
|
||||
paciente: ['tratamentos', 'notificacoes'],
|
||||
dentista: ['dashboard', 'pacientes', 'agenda', 'ortodontia', 'tratamentos', 'notificacoes', 'clinicas'],
|
||||
funcionario: ['dashboard', 'leads', 'pacientes', 'agenda', 'financeiro', 'tratamentos', 'lancar-gto', 'notificacoes', 'clinicas'],
|
||||
};
|
||||
|
||||
return permissions[role]?.includes(view) || false;
|
||||
};
|
||||
|
||||
const getDefaultViewForRole = (role: string): ViewKey => {
|
||||
if (role === 'paciente') return 'tratamentos';
|
||||
return 'dashboard';
|
||||
};
|
||||
|
||||
const getInitialView = (): ViewKey => {
|
||||
const fromUrl = resolveViewFromUrl();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
|
||||
// If user is authenticated, and trying to go to landing or login, send to default for role
|
||||
if (HybridBackend.isAuthenticated() && ['landing', 'login'].includes(fromUrl)) return getDefaultViewForRole(role);
|
||||
|
||||
// If URL points to a protected view but user is not auth'd → landing (or login)
|
||||
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
|
||||
if (isProtected && !HybridBackend.isAuthenticated()) return 'landing';
|
||||
|
||||
// Verify permission for the view
|
||||
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
|
||||
return getDefaultViewForRole(role);
|
||||
}
|
||||
|
||||
return fromUrl;
|
||||
};
|
||||
|
||||
const [currentView, setCurrentView] = useState<ViewKey>(getInitialView);
|
||||
|
||||
// Keep URL in sync when view changes programmatically via sidebar
|
||||
const handleNavigate = (view: ViewKey) => {
|
||||
if (isViewAllowed(view, currentRole)) {
|
||||
setCurrentView(view);
|
||||
navigate(view);
|
||||
setSidebarOpen(false); // Close sidebar on mobile after navigation
|
||||
}
|
||||
};
|
||||
|
||||
// Sync view when user uses browser back/forward buttons
|
||||
useEffect(() => {
|
||||
const onHashChange = () => {
|
||||
const fromUrl = resolveViewFromUrl();
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
const isProtected = !['landing', 'login', 'public'].includes(fromUrl);
|
||||
|
||||
if (isProtected && !HybridBackend.isAuthenticated()) {
|
||||
setCurrentView('landing');
|
||||
navigate('landing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (HybridBackend.isAuthenticated() && !isViewAllowed(fromUrl, role)) {
|
||||
const defView = getDefaultViewForRole(role);
|
||||
setCurrentView(defView);
|
||||
navigate(defView);
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentView(fromUrl);
|
||||
};
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
return () => window.removeEventListener('hashchange', onHashChange);
|
||||
}, []);
|
||||
|
||||
// Inject dynamic brand color
|
||||
useEffect(() => {
|
||||
document.documentElement.style.setProperty('--brand-color', clinColor);
|
||||
document.documentElement.style.setProperty('--brand-color-soft', `${clinColor}11`);
|
||||
}, [clinColor]);
|
||||
|
||||
// On mount: if there's no hash yet, write the current view into the URL
|
||||
useEffect(() => {
|
||||
if (!window.location.hash || window.location.hash === '#update_db') {
|
||||
navigate(currentView);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'landing': return <LandingPage onGetStarted={() => handleNavigate('login')} />;
|
||||
case 'dashboard': return <Dashboard />;
|
||||
case 'leads': return <LeadsView />;
|
||||
case 'public': return <PublicContactForm />;
|
||||
case 'pacientes': return <PatientsView />;
|
||||
case 'agenda': return <AgendaView />;
|
||||
case 'ortodontia': return <OrthoView />;
|
||||
case 'financeiro': return <FinanceiroView />;
|
||||
case 'dentistas': return <DentistasView />;
|
||||
case 'especialidades': return <EspecialidadesView />;
|
||||
case 'procedimentos': return <ProcedimentosView />;
|
||||
case 'planos': return <PlanosView />;
|
||||
case 'sync': return <SyncView />;
|
||||
case 'tratamentos': return <MeusTratamentos />;
|
||||
case 'lancar-gto': return <LancarGTO />;
|
||||
case 'update': return <UpdateDbView />;
|
||||
case 'clinicas': return <ClinicasView />;
|
||||
case 'notificacoes': return <NotificationsView />;
|
||||
case 'relatorios': return <ReportsView />;
|
||||
case 'cadastro-dentista': return <DentistRegisterView />;
|
||||
case 'login':
|
||||
default:
|
||||
return <LoginView onLoginSuccess={() => {
|
||||
const role = HybridBackend.getCurrentRole();
|
||||
handleNavigate(getDefaultViewForRole(role));
|
||||
}} />;
|
||||
}
|
||||
};
|
||||
|
||||
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista'].includes(currentView);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen bg-gray-50 text-gray-800 overflow-hidden relative">
|
||||
<style>{`
|
||||
:root { --brand-color: ${clinColor}; }
|
||||
.bg-brand { background-color: var(--brand-color); }
|
||||
.text-brand { color: var(--brand-color); }
|
||||
.border-brand { border-color: var(--brand-color); }
|
||||
.hover\\:bg-brand:hover { background-color: var(--brand-color); }
|
||||
.focus\\:ring-brand:focus { --tw-ring-color: var(--brand-color); }
|
||||
`}</style>
|
||||
|
||||
{!isStandaloneView && (
|
||||
<>
|
||||
{/* Mobile Hamburger Overlay */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-20 md:hidden backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Sidebar with mobile toggle logic */}
|
||||
<div className={`fixed inset-y-0 left-0 z-30 transform transition-transform duration-300 md:relative md:translate-x-0 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<Sidebar
|
||||
activeTab={currentView}
|
||||
setActiveTab={(t) => handleNavigate(t as ViewKey)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<main className={`flex-1 h-full relative flex flex-col min-w-0`}>
|
||||
{!isStandaloneView && (
|
||||
<>
|
||||
{/* Mobile Navbar Header */}
|
||||
<div className="md:hidden flex items-center justify-between px-4 py-3 bg-white border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded flex items-center justify-center text-white font-bold text-sm shadow-sm" style={{ backgroundColor: clinColor }}>
|
||||
S
|
||||
</div>
|
||||
<span className="font-bold text-gray-800 text-sm tracking-tight uppercase">SCOREODONTO</span>
|
||||
</div>
|
||||
<div className="w-8"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 pb-20 scroll-smooth">
|
||||
<div className="max-w-7xl mx-auto h-full">
|
||||
{renderView()}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<ToastContainer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user