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-05-28 15:01:22 -05:00
tr . row-filtered td { opacity : 0.45 ; }
tr . row-filtered . name-cell :: after {
content : 'FILTERED' ; margin-left : 8 px ; font-size : 0.65 rem ; letter-spacing : 0.1 em ;
color : var ( -- warn ); border : 1 px solid rgba ( 243 , 156 , 18 , 0.5 );
padding : 1 px 6 px ; border-radius : 999 px ; vertical-align : middle ;
}
. row-action-btn {
padding : 4 px 10 px ; font-size : 0.7 rem ; letter-spacing : 0.08 em ;
border-radius : 999 px ; border : 1 px solid rgba ( 255 , 255 , 255 , 0.18 );
background : rgba ( 255 , 255 , 255 , 0.04 ); color : var ( -- text ); cursor : pointer ;
text-transform : uppercase ;
}
. row-action-btn : hover { border-color : rgba ( 212 , 175 , 55 , 0.6 ); }
. row-action-btn . unhide { border-color : rgba ( 46 , 204 , 113 , 0.6 ); color : #c9f7dc ; }
. show-filtered-toggle {
display : inline-flex ; align-items : center ; gap : 6 px ;
font-size : 0.78 rem ; color : var ( -- muted );
letter-spacing : 0.08 em ; text-transform : uppercase ;
}
. show-filtered-toggle input { min-width : 0 ; width : auto ; margin : 0 ; }
. filtered-btn {
border-color : rgba ( 243 , 156 , 18 , 0.6 );
background : radial-gradient ( circle at top , rgba ( 243 , 156 , 18 , 0.18 ), rgba ( 2 , 2 , 4 , 0.95 ));
}
2026-05-28 15:56:36 -05:00
. people-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 ));
}
2026-06-19 09:10:34 -05:00
. report-btn {
border-color : rgba ( 46 , 204 , 113 , 0.6 );
background : radial-gradient ( circle at top , rgba ( 46 , 204 , 113 , 0.18 ), rgba ( 2 , 2 , 4 , 0.95 ));
}
/* Report modal */
. modal . wide { max-width : 1000 px ; }
. report-controls {
display : flex ; flex-wrap : wrap ; gap : 12 px ; align-items : flex-end ; margin-bottom : 14 px ;
}
. report-controls . control-group { display : flex ; flex-direction : column ; gap : 6 px ; }
. report-controls input { min-width : 130 px ; }
. subject-picker { display : flex ; flex-direction : column ; gap : 6 px ;
max-height : 220 px ; overflow-y : auto ; margin : 6 px 0 4 px ;
border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 8 px ; }
. subject-row {
display : flex ; align-items : center ; gap : 10 px ; padding : 5 px 8 px ;
border-radius : 8 px ; cursor : pointer ; font-size : 0.85 rem ;
}
. subject-row : hover { background : rgba ( 255 , 255 , 255 , 0.03 ); }
. subject-row input { width : auto ; min-width : 0 ; margin : 0 ; }
. subject-row . s-sub { color : var ( -- muted ); font-size : 0.72 rem ; margin-left : auto ; }
. picker-toolbar { display : flex ; gap : 12 px ; align-items : center ; margin-bottom : 6 px ;
font-size : 0.74 rem ; }
. picker-toolbar a { color : var ( -- blue ); cursor : pointer ; text-decoration : underline ; }
. picker-toolbar . count { color : var ( -- muted ); margin-left : auto ; }
. report-result { margin-top : 8 px ; overflow-x : auto ; }
table . pivot { font-size : 0.82 rem ; }
table . pivot th , table . pivot td { padding : 8 px 10 px ; white-space : nowrap ; text-align : center ; }
table . pivot th . name-col , table . pivot td . name-col {
text-align : left ; position : sticky ; left : 0 ; background : var ( -- bg - card ); z-index : 1 ;
font-weight : 500 ; color : var ( -- text );
}
table . pivot td . cell-late { color : #ffd6d7 ; }
table . pivot td . cell-on { color : #c9f7dc ; }
table . pivot td . cell-absent { color : var ( -- muted ); }
table . pivot th . weekend , table . pivot td . weekend { background : rgba ( 255 , 255 , 255 , 0.02 ); }
. report-summary { font-size : 0.78 rem ; color : var ( -- muted ); margin : 10 px 0 ; }
2026-05-28 15:56:36 -05:00
. source-list { display : inline-flex ; flex-wrap : wrap ; gap : 4 px ; }
. merged-pill {
display : inline-block ; margin-left : 8 px ; padding : 1 px 8 px ;
font-size : 0.65 rem ; letter-spacing : 0.1 em ; border-radius : 999 px ;
color : var ( -- blue ); border : 1 px solid rgba ( 100 , 180 , 255 , 0.5 );
vertical-align : middle ;
}
. row-action-btn . merge { border-color : rgba ( 100 , 180 , 255 , 0.55 ); color : var ( -- blue ); }
. row-action-group { display : inline-flex ; gap : 6 px ; justify-content : center ; }
. picker-list { display : flex ; flex-direction : column ; gap : 6 px ; max-height : 320 px ; overflow-y : auto ; margin-bottom : 12 px ; }
. picker-row {
display : grid ; grid-template-columns : 1 fr auto ; gap : 10 px ;
padding : 8 px 12 px ; background : rgba ( 255 , 255 , 255 , 0.02 );
border : 1 px solid var ( -- border ); border-radius : 8 px ; align-items : center ;
}
. picker-row . ctrl-name { font-size : 0.88 rem ; }
. picker-row . ctrl-sub { font-size : 0.74 rem ; }
. picker-search { width : 100 % ; margin-bottom : 10 px ; }
. suggestion-row {
padding : 10 px 14 px ; background : rgba ( 100 , 180 , 255 , 0.05 );
border : 1 px solid rgba ( 100 , 180 , 255 , 0.25 ); border-radius : 10 px ;
display : grid ; grid-template-columns : 1 fr auto ; gap : 10 px ;
align-items : center ; margin-bottom : 8 px ;
}
. suggestion-row . ctrl-name { font-weight : 600 ; }
. suggestion-row . ctrl-sub { font-size : 0.76 rem ; color : var ( -- muted ); }
. section-label {
font-size : 0.72 rem ; letter-spacing : 0.14 em ; text-transform : uppercase ;
color : var ( -- muted ); margin : 14 px 0 8 px ;
}
2026-05-28 15:01:22 -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-06-19 14:48:32 -05:00
@ media ( max-width : 800px ) {
body { padding : 12 px ; align-items : flex-start ; }
. app-shell { padding : 18 px 14 px 22 px ; border-radius : 14 px ; }
header { flex-direction : column ; align-items : flex-start ; }
h1 { font-size : 1.25 rem ; letter-spacing : 0.04 em ; }
. subtitle { font-size : 0.82 rem ; }
. badge { letter-spacing : 0.08 em ; }
. controls { flex-direction : column ; align-items : stretch ; }
. control-group { flex-direction : column ; align-items : stretch ; gap : 6 px ; }
input , select , button { width : 100 % ; }
. show-filtered-toggle { flex-direction : row ; align-items : center ; width : 100 % ; }
. show-filtered-toggle input ,
. row-action-btn ,
. ctrl-actions button ,
. picker-row button ,
. subject-row input { width : auto ; }
. summary-row { display : grid ; grid-template-columns : 1 fr ; }
. table-card { background : transparent ; border : 0 ; overflow : visible ; }
. table-card table ,
. table-card thead ,
. table-card tbody ,
. table-card tr ,
. table-card td { display : block ; width : 100 % ; }
. table-card thead { display : none ; }
. table-card tbody { display : flex ; flex-direction : column ; gap : 12 px ; }
. table-card tbody tr {
background : linear-gradient ( 135 deg , rgba ( 17 , 17 , 19 , 0.98 ), rgba ( 8 , 8 , 10 , 0.98 ));
border : 1 px solid rgba ( 255 , 255 , 255 , 0.07 );
border-radius : 12 px ;
padding : 10 px 12 px ;
}
. table-card tbody tr : hover { background : linear-gradient ( 135 deg , rgba ( 24 , 24 , 27 , 0.98 ), rgba ( 10 , 10 , 12 , 0.98 )); }
. table-card td {
display : grid ;
grid-template-columns : minmax ( 96 px , 35 % ) 1 fr ;
gap : 12 px ;
align-items : start ;
padding : 9 px 0 ;
border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.06 );
white-space : normal ;
text-align : right ;
}
. table-card td : last-child { border-bottom : 0 ; }
. table-card td :: before {
content : attr ( data - label );
color : var ( -- muted );
font-size : 0.68 rem ;
letter-spacing : 0.1 em ;
text-transform : uppercase ;
text-align : left ;
}
. table-card . name-cell { font-size : 1 rem ; }
. table-card . align-center { text-align : right ; }
. table-card . align-center . status-chip ,
. table-card . align-center . row-action-group { justify-content : flex-end ; margin-left : auto ; }
. source-list { justify-content : flex-end ; }
. source-chip , . merged-pill { max-width : 100 % ; overflow-wrap : anywhere ; }
. row-action-group { flex-wrap : wrap ; justify-content : flex-end ; }
. table-card tbody tr . empty-table-row { padding : 0 ; }
. table-card tbody tr . empty-table-row td {
display : block ;
text-align : center ;
padding : 24 px 14 px ;
border-bottom : 0 ;
}
. table-card tbody tr . empty-table-row td :: before { content : none ; }
. form-grid { grid-template-columns : 1 fr ; }
. ctrl-row { grid-template-columns : 1 fr ; }
. ctrl-actions { justify-content : flex-start ; }
. modal { padding : 18 px ; width : 94 % ; border-radius : 12 px ; }
. modal-actions { flex-direction : column-reverse ; }
}
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-05-28 15:01:22 -05:00
< div class = "control-group" >
< label class = "show-filtered-toggle" for = "show-filtered" >
< input type = "checkbox" id = "show-filtered" > Show filtered
</ label >
</ 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 15:01:22 -05:00
< button class = "filtered-btn" id = "open-filtered-btn" > 🚫 Filtered</ button >
2026-06-19 09:10:34 -05:00
< button class = "report-btn" id = "open-report-btn" > 📊 Report</ button >
2026-05-28 15:56:36 -05:00
< button class = "people-btn" id = "open-people-btn" > 👥 People</ 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 >
2026-05-28 15:01:22 -05:00
< th class = "align-center" > Actions</ th >
2026-03-04 19:53:39 -06:00
</ tr >
</ thead >
2026-06-19 14:48:32 -05:00
< tbody id = "table-body" >
< tr class = "empty-table-row" >< td colspan = "8" class = "empty-state" > No data yet. < span > Badge into a door</ span > and press Refresh.</ td ></ tr >
</ tbody >
</ table >
</ section >
2026-03-04 19:53:39 -06:00
</ div >
2026-06-19 14:48:32 -05:00
<!-- Reset confirmation modal -->
< div class = "modal-overlay" id = "reset-modal" >
< div class = "modal danger" role = "dialog" aria-modal = "true" aria-labelledby = "reset-title" tabindex = "-1" >
< h2 id = "reset-title" > ⚠ Reset Day</ h2 >
< p > This will permanently delete all badge-in records for < strong id = "modal-date-label" ></ strong > .< br > Use this for testing only.</ p >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "modal-cancel" > Cancel</ button >
2026-03-04 19:53:39 -06:00
< 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-06-19 14:48:32 -05:00
<!-- Controllers management modal -->
< div class = "modal-overlay" id = "controllers-modal" >
< div class = "modal" role = "dialog" aria-modal = "true" aria-labelledby = "controllers-title" tabindex = "-1" >
< h2 id = "controllers-title" > 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 >
2026-05-28 00:39:46 -05:00
< 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-06-19 14:48:32 -05:00
<!-- Merge picker modal -->
< div class = "modal-overlay" id = "merge-modal" >
< div class = "modal" role = "dialog" aria-modal = "true" aria-labelledby = "merge-title" tabindex = "-1" >
< h2 id = "merge-title" > Merge with another actor</ h2 >
< p > Pick the other badge identity for < strong id = "merge-source-name" ></ strong > .
Names are matched fuzzily; type to narrow.</ p >
2026-05-28 15:56:36 -05:00
< input type = "text" id = "merge-search" class = "picker-search" placeholder = "Search by name…" >
< div class = "picker-list" id = "merge-candidates" >
< div class = "empty-state" > Loading…</ div >
</ div >
< div class = "form-grid" >
< div class = "full" >
< label for = "merge-display-name" > Display name</ label >
< input type = "text" id = "merge-display-name" placeholder = "Will appear in the attendance table" >
</ div >
</ div >
< div class = "form-error" id = "merge-error" ></ div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "merge-cancel" > Cancel</ button >
< button id = "merge-confirm" > Merge</ button >
</ div >
</ div >
</ div >
2026-06-19 14:48:32 -05:00
<!-- People management modal -->
< div class = "modal-overlay" id = "people-modal" >
< div class = "modal" role = "dialog" aria-modal = "true" aria-labelledby = "people-title" tabindex = "-1" >
< h2 id = "people-title" > People</ h2 >
< p > A "person" groups one or more badge identities (controller + actor) so the
same human counts as a single row in the attendance table.</ p >
2026-05-28 15:56:36 -05:00
< div class = "section-label" > Suggested merges</ div >
< div class = "ctrl-list" id = "suggestions-list" >
< div class = "empty-state" > Loading…</ div >
</ div >
< div class = "section-label" > Merged people</ div >
< div class = "ctrl-list" id = "people-list" >
< div class = "empty-state" > Loading…</ div >
</ div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "people-close" > Close</ button >
</ div >
</ div >
</ div >
2026-06-19 14:48:32 -05:00
<!-- Filtered tenants modal -->
< div class = "modal-overlay" id = "filtered-modal" >
< div class = "modal" role = "dialog" aria-modal = "true" aria-labelledby = "filtered-title" tabindex = "-1" >
< h2 id = "filtered-title" > Filtered Tenants</ h2 >
< p > These actors are hidden from the attendance table. Their badge events are
still recorded — unhide to bring them back.</ p >
2026-05-28 15:01:22 -05:00
< div class = "ctrl-list" id = "filtered-list" >
< div class = "empty-state" > Loading…</ div >
</ div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "filtered-close" > Close</ button >
</ div >
</ div >
</ div >
2026-06-19 14:48:32 -05:00
<!-- Report modal -->
< div class = "modal-overlay" id = "report-modal" >
< div class = "modal wide" role = "dialog" aria-modal = "true" aria-labelledby = "report-title" tabindex = "-1" >
< h2 id = "report-title" > First Badge-In Report</ h2 >
< p > First badge-in per day over a date range. Pick one person for an individual
report, or several for a group. Days with no badge-in show as < strong > absent</ strong > .</ p >
2026-06-19 09:10:34 -05:00
< div class = "report-controls" >
< div class = "control-group" >
< label for = "report-start" > Start</ label >
< input type = "date" id = "report-start" >
</ div >
< div class = "control-group" >
< label for = "report-end" > End</ label >
< input type = "date" id = "report-end" >
</ div >
< div class = "control-group" >
< label for = "report-cutoff" > On-time cutoff</ label >
< input type = "time" id = "report-cutoff" value = "09:00" >
</ div >
</ div >
< div class = "section-label" > Users</ div >
< input type = "text" id = "report-search" class = "picker-search" placeholder = "Search by name…" >
< div class = "picker-toolbar" >
< a id = "report-select-all" > Select all</ a >
< a id = "report-select-none" > Clear</ a >
< span class = "count" id = "report-sel-count" > 0 selected</ span >
</ div >
< div class = "subject-picker" id = "report-subjects" >
< div class = "empty-state" > Loading…</ div >
</ div >
< div class = "form-error" id = "report-error" ></ div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "report-close" > Close</ button >
< button class = "report-btn" id = "report-csv-btn" > ⬇ Export CSV</ button >
< button id = "report-run-btn" > Run Report</ button >
</ div >
< div class = "report-summary" id = "report-summary" ></ div >
< div class = "report-result" id = "report-result" ></ div >
2026-06-19 14:48:32 -05:00
</ div >
2026-06-19 09:10:34 -05:00
</ div >
2026-06-19 14:48:32 -05:00
<!-- Generic confirmation modal -->
< div class = "modal-overlay" id = "confirm-modal" >
< div class = "modal danger" role = "dialog" aria-modal = "true" aria-labelledby = "confirm-title" tabindex = "-1" >
< h2 id = "confirm-title" > Confirm</ h2 >
< p id = "confirm-message" ></ p >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "confirm-cancel" > Cancel</ button >
< button class = "modal-confirm" id = "confirm-ok" > Confirm</ button >
</ div >
</ div >
</ div >
2026-03-04 19:42:38 -06:00
2026-06-19 14:48:32 -05:00
<!-- Generic text input modal -->
< div class = "modal-overlay" id = "text-modal" >
< div class = "modal" role = "dialog" aria-modal = "true" aria-labelledby = "text-title" tabindex = "-1" >
< h2 id = "text-title" > Update</ h2 >
< div class = "form-grid" >
< div class = "full" >
< label for = "text-input" id = "text-label" > Value</ label >
< input type = "text" id = "text-input" >
</ div >
</ div >
< div class = "form-error" id = "text-error" ></ div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "text-cancel" > Cancel</ button >
< button id = "text-ok" > Save</ button >
</ div >
</ div >
</ div >
< div class = "toast" id = "toast" role = "status" aria-live = "polite" aria-atomic = "true" ></ div >
2026-03-04 19:53:39 -06:00
2026-06-19 14:48:32 -05:00
< script >
function isoToday ( date = new Date ()) {
const y = date . getFullYear ();
const m = String ( date . getMonth () + 1 ). padStart ( 2 , '0' );
const d = String ( date . getDate ()). padStart ( 2 , '0' );
return ` ${ y } - ${ m } - ${ d } ` ;
}
function showToast ( msg , isError = false , duration = 3500 ) {
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' );
2026-06-19 14:48:32 -05:00
clearTimeout ( t . _timer );
t . _timer = setTimeout (() => t . classList . remove ( 'show' ), duration );
}
async function apiFetch ( url , options = {}) {
const { json , expectJson = true , ... fetchOptions } = options ;
const headers = new Headers ( fetchOptions . headers || {});
if ( json !== undefined ) {
headers . set ( 'Content-Type' , 'application/json' );
fetchOptions . body = JSON . stringify ( json );
}
fetchOptions . headers = headers ;
let res ;
try {
res = await fetch ( url , fetchOptions );
} catch ( e ) {
throw new Error ( `Network error: ${ e . message || e } ` );
}
const contentType = res . headers . get ( 'content-type' ) || '' ;
let payload = null ;
if ( expectJson ) {
if ( contentType . includes ( 'application/json' )) {
payload = await res . json (). catch (() => null );
} else {
const text = await res . text (). catch (() => '' );
payload = text ? { error : text } : null ;
}
} else {
payload = await res . text ();
}
if ( ! res . ok ) {
const detail = payload && ( payload . error || payload . message || payload . response );
const err = new Error ( detail ? String ( detail ) : `Request failed ( ${ res . status } )` );
err . status = res . status ;
err . payload = payload ;
throw err ;
}
return payload ;
}
function openModal ( id , focusSelector ) {
const overlay = document . getElementById ( id );
overlay . classList . add ( 'open' );
const target = focusSelector
? overlay . querySelector ( focusSelector )
: overlay . querySelector ( '.modal' );
if ( target ) requestAnimationFrame (() => target . focus ());
}
function closeModal ( id ) {
document . getElementById ( id ). classList . remove ( 'open' );
}
let confirmResolver = null ;
function askConfirm ({ title , message , confirmText = 'Confirm' }) {
document . getElementById ( 'confirm-title' ). textContent = title ;
document . getElementById ( 'confirm-message' ). textContent = message ;
document . getElementById ( 'confirm-ok' ). textContent = confirmText ;
openModal ( 'confirm-modal' , '#confirm-cancel' );
return new Promise ( resolve => { confirmResolver = resolve ; });
}
function resolveConfirm ( value ) {
closeModal ( 'confirm-modal' );
if ( confirmResolver ) {
confirmResolver ( value );
confirmResolver = null ;
}
}
let textResolver = null ;
function askText ({ title , label , value = '' , confirmText = 'Save' }) {
document . getElementById ( 'text-title' ). textContent = title ;
document . getElementById ( 'text-label' ). textContent = label ;
document . getElementById ( 'text-input' ). value = value ;
document . getElementById ( 'text-error' ). textContent = '' ;
document . getElementById ( 'text-ok' ). textContent = confirmText ;
openModal ( 'text-modal' , '#text-input' );
return new Promise ( resolve => { textResolver = resolve ; });
}
function resolveText ( value ) {
closeModal ( 'text-modal' );
if ( textResolver ) {
textResolver ( value );
textResolver = null ;
}
}
function closeTopModal () {
const openModals = Array . from ( document . querySelectorAll ( '.modal-overlay.open' ));
const top = openModals [ openModals . length - 1 ];
if ( ! top ) return ;
if ( top . id === 'confirm-modal' ) resolveConfirm ( false );
else if ( top . id === 'text-modal' ) resolveText ( null );
else closeModal ( top . id );
}
2026-03-04 19:53:39 -06:00
2026-06-19 14:48:32 -05:00
document . getElementById ( 'confirm-cancel' ). addEventListener ( 'click' , () => resolveConfirm ( false ));
document . getElementById ( 'confirm-ok' ). addEventListener ( 'click' , () => resolveConfirm ( true ));
document . getElementById ( 'confirm-modal' ). addEventListener ( 'click' , e => {
if ( e . target . id === 'confirm-modal' ) resolveConfirm ( false );
});
document . getElementById ( 'text-cancel' ). addEventListener ( 'click' , () => resolveText ( null ));
document . getElementById ( 'text-ok' ). addEventListener ( 'click' , () => {
const input = document . getElementById ( 'text-input' );
const value = input . value . trim ();
if ( ! value ) {
document . getElementById ( 'text-error' ). textContent = 'Value is required.' ;
input . focus ();
return ;
}
resolveText ( value );
});
document . getElementById ( 'text-input' ). addEventListener ( 'keydown' , e => {
if ( e . key === 'Enter' ) document . getElementById ( 'text-ok' ). click ();
});
document . getElementById ( 'text-modal' ). addEventListener ( 'click' , e => {
if ( e . target . id === 'text-modal' ) resolveText ( null );
});
document . addEventListener ( 'keydown' , e => {
if ( e . key === 'Escape' ) closeTopModal ();
});
async function loadControllerList () {
const sel = document . getElementById ( 'controller-filter' );
const prev = sel . value ;
try {
const items = await apiFetch ( '/api/controllers' );
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 {
2026-05-28 00:39:46 -05:00
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 15:01:22 -05:00
const controllerId = document . getElementById ( 'controller-filter' ). value ;
const showFiltered = document . getElementById ( 'show-filtered' ). checked ;
2026-03-04 19:53:39 -06:00
const params = new URLSearchParams ({ date , cutoff });
2026-05-28 15:01:22 -05:00
if ( controllerId ) params . set ( 'controller_id' , controllerId );
if ( showFiltered ) params . set ( 'include_filtered' , '1' );
2026-03-04 19:53:39 -06:00
2026-06-19 14:48:32 -05:00
let data ;
try {
data = await apiFetch ( '/api/first-badge-status?' + params . toString ());
} catch ( e ) {
showToast ( `Could not load data: ${ e . message } ` , true );
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 = '' ;
2026-06-19 14:48:32 -05:00
if ( ! data . length ) {
tbody . innerHTML = '<tr class="empty-table-row"><td colspan="8" class="empty-state">No badge-in records for this day.</td></tr>' ;
[ 'on-time-count' , 'late-count' , 'total-count' ]. forEach ( id => {
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' );
2026-05-28 15:01:22 -05:00
if ( row . filtered ) tr . classList . add ( 'row-filtered' );
2026-03-04 19:53:39 -06:00
2026-06-19 14:48:32 -05:00
const numTd = document . createElement ( 'td' );
numTd . className = 'muted-cell' ;
numTd . dataset . label = '#' ;
numTd . textContent = i + 1 ;
tr . appendChild ( numTd );
const nameTd = document . createElement ( 'td' );
nameTd . className = 'name-cell' ;
nameTd . dataset . label = 'Name' ;
nameTd . textContent = row . name ;
tr . appendChild ( nameTd );
const sourceTd = document . createElement ( 'td' );
sourceTd . dataset . label = 'Source' ;
const sourceWrap = document . createElement ( 'span' );
2026-05-28 15:56:36 -05:00
sourceWrap . className = 'source-list' ;
const sources = ( row . sources && row . sources . length ) ? row . sources : [ row . source || '—' ];
sources . forEach ( s => {
const chip = document . createElement ( 'span' );
chip . className = 'source-chip' ;
chip . textContent = s ;
sourceWrap . appendChild ( chip );
});
sourceTd . appendChild ( sourceWrap );
if ( row . merged ) {
const pill = document . createElement ( 'span' );
pill . className = 'merged-pill' ;
pill . textContent = 'MERGED' ;
sourceTd . appendChild ( pill );
}
2026-05-28 00:39:46 -05:00
tr . appendChild ( sourceTd );
2026-06-19 14:48:32 -05:00
const firstTd = document . createElement ( 'td' );
firstTd . className = 'time-first' ;
firstTd . dataset . label = 'First Badge In' ;
firstTd . textContent = row . first_ts || '—' ;
tr . appendChild ( firstTd );
2026-03-04 19:53:39 -06:00
2026-06-19 14:48:32 -05:00
const latestTd = document . createElement ( 'td' );
latestTd . dataset . label = 'Latest Badge In' ;
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-06-19 14:48:32 -05:00
const idTd = document . createElement ( 'td' );
idTd . className = 'muted-cell' ;
idTd . dataset . label = 'Actor ID' ;
idTd . textContent = row . actor_id ? row . actor_id . slice ( 0 , 8 ) + '...' : '—' ;
tr . appendChild ( idTd );
2026-03-04 19:42:38 -06:00
2026-06-19 14:48:32 -05:00
const statusTd = document . createElement ( 'td' );
statusTd . className = 'align-center' ;
statusTd . dataset . label = 'Status' ;
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 );
2026-06-19 14:48:32 -05:00
const actionTd = document . createElement ( 'td' );
actionTd . className = 'align-center' ;
actionTd . dataset . label = 'Actions' ;
2026-05-28 15:56:36 -05:00
const actionGroup = document . createElement ( 'div' );
actionGroup . className = 'row-action-group' ;
const hideBtn = document . createElement ( 'button' );
hideBtn . className = 'row-action-btn hide-btn' + ( row . filtered ? ' unhide' : '' );
hideBtn . textContent = row . filtered ? 'Unhide' : 'Hide' ;
hideBtn . dataset . personId = row . person_id || '' ;
hideBtn . dataset . controllerId = row . controller_id || '' ;
hideBtn . dataset . actorId = row . actor_id || '' ;
hideBtn . dataset . filtered = row . filtered ? '1' : '0' ;
hideBtn . dataset . name = row . name || '' ;
actionGroup . appendChild ( hideBtn );
const mergeBtn = document . createElement ( 'button' );
mergeBtn . className = 'row-action-btn merge merge-btn' ;
mergeBtn . textContent = row . merged ? 'Manage' : 'Merge' ;
mergeBtn . dataset . personId = row . person_id || '' ;
mergeBtn . dataset . controllerId = row . controller_id || '' ;
mergeBtn . dataset . actorId = row . actor_id || '' ;
mergeBtn . dataset . name = row . name || '' ;
actionGroup . appendChild ( mergeBtn );
actionTd . appendChild ( actionGroup );
2026-05-28 15:01:22 -05:00
tr . appendChild ( actionTd );
2026-03-04 19:53:39 -06:00
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
}
2026-06-19 14:48:32 -05:00
async function setActorFiltered ( controllerId , actorId , filtered ) {
if ( ! controllerId || ! actorId ) {
showToast ( 'Missing controller or actor id' , true );
return false ;
}
try {
await apiFetch ( `/api/users/ ${ controllerId } / ${ actorId } ` , {
method : 'PATCH' ,
json : { filtered },
});
} catch ( e ) {
showToast ( `Failed: ${ e . message } ` , true );
return false ;
}
return true ;
2026-05-28 15:01:22 -05:00
}
2026-06-19 14:48:32 -05:00
async function setPersonFiltered ( personId , filtered ) {
try {
await apiFetch ( `/api/persons/ ${ personId } ` , {
method : 'PATCH' ,
json : { filtered },
});
} catch ( e ) {
showToast ( `Failed: ${ e . message } ` , true );
return false ;
}
return true ;
2026-05-28 15:56:36 -05:00
}
2026-05-28 15:01:22 -05:00
document . getElementById ( 'table-body' ). addEventListener ( 'click' , async e => {
2026-05-28 15:56:36 -05:00
const hideBtn = e . target . closest ( '.hide-btn' );
const mergeBtn = e . target . closest ( '.merge-btn' );
if ( hideBtn ) {
const wasFiltered = hideBtn . dataset . filtered === '1' ;
hideBtn . disabled = true ;
let ok ;
if ( hideBtn . dataset . personId ) {
ok = await setPersonFiltered ( hideBtn . dataset . personId , ! wasFiltered );
} else {
ok = await setActorFiltered ( hideBtn . dataset . controllerId , hideBtn . dataset . actorId , ! wasFiltered );
}
hideBtn . disabled = false ;
if ( ! ok ) return ;
showToast ( wasFiltered ? `Unhid ${ hideBtn . dataset . name } ` : `Hid ${ hideBtn . dataset . name } ` );
await loadData ();
return ;
}
2026-06-19 14:48:32 -05:00
if ( mergeBtn ) {
if ( mergeBtn . dataset . personId ) {
// Already merged — open People modal scrolled to this person
openModal ( 'people-modal' , '#people-close' );
await renderSuggestions ();
await renderPeople ( mergeBtn . dataset . personId );
} else {
2026-05-28 15:56:36 -05:00
await openMergeModal ({
controllerId : mergeBtn . dataset . controllerId ,
actorId : mergeBtn . dataset . actorId ,
name : mergeBtn . dataset . name ,
});
}
}
2026-05-28 15:01:22 -05:00
});
2026-03-04 19:53:39 -06:00
async function syncUsers () {
const btn = document . getElementById ( 'sync-btn' );
2026-06-19 14:48:32 -05:00
const orig = btn . innerHTML ;
btn . textContent = 'Syncing…' ; btn . disabled = true ;
try {
await apiFetch ( '/api/sync-users' );
showToast ( 'User list synced from all controllers' );
await loadData ();
} catch ( e ) {
showToast ( `Sync failed: ${ e . message } ` , true );
} finally {
btn . innerHTML = orig ; btn . disabled = false ;
}
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-06-19 14:48:32 -05:00
document . getElementById ( 'reset-btn' ). addEventListener ( 'click' , () => {
const date = document . getElementById ( 'date' ). value || isoToday ();
document . getElementById ( 'modal-date-label' ). textContent = date ;
openModal ( 'reset-modal' , '#modal-cancel' );
});
document . getElementById ( 'modal-cancel' ). addEventListener ( 'click' , () => {
closeModal ( 'reset-modal' );
});
document . getElementById ( 'modal-confirm' ). addEventListener ( 'click' , async () => {
closeModal ( 'reset-modal' );
const date = document . getElementById ( 'date' ). value || isoToday ();
const controllerId = document . getElementById ( 'controller-filter' ). value ;
const params = new URLSearchParams ({ date });
if ( controllerId ) params . set ( 'controller_id' , controllerId );
try {
const json = await apiFetch ( '/api/reset-day?' + params . toString (), { method : 'DELETE' });
showToast ( `Reset complete — ${ json . deleted } record(s) deleted for ${ date } ` );
await loadData ();
} catch ( e ) {
showToast ( `Reset failed: ${ e . message } ` , true );
}
});
document . getElementById ( 'reset-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'reset-modal' ))
closeModal ( 'reset-modal' );
});
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>' ;
2026-06-19 14:48:32 -05:00
let items = [];
try {
items = await apiFetch ( '/api/controllers' );
} catch {
list . innerHTML = '<div class="empty-state">Failed to load controllers.</div>' ;
return ;
2026-05-28 00:39:46 -05:00
}
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 );
});
}
2026-06-19 14:48:32 -05:00
document . getElementById ( 'open-controllers-btn' ). addEventListener ( 'click' , async () => {
document . getElementById ( 'base-url-hint' ). textContent = window . location . origin ;
document . getElementById ( 'add-error' ). textContent = '' ;
openModal ( 'controllers-modal' , '#controllers-close' );
await renderControllers ();
});
2026-05-28 00:39:46 -05:00
2026-06-19 14:48:32 -05:00
document . getElementById ( 'controllers-close' ). addEventListener ( 'click' , () => {
closeModal ( 'controllers-modal' );
});
2026-05-28 00:39:46 -05:00
2026-06-19 14:48:32 -05:00
document . getElementById ( 'controllers-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'controllers-modal' ))
closeModal ( 'controllers-modal' );
});
2026-05-28 00:39:46 -05:00
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 ;
2026-06-19 14:48:32 -05:00
try {
if ( act === 'test' ) {
const j = await apiFetch ( `/api/controllers/ ${ id } /test` , { method : 'POST' });
showToast ( j . ok
? `Connected — ${ j . user_count } users on controller`
: `Test failed: ${ j . message } ` ,
! j . ok );
} else if ( act === 'sync' ) {
const j = await apiFetch ( `/api/controllers/ ${ id } /sync` , { method : 'POST' });
showToast ( `Synced ${ j . synced } users` );
await renderControllers ();
await loadData ();
} else if ( act === 'toggle' ) {
const isEnabling = btn . textContent . trim (). toLowerCase () === 'enable' ;
await apiFetch ( `/api/controllers/ ${ id } ` , {
method : 'PATCH' ,
json : { enabled : isEnabling },
});
await renderControllers ();
await loadControllerList ();
} else if ( act === 'delete' ) {
const name = btn . dataset . name ;
const ok = await askConfirm ({
title : 'Remove Controller' ,
message : `Remove controller " ${ name } "? This deletes its webhook from the controller and removes all its badge events from the dashboard. This cannot be undone.` ,
confirmText : 'Remove' ,
});
if ( ! ok )
return ;
await apiFetch ( `/api/controllers/ ${ id } ` , { method : 'DELETE' });
showToast ( `Removed ${ name } ` );
await renderControllers ();
2026-05-28 00:39:46 -05:00
await loadControllerList ();
await loadData ();
}
2026-06-19 14:48:32 -05:00
} catch ( err ) {
showToast ( `Action failed: ${ err . message || err } ` , true );
} finally {
2026-05-28 00:39:46 -05:00
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 ;
}
2026-06-19 14:48:32 -05:00
btn . disabled = true ;
btn . textContent = 'Adding…' ;
try {
const j = await apiFetch ( '/api/controllers' , {
method : 'POST' ,
json : body ,
});
document . getElementById ( 'add-name' ). value = '' ;
document . getElementById ( 'add-host' ). value = '' ;
2026-05-28 00:39:46 -05:00
document . getElementById ( 'add-token' ). value = '' ;
showToast ( `Added ${ j . name } ` );
2026-06-19 14:48:32 -05:00
await renderControllers ();
await loadControllerList ();
await loadData ();
} catch ( e ) {
err . textContent = e . message || 'Failed to add controller.' ;
if ( e . payload && e . payload . response )
err . textContent += ` — ${ String ( e . payload . response ). slice ( 0 , 200 ) } ` ;
} finally {
2026-05-28 00:39:46 -05:00
btn . disabled = false ;
btn . textContent = 'Add Controller' ;
}
});
2026-05-28 15:01:22 -05:00
// -------- Filtered tenants modal --------
async function renderFiltered () {
const list = document . getElementById ( 'filtered-list' );
list . innerHTML = '<div class="empty-state">Loading…</div>' ;
2026-06-19 14:48:32 -05:00
let items = [];
try {
items = await apiFetch ( '/api/users?filtered=1' );
} catch {
list . innerHTML = '<div class="empty-state">Failed to load filtered tenants.</div>' ;
return ;
2026-05-28 15:01:22 -05:00
}
if ( ! items . length ) {
list . innerHTML = '<div class="empty-state">No filtered tenants. Use the <span>Hide</span> button on any row to filter someone out.</div>' ;
return ;
}
list . innerHTML = '' ;
items . forEach ( u => {
const row = document . createElement ( 'div' );
row . className = 'ctrl-row' ;
row . innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name"> ${ escapeHtml ( u . full_name ) } </div>
<div class="ctrl-sub"> ${ escapeHtml ( u . controller_name ) } · ${ escapeHtml (( u . actor_id || '' ). slice ( 0 , 8 )) } …</div>
</div>
<div class="ctrl-actions">
<button class="small-btn sync-btn" data-act="unhide"
data-controller-id=" ${ escapeHtml ( u . controller_id ) } "
data-actor-id=" ${ escapeHtml ( u . actor_id ) } "
data-name=" ${ escapeHtml ( u . full_name ) } ">Unhide</button>
</div>
` ;
list . appendChild ( row );
});
}
2026-06-19 14:48:32 -05:00
document . getElementById ( 'open-filtered-btn' ). addEventListener ( 'click' , async () => {
openModal ( 'filtered-modal' , '#filtered-close' );
await renderFiltered ();
});
document . getElementById ( 'filtered-close' ). addEventListener ( 'click' , () => {
closeModal ( 'filtered-modal' );
});
document . getElementById ( 'filtered-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'filtered-modal' ))
closeModal ( 'filtered-modal' );
});
2026-05-28 15:01:22 -05:00
document . getElementById ( 'filtered-list' ). addEventListener ( 'click' , async e => {
const btn = e . target . closest ( 'button[data-act="unhide"]' );
if ( ! btn ) return ;
btn . disabled = true ;
const ok = await setActorFiltered ( btn . dataset . controllerId , btn . dataset . actorId , false );
btn . disabled = false ;
if ( ! ok ) return ;
showToast ( `Unhid ${ btn . dataset . name } ` );
await renderFiltered ();
await loadData ();
});
2026-05-28 15:56:36 -05:00
// -------- Merge picker modal --------
let mergeSource = null ; // {controllerId, actorId, name}
let mergeCandidates = []; // cached user list
let mergeSelected = null ; // {controllerId, actorId, name}
function fuzzyScore ( needle , hay ) {
needle = ( needle || '' ). toLowerCase (). trim ();
hay = ( hay || '' ). toLowerCase ();
if ( ! needle ) return 1 ;
if ( hay . includes ( needle )) return 2 ;
// simple subsequence check
let i = 0 ;
for ( const ch of hay ) { if ( ch === needle [ i ]) i ++ ; if ( i === needle . length ) return 1 ; }
return 0 ;
}
function renderMergeCandidates () {
const search = document . getElementById ( 'merge-search' ). value ;
const list = document . getElementById ( 'merge-candidates' );
const items = mergeCandidates
. filter ( u => ! ( u . controller_id === mergeSource . controllerId && u . actor_id === mergeSource . actorId ))
. map ( u => ({ u , score : search ? fuzzyScore ( search , u . full_name ) : fuzzyScore ( mergeSource . name , u . full_name ) }))
. filter ( x => x . score > 0 )
. sort (( a , b ) => b . score - a . score || a . u . full_name . localeCompare ( b . u . full_name ));
if ( ! items . length ) {
list . innerHTML = '<div class="empty-state">No matching candidates. Try clearing the search.</div>' ;
return ;
}
list . innerHTML = '' ;
items . forEach (({ u }) => {
const row = document . createElement ( 'div' );
row . className = 'picker-row' ;
const selected = mergeSelected
&& mergeSelected . controllerId === u . controller_id
&& mergeSelected . actorId === u . actor_id ;
row . innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name"> ${ escapeHtml ( u . full_name ) } </div>
<div class="ctrl-sub"> ${ escapeHtml ( u . controller_name ) } · ${ escapeHtml (( u . actor_id || '' ). slice ( 0 , 8 )) } …</div>
</div>
<button class="small-btn ${ selected ? 'sync-btn' : '' } "
data-controller-id=" ${ escapeHtml ( u . controller_id ) } "
data-actor-id=" ${ escapeHtml ( u . actor_id ) } "
data-name=" ${ escapeHtml ( u . full_name ) } "> ${ selected ? 'Selected' : 'Select' } </button>
` ;
list . appendChild ( row );
});
}
async function openMergeModal ( source ) {
mergeSource = source ;
mergeSelected = null ;
2026-06-19 14:48:32 -05:00
document . getElementById ( 'merge-source-name' ). textContent = source . name ;
document . getElementById ( 'merge-display-name' ). value = source . name ;
document . getElementById ( 'merge-search' ). value = '' ;
document . getElementById ( 'merge-error' ). textContent = '' ;
openModal ( 'merge-modal' , '#merge-search' );
try {
mergeCandidates = await apiFetch ( '/api/users' );
} catch {
mergeCandidates = [];
}
// Only show actors not already in a person, by querying the persons API
let merged = [];
try {
const persons = await apiFetch ( '/api/persons' );
merged = persons . flatMap ( p => p . members . map ( m => ` ${ m . controller_id } | ${ m . actor_id } ` ));
} catch {}
2026-05-28 15:56:36 -05:00
const mergedSet = new Set ( merged );
mergeCandidates = mergeCandidates . filter ( u => ! mergedSet . has ( ` ${ u . controller_id } | ${ u . actor_id } ` ));
renderMergeCandidates ();
}
document . getElementById ( 'merge-search' ). addEventListener ( 'input' , renderMergeCandidates );
document . getElementById ( 'merge-candidates' ). addEventListener ( 'click' , e => {
const btn = e . target . closest ( 'button[data-controller-id]' );
if ( ! btn ) return ;
mergeSelected = {
controllerId : btn . dataset . controllerId ,
actorId : btn . dataset . actorId ,
name : btn . dataset . name ,
};
renderMergeCandidates ();
2026-06-19 14:48:32 -05:00
});
document . getElementById ( 'merge-cancel' ). addEventListener ( 'click' , () => {
closeModal ( 'merge-modal' );
});
document . getElementById ( 'merge-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'merge-modal' ))
closeModal ( 'merge-modal' );
});
2026-05-28 15:56:36 -05:00
document . getElementById ( 'merge-confirm' ). addEventListener ( 'click' , async () => {
const err = document . getElementById ( 'merge-error' );
err . textContent = '' ;
if ( ! mergeSelected ) {
err . textContent = 'Pick a candidate to merge with.' ;
return ;
}
const displayName = document . getElementById ( 'merge-display-name' ). value . trim ();
if ( ! displayName ) {
err . textContent = 'Display name is required.' ;
return ;
}
2026-06-19 14:48:32 -05:00
const btn = document . getElementById ( 'merge-confirm' );
btn . disabled = true ; btn . textContent = 'Merging…' ;
try {
await apiFetch ( '/api/persons' , {
method : 'POST' ,
json : {
display_name : displayName ,
members : [
{ controller_id : mergeSource . controllerId , actor_id : mergeSource . actorId },
{ controller_id : mergeSelected . controllerId , actor_id : mergeSelected . actorId },
],
},
});
closeModal ( 'merge-modal' );
showToast ( `Merged into " ${ displayName } "` );
await loadData ();
} catch ( e ) {
err . textContent = e . message || 'Merge failed' ;
} finally {
btn . disabled = false ; btn . textContent = 'Merge' ;
2026-05-28 15:56:36 -05:00
}
});
// -------- People modal --------
async function renderSuggestions () {
const list = document . getElementById ( 'suggestions-list' );
list . innerHTML = '<div class="empty-state">Loading…</div>' ;
2026-06-19 14:48:32 -05:00
let items = [];
try {
items = await apiFetch ( '/api/persons/suggestions' );
} catch {
list . innerHTML = '<div class="empty-state">Failed to load suggestions.</div>' ;
return ;
2026-05-28 15:56:36 -05:00
}
if ( ! items . length ) {
list . innerHTML = '<div class="empty-state">No suggestions. Names already match a person or are unique per controller.</div>' ;
return ;
}
list . innerHTML = '' ;
items . forEach ( s => {
const row = document . createElement ( 'div' );
row . className = 'suggestion-row' ;
const controllerSummary = s . members . map ( m => escapeHtml ( m . controller_name )). join ( ', ' );
row . innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name"> ${ escapeHtml ( s . display_name ) } </div>
<div class="ctrl-sub"> ${ controllerSummary } · ${ s . members . length } identities</div>
</div>
<button class="small-btn sync-btn" data-act="merge"
data-payload=' ${ escapeHtml ( JSON . stringify ( s )) } '>Merge</button>
` ;
list . appendChild ( row );
});
}
async function renderPeople ( scrollToId ) {
const list = document . getElementById ( 'people-list' );
list . innerHTML = '<div class="empty-state">Loading…</div>' ;
2026-06-19 14:48:32 -05:00
let items = [];
try {
items = await apiFetch ( '/api/persons' );
} catch {
list . innerHTML = '<div class="empty-state">Failed to load people.</div>' ;
return ;
2026-05-28 15:56:36 -05:00
}
if ( ! items . length ) {
list . innerHTML = '<div class="empty-state">No merged people yet. Use the <span>Merge</span> button on a row.</div>' ;
return ;
}
list . innerHTML = '' ;
items . forEach ( p => {
const row = document . createElement ( 'div' );
row . className = 'ctrl-row' ;
row . dataset . personId = p . id ;
const memberLines = p . members . map ( m =>
`<div class="ctrl-sub"> ${ escapeHtml ( m . controller_name ) } · ${ escapeHtml ( m . full_name ) }
<button class="row-action-btn" data-act="unmerge-member"
data-person-id=" ${ escapeHtml ( p . id ) } "
data-controller-id=" ${ escapeHtml ( m . controller_id ) } "
data-actor-id=" ${ escapeHtml ( m . actor_id ) } "
data-name=" ${ escapeHtml ( m . full_name ) } ">Split off</button>
</div>`
). join ( '' );
row . innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name">
${ escapeHtml ( p . display_name ) }
${ p . filtered ? '<span class="disabled-tag">(filtered)</span>' : '' }
</div>
${ memberLines }
</div>
<div class="ctrl-actions">
<button class="small-btn" data-act="rename" data-person-id=" ${ escapeHtml ( p . id ) } "
data-name=" ${ escapeHtml ( p . display_name ) } ">Rename</button>
<button class="small-btn reset-btn" data-act="dissolve" data-person-id=" ${ escapeHtml ( p . id ) } "
data-name=" ${ escapeHtml ( p . display_name ) } ">Dissolve</button>
</div>
` ;
list . appendChild ( row );
});
if ( scrollToId ) {
const target = list . querySelector ( `.ctrl-row[data-person-id=" ${ scrollToId } "]` );
if ( target ) target . scrollIntoView ({ block : 'nearest' });
}
}
2026-06-19 14:48:32 -05:00
document . getElementById ( 'open-people-btn' ). addEventListener ( 'click' , async () => {
openModal ( 'people-modal' , '#people-close' );
await renderSuggestions ();
await renderPeople ();
});
document . getElementById ( 'people-close' ). addEventListener ( 'click' , () => {
closeModal ( 'people-modal' );
});
document . getElementById ( 'people-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'people-modal' ))
closeModal ( 'people-modal' );
});
2026-05-28 15:56:36 -05:00
document . getElementById ( 'suggestions-list' ). addEventListener ( 'click' , async e => {
const btn = e . target . closest ( 'button[data-act="merge"]' );
if ( ! btn ) return ;
btn . disabled = true ;
let payload ;
try { payload = JSON . parse ( btn . dataset . payload ); }
2026-06-19 14:48:32 -05:00
catch { btn . disabled = false ; return ; }
try {
await apiFetch ( '/api/persons' , {
method : 'POST' ,
json : {
display_name : payload . display_name ,
members : payload . members . map ( m => ({
controller_id : m . controller_id , actor_id : m . actor_id ,
})),
},
});
showToast ( `Merged " ${ payload . display_name } "` );
await renderSuggestions ();
await renderPeople ();
await loadData ();
} catch ( e ) {
showToast ( `Merge failed: ${ e . message } ` , true );
} finally {
btn . disabled = false ;
2026-05-28 15:56:36 -05:00
}
});
document . getElementById ( 'people-list' ). addEventListener ( 'click' , async e => {
const btn = e . target . closest ( 'button[data-act]' );
if ( ! btn ) return ;
const act = btn . dataset . act ;
2026-06-19 14:48:32 -05:00
btn . disabled = true ;
try {
if ( act === 'rename' ) {
const name = await askText ({
title : 'Rename Person' ,
label : 'Display name' ,
value : btn . dataset . name ,
confirmText : 'Rename' ,
});
if ( ! name || ! name . trim ()) return ;
await apiFetch ( `/api/persons/ ${ btn . dataset . personId } ` , {
method : 'PATCH' ,
json : { display_name : name . trim () },
});
showToast ( `Renamed to " ${ name . trim () } "` );
await renderPeople ();
await loadData ();
} else if ( act === 'dissolve' ) {
const ok = await askConfirm ({
title : 'Dissolve Person' ,
message : `Dissolve merged person " ${ btn . dataset . name } "? All members will become standalone actors again. Past badge events are unaffected.` ,
confirmText : 'Dissolve' ,
});
if ( ! ok )
return ;
await apiFetch ( `/api/persons/ ${ btn . dataset . personId } ` , { method : 'DELETE' });
showToast ( `Dissolved " ${ btn . dataset . name } "` );
await renderSuggestions ();
await renderPeople ();
await loadData ();
} else if ( act === 'unmerge-member' ) {
const ok = await askConfirm ({
title : 'Split Identity' ,
message : `Split " ${ btn . dataset . name } " off from this person?` ,
confirmText : 'Split Off' ,
});
if ( ! ok )
return ;
const url = `/api/persons/ ${ btn . dataset . personId } /members/ ${ btn . dataset . controllerId } / ${ btn . dataset . actorId } ` ;
await apiFetch ( url , { method : 'DELETE' });
showToast ( `Split off ${ btn . dataset . name } ` );
await renderSuggestions ();
await renderPeople ();
await loadData ();
}
} catch ( e ) {
showToast ( `Action failed: ${ e . message } ` , true );
} finally {
btn . disabled = false ;
2026-05-28 15:56:36 -05:00
}
});
2026-06-19 09:10:34 -05:00
// -------- Report --------
let reportSubjects = [];
const reportSelected = new Set ();
function reportParams ( extra = {}) {
const p = new URLSearchParams ({
start : document . getElementById ( 'report-start' ). value || isoToday (),
end : document . getElementById ( 'report-end' ). value || isoToday (),
cutoff : document . getElementById ( 'report-cutoff' ). value || '09:00' ,
});
const cid = document . getElementById ( 'controller-filter' ). value ;
if ( cid ) p . set ( 'controller_id' , cid );
if ( document . getElementById ( 'show-filtered' ). checked ) p . set ( 'include_filtered' , '1' );
if ( reportSelected . size ) p . set ( 'subjects' , [... reportSelected ]. join ( ',' ));
Object . entries ( extra ). forEach (([ k , v ]) => p . set ( k , v ));
return p ;
}
function updateReportSelCount () {
document . getElementById ( 'report-sel-count' ). textContent =
` ${ reportSelected . size } selected` ;
}
function renderReportSubjects () {
const term = document . getElementById ( 'report-search' ). value . trim (). toLowerCase ();
const box = document . getElementById ( 'report-subjects' );
const list = reportSubjects . filter ( s => s . name . toLowerCase (). includes ( term ));
if ( ! list . length ) {
box . innerHTML = '<div class="empty-state">No matching users.</div>' ;
return ;
}
box . innerHTML = list . map ( s => `
<label class="subject-row">
<input type="checkbox" data-key=" ${ escapeHtml ( s . key ) } " ${ reportSelected . has ( s . key ) ? 'checked' : '' } >
<span> ${ escapeHtml ( s . name ) }${ s . merged ? ' <span class="merged-pill">MERGED</span>' : '' } </span>
<span class="s-sub"> ${ escapeHtml ( s . controller_name || '' ) } </span>
</label>
` ). join ( '' );
updateReportSelCount ();
}
async function loadReportSubjects () {
const box = document . getElementById ( 'report-subjects' );
box . innerHTML = '<div class="empty-state">Loading…</div>' ;
const p = new URLSearchParams ();
const cid = document . getElementById ( 'controller-filter' ). value ;
2026-06-19 14:48:32 -05:00
if ( cid ) p . set ( 'controller_id' , cid );
if ( document . getElementById ( 'show-filtered' ). checked ) p . set ( 'include_filtered' , '1' );
try {
reportSubjects = await apiFetch ( '/api/report/subjects?' + p . toString ());
} catch ( e ) {
reportSubjects = [];
showToast ( `Could not load users: ${ e . message } ` , true );
}
renderReportSubjects ();
2026-06-19 09:10:34 -05:00
}
async function runReport () {
2026-06-19 14:48:32 -05:00
const errEl = document . getElementById ( 'report-error' );
errEl . textContent = '' ;
try {
const data = await apiFetch ( '/api/report?' + reportParams (). toString ());
renderPivot ( data );
} catch ( e ) {
errEl . textContent = e . message || 'Report failed' ;
return ;
}
2026-06-19 09:10:34 -05:00
}
function renderPivot ( data ) {
const summary = document . getElementById ( 'report-summary' );
const result = document . getElementById ( 'report-result' );
if ( ! data . subjects . length ) {
summary . textContent = '' ;
result . innerHTML = '<div class="empty-state">No users selected (or none match).</div>' ;
return ;
}
// index rows by subject_key + date
const byKey = {};
data . rows . forEach ( r => {
( byKey [ r . subject_key ] = byKey [ r . subject_key ] || {})[ r . date ] = r ;
});
let late = 0 , absent = 0 , present = 0 ;
data . rows . forEach ( r => {
if ( r . status === 'LATE' ) late ++ ;
else if ( r . status === 'ABSENT' ) absent ++ ;
else present ++ ;
});
summary . textContent =
` ${ data . subjects . length } user(s) · ${ data . dates . length } day(s) · ` +
` ${ present } on time · ${ late } late · ${ absent } absent` ;
const isWeekend = d => { const g = new Date ( d + 'T00:00:00' ). getDay (); return g === 0 || g === 6 ; };
const head = '<th class="name-col">Name</th>' + data . dates . map ( d => {
const label = d . slice ( 5 ); // MM-DD
return `<th class=" ${ isWeekend ( d ) ? 'weekend' : '' } "> ${ label } </th>` ;
}). join ( '' );
const body = data . subjects . map ( s => {
const cells = data . dates . map ( d => {
const r = byKey [ s . key ] && byKey [ s . key ][ d ];
const wknd = isWeekend ( d ) ? ' weekend' : '' ;
if ( ! r || r . status === 'ABSENT' )
return `<td class="cell-absent ${ wknd } ">—</td>` ;
const cls = r . status === 'LATE' ? 'cell-late' : 'cell-on' ;
return `<td class=" ${ cls }${ wknd } "> ${ escapeHtml ( r . first_ts ) } </td>` ;
}). join ( '' );
return `<tr><td class="name-col"> ${ escapeHtml ( s . name ) } </td> ${ cells } </tr>` ;
}). join ( '' );
result . innerHTML =
`<table class="pivot"><thead><tr> ${ head } </tr></thead><tbody> ${ body } </tbody></table>` ;
}
function openReportModal () {
2026-06-19 14:48:32 -05:00
const today = isoToday ();
const start = new Date (); start . setDate ( start . getDate () - 6 );
document . getElementById ( 'report-start' ). value = isoToday ( start );
2026-06-19 09:10:34 -05:00
document . getElementById ( 'report-end' ). value = today ;
document . getElementById ( 'report-cutoff' ). value =
document . getElementById ( 'cutoff' ). value || '09:00' ;
document . getElementById ( 'report-search' ). value = '' ;
2026-06-19 14:48:32 -05:00
document . getElementById ( 'report-error' ). textContent = '' ;
document . getElementById ( 'report-summary' ). textContent = '' ;
document . getElementById ( 'report-result' ). innerHTML = '' ;
reportSelected . clear ();
updateReportSelCount ();
openModal ( 'report-modal' , '#report-search' );
loadReportSubjects ();
}
2026-06-19 09:10:34 -05:00
2026-06-19 14:48:32 -05:00
document . getElementById ( 'open-report-btn' ). addEventListener ( 'click' , openReportModal );
document . getElementById ( 'report-close' ). addEventListener ( 'click' , () => {
closeModal ( 'report-modal' );
});
document . getElementById ( 'report-modal' ). addEventListener ( 'click' , e => {
if ( e . target . id === 'report-modal' ) closeModal ( 'report-modal' );
});
2026-06-19 09:10:34 -05:00
document . getElementById ( 'report-search' ). addEventListener ( 'input' , renderReportSubjects );
document . getElementById ( 'report-subjects' ). addEventListener ( 'change' , e => {
const cb = e . target . closest ( 'input[type=checkbox]' );
if ( ! cb ) return ;
if ( cb . checked ) reportSelected . add ( cb . dataset . key );
else reportSelected . delete ( cb . dataset . key );
updateReportSelCount ();
});
document . getElementById ( 'report-select-all' ). addEventListener ( 'click' , () => {
const term = document . getElementById ( 'report-search' ). value . trim (). toLowerCase ();
reportSubjects . filter ( s => s . name . toLowerCase (). includes ( term ))
. forEach ( s => reportSelected . add ( s . key ));
renderReportSubjects ();
});
document . getElementById ( 'report-select-none' ). addEventListener ( 'click' , () => {
reportSelected . clear ();
renderReportSubjects ();
});
document . getElementById ( 'report-run-btn' ). addEventListener ( 'click' , runReport );
document . getElementById ( 'report-csv-btn' ). addEventListener ( 'click' , () => {
window . location = '/api/report?' + reportParams ({ format : 'csv' }). toString ();
});
2026-05-28 00:39:46 -05:00
// -------- 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-05-28 15:01:22 -05:00
document . getElementById ( 'show-filtered' ). 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 >