2026-03-04 19:42:38 -06:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-03-04 22:58:00 -06:00
< meta charset = "UTF-8" >
2026-03-04 19:42:38 -06:00
< title > UniFi Access Attendance< / title >
2026-03-04 22:58:00 -06:00
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-03-04 19:42:38 -06:00
< style >
2026-03-04 22:58:00 -06:00
* , * :: before , * :: after { box-sizing : border-box ; margin : 0 ; padding : 0 ; }
2026-03-04 19:42:38 -06:00
: root {
2026-03-04 22:58:00 -06:00
--bg : #050508 ; --bg-card : #111113 ; --gold : #d4af37 ; --gold-soft : #b89630 ;
--text : #f5f5f5 ; --muted : #888 ; --danger : #ff4d4f ; --success : #2ecc71 ;
2026-05-28 00:39:46 -05:00
--warn : #f39c12 ; --border : #222 ; --blue : #64b4ff ;
2026-03-04 19:42:38 -06:00
}
body {
2026-03-04 22:58:00 -06:00
background : radial-gradient ( circle at top , #18181c 0 % , #050508 55 % ) ;
2026-03-04 19:42:38 -06:00
color : var ( - - text ) ;
font-family : system-ui , - apple-system , BlinkMacSystemFont , "Segoe UI" , sans-serif ;
2026-03-04 22:58:00 -06:00
min-height : 100 vh ; display : flex ; justify-content : center ; padding : 32 px 16 px ;
2026-03-04 19:42:38 -06:00
}
. app-shell {
2026-03-04 19:53:39 -06:00
width : 100 % ; max-width : 1280 px ;
2026-03-04 19:42:38 -06:00
background : rgba ( 5 , 5 , 8 , 0.96 ) ;
2026-03-04 22:58:00 -06:00
border-radius : 20 px ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.3 ) ;
2026-03-04 19:42:38 -06:00
box-shadow : 0 32 px 80 px rgba ( 0 , 0 , 0 , 0.7 ) ;
padding : 24 px 24 px 32 px ;
}
2026-03-04 22:58:00 -06:00
header { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 20 px ; gap : 16 px ; }
2026-03-04 19:42:38 -06:00
. title-block { display : flex ; flex-direction : column ; gap : 4 px ; }
2026-03-04 19:53:39 -06:00
h1 { font-size : 1.6 rem ; letter-spacing : 0.08 em ; text-transform : uppercase ; color : var ( - - gold ) ; }
2026-03-04 19:42:38 -06:00
. subtitle { font-size : 0.9 rem ; color : var ( - - muted ) ; }
. badge {
2026-03-04 22:58:00 -06:00
border-radius : 999 px ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.5 ) ;
padding : 4 px 10 px ; font-size : 0.75 rem ; text-transform : uppercase ;
letter-spacing : 0.12 em ; color : var ( - - gold - soft ) ;
background : linear-gradient ( 90 deg , rgba ( 212 , 175 , 55 , 0.1 ) , rgba ( 212 , 175 , 55 , 0.02 ) ) ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 22:58:00 -06:00
. controls { display : flex ; flex-wrap : wrap ; gap : 12 px ; margin-bottom : 18 px ; align-items : center ; }
2026-03-04 19:42:38 -06:00
. control-group { display : flex ; flex-wrap : wrap ; gap : 10 px ; align-items : center ; }
2026-03-04 19:53:39 -06:00
. spacer { flex : 1 ; }
2026-03-04 19:42:38 -06:00
label { font-size : 0.8 rem ; letter-spacing : 0.1 em ; text-transform : uppercase ; color : var ( - - muted ) ; }
2026-05-28 00:39:46 -05:00
input , select {
2026-03-04 22:58:00 -06:00
background : var ( - - bg - card ) ; border-radius : 999 px ; border : 1 px solid var ( - - border ) ;
padding : 8 px 14 px ; font-size : 0.9 rem ; color : var ( - - text ) ; outline : none ; min-width : 130 px ;
2026-03-04 19:42:38 -06:00
}
2026-05-28 00:39:46 -05:00
input : focus , select : focus { border-color : var ( - - gold - soft ) ; box-shadow : 0 0 0 1 px rgba ( 212 , 175 , 55 , 0.4 ) ; }
2026-03-04 19:42:38 -06:00
button {
2026-03-04 22:58:00 -06:00
border-radius : 999 px ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.7 ) ;
background : radial-gradient ( circle at top , rgba ( 212 , 175 , 55 , 0.35 ) , rgba ( 2 , 2 , 4 , 0.95 ) ) ;
color : var ( - - text ) ; padding : 8 px 18 px ; font-size : 0.85 rem ;
letter-spacing : 0.1 em ; text-transform : uppercase ; cursor : pointer ;
transition : transform 0.08 s , box-shadow 0.08 s ; white-space : nowrap ;
2026-03-04 19:42:38 -06:00
}
button : hover { transform : translateY ( -1 px ) ; box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.5 ) ; }
2026-03-04 19:53:39 -06:00
button : active { transform : translateY ( 1 px ) ; box-shadow : none ; }
button : disabled { opacity : 0.45 ; cursor : default ; transform : none ; }
2026-05-28 00:39:46 -05:00
. sync-btn { border-color : rgba ( 100 , 180 , 255 , 0.6 ) ; background : radial-gradient ( circle at top , rgba ( 100 , 180 , 255 , 0.18 ) , rgba ( 2 , 2 , 4 , 0.95 ) ) ; }
. controllers-btn { border-color : rgba ( 160 , 120 , 255 , 0.6 ) ; background : radial-gradient ( circle at top , rgba ( 160 , 120 , 255 , 0.18 ) , rgba ( 2 , 2 , 4 , 0.95 ) ) ; }
. reset-btn { border-color : rgba ( 255 , 100 , 100 , 0.6 ) ; background : radial-gradient ( circle at top , rgba ( 255 , 80 , 80 , 0.18 ) , rgba ( 2 , 2 , 4 , 0.95 ) ) ; }
. small-btn { padding : 6 px 12 px ; font-size : 0.75 rem ; }
2026-03-04 22:58:00 -06:00
. summary-row { display : flex ; flex-wrap : wrap ; gap : 12 px ; margin-bottom : 16 px ; font-size : 0.85 rem ; }
. summary-pill { display : inline-flex ; align-items : center ; gap : 8 px ; background : var ( - - bg - card ) ; border-radius : 999 px ; padding : 6 px 14 px ; border : 1 px solid var ( - - border ) ; color : var ( - - muted ) ; }
2026-03-04 19:42:38 -06:00
. dot { width : 9 px ; height : 9 px ; border-radius : 50 % ; }
. dot . on { background : var ( - - success ) ; box-shadow : 0 0 10 px rgba ( 46 , 204 , 113 , 0.7 ) ; }
. dot . off { background : var ( - - danger ) ; box-shadow : 0 0 10 px rgba ( 255 , 77 , 79 , 0.7 ) ; }
. dot . all { background : var ( - - gold - soft ) ; box-shadow : 0 0 10 px rgba ( 184 , 150 , 48 , 0.5 ) ; }
. table-card {
2026-03-04 22:58:00 -06:00
background : linear-gradient ( 135 deg , rgba ( 17 , 17 , 19 , 0.98 ) , rgba ( 8 , 8 , 10 , 0.98 ) ) ;
border-radius : 14 px ; border : 1 px solid rgba ( 255 , 255 , 255 , 0.04 ) ; overflow : hidden ;
2026-03-04 19:42:38 -06:00
}
table { width : 100 % ; border-collapse : collapse ; font-size : 0.9 rem ; }
2026-03-04 22:58:00 -06:00
thead { background : radial-gradient ( circle at top left , rgba ( 212 , 175 , 55 , 0.22 ) , rgba ( 10 , 10 , 12 , 0.9 ) ) ; }
th , td { padding : 10 px 16 px ; text-align : left ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.04 ) ; white-space : nowrap ; }
2026-03-04 19:53:39 -06:00
th { font-size : 0.78 rem ; text-transform : uppercase ; letter-spacing : 0.12 em ; color : var ( - - muted ) ; }
2026-03-04 19:42:38 -06:00
tbody tr : last-child td { border-bottom : none ; }
tbody tr : hover { background : rgba ( 212 , 175 , 55 , 0.04 ) ; }
2026-03-04 22:58:00 -06:00
. name-cell { font-weight : 500 ; color : var ( - - text ) ; }
. muted-cell { color : var ( - - muted ) ; font-size : 0.82 rem ; }
2026-05-28 00:39:46 -05:00
. source-chip {
display : inline-block ; padding : 3 px 10 px ; border-radius : 999 px ;
font-size : 0.72 rem ; letter-spacing : 0.08 em ; text-transform : uppercase ;
color : var ( - - blue ) ; background : rgba ( 100 , 180 , 255 , 0.08 ) ;
border : 1 px solid rgba ( 100 , 180 , 255 , 0.35 ) ;
}
2026-03-04 19:42:38 -06:00
. align-center { text-align : center ; }
2026-03-04 22:58:00 -06:00
. time-first { color : var ( - - text ) ; font-weight : 500 ; }
2026-03-04 19:53:39 -06:00
. time-latest { color : var ( - - muted ) ; font-size : 0.85 rem ; }
2026-03-04 22:58:00 -06:00
. same-badge { color : #555 ; font-size : 0.82 rem ; font-style : italic ; }
2026-03-04 19:42:38 -06:00
. status-chip {
2026-03-04 19:53:39 -06:00
display : inline-flex ; align-items : center ; justify-content : center ;
min-width : 88 px ; padding : 5 px 12 px ; border-radius : 999 px ;
2026-03-04 22:58:00 -06:00
font-size : 0.78 rem ; font-weight : 600 ; text-transform : uppercase ; letter-spacing : 0.1 em ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
. status-on { background : rgba ( 46 , 204 , 113 , 0.1 ) ; color : #c9f7dc ; border : 1 px solid rgba ( 46 , 204 , 113 , 0.65 ) ; box-shadow : 0 0 14 px rgba ( 46 , 204 , 113 , 0.2 ) ; }
. status-off { background : rgba ( 255 , 77 , 79 , 0.1 ) ; color : #ffd6d7 ; border : 1 px solid rgba ( 255 , 77 , 79 , 0.75 ) ; box-shadow : 0 0 14 px rgba ( 255 , 77 , 79 , 0.2 ) ; }
. chip-dot { width : 7 px ; height : 7 px ; border-radius : 50 % ; margin-right : 6 px ; background : currentColor ; }
. empty-state { padding : 28 px 16 px ; text-align : center ; color : var ( - - muted ) ; font-size : 0.9 rem ; }
. empty-state span { color : var ( - - gold - soft ) ; }
2026-05-28 00:39:46 -05:00
2026-03-04 22:58:00 -06:00
. modal-overlay { display : none ; position : fixed ; inset : 0 ; background : rgba ( 0 , 0 , 0 , 0.75 ) ; backdrop-filter : blur ( 4 px ) ; z-index : 100 ; align-items : center ; justify-content : center ; }
2026-03-04 19:53:39 -06:00
. modal-overlay . open { display : flex ; }
2026-05-28 00:39:46 -05:00
. modal { background : var ( - - bg - card ) ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.3 ) ; border-radius : 16 px ; padding : 24 px ; max-width : 720 px ; width : 92 % ; box-shadow : 0 24 px 60 px rgba ( 0 , 0 , 0 , 0.8 ) ; max-height : 90 vh ; overflow-y : auto ; }
. modal . danger { border-color : rgba ( 255 , 100 , 100 , 0.5 ) ; max-width : 420 px ; text-align : center ; }
. modal h2 { color : var ( - - gold ) ; font-size : 1.05 rem ; margin-bottom : 14 px ; text-transform : uppercase ; letter-spacing : 0.08 em ; }
. modal . danger h2 { color : var ( - - danger ) ; }
. modal p { color : var ( - - muted ) ; font-size : 0.88 rem ; margin-bottom : 16 px ; line-height : 1.55 ; }
2026-03-04 19:53:39 -06:00
. modal p strong { color : var ( - - text ) ; }
2026-05-28 00:39:46 -05:00
. modal-actions { display : flex ; gap : 12 px ; justify-content : flex-end ; margin-top : 16 px ; }
. modal . danger . modal-actions { justify-content : center ; }
2026-03-04 22:58:00 -06:00
. modal-cancel { border-color : rgba ( 255 , 255 , 255 , 0.15 ) ; background : rgba ( 255 , 255 , 255 , 0.05 ) ; }
2026-03-04 19:53:39 -06:00
. modal-confirm { border-color : rgba ( 255 , 100 , 100 , 0.6 ) ; background : rgba ( 255 , 80 , 80 , 0.18 ) ; }
2026-05-28 00:39:46 -05:00
. ctrl-list { display : flex ; flex-direction : column ; gap : 10 px ; margin-bottom : 18 px ; }
. ctrl-row {
display : grid ; grid-template-columns : 1 fr auto ;
gap : 8 px 16 px ; padding : 12 px 14 px ;
background : rgba ( 255 , 255 , 255 , 0.02 ) ;
border : 1 px solid var ( - - border ) ; border-radius : 10 px ; align-items : center ;
}
. ctrl-meta { display : flex ; flex-direction : column ; gap : 3 px ; min-width : 0 ; }
. ctrl-name { font-weight : 600 ; color : var ( - - text ) ; font-size : 0.95 rem ; }
. ctrl-name . disabled-tag { color : var ( - - muted ) ; font-size : 0.7 rem ; margin-left : 6 px ; font-weight : 400 ; }
. ctrl-sub { font-size : 0.78 rem ; color : var ( - - muted ) ; font-family : ui-monospace , monospace ; overflow : hidden ; text-overflow : ellipsis ; }
. ctrl-actions { display : flex ; gap : 6 px ; flex-wrap : wrap ; justify-content : flex-end ; }
. ctrl-actions button { padding : 5 px 10 px ; font-size : 0.7 rem ; letter-spacing : 0.08 em ; }
. form-grid { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 10 px 14 px ; margin-bottom : 14 px ; }
. form-grid . full { grid-column : 1 / -1 ; }
. form-grid label { display : block ; margin-bottom : 4 px ; }
. form-grid input { width : 100 % ; min-width : 0 ; }
. form-hint { font-size : 0.78 rem ; color : var ( - - muted ) ; margin-top : -4 px ; margin-bottom : 10 px ; }
. form-error { color : var ( - - danger ) ; font-size : 0.82 rem ; margin-bottom : 10 px ; min-height : 1.1 em ; }
2026-03-04 19:42:38 -06:00
. toast {
2026-03-04 22:58:00 -06:00
position : fixed ; bottom : 24 px ; right : 24 px ; background : var ( - - bg - card ) ;
border : 1 px solid rgba ( 212 , 175 , 55 , 0.5 ) ; border-radius : 12 px ; padding : 12 px 18 px ;
font-size : 0.85 rem ; color : var ( - - gold ) ; opacity : 0 ; transform : translateY ( 12 px ) ;
transition : opacity 0.25 s , transform 0.25 s ; pointer-events : none ; z-index : 200 ;
2026-05-28 00:39:46 -05:00
max-width : 360 px ;
2026-03-04 19:42:38 -06:00
}
. toast . show { opacity : 1 ; transform : translateY ( 0 ) ; }
2026-05-28 00:39:46 -05:00
. toast . error { border-color : rgba ( 255 , 100 , 100 , 0.6 ) ; color : #ffd6d7 ; }
2026-03-04 19:53:39 -06:00
@ media ( max-width : 800px ) {
2026-03-04 19:42:38 -06:00
header { flex-direction : column ; align-items : flex-start ; }
. controls { flex-direction : column ; align-items : stretch ; }
2026-05-28 00:39:46 -05:00
input , select , button { width : 100 % ; }
th : nth-child ( 5 ) , td : nth-child ( 5 ) { display : none ; }
. form-grid { grid-template-columns : 1 fr ; }
. ctrl-row { grid-template-columns : 1 fr ; }
. ctrl-actions { justify-content : flex-start ; }
2026-03-04 19:42:38 -06:00
}
< / style >
< / head >
< body >
2026-03-04 19:53:39 -06:00
< div class = "app-shell" >
< header >
< div class = "title-block" >
< h1 > Building Access< / h1 >
2026-03-04 22:58:00 -06:00
< div class = "subtitle" > Daily badge-in attendance — powered by UniFi Access< / div >
2026-03-04 19:53:39 -06:00
< / div >
< div class = "badge" > LIVE ATTENDANCE DASHBOARD< / div >
< / header >
< section class = "controls" >
< div class = "control-group" >
< label for = "date" > Date< / label >
2026-03-04 22:58:00 -06:00
< input type = "date" id = "date" >
2026-03-04 19:53:39 -06:00
< / div >
< div class = "control-group" >
< label for = "cutoff" > Badged in by< / label >
2026-03-04 22:58:00 -06:00
< input type = "time" id = "cutoff" value = "09:00" >
2026-03-04 19:53:39 -06:00
< / div >
2026-05-28 00:39:46 -05:00
< div class = "control-group" >
< label for = "controller-filter" > Controller< / label >
< select id = "controller-filter" > < option value = "" > All< / option > < / select >
< / div >
2026-03-04 19:53:39 -06:00
< div class = "spacer" > < / div >
< div class = "control-group" >
< button id = "refresh-btn" > ↻ Refresh< / button >
< button class = "sync-btn" id = "sync-btn" > ↻ Sync Users< / button >
2026-05-28 00:39:46 -05:00
< button class = "controllers-btn" id = "open-controllers-btn" > ⚙ Controllers< / button >
2026-03-04 19:53:39 -06:00
< button class = "reset-btn" id = "reset-btn" > ✕ Reset Day< / button >
< / div >
< / section >
< section class = "summary-row" >
2026-03-04 22:58:00 -06:00
< div class = "summary-pill" > < div class = "dot on" > < / div > < span id = "on-time-count" > 0< / span > on time< / div >
< div class = "summary-pill" > < div class = "dot off" > < / div > < span id = "late-count" > 0< / span > late< / div >
< div class = "summary-pill" > < div class = "dot all" > < / div > < span id = "total-count" > 0< / span > total< / div >
2026-03-04 19:53:39 -06:00
< / section >
< section class = "table-card" >
< table >
< thead >
< tr >
< th > #< / th >
< th > Name< / th >
2026-05-28 00:39:46 -05:00
< th > Source< / th >
2026-03-04 19:53:39 -06:00
< th > First Badge In< / th >
< th > Latest Badge In< / th >
< th > Actor ID< / th >
< th class = "align-center" > Status< / th >
< / tr >
< / thead >
< tbody id = "table-body" >
2026-05-28 00:39:46 -05:00
< tr > < td colspan = "7" class = "empty-state" > No data yet. < span > Badge into a door< / span > and press Refresh.< / td > < / tr >
2026-03-04 19:53:39 -06:00
< / tbody >
< / table >
< / section >
< / div >
<!-- Reset confirmation modal -->
< div class = "modal-overlay" id = "reset-modal" >
2026-05-28 00:39:46 -05:00
< div class = "modal danger" >
2026-03-04 19:53:39 -06:00
< h2 > ⚠ Reset Day< / h2 >
2026-03-04 22:58:00 -06:00
< p > This will permanently delete all badge-in records for < strong id = "modal-date-label" > < / strong > .< br > Use this for testing only.< / p >
2026-03-04 19:53:39 -06:00
< div class = "modal-actions" >
< button class = "modal-cancel" id = "modal-cancel" > Cancel< / button >
< button class = "modal-confirm" id = "modal-confirm" > Yes, Reset< / button >
< / div >
2026-03-04 19:42:38 -06:00
< / div >
2026-03-04 19:53:39 -06:00
< / div >
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
<!-- Controllers management modal -->
< div class = "modal-overlay" id = "controllers-modal" >
< div class = "modal" >
< h2 > Controllers< / h2 >
< p > Each controller is a UniFi Access instance reachable from this server.
Adding one will register a webhook on that controller automatically.< / p >
< div class = "ctrl-list" id = "ctrl-list" >
< div class = "empty-state" > No controllers configured.< / div >
< / div >
< h2 style = "margin-top: 8px;" > Add Controller< / h2 >
< div class = "form-grid" >
< div class = "full" >
< label for = "add-name" > Name< / label >
< input type = "text" id = "add-name" placeholder = "e.g. Main Office" >
< / div >
< div >
< label for = "add-host" > Host / IP< / label >
< input type = "text" id = "add-host" placeholder = "10.0.0.1" >
< / div >
< div >
< label for = "add-port" > Port< / label >
< input type = "number" id = "add-port" value = "12445" >
< / div >
< div class = "full" >
< label for = "add-token" > Developer API Token< / label >
< input type = "password" id = "add-token" placeholder = "paste token from UniFi Access" >
< / div >
< / div >
< div class = "form-hint" >
The dashboard registers its webhook URL with the controller using
< span id = "base-url-hint" style = "color: var(--gold-soft);" > < / span > .
Set the < code > DASHBOARD_BASE_URL< / code > env var if the controller can't reach that address.
< / div >
< div class = "form-error" id = "add-error" > < / div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "controllers-close" > Close< / button >
< button id = "add-controller-btn" > Add Controller< / button >
< / div >
< / div >
< / div >
2026-03-04 19:53:39 -06:00
< div class = "toast" id = "toast" > < / div >
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
< script >
2026-05-28 00:39:46 -05:00
function isoToday ( ) { return new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ; }
2026-03-04 19:53:39 -06:00
2026-05-28 00:39:46 -05:00
function showToast ( msg , isError = false , duration = 3500 ) {
2026-03-04 19:53:39 -06:00
const t = document . getElementById ( 'toast' ) ;
t . textContent = msg ;
2026-05-28 00:39:46 -05:00
t . classList . toggle ( 'error' , isError ) ;
2026-03-04 19:53:39 -06:00
t . classList . add ( 'show' ) ;
clearTimeout ( t . _timer ) ;
t . _timer = setTimeout ( ( ) => t . classList . remove ( 'show' ) , duration ) ;
}
2026-05-28 00:39:46 -05:00
async function loadControllerList ( ) {
const sel = document . getElementById ( 'controller-filter' ) ;
const prev = sel . value ;
try {
const res = await fetch ( '/api/controllers' ) ;
const items = await res . json ( ) ;
sel . innerHTML = '<option value="">All controllers</option>' +
items . map ( c => ` <option value=" ${ c . id } "> ${ escapeHtml ( c . name ) } ${ c . enabled ? '' : ' (disabled)' } </option> ` ) . join ( '' ) ;
if ( prev && items . some ( c => c . id === prev ) ) sel . value = prev ;
return items ;
} catch {
return [ ] ;
}
}
2026-03-04 19:53:39 -06:00
async function loadData ( ) {
const date = document . getElementById ( 'date' ) . value || isoToday ( ) ;
const cutoff = document . getElementById ( 'cutoff' ) . value || '09:00' ;
2026-05-28 00:39:46 -05:00
const controllerId = document . getElementById ( 'controller-filter' ) . value ;
2026-03-04 19:53:39 -06:00
const params = new URLSearchParams ( { date , cutoff } ) ;
2026-05-28 00:39:46 -05:00
if ( controllerId ) params . set ( 'controller_id' , controllerId ) ;
2026-03-04 19:53:39 -06:00
2026-03-04 22:58:00 -06:00
let data ;
2026-03-04 19:53:39 -06:00
try {
const res = await fetch ( '/api/first-badge-status?' + params . toString ( ) ) ;
data = await res . json ( ) ;
} catch {
2026-05-28 00:39:46 -05:00
showToast ( 'Could not load data' , true ) ;
2026-03-04 19:53:39 -06:00
return ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
const tbody = document . getElementById ( 'table-body' ) ;
tbody . innerHTML = '' ;
if ( ! data . length ) {
2026-05-28 00:39:46 -05:00
tbody . innerHTML = '<tr><td colspan="7" class="empty-state">No badge-in records for this day.</td></tr>' ;
2026-03-04 19:53:39 -06:00
[ 'on-time-count' , 'late-count' , 'total-count' ] . forEach ( id => {
2026-03-04 22:58:00 -06:00
document . getElementById ( id ) . textContent = '0' ;
2026-03-04 19:53:39 -06:00
} ) ;
return ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
let onTime = 0 , late = 0 ;
data . forEach ( ( row , i ) => {
const tr = document . createElement ( 'tr' ) ;
const numTd = document . createElement ( 'td' ) ;
numTd . className = 'muted-cell' ;
numTd . textContent = i + 1 ;
tr . appendChild ( numTd ) ;
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
const nameTd = document . createElement ( 'td' ) ;
nameTd . className = 'name-cell' ;
2026-03-04 22:58:00 -06:00
nameTd . textContent = row . name ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( nameTd ) ;
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
const sourceTd = document . createElement ( 'td' ) ;
const chip = document . createElement ( 'span' ) ;
chip . className = 'source-chip' ;
chip . textContent = row . source || '—' ;
sourceTd . appendChild ( chip ) ;
tr . appendChild ( sourceTd ) ;
2026-03-04 19:53:39 -06:00
const firstTd = document . createElement ( 'td' ) ;
firstTd . className = 'time-first' ;
2026-03-04 22:58:00 -06:00
firstTd . textContent = row . first _ts || '—' ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( firstTd ) ;
const latestTd = document . createElement ( 'td' ) ;
2026-03-04 22:58:00 -06:00
if ( ! row . latest _ts ) {
2026-03-04 19:53:39 -06:00
latestTd . className = 'same-badge' ;
latestTd . textContent = '— same' ;
} else {
latestTd . className = 'time-latest' ;
2026-03-04 22:58:00 -06:00
latestTd . textContent = row . latest _ts ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
tr . appendChild ( latestTd ) ;
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
const idTd = document . createElement ( 'td' ) ;
idTd . className = 'muted-cell' ;
2026-03-04 22:58:00 -06:00
idTd . textContent = row . actor _id ? row . actor _id . slice ( 0 , 8 ) + '...' : '—' ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( idTd ) ;
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
const statusTd = document . createElement ( 'td' ) ;
statusTd . className = 'align-center' ;
2026-05-28 00:39:46 -05:00
const statusChip = document . createElement ( 'div' ) ;
2026-03-04 22:58:00 -06:00
const isOnTime = row . status === 'ON TIME' ;
2026-05-28 00:39:46 -05:00
statusChip . className = 'status-chip ' + ( isOnTime ? 'status-on' : 'status-off' ) ;
statusChip . innerHTML = '<span class="chip-dot"></span>' + ( isOnTime ? 'ON TIME' : 'LATE' ) ;
statusTd . appendChild ( statusChip ) ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( statusTd ) ;
tbody . appendChild ( tr ) ;
2026-03-04 22:58:00 -06:00
isOnTime ? onTime ++ : late ++ ;
2026-03-04 19:53:39 -06:00
} ) ;
2026-03-04 22:58:00 -06:00
document . getElementById ( 'on-time-count' ) . textContent = onTime ;
document . getElementById ( 'late-count' ) . textContent = late ;
document . getElementById ( 'total-count' ) . textContent = onTime + late ;
2026-03-04 19:53:39 -06:00
}
async function syncUsers ( ) {
const btn = document . getElementById ( 'sync-btn' ) ;
2026-05-28 00:39:46 -05:00
const orig = btn . innerHTML ;
btn . textContent = 'Syncing…' ; btn . disabled = true ;
2026-03-04 19:53:39 -06:00
try {
await fetch ( '/api/sync-users' ) ;
2026-05-28 00:39:46 -05:00
showToast ( 'User list synced from all controllers' ) ;
2026-03-04 19:53:39 -06:00
await loadData ( ) ;
} catch {
2026-05-28 00:39:46 -05:00
showToast ( 'Sync failed — check server logs' , true ) ;
2026-03-04 19:53:39 -06:00
} finally {
2026-05-28 00:39:46 -05:00
btn . innerHTML = orig ; btn . disabled = false ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
}
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
// -------- Reset day modal --------
2026-03-04 19:53:39 -06:00
document . getElementById ( 'reset-btn' ) . addEventListener ( 'click' , ( ) => {
const date = document . getElementById ( 'date' ) . value || isoToday ( ) ;
document . getElementById ( 'modal-date-label' ) . textContent = date ;
document . getElementById ( 'reset-modal' ) . classList . add ( 'open' ) ;
} ) ;
document . getElementById ( 'modal-cancel' ) . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'reset-modal' ) . classList . remove ( 'open' ) ;
} ) ;
document . getElementById ( 'modal-confirm' ) . addEventListener ( 'click' , async ( ) => {
document . getElementById ( 'reset-modal' ) . classList . remove ( 'open' ) ;
const date = document . getElementById ( 'date' ) . value || isoToday ( ) ;
2026-05-28 00:39:46 -05:00
const controllerId = document . getElementById ( 'controller-filter' ) . value ;
const params = new URLSearchParams ( { date } ) ;
if ( controllerId ) params . set ( 'controller_id' , controllerId ) ;
2026-03-04 19:53:39 -06:00
try {
2026-05-28 00:39:46 -05:00
const res = await fetch ( '/api/reset-day?' + params . toString ( ) , { method : 'DELETE' } ) ;
2026-03-04 19:53:39 -06:00
const json = await res . json ( ) ;
2026-03-04 22:58:00 -06:00
showToast ( ` Reset complete — ${ json . deleted } record(s) deleted for ${ date } ` ) ;
2026-03-04 19:53:39 -06:00
await loadData ( ) ;
} catch {
2026-05-28 00:39:46 -05:00
showToast ( 'Reset failed — check server logs' , true ) ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
} ) ;
document . getElementById ( 'reset-modal' ) . addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'reset-modal' ) )
document . getElementById ( 'reset-modal' ) . classList . remove ( 'open' ) ;
} ) ;
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
// -------- Controllers modal --------
function escapeHtml ( s ) {
return String ( s ) . replace ( /[&<>"']/g , c => ( {
'&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : '''
} [ c ] ) ) ;
}
function fmtDate ( iso ) {
if ( ! iso ) return 'never' ;
const d = new Date ( iso ) ;
return isNaN ( d ) ? iso : d . toLocaleString ( ) ;
}
async function renderControllers ( ) {
const list = document . getElementById ( 'ctrl-list' ) ;
list . innerHTML = '<div class="empty-state">Loading…</div>' ;
let items = [ ] ;
try {
const res = await fetch ( '/api/controllers' ) ;
items = await res . json ( ) ;
} catch {
list . innerHTML = '<div class="empty-state">Failed to load controllers.</div>' ;
return ;
}
if ( ! items . length ) {
list . innerHTML = '<div class="empty-state">No controllers configured. Add one below to start receiving badge events.</div>' ;
return ;
}
list . innerHTML = '' ;
items . forEach ( c => {
const row = document . createElement ( 'div' ) ;
row . className = 'ctrl-row' ;
row . innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name">
${ escapeHtml ( c . name ) }
${ c . enabled ? '' : '<span class="disabled-tag">(disabled)</span>' }
</div>
<div class="ctrl-sub"> ${ escapeHtml ( c . host ) } : ${ c . port } · last sync ${ fmtDate ( c . last _sync _at ) } ${ c . has _webhook ? '' : ' · <span style="color:var(--danger)">no webhook</span>' } </div>
</div>
<div class="ctrl-actions">
<button class="small-btn sync-btn" data-act="test" data-id=" ${ c . id } ">Test</button>
<button class="small-btn sync-btn" data-act="sync" data-id=" ${ c . id } ">Sync</button>
<button class="small-btn" data-act="toggle" data-id=" ${ c . id } "> ${ c . enabled ? 'Disable' : 'Enable' } </button>
<button class="small-btn reset-btn" data-act="delete" data-id=" ${ c . id } " data-name=" ${ escapeHtml ( c . name ) } ">Remove</button>
</div>
` ;
list . appendChild ( row ) ;
} ) ;
}
document . getElementById ( 'open-controllers-btn' ) . addEventListener ( 'click' , async ( ) => {
document . getElementById ( 'base-url-hint' ) . textContent = window . location . origin ;
document . getElementById ( 'add-error' ) . textContent = '' ;
document . getElementById ( 'controllers-modal' ) . classList . add ( 'open' ) ;
await renderControllers ( ) ;
} ) ;
document . getElementById ( 'controllers-close' ) . addEventListener ( 'click' , ( ) => {
document . getElementById ( 'controllers-modal' ) . classList . remove ( 'open' ) ;
} ) ;
document . getElementById ( 'controllers-modal' ) . addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'controllers-modal' ) )
document . getElementById ( 'controllers-modal' ) . classList . remove ( 'open' ) ;
} ) ;
document . getElementById ( 'ctrl-list' ) . addEventListener ( 'click' , async e => {
const btn = e . target . closest ( 'button[data-act]' ) ;
if ( ! btn ) return ;
const id = btn . dataset . id ;
const act = btn . dataset . act ;
btn . disabled = true ;
try {
if ( act === 'test' ) {
const r = await fetch ( ` /api/controllers/ ${ id } /test ` , { method : 'POST' } ) ;
const j = await r . json ( ) ;
showToast ( j . ok
? ` Connected — ${ j . user _count } users on controller `
: ` Test failed: ${ j . message } ` ,
! j . ok ) ;
} else if ( act === 'sync' ) {
const r = await fetch ( ` /api/controllers/ ${ id } /sync ` , { method : 'POST' } ) ;
const j = await r . json ( ) ;
showToast ( ` Synced ${ j . synced } users ` ) ;
await renderControllers ( ) ;
await loadData ( ) ;
} else if ( act === 'toggle' ) {
const isEnabling = btn . textContent . trim ( ) . toLowerCase ( ) === 'enable' ;
await fetch ( ` /api/controllers/ ${ id } ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { enabled : isEnabling } ) ,
} ) ;
await renderControllers ( ) ;
await loadControllerList ( ) ;
} else if ( act === 'delete' ) {
const name = btn . dataset . name ;
if ( ! confirm ( ` Remove controller " ${ name } "? \n \n This deletes its webhook from the controller and removes all its badge events from the dashboard. This cannot be undone. ` ) )
return ;
const r = await fetch ( ` /api/controllers/ ${ id } ` , { method : 'DELETE' } ) ;
if ( ! r . ok ) {
const j = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
showToast ( ` Remove failed: ${ j . error || r . status } ` , true ) ;
return ;
}
showToast ( ` Removed ${ name } ` ) ;
await renderControllers ( ) ;
await loadControllerList ( ) ;
await loadData ( ) ;
}
} catch ( err ) {
showToast ( ` Action failed: ${ err . message || err } ` , true ) ;
} finally {
btn . disabled = false ;
}
} ) ;
document . getElementById ( 'add-controller-btn' ) . addEventListener ( 'click' , async ( ) => {
const btn = document . getElementById ( 'add-controller-btn' ) ;
const err = document . getElementById ( 'add-error' ) ;
err . textContent = '' ;
const body = {
name : document . getElementById ( 'add-name' ) . value . trim ( ) ,
host : document . getElementById ( 'add-host' ) . value . trim ( ) ,
port : parseInt ( document . getElementById ( 'add-port' ) . value , 10 ) || 12445 ,
api _token : document . getElementById ( 'add-token' ) . value . trim ( ) ,
} ;
if ( ! body . name || ! body . host || ! body . api _token ) {
err . textContent = 'Name, host, and API token are required.' ;
return ;
}
btn . disabled = true ;
btn . textContent = 'Adding…' ;
try {
const r = await fetch ( '/api/controllers' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( body ) ,
} ) ;
const j = await r . json ( ) ;
if ( ! r . ok ) {
err . textContent = j . error || 'Failed to add controller.' ;
if ( j . response ) err . textContent += ` — ${ String ( j . response ) . slice ( 0 , 200 ) } ` ;
return ;
}
document . getElementById ( 'add-name' ) . value = '' ;
document . getElementById ( 'add-host' ) . value = '' ;
document . getElementById ( 'add-token' ) . value = '' ;
showToast ( ` Added ${ j . name } ` ) ;
await renderControllers ( ) ;
await loadControllerList ( ) ;
await loadData ( ) ;
} catch ( e ) {
err . textContent = ` Network error: ${ e . message || e } ` ;
} finally {
btn . disabled = false ;
btn . textContent = 'Add Controller' ;
}
} ) ;
// -------- Wire up --------
2026-03-04 19:53:39 -06:00
document . getElementById ( 'refresh-btn' ) . addEventListener ( 'click' , loadData ) ;
document . getElementById ( 'sync-btn' ) . addEventListener ( 'click' , syncUsers ) ;
2026-05-28 00:39:46 -05:00
document . getElementById ( 'controller-filter' ) . addEventListener ( 'change' , loadData ) ;
2026-03-04 19:53:39 -06:00
2026-05-28 00:39:46 -05:00
window . addEventListener ( 'load' , async ( ) => {
2026-03-04 19:53:39 -06:00
const dateInput = document . getElementById ( 'date' ) ;
if ( ! dateInput . value ) dateInput . value = isoToday ( ) ;
2026-05-28 00:39:46 -05:00
await loadControllerList ( ) ;
await loadData ( ) ;
2026-03-04 19:53:39 -06:00
} ) ;
< / script >
2026-03-04 19:42:38 -06:00
< / body >
< / html >