Commit 7b9f7368 authored by Administrator's avatar Administrator

Update 4 files via Son of Anton

parent af73d3c0
Pipeline #23 canceled with stage
...@@ -3,125 +3,261 @@ declare(strict_types=1); ...@@ -3,125 +3,261 @@ declare(strict_types=1);
namespace Engine\RealTime; namespace Engine\RealTime;
use Engine\Core\Container;
use Engine\Core\Request; use Engine\Core\Request;
use Engine\Core\Response; use Engine\Core\Response;
use Engine\Auth\SessionManager;
use Engine\Notifications\NotificationManager;
use Engine\Database\Connection; use Engine\Database\Connection;
/**
* Server-Sent Events controller.
*
* CRITICAL PHP SSE RULES:
* 1. Close the session BEFORE entering the loop (session_write_close)
* 2. Disable ALL output buffering
* 3. Set execution time to 0 (unlimited)
* 4. Check connection_aborted() every iteration
* 5. Send keepalive comments to prevent proxy timeouts
*/
final class SSEController final class SSEController
{ {
private SessionManager $sessions; public function stream(Request $request): Response
private NotificationManager $notifications;
private Connection $db;
public function __construct(SessionManager $sessions, NotificationManager $notifications, Connection $db)
{
$this->sessions = $sessions;
$this->notifications = $notifications;
$this->db = $db;
}
public function stream(Request $request): void
{ {
$user = $request->user(); $user = $request->user();
if (!$user) { if (!$user) {
http_response_code(401); return Response::json(['error' => 'Not authenticated'], 401);
exit;
} }
$userId = (int)$user['id'];
// ──────────────────────────────────────────────
// 1. CLOSE THE GODDAMN SESSION
// PHP sessions use file locks. If we don't close it,
// every other request from this user hangs until we die.
// ──────────────────────────────────────────────
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
// ──────────────────────────────────────────────
// 2. KILL ALL OUTPUT BUFFERING
// SSE requires unbuffered output. PHP and Apache
// love to buffer. We must murder every buffer.
// ──────────────────────────────────────────────
@ini_set('output_buffering', 'Off');
@ini_set('zlib.output_compression', '0');
@ini_set('implicit_flush', '1');
while (ob_get_level() > 0) {
ob_end_clean();
}
// ──────────────────────────────────────────────
// 3. SET HEADERS
// ──────────────────────────────────────────────
header('Content-Type: text/event-stream'); header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
header('Connection: keep-alive'); header('Connection: keep-alive');
header('X-Accel-Buffering: no'); header('X-Accel-Buffering: no'); // Nginx buffering bypass
header('Access-Control-Allow-Origin: *');
$lastCheck = time(); // ──────────────────────────────────────────────
$timeout = 30; // 4. REMOVE EXECUTION TIME LIMIT
$start = time(); // SSE connections are long-lived. Default 30s timeout = death.
// ──────────────────────────────────────────────
set_time_limit(0);
ignore_user_abort(false);
while (true) { // ──────────────────────────────────────────────
if (connection_aborted()) break; // 5. GET DATABASE CONNECTION
if ((time() - $start) > $timeout) break; // We need our own connection since this is a long-running process
// ──────────────────────────────────────────────
try {
$db = Container::getInstance()->resolve(Connection::class);
} catch (\Throwable $e) {
echo "event: error\ndata: {\"message\":\"Database unavailable\"}\n\n";
flush();
return new Response('', 200);
}
// Send initial connection event
$this->sendEvent('connected', ['status' => 'ok', 'user_id' => $userId]);
// Check for new notifications $lastNotificationCheck = 0;
$unreadCount = $this->notifications->getUnreadCount($user['id']); $lastHudCheck = 0;
$hasBlocking = $this->notifications->hasBlocking($user['id']); $iteration = 0;
$maxLifetime = 30; // seconds — then let the client reconnect
$startTime = time();
echo "event: notification_count\n"; // ──────────────────────────────────────────────
echo "data: " . json_encode(['unread_count' => $unreadCount, 'has_blocking' => $hasBlocking]) . "\n\n"; // 6. THE MAIN LOOP
// ──────────────────────────────────────────────
while (true) {
// Check if client disconnected
if (connection_aborted()) {
break;
}
if ($hasBlocking) { // Check if we've exceeded our lifetime
$blocking = $this->notifications->getBlockingUnacknowledged($user['id']); if ((time() - $startTime) >= $maxLifetime) {
if (!empty($blocking)) { $this->sendEvent('reconnect', ['reason' => 'lifetime']);
echo "event: blocking_notification\n"; break;
echo "data: " . json_encode($blocking[0]) . "\n\n";
} }
$now = time();
try {
// Check for new notifications every 3 seconds
if (($now - $lastNotificationCheck) >= 3) {
$lastNotificationCheck = $now;
$this->checkNotifications($db, $userId);
} }
// HUD data for contractors // Check HUD data every 5 seconds
if ($user['role'] === 'contractor' && in_array($user['status'], ['active', 'on_pip', 'suspended'])) { if (($now - $lastHudCheck) >= 5) {
$hudData = $this->getHudData($user); $lastHudCheck = $now;
echo "event: hud_update\n"; $this->checkHud($db, $userId);
echo "data: " . json_encode($hudData) . "\n\n"; }
} catch (\Throwable $e) {
// Don't let a DB error kill the entire stream.
// Log it and continue. The next iteration will retry.
$this->sendComment('error: ' . substr($e->getMessage(), 0, 100));
} }
ob_flush(); // Send keepalive comment every iteration to prevent proxy timeout
flush(); $this->sendComment('keepalive ' . $iteration);
$iteration++;
// Sleep 2 seconds between checks
// Use sleep() not usleep() — more reliable for long-running
if (connection_aborted()) {
break;
}
sleep(2); sleep(2);
} }
return new Response('', 200);
} }
private function getHudData(array $user): array private function checkNotifications(Connection $db, int $userId): void
{ {
$month = date('Y-m'); try {
$userId = $user['id']; $unreadCount = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0",
[$userId]
);
$hasBlocking = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0",
[$userId]
);
$this->sendEvent('notification_count', [
'unread_count' => $unreadCount,
'has_blocking' => $hasBlocking > 0,
]);
// If there's a blocking notification, send its details
if ($hasBlocking > 0) {
$blocking = $db->fetchOne(
"SELECT id, title, content, link_url FROM notifications
WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0
ORDER BY created_at ASC LIMIT 1",
[$userId]
);
if ($blocking) {
$this->sendEvent('blocking_notification', $blocking);
}
}
} catch (\Throwable $e) {
// Swallow — next iteration will retry
}
}
private function checkHud(Connection $db, int $userId): void
{
try {
$user = $db->fetchOne(
"SELECT actual_salary, role, status FROM users WHERE id = ?",
[$userId]
);
if (!$user || $user['role'] !== 'contractor' || !in_array($user['status'], ['active', 'on_pip', 'suspended'])) {
return; // HUD not applicable
}
$actualSalary = (float)($user['actual_salary'] ?? 0); $actualSalary = (float)($user['actual_salary'] ?? 0);
if ($actualSalary <= 0) return;
$totalBounties = (float)$this->db->fetchColumn( $month = date('Y-m');
$bounties = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?", "SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?",
[$userId, $month] [$userId, $month]
); );
$totalDeductions = (float)$this->db->fetchColumn( $deductions = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions "SELECT COALESCE(SUM(COALESCE(final_amount, calculated_amount)), 0) FROM deductions
WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL", WHERE contractor_id = ? AND payroll_month = ?
AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$userId, $month] [$userId, $month]
); );
$totalPosAdj = (float)$this->db->fetchColumn( $posAdj = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments "SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL", WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month] [$userId, $month]
); );
$totalNegAdj = (float)$this->db->fetchColumn( $negAdj = (float)$db->fetchColumn(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments "SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL", WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL",
[$userId, $month] [$userId, $month]
); );
$liveSalary = $actualSalary + $totalBounties + $totalPosAdj - $totalDeductions - $totalNegAdj; $deductionCount = (int)$db->fetchColumn(
"SELECT COUNT(*) FROM deductions WHERE contractor_id = ? AND payroll_month = ?
$deductionCount = (int)$this->db->fetchColumn( AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
"SELECT COUNT(*) FROM deductions WHERE contractor_id = ? AND payroll_month = ? AND status IN ('applied','applied_no_response','reduced','accepted') AND deleted_at IS NULL",
[$userId, $month] [$userId, $month]
); );
$bountyCount = (int)$this->db->fetchColumn( $liveSalary = $actualSalary + $bounties + $posAdj - $deductions - $negAdj;
"SELECT COUNT(*) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?", $retentionPct = $actualSalary > 0 ? ($liveSalary / $actualSalary) * 100 : 100;
[$userId, $month]
);
return [ $this->sendEvent('hud_update', [
'actual_salary' => $actualSalary, 'actual_salary' => $actualSalary,
'live_salary' => $liveSalary, 'live_salary' => round($liveSalary, 2),
'total_bounties' => $totalBounties, 'total_bounties' => round($bounties, 2),
'total_deductions' => $totalDeductions, 'total_deductions'=> round($deductions, 2),
'total_pos_adj' => $totalPosAdj,
'total_neg_adj' => $totalNegAdj,
'deduction_count' => $deductionCount, 'deduction_count' => $deductionCount,
'bounty_count' => $bountyCount, 'retention_pct' => round($retentionPct, 1),
'month' => $month, 'month' => $month,
]; ]);
} catch (\Throwable $e) {
// Swallow
}
}
private function sendEvent(string $event, array $data): void
{
echo "event: {$event}\n";
echo "data: " . json_encode($data) . "\n\n";
$this->flush();
}
private function sendComment(string $comment): void
{
echo ": {$comment}\n\n";
$this->flush();
}
private function flush(): void
{
if (ob_get_level() > 0) {
ob_flush();
}
flush();
} }
} }
\ No newline at end of file
...@@ -5,5 +5,6 @@ use Engine\Core\Container; ...@@ -5,5 +5,6 @@ use Engine\Core\Container;
$router = Container::getInstance()->resolve(Engine\Core\Router::class); $router = Container::getInstance()->resolve(Engine\Core\Router::class);
// SSE stream — only auth middleware, NO CSRF, NO audit (would flood the audit trail)
$router->get('/sse/stream', Engine\RealTime\SSEController::class, 'stream') $router->get('/sse/stream', Engine\RealTime\SSEController::class, 'stream')
->middleware([Middleware\AuthenticationMiddleware::class]); ->middleware([Middleware\AuthenticationMiddleware::class]);
\ No newline at end of file
/** /* ============================================================================
* AL-ARCADE HR PLATFORM v3.0 — Core JavaScript * AL-ARCADE HR PLATFORM v3.0 — "THE GRIND"
* Phase 1: SSE, Notifications, Search, Toast, CSRF * Core JavaScript — SSE, Notifications, CSRF, Toast, Theme
*/ * ============================================================================ */
(function() { (function() {
'use strict'; 'use strict';
// ========== CSRF Token ========== // ─── CSRF TOKEN ───
function getCsrfToken() { const csrfMeta = document.querySelector('meta[name="csrf_token"]');
const meta = document.querySelector('meta[name="csrf-token"]'); const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
return meta ? meta.content : (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
}
// ========== Fetch Helper ========== // ─── API HELPER ───
async function apiFetch(url, options = {}) { window.api = {
async fetch(url, options = {}) {
const defaults = { const defaults = {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(), 'X-CSRF-Token': csrfToken,
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json', 'Accept': 'application/json',
}, },
credentials: 'same-origin',
}; };
const config = { ...defaults, ...options }; const config = { ...defaults, ...options };
if (options.headers) config.headers = { ...defaults.headers, ...options.headers }; if (options.headers) {
config.headers = { ...defaults.headers, ...options.headers };
}
const response = await fetch(url, config); const response = await fetch(url, config);
return response.json(); if (response.status === 401) {
} window.location.href = '/login';
throw new Error('Unauthorized');
// ========== Toast System ========== }
window.showToast = function(message, type = 'info', duration = 5000) { return response;
const container = document.getElementById('toast-container'); },
if (!container) return;
const toast = document.createElement('div'); async get(url) {
toast.className = `toast toast-${type}`; return this.fetch(url);
toast.textContent = message; },
container.appendChild(toast);
setTimeout(() => { async post(url, data) {
toast.style.opacity = '0'; return this.fetch(url, {
toast.style.transform = 'translateX(100%)'; method: 'POST',
setTimeout(() => toast.remove(), 300); body: JSON.stringify(data),
}, duration); });
},
async put(url, data) {
return this.fetch(url, {
method: 'PUT',
body: JSON.stringify(data),
});
},
async delete(url) {
return this.fetch(url, { method: 'DELETE' });
},
}; };
// ========== SSE Connection ========== // ─── TOAST NOTIFICATIONS ───
function connectSSE() { window.toast = {
if (typeof EventSource === 'undefined') return; container: null,
const evtSource = new EventSource('/sse/stream');
evtSource.addEventListener('notification_count', function(e) { init() {
try { this.container = document.getElementById('toast-container');
const data = JSON.parse(e.data); if (!this.container) {
const badge = document.getElementById('notif-count'); this.container = document.createElement('div');
if (badge) { this.container.id = 'toast-container';
if (data.unread_count > 0) { this.container.style.cssText = 'position:fixed;top:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;max-width:400px;';
badge.textContent = data.unread_count; document.body.appendChild(this.container);
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
} }
},
show(message, type = 'info', duration = 5000) {
if (!this.container) this.init();
const colors = {
success: { bg: '#059669', border: '#047857' },
error: { bg: '#DC2626', border: '#B91C1C' },
warning: { bg: '#D97706', border: '#B45309' },
info: { bg: '#6366F1', border: '#4F46E5' },
bounty: { bg: '#D97706', border: '#B45309' },
};
const c = colors[type] || colors.info;
const el = document.createElement('div');
el.style.cssText = `background:${c.bg};color:white;padding:12px 20px;border-radius:8px;border-left:4px solid ${c.border};box-shadow:0 4px 12px rgba(0,0,0,0.15);font-size:14px;opacity:0;transform:translateX(100%);transition:all 0.3s ease;cursor:pointer;`;
el.textContent = message;
el.onclick = () => this.dismiss(el);
this.container.appendChild(el);
// Animate in
requestAnimationFrame(() => {
el.style.opacity = '1';
el.style.transform = 'translateX(0)';
});
// Auto-dismiss
if (duration > 0) {
setTimeout(() => this.dismiss(el), duration);
} }
} catch(err) {} },
dismiss(el) {
el.style.opacity = '0';
el.style.transform = 'translateX(100%)';
setTimeout(() => el.remove(), 300);
},
success(msg, dur) { this.show(msg, 'success', dur); },
error(msg, dur) { this.show(msg, 'error', dur); },
warning(msg, dur) { this.show(msg, 'warning', dur); },
info(msg, dur) { this.show(msg, 'info', dur); },
};
// ─── SSE (Server-Sent Events) — WITH PROPER ERROR HANDLING ───
window.sseManager = {
connection: null,
retryCount: 0,
maxRetries: 10,
baseRetryDelay: 3000, // 3 seconds
maxRetryDelay: 60000, // 60 seconds
retryTimer: null,
enabled: true,
init() {
// Only enable SSE if user is logged in (check for a body class or meta tag)
const isLoggedIn = document.body.classList.contains('logged-in') ||
document.querySelector('meta[name="user-id"]');
if (!isLoggedIn) {
return; // Don't even try SSE on login page
}
// Check if EventSource is supported
if (typeof EventSource === 'undefined') {
console.warn('[SSE] EventSource not supported in this browser.');
// Fall back to polling
this.startPolling();
return;
}
this.connect();
},
connect() {
if (!this.enabled) return;
if (this.connection) {
this.connection.close();
this.connection = null;
}
try {
this.connection = new EventSource('/sse/stream');
this.connection.onopen = () => {
console.log('[SSE] Connected.');
this.retryCount = 0; // Reset on successful connection
};
// Listen for specific events
this.connection.addEventListener('connected', (e) => {
try {
const data = JSON.parse(e.data);
console.log('[SSE] Stream established for user', data.user_id);
} catch (err) {}
}); });
evtSource.addEventListener('hud_update', function(e) { this.connection.addEventListener('notification_count', (e) => {
try { try {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
const hudAmount = document.querySelector('.hud-amount'); this.updateNotificationBadge(data.unread_count);
if (hudAmount) { if (data.has_blocking) {
hudAmount.textContent = `EGP ${Math.round(data.live_salary).toLocaleString()} / ${Math.round(data.actual_salary).toLocaleString()}`; this.handleBlockingNotification();
}
const bar = document.querySelector('.hud-bar');
if (bar && data.actual_salary > 0) {
const pct = Math.min(100, Math.max(0, (data.live_salary / data.actual_salary) * 100));
bar.style.width = pct + '%';
} }
} catch(err) {} } catch (err) {}
}); });
evtSource.addEventListener('blocking_notification', function(e) { this.connection.addEventListener('blocking_notification', (e) => {
try {
const data = JSON.parse(e.data);
// Redirect to blocking notification page
if (window.location.pathname !== '/notifications/blocking') {
window.location.href = '/notifications/blocking'; window.location.href = '/notifications/blocking';
}
} catch (err) {}
}); });
evtSource.addEventListener('toast', function(e) { this.connection.addEventListener('hud_update', (e) => {
try { try {
const data = JSON.parse(e.data); const data = JSON.parse(e.data);
showToast(data.message, data.type, data.duration || 5000); this.updateHud(data);
} catch(err) {} } catch (err) {}
}); });
evtSource.onerror = function() { this.connection.addEventListener('toast', (e) => {
evtSource.close(); try {
setTimeout(connectSSE, 5000); const data = JSON.parse(e.data);
window.toast.show(data.message, data.type, data.duration || 5000);
} catch (err) {}
});
this.connection.addEventListener('reconnect', () => {
// Server asked us to reconnect (lifetime expired)
this.connection.close();
this.connection = null;
// Reconnect after a short delay
setTimeout(() => this.connect(), 1000);
});
this.connection.onerror = (e) => {
// EventSource automatically reconnects, but we want to
// add exponential backoff for persistent errors
if (this.connection.readyState === EventSource.CLOSED) {
console.warn('[SSE] Connection closed. Retry', this.retryCount + 1);
this.connection = null;
this.scheduleRetry();
}
// If readyState is CONNECTING, EventSource is auto-retrying
// and we should let it do its thing for a while
}; };
} catch (err) {
console.error('[SSE] Failed to create EventSource:', err);
this.scheduleRetry();
} }
},
// ========== Search (Ctrl+K) ========== scheduleRetry() {
document.addEventListener('keydown', function(e) { if (this.retryTimer) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { clearTimeout(this.retryTimer);
e.preventDefault();
const searchInput = document.getElementById('global-search');
if (searchInput) searchInput.focus();
} }
});
const searchInput = document.getElementById('global-search'); this.retryCount++;
if (searchInput) {
let searchTimeout; if (this.retryCount > this.maxRetries) {
searchInput.addEventListener('input', function() { console.warn('[SSE] Max retries exceeded. Falling back to polling.');
clearTimeout(searchTimeout); this.enabled = false;
const q = this.value.trim(); this.startPolling();
if (q.length < 2) return; return;
searchTimeout = setTimeout(async () => {
const data = await apiFetch('/api/search?q=' + encodeURIComponent(q));
// Render search results dropdown
const modal = document.getElementById('search-modal');
if (modal && data.results) {
let html = '<div class="search-results">';
data.results.forEach(r => {
html += `<a href="${r.url}" class="search-result-item">
<span class="search-type">${r.type}</span>
<span class="search-title">${r.title}</span>
<span class="search-context">${r.context}</span>
</a>`;
});
if (data.results.length === 0) {
html += '<p class="text-muted" style="padding:12px">No results found.</p>';
} }
html += '</div>';
modal.innerHTML = html; // Exponential backoff: 3s, 6s, 12s, 24s, 48s, 60s (capped)
modal.style.display = 'block'; const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryCount - 1),
this.maxRetryDelay
);
console.log(`[SSE] Retrying in ${delay / 1000}s (attempt ${this.retryCount}/${this.maxRetries})`);
this.retryTimer = setTimeout(() => {
this.connect();
}, delay);
},
startPolling() {
// Fallback: poll notifications every 30 seconds
console.log('[SSE] Falling back to polling mode.');
setInterval(async () => {
try {
const resp = await window.api.get('/notifications/recent');
if (resp.ok) {
const data = await resp.json();
this.updateNotificationBadge(data.unread_count || 0);
} }
}, 300); } catch (e) {
}); // Silent fail — it's just polling
}
}, 30000);
},
searchInput.addEventListener('blur', function() { updateNotificationBadge(count) {
setTimeout(() => { const badges = document.querySelectorAll('.notification-badge, .notif-count');
const modal = document.getElementById('search-modal'); badges.forEach(badge => {
if (modal) modal.style.display = 'none'; if (count > 0) {
}, 200); badge.textContent = count > 99 ? '99+' : count;
badge.style.display = 'inline-flex';
} else {
badge.style.display = 'none';
}
}); });
},
handleBlockingNotification() {
if (window.location.pathname !== '/notifications/blocking') {
window.location.href = '/notifications/blocking';
} }
},
// ========== Notification Bell ========== updateHud(data) {
const notifBell = document.getElementById('notif-bell'); // Update HUD elements if they exist on the page
if (notifBell) { const hudSalary = document.getElementById('hud-live-salary');
notifBell.addEventListener('click', async function() { if (hudSalary) {
const dropdown = document.getElementById('notif-dropdown'); hudSalary.textContent = new Intl.NumberFormat('en-EG', {
if (!dropdown) return; minimumFractionDigits: 2,
if (dropdown.style.display === 'block') { maximumFractionDigits: 2,
dropdown.style.display = 'none'; }).format(data.live_salary);
return;
} }
const data = await apiFetch('/notifications/recent');
let html = '<div class="notif-dropdown-list">'; const hudBar = document.getElementById('hud-salary-bar');
(data.notifications || []).forEach(n => { if (hudBar) {
html += `<div class="notif-dropdown-item ${n.is_read ? '' : 'unread'}"> const pct = Math.min(Math.max(data.retention_pct, 0), 150);
<strong>${n.title}</strong> hudBar.style.width = Math.min(pct, 100) + '%';
<p>${n.content.substring(0, 80)}</p>
<small>${new Date(n.created_at).toLocaleString()}</small> hudBar.classList.remove('hud-healthy', 'hud-warning', 'hud-critical', 'hud-exceptional');
</div>`; if (pct > 100) hudBar.classList.add('hud-exceptional');
}); else if (pct >= 80) hudBar.classList.add('hud-healthy');
html += '<a href="/notifications" style="display:block;padding:8px;text-align:center">View All</a>'; else if (pct >= 60) hudBar.classList.add('hud-warning');
html += '</div>'; else hudBar.classList.add('hud-critical');
dropdown.innerHTML = html; }
dropdown.style.display = 'block'; },
dropdown.style.position = 'fixed';
dropdown.style.right = '80px'; destroy() {
dropdown.style.top = '50px'; this.enabled = false;
dropdown.style.width = '350px'; if (this.connection) {
dropdown.style.maxHeight = '400px'; this.connection.close();
dropdown.style.overflow = 'auto'; this.connection = null;
dropdown.style.background = 'var(--bg-card)'; }
dropdown.style.border = '1px solid var(--border)'; if (this.retryTimer) {
dropdown.style.borderRadius = 'var(--radius)'; clearTimeout(this.retryTimer);
dropdown.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'; }
dropdown.style.zIndex = '200'; },
}); };
// ─── THEME (Dark Mode) ───
window.themeManager = {
init() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
this.apply(theme);
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
this.apply(e.matches ? 'dark' : 'light');
} }
});
},
// ========== Initialize ========== apply(theme) {
document.addEventListener('DOMContentLoaded', function() { document.documentElement.setAttribute('data-theme', theme);
connectSSE(); document.body.classList.toggle('dark-mode', theme === 'dark');
},
// Load initial notification count toggle() {
apiFetch('/notifications/recent').then(data => { const current = document.documentElement.getAttribute('data-theme') || 'light';
const badge = document.getElementById('notif-count'); const next = current === 'dark' ? 'light' : 'dark';
if (badge && data.unread_count > 0) { localStorage.setItem('theme', next);
badge.textContent = data.unread_count; this.apply(next);
badge.style.display = 'flex'; },
};
// ─── CONFIRM DIALOGS ───
window.confirmAction = function(message, callback) {
if (confirm(message)) {
callback();
} }
}).catch(() => {}); };
// ─── INIT ON DOM READY ───
document.addEventListener('DOMContentLoaded', () => {
window.toast.init();
window.themeManager.init();
window.sseManager.init();
});
// ─── CLEANUP ON PAGE UNLOAD ───
window.addEventListener('beforeunload', () => {
window.sseManager.destroy();
}); });
})(); })();
\ No newline at end of file
<?php
/** @var array $user */
/** @var string $content */
$notifCount = $unread_count ?? 0;
?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-theme="<?= $__engine->e($user['theme_preference'] ?? 'light') ?>"> <html lang="en" data-theme="<?= htmlspecialchars($user['theme_preference'] ?? 'light') ?>">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $__engine->yield('title', 'AL-ARCADE HR Platform') ?></title> <meta name="csrf_token" content="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<?= $__engine->csrfMeta() ?> <meta name="user-id" content="<?= (int)($user['id'] ?? 0) ?>">
<title>The Grind — AL-ARCADE HR</title>
<link rel="stylesheet" href="/assets/css/app.css"> <link rel="stylesheet" href="/assets/css/app.css">
<?= $__engine->yield('head') ?> <link rel="stylesheet" href="/assets/css/dark-mode.css">
</head> </head>
<body class="<?= $__engine->yield('body_class', '') ?>"> <body class="logged-in <?= ($user['theme_preference'] ?? '') === 'dark' ? 'dark-mode' : '' ?>">
<?php if (isset($user) && $user): ?>
<nav class="top-nav"> <header class="top-nav">
<div class="nav-brand"> <div class="nav-left">
<a href="/dashboard">🎮 The Grind</a> <a href="/dashboard" class="nav-brand">🎮 <span>The Grind</span></a>
</div> <div class="search-bar">
<div class="nav-search">
<input type="text" id="global-search" placeholder="Search... (Ctrl+K)" autocomplete="off"> <input type="text" id="global-search" placeholder="Search... (Ctrl+K)" autocomplete="off">
</div> </div>
<div class="nav-actions">
<button class="nav-btn" id="notif-bell" title="Notifications">
🔔 <span class="badge" id="notif-count" style="display:none">0</span>
</button>
<a href="/messages" class="nav-btn" title="Messages">💬</a>
<div class="nav-user">
<span><?= $__engine->e($user['full_name_en']) ?></span>
<span class="role-badge"><?= $__engine->e(ucfirst(str_replace('_', ' ', $user['role']))) ?></span>
</div>
<form action="/logout" method="POST" style="display:inline">
<input type="hidden" name="_csrf_token" value="<?= $__engine->e($_COOKIE['csrf_token'] ?? '') ?>">
<button type="submit" class="nav-btn" title="Logout">🚪</button>
</form>
</div> </div>
</nav> <div class="nav-right">
<a href="/notifications" class="nav-icon" title="Notifications">
<?php if (isset($user['role']) && $user['role'] === 'contractor' && in_array($user['status'] ?? '', ['active','on_pip','suspended']) && isset($hud)): ?> 🔔
<div class="hud" id="salary-hud" data-color="<?= $__engine->e($hud['color_class'] ?? 'hud-healthy') ?>"> <span class="notification-badge notif-count" style="<?= $notifCount > 0 ? '' : 'display:none' ?>"><?= $notifCount ?></span>
<div class="hud-primary"> </a>
<span class="hud-month">💰 <?= $__engine->e($hud['month_label'] ?? date('F Y')) ?></span> <a href="/messages" class="nav-icon" title="Messages">💬</a>
<div class="hud-bar-container"> <div class="nav-user">
<div class="hud-bar" style="width: <?= min(100, max(0, $hud['retention_pct'] ?? 100)) ?>%"></div> <span class="nav-user-name"><?= htmlspecialchars($user['full_name_en'] ?? 'User') ?></span>
</div> <span class="nav-user-role"><?= strtoupper(str_replace('_', ' ', $user['role'] ?? '')) ?></span>
<span class="hud-amount">
EGP <?= number_format($hud['live_salary'] ?? 0, 0) ?> / <?= number_format($hud['actual_salary'] ?? 0, 0) ?>
</span>
</div> </div>
<div class="hud-secondary"> <a href="/users/<?= (int)($user['id'] ?? 0) ?>" class="nav-avatar" title="Profile">
<?php if (($hud['deduction_count'] ?? 0) > 0): ?> <?php if (!empty($user['profile_photo_id'])): ?>
<span class="hud-deductions"><?= $hud['deduction_count'] ?> deductions (-<?= number_format($hud['total_deductions'] ?? 0, 0) ?>)</span> <img src="/uploads/photos/<?= (int)$user['profile_photo_id'] ?>" alt="Profile">
<?php endif; ?> <?php else: ?>
<?php if (($hud['bounty_count'] ?? 0) > 0): ?> 🧑
<span class="hud-bounties"><?= $hud['bounty_count'] ?> bounties (+<?= number_format($hud['total_bounties'] ?? 0, 0) ?>)</span>
<?php endif; ?> <?php endif; ?>
<span class="hud-health"><?= $hud['health']['icon'] ?? '🟢' ?> <?= $hud['health']['label'] ?? 'Healthy' ?></span> </a>
</div>
</div> </div>
</header>
<nav class="sidebar">
<ul>
<li><a href="/dashboard" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/dashboard') === 0 ? 'active' : '' ?>">📊 Dashboard</a></li>
<li><a href="/boards" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/boards') === 0 ? 'active' : '' ?>">📋 Boards</a></li>
<li><a href="/reports/submit" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/reports') === 0 ? 'active' : '' ?>">📝 Reports</a></li>
<li><a href="/users" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/users') === 0 ? 'active' : '' ?>">👥 Directory</a></li>
<li><a href="/messages" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/messages') === 0 ? 'active' : '' ?>">💬 Messages</a></li>
<li><a href="/notifications" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/notifications') === 0 ? 'active' : '' ?>">🔔 Notifications</a></li>
<?php if (in_array($user['role'] ?? '', ['super_admin', 'admin'])): ?>
<li class="sidebar-divider"></li>
<li><a href="/deductions" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/deductions') === 0 ? 'active' : '' ?>">⚠️ Deductions</a></li>
<li><a href="/payroll" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/payroll') === 0 ? 'active' : '' ?>">💰 Payroll</a></li>
<li><a href="/evaluations" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/evaluations') === 0 ? 'active' : '' ?>">📊 Evaluations</a></li>
<li><a href="/pips" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/pips') === 0 ? 'active' : '' ?>">📈 PIPs</a></li>
<li><a href="/invites" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/invites') === 0 ? 'active' : '' ?>">📨 Invites</a></li>
<li><a href="/analytics" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/analytics') === 0 ? 'active' : '' ?>">📈 Analytics</a></li>
<?php endif; ?> <?php endif; ?>
<main class="main-content"> <?php if (($user['role'] ?? '') === 'super_admin'): ?>
<?= $__engine->content() ?> <li class="sidebar-divider"></li>
</main> <li><a href="/control-panel" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/control-panel') === 0 ? 'active' : '' ?>">⚙️ Control Panel</a></li>
<?php else: ?> <li><a href="/settings" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/settings') === 0 ? 'active' : '' ?>">🔧 Settings</a></li>
<?= $__engine->content() ?> <li><a href="/audit-trail" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/audit-trail') === 0 ? 'active' : '' ?>">📜 Audit Trail</a></li>
<li><a href="/api-keys" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/api-keys') === 0 ? 'active' : '' ?>">🔑 API Keys</a></li>
<li><a href="/webhooks" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/webhooks') === 0 ? 'active' : '' ?>">🔗 Webhooks</a></li>
<li><a href="/system-health" class="<?= strpos($_SERVER['REQUEST_URI'] ?? '', '/system-health') === 0 ? 'active' : '' ?>">🏥 System Health</a></li>
<?php endif; ?> <?php endif; ?>
<div id="toast-container"></div> <li class="sidebar-divider"></li>
<div id="search-modal" class="modal" style="display:none"></div> <li>
<div id="notif-dropdown" class="dropdown" style="display:none"></div> <form action="/logout" method="POST" style="margin:0">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>">
<button type="submit" class="sidebar-link-btn">🚪 Logout</button>
</form>
</li>
</ul>
</nav>
<main class="main-content">
<?= $content ?? '' ?>
</main>
<div id="toast-container"></div>
<script src="/assets/js/app.js"></script> <script src="/assets/js/app.js"></script>
<?= $__engine->yield('scripts') ?> <?php if (isset($extra_js)): ?>
<?php foreach ((array)$extra_js as $js): ?>
<script src="<?= htmlspecialchars($js) ?>"></script>
<?php endforeach; ?>
<?php endif; ?>
</body> </body>
</html> </html>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment