314 lines
12 KiB
TypeScript
314 lines
12 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { createRoot } from 'react-dom/client';
|
|
import { View, SchoolData, User } from './types';
|
|
import { dbService } from './services/dbService';
|
|
import Sidebar from './components/Sidebar';
|
|
import Dashboard from './components/Dashboard';
|
|
import Students from './components/Students';
|
|
import Classes from './components/Classes';
|
|
import Courses from './components/Courses';
|
|
import Finance from './components/Finance';
|
|
import Settings from './components/Settings';
|
|
import Contracts from './components/Contracts';
|
|
import Certificates from './components/Certificates';
|
|
import AttendanceCapture from './components/AttendanceCapture';
|
|
import AttendanceQuery from './components/AttendanceQuery';
|
|
import ReportCard from './components/ReportCard';
|
|
import Auth from './components/Auth';
|
|
import UserManagement from './components/UserManagement';
|
|
import Handouts from './components/Handouts';
|
|
import Employees from './components/Employees';
|
|
import Messages from './components/Messages';
|
|
import AdminNotifications from './components/AdminNotifications';
|
|
import Exams from './components/Exams';
|
|
import { Cloud, CloudOff, RefreshCw, AlertCircle } from 'lucide-react';
|
|
import { supabase, isSupabaseConfigured } from './services/supabase';
|
|
import { DialogProvider } from './DialogContext';
|
|
|
|
const App = () => {
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
|
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
|
const [currentView, setCurrentView] = useState<View>(View.Dashboard);
|
|
const [deepLinkStudentId, setDeepLinkStudentId] = useState<string | null>(null);
|
|
const [deepLinkClassId, setDeepLinkClassId] = useState<string | null>(null);
|
|
// Initial load from LocalStorage for speed (fallback), then IDB
|
|
const [data, setData] = useState<SchoolData>(dbService.getData());
|
|
|
|
// Sync Status
|
|
const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'saved' | 'error' | 'conflict'>('idle');
|
|
const [isCloudEnabled, setIsCloudEnabled] = useState(false);
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// 0. Load from IndexedDB on mount
|
|
useEffect(() => {
|
|
const loadSessionAndData = async () => {
|
|
try {
|
|
const savedSession = localStorage.getItem('edumanager_session');
|
|
if (savedSession) {
|
|
const user = JSON.parse(savedSession);
|
|
setCurrentUser(user);
|
|
setIsAuthenticated(true);
|
|
}
|
|
const localData = await dbService.initData();
|
|
setData(prev => ({ ...prev, ...localData }));
|
|
} catch (e) {
|
|
console.error("Erro ao carregar sessão:", e);
|
|
} finally {
|
|
setIsCheckingAuth(false);
|
|
}
|
|
};
|
|
loadSessionAndData();
|
|
}, []);
|
|
|
|
// 1. Initial Cloud Fetch (Sync on Load)
|
|
useEffect(() => {
|
|
const initCloud = async () => {
|
|
if (isSupabaseConfigured()) {
|
|
setSyncStatus('syncing');
|
|
const cloudData = await dbService.fetchFromCloud();
|
|
|
|
if (cloudData) {
|
|
// If cloud data exists, it takes precedence.
|
|
setData(cloudData);
|
|
dbService.saveData(cloudData); // Update local cache
|
|
setSyncStatus('saved');
|
|
} else {
|
|
// If no cloud data, we might be starting fresh
|
|
setSyncStatus('idle');
|
|
}
|
|
// Only enable cloud saving AFTER the initial fetch is attempted
|
|
setIsCloudEnabled(true);
|
|
}
|
|
};
|
|
initCloud();
|
|
}, []);
|
|
|
|
// 2. Save Data Effect (Local + Debounced Cloud)
|
|
useEffect(() => {
|
|
// Immediate Local Save
|
|
dbService.saveData(data);
|
|
|
|
// Debounced Cloud Save
|
|
if (isCloudEnabled) {
|
|
setSyncStatus('syncing');
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
|
|
saveTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
const result = await dbService.saveToCloud(data);
|
|
if (result.success) {
|
|
setSyncStatus('saved');
|
|
} else if (result.reason === 'newer_version') {
|
|
setSyncStatus('conflict');
|
|
console.warn("⚠️ Conflito de versão detectado. Sincronizando com os dados mais recentes do servidor...");
|
|
forceSyncFromCloud();
|
|
} else {
|
|
setSyncStatus('error');
|
|
}
|
|
} catch (e) {
|
|
setSyncStatus('error');
|
|
}
|
|
}, 2000); // Save to cloud 2 seconds after last change
|
|
}
|
|
|
|
return () => {
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
};
|
|
}, [data, isCloudEnabled]);
|
|
|
|
// 3. Dynamic Favicon
|
|
useEffect(() => {
|
|
const logoUrl = data.logo;
|
|
if (logoUrl) {
|
|
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
|
|
if (!link) {
|
|
link = document.createElement('link');
|
|
link.type = 'image/x-icon';
|
|
link.rel = 'icon';
|
|
document.getElementsByTagName('head')[0].appendChild(link);
|
|
}
|
|
link.href = logoUrl;
|
|
}
|
|
}, [data.logo]);
|
|
// 4. Efeito para Realtime (Escuta mudanças do Portal em tempo real)
|
|
const dataRef = useRef(data);
|
|
useEffect(() => {
|
|
dataRef.current = data;
|
|
}, [data]);
|
|
|
|
// 4. Polling Inteligente (Substitui o Realtime do Supabase no ambiente Self-Hosted)
|
|
// Verifica mudanças no servidor a cada 30 segundos
|
|
useEffect(() => {
|
|
const pollInterval = setInterval(async () => {
|
|
// Pequena validação para não interromper ações do usuário
|
|
if (syncStatus === 'syncing') return;
|
|
|
|
try {
|
|
const cloudData = await dbService.fetchFromCloud();
|
|
|
|
if (cloudData && cloudData.lastUpdated && dataRef.current.lastUpdated) {
|
|
const cloudTime = new Date(cloudData.lastUpdated).getTime();
|
|
const localTime = new Date(dataRef.current.lastUpdated).getTime();
|
|
|
|
// Regra crucial: Só substitui o estado local se o servidor tiver dados ESTRITAMENTE mais novos.
|
|
// Isso impede que verificações durante o "debounce" (espera de 2 segs) sobrescrevam o estado
|
|
// local com dados velhos do servidor, fazendo itens recém-criados "sumirem".
|
|
if (cloudTime > localTime) {
|
|
console.log("🔔 Polling: Novos dados detectados no servidor!");
|
|
setData(cloudData);
|
|
dbService.saveData(cloudData);
|
|
setSyncStatus('saved');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Silencioso em caso de erro de rede temporário
|
|
}
|
|
}, 30000); // 30 segundos
|
|
|
|
return () => clearInterval(pollInterval);
|
|
}, [syncStatus]);
|
|
|
|
const updateData = (newData: Partial<SchoolData>) => {
|
|
setData(prev => ({
|
|
...prev,
|
|
...newData,
|
|
lastUpdated: new Date().toISOString()
|
|
}));
|
|
};
|
|
|
|
const handleUpdateUsers = (newUsers: User[]) => {
|
|
updateData({ users: newUsers });
|
|
};
|
|
|
|
const forceSyncFromCloud = async () => {
|
|
setSyncStatus('syncing');
|
|
const cloudData = await dbService.fetchFromCloud();
|
|
if (cloudData) {
|
|
setData(cloudData);
|
|
dbService.saveData(cloudData);
|
|
setSyncStatus('saved');
|
|
} else {
|
|
setSyncStatus('error');
|
|
}
|
|
};
|
|
|
|
const handleNavigateToStudent = (studentId: string) => {
|
|
setDeepLinkStudentId(studentId);
|
|
setCurrentView(View.AttendanceQuery);
|
|
};
|
|
|
|
const handleNavigateToClass = (classId: string, studentId?: string) => {
|
|
setDeepLinkClassId(classId);
|
|
setDeepLinkStudentId(studentId || null);
|
|
setCurrentView(View.Students);
|
|
};
|
|
|
|
const renderView = () => {
|
|
switch (currentView) {
|
|
case View.Dashboard:
|
|
return <Dashboard data={data} />;
|
|
case View.Courses:
|
|
return <Courses data={data} updateData={updateData} />;
|
|
case View.Students:
|
|
return <Students data={data} updateData={updateData} deepLinkStudentId={deepLinkStudentId} deepLinkClassId={deepLinkClassId} clearDeepLink={() => { setDeepLinkStudentId(null); setDeepLinkClassId(null); }} />;
|
|
case View.Classes:
|
|
return <Classes data={data} updateData={updateData} onNavigateToClass={handleNavigateToClass} />;
|
|
case View.Finance:
|
|
return <Finance data={data} updateData={updateData} />;
|
|
case View.Contracts:
|
|
return <Contracts data={data} updateData={updateData} />;
|
|
case View.Certificates:
|
|
return <Certificates data={data} updateData={updateData} />;
|
|
case View.Attendance:
|
|
return <AttendanceCapture data={data} updateData={updateData} />;
|
|
case View.AttendanceQuery:
|
|
return <AttendanceQuery data={data} updateData={updateData} deepLinkStudentId={deepLinkStudentId} clearDeepLink={() => setDeepLinkStudentId(null)} />;
|
|
case View.ReportCard:
|
|
return <ReportCard data={data} updateData={updateData} />;
|
|
case View.Handouts:
|
|
return <Handouts data={data} updateData={updateData} />;
|
|
case View.Exams:
|
|
return <Exams data={data} updateData={updateData} />;
|
|
case View.Employees:
|
|
return <Employees data={data} updateData={updateData} />;
|
|
case View.Users:
|
|
return <UserManagement data={data} updateData={updateData} />;
|
|
case View.Messages:
|
|
return <Messages data={data} updateData={updateData} />;
|
|
case View.Settings:
|
|
return <Settings data={data} updateData={updateData} setData={setData} />;
|
|
default:
|
|
return <Dashboard data={data} />;
|
|
}
|
|
};
|
|
|
|
if (isCheckingAuth) {
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
|
<RefreshCw size={48} className="text-indigo-600 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return <Auth data={data} onLogin={(user) => {
|
|
localStorage.setItem('edumanager_session', JSON.stringify(user));
|
|
setCurrentUser(user);
|
|
setIsAuthenticated(true);
|
|
}} onUpdateUsers={handleUpdateUsers} />;
|
|
}
|
|
|
|
const handleLogout = () => {
|
|
localStorage.removeItem('edumanager_session');
|
|
setIsAuthenticated(false);
|
|
setCurrentUser(null);
|
|
setCurrentView(View.Dashboard);
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-slate-50 relative">
|
|
<Sidebar currentView={currentView} setView={setCurrentView} user={currentUser} logo={data.logo} onLogout={handleLogout} />
|
|
<main className="flex-1 w-full overflow-y-auto max-h-screen pt-16 md:pt-0 relative">
|
|
{/* Sync Indicator - Green Strip on the Right */}
|
|
{syncStatus === 'syncing' && (
|
|
<div className="fixed top-6 right-0 z-[100] flex flex-col items-end pointer-events-none animate-in slide-in-from-right duration-500">
|
|
<div className="bg-emerald-500 text-white py-2.5 px-6 shadow-2xl flex items-center gap-3 border-l-4 border-emerald-300">
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
<span className="text-[10px] font-black uppercase tracking-[0.2em]">Sincronizando</span>
|
|
</div>
|
|
<div className="w-full h-1 bg-emerald-600/20 relative overflow-hidden">
|
|
<div className="absolute inset-0 bg-white/60 animate-pulse"></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conflict Alert - Only show when there is a version mismatch */}
|
|
{syncStatus === 'conflict' && (
|
|
<div className="fixed bottom-6 right-6 z-[100] animate-in slide-in-from-bottom duration-500">
|
|
<button
|
|
onClick={forceSyncFromCloud}
|
|
className="flex items-center gap-3 px-6 py-3 bg-amber-500 text-white rounded-2xl font-black text-xs shadow-2xl hover:bg-amber-600 transition-all active:scale-95 border-2 border-white"
|
|
>
|
|
<AlertCircle size={18} />
|
|
<span>DADOS NOVOS NA NUVEM - CLIQUE PARA ATUALIZAR</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="max-w-7xl mx-auto p-4 md:p-8">
|
|
<AdminNotifications data={data} updateData={updateData} setView={setCurrentView} onNavigateToStudent={handleNavigateToStudent} />
|
|
{renderView()}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const root = createRoot(document.getElementById('root')!);
|
|
root.render(
|
|
<DialogProvider>
|
|
<App />
|
|
</DialogProvider>
|
|
);
|