Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
P
phphr
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
phphr
Commits
7b9f7368
Commit
7b9f7368
authored
Apr 08, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 4 files via Son of Anton
parent
af73d3c0
Pipeline
#23
canceled with stage
Changes
4
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
663 additions
and
324 deletions
+663
-324
SSEController.php
engine/RealTime/SSEController.php
+228
-92
routes.php
modules/SSE/routes.php
+1
-0
app.js
public/assets/js/app.js
+352
-175
app.php
templates/layouts/app.php
+82
-57
No files found.
engine/RealTime/SSEController.php
View file @
7b9f7368
...
@@ -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: *'
);
// ──────────────────────────────────────────────
// 4. REMOVE EXECUTION TIME LIMIT
// SSE connections are long-lived. Default 30s timeout = death.
// ──────────────────────────────────────────────
set_time_limit
(
0
);
ignore_user_abort
(
false
);
// ──────────────────────────────────────────────
// 5. GET DATABASE CONNECTION
// 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
\n
data: {\"
message
\
":
\"
Database unavailable
\"
}
\n\n
"
;
flush
();
return
new
Response
(
''
,
200
);
}
// Send initial connection event
$this
->
sendEvent
(
'connected'
,
[
'status'
=>
'ok'
,
'user_id'
=>
$userId
]);
$lastCheck
=
time
();
$lastNotificationCheck
=
0
;
$timeout
=
30
;
$lastHudCheck
=
0
;
$start
=
time
();
$iteration
=
0
;
$maxLifetime
=
30
;
// seconds — then let the client reconnect
$startTime
=
time
();
// ──────────────────────────────────────────────
// 6. THE MAIN LOOP
// ──────────────────────────────────────────────
while
(
true
)
{
while
(
true
)
{
if
(
connection_aborted
())
break
;
// Check if client disconnected
if
((
time
()
-
$start
)
>
$timeout
)
break
;
if
(
connection_aborted
())
{
break
;
}
// Check for new notifications
// Check if we've exceeded our lifetime
$unreadCount
=
$this
->
notifications
->
getUnreadCount
(
$user
[
'id'
]);
if
((
time
()
-
$startTime
)
>=
$maxLifetime
)
{
$hasBlocking
=
$this
->
notifications
->
hasBlocking
(
$user
[
'id'
]);
$this
->
sendEvent
(
'reconnect'
,
[
'reason'
=>
'lifetime'
]);
break
;
}
echo
"event: notification_count
\n
"
;
$now
=
time
();
echo
"data: "
.
json_encode
([
'unread_count'
=>
$unreadCount
,
'has_blocking'
=>
$hasBlocking
])
.
"
\n\n
"
;
if
(
$hasBlocking
)
{
try
{
$blocking
=
$this
->
notifications
->
getBlockingUnacknowledged
(
$user
[
'id'
]);
// Check for new notifications every 3 seconds
if
(
!
empty
(
$blocking
)
)
{
if
(
(
$now
-
$lastNotificationCheck
)
>=
3
)
{
echo
"event: blocking_notification
\n
"
;
$lastNotificationCheck
=
$now
;
echo
"data: "
.
json_encode
(
$blocking
[
0
])
.
"
\n\n
"
;
$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
(
$actualSalary
=
(
float
)(
$user
[
'actual_salary'
]
??
0
);
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND is_read = 0"
,
[
$userId
]
$totalBounties
=
(
float
)
$this
->
db
->
fetchColumn
(
);
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?"
,
[
$userId
,
$month
]
$hasBlocking
=
(
int
)
$db
->
fetchColumn
(
);
"SELECT COUNT(*) FROM notifications WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0"
,
[
$userId
]
$totalDeductions
=
(
float
)
$this
->
db
->
fetchColumn
(
);
"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"
,
$this
->
sendEvent
(
'notification_count'
,
[
[
$userId
,
$month
]
'unread_count'
=>
$unreadCount
,
);
'has_blocking'
=>
$hasBlocking
>
0
,
]);
$totalPosAdj
=
(
float
)
$this
->
db
->
fetchColumn
(
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
// If there's a blocking notification, send its details
WHERE contractor_id = ? AND effective_month = ? AND type = 'positive' AND status = 'approved' AND deleted_at IS NULL"
,
if
(
$hasBlocking
>
0
)
{
[
$userId
,
$month
]
$blocking
=
$db
->
fetchOne
(
);
"SELECT id, title, content, link_url FROM notifications
WHERE user_id = ? AND tier = 'blocking' AND is_acknowledged = 0
$totalNegAdj
=
(
float
)
$this
->
db
->
fetchColumn
(
ORDER BY created_at ASC LIMIT 1"
,
"SELECT COALESCE(SUM(amount), 0) FROM manual_adjustments
[
$userId
]
WHERE contractor_id = ? AND effective_month = ? AND type = 'negative' AND status = 'approved' AND deleted_at IS NULL"
,
);
[
$userId
,
$month
]
if
(
$blocking
)
{
);
$this
->
sendEvent
(
'blocking_notification'
,
$blocking
);
}
$liveSalary
=
$actualSalary
+
$totalBounties
+
$totalPosAdj
-
$totalDeductions
-
$totalNegAdj
;
}
}
catch
(
\Throwable
$e
)
{
$deductionCount
=
(
int
)
$this
->
db
->
fetchColumn
(
// Swallow — next iteration will retry
"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
]
}
);
private
function
checkHud
(
Connection
$db
,
int
$userId
)
:
void
$bountyCount
=
(
int
)
$this
->
db
->
fetchColumn
(
{
"SELECT COUNT(*) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?"
,
try
{
[
$userId
,
$month
]
$user
=
$db
->
fetchOne
(
);
"SELECT actual_salary, role, status FROM users WHERE id = ?"
,
[
$userId
]
return
[
);
'actual_salary'
=>
$actualSalary
,
'live_salary'
=>
$liveSalary
,
if
(
!
$user
||
$user
[
'role'
]
!==
'contractor'
||
!
in_array
(
$user
[
'status'
],
[
'active'
,
'on_pip'
,
'suspended'
]))
{
'total_bounties'
=>
$totalBounties
,
return
;
// HUD not applicable
'total_deductions'
=>
$totalDeductions
,
}
'total_pos_adj'
=>
$totalPosAdj
,
'total_neg_adj'
=>
$totalNegAdj
,
$actualSalary
=
(
float
)(
$user
[
'actual_salary'
]
??
0
);
'deduction_count'
=>
$deductionCount
,
if
(
$actualSalary
<=
0
)
return
;
'bounty_count'
=>
$bountyCount
,
'month'
=>
$month
,
$month
=
date
(
'Y-m'
);
];
$bounties
=
(
float
)
$db
->
fetchColumn
(
"SELECT COALESCE(SUM(amount), 0) FROM bounty_payouts WHERE recipient_id = ? AND payroll_month = ?"
,
[
$userId
,
$month
]
);
$deductions
=
(
float
)
$db
->
fetchColumn
(
"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"
,
[
$userId
,
$month
]
);
$posAdj
=
(
float
)
$db
->
fetchColumn
(
"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"
,
[
$userId
,
$month
]
);
$negAdj
=
(
float
)
$db
->
fetchColumn
(
"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"
,
[
$userId
,
$month
]
);
$deductionCount
=
(
int
)
$db
->
fetchColumn
(
"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
]
);
$liveSalary
=
$actualSalary
+
$bounties
+
$posAdj
-
$deductions
-
$negAdj
;
$retentionPct
=
$actualSalary
>
0
?
(
$liveSalary
/
$actualSalary
)
*
100
:
100
;
$this
->
sendEvent
(
'hud_update'
,
[
'actual_salary'
=>
$actualSalary
,
'live_salary'
=>
round
(
$liveSalary
,
2
),
'total_bounties'
=>
round
(
$bounties
,
2
),
'total_deductions'
=>
round
(
$deductions
,
2
),
'deduction_count'
=>
$deductionCount
,
'retention_pct'
=>
round
(
$retentionPct
,
1
),
'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
modules/SSE/routes.php
View file @
7b9f7368
...
@@ -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
public/assets/js/app.js
View file @
7b9f7368
/*
*
/*
============================================================================
* 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
]
||
''
;
}
// ─── API HELPER ───
window
.
api
=
{
// ========== Fetch Helper ==========
async
fetch
(
url
,
options
=
{})
{
async
function
apiFetch
(
url
,
options
=
{})
{
const
defaults
=
{
const
defaults
=
{
headers
:
{
headers
:
{
'Content-Type'
:
'application/json'
,
'Content-Type'
:
'application/json'
,
'X-CSRF-Token'
:
csrfToken
,
'X-CSRF-Token'
:
getCsrfToken
(),
'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
)
{
const
response
=
await
fetch
(
url
,
config
);
config
.
headers
=
{
...
defaults
.
headers
,
...
options
.
headers
};
return
response
.
json
();
}
}
const
response
=
await
fetch
(
url
,
config
);
if
(
response
.
status
===
401
)
{
// ========== Toast System ==========
window
.
location
.
href
=
'/login'
;
window
.
showToast
=
function
(
message
,
type
=
'info'
,
duration
=
5000
)
{
throw
new
Error
(
'Unauthorized'
);
const
container
=
document
.
getElementById
(
'toast-container'
);
}
if
(
!
container
)
return
;
return
response
;
const
toast
=
document
.
createElement
(
'div'
);
},
toast
.
className
=
`toast toast-
${
type
}
`
;
toast
.
textContent
=
message
;
async
get
(
url
)
{
container
.
appendChild
(
toast
);
return
this
.
fetch
(
url
);
setTimeout
(()
=>
{
},
toast
.
style
.
opacity
=
'0'
;
toast
.
style
.
transform
=
'translateX(100%)'
;
async
post
(
url
,
data
)
{
setTimeout
(()
=>
toast
.
remove
(),
300
);
return
this
.
fetch
(
url
,
{
},
duration
);
method
:
'POST'
,
body
:
JSON
.
stringify
(
data
),
});
},
async
put
(
url
,
data
)
{
return
this
.
fetch
(
url
,
{
method
:
'PUT'
,
body
:
JSON
.
stringify
(
data
),
});
},
async
delete
(
url
)
{
return
this
.
fetch
(
url
,
{
method
:
'DELETE'
});
},
};
// ─── TOAST NOTIFICATIONS ───
window
.
toast
=
{
container
:
null
,
init
()
{
this
.
container
=
document
.
getElementById
(
'toast-container'
);
if
(
!
this
.
container
)
{
this
.
container
=
document
.
createElement
(
'div'
);
this
.
container
.
id
=
'toast-container'
;
this
.
container
.
style
.
cssText
=
'position:fixed;top:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;max-width:400px;'
;
document
.
body
.
appendChild
(
this
.
container
);
}
},
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
);
}
},
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 Connection ==========
// ─── SSE (Server-Sent Events) — WITH PROPER ERROR HANDLING ───
function
connectSSE
()
{
window
.
sseManager
=
{
if
(
typeof
EventSource
===
'undefined'
)
return
;
connection
:
null
,
const
evtSource
=
new
EventSource
(
'/sse/stream'
);
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
;
}
evtSource
.
addEventListener
(
'notification_count'
,
function
(
e
)
{
try
{
try
{
const
data
=
JSON
.
parse
(
e
.
data
);
this
.
connection
=
new
EventSource
(
'/sse/stream'
);
const
badge
=
document
.
getElementById
(
'notif-count'
);
if
(
badge
)
{
this
.
connection
.
onopen
=
()
=>
{
if
(
data
.
unread_count
>
0
)
{
console
.
log
(
'[SSE] Connected.'
);
badge
.
textContent
=
data
.
unread_count
;
this
.
retryCount
=
0
;
// Reset on successful connection
badge
.
style
.
display
=
'flex'
;
};
}
else
{
badge
.
style
.
display
=
'none'
;
// 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
)
{}
});
this
.
connection
.
addEventListener
(
'notification_count'
,
(
e
)
=>
{
try
{
const
data
=
JSON
.
parse
(
e
.
data
);
this
.
updateNotificationBadge
(
data
.
unread_count
);
if
(
data
.
has_blocking
)
{
this
.
handleBlockingNotification
();
}
}
catch
(
err
)
{}
});
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'
;
}
}
catch
(
err
)
{}
});
this
.
connection
.
addEventListener
(
'hud_update'
,
(
e
)
=>
{
try
{
const
data
=
JSON
.
parse
(
e
.
data
);
this
.
updateHud
(
data
);
}
catch
(
err
)
{}
});
this
.
connection
.
addEventListener
(
'toast'
,
(
e
)
=>
{
try
{
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
();
}
},
scheduleRetry
()
{
if
(
this
.
retryTimer
)
{
clearTimeout
(
this
.
retryTimer
);
}
this
.
retryCount
++
;
if
(
this
.
retryCount
>
this
.
maxRetries
)
{
console
.
warn
(
'[SSE] Max retries exceeded. Falling back to polling.'
);
this
.
enabled
=
false
;
this
.
startPolling
();
return
;
}
// Exponential backoff: 3s, 6s, 12s, 24s, 48s, 60s (capped)
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
);
}
}
catch
(
e
)
{
// Silent fail — it's just polling
}
}
}
catch
(
err
)
{}
}
,
30000
);
}
);
}
,
evtSource
.
addEventListener
(
'hud_update'
,
function
(
e
)
{
updateNotificationBadge
(
count
)
{
try
{
const
badges
=
document
.
querySelectorAll
(
'.notification-badge, .notif-count'
);
const
data
=
JSON
.
parse
(
e
.
data
);
badges
.
forEach
(
badge
=>
{
const
hudAmount
=
document
.
querySelector
(
'.hud-amount'
);
if
(
count
>
0
)
{
if
(
hudAmount
)
{
badge
.
textContent
=
count
>
99
?
'99+'
:
count
;
hudAmount
.
textContent
=
`EGP
${
Math
.
round
(
data
.
live_salary
).
toLocaleString
()}
/
${
Math
.
round
(
data
.
actual_salary
).
toLocaleString
()}
`
;
badge
.
style
.
display
=
'inline-flex'
;
}
else
{
badge
.
style
.
display
=
'none'
;
}
}
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
+
'%'
;
handleBlockingNotification
()
{
if
(
window
.
location
.
pathname
!==
'/notifications/blocking'
)
{
window
.
location
.
href
=
'/notifications/blocking'
;
}
},
updateHud
(
data
)
{
// Update HUD elements if they exist on the page
const
hudSalary
=
document
.
getElementById
(
'hud-live-salary'
);
if
(
hudSalary
)
{
hudSalary
.
textContent
=
new
Intl
.
NumberFormat
(
'en-EG'
,
{
minimumFractionDigits
:
2
,
maximumFractionDigits
:
2
,
}).
format
(
data
.
live_salary
);
}
const
hudBar
=
document
.
getElementById
(
'hud-salary-bar'
);
if
(
hudBar
)
{
const
pct
=
Math
.
min
(
Math
.
max
(
data
.
retention_pct
,
0
),
150
);
hudBar
.
style
.
width
=
Math
.
min
(
pct
,
100
)
+
'%'
;
hudBar
.
classList
.
remove
(
'hud-healthy'
,
'hud-warning'
,
'hud-critical'
,
'hud-exceptional'
);
if
(
pct
>
100
)
hudBar
.
classList
.
add
(
'hud-exceptional'
);
else
if
(
pct
>=
80
)
hudBar
.
classList
.
add
(
'hud-healthy'
);
else
if
(
pct
>=
60
)
hudBar
.
classList
.
add
(
'hud-warning'
);
else
hudBar
.
classList
.
add
(
'hud-critical'
);
}
},
destroy
()
{
this
.
enabled
=
false
;
if
(
this
.
connection
)
{
this
.
connection
.
close
();
this
.
connection
=
null
;
}
if
(
this
.
retryTimer
)
{
clearTimeout
(
this
.
retryTimer
);
}
},
};
// ─── 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'
);
}
}
}
catch
(
err
)
{}
}
);
}
);
}
,
evtSource
.
addEventListener
(
'blocking_notification'
,
function
(
e
)
{
apply
(
theme
)
{
window
.
location
.
href
=
'/notifications/blocking'
;
document
.
documentElement
.
setAttribute
(
'data-theme'
,
theme
);
});
document
.
body
.
classList
.
toggle
(
'dark-mode'
,
theme
===
'dark'
);
},
evtSource
.
addEventListener
(
'toast'
,
function
(
e
)
{
toggle
()
{
try
{
const
current
=
document
.
documentElement
.
getAttribute
(
'data-theme'
)
||
'light'
;
const
data
=
JSON
.
parse
(
e
.
data
);
const
next
=
current
===
'dark'
?
'light'
:
'dark'
;
showToast
(
data
.
message
,
data
.
type
,
data
.
duration
||
5000
);
localStorage
.
setItem
(
'theme'
,
next
);
}
catch
(
err
)
{}
this
.
apply
(
next
);
});
},
};
evtSource
.
onerror
=
function
()
{
evtSource
.
close
();
// ─── CONFIRM DIALOGS ───
setTimeout
(
connectSSE
,
5000
);
window
.
confirmAction
=
function
(
message
,
callback
)
{
};
if
(
confirm
(
message
))
{
}
callback
();
// ========== Search (Ctrl+K) ==========
document
.
addEventListener
(
'keydown'
,
function
(
e
)
{
if
((
e
.
ctrlKey
||
e
.
metaKey
)
&&
e
.
key
===
'k'
)
{
e
.
preventDefault
();
const
searchInput
=
document
.
getElementById
(
'global-search'
);
if
(
searchInput
)
searchInput
.
focus
();
}
}
};
// ─── INIT ON DOM READY ───
document
.
addEventListener
(
'DOMContentLoaded'
,
()
=>
{
window
.
toast
.
init
();
window
.
themeManager
.
init
();
window
.
sseManager
.
init
();
});
});
const
searchInput
=
document
.
getElementById
(
'global-search'
);
// ─── CLEANUP ON PAGE UNLOAD ───
if
(
searchInput
)
{
window
.
addEventListener
(
'beforeunload'
,
()
=>
{
let
searchTimeout
;
window
.
sseManager
.
destroy
();
searchInput
.
addEventListener
(
'input'
,
function
()
{
clearTimeout
(
searchTimeout
);
const
q
=
this
.
value
.
trim
();
if
(
q
.
length
<
2
)
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
;
modal
.
style
.
display
=
'block'
;
}
},
300
);
});
searchInput
.
addEventListener
(
'blur'
,
function
()
{
setTimeout
(()
=>
{
const
modal
=
document
.
getElementById
(
'search-modal'
);
if
(
modal
)
modal
.
style
.
display
=
'none'
;
},
200
);
});
}
// ========== Notification Bell ==========
const
notifBell
=
document
.
getElementById
(
'notif-bell'
);
if
(
notifBell
)
{
notifBell
.
addEventListener
(
'click'
,
async
function
()
{
const
dropdown
=
document
.
getElementById
(
'notif-dropdown'
);
if
(
!
dropdown
)
return
;
if
(
dropdown
.
style
.
display
===
'block'
)
{
dropdown
.
style
.
display
=
'none'
;
return
;
}
const
data
=
await
apiFetch
(
'/notifications/recent'
);
let
html
=
'<div class="notif-dropdown-list">'
;
(
data
.
notifications
||
[]).
forEach
(
n
=>
{
html
+=
`<div class="notif-dropdown-item
${
n
.
is_read
?
''
:
'unread'
}
">
<strong>
${
n
.
title
}
</strong>
<p>
${
n
.
content
.
substring
(
0
,
80
)}
</p>
<small>
${
new
Date
(
n
.
created_at
).
toLocaleString
()}
</small>
</div>`
;
});
html
+=
'<a href="/notifications" style="display:block;padding:8px;text-align:center">View All</a>'
;
html
+=
'</div>'
;
dropdown
.
innerHTML
=
html
;
dropdown
.
style
.
display
=
'block'
;
dropdown
.
style
.
position
=
'fixed'
;
dropdown
.
style
.
right
=
'80px'
;
dropdown
.
style
.
top
=
'50px'
;
dropdown
.
style
.
width
=
'350px'
;
dropdown
.
style
.
maxHeight
=
'400px'
;
dropdown
.
style
.
overflow
=
'auto'
;
dropdown
.
style
.
background
=
'var(--bg-card)'
;
dropdown
.
style
.
border
=
'1px solid var(--border)'
;
dropdown
.
style
.
borderRadius
=
'var(--radius)'
;
dropdown
.
style
.
boxShadow
=
'0 4px 12px rgba(0,0,0,0.15)'
;
dropdown
.
style
.
zIndex
=
'200'
;
});
}
// ========== Initialize ==========
document
.
addEventListener
(
'DOMContentLoaded'
,
function
()
{
connectSSE
();
// Load initial notification count
apiFetch
(
'/notifications/recent'
).
then
(
data
=>
{
const
badge
=
document
.
getElementById
(
'notif-count'
);
if
(
badge
&&
data
.
unread_count
>
0
)
{
badge
.
textContent
=
data
.
unread_count
;
badge
.
style
.
display
=
'flex'
;
}
}).
catch
(()
=>
{});
});
});
})();
})();
\ No newline at end of file
templates/layouts/app.php
View file @
7b9f7368
<?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"
>
</div>
<button
class=
"nav-btn"
id=
"notif-bell"
title=
"Notifications"
>
<div
class=
"nav-right"
>
🔔
<span
class=
"badge"
id=
"notif-count"
style=
"display:none"
>
0
</span>
<a
href=
"/notifications"
class=
"nav-icon"
title=
"Notifications"
>
</button>
🔔
<a
href=
"/messages"
class=
"nav-btn"
title=
"Messages"
>
💬
</a>
<span
class=
"notification-badge notif-count"
style=
"
<?=
$notifCount
>
0
?
''
:
'display:none'
?>
"
>
<?=
$notifCount
?>
</span>
<div
class=
"nav-user"
>
</a>
<span>
<?=
$__engine
->
e
(
$user
[
'full_name_en'
])
?>
</span>
<a
href=
"/messages"
class=
"nav-icon"
title=
"Messages"
>
💬
</a>
<span
class=
"role-badge"
>
<?=
$__engine
->
e
(
ucfirst
(
str_replace
(
'_'
,
' '
,
$user
[
'role'
])))
?>
</span>
<div
class=
"nav-user"
>
</div>
<span
class=
"nav-user-name"
>
<?=
htmlspecialchars
(
$user
[
'full_name_en'
]
??
'User'
)
?>
</span>
<form
action=
"/logout"
method=
"POST"
style=
"display:inline"
>
<span
class=
"nav-user-role"
>
<?=
strtoupper
(
str_replace
(
'_'
,
' '
,
$user
[
'role'
]
??
''
))
?>
</span>
<input
type=
"hidden"
name=
"_csrf_token"
value=
"
<?=
$__engine
->
e
(
$_COOKIE
[
'csrf_token'
]
??
''
)
?>
"
>
<button
type=
"submit"
class=
"nav-btn"
title=
"Logout"
>
🚪
</button>
</form>
</div>
</nav>
<?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'
)
?>
"
>
<div
class=
"hud-primary"
>
<span
class=
"hud-month"
>
💰
<?=
$__engine
->
e
(
$hud
[
'month_label'
]
??
date
(
'F Y'
))
?>
</span>
<div
class=
"hud-bar-container"
>
<div
class=
"hud-bar"
style=
"width:
<?=
min
(
100
,
max
(
0
,
$hud
[
'retention_pct'
]
??
100
))
?>
%"
></div>
</div>
<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>
<?php
endif
;
?>
</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
if
((
$user
[
'role'
]
??
''
)
===
'super_admin'
)
:
?>
<li
class=
"sidebar-divider"
></li>
<li><a
href=
"/control-panel"
class=
"
<?=
strpos
(
$_SERVER
[
'REQUEST_URI'
]
??
''
,
'/control-panel'
)
===
0
?
'active'
:
''
?>
"
>
⚙️ Control Panel
</a></li>
<li><a
href=
"/settings"
class=
"
<?=
strpos
(
$_SERVER
[
'REQUEST_URI'
]
??
''
,
'/settings'
)
===
0
?
'active'
:
''
?>
"
>
🔧 Settings
</a></li>
<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
;
?>
<li
class=
"sidebar-divider"
></li>
<li>
<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"
>
<main
class=
"main-content"
>
<?=
$__engine
->
content
()
?>
<?=
$content
??
''
?>
</main>
</main>
<?php
else
:
?>
<?=
$__engine
->
content
()
?>
<?php
endif
;
?>
<div
id=
"toast-container"
></div>
<div
id=
"toast-container"
></div>
<div
id=
"search-modal"
class=
"modal"
style=
"display:none"
></div>
<div
id=
"notif-dropdown"
class=
"dropdown"
style=
"display:none"
></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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment