2026-01-31 14:46:12 +00:00

591 lines
23 KiB
JavaScript

const API_URL = '/api/v1/index.php?request=';
// --- IndexedDB Configuration ---
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open('SOMS_DB', 2);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('data')) db.createObjectStore('data');
if (!db.objectStoreNames.contains('sync_queue')) db.createObjectStore('sync_queue', { keyPath: 'id', autoIncrement: true });
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
async function dbGet(store, key) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const trans = db.transaction(store, 'readonly');
const req = trans.objectStore(store).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbSet(store, key, value) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const trans = db.transaction(store, 'readwrite');
const req = trans.objectStore(store).put(value, key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function dbAdd(store, value) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const trans = db.transaction(store, 'readwrite');
const req = trans.objectStore(store).add(value);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function dbGetAll(store) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const trans = db.transaction(store, 'readonly');
const req = trans.objectStore(store).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbDelete(store, key) {
const db = await dbPromise;
return new Promise((resolve, reject) => {
const trans = db.transaction(store, 'readwrite');
const req = trans.objectStore(store).delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
// --- App State ---
const state = {
user: JSON.parse(localStorage.getItem('user')) || null,
token: localStorage.getItem('token') || null,
};
const routes = {
'/': homePage,
'/login': loginPage,
'/learners': learnersPage,
'/assessments': assessmentsPage,
'/events': eventsPage,
'/collaboration': collaborationPage,
'/leaderboard': leaderboardPage,
'/super-admin': superAdminPage,
};
// --- Initialization ---
async function init() {
window.addEventListener('hashchange', router);
window.addEventListener('online', syncData);
window.addEventListener('offline', () => updateOnlineStatus(false));
updateOnlineStatus(navigator.onLine);
router();
updateNav();
if (navigator.onLine) {
syncData();
}
}
function updateOnlineStatus(isOnline) {
const indicator = document.getElementById('offline-indicator');
if (indicator) {
indicator.style.display = isOnline ? 'none' : 'block';
}
}
async function syncData() {
updateOnlineStatus(true);
const queue = await dbGetAll('sync_queue');
if (queue.length === 0) return;
console.log('Syncing data...', queue.length, 'items');
for (const item of queue) {
try {
await apiFetch(item.endpoint, {
method: item.method,
body: item.body
}, true); // forceOnline = true
await dbDelete('sync_queue', item.id);
} catch (err) {
console.error('Failed to sync item:', item, err);
break; // Stop syncing if network fails again
}
}
}
function router() {
const hash = window.location.hash || '#/';
const path = hash.substring(1);
if (!state.token && path !== '/login') {
window.location.hash = '#/login';
return;
}
const page = routes[path] || routes['/'];
page();
}
function updateNav() {
const navLinks = document.getElementById('nav-links');
if (!navLinks) return;
if (!state.token) {
navLinks.innerHTML = `
<li class="nav-item"><a class="nav-link" href="#/login">Login</a></li>
`;
return;
}
let html = `<li class="nav-item"><a class="nav-link" href="#/">Dashboard</a></li>`;
if (state.user.role === 'Super Admin') {
html += `<li class="nav-item"><a class="nav-link fw-bold text-warning" href="#/super-admin">Super Admin</a></li>`;
}
html += `
<li class="nav-item"><a class="nav-link" href="#/learners">Learners</a></li>
<li class="nav-item"><a class="nav-link" href="#/assessments">Assessments</a></li>
<li class="nav-item"><a class="nav-link" href="#/events">Events</a></li>
<li class="nav-item"><a class="nav-link" href="#/collaboration">Hub</a></li>
<li class="nav-item"><a class="nav-link" href="#/leaderboard">Ranking</a></li>
<li class="nav-item"><a class="nav-link text-danger" href="#" id="logout-btn">Logout</a></li>
`;
navLinks.innerHTML = html;
document.getElementById('logout-btn').addEventListener('click', (e) => {
e.preventDefault();
logout();
});
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('user');
state.token = null;
state.user = null;
window.location.hash = '#/login';
updateNav();
}
async function apiFetch(endpoint, options = {}, forceOnline = false) {
const url = API_URL + endpoint;
const method = options.method || 'GET';
const body = options.body || null;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
...(state.token ? { 'Authorization': `Bearer ${state.token}` } : {})
}
};
if (method === 'GET' && !forceOnline) {
try {
const response = await fetch(url, { ...defaultOptions, ...options });
if (response.status === 401) { logout(); throw new Error('Unauthorized'); }
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'API Error');
// Save to offline storage
await dbSet('data', endpoint, data);
return data;
} catch (err) {
console.warn('Network failed, trying offline storage for:', endpoint);
const cached = await dbGet('data', endpoint);
if (cached) return cached;
throw err;
}
} else {
// POST/PUT/DELETE
try {
const response = await fetch(url, { ...defaultOptions, ...options });
if (response.status === 401) { logout(); throw new Error('Unauthorized'); }
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'API Error');
return data;
} catch (err) {
if (forceOnline) throw err;
console.warn('Network failed, queueing request for:', endpoint);
await dbAdd('sync_queue', { endpoint, method, body, timestamp: Date.now() });
// Return a "fake" success to keep the UI responsive
return { success: true, offline: true, message: 'Data queued for sync' };
}
}
}
function render(html) {
const content = document.getElementById('content');
if (content) content.innerHTML = html;
}
// --- Page Renderers ---
async function homePage() {
render(`
<div class="p-4 mb-4 bg-white rounded-4 shadow-sm">
<h1 class="h2 fw-bold">Welcome back, ${state.user.email}</h1>
<p class="text-muted">You are logged in as <strong>${state.user.role}</strong>.</p>
<hr class="my-4">
<div class="row g-4">
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm text-center p-3">
<i class="bi bi-people fs-1 text-primary mb-2"></i>
<h5 class="card-title">Learners</h5>
<a href="#/learners" class="btn btn-sm btn-outline-primary mt-auto">Manage</a>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm text-center p-3">
<i class="bi bi-journal-check fs-1 text-success mb-2"></i>
<h5 class="card-title">Assessments</h5>
<a href="#/assessments" class="btn btn-sm btn-outline-success mt-auto">Open Hub</a>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm text-center p-3">
<i class="bi bi-calendar-event fs-1 text-warning mb-2"></i>
<h5 class="card-title">Events</h5>
<a href="#/events" class="btn btn-sm btn-outline-warning mt-auto">Calendar</a>
</div>
</div>
<div class="col-md-3">
<div class="card h-100 border-0 shadow-sm text-center p-3">
<i class="bi bi-trophy fs-1 text-info mb-2"></i>
<h5 class="card-title">Leaderboard</h5>
<a href="#/leaderboard" class="btn btn-sm btn-outline-info mt-auto">View Rankings</a>
</div>
</div>
</div>
</div>
`);
}
function loginPage() {
render(`
<div class="row justify-content-center">
<div class="col-md-5">
<div class="card shadow-lg border-0 rounded-4">
<div class="card-body p-5">
<h2 class="fw-bold text-center mb-4 text-primary"><i class="bi bi-mortarboard-fill me-2"></i>SOMS Platform</h2>
<form id="login-form">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" id="email" class="form-control" placeholder="superadmin@system.com" required>
</div>
<div class="mb-4">
<label class="form-label">Password</label>
<input type="password" id="password" class="form-control" placeholder="password" required>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-bold shadow">Sign In</button>
</form>
<div class="mt-4 p-3 bg-light rounded-3 text-center text-muted small">
<strong>Demo Credentials</strong><br>
Super Admin: superadmin@system.com / password<br>
Admin: admin@sowetohigh.edu.za / password
</div>
</div>
</div>
</div>
</div>
`);
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
try {
const data = await apiFetch('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}, true); // forceOnline for login
if (data.token) {
localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user));
state.token = data.token;
state.user = data.user;
updateNav();
window.location.hash = '#/';
}
} catch (err) {
alert('Login failed: ' + err.message);
}
});
}
async function superAdminPage() {
render('<div class="text-center p-5"><div class="spinner-border text-primary"></div></div>');
try {
const stats = await apiFetch('/schools/stats');
const schools = await apiFetch('/schools');
let html = `
<div class="row mb-4">
<div class="col">
<h2 class="fw-bold h3">Super Admin Console</h2>
<p class="text-muted">Global platform management</p>
</div>
<div class="col-auto">
<button class="btn btn-primary shadow">Onboard New School</button>
</div>
</div>
<div class="row g-4 mb-5">
<div class="col-md-3">
<div class="card p-3 border-0 shadow-sm text-center">
<div class="text-muted small mb-1">Total Schools</div>
<div class="h2 fw-bold text-primary">${stats.total_schools}</div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3 border-0 shadow-sm text-center">
<div class="text-muted small mb-1">Total Learners</div>
<div class="h2 fw-bold text-success">${stats.total_learners}</div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3 border-0 shadow-sm text-center">
<div class="text-muted small mb-1">System Uptime</div>
<div class="h2 fw-bold text-info">${stats.uptime}</div>
</div>
</div>
<div class="col-md-3">
<div class="card p-3 border-0 shadow-sm text-center">
<div class="text-muted small mb-1">Data Storage</div>
<div class="h2 fw-bold text-warning">${stats.storage}</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm rounded-4">
<div class="card-header bg-white py-3">
<h5 class="mb-0 fw-bold">Participating Schools</h5>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th class="ps-4">Name</th>
<th>Province</th>
<th>District</th>
<th class="text-end pe-4">Actions</th>
</tr>
</thead>
<tbody>
`;
schools.forEach(s => {
html += `
<tr>
<td class="ps-4 fw-bold text-primary">${s.name}</td>
<td>${s.province}</td>
<td>${s.district}</td>
<td class="text-end pe-4">
<button class="btn btn-sm btn-outline-primary">Dashboard</button>
</td>
</tr>
`;
});
html += '</tbody></table></div></div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Access Denied: Super Admin only (or offline)</div>');
}
}
async function learnersPage() {
render('<div class="text-center p-5"><div class="spinner-border text-primary"></div></div>');
try {
const learners = await apiFetch('/learners');
let html = `
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold">Learners</h2>
<button class="btn btn-primary btn-sm">Add New Learner</button>
</div>
<div class="table-responsive bg-white p-3 rounded-4 shadow-sm">
<table class="table table-hover align-middle">
<thead>
<tr>
<th>Full Name</th>
<th>Grade</th>
<th>Student ID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
learners.forEach(l => {
html += `
<tr>
<td>${l.full_name}</td>
<td><span class="badge bg-light text-dark">${l.grade}</span></td>
<td><code>${l.student_id}</code></td>
<td><button class="btn btn-sm btn-outline-secondary">Edit</button></td>
</tr>
`;
});
html += '</tbody></table></div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load learners</div>');
}
}
async function assessmentsPage() {
render('<div class="text-center p-5"><div class="spinner-border text-primary"></div></div>');
try {
const assessments = await apiFetch('/assessments');
let html = `
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold">Assessments</h2>
<button class="btn btn-primary btn-sm">Create Assessment</button>
</div>
<div class="row g-3">
`;
assessments.forEach(a => {
html += `
<div class="col-md-4">
<div class="card shadow-sm border-0 rounded-4">
<div class="card-body">
<h5 class="card-title fw-bold">${a.title || a.name}</h5>
<h6 class="card-subtitle mb-3 text-muted">${a.subject || a.grade}${a.type}</h6>
<div class="d-grid">
<button class="btn btn-sm btn-primary">Record Marks</button>
</div>
</div>
</div>
</div>
`;
});
html += '</div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load assessments</div>');
}
}
async function eventsPage() {
render('<div class="text-center p-5"><div class="spinner-border text-primary"></div></div>');
try {
const events = await apiFetch('/events');
let html = `
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold">School Calendar</h2>
<button class="btn btn-primary btn-sm">Add Event</button>
</div>
<div class="bg-white p-4 rounded-4 shadow-sm">
`;
if (events.length === 0) html += '<p class="text-muted text-center">No upcoming events.</p>';
events.forEach(e => {
html += `
<div class="d-flex border-bottom py-3">
<div class="text-center me-4" style="min-width: 60px;">
<div class="h3 fw-bold mb-0">${new Date(e.start_datetime).getDate()}</div>
<div class="small text-uppercase text-muted">${new Date(e.start_datetime).toLocaleString('default', { month: 'short' })}</div>
</div>
<div>
<h5 class="mb-1 fw-bold">${e.title}</h5>
<p class="text-muted small mb-0"><i class="bi bi-clock"></i> ${new Date(e.start_datetime).toLocaleTimeString()}${e.location || 'No location'}</p>
</div>
</div>
`;
});
html += '</div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load events</div>');
}
}
async function collaborationPage() {
render('<div class="text-center p-5"><div class="spinner-border text-primary"></div></div>');
try {
const resources = await apiFetch('/collaboration/resources');
let html = `
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="fw-bold">Collaboration Hub</h2>
<button class="btn btn-primary btn-sm">Share Resource</button>
</div>
<div class="row g-4">
`;
resources.forEach(r => {
html += `
<div class="col-md-6">
<div class="card h-100 border-0 shadow-sm rounded-4">
<div class="card-body">
<div class="d-flex justify-content-between">
<h5 class="fw-bold mb-1">${r.title}</h5>
${r.is_public == 1 ? '<span class="badge bg-success">Public</span>' : '<span class="badge bg-secondary">School Only</span>'}
</div>
<p class="text-muted small mb-3">${r.description}</p>
<div class="d-flex align-items-center text-muted small">
<i class="bi bi-person-circle me-2"></i> ${r.teacher_email}
</div>
</div>
<div class="card-footer bg-transparent border-0 text-end">
<button class="btn btn-link btn-sm text-decoration-none">Download</button>
</div>
</div>
</div>
`;
});
html += '</div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load resources</div>');
}
}
async function leaderboardPage() {
render('<div class="text-center p-5"><div class="spinner-border text-primary"></div></div>');
try {
const rankings = await apiFetch('/leaderboard');
let html = `
<h2 class="fw-bold mb-4">Student Rankings</h2>
<div class="bg-white p-3 rounded-4 shadow-sm">
<table class="table table-hover align-middle">
<thead>
<tr>
<th width="80">Rank</th>
<th>Learner</th>
<th>Performance</th>
<th class="text-end">Average</th>
</tr>
</thead>
<tbody>
`;
rankings.forEach((r, index) => {
const color = index === 0 ? 'text-warning' : (index === 1 ? 'text-secondary' : (index === 2 ? 'text-brown' : ''));
html += `
<tr>
<td class="h4 fw-bold ${color}">#${index + 1}</td>
<td><div class="fw-bold">${r.full_name}</div></td>
<td>
<div class="progress" style="height: 10px;">
<div class="progress-bar bg-primary" style="width: ${r.average_percent}%"></div>
</div>
</td>
<td class="text-end fw-bold">${parseFloat(r.average_percent).toFixed(1)}%</td>
</tr>
`;
});
html += '</tbody></table></div>';
render(html);
} catch (err) {
render('<div class="alert alert-danger">Failed to load leaderboard</div>');
}
}
init();